@raystack/chronicle 0.5.4 → 0.6.1

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.
Files changed (68) hide show
  1. package/dist/cli/index.js +258 -80
  2. package/package.json +7 -5
  3. package/src/cli/commands/build.ts +5 -8
  4. package/src/cli/commands/dev.ts +5 -6
  5. package/src/cli/commands/init.test.ts +77 -0
  6. package/src/cli/commands/init.ts +73 -40
  7. package/src/cli/commands/serve.ts +6 -9
  8. package/src/cli/commands/start.ts +5 -5
  9. package/src/cli/utils/config.ts +6 -12
  10. package/src/cli/utils/scaffold.test.ts +179 -0
  11. package/src/cli/utils/scaffold.ts +70 -9
  12. package/src/components/api/field-row.tsx +1 -1
  13. package/src/components/api/field-section.tsx +2 -2
  14. package/src/components/mdx/index.tsx +1 -1
  15. package/src/components/ui/breadcrumbs.tsx +4 -2
  16. package/src/components/ui/client-theme-switcher.tsx +21 -4
  17. package/src/components/ui/search.module.css +16 -41
  18. package/src/components/ui/search.tsx +30 -41
  19. package/src/lib/config.test.ts +493 -0
  20. package/src/lib/config.ts +123 -22
  21. package/src/lib/head.tsx +23 -5
  22. package/src/lib/llms.test.ts +94 -0
  23. package/src/lib/llms.ts +41 -0
  24. package/src/lib/navigation.test.ts +94 -0
  25. package/src/lib/navigation.ts +51 -0
  26. package/src/lib/page-context.tsx +51 -32
  27. package/src/lib/route-resolver.test.ts +173 -0
  28. package/src/lib/route-resolver.ts +73 -0
  29. package/src/lib/source.ts +94 -1
  30. package/src/lib/version-source.test.ts +163 -0
  31. package/src/lib/version-source.ts +101 -0
  32. package/src/pages/ApiPage.tsx +1 -1
  33. package/src/pages/DocsLayout.tsx +24 -3
  34. package/src/pages/DocsPage.tsx +3 -6
  35. package/src/pages/LandingPage.module.css +56 -0
  36. package/src/pages/LandingPage.tsx +39 -0
  37. package/src/pages/NotFound.tsx +2 -0
  38. package/src/server/App.tsx +21 -23
  39. package/src/server/api/page.ts +5 -1
  40. package/src/server/api/search.ts +51 -24
  41. package/src/server/api/specs.ts +17 -5
  42. package/src/server/entry-client.tsx +42 -14
  43. package/src/server/entry-server.tsx +33 -11
  44. package/src/server/routes/[...slug].md.ts +0 -6
  45. package/src/server/routes/[version]/llms.txt.ts +26 -0
  46. package/src/server/routes/llms.txt.ts +10 -13
  47. package/src/server/routes/og.tsx +2 -2
  48. package/src/server/routes/sitemap.xml.ts +14 -6
  49. package/src/server/vite-config.ts +5 -5
  50. package/src/themes/default/ContentDirButtons.tsx +66 -0
  51. package/src/themes/default/Layout.module.css +187 -40
  52. package/src/themes/default/Layout.tsx +166 -65
  53. package/src/themes/default/OpenInAI.tsx +112 -0
  54. package/src/themes/default/Page.module.css +30 -0
  55. package/src/themes/default/Page.tsx +1 -3
  56. package/src/themes/default/SidebarLogo.tsx +26 -0
  57. package/src/themes/default/Toc.module.css +102 -25
  58. package/src/themes/default/Toc.tsx +56 -10
  59. package/src/themes/default/VersionSwitcher.tsx +59 -0
  60. package/src/themes/paper/ContentDirDropdown.tsx +47 -0
  61. package/src/themes/paper/Layout.module.css +7 -0
  62. package/src/themes/paper/Layout.tsx +20 -13
  63. package/src/themes/paper/VersionSwitcher.tsx +60 -0
  64. package/src/types/config.ts +145 -23
  65. package/src/types/content.ts +11 -1
  66. package/src/types/theme.ts +1 -0
  67. package/src/components/ui/footer.module.css +0 -27
  68. package/src/components/ui/footer.tsx +0 -30
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
5
+ import { parse } from 'yaml'
6
+ import { chronicleConfigSchema } from '@/types'
7
+ import { runInit } from './init'
8
+
9
+ let tmp: string
10
+
11
+ beforeEach(() => {
12
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'chronicle-init-'))
13
+ })
14
+
15
+ afterEach(() => {
16
+ fs.rmSync(tmp, { recursive: true, force: true })
17
+ })
18
+
19
+ describe('runInit', () => {
20
+ test('scaffolds content/<dir>/, chronicle.yaml and .gitignore from empty', () => {
21
+ const events = runInit(tmp)
22
+ const created = events.filter(e => e.type === 'created').map(e => e.path)
23
+ expect(created).toContain(path.join(tmp, 'content/docs'))
24
+ expect(created).toContain(path.join(tmp, 'chronicle.yaml'))
25
+ expect(created).toContain(path.join(tmp, 'content/docs/index.mdx'))
26
+ expect(created).toContain(path.join(tmp, '.gitignore'))
27
+ })
28
+
29
+ test('emitted chronicle.yaml passes the schema', () => {
30
+ runInit(tmp)
31
+ const raw = fs.readFileSync(path.join(tmp, 'chronicle.yaml'), 'utf-8')
32
+ const parsed = chronicleConfigSchema.parse(parse(raw))
33
+ expect(parsed.site.title).toBe('My Documentation')
34
+ expect(parsed.content).toEqual([{ dir: 'docs', label: 'Docs' }])
35
+ })
36
+
37
+ test('skips chronicle.yaml when it already exists', () => {
38
+ fs.writeFileSync(path.join(tmp, 'chronicle.yaml'), 'site:\n title: Mine\n')
39
+ const events = runInit(tmp)
40
+ const yamlEvent = events.find(e => e.path.endsWith('chronicle.yaml'))
41
+ expect(yamlEvent?.type).toBe('skipped')
42
+ const raw = fs.readFileSync(path.join(tmp, 'chronicle.yaml'), 'utf-8')
43
+ expect(raw).toBe('site:\n title: Mine\n')
44
+ })
45
+
46
+ test('does not overwrite an index.mdx already present in content/docs', () => {
47
+ fs.mkdirSync(path.join(tmp, 'content/docs'), { recursive: true })
48
+ const existing = '---\ntitle: Keep\n---\n# Keep\n'
49
+ fs.writeFileSync(path.join(tmp, 'content/docs/index.mdx'), existing)
50
+ runInit(tmp)
51
+ const contents = fs.readFileSync(
52
+ path.join(tmp, 'content/docs/index.mdx'),
53
+ 'utf-8',
54
+ )
55
+ expect(contents).toBe(existing)
56
+ })
57
+
58
+ test('appends missing entries to an existing .gitignore', () => {
59
+ fs.writeFileSync(path.join(tmp, '.gitignore'), 'node_modules\n')
60
+ const events = runInit(tmp)
61
+ const gitignoreEvent = events.find(e => e.path.endsWith('.gitignore'))
62
+ expect(gitignoreEvent?.type).toBe('updated')
63
+ const contents = fs.readFileSync(path.join(tmp, '.gitignore'), 'utf-8')
64
+ expect(contents).toContain('dist')
65
+ expect(contents).toContain('.output')
66
+ })
67
+
68
+ test('matches .gitignore entries by line, not substring', () => {
69
+ fs.writeFileSync(path.join(tmp, '.gitignore'), 'distribution\n')
70
+ runInit(tmp)
71
+ const contents = fs.readFileSync(path.join(tmp, '.gitignore'), 'utf-8')
72
+ // `distribution` must not satisfy the `dist` requirement
73
+ const lines = contents.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
74
+ expect(lines).toContain('distribution')
75
+ expect(lines).toContain('dist')
76
+ })
77
+ })
@@ -5,9 +5,12 @@ import { Command } from 'commander';
5
5
  import { stringify } from 'yaml';
6
6
  import type { ChronicleConfig } from '@/types';
7
7
 
8
- const defaultConfig: ChronicleConfig = {
9
- title: 'My Documentation',
10
- description: 'Documentation powered by Chronicle',
8
+ export const defaultInitConfig: ChronicleConfig = {
9
+ site: {
10
+ title: 'My Documentation',
11
+ description: 'Documentation powered by Chronicle',
12
+ },
13
+ content: [{ dir: 'docs', label: 'Docs' }],
11
14
  theme: { name: 'default' },
12
15
  search: { enabled: true, placeholder: 'Search documentation...' }
13
16
  };
@@ -23,47 +26,77 @@ order: 1
23
26
  This is your documentation home page.
24
27
  `;
25
28
 
26
- export const initCommand = new Command('init')
27
- .description('Initialize a new Chronicle project')
28
- .option('-c, --content <path>', 'Content directory name', 'content')
29
- .action(options => {
30
- const projectDir = process.cwd();
31
- const contentDir = path.join(projectDir, options.content);
29
+ const GITIGNORE_ENTRIES = ['node_modules', 'dist', '.output'];
32
30
 
33
- if (!fs.existsSync(contentDir)) {
34
- fs.mkdirSync(contentDir, { recursive: true });
35
- console.log(chalk.green('\u2713'), 'Created', contentDir);
36
- }
31
+ export interface InitEvent {
32
+ type: 'created' | 'skipped' | 'updated';
33
+ path: string;
34
+ detail?: string;
35
+ }
37
36
 
38
- const configPath = path.join(projectDir, 'chronicle.yaml');
39
- if (!fs.existsSync(configPath)) {
40
- fs.writeFileSync(configPath, stringify(defaultConfig));
41
- console.log(chalk.green('\u2713'), 'Created', configPath);
42
- } else {
43
- console.log(chalk.yellow('\u26a0'), configPath, 'already exists');
44
- }
37
+ export function runInit(projectDir: string): InitEvent[] {
38
+ const events: InitEvent[] = [];
39
+ const defaultDir = defaultInitConfig.content[0].dir;
40
+ const contentDir = path.join(projectDir, 'content', defaultDir);
45
41
 
46
- const contentFiles = fs.readdirSync(contentDir);
47
- if (contentFiles.length === 0) {
48
- const indexPath = path.join(contentDir, 'index.mdx');
49
- fs.writeFileSync(indexPath, sampleMdx);
50
- console.log(chalk.green('\u2713'), 'Created', indexPath);
51
- }
42
+ if (!fs.existsSync(contentDir)) {
43
+ fs.mkdirSync(contentDir, { recursive: true });
44
+ events.push({ type: 'created', path: contentDir });
45
+ }
46
+
47
+ const configPath = path.join(projectDir, 'chronicle.yaml');
48
+ if (!fs.existsSync(configPath)) {
49
+ fs.writeFileSync(configPath, stringify(defaultInitConfig));
50
+ events.push({ type: 'created', path: configPath });
51
+ } else {
52
+ events.push({ type: 'skipped', path: configPath, detail: 'already exists' });
53
+ }
52
54
 
53
- const gitignorePath = path.join(projectDir, '.gitignore');
54
- const gitignoreEntries = ['node_modules', 'dist', '.output'];
55
- if (fs.existsSync(gitignorePath)) {
56
- const existing = fs.readFileSync(gitignorePath, 'utf-8');
57
- const missing = gitignoreEntries.filter(e => !existing.includes(e));
58
- if (missing.length > 0) {
59
- fs.appendFileSync(gitignorePath, `\n${missing.join('\n')}\n`);
60
- console.log(chalk.green('\u2713'), 'Added', missing.join(', '), 'to .gitignore');
61
- }
62
- } else {
63
- fs.writeFileSync(gitignorePath, `${gitignoreEntries.join('\n')}\n`);
64
- console.log(chalk.green('\u2713'), 'Created .gitignore');
55
+ const contentFiles = fs.readdirSync(contentDir);
56
+ if (contentFiles.length === 0) {
57
+ const indexPath = path.join(contentDir, 'index.mdx');
58
+ fs.writeFileSync(indexPath, sampleMdx);
59
+ events.push({ type: 'created', path: indexPath });
60
+ }
61
+
62
+ const gitignorePath = path.join(projectDir, '.gitignore');
63
+ if (fs.existsSync(gitignorePath)) {
64
+ const existing = fs.readFileSync(gitignorePath, 'utf-8');
65
+ const existingLines = new Set(
66
+ existing.split(/\r?\n/).map(l => l.trim()).filter(Boolean),
67
+ );
68
+ const missing = GITIGNORE_ENTRIES.filter(e => !existingLines.has(e));
69
+ if (missing.length > 0) {
70
+ fs.appendFileSync(gitignorePath, `\n${missing.join('\n')}\n`);
71
+ events.push({ type: 'updated', path: gitignorePath, detail: missing.join(', ') });
65
72
  }
73
+ } else {
74
+ fs.writeFileSync(gitignorePath, `${GITIGNORE_ENTRIES.join('\n')}\n`);
75
+ events.push({ type: 'created', path: gitignorePath });
76
+ }
77
+
78
+ return events;
79
+ }
80
+
81
+ function formatEvent(e: InitEvent): string {
82
+ if (e.type === 'skipped') {
83
+ return `${chalk.yellow('⚠')} ${e.path}${e.detail ? ` ${e.detail}` : ''}`;
84
+ }
85
+ if (e.type === 'updated') {
86
+ return `${chalk.green('✓')} Updated ${e.path}${e.detail ? ` (+${e.detail})` : ''}`;
87
+ }
88
+ return `${chalk.green('✓')} Created ${e.path}`;
89
+ }
66
90
 
67
- console.log(chalk.green('\n\u2713 Chronicle initialized!'));
68
- console.log('\nRun', chalk.cyan('chronicle dev'), 'to start development server');
91
+ export const initCommand = new Command('init')
92
+ .description('Initialize a new Chronicle project')
93
+ .action(() => {
94
+ const events = runInit(process.cwd());
95
+ for (const e of events) console.log(formatEvent(e));
96
+ console.log(chalk.green('\n✓ Chronicle initialized!'));
97
+ console.log(
98
+ '\nRun',
99
+ chalk.cyan('chronicle dev'),
100
+ 'to start development server',
101
+ );
69
102
  });
@@ -7,7 +7,6 @@ import { linkContent } from '@/cli/utils/scaffold';
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('--content <path>', 'Content directory')
11
10
  .option('--config <path>', 'Path to chronicle.yaml')
12
11
  .option('--host <host>', 'Host address', 'localhost')
13
12
  .option(
@@ -15,30 +14,28 @@ export const serveCommand = new Command('serve')
15
14
  'Deploy preset (vercel, cloudflare, node-server)'
16
15
  )
17
16
  .action(async options => {
18
- const { contentDir, configPath, preset } = await loadCLIConfig(options.config, {
19
- content: options.content,
17
+ const { config, projectRoot, configPath, preset } = await loadCLIConfig(options.config, {
20
18
  preset: options.preset,
21
19
  });
22
20
  const port = parseInt(options.port, 10);
23
- await linkContent(contentDir);
21
+ await linkContent(projectRoot, config);
24
22
 
25
23
  const { build, preview } = await import('vite');
26
24
  const { createViteConfig } = await import('@/server/vite-config');
27
25
 
28
- const config = await createViteConfig({
26
+ const viteConfig = await createViteConfig({
29
27
  packageRoot: PACKAGE_ROOT,
30
- projectRoot: process.cwd(),
31
- contentDir,
28
+ projectRoot,
32
29
  configPath,
33
30
  preset
34
31
  });
35
32
 
36
33
  console.log(chalk.cyan('Building for production...'));
37
- await build(config);
34
+ await build(viteConfig);
38
35
 
39
36
  console.log(chalk.cyan('Starting production server...'));
40
37
  const server = await preview({
41
- ...config,
38
+ ...viteConfig,
42
39
  preview: { port, host: options.host }
43
40
  });
44
41
 
@@ -7,21 +7,21 @@ import { linkContent } from '@/cli/utils/scaffold';
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('--content <path>', 'Content directory')
10
+ .option('--config <path>', 'Path to chronicle.yaml')
11
11
  .option('--host <host>', 'Host address', 'localhost')
12
12
  .action(async options => {
13
- const { contentDir, configPath } = await loadCLIConfig(undefined, { content: options.content });
13
+ const { config, projectRoot, configPath } = await loadCLIConfig(options.config);
14
14
  const port = parseInt(options.port, 10);
15
- await linkContent(contentDir);
15
+ await linkContent(projectRoot, config);
16
16
 
17
17
  console.log(chalk.cyan('Starting production server...'));
18
18
 
19
19
  const { preview } = await import('vite');
20
20
  const { createViteConfig } = await import('@/server/vite-config');
21
21
 
22
- const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir, configPath });
22
+ const viteConfig = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot, configPath });
23
23
  const server = await preview({
24
- ...config,
24
+ ...viteConfig,
25
25
  preview: { port, host: options.host }
26
26
  });
27
27
 
@@ -7,7 +7,7 @@ import { chronicleConfigSchema, type ChronicleConfig } from '@/types';
7
7
  export interface CLIConfig {
8
8
  config: ChronicleConfig;
9
9
  configPath: string;
10
- contentDir: string;
10
+ projectRoot: string;
11
11
  preset?: string;
12
12
  }
13
13
 
@@ -36,8 +36,8 @@ function validateConfig(raw: string, configPath: string): ChronicleConfig {
36
36
  if (!result.success) {
37
37
  console.log(chalk.red(`Error: Invalid chronicle.yaml at '${configPath}'`));
38
38
  for (const issue of result.error.issues) {
39
- const path = issue.path.join('.');
40
- console.log(chalk.gray(` ${path ? `${path}: ` : ''}${issue.message}`));
39
+ const issuePath = issue.path.join('.');
40
+ console.log(chalk.gray(` ${issuePath ? `${issuePath}: ` : ''}${issue.message}`));
41
41
  }
42
42
  process.exit(1);
43
43
  }
@@ -45,27 +45,21 @@ function validateConfig(raw: string, configPath: string): ChronicleConfig {
45
45
  return result.data;
46
46
  }
47
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
48
  export function resolvePreset(config: ChronicleConfig, presetFlag?: string): string | undefined {
55
49
  return presetFlag ?? config.preset;
56
50
  }
57
51
 
58
52
  export async function loadCLIConfig(
59
53
  configPath?: string,
60
- options?: { content?: string; preset?: string }
54
+ options?: { preset?: string }
61
55
  ): Promise<CLIConfig> {
62
56
  const resolvedConfigPath = resolveConfigPath(configPath)
63
57
  ?? path.join(process.cwd(), 'chronicle.yaml');
64
58
 
65
59
  const raw = await readConfig(resolvedConfigPath);
66
60
  const config = validateConfig(raw, resolvedConfigPath);
67
- const contentDir = resolveContentDir(config, resolvedConfigPath, options?.content);
61
+ const projectRoot = path.dirname(resolvedConfigPath);
68
62
  const preset = resolvePreset(config, options?.preset);
69
63
 
70
- return { config, configPath: resolvedConfigPath, contentDir, preset };
64
+ return { config, configPath: resolvedConfigPath, projectRoot, preset };
71
65
  }
@@ -0,0 +1,179 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
5
+ import { chronicleConfigSchema } from '@/types'
6
+ import { buildContentMirror } from './scaffold'
7
+
8
+ let tmp: string
9
+ let projectRoot: string
10
+ let mirrorRoot: string
11
+
12
+ async function seedContent(relPath: string, file = 'index.mdx'): Promise<void> {
13
+ const dir = path.join(projectRoot, relPath)
14
+ await fs.mkdir(dir, { recursive: true })
15
+ await fs.writeFile(path.join(dir, file), `---\ntitle: ${relPath}\n---\n`)
16
+ }
17
+
18
+ async function isDir(p: string): Promise<boolean> {
19
+ return (await fs.lstat(p)).isDirectory()
20
+ }
21
+
22
+ async function fileSymlinkTarget(p: string): Promise<string> {
23
+ const st = await fs.lstat(p)
24
+ expect(st.isSymbolicLink()).toBe(true)
25
+ return fs.readlink(p)
26
+ }
27
+
28
+ beforeEach(async () => {
29
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'chronicle-scaffold-'))
30
+ projectRoot = path.join(tmp, 'project')
31
+ mirrorRoot = path.join(tmp, 'mirror')
32
+ await fs.mkdir(projectRoot, { recursive: true })
33
+ })
34
+
35
+ afterEach(async () => {
36
+ await fs.rm(tmp, { recursive: true, force: true })
37
+ })
38
+
39
+ describe('buildContentMirror', () => {
40
+ test('single-content latest: mirrors as real dirs with per-file symlinks', async () => {
41
+ await seedContent('content/docs', 'index.mdx')
42
+ await seedContent('content/docs', 'guide.mdx')
43
+ const config = chronicleConfigSchema.parse({
44
+ site: { title: 'x' },
45
+ content: [{ dir: 'docs', label: 'Docs' }],
46
+ })
47
+
48
+ await buildContentMirror(mirrorRoot, projectRoot, config)
49
+
50
+ expect(await isDir(path.join(mirrorRoot, 'docs'))).toBe(true)
51
+ expect(await fileSymlinkTarget(path.join(mirrorRoot, 'docs/index.mdx'))).toBe(
52
+ path.join(projectRoot, 'content/docs/index.mdx'),
53
+ )
54
+ expect(await fileSymlinkTarget(path.join(mirrorRoot, 'docs/guide.mdx'))).toBe(
55
+ path.join(projectRoot, 'content/docs/guide.mdx'),
56
+ )
57
+ })
58
+
59
+ test('preserves nested subdirectories via recursive mirror', async () => {
60
+ await seedContent('content/docs/guides', 'install.mdx')
61
+ const config = chronicleConfigSchema.parse({
62
+ site: { title: 'x' },
63
+ content: [{ dir: 'docs', label: 'Docs' }],
64
+ })
65
+
66
+ await buildContentMirror(mirrorRoot, projectRoot, config)
67
+
68
+ const nested = path.join(mirrorRoot, 'docs/guides/install.mdx')
69
+ expect(await fileSymlinkTarget(nested)).toBe(
70
+ path.join(projectRoot, 'content/docs/guides/install.mdx'),
71
+ )
72
+ expect(await isDir(path.join(mirrorRoot, 'docs/guides'))).toBe(true)
73
+ })
74
+
75
+ test('multi-content latest produces one real dir per content entry', async () => {
76
+ await seedContent('content/docs', 'index.mdx')
77
+ await seedContent('content/dev', 'index.mdx')
78
+ const config = chronicleConfigSchema.parse({
79
+ site: { title: 'x' },
80
+ content: [
81
+ { dir: 'docs', label: 'Docs' },
82
+ { dir: 'dev', label: 'Dev' },
83
+ ],
84
+ })
85
+
86
+ await buildContentMirror(mirrorRoot, projectRoot, config)
87
+
88
+ expect(await isDir(path.join(mirrorRoot, 'docs'))).toBe(true)
89
+ expect(await isDir(path.join(mirrorRoot, 'dev'))).toBe(true)
90
+ expect(
91
+ await fileSymlinkTarget(path.join(mirrorRoot, 'dev/index.mdx')),
92
+ ).toBe(path.join(projectRoot, 'content/dev/index.mdx'))
93
+ })
94
+
95
+ test('versioned mirror nests version dir then content dir', async () => {
96
+ await seedContent('content/docs', 'index.mdx')
97
+ await seedContent('versions/v1/docs', 'index.mdx')
98
+ await seedContent('versions/v1/dev', 'api.mdx')
99
+ const config = chronicleConfigSchema.parse({
100
+ site: { title: 'x' },
101
+ content: [{ dir: 'docs', label: 'Docs' }],
102
+ latest: { label: '2.0' },
103
+ versions: [
104
+ {
105
+ dir: 'v1',
106
+ label: '1.0',
107
+ content: [
108
+ { dir: 'docs', label: 'Docs' },
109
+ { dir: 'dev', label: 'Dev' },
110
+ ],
111
+ },
112
+ ],
113
+ })
114
+
115
+ await buildContentMirror(mirrorRoot, projectRoot, config)
116
+
117
+ expect(await isDir(path.join(mirrorRoot, 'v1/docs'))).toBe(true)
118
+ expect(
119
+ await fileSymlinkTarget(path.join(mirrorRoot, 'v1/docs/index.mdx')),
120
+ ).toBe(path.join(projectRoot, 'versions/v1/docs/index.mdx'))
121
+ expect(
122
+ await fileSymlinkTarget(path.join(mirrorRoot, 'v1/dev/api.mdx')),
123
+ ).toBe(path.join(projectRoot, 'versions/v1/dev/api.mdx'))
124
+ })
125
+
126
+ test('is idempotent — re-running yields the same tree', async () => {
127
+ await seedContent('content/docs', 'index.mdx')
128
+ const config = chronicleConfigSchema.parse({
129
+ site: { title: 'x' },
130
+ content: [{ dir: 'docs', label: 'Docs' }],
131
+ })
132
+
133
+ await buildContentMirror(mirrorRoot, projectRoot, config)
134
+ await buildContentMirror(mirrorRoot, projectRoot, config)
135
+
136
+ expect(
137
+ await fileSymlinkTarget(path.join(mirrorRoot, 'docs/index.mdx')),
138
+ ).toBe(path.join(projectRoot, 'content/docs/index.mdx'))
139
+ })
140
+
141
+ test('wipes stale entries when config shrinks', async () => {
142
+ await seedContent('content/docs', 'index.mdx')
143
+ await seedContent('content/dev', 'index.mdx')
144
+ const before = chronicleConfigSchema.parse({
145
+ site: { title: 'x' },
146
+ content: [
147
+ { dir: 'docs', label: 'Docs' },
148
+ { dir: 'dev', label: 'Dev' },
149
+ ],
150
+ })
151
+
152
+ await buildContentMirror(mirrorRoot, projectRoot, before)
153
+ expect((await fs.readdir(mirrorRoot)).sort()).toEqual(['dev', 'docs'])
154
+
155
+ const after = chronicleConfigSchema.parse({
156
+ site: { title: 'x' },
157
+ content: [{ dir: 'docs', label: 'Docs' }],
158
+ })
159
+ await buildContentMirror(mirrorRoot, projectRoot, after)
160
+
161
+ expect(await fs.readdir(mirrorRoot)).toEqual(['docs'])
162
+ })
163
+
164
+ test('replaces a legacy single-symlink mirror', async () => {
165
+ await seedContent('content/docs', 'index.mdx')
166
+ await fs.symlink(path.join(projectRoot, 'content/docs'), mirrorRoot)
167
+
168
+ const config = chronicleConfigSchema.parse({
169
+ site: { title: 'x' },
170
+ content: [{ dir: 'docs', label: 'Docs' }],
171
+ })
172
+ await buildContentMirror(mirrorRoot, projectRoot, config)
173
+
174
+ expect(await isDir(mirrorRoot)).toBe(true)
175
+ expect(
176
+ await fileSymlinkTarget(path.join(mirrorRoot, 'docs/index.mdx')),
177
+ ).toBe(path.join(projectRoot, 'content/docs/index.mdx'))
178
+ })
179
+ })
@@ -1,18 +1,79 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import type { ChronicleConfig } from '@/types';
4
+ import { getLatestContentRoots, getVersionContentRoots } from '@/lib/config';
3
5
  import { PACKAGE_ROOT } from './resolve';
4
6
 
5
- export async function linkContent(contentDir: string): Promise<void> {
6
- const linkPath = path.join(PACKAGE_ROOT, '.content');
7
- const target = path.resolve(contentDir);
7
+ export async function buildContentMirror(
8
+ mirrorRoot: string,
9
+ projectRoot: string,
10
+ config: ChronicleConfig,
11
+ ): Promise<void> {
12
+ await removeMirror(mirrorRoot);
13
+ await fs.mkdir(mirrorRoot, { recursive: true });
8
14
 
15
+ for (const root of getLatestContentRoots(config)) {
16
+ const source = path.resolve(projectRoot, root.fsPath);
17
+ const dest = path.join(mirrorRoot, root.contentDir);
18
+ await mirrorTree(source, dest);
19
+ }
20
+
21
+ for (const version of config.versions ?? []) {
22
+ const versionMirror = path.join(mirrorRoot, version.dir);
23
+ await fs.mkdir(versionMirror, { recursive: true });
24
+
25
+ for (const root of getVersionContentRoots(config, version.dir)) {
26
+ const source = path.resolve(projectRoot, root.fsPath);
27
+ const dest = path.join(versionMirror, root.contentDir);
28
+ await mirrorTree(source, dest);
29
+ }
30
+ }
31
+ }
32
+
33
+ export function linkContent(
34
+ projectRoot: string,
35
+ config: ChronicleConfig,
36
+ ): Promise<void> {
37
+ return buildContentMirror(
38
+ path.join(PACKAGE_ROOT, '.content'),
39
+ projectRoot,
40
+ config,
41
+ );
42
+ }
43
+
44
+ async function mirrorTree(source: string, dest: string): Promise<void> {
45
+ let entries: import('node:fs').Dirent[];
9
46
  try {
10
- const existing = await fs.readlink(linkPath);
11
- if (existing === target) return;
12
- await fs.unlink(linkPath);
13
- } catch {
14
- // link doesn't exist
47
+ entries = await fs.readdir(source, { withFileTypes: true });
48
+ } catch (error) {
49
+ const err = error as NodeJS.ErrnoException;
50
+ if (err.code === 'ENOENT') {
51
+ throw new Error(`Content directory not found: ${source}`);
52
+ }
53
+ throw error;
54
+ }
55
+ await fs.mkdir(dest, { recursive: true });
56
+ for (const entry of entries) {
57
+ const sourcePath = path.join(source, entry.name);
58
+ const destPath = path.join(dest, entry.name);
59
+ if (entry.isDirectory()) {
60
+ await mirrorTree(sourcePath, destPath);
61
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
62
+ await fs.symlink(sourcePath, destPath);
63
+ }
15
64
  }
65
+ }
16
66
 
17
- await fs.symlink(target, linkPath);
67
+ async function removeMirror(mirrorRoot: string): Promise<void> {
68
+ try {
69
+ const stat = await fs.lstat(mirrorRoot);
70
+ if (stat.isSymbolicLink() || stat.isFile()) {
71
+ await fs.unlink(mirrorRoot);
72
+ } else if (stat.isDirectory()) {
73
+ await fs.rm(mirrorRoot, { recursive: true, force: true });
74
+ }
75
+ } catch (error) {
76
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
77
+ throw error;
78
+ }
18
79
  }
@@ -30,7 +30,7 @@ export function FieldRow({ field, location, editable, value, onChange }: FieldRo
30
30
  const objValue = (value ?? {}) as Record<string, unknown>
31
31
  return (
32
32
  <div className={styles.row}>
33
- <Accordion collapsible className={styles.accordion}>
33
+ <Accordion className={styles.accordion}>
34
34
  <Accordion.Item value={field.name}>
35
35
  <Accordion.Trigger className={styles.trigger}>{label}</Accordion.Trigger>
36
36
  <Accordion.Content>
@@ -57,8 +57,8 @@ export function FieldSection({
57
57
  <div className={styles.separator} />
58
58
  <Tabs defaultValue="fields" className={styles.tabs}>
59
59
  <Tabs.List>
60
- <Tabs.Trigger value="fields">Fields</Tabs.Trigger>
61
- <Tabs.Trigger value="json">JSON</Tabs.Trigger>
60
+ <Tabs.Tab value="fields">Fields</Tabs.Tab>
61
+ <Tabs.Tab value="json">JSON</Tabs.Tab>
62
62
  </Tabs.List>
63
63
  <Tabs.Content value="fields">
64
64
  {fieldsContent}
@@ -20,7 +20,7 @@ function MdxTabs(props: ComponentProps<typeof Tabs>) {
20
20
  return <ClientOnly><Tabs {...props} /></ClientOnly>
21
21
  }
22
22
  MdxTabs.List = Tabs.List
23
- MdxTabs.Trigger = Tabs.Trigger
23
+ MdxTabs.Tab = Tabs.Tab
24
24
  MdxTabs.Content = Tabs.Content
25
25
 
26
26
  export const mdxComponents: MDXComponents = {
@@ -3,6 +3,7 @@
3
3
  import { Breadcrumb } from '@raystack/apsara'
4
4
  import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb'
5
5
  import type { Root } from 'fumadocs-core/page-tree'
6
+ import { Link as RouterLink } from 'react-router'
6
7
 
7
8
  interface BreadcrumbsProps {
8
9
  slug: string[]
@@ -18,11 +19,12 @@ export function Breadcrumbs({ slug, tree }: BreadcrumbsProps) {
18
19
  return (
19
20
  <Breadcrumb size="small">
20
21
  {items.flatMap((item, index) => {
22
+ const isCurrent = index === items.length - 1
21
23
  const breadcrumbItem = (
22
24
  <Breadcrumb.Item
23
25
  key={`item-${index}`}
24
- href={item.url}
25
- current={index === items.length - 1}
26
+ current={isCurrent}
27
+ render={isCurrent ? undefined : <RouterLink to={item.url} />}
26
28
  >
27
29
  {item.name}
28
30
  </Breadcrumb.Item>