Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion examples/basic/chronicle.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
title: My Documentation
description: Documentation powered by Chronicle
url: https://docs.example.com
contentDir: .
content: .
theme:
name: default
search:
Expand Down
5 changes: 3 additions & 2 deletions packages/chronicle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
10 changes: 6 additions & 4 deletions packages/chronicle/src/cli/commands/build.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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...'));
Expand All @@ -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: {} });
Expand Down
7 changes: 2 additions & 5 deletions packages/chronicle/src/cli/commands/dev.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -12,8 +10,7 @@ export const devCommand = new Command('dev')
.option('--content <path>', 'Content directory')
.option('--config <path>', '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);
Expand Down
10 changes: 6 additions & 4 deletions packages/chronicle/src/cli/commands/serve.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);

Expand All @@ -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...'));
Expand Down
6 changes: 3 additions & 3 deletions packages/chronicle/src/cli/commands/start.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -9,7 +9,7 @@ export const startCommand = new Command('start')
.option('-p, --port <port>', 'Port number', '3000')
.option('--content <path>', '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);

Expand All @@ -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 }
Expand Down
61 changes: 43 additions & 18 deletions packages/chronicle/src/cli/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,70 @@ 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 {
if (configPath) return path.resolve(configPath);
return undefined;
}

async function readConfig(configPath: string): Promise<ChronicleConfig> {
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<string> {
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<CLIConfig> {
export async function loadCLIConfig(
configPath?: string,
options?: { content?: string; preset?: string }
): Promise<CLIConfig> {
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 };
}
153 changes: 86 additions & 67 deletions packages/chronicle/src/types/config.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
}
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<typeof chronicleConfigSchema>
export type LogoConfig = z.infer<typeof logoSchema>
export type ThemeConfig = z.infer<typeof themeSchema>
export type NavigationConfig = z.infer<typeof navigationSchema>
export type NavLink = z.infer<typeof navLinkSchema>
export type SocialLink = z.infer<typeof socialLinkSchema>
export type SearchConfig = z.infer<typeof searchSchema>
export type ApiConfig = z.infer<typeof apiSchema>
export type ApiServerConfig = z.infer<typeof apiServerSchema>
export type ApiAuthConfig = z.infer<typeof apiAuthSchema>
export type FooterConfig = z.infer<typeof footerSchema>
export type LlmsConfig = z.infer<typeof llmsSchema>
export type AnalyticsConfig = z.infer<typeof analyticsSchema>
export type GoogleAnalyticsConfig = z.infer<typeof googleAnalyticsSchema>