Skip to content

Commit 489c8b0

Browse files
rsbhclaude
andauthored
feat: add content/preset support in chronicle.yaml with Zod validation (#27)
Add `content` and `preset` fields to chronicle.yaml config. CLI flags override YAML values (flag > yaml > default). Replace manual TypeScript interfaces with Zod schemas for runtime config validation. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ec1599a commit 489c8b0

File tree

9 files changed

+151
-104
lines changed

9 files changed

+151
-104
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/basic/chronicle.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
title: My Documentation
22
description: Documentation powered by Chronicle
33
url: https://docs.example.com
4-
contentDir: .
4+
content: .
55
theme:
66
name: default
77
search:

packages/chronicle/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,16 @@
5757
"react-dom": "^19.0.0",
5858
"react-router": "^7.13.1",
5959
"remark-directive": "^4.0.0",
60-
"remark-parse": "^11.0.0",
6160
"remark-frontmatter": "^5.0.0",
6261
"remark-gfm": "^4.0.1",
6362
"remark-mdx-frontmatter": "^5.2.0",
63+
"remark-parse": "^11.0.0",
6464
"satori": "^0.25.0",
6565
"slugify": "^1.6.6",
6666
"unified": "^11.0.5",
6767
"unist-util-visit": "^5.1.0",
6868
"vite": "^8.0.0",
69-
"yaml": "^2.8.2"
69+
"yaml": "^2.8.2",
70+
"zod": "^4.3.6"
7071
}
7172
}

packages/chronicle/src/cli/commands/build.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import chalk from 'chalk';
22
import { Command } from 'commander';
3-
import { resolveConfigPath, resolveContentDir } from '@/cli/utils/config';
3+
import { loadCLIConfig } from '@/cli/utils/config';
44
import { PACKAGE_ROOT } from '@/cli/utils/resolve';
55
import { linkContent } from '@/cli/utils/scaffold';
66

@@ -13,8 +13,10 @@ export const buildCommand = new Command('build')
1313
'Deploy preset (vercel, cloudflare, node-server)'
1414
)
1515
.action(async options => {
16-
const contentDir = resolveContentDir(options.content);
17-
const configPath = resolveConfigPath(options.config);
16+
const { contentDir, configPath, preset } = await loadCLIConfig(options.config, {
17+
content: options.content,
18+
preset: options.preset,
19+
});
1820
await linkContent(contentDir);
1921

2022
console.log(chalk.cyan('Building for production...'));
@@ -27,7 +29,7 @@ export const buildCommand = new Command('build')
2729
projectRoot: process.cwd(),
2830
contentDir,
2931
configPath,
30-
preset: options.preset
32+
preset
3133
});
3234

3335
const builder = await createBuilder({ ...config, builder: {} });

packages/chronicle/src/cli/commands/dev.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import fs from 'node:fs';
2-
import path from 'node:path';
31
import chalk from 'chalk';
42
import { Command } from 'commander';
5-
import { resolveConfigPath, resolveContentDir } from '@/cli/utils/config';
3+
import { loadCLIConfig } from '@/cli/utils/config';
64
import { PACKAGE_ROOT } from '@/cli/utils/resolve';
75
import { linkContent } from '@/cli/utils/scaffold';
86

@@ -12,8 +10,7 @@ export const devCommand = new Command('dev')
1210
.option('--content <path>', 'Content directory')
1311
.option('--config <path>', 'Path to chronicle.yaml')
1412
.action(async options => {
15-
const contentDir = resolveContentDir(options.content);
16-
const configPath = resolveConfigPath(options.config);
13+
const { contentDir, configPath } = await loadCLIConfig(options.config, { content: options.content });
1714
const port = parseInt(options.port, 10);
1815

1916
await linkContent(contentDir);

packages/chronicle/src/cli/commands/serve.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import chalk from 'chalk';
22
import { Command } from 'commander';
3-
import { resolveConfigPath, resolveContentDir } from '@/cli/utils/config';
3+
import { loadCLIConfig } from '@/cli/utils/config';
44
import { PACKAGE_ROOT } from '@/cli/utils/resolve';
55
import { linkContent } from '@/cli/utils/scaffold';
66

@@ -14,8 +14,10 @@ export const serveCommand = new Command('serve')
1414
'Deploy preset (vercel, cloudflare, node-server)'
1515
)
1616
.action(async options => {
17-
const contentDir = resolveContentDir(options.content);
18-
const configPath = resolveConfigPath(options.config);
17+
const { contentDir, configPath, preset } = await loadCLIConfig(options.config, {
18+
content: options.content,
19+
preset: options.preset,
20+
});
1921
const port = parseInt(options.port, 10);
2022
await linkContent(contentDir);
2123

@@ -27,7 +29,7 @@ export const serveCommand = new Command('serve')
2729
projectRoot: process.cwd(),
2830
contentDir,
2931
configPath,
30-
preset: options.preset
32+
preset
3133
});
3234

3335
console.log(chalk.cyan('Building for production...'));

packages/chronicle/src/cli/commands/start.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import chalk from 'chalk';
22
import { Command } from 'commander';
3-
import { resolveContentDir } from '@/cli/utils/config';
3+
import { loadCLIConfig } from '@/cli/utils/config';
44
import { PACKAGE_ROOT } from '@/cli/utils/resolve';
55
import { linkContent } from '@/cli/utils/scaffold';
66

@@ -9,7 +9,7 @@ export const startCommand = new Command('start')
99
.option('-p, --port <port>', 'Port number', '3000')
1010
.option('--content <path>', 'Content directory')
1111
.action(async options => {
12-
const contentDir = resolveContentDir(options.content);
12+
const { contentDir, configPath } = await loadCLIConfig(undefined, { content: options.content });
1313
const port = parseInt(options.port, 10);
1414
await linkContent(contentDir);
1515

@@ -18,7 +18,7 @@ export const startCommand = new Command('start')
1818
const { preview } = await import('vite');
1919
const { createViteConfig } = await import('@/server/vite-config');
2020

21-
const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir });
21+
const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir, configPath });
2222
const server = await preview({
2323
...config,
2424
preview: { port }

packages/chronicle/src/cli/utils/config.ts

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,70 @@ import fs from 'node:fs/promises';
22
import path from 'node:path';
33
import chalk from 'chalk';
44
import { parse } from 'yaml';
5-
import type { ChronicleConfig } from '@/types';
5+
import { chronicleConfigSchema, type ChronicleConfig } from '@/types';
66

77
export interface CLIConfig {
88
config: ChronicleConfig;
99
configPath: string;
1010
contentDir: string;
11-
}
12-
13-
export function resolveContentDir(contentFlag?: string): string {
14-
if (contentFlag) return path.resolve(contentFlag);
15-
return path.resolve('content');
11+
preset?: string;
1612
}
1713

1814
export function resolveConfigPath(configPath?: string): string | undefined {
1915
if (configPath) return path.resolve(configPath);
2016
return undefined;
2117
}
2218

23-
async function readConfig(configPath: string): Promise<ChronicleConfig> {
24-
try {
25-
const raw = await fs.readFile(configPath, 'utf-8');
26-
return parse(raw) as ChronicleConfig;
27-
} catch (error) {
28-
const err = error as NodeJS.ErrnoException;
29-
if (err.code === 'ENOENT') {
19+
async function readConfig(configPath: string): Promise<string> {
20+
return fs.readFile(configPath, 'utf-8').catch((error: NodeJS.ErrnoException) => {
21+
if (error.code === 'ENOENT') {
3022
console.log(chalk.red(`Error: chronicle.yaml not found at '${configPath}'`));
3123
console.log(chalk.gray("Run 'chronicle init' to create one"));
3224
} else {
33-
console.log(chalk.red(`Error: Invalid YAML in '${configPath}'`));
34-
console.log(chalk.gray(err.message));
25+
console.log(chalk.red(`Error: Failed to read '${configPath}'`));
26+
console.log(chalk.gray(error.message));
27+
}
28+
process.exit(1);
29+
});
30+
}
31+
32+
function validateConfig(raw: string, configPath: string): ChronicleConfig {
33+
const parsed = parse(raw);
34+
const result = chronicleConfigSchema.safeParse(parsed);
35+
36+
if (!result.success) {
37+
console.log(chalk.red(`Error: Invalid chronicle.yaml at '${configPath}'`));
38+
for (const issue of result.error.issues) {
39+
const path = issue.path.join('.');
40+
console.log(chalk.gray(` ${path ? `${path}: ` : ''}${issue.message}`));
3541
}
3642
process.exit(1);
3743
}
44+
45+
return result.data;
46+
}
47+
48+
export function resolveContentDir(config: ChronicleConfig, contentFlag?: string): string {
49+
if (contentFlag) return path.resolve(contentFlag);
50+
if (config.content) return path.resolve(config.content);
51+
return path.resolve('content');
52+
}
53+
54+
export function resolvePreset(config: ChronicleConfig, presetFlag?: string): string | undefined {
55+
return presetFlag ?? config.preset;
3856
}
3957

40-
export async function loadCLIConfig(contentDir: string, configPath?: string): Promise<CLIConfig> {
58+
export async function loadCLIConfig(
59+
configPath?: string,
60+
options?: { content?: string; preset?: string }
61+
): Promise<CLIConfig> {
4162
const resolvedConfigPath = resolveConfigPath(configPath)
4263
?? path.join(process.cwd(), 'chronicle.yaml');
4364

44-
const config = await readConfig(resolvedConfigPath);
45-
return { config, configPath: resolvedConfigPath, contentDir };
65+
const raw = await readConfig(resolvedConfigPath);
66+
const config = validateConfig(raw, resolvedConfigPath);
67+
const contentDir = resolveContentDir(config, options?.content);
68+
const preset = resolvePreset(config, options?.preset);
69+
70+
return { config, configPath: resolvedConfigPath, contentDir, preset };
4671
}
Lines changed: 86 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,99 @@
1-
export interface ChronicleConfig {
2-
title: string
3-
description?: string
4-
url?: string
5-
logo?: LogoConfig
6-
theme?: ThemeConfig
7-
navigation?: NavigationConfig
8-
search?: SearchConfig
9-
footer?: FooterConfig
10-
api?: ApiConfig[]
11-
llms?: LlmsConfig
12-
analytics?: AnalyticsConfig
13-
}
1+
import { z } from 'zod'
142

15-
export interface LlmsConfig {
16-
enabled?: boolean
17-
}
3+
const logoSchema = z.object({
4+
light: z.string().optional(),
5+
dark: z.string().optional(),
6+
})
187

19-
export interface AnalyticsConfig {
20-
enabled?: boolean
21-
googleAnalytics?: GoogleAnalyticsConfig
22-
}
8+
const themeSchema = z.object({
9+
name: z.enum(['default', 'paper']),
10+
colors: z.record(z.string(), z.string()).optional(),
11+
})
2312

24-
export interface GoogleAnalyticsConfig {
25-
measurementId: string
26-
}
13+
const navLinkSchema = z.object({
14+
label: z.string(),
15+
href: z.string(),
16+
})
2717

28-
export interface ApiConfig {
29-
name: string
30-
spec: string
31-
basePath: string
32-
server: ApiServerConfig
33-
auth?: ApiAuthConfig
34-
}
18+
const socialLinkSchema = z.object({
19+
type: z.string(),
20+
href: z.string(),
21+
})
3522

36-
export interface ApiServerConfig {
37-
url: string
38-
description?: string
39-
}
23+
const navigationSchema = z.object({
24+
links: z.array(navLinkSchema).optional(),
25+
social: z.array(socialLinkSchema).optional(),
26+
})
4027

41-
export interface ApiAuthConfig {
42-
type: string
43-
header: string
44-
placeholder?: string
45-
}
28+
const searchSchema = z.object({
29+
enabled: z.boolean().optional(),
30+
placeholder: z.string().optional(),
31+
})
4632

47-
export interface LogoConfig {
48-
light?: string
49-
dark?: string
50-
}
33+
const apiServerSchema = z.object({
34+
url: z.string(),
35+
description: z.string().optional(),
36+
})
5137

52-
export interface ThemeConfig {
53-
name: 'default' | 'paper'
54-
colors?: Record<string, string>
55-
}
38+
const apiAuthSchema = z.object({
39+
type: z.string(),
40+
header: z.string(),
41+
placeholder: z.string().optional(),
42+
})
5643

57-
export interface NavigationConfig {
58-
links?: NavLink[]
59-
social?: SocialLink[]
60-
}
44+
const apiSchema = z.object({
45+
name: z.string(),
46+
spec: z.string(),
47+
basePath: z.string(),
48+
server: apiServerSchema,
49+
auth: apiAuthSchema.optional(),
50+
})
6151

62-
export interface NavLink {
63-
label: string
64-
href: string
65-
}
52+
const footerSchema = z.object({
53+
copyright: z.string().optional(),
54+
links: z.array(navLinkSchema).optional(),
55+
})
6656

67-
export interface SocialLink {
68-
type: 'github' | 'twitter' | 'discord' | string
69-
href: string
70-
}
57+
const llmsSchema = z.object({
58+
enabled: z.boolean().optional(),
59+
})
7160

72-
export interface SearchConfig {
73-
enabled?: boolean
74-
placeholder?: string
75-
}
61+
const googleAnalyticsSchema = z.object({
62+
measurementId: z.string(),
63+
})
7664

77-
export interface FooterConfig {
78-
copyright?: string
79-
links?: NavLink[]
80-
}
65+
const analyticsSchema = z.object({
66+
enabled: z.boolean().optional(),
67+
googleAnalytics: googleAnalyticsSchema.optional(),
68+
})
69+
70+
export const chronicleConfigSchema = z.object({
71+
title: z.string(),
72+
description: z.string().optional(),
73+
url: z.string().optional(),
74+
content: z.string().optional(),
75+
preset: z.string().optional(),
76+
logo: logoSchema.optional(),
77+
theme: themeSchema.optional(),
78+
navigation: navigationSchema.optional(),
79+
search: searchSchema.optional(),
80+
footer: footerSchema.optional(),
81+
api: z.array(apiSchema).optional(),
82+
llms: llmsSchema.optional(),
83+
analytics: analyticsSchema.optional(),
84+
})
85+
86+
export type ChronicleConfig = z.infer<typeof chronicleConfigSchema>
87+
export type LogoConfig = z.infer<typeof logoSchema>
88+
export type ThemeConfig = z.infer<typeof themeSchema>
89+
export type NavigationConfig = z.infer<typeof navigationSchema>
90+
export type NavLink = z.infer<typeof navLinkSchema>
91+
export type SocialLink = z.infer<typeof socialLinkSchema>
92+
export type SearchConfig = z.infer<typeof searchSchema>
93+
export type ApiConfig = z.infer<typeof apiSchema>
94+
export type ApiServerConfig = z.infer<typeof apiServerSchema>
95+
export type ApiAuthConfig = z.infer<typeof apiAuthSchema>
96+
export type FooterConfig = z.infer<typeof footerSchema>
97+
export type LlmsConfig = z.infer<typeof llmsSchema>
98+
export type AnalyticsConfig = z.infer<typeof analyticsSchema>
99+
export type GoogleAnalyticsConfig = z.infer<typeof googleAnalyticsSchema>

0 commit comments

Comments
 (0)