diff --git a/bun.lock b/bun.lock index 5042c56..10c12c2 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "unist-util-visit": "^5.1.0", "vite": "^8.0.0", "yaml": "^2.8.2", + "zod": "^4.3.6", }, "devDependencies": { "@biomejs/biome": "^2.3.13", diff --git a/examples/basic/chronicle.yaml b/examples/basic/chronicle.yaml index 8c152a6..a14bf5d 100644 --- a/examples/basic/chronicle.yaml +++ b/examples/basic/chronicle.yaml @@ -1,7 +1,7 @@ title: My Documentation description: Documentation powered by Chronicle url: https://docs.example.com -contentDir: . +content: . theme: name: default search: diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json index a23682e..cb01e78 100644 --- a/packages/chronicle/package.json +++ b/packages/chronicle/package.json @@ -57,15 +57,16 @@ "react-dom": "^19.0.0", "react-router": "^7.13.1", "remark-directive": "^4.0.0", - "remark-parse": "^11.0.0", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "remark-mdx-frontmatter": "^5.2.0", + "remark-parse": "^11.0.0", "satori": "^0.25.0", "slugify": "^1.6.6", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vite": "^8.0.0", - "yaml": "^2.8.2" + "yaml": "^2.8.2", + "zod": "^4.3.6" } } \ No newline at end of file diff --git a/packages/chronicle/src/cli/commands/build.ts b/packages/chronicle/src/cli/commands/build.ts index becb1a5..02bf45e 100644 --- a/packages/chronicle/src/cli/commands/build.ts +++ b/packages/chronicle/src/cli/commands/build.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { Command } from 'commander'; -import { resolveConfigPath, resolveContentDir } from '@/cli/utils/config'; +import { loadCLIConfig } from '@/cli/utils/config'; import { PACKAGE_ROOT } from '@/cli/utils/resolve'; import { linkContent } from '@/cli/utils/scaffold'; @@ -13,8 +13,10 @@ export const buildCommand = new Command('build') 'Deploy preset (vercel, cloudflare, node-server)' ) .action(async options => { - const contentDir = resolveContentDir(options.content); - const configPath = resolveConfigPath(options.config); + const { contentDir, configPath, preset } = await loadCLIConfig(options.config, { + content: options.content, + preset: options.preset, + }); await linkContent(contentDir); console.log(chalk.cyan('Building for production...')); @@ -27,7 +29,7 @@ export const buildCommand = new Command('build') projectRoot: process.cwd(), contentDir, configPath, - preset: options.preset + preset }); const builder = await createBuilder({ ...config, builder: {} }); diff --git a/packages/chronicle/src/cli/commands/dev.ts b/packages/chronicle/src/cli/commands/dev.ts index 35c6a1a..3d73b6d 100644 --- a/packages/chronicle/src/cli/commands/dev.ts +++ b/packages/chronicle/src/cli/commands/dev.ts @@ -1,8 +1,6 @@ -import fs from 'node:fs'; -import path from 'node:path'; import chalk from 'chalk'; import { Command } from 'commander'; -import { resolveConfigPath, resolveContentDir } from '@/cli/utils/config'; +import { loadCLIConfig } from '@/cli/utils/config'; import { PACKAGE_ROOT } from '@/cli/utils/resolve'; import { linkContent } from '@/cli/utils/scaffold'; @@ -12,8 +10,7 @@ export const devCommand = new Command('dev') .option('--content ', 'Content directory') .option('--config ', 'Path to chronicle.yaml') .action(async options => { - const contentDir = resolveContentDir(options.content); - const configPath = resolveConfigPath(options.config); + const { contentDir, configPath } = await loadCLIConfig(options.config, { content: options.content }); const port = parseInt(options.port, 10); await linkContent(contentDir); diff --git a/packages/chronicle/src/cli/commands/serve.ts b/packages/chronicle/src/cli/commands/serve.ts index 2861678..0af33a2 100644 --- a/packages/chronicle/src/cli/commands/serve.ts +++ b/packages/chronicle/src/cli/commands/serve.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { Command } from 'commander'; -import { resolveConfigPath, resolveContentDir } from '@/cli/utils/config'; +import { loadCLIConfig } from '@/cli/utils/config'; import { PACKAGE_ROOT } from '@/cli/utils/resolve'; import { linkContent } from '@/cli/utils/scaffold'; @@ -14,8 +14,10 @@ export const serveCommand = new Command('serve') 'Deploy preset (vercel, cloudflare, node-server)' ) .action(async options => { - const contentDir = resolveContentDir(options.content); - const configPath = resolveConfigPath(options.config); + const { contentDir, configPath, preset } = await loadCLIConfig(options.config, { + content: options.content, + preset: options.preset, + }); const port = parseInt(options.port, 10); await linkContent(contentDir); @@ -27,7 +29,7 @@ export const serveCommand = new Command('serve') projectRoot: process.cwd(), contentDir, configPath, - preset: options.preset + preset }); console.log(chalk.cyan('Building for production...')); diff --git a/packages/chronicle/src/cli/commands/start.ts b/packages/chronicle/src/cli/commands/start.ts index 9739ed1..8603942 100644 --- a/packages/chronicle/src/cli/commands/start.ts +++ b/packages/chronicle/src/cli/commands/start.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { Command } from 'commander'; -import { resolveContentDir } from '@/cli/utils/config'; +import { loadCLIConfig } from '@/cli/utils/config'; import { PACKAGE_ROOT } from '@/cli/utils/resolve'; import { linkContent } from '@/cli/utils/scaffold'; @@ -9,7 +9,7 @@ export const startCommand = new Command('start') .option('-p, --port ', 'Port number', '3000') .option('--content ', 'Content directory') .action(async options => { - const contentDir = resolveContentDir(options.content); + const { contentDir, configPath } = await loadCLIConfig(undefined, { content: options.content }); const port = parseInt(options.port, 10); await linkContent(contentDir); @@ -18,7 +18,7 @@ export const startCommand = new Command('start') const { preview } = await import('vite'); const { createViteConfig } = await import('@/server/vite-config'); - const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir }); + const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir, configPath }); const server = await preview({ ...config, preview: { port } diff --git a/packages/chronicle/src/cli/utils/config.ts b/packages/chronicle/src/cli/utils/config.ts index a3265ad..737ca13 100644 --- a/packages/chronicle/src/cli/utils/config.ts +++ b/packages/chronicle/src/cli/utils/config.ts @@ -2,17 +2,13 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import chalk from 'chalk'; import { parse } from 'yaml'; -import type { ChronicleConfig } from '@/types'; +import { chronicleConfigSchema, type ChronicleConfig } from '@/types'; export interface CLIConfig { config: ChronicleConfig; configPath: string; contentDir: string; -} - -export function resolveContentDir(contentFlag?: string): string { - if (contentFlag) return path.resolve(contentFlag); - return path.resolve('content'); + preset?: string; } export function resolveConfigPath(configPath?: string): string | undefined { @@ -20,27 +16,56 @@ export function resolveConfigPath(configPath?: string): string | undefined { return undefined; } -async function readConfig(configPath: string): Promise { - try { - const raw = await fs.readFile(configPath, 'utf-8'); - return parse(raw) as ChronicleConfig; - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code === 'ENOENT') { +async function readConfig(configPath: string): Promise { + return fs.readFile(configPath, 'utf-8').catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') { console.log(chalk.red(`Error: chronicle.yaml not found at '${configPath}'`)); console.log(chalk.gray("Run 'chronicle init' to create one")); } else { - console.log(chalk.red(`Error: Invalid YAML in '${configPath}'`)); - console.log(chalk.gray(err.message)); + console.log(chalk.red(`Error: Failed to read '${configPath}'`)); + console.log(chalk.gray(error.message)); + } + process.exit(1); + }); +} + +function validateConfig(raw: string, configPath: string): ChronicleConfig { + const parsed = parse(raw); + const result = chronicleConfigSchema.safeParse(parsed); + + if (!result.success) { + console.log(chalk.red(`Error: Invalid chronicle.yaml at '${configPath}'`)); + for (const issue of result.error.issues) { + const path = issue.path.join('.'); + console.log(chalk.gray(` ${path ? `${path}: ` : ''}${issue.message}`)); } process.exit(1); } + + return result.data; +} + +export function resolveContentDir(config: ChronicleConfig, contentFlag?: string): string { + if (contentFlag) return path.resolve(contentFlag); + if (config.content) return path.resolve(config.content); + return path.resolve('content'); +} + +export function resolvePreset(config: ChronicleConfig, presetFlag?: string): string | undefined { + return presetFlag ?? config.preset; } -export async function loadCLIConfig(contentDir: string, configPath?: string): Promise { +export async function loadCLIConfig( + configPath?: string, + options?: { content?: string; preset?: string } +): Promise { const resolvedConfigPath = resolveConfigPath(configPath) ?? path.join(process.cwd(), 'chronicle.yaml'); - const config = await readConfig(resolvedConfigPath); - return { config, configPath: resolvedConfigPath, contentDir }; + const raw = await readConfig(resolvedConfigPath); + const config = validateConfig(raw, resolvedConfigPath); + const contentDir = resolveContentDir(config, options?.content); + const preset = resolvePreset(config, options?.preset); + + return { config, configPath: resolvedConfigPath, contentDir, preset }; } diff --git a/packages/chronicle/src/types/config.ts b/packages/chronicle/src/types/config.ts index 510643b..825b381 100644 --- a/packages/chronicle/src/types/config.ts +++ b/packages/chronicle/src/types/config.ts @@ -1,80 +1,99 @@ -export interface ChronicleConfig { - title: string - description?: string - url?: string - logo?: LogoConfig - theme?: ThemeConfig - navigation?: NavigationConfig - search?: SearchConfig - footer?: FooterConfig - api?: ApiConfig[] - llms?: LlmsConfig - analytics?: AnalyticsConfig -} +import { z } from 'zod' -export interface LlmsConfig { - enabled?: boolean -} +const logoSchema = z.object({ + light: z.string().optional(), + dark: z.string().optional(), +}) -export interface AnalyticsConfig { - enabled?: boolean - googleAnalytics?: GoogleAnalyticsConfig -} +const themeSchema = z.object({ + name: z.enum(['default', 'paper']), + colors: z.record(z.string(), z.string()).optional(), +}) -export interface GoogleAnalyticsConfig { - measurementId: string -} +const navLinkSchema = z.object({ + label: z.string(), + href: z.string(), +}) -export interface ApiConfig { - name: string - spec: string - basePath: string - server: ApiServerConfig - auth?: ApiAuthConfig -} +const socialLinkSchema = z.object({ + type: z.string(), + href: z.string(), +}) -export interface ApiServerConfig { - url: string - description?: string -} +const navigationSchema = z.object({ + links: z.array(navLinkSchema).optional(), + social: z.array(socialLinkSchema).optional(), +}) -export interface ApiAuthConfig { - type: string - header: string - placeholder?: string -} +const searchSchema = z.object({ + enabled: z.boolean().optional(), + placeholder: z.string().optional(), +}) -export interface LogoConfig { - light?: string - dark?: string -} +const apiServerSchema = z.object({ + url: z.string(), + description: z.string().optional(), +}) -export interface ThemeConfig { - name: 'default' | 'paper' - colors?: Record -} +const apiAuthSchema = z.object({ + type: z.string(), + header: z.string(), + placeholder: z.string().optional(), +}) -export interface NavigationConfig { - links?: NavLink[] - social?: SocialLink[] -} +const apiSchema = z.object({ + name: z.string(), + spec: z.string(), + basePath: z.string(), + server: apiServerSchema, + auth: apiAuthSchema.optional(), +}) -export interface NavLink { - label: string - href: string -} +const footerSchema = z.object({ + copyright: z.string().optional(), + links: z.array(navLinkSchema).optional(), +}) -export interface SocialLink { - type: 'github' | 'twitter' | 'discord' | string - href: string -} +const llmsSchema = z.object({ + enabled: z.boolean().optional(), +}) -export interface SearchConfig { - enabled?: boolean - placeholder?: string -} +const googleAnalyticsSchema = z.object({ + measurementId: z.string(), +}) -export interface FooterConfig { - copyright?: string - links?: NavLink[] -} +const analyticsSchema = z.object({ + enabled: z.boolean().optional(), + googleAnalytics: googleAnalyticsSchema.optional(), +}) + +export const chronicleConfigSchema = z.object({ + title: z.string(), + description: z.string().optional(), + url: z.string().optional(), + content: z.string().optional(), + preset: z.string().optional(), + logo: logoSchema.optional(), + theme: themeSchema.optional(), + navigation: navigationSchema.optional(), + search: searchSchema.optional(), + footer: footerSchema.optional(), + api: z.array(apiSchema).optional(), + llms: llmsSchema.optional(), + analytics: analyticsSchema.optional(), +}) + +export type ChronicleConfig = z.infer +export type LogoConfig = z.infer +export type ThemeConfig = z.infer +export type NavigationConfig = z.infer +export type NavLink = z.infer +export type SocialLink = z.infer +export type SearchConfig = z.infer +export type ApiConfig = z.infer +export type ApiServerConfig = z.infer +export type ApiAuthConfig = z.infer +export type FooterConfig = z.infer +export type LlmsConfig = z.infer +export type AnalyticsConfig = z.infer +export type GoogleAnalyticsConfig = z.infer