@raystack/chronicle 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -68,7 +68,7 @@ import { remarkDirectiveAdmonition, remarkMdxMermaid } from "fumadocs-core/mdx-p
68
68
  import { defineConfig as defineFumadocsConfig } from "fumadocs-mdx/config";
69
69
  import mdx from "fumadocs-mdx/vite";
70
70
  import { nitro } from "nitro/vite";
71
- import fs2 from "node:fs/promises";
71
+ import fs3 from "node:fs/promises";
72
72
  import path4 from "node:path";
73
73
  import remarkDirective from "remark-directive";
74
74
  function resolveOutputDir(projectRoot, preset) {
@@ -76,18 +76,23 @@ function resolveOutputDir(projectRoot, preset) {
76
76
  return path4.resolve(projectRoot, ".vercel/output");
77
77
  return path4.resolve(projectRoot, ".output");
78
78
  }
79
- async function readChronicleConfig(projectRoot, contentDir) {
80
- for (const dir of [projectRoot, contentDir]) {
81
- const filePath = path4.join(dir, "chronicle.yaml");
79
+ async function readChronicleConfig(projectRoot, configPath) {
80
+ if (configPath) {
82
81
  try {
83
- return await fs2.readFile(filePath, "utf-8");
84
- } catch {}
82
+ return await fs3.readFile(configPath, "utf-8");
83
+ } catch (error) {
84
+ throw new Error(`Failed to read config file '${configPath}': ${error.message}`);
85
+ }
86
+ }
87
+ try {
88
+ return await fs3.readFile(path4.join(projectRoot, "chronicle.yaml"), "utf-8");
89
+ } catch {
90
+ return null;
85
91
  }
86
- return null;
87
92
  }
88
93
  async function createViteConfig(options) {
89
- const { packageRoot, projectRoot, contentDir, preset } = options;
90
- const rawConfig = await readChronicleConfig(projectRoot, contentDir);
94
+ const { packageRoot, projectRoot, contentDir, configPath, preset } = options;
95
+ const rawConfig = await readChronicleConfig(projectRoot, configPath);
91
96
  return {
92
97
  root: packageRoot,
93
98
  configFile: false,
@@ -188,14 +193,131 @@ import chalk2 from "chalk";
188
193
  import { Command } from "commander";
189
194
 
190
195
  // src/cli/utils/config.ts
196
+ import fs from "node:fs/promises";
191
197
  import path from "node:path";
192
198
  import chalk from "chalk";
193
199
  import { parse } from "yaml";
194
- function resolveContentDir(contentFlag) {
200
+
201
+ // src/types/config.ts
202
+ import { z } from "zod";
203
+ var logoSchema = z.object({
204
+ light: z.string().optional(),
205
+ dark: z.string().optional()
206
+ });
207
+ var themeSchema = z.object({
208
+ name: z.enum(["default", "paper"]),
209
+ colors: z.record(z.string(), z.string()).optional()
210
+ });
211
+ var navLinkSchema = z.object({
212
+ label: z.string(),
213
+ href: z.string()
214
+ });
215
+ var socialLinkSchema = z.object({
216
+ type: z.string(),
217
+ href: z.string()
218
+ });
219
+ var navigationSchema = z.object({
220
+ links: z.array(navLinkSchema).optional(),
221
+ social: z.array(socialLinkSchema).optional()
222
+ });
223
+ var searchSchema = z.object({
224
+ enabled: z.boolean().optional(),
225
+ placeholder: z.string().optional()
226
+ });
227
+ var apiServerSchema = z.object({
228
+ url: z.string(),
229
+ description: z.string().optional()
230
+ });
231
+ var apiAuthSchema = z.object({
232
+ type: z.string(),
233
+ header: z.string(),
234
+ placeholder: z.string().optional()
235
+ });
236
+ var apiSchema = z.object({
237
+ name: z.string(),
238
+ spec: z.string(),
239
+ basePath: z.string(),
240
+ server: apiServerSchema,
241
+ auth: apiAuthSchema.optional()
242
+ });
243
+ var footerSchema = z.object({
244
+ copyright: z.string().optional(),
245
+ links: z.array(navLinkSchema).optional()
246
+ });
247
+ var llmsSchema = z.object({
248
+ enabled: z.boolean().optional()
249
+ });
250
+ var googleAnalyticsSchema = z.object({
251
+ measurementId: z.string()
252
+ });
253
+ var analyticsSchema = z.object({
254
+ enabled: z.boolean().optional(),
255
+ googleAnalytics: googleAnalyticsSchema.optional()
256
+ });
257
+ var chronicleConfigSchema = z.object({
258
+ title: z.string(),
259
+ description: z.string().optional(),
260
+ url: z.string().optional(),
261
+ content: z.string().optional(),
262
+ preset: z.string().optional(),
263
+ logo: logoSchema.optional(),
264
+ theme: themeSchema.optional(),
265
+ navigation: navigationSchema.optional(),
266
+ search: searchSchema.optional(),
267
+ footer: footerSchema.optional(),
268
+ api: z.array(apiSchema).optional(),
269
+ llms: llmsSchema.optional(),
270
+ analytics: analyticsSchema.optional()
271
+ });
272
+ // src/cli/utils/config.ts
273
+ function resolveConfigPath(configPath) {
274
+ if (configPath)
275
+ return path.resolve(configPath);
276
+ return;
277
+ }
278
+ async function readConfig(configPath) {
279
+ return fs.readFile(configPath, "utf-8").catch((error) => {
280
+ if (error.code === "ENOENT") {
281
+ console.log(chalk.red(`Error: chronicle.yaml not found at '${configPath}'`));
282
+ console.log(chalk.gray("Run 'chronicle init' to create one"));
283
+ } else {
284
+ console.log(chalk.red(`Error: Failed to read '${configPath}'`));
285
+ console.log(chalk.gray(error.message));
286
+ }
287
+ process.exit(1);
288
+ });
289
+ }
290
+ function validateConfig(raw, configPath) {
291
+ const parsed = parse(raw);
292
+ const result = chronicleConfigSchema.safeParse(parsed);
293
+ if (!result.success) {
294
+ console.log(chalk.red(`Error: Invalid chronicle.yaml at '${configPath}'`));
295
+ for (const issue of result.error.issues) {
296
+ const path2 = issue.path.join(".");
297
+ console.log(chalk.gray(` ${path2 ? `${path2}: ` : ""}${issue.message}`));
298
+ }
299
+ process.exit(1);
300
+ }
301
+ return result.data;
302
+ }
303
+ function resolveContentDir(config2, configPath, contentFlag) {
195
304
  if (contentFlag)
196
305
  return path.resolve(contentFlag);
306
+ if (config2.content)
307
+ return path.resolve(path.dirname(configPath), config2.content);
197
308
  return path.resolve("content");
198
309
  }
310
+ function resolvePreset(config2, presetFlag) {
311
+ return presetFlag ?? config2.preset;
312
+ }
313
+ async function loadCLIConfig(configPath, options) {
314
+ const resolvedConfigPath = resolveConfigPath(configPath) ?? path.join(process.cwd(), "chronicle.yaml");
315
+ const raw = await readConfig(resolvedConfigPath);
316
+ const config2 = validateConfig(raw, resolvedConfigPath);
317
+ const contentDir = resolveContentDir(config2, resolvedConfigPath, options?.content);
318
+ const preset = resolvePreset(config2, options?.preset);
319
+ return { config: config2, configPath: resolvedConfigPath, contentDir, preset };
320
+ }
199
321
 
200
322
  // src/cli/utils/resolve.ts
201
323
  import path2 from "path";
@@ -203,34 +325,38 @@ import { fileURLToPath } from "url";
203
325
  var PACKAGE_ROOT = path2.resolve(path2.dirname(fileURLToPath(import.meta.url)), "..", "..");
204
326
 
205
327
  // src/cli/utils/scaffold.ts
206
- import fs from "node:fs/promises";
328
+ import fs2 from "node:fs/promises";
207
329
  import path3 from "node:path";
208
330
  async function linkContent(contentDir) {
209
331
  const linkPath = path3.join(PACKAGE_ROOT, ".content");
210
332
  const target = path3.resolve(contentDir);
211
333
  try {
212
- const existing = await fs.readlink(linkPath);
334
+ const existing = await fs2.readlink(linkPath);
213
335
  if (existing === target)
214
336
  return;
215
- await fs.unlink(linkPath);
337
+ await fs2.unlink(linkPath);
216
338
  } catch {}
217
- await fs.symlink(target, linkPath);
339
+ await fs2.symlink(target, linkPath);
218
340
  }
219
341
 
220
342
  // src/cli/commands/build.ts
221
- var buildCommand = new Command("build").description("Build for production").option("-c, --content <path>", "Content directory").option("--preset <preset>", "Deploy preset (vercel, cloudflare, node-server)").action(async (options) => {
222
- const contentDir = resolveContentDir(options.content);
343
+ var buildCommand = new Command("build").description("Build for production").option("--content <path>", "Content directory").option("--config <path>", "Path to chronicle.yaml").option("--preset <preset>", "Deploy preset (vercel, cloudflare, node-server)").action(async (options) => {
344
+ const { contentDir, configPath, preset } = await loadCLIConfig(options.config, {
345
+ content: options.content,
346
+ preset: options.preset
347
+ });
223
348
  await linkContent(contentDir);
224
349
  console.log(chalk2.cyan("Building for production..."));
225
350
  const { createBuilder } = await import("vite");
226
351
  const { createViteConfig: createViteConfig2 } = await Promise.resolve().then(() => (init_vite_config(), exports_vite_config));
227
- const config = await createViteConfig2({
352
+ const config2 = await createViteConfig2({
228
353
  packageRoot: PACKAGE_ROOT,
229
354
  projectRoot: process.cwd(),
230
355
  contentDir,
231
- preset: options.preset
356
+ configPath,
357
+ preset
232
358
  });
233
- const builder = await createBuilder({ ...config, builder: {} });
359
+ const builder = await createBuilder({ ...config2, builder: {} });
234
360
  await builder.buildApp();
235
361
  console.log(chalk2.green("Build complete"));
236
362
  console.log(chalk2.cyan("Run `chronicle start` to start the server"));
@@ -239,24 +365,24 @@ var buildCommand = new Command("build").description("Build for production").opti
239
365
  // src/cli/commands/dev.ts
240
366
  import chalk3 from "chalk";
241
367
  import { Command as Command2 } from "commander";
242
- var devCommand = new Command2("dev").description("Start development server").option("-p, --port <port>", "Port number", "3000").option("-c, --content <path>", "Content directory").action(async (options) => {
243
- const contentDir = resolveContentDir(options.content);
368
+ var devCommand = new Command2("dev").description("Start development server").option("-p, --port <port>", "Port number", "3000").option("--content <path>", "Content directory").option("--config <path>", "Path to chronicle.yaml").action(async (options) => {
369
+ const { contentDir, configPath } = await loadCLIConfig(options.config, { content: options.content });
244
370
  const port = parseInt(options.port, 10);
245
371
  await linkContent(contentDir);
246
372
  console.log(chalk3.cyan("Starting dev server..."));
247
373
  const { createServer } = await import("vite");
248
374
  const { createViteConfig: createViteConfig2 } = await Promise.resolve().then(() => (init_vite_config(), exports_vite_config));
249
- const config = await createViteConfig2({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir });
375
+ const config2 = await createViteConfig2({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir, configPath });
250
376
  const server = await createServer({
251
- ...config,
252
- server: { ...config.server, port }
377
+ ...config2,
378
+ server: { ...config2.server, port }
253
379
  });
254
380
  await server.listen();
255
381
  server.printUrls();
256
382
  });
257
383
 
258
384
  // src/cli/commands/init.ts
259
- import fs3 from "node:fs";
385
+ import fs4 from "node:fs";
260
386
  import path5 from "node:path";
261
387
  import chalk4 from "chalk";
262
388
  import { Command as Command3 } from "commander";
@@ -280,37 +406,37 @@ This is your documentation home page.
280
406
  var initCommand = new Command3("init").description("Initialize a new Chronicle project").option("-c, --content <path>", "Content directory name", "content").action((options) => {
281
407
  const projectDir = process.cwd();
282
408
  const contentDir = path5.join(projectDir, options.content);
283
- if (!fs3.existsSync(contentDir)) {
284
- fs3.mkdirSync(contentDir, { recursive: true });
409
+ if (!fs4.existsSync(contentDir)) {
410
+ fs4.mkdirSync(contentDir, { recursive: true });
285
411
  console.log(chalk4.green("✓"), "Created", contentDir);
286
412
  }
287
413
  const configPath = path5.join(projectDir, "chronicle.yaml");
288
- if (!fs3.existsSync(configPath)) {
289
- fs3.writeFileSync(configPath, stringify(defaultConfig));
414
+ if (!fs4.existsSync(configPath)) {
415
+ fs4.writeFileSync(configPath, stringify(defaultConfig));
290
416
  console.log(chalk4.green("✓"), "Created", configPath);
291
417
  } else {
292
418
  console.log(chalk4.yellow("⚠"), configPath, "already exists");
293
419
  }
294
- const contentFiles = fs3.readdirSync(contentDir);
420
+ const contentFiles = fs4.readdirSync(contentDir);
295
421
  if (contentFiles.length === 0) {
296
422
  const indexPath = path5.join(contentDir, "index.mdx");
297
- fs3.writeFileSync(indexPath, sampleMdx);
423
+ fs4.writeFileSync(indexPath, sampleMdx);
298
424
  console.log(chalk4.green("✓"), "Created", indexPath);
299
425
  }
300
426
  const gitignorePath = path5.join(projectDir, ".gitignore");
301
427
  const gitignoreEntries = ["node_modules", "dist", ".output"];
302
- if (fs3.existsSync(gitignorePath)) {
303
- const existing = fs3.readFileSync(gitignorePath, "utf-8");
428
+ if (fs4.existsSync(gitignorePath)) {
429
+ const existing = fs4.readFileSync(gitignorePath, "utf-8");
304
430
  const missing = gitignoreEntries.filter((e) => !existing.includes(e));
305
431
  if (missing.length > 0) {
306
- fs3.appendFileSync(gitignorePath, `
432
+ fs4.appendFileSync(gitignorePath, `
307
433
  ${missing.join(`
308
434
  `)}
309
435
  `);
310
436
  console.log(chalk4.green("✓"), "Added", missing.join(", "), "to .gitignore");
311
437
  }
312
438
  } else {
313
- fs3.writeFileSync(gitignorePath, `${gitignoreEntries.join(`
439
+ fs4.writeFileSync(gitignorePath, `${gitignoreEntries.join(`
314
440
  `)}
315
441
  `);
316
442
  console.log(chalk4.green("✓"), "Created .gitignore");
@@ -324,23 +450,27 @@ Run`, chalk4.cyan("chronicle dev"), "to start development server");
324
450
  // src/cli/commands/serve.ts
325
451
  import chalk5 from "chalk";
326
452
  import { Command as Command4 } from "commander";
327
- var serveCommand = new Command4("serve").description("Build and start production server").option("-p, --port <port>", "Port number", "3000").option("-c, --content <path>", "Content directory").option("--preset <preset>", "Deploy preset (vercel, cloudflare, node-server)").action(async (options) => {
328
- const contentDir = resolveContentDir(options.content);
453
+ var serveCommand = new Command4("serve").description("Build and start production server").option("-p, --port <port>", "Port number", "3000").option("--content <path>", "Content directory").option("--config <path>", "Path to chronicle.yaml").option("--preset <preset>", "Deploy preset (vercel, cloudflare, node-server)").action(async (options) => {
454
+ const { contentDir, configPath, preset } = await loadCLIConfig(options.config, {
455
+ content: options.content,
456
+ preset: options.preset
457
+ });
329
458
  const port = parseInt(options.port, 10);
330
459
  await linkContent(contentDir);
331
460
  const { build, preview } = await import("vite");
332
461
  const { createViteConfig: createViteConfig2 } = await Promise.resolve().then(() => (init_vite_config(), exports_vite_config));
333
- const config = await createViteConfig2({
462
+ const config2 = await createViteConfig2({
334
463
  packageRoot: PACKAGE_ROOT,
335
464
  projectRoot: process.cwd(),
336
465
  contentDir,
337
- preset: options.preset
466
+ configPath,
467
+ preset
338
468
  });
339
469
  console.log(chalk5.cyan("Building for production..."));
340
- await build(config);
470
+ await build(config2);
341
471
  console.log(chalk5.cyan("Starting production server..."));
342
472
  const server = await preview({
343
- ...config,
473
+ ...config2,
344
474
  preview: { port }
345
475
  });
346
476
  server.printUrls();
@@ -349,16 +479,16 @@ var serveCommand = new Command4("serve").description("Build and start production
349
479
  // src/cli/commands/start.ts
350
480
  import chalk6 from "chalk";
351
481
  import { Command as Command5 } from "commander";
352
- var startCommand = new Command5("start").description("Start production server").option("-p, --port <port>", "Port number", "3000").option("-c, --content <path>", "Content directory").action(async (options) => {
353
- const contentDir = resolveContentDir(options.content);
482
+ var startCommand = new Command5("start").description("Start production server").option("-p, --port <port>", "Port number", "3000").option("--content <path>", "Content directory").action(async (options) => {
483
+ const { contentDir, configPath } = await loadCLIConfig(undefined, { content: options.content });
354
484
  const port = parseInt(options.port, 10);
355
485
  await linkContent(contentDir);
356
486
  console.log(chalk6.cyan("Starting production server..."));
357
487
  const { preview } = await import("vite");
358
488
  const { createViteConfig: createViteConfig2 } = await Promise.resolve().then(() => (init_vite_config(), exports_vite_config));
359
- const config = await createViteConfig2({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir });
489
+ const config2 = await createViteConfig2({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir, configPath });
360
490
  const server = await preview({
361
- ...config,
491
+ ...config2,
362
492
  preview: { port }
363
493
  });
364
494
  server.printUrls();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Config-driven documentation framework",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -51,21 +51,22 @@
51
51
  "lodash": "^4.17.23",
52
52
  "mermaid": "^11.13.0",
53
53
  "minisearch": "^7.2.0",
54
- "nitro": "latest",
54
+ "nitro": "3.0.260311-beta",
55
55
  "openapi-types": "^12.1.3",
56
56
  "react": "^19.0.0",
57
57
  "react-dom": "^19.0.0",
58
58
  "react-router": "^7.13.1",
59
59
  "remark-directive": "^4.0.0",
60
- "remark-parse": "^11.0.0",
61
60
  "remark-frontmatter": "^5.0.0",
62
61
  "remark-gfm": "^4.0.1",
63
62
  "remark-mdx-frontmatter": "^5.2.0",
63
+ "remark-parse": "^11.0.0",
64
64
  "satori": "^0.25.0",
65
65
  "slugify": "^1.6.6",
66
66
  "unified": "^11.0.5",
67
67
  "unist-util-visit": "^5.1.0",
68
68
  "vite": "^8.0.0",
69
- "yaml": "^2.8.2"
69
+ "yaml": "^2.8.2",
70
+ "zod": "^4.3.6"
70
71
  }
71
72
  }
@@ -1,18 +1,22 @@
1
1
  import chalk from 'chalk';
2
2
  import { Command } from 'commander';
3
- import { resolveContentDir } from '@/cli/utils/config';
3
+ import { loadCLIConfig } from '@/cli/utils/config';
4
4
  import { PACKAGE_ROOT } from '@/cli/utils/resolve';
5
5
  import { linkContent } from '@/cli/utils/scaffold';
6
6
 
7
7
  export const buildCommand = new Command('build')
8
8
  .description('Build for production')
9
- .option('-c, --content <path>', 'Content directory')
9
+ .option('--content <path>', 'Content directory')
10
+ .option('--config <path>', 'Path to chronicle.yaml')
10
11
  .option(
11
12
  '--preset <preset>',
12
13
  'Deploy preset (vercel, cloudflare, node-server)'
13
14
  )
14
15
  .action(async options => {
15
- const contentDir = resolveContentDir(options.content);
16
+ const { contentDir, configPath, preset } = await loadCLIConfig(options.config, {
17
+ content: options.content,
18
+ preset: options.preset,
19
+ });
16
20
  await linkContent(contentDir);
17
21
 
18
22
  console.log(chalk.cyan('Building for production...'));
@@ -24,7 +28,8 @@ export const buildCommand = new Command('build')
24
28
  packageRoot: PACKAGE_ROOT,
25
29
  projectRoot: process.cwd(),
26
30
  contentDir,
27
- preset: options.preset
31
+ configPath,
32
+ preset
28
33
  });
29
34
 
30
35
  const builder = await createBuilder({ ...config, builder: {} });
@@ -1,17 +1,16 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
1
  import chalk from 'chalk';
4
2
  import { Command } from 'commander';
5
- import { resolveContentDir } from '@/cli/utils/config';
3
+ import { loadCLIConfig } from '@/cli/utils/config';
6
4
  import { PACKAGE_ROOT } from '@/cli/utils/resolve';
7
5
  import { linkContent } from '@/cli/utils/scaffold';
8
6
 
9
7
  export const devCommand = new Command('dev')
10
8
  .description('Start development server')
11
9
  .option('-p, --port <port>', 'Port number', '3000')
12
- .option('-c, --content <path>', 'Content directory')
10
+ .option('--content <path>', 'Content directory')
11
+ .option('--config <path>', 'Path to chronicle.yaml')
13
12
  .action(async options => {
14
- const contentDir = resolveContentDir(options.content);
13
+ const { contentDir, configPath } = await loadCLIConfig(options.config, { content: options.content });
15
14
  const port = parseInt(options.port, 10);
16
15
 
17
16
  await linkContent(contentDir);
@@ -21,7 +20,7 @@ export const devCommand = new Command('dev')
21
20
  const { createServer } = await import('vite');
22
21
  const { createViteConfig } = await import('@/server/vite-config');
23
22
 
24
- const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir });
23
+ const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir, configPath });
25
24
  const server = await createServer({
26
25
  ...config,
27
26
  server: { ...config.server, port }
@@ -1,19 +1,23 @@
1
1
  import chalk from 'chalk';
2
2
  import { Command } from 'commander';
3
- import { resolveContentDir } from '@/cli/utils/config';
3
+ import { loadCLIConfig } from '@/cli/utils/config';
4
4
  import { PACKAGE_ROOT } from '@/cli/utils/resolve';
5
5
  import { linkContent } from '@/cli/utils/scaffold';
6
6
 
7
7
  export const serveCommand = new Command('serve')
8
8
  .description('Build and start production server')
9
9
  .option('-p, --port <port>', 'Port number', '3000')
10
- .option('-c, --content <path>', 'Content directory')
10
+ .option('--content <path>', 'Content directory')
11
+ .option('--config <path>', 'Path to chronicle.yaml')
11
12
  .option(
12
13
  '--preset <preset>',
13
14
  'Deploy preset (vercel, cloudflare, node-server)'
14
15
  )
15
16
  .action(async options => {
16
- const contentDir = resolveContentDir(options.content);
17
+ const { contentDir, configPath, preset } = await loadCLIConfig(options.config, {
18
+ content: options.content,
19
+ preset: options.preset,
20
+ });
17
21
  const port = parseInt(options.port, 10);
18
22
  await linkContent(contentDir);
19
23
 
@@ -24,7 +28,8 @@ export const serveCommand = new Command('serve')
24
28
  packageRoot: PACKAGE_ROOT,
25
29
  projectRoot: process.cwd(),
26
30
  contentDir,
27
- preset: options.preset
31
+ configPath,
32
+ preset
28
33
  });
29
34
 
30
35
  console.log(chalk.cyan('Building for production...'));
@@ -1,15 +1,15 @@
1
1
  import chalk from 'chalk';
2
2
  import { Command } from 'commander';
3
- import { resolveContentDir } from '@/cli/utils/config';
3
+ import { loadCLIConfig } from '@/cli/utils/config';
4
4
  import { PACKAGE_ROOT } from '@/cli/utils/resolve';
5
5
  import { linkContent } from '@/cli/utils/scaffold';
6
6
 
7
7
  export const startCommand = new Command('start')
8
8
  .description('Start production server')
9
9
  .option('-p, --port <port>', 'Port number', '3000')
10
- .option('-c, --content <path>', 'Content directory')
10
+ .option('--content <path>', 'Content directory')
11
11
  .action(async options => {
12
- const contentDir = resolveContentDir(options.content);
12
+ const { contentDir, configPath } = await loadCLIConfig(undefined, { content: options.content });
13
13
  const port = parseInt(options.port, 10);
14
14
  await linkContent(contentDir);
15
15
 
@@ -18,7 +18,7 @@ export const startCommand = new Command('start')
18
18
  const { preview } = await import('vite');
19
19
  const { createViteConfig } = await import('@/server/vite-config');
20
20
 
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 });
22
22
  const server = await preview({
23
23
  ...config,
24
24
  preview: { port }
@@ -1,42 +1,71 @@
1
- import fs from 'node:fs';
1
+ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import { parse } from 'yaml';
5
- import type { ChronicleConfig } from '@/types';
5
+ import { chronicleConfigSchema, type ChronicleConfig } from '@/types';
6
6
 
7
7
  export interface CLIConfig {
8
8
  config: ChronicleConfig;
9
9
  configPath: string;
10
10
  contentDir: string;
11
+ preset?: string;
11
12
  }
12
13
 
13
- export function resolveContentDir(contentFlag?: string): string {
14
- if (contentFlag) return path.resolve(contentFlag);
15
- return path.resolve('content');
14
+ export function resolveConfigPath(configPath?: string): string | undefined {
15
+ if (configPath) return path.resolve(configPath);
16
+ return undefined;
16
17
  }
17
18
 
18
- function resolveConfigPath(contentDir: string): string | null {
19
- const cwdPath = path.join(process.cwd(), 'chronicle.yaml');
20
- if (fs.existsSync(cwdPath)) return cwdPath;
21
- const contentPath = path.join(contentDir, 'chronicle.yaml');
22
- if (fs.existsSync(contentPath)) return contentPath;
23
- return null;
19
+ async function readConfig(configPath: string): Promise<string> {
20
+ return fs.readFile(configPath, 'utf-8').catch((error: NodeJS.ErrnoException) => {
21
+ if (error.code === 'ENOENT') {
22
+ console.log(chalk.red(`Error: chronicle.yaml not found at '${configPath}'`));
23
+ console.log(chalk.gray("Run 'chronicle init' to create one"));
24
+ } else {
25
+ console.log(chalk.red(`Error: Failed to read '${configPath}'`));
26
+ console.log(chalk.gray(error.message));
27
+ }
28
+ process.exit(1);
29
+ });
24
30
  }
25
31
 
26
- export function loadCLIConfig(contentDir: string): CLIConfig {
27
- const configPath = resolveConfigPath(contentDir);
32
+ function validateConfig(raw: string, configPath: string): ChronicleConfig {
33
+ const parsed = parse(raw);
34
+ const result = chronicleConfigSchema.safeParse(parsed);
28
35
 
29
- if (!configPath) {
30
- console.log(
31
- chalk.red(
32
- `Error: chronicle.yaml not found in '${process.cwd()}' or '${contentDir}'`
33
- )
34
- );
35
- console.log(chalk.gray("Run 'chronicle init' to create one"));
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}`));
41
+ }
36
42
  process.exit(1);
37
43
  }
38
44
 
39
- const config = parse(fs.readFileSync(configPath, 'utf-8')) as ChronicleConfig;
45
+ return result.data;
46
+ }
47
+
48
+ export function resolveContentDir(config: ChronicleConfig, configPath: string, contentFlag?: string): string {
49
+ if (contentFlag) return path.resolve(contentFlag);
50
+ if (config.content) return path.resolve(path.dirname(configPath), config.content);
51
+ return path.resolve('content');
52
+ }
53
+
54
+ export function resolvePreset(config: ChronicleConfig, presetFlag?: string): string | undefined {
55
+ return presetFlag ?? config.preset;
56
+ }
57
+
58
+ export async function loadCLIConfig(
59
+ configPath?: string,
60
+ options?: { content?: string; preset?: string }
61
+ ): Promise<CLIConfig> {
62
+ const resolvedConfigPath = resolveConfigPath(configPath)
63
+ ?? path.join(process.cwd(), 'chronicle.yaml');
64
+
65
+ const raw = await readConfig(resolvedConfigPath);
66
+ const config = validateConfig(raw, resolvedConfigPath);
67
+ const contentDir = resolveContentDir(config, resolvedConfigPath, options?.content);
68
+ const preset = resolvePreset(config, options?.preset);
40
69
 
41
- return { config, configPath, contentDir };
70
+ return { config, configPath: resolvedConfigPath, contentDir, preset };
42
71
  }
@@ -19,26 +19,30 @@ export interface ViteConfigOptions {
19
19
  packageRoot: string;
20
20
  projectRoot: string;
21
21
  contentDir: string;
22
+ configPath?: string;
22
23
  preset?: string;
23
24
  }
24
25
 
25
- async function readChronicleConfig(projectRoot: string, contentDir: string): Promise<string | null> {
26
- for (const dir of [projectRoot, contentDir]) {
27
- const filePath = path.join(dir, 'chronicle.yaml');
26
+ async function readChronicleConfig(projectRoot: string, configPath?: string): Promise<string | null> {
27
+ if (configPath) {
28
28
  try {
29
- return await fs.readFile(filePath, 'utf-8');
30
- } catch {
31
- // not found, try next
29
+ return await fs.readFile(configPath, 'utf-8');
30
+ } catch (error) {
31
+ throw new Error(`Failed to read config file '${configPath}': ${(error as Error).message}`);
32
32
  }
33
33
  }
34
- return null;
34
+ try {
35
+ return await fs.readFile(path.join(projectRoot, 'chronicle.yaml'), 'utf-8');
36
+ } catch {
37
+ return null;
38
+ }
35
39
  }
36
40
 
37
41
  export async function createViteConfig(
38
42
  options: ViteConfigOptions
39
43
  ): Promise<InlineConfig> {
40
- const { packageRoot, projectRoot, contentDir, preset } = options;
41
- const rawConfig = await readChronicleConfig(projectRoot, contentDir);
44
+ const { packageRoot, projectRoot, contentDir, configPath, preset } = options;
45
+ const rawConfig = await readChronicleConfig(projectRoot, configPath);
42
46
 
43
47
  return {
44
48
  root: packageRoot,
@@ -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'
14
2
 
15
- export interface LlmsConfig {
16
- enabled?: boolean
17
- }
3
+ const logoSchema = z.object({
4
+ light: z.string().optional(),
5
+ dark: z.string().optional(),
6
+ })
18
7
 
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
+ })
23
12
 
24
- export interface GoogleAnalyticsConfig {
25
- measurementId: string
26
- }
13
+ const navLinkSchema = z.object({
14
+ label: z.string(),
15
+ href: z.string(),
16
+ })
27
17
 
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
+ })
35
22
 
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
+ })
40
27
 
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
+ })
46
32
 
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
+ })
51
37
 
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
+ })
56
43
 
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
+ })
61
51
 
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
+ })
66
56
 
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
+ })
71
60
 
72
- export interface SearchConfig {
73
- enabled?: boolean
74
- placeholder?: string
75
- }
61
+ const googleAnalyticsSchema = z.object({
62
+ measurementId: z.string(),
63
+ })
76
64
 
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>