@raystack/chronicle 0.5.4 → 0.6.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 +258 -80
- package/package.json +8 -6
- package/src/cli/commands/build.ts +5 -8
- package/src/cli/commands/dev.ts +5 -6
- package/src/cli/commands/init.test.ts +77 -0
- package/src/cli/commands/init.ts +73 -40
- package/src/cli/commands/serve.ts +6 -9
- package/src/cli/commands/start.ts +5 -5
- package/src/cli/utils/config.ts +6 -12
- package/src/cli/utils/scaffold.test.ts +179 -0
- package/src/cli/utils/scaffold.ts +70 -9
- package/src/components/api/field-row.tsx +1 -1
- package/src/components/api/field-section.tsx +2 -2
- package/src/components/mdx/index.tsx +1 -1
- package/src/components/mdx/mermaid.tsx +24 -21
- package/src/components/ui/breadcrumbs.tsx +4 -2
- package/src/components/ui/client-theme-switcher.tsx +21 -4
- package/src/components/ui/search.module.css +16 -41
- package/src/components/ui/search.tsx +30 -41
- package/src/lib/config.test.ts +493 -0
- package/src/lib/config.ts +123 -22
- package/src/lib/head.tsx +23 -5
- package/src/lib/llms.test.ts +94 -0
- package/src/lib/llms.ts +41 -0
- package/src/lib/navigation.test.ts +94 -0
- package/src/lib/navigation.ts +51 -0
- package/src/lib/page-context.tsx +51 -32
- package/src/lib/route-resolver.test.ts +173 -0
- package/src/lib/route-resolver.ts +73 -0
- package/src/lib/source.ts +94 -1
- package/src/lib/version-source.test.ts +163 -0
- package/src/lib/version-source.ts +101 -0
- package/src/pages/ApiPage.tsx +1 -1
- package/src/pages/DocsLayout.tsx +24 -3
- package/src/pages/DocsPage.tsx +3 -6
- package/src/pages/LandingPage.module.css +56 -0
- package/src/pages/LandingPage.tsx +39 -0
- package/src/pages/NotFound.tsx +2 -0
- package/src/server/App.tsx +21 -23
- package/src/server/api/page.ts +5 -1
- package/src/server/api/search.ts +51 -24
- package/src/server/api/specs.ts +17 -5
- package/src/server/entry-client.tsx +42 -14
- package/src/server/entry-server.tsx +33 -11
- package/src/server/routes/[...slug].md.ts +0 -6
- package/src/server/routes/[version]/llms.txt.ts +26 -0
- package/src/server/routes/llms.txt.ts +10 -13
- package/src/server/routes/og.tsx +2 -2
- package/src/server/routes/sitemap.xml.ts +14 -6
- package/src/server/vite-config.ts +5 -5
- package/src/themes/default/ContentDirButtons.tsx +66 -0
- package/src/themes/default/Layout.module.css +187 -40
- package/src/themes/default/Layout.tsx +166 -65
- package/src/themes/default/OpenInAI.tsx +112 -0
- package/src/themes/default/Page.module.css +30 -0
- package/src/themes/default/Page.tsx +1 -3
- package/src/themes/default/SidebarLogo.tsx +26 -0
- package/src/themes/default/Toc.module.css +102 -25
- package/src/themes/default/Toc.tsx +56 -10
- package/src/themes/default/VersionSwitcher.tsx +59 -0
- package/src/themes/paper/ContentDirDropdown.tsx +47 -0
- package/src/themes/paper/Layout.module.css +7 -0
- package/src/themes/paper/Layout.tsx +20 -13
- package/src/themes/paper/VersionSwitcher.tsx +60 -0
- package/src/types/config.ts +145 -23
- package/src/types/content.ts +11 -1
- package/src/types/theme.ts +1 -0
- package/src/components/ui/footer.module.css +0 -27
- 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
|
+
})
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -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
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
export interface InitEvent {
|
|
32
|
+
type: 'created' | 'skipped' | 'updated';
|
|
33
|
+
path: string;
|
|
34
|
+
detail?: string;
|
|
35
|
+
}
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
26
|
+
const viteConfig = await createViteConfig({
|
|
29
27
|
packageRoot: PACKAGE_ROOT,
|
|
30
|
-
projectRoot
|
|
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(
|
|
34
|
+
await build(viteConfig);
|
|
38
35
|
|
|
39
36
|
console.log(chalk.cyan('Starting production server...'));
|
|
40
37
|
const server = await preview({
|
|
41
|
-
...
|
|
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('--
|
|
10
|
+
.option('--config <path>', 'Path to chronicle.yaml')
|
|
11
11
|
.option('--host <host>', 'Host address', 'localhost')
|
|
12
12
|
.action(async options => {
|
|
13
|
-
const {
|
|
13
|
+
const { config, projectRoot, configPath } = await loadCLIConfig(options.config);
|
|
14
14
|
const port = parseInt(options.port, 10);
|
|
15
|
-
await linkContent(
|
|
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
|
|
22
|
+
const viteConfig = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot, configPath });
|
|
23
23
|
const server = await preview({
|
|
24
|
-
...
|
|
24
|
+
...viteConfig,
|
|
25
25
|
preview: { port, host: options.host }
|
|
26
26
|
});
|
|
27
27
|
|
package/src/cli/utils/config.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { chronicleConfigSchema, type ChronicleConfig } from '@/types';
|
|
|
7
7
|
export interface CLIConfig {
|
|
8
8
|
config: ChronicleConfig;
|
|
9
9
|
configPath: string;
|
|
10
|
-
|
|
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
|
|
40
|
-
console.log(chalk.gray(` ${
|
|
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?: {
|
|
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
|
|
61
|
+
const projectRoot = path.dirname(resolvedConfigPath);
|
|
68
62
|
const preset = resolvePreset(config, options?.preset);
|
|
69
63
|
|
|
70
|
-
return { config, configPath: resolvedConfigPath,
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
61
|
-
<Tabs.
|
|
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.
|
|
23
|
+
MdxTabs.Tab = Tabs.Tab
|
|
24
24
|
MdxTabs.Content = Tabs.Content
|
|
25
25
|
|
|
26
26
|
export const mdxComponents: MDXComponents = {
|