From 8822ecd67fc9b764b63c49b3b4436358b8d23ff8 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 10:05:14 +0530 Subject: [PATCH 01/55] feat: add versioned multi-content config schema Rewrite chronicleConfigSchema for multi-content + versioning: - site.title replaces top-level title - content is now {dir,label}[] (single string form removed) - latest + versions[] with per-version content, api, and badge - badge variant maps to Apsara Badge color prop - strict root + dir-uniqueness refines Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/types/config.ts | 104 +++++++++++++++++++++---- 1 file changed, 91 insertions(+), 13 deletions(-) diff --git a/packages/chronicle/src/types/config.ts b/packages/chronicle/src/types/config.ts index f28a322..9be8fe5 100644 --- a/packages/chronicle/src/types/config.ts +++ b/packages/chronicle/src/types/config.ts @@ -73,24 +73,102 @@ const telemetrySchema = z.object({ port: z.number().int().min(1).max(65535).default(9090), }) -export const chronicleConfigSchema = z.object({ +const siteSchema = z.object({ title: z.string(), - description: z.string().optional(), - url: z.string().optional(), - content: z.string().optional(), - preset: z.string().optional(), - logo: logoSchema.optional(), - theme: themeSchema.optional(), - navigation: navigationSchema.optional(), - search: searchSchema.optional(), - footer: footerSchema.optional(), +}) + +const contentEntrySchema = z.object({ + dir: z.string().min(1), + label: z.string().min(1), +}) + +// Variants map to Apsara Badge color prop. +// https://apsara.raystack.org/docs/components/badge +const badgeVariantSchema = z.enum([ + 'accent', + 'warning', + 'danger', + 'success', + 'neutral', + 'gradient', +]) + +const badgeSchema = z.object({ + label: z.string().min(1), + variant: badgeVariantSchema.default('accent'), +}) + +const latestSchema = z.object({ + label: z.string().min(1), +}) + +const versionSchema = z.object({ + dir: z.string().min(1), + label: z.string().min(1), + badge: badgeSchema.optional(), + content: z.array(contentEntrySchema).min(1), api: z.array(apiSchema).optional(), - llms: llmsSchema.optional(), - analytics: analyticsSchema.optional(), - telemetry: telemetrySchema.optional(), }) +const uniqueBy = (items: T[], key: (item: T) => string): boolean => { + const seen = new Set() + for (const item of items) { + const k = key(item) + if (seen.has(k)) return false + seen.add(k) + } + return true +} + +export const chronicleConfigSchema = z + .object({ + site: siteSchema, + description: z.string().optional(), + url: z.string().optional(), + content: z.array(contentEntrySchema).min(1), + latest: latestSchema.optional(), + versions: z.array(versionSchema).optional(), + preset: z.string().optional(), + logo: logoSchema.optional(), + theme: themeSchema.optional(), + navigation: navigationSchema.optional(), + search: searchSchema.optional(), + footer: footerSchema.optional(), + api: z.array(apiSchema).optional(), + llms: llmsSchema.optional(), + analytics: analyticsSchema.optional(), + telemetry: telemetrySchema.optional(), + }) + .strict() + .refine((cfg) => uniqueBy(cfg.content, (c) => c.dir), { + message: 'content[].dir must be unique', + path: ['content'], + }) + .refine((cfg) => !cfg.versions || uniqueBy(cfg.versions, (v) => v.dir), { + message: 'versions[].dir must be unique', + path: ['versions'], + }) + .refine( + (cfg) => + !cfg.versions || + cfg.versions.every((v) => uniqueBy(v.content, (c) => c.dir)), + { + message: 'versions[].content[].dir must be unique within each version', + path: ['versions'], + }, + ) + .refine((cfg) => !cfg.versions || cfg.versions.length === 0 || !!cfg.latest, { + message: 'latest is required when versions are declared', + path: ['latest'], + }) + export type ChronicleConfig = z.infer +export type SiteConfig = z.infer +export type ContentEntry = z.infer +export type BadgeConfig = z.infer +export type BadgeVariant = z.infer +export type LatestConfig = z.infer +export type VersionConfig = z.infer export type LogoConfig = z.infer export type ThemeConfig = z.infer export type NavigationConfig = z.infer From 223a75f3f2d528870172c5c57128fe9ea2f513a0 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 10:07:02 +0530 Subject: [PATCH 02/55] feat: rewrite config loader for multi-content + versions - Validate via chronicleConfigSchema.parse instead of ad-hoc spread - Default config uses new {site, content[]} shape - Add helpers: getLatestContentRoots, getVersionContentRoots, getAllVersions - ContentRoot resolves fs path (content/ or versions//) and URL prefix Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/config.ts | 104 ++++++++++++++++++++------- 1 file changed, 80 insertions(+), 24 deletions(-) diff --git a/packages/chronicle/src/lib/config.ts b/packages/chronicle/src/lib/config.ts index 8237c64..1b1cbbb 100644 --- a/packages/chronicle/src/lib/config.ts +++ b/packages/chronicle/src/lib/config.ts @@ -1,32 +1,88 @@ -import { parse } from 'yaml'; -import type { ChronicleConfig } from '@/types'; +import path from 'node:path' +import { parse } from 'yaml' +import { + type BadgeConfig, + type ChronicleConfig, + chronicleConfigSchema, +} from '@/types' -const defaultConfig: ChronicleConfig = { - title: 'Documentation', +const defaultConfig: ChronicleConfig = chronicleConfigSchema.parse({ + site: { title: 'Documentation' }, + content: [{ dir: 'docs', label: 'Docs' }], theme: { name: 'default' }, - search: { enabled: true, placeholder: 'Search...' } -}; + search: { enabled: true, placeholder: 'Search...' }, +}) export function loadConfig(): ChronicleConfig { - const raw = typeof __CHRONICLE_CONFIG_RAW__ !== 'undefined' ? __CHRONICLE_CONFIG_RAW__ : null; + const raw = + typeof __CHRONICLE_CONFIG_RAW__ !== 'undefined' + ? __CHRONICLE_CONFIG_RAW__ + : null - if (!raw) { - return defaultConfig; + if (!raw) return defaultConfig + + return chronicleConfigSchema.parse(parse(raw)) +} + +export interface ContentRoot { + versionDir: string | null + versionLabel: string | null + contentDir: string + contentLabel: string + fsPath: string + urlPrefix: string +} + +export function getLatestContentRoots(config: ChronicleConfig): ContentRoot[] { + return config.content.map((c) => ({ + versionDir: null, + versionLabel: config.latest?.label ?? null, + contentDir: c.dir, + contentLabel: c.label, + fsPath: path.join('content', c.dir), + urlPrefix: `/${c.dir}`, + })) +} + +export function getVersionContentRoots( + config: ChronicleConfig, + versionDir: string, +): ContentRoot[] { + const version = config.versions?.find((v) => v.dir === versionDir) + if (!version) return [] + + return version.content.map((c) => ({ + versionDir: version.dir, + versionLabel: version.label, + contentDir: c.dir, + contentLabel: c.label, + fsPath: path.join('versions', version.dir, c.dir), + urlPrefix: `/${version.dir}/${c.dir}`, + })) +} + +export interface VersionDescriptor { + dir: string | null + label: string + badge?: BadgeConfig + isLatest: boolean +} + +export function getAllVersions(config: ChronicleConfig): VersionDescriptor[] { + const result: VersionDescriptor[] = [] + + if (config.latest) { + result.push({ dir: null, label: config.latest.label, isLatest: true }) + } + + for (const v of config.versions ?? []) { + result.push({ + dir: v.dir, + label: v.label, + badge: v.badge, + isLatest: false, + }) } - const userConfig = parse(raw) as Partial; - - return { - ...defaultConfig, - ...userConfig, - theme: { - name: userConfig.theme?.name ?? defaultConfig.theme!.name, - colors: { ...defaultConfig.theme?.colors, ...userConfig.theme?.colors } - }, - search: { ...defaultConfig.search, ...userConfig.search }, - footer: userConfig.footer, - api: userConfig.api, - llms: { enabled: false, ...userConfig.llms }, - analytics: { enabled: false, ...userConfig.analytics } - }; + return result } From 65f33219e3aa543807085574a8504d66bbe7e78e Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 10:08:17 +0530 Subject: [PATCH 03/55] chore: add test script using bun's built-in runner Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json index 48b40e9..56afe7e 100644 --- a/packages/chronicle/package.json +++ b/packages/chronicle/package.json @@ -16,7 +16,8 @@ }, "scripts": { "build:cli": "bun build-cli.ts", - "lint": "biome lint src/" + "lint": "biome lint src/", + "test": "bun test" }, "devDependencies": { "@biomejs/biome": "^2.3.13", From bfa17f3072a388148f96d1224c496a986cc22ec4 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 10:09:19 +0530 Subject: [PATCH 04/55] test: cover config schema + loader helpers - Validates parse rules: required site, non-empty content[], strict root - Rejects legacy title, content:string, versions-without-latest, duplicate dirs - Covers getLatestContentRoots, getVersionContentRoots, getAllVersions order - Covers loadConfig fallback + yaml parsing via __CHRONICLE_CONFIG_RAW__ Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/config.test.ts | 317 ++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 packages/chronicle/src/lib/config.test.ts diff --git a/packages/chronicle/src/lib/config.test.ts b/packages/chronicle/src/lib/config.test.ts new file mode 100644 index 0000000..af24032 --- /dev/null +++ b/packages/chronicle/src/lib/config.test.ts @@ -0,0 +1,317 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { chronicleConfigSchema } from '@/types' +import { + getAllVersions, + getLatestContentRoots, + getVersionContentRoots, + loadConfig, +} from './config' + +type GlobalWithRaw = typeof globalThis & { + __CHRONICLE_CONFIG_RAW__?: string | null +} + +const g = globalThis as GlobalWithRaw + +const minimal = { + site: { title: 'My Docs' }, + content: [{ dir: 'docs', label: 'Docs' }], +} + +describe('chronicleConfigSchema', () => { + test('parses minimal single-content config', () => { + const parsed = chronicleConfigSchema.parse(minimal) + expect(parsed.site.title).toBe('My Docs') + expect(parsed.content).toEqual([{ dir: 'docs', label: 'Docs' }]) + }) + + test('parses multi-content config', () => { + const parsed = chronicleConfigSchema.parse({ + ...minimal, + content: [ + { dir: 'docs', label: 'Docs' }, + { dir: 'dev', label: 'Dev Docs' }, + ], + }) + expect(parsed.content).toHaveLength(2) + }) + + test('parses versioned config with badge and defaults variant to accent', () => { + const parsed = chronicleConfigSchema.parse({ + ...minimal, + latest: { label: '3.0' }, + versions: [ + { + dir: 'v1', + label: '1.0', + badge: { label: 'deprecated' }, + content: [{ dir: 'docs', label: 'Docs' }], + }, + ], + }) + expect(parsed.versions?.[0].badge).toEqual({ + label: 'deprecated', + variant: 'accent', + }) + }) + + test('accepts explicit badge variant', () => { + const parsed = chronicleConfigSchema.parse({ + ...minimal, + latest: { label: '3.0' }, + versions: [ + { + dir: 'v1', + label: '1.0', + badge: { label: 'deprecated', variant: 'warning' }, + content: [{ dir: 'docs', label: 'Docs' }], + }, + ], + }) + expect(parsed.versions?.[0].badge?.variant).toBe('warning') + }) + + test('rejects unknown top-level field (legacy title)', () => { + expect(() => + chronicleConfigSchema.parse({ ...minimal, title: 'My Docs' }), + ).toThrow() + }) + + test('rejects string form of content', () => { + expect(() => + chronicleConfigSchema.parse({ site: { title: 'x' }, content: '.' }), + ).toThrow() + }) + + test('rejects empty content array', () => { + expect(() => + chronicleConfigSchema.parse({ site: { title: 'x' }, content: [] }), + ).toThrow() + }) + + test('rejects versions without latest', () => { + expect(() => + chronicleConfigSchema.parse({ + ...minimal, + versions: [ + { + dir: 'v1', + label: '1.0', + content: [{ dir: 'docs', label: 'Docs' }], + }, + ], + }), + ).toThrow(/latest is required/) + }) + + test('rejects duplicate content[].dir', () => { + expect(() => + chronicleConfigSchema.parse({ + ...minimal, + content: [ + { dir: 'docs', label: 'A' }, + { dir: 'docs', label: 'B' }, + ], + }), + ).toThrow(/content\[\]\.dir must be unique/) + }) + + test('rejects duplicate versions[].dir', () => { + expect(() => + chronicleConfigSchema.parse({ + ...minimal, + latest: { label: '3.0' }, + versions: [ + { dir: 'v1', label: '1', content: [{ dir: 'docs', label: 'd' }] }, + { dir: 'v1', label: '1b', content: [{ dir: 'docs', label: 'd' }] }, + ], + }), + ).toThrow(/versions\[\]\.dir must be unique/) + }) + + test('rejects duplicate content dirs within a version', () => { + expect(() => + chronicleConfigSchema.parse({ + ...minimal, + latest: { label: '3.0' }, + versions: [ + { + dir: 'v1', + label: '1', + content: [ + { dir: 'docs', label: 'A' }, + { dir: 'docs', label: 'B' }, + ], + }, + ], + }), + ).toThrow(/unique within each version/) + }) + + test('rejects invalid badge variant', () => { + expect(() => + chronicleConfigSchema.parse({ + ...minimal, + latest: { label: '3.0' }, + versions: [ + { + dir: 'v1', + label: '1', + badge: { label: 'x', variant: 'info' }, + content: [{ dir: 'docs', label: 'd' }], + }, + ], + }), + ).toThrow() + }) +}) + +describe('getLatestContentRoots', () => { + test('maps each content entry to content/', () => { + const cfg = chronicleConfigSchema.parse({ + ...minimal, + content: [ + { dir: 'docs', label: 'Docs' }, + { dir: 'dev', label: 'Dev Docs' }, + ], + }) + const roots = getLatestContentRoots(cfg) + expect(roots).toEqual([ + { + versionDir: null, + versionLabel: null, + contentDir: 'docs', + contentLabel: 'Docs', + fsPath: 'content/docs', + urlPrefix: '/docs', + }, + { + versionDir: null, + versionLabel: null, + contentDir: 'dev', + contentLabel: 'Dev Docs', + fsPath: 'content/dev', + urlPrefix: '/dev', + }, + ]) + }) + + test('includes versionLabel when latest is set', () => { + const cfg = chronicleConfigSchema.parse({ + ...minimal, + latest: { label: '3.0' }, + }) + expect(getLatestContentRoots(cfg)[0].versionLabel).toBe('3.0') + }) +}) + +describe('getVersionContentRoots', () => { + test('resolves versions// and preserves config order', () => { + const cfg = chronicleConfigSchema.parse({ + ...minimal, + latest: { label: '3.0' }, + versions: [ + { + dir: 'v1', + label: '1.0', + content: [ + { dir: 'dev', label: 'Developer Guide' }, + { dir: 'docs', label: 'Docs' }, + ], + }, + ], + }) + const roots = getVersionContentRoots(cfg, 'v1') + expect(roots.map((r) => r.fsPath)).toEqual([ + 'versions/v1/dev', + 'versions/v1/docs', + ]) + expect(roots.map((r) => r.urlPrefix)).toEqual(['/v1/dev', '/v1/docs']) + expect(roots[0].contentLabel).toBe('Developer Guide') + }) + + test('returns empty array for unknown version', () => { + const cfg = chronicleConfigSchema.parse(minimal) + expect(getVersionContentRoots(cfg, 'v1')).toEqual([]) + }) +}) + +describe('getAllVersions', () => { + test('returns latest first then versions in config order', () => { + const cfg = chronicleConfigSchema.parse({ + ...minimal, + latest: { label: '3.0' }, + versions: [ + { + dir: 'v2', + label: '2.0', + content: [{ dir: 'docs', label: 'Docs' }], + }, + { + dir: 'v1', + label: '1.0', + badge: { label: 'deprecated', variant: 'warning' }, + content: [{ dir: 'docs', label: 'Docs' }], + }, + ], + }) + const all = getAllVersions(cfg) + expect(all).toEqual([ + { dir: null, label: '3.0', isLatest: true }, + { dir: 'v2', label: '2.0', isLatest: false }, + { + dir: 'v1', + label: '1.0', + badge: { label: 'deprecated', variant: 'warning' }, + isLatest: false, + }, + ]) + }) + + test('returns empty when no latest and no versions', () => { + const cfg = chronicleConfigSchema.parse(minimal) + expect(getAllVersions(cfg)).toEqual([]) + }) +}) + +describe('loadConfig', () => { + beforeEach(() => { + delete g.__CHRONICLE_CONFIG_RAW__ + }) + + afterEach(() => { + delete g.__CHRONICLE_CONFIG_RAW__ + }) + + test('returns default config when raw is undefined', () => { + const cfg = loadConfig() + expect(cfg.site.title).toBe('Documentation') + expect(cfg.content).toEqual([{ dir: 'docs', label: 'Docs' }]) + }) + + test('returns default config when raw is null', () => { + g.__CHRONICLE_CONFIG_RAW__ = null + const cfg = loadConfig() + expect(cfg.site.title).toBe('Documentation') + }) + + test('parses yaml raw string', () => { + g.__CHRONICLE_CONFIG_RAW__ = ` +site: + title: Yaml Docs +content: + - dir: docs + label: Docs + - dir: dev + label: Dev +` + const cfg = loadConfig() + expect(cfg.site.title).toBe('Yaml Docs') + expect(cfg.content).toHaveLength(2) + }) + + test('throws on invalid yaml config', () => { + g.__CHRONICLE_CONFIG_RAW__ = 'title: Legacy' + expect(() => loadConfig()).toThrow() + }) +}) From 6105ac67bde9e63d610ffb89333b4b84d112a6db Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 11:01:55 +0530 Subject: [PATCH 05/55] refactor: use lodash/uniqBy for dir uniqueness check Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/types/config.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/chronicle/src/types/config.ts b/packages/chronicle/src/types/config.ts index 9be8fe5..a9c6343 100644 --- a/packages/chronicle/src/types/config.ts +++ b/packages/chronicle/src/types/config.ts @@ -1,3 +1,4 @@ +import uniqBy from 'lodash/uniqBy' import { z } from 'zod' const logoSchema = z.object({ @@ -110,15 +111,8 @@ const versionSchema = z.object({ api: z.array(apiSchema).optional(), }) -const uniqueBy = (items: T[], key: (item: T) => string): boolean => { - const seen = new Set() - for (const item of items) { - const k = key(item) - if (seen.has(k)) return false - seen.add(k) - } - return true -} +const allUnique = (items: T[], key: (item: T) => string): boolean => + uniqBy(items, key).length === items.length export const chronicleConfigSchema = z .object({ @@ -140,18 +134,18 @@ export const chronicleConfigSchema = z telemetry: telemetrySchema.optional(), }) .strict() - .refine((cfg) => uniqueBy(cfg.content, (c) => c.dir), { + .refine((cfg) => allUnique(cfg.content, (c) => c.dir), { message: 'content[].dir must be unique', path: ['content'], }) - .refine((cfg) => !cfg.versions || uniqueBy(cfg.versions, (v) => v.dir), { + .refine((cfg) => !cfg.versions || allUnique(cfg.versions, (v) => v.dir), { message: 'versions[].dir must be unique', path: ['versions'], }) .refine( (cfg) => !cfg.versions || - cfg.versions.every((v) => uniqueBy(v.content, (c) => c.dir)), + cfg.versions.every((v) => allUnique(v.content, (c) => c.dir)), { message: 'versions[].content[].dir must be unique within each version', path: ['versions'], From f58dd839e62f1722b0f8e6510b5daee8742a0009 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 12:04:07 +0530 Subject: [PATCH 06/55] refactor: thread projectRoot through CLI instead of contentDir - loadCLIConfig returns projectRoot (configPath's dirname) and drops resolveContentDir now that content is an array of {dir,label} - Commands drop --content flag; content path is config-driven - Vite define __CHRONICLE_CONTENT_DIR__ points at packageRoot/.content mirror so downstream routes remain stable as the mirror grows to include versioned subtrees - linkContent still called with a bridge path (projectRoot/content); scaffold gets its real multi-root rewrite next commit Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/cli/commands/build.ts | 10 ++++------ packages/chronicle/src/cli/commands/dev.ts | 8 ++++---- packages/chronicle/src/cli/commands/serve.ts | 10 ++++------ packages/chronicle/src/cli/commands/start.ts | 8 ++++---- packages/chronicle/src/cli/utils/config.ts | 18 ++++++------------ packages/chronicle/src/server/vite-config.ts | 8 ++++---- 6 files changed, 26 insertions(+), 36 deletions(-) diff --git a/packages/chronicle/src/cli/commands/build.ts b/packages/chronicle/src/cli/commands/build.ts index 02bf45e..5246fa5 100644 --- a/packages/chronicle/src/cli/commands/build.ts +++ b/packages/chronicle/src/cli/commands/build.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import chalk from 'chalk'; import { Command } from 'commander'; import { loadCLIConfig } from '@/cli/utils/config'; @@ -6,18 +7,16 @@ import { linkContent } from '@/cli/utils/scaffold'; export const buildCommand = new Command('build') .description('Build for production') - .option('--content ', 'Content directory') .option('--config ', 'Path to chronicle.yaml') .option( '--preset ', 'Deploy preset (vercel, cloudflare, node-server)' ) .action(async options => { - const { contentDir, configPath, preset } = await loadCLIConfig(options.config, { - content: options.content, + const { projectRoot, configPath, preset } = await loadCLIConfig(options.config, { preset: options.preset, }); - await linkContent(contentDir); + await linkContent(path.join(projectRoot, 'content')); console.log(chalk.cyan('Building for production...')); @@ -26,8 +25,7 @@ export const buildCommand = new Command('build') const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, - projectRoot: process.cwd(), - contentDir, + projectRoot, configPath, preset }); diff --git a/packages/chronicle/src/cli/commands/dev.ts b/packages/chronicle/src/cli/commands/dev.ts index b34853c..20dec32 100644 --- a/packages/chronicle/src/cli/commands/dev.ts +++ b/packages/chronicle/src/cli/commands/dev.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import chalk from 'chalk'; import { Command } from 'commander'; import { loadCLIConfig } from '@/cli/utils/config'; @@ -7,21 +8,20 @@ import { linkContent } from '@/cli/utils/scaffold'; export const devCommand = new Command('dev') .description('Start development server') .option('-p, --port ', 'Port number', '3000') - .option('--content ', 'Content directory') .option('--config ', 'Path to chronicle.yaml') .option('--host ', 'Host address', 'localhost') .action(async options => { - const { contentDir, configPath } = await loadCLIConfig(options.config, { content: options.content }); + const { projectRoot, configPath } = await loadCLIConfig(options.config); const port = parseInt(options.port, 10); - await linkContent(contentDir); + await linkContent(path.join(projectRoot, 'content')); console.log(chalk.cyan('Starting dev server...')); const { createServer } = await import('vite'); const { createViteConfig } = await import('@/server/vite-config'); - const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir, configPath }); + const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot, configPath }); const server = await createServer({ ...config, server: { ...config.server, port, host: options.host } diff --git a/packages/chronicle/src/cli/commands/serve.ts b/packages/chronicle/src/cli/commands/serve.ts index 14062c7..302ee57 100644 --- a/packages/chronicle/src/cli/commands/serve.ts +++ b/packages/chronicle/src/cli/commands/serve.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import chalk from 'chalk'; import { Command } from 'commander'; import { loadCLIConfig } from '@/cli/utils/config'; @@ -7,7 +8,6 @@ import { linkContent } from '@/cli/utils/scaffold'; export const serveCommand = new Command('serve') .description('Build and start production server') .option('-p, --port ', 'Port number', '3000') - .option('--content ', 'Content directory') .option('--config ', 'Path to chronicle.yaml') .option('--host ', 'Host address', 'localhost') .option( @@ -15,20 +15,18 @@ export const serveCommand = new Command('serve') 'Deploy preset (vercel, cloudflare, node-server)' ) .action(async options => { - const { contentDir, configPath, preset } = await loadCLIConfig(options.config, { - content: options.content, + const { projectRoot, configPath, preset } = await loadCLIConfig(options.config, { preset: options.preset, }); const port = parseInt(options.port, 10); - await linkContent(contentDir); + await linkContent(path.join(projectRoot, 'content')); const { build, preview } = await import('vite'); const { createViteConfig } = await import('@/server/vite-config'); const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, - projectRoot: process.cwd(), - contentDir, + projectRoot, configPath, preset }); diff --git a/packages/chronicle/src/cli/commands/start.ts b/packages/chronicle/src/cli/commands/start.ts index 2626b30..0e15725 100644 --- a/packages/chronicle/src/cli/commands/start.ts +++ b/packages/chronicle/src/cli/commands/start.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import chalk from 'chalk'; import { Command } from 'commander'; import { loadCLIConfig } from '@/cli/utils/config'; @@ -7,19 +8,18 @@ import { linkContent } from '@/cli/utils/scaffold'; export const startCommand = new Command('start') .description('Start production server') .option('-p, --port ', 'Port number', '3000') - .option('--content ', 'Content directory') .option('--host ', 'Host address', 'localhost') .action(async options => { - const { contentDir, configPath } = await loadCLIConfig(undefined, { content: options.content }); + const { projectRoot, configPath } = await loadCLIConfig(); const port = parseInt(options.port, 10); - await linkContent(contentDir); + await linkContent(path.join(projectRoot, 'content')); console.log(chalk.cyan('Starting production server...')); const { preview } = await import('vite'); const { createViteConfig } = await import('@/server/vite-config'); - const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot: process.cwd(), contentDir, configPath }); + const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot, configPath }); const server = await preview({ ...config, preview: { port, host: options.host } diff --git a/packages/chronicle/src/cli/utils/config.ts b/packages/chronicle/src/cli/utils/config.ts index 90d7728..8056980 100644 --- a/packages/chronicle/src/cli/utils/config.ts +++ b/packages/chronicle/src/cli/utils/config.ts @@ -7,7 +7,7 @@ import { chronicleConfigSchema, type ChronicleConfig } from '@/types'; export interface CLIConfig { config: ChronicleConfig; configPath: string; - contentDir: string; + projectRoot: string; preset?: string; } @@ -36,8 +36,8 @@ function validateConfig(raw: string, configPath: string): ChronicleConfig { if (!result.success) { console.log(chalk.red(`Error: Invalid chronicle.yaml at '${configPath}'`)); for (const issue of result.error.issues) { - const path = issue.path.join('.'); - console.log(chalk.gray(` ${path ? `${path}: ` : ''}${issue.message}`)); + const issuePath = issue.path.join('.'); + console.log(chalk.gray(` ${issuePath ? `${issuePath}: ` : ''}${issue.message}`)); } process.exit(1); } @@ -45,27 +45,21 @@ function validateConfig(raw: string, configPath: string): ChronicleConfig { return result.data; } -export function resolveContentDir(config: ChronicleConfig, configPath: string, contentFlag?: string): string { - if (contentFlag) return path.resolve(contentFlag); - if (config.content) return path.resolve(path.dirname(configPath), config.content); - return path.resolve('content'); -} - export function resolvePreset(config: ChronicleConfig, presetFlag?: string): string | undefined { return presetFlag ?? config.preset; } export async function loadCLIConfig( configPath?: string, - options?: { content?: string; preset?: string } + options?: { preset?: string } ): Promise { const resolvedConfigPath = resolveConfigPath(configPath) ?? path.join(process.cwd(), 'chronicle.yaml'); const raw = await readConfig(resolvedConfigPath); const config = validateConfig(raw, resolvedConfigPath); - const contentDir = resolveContentDir(config, resolvedConfigPath, options?.content); + const projectRoot = path.dirname(resolvedConfigPath); const preset = resolvePreset(config, options?.preset); - return { config, configPath: resolvedConfigPath, contentDir, preset }; + return { config, configPath: resolvedConfigPath, projectRoot, preset }; } diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts index 578e121..78819e7 100644 --- a/packages/chronicle/src/server/vite-config.ts +++ b/packages/chronicle/src/server/vite-config.ts @@ -18,7 +18,6 @@ function resolveOutputDir(projectRoot: string, preset?: string): string { export interface ViteConfigOptions { packageRoot: string; projectRoot: string; - contentDir: string; configPath?: string; preset?: string; } @@ -41,8 +40,9 @@ async function readChronicleConfig(projectRoot: string, configPath?: string): Pr export async function createViteConfig( options: ViteConfigOptions ): Promise { - const { packageRoot, projectRoot, contentDir, configPath, preset } = options; + const { packageRoot, projectRoot, configPath, preset } = options; const rawConfig = await readChronicleConfig(projectRoot, configPath); + const contentMirror = path.resolve(packageRoot, '.content'); return { root: packageRoot, @@ -98,11 +98,11 @@ export async function createViteConfig( }, server: { fs: { - allow: [packageRoot, projectRoot, contentDir] + allow: [packageRoot, projectRoot, contentMirror] } }, define: { - __CHRONICLE_CONTENT_DIR__: JSON.stringify(contentDir), + __CHRONICLE_CONTENT_DIR__: JSON.stringify(contentMirror), __CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot), __CHRONICLE_CONFIG_RAW__: JSON.stringify(rawConfig), }, From bbc6fa293319a4272946e5b9250a01e9d0cda5b6 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 12:08:01 +0530 Subject: [PATCH 07/55] feat: build multi-content + versioned .content mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit linkContent now takes (projectRoot, config) and rebuilds packageRoot/.content to mirror the configured layout: .content//content/ .content///versions// The mirror is wiped on each run (handles legacy single-symlink and stale entries), then rebuilt from getLatestContentRoots and getVersionContentRoots. CLI commands pass config through and rename vite's return to viteConfig to avoid shadowing. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/cli/commands/build.ts | 9 ++-- packages/chronicle/src/cli/commands/dev.ts | 11 +++-- packages/chronicle/src/cli/commands/serve.ts | 11 +++-- packages/chronicle/src/cli/commands/start.ts | 9 ++-- packages/chronicle/src/cli/utils/scaffold.ts | 45 ++++++++++++++++---- 5 files changed, 54 insertions(+), 31 deletions(-) diff --git a/packages/chronicle/src/cli/commands/build.ts b/packages/chronicle/src/cli/commands/build.ts index 5246fa5..3ac7172 100644 --- a/packages/chronicle/src/cli/commands/build.ts +++ b/packages/chronicle/src/cli/commands/build.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import chalk from 'chalk'; import { Command } from 'commander'; import { loadCLIConfig } from '@/cli/utils/config'; @@ -13,24 +12,24 @@ export const buildCommand = new Command('build') 'Deploy preset (vercel, cloudflare, node-server)' ) .action(async options => { - const { projectRoot, configPath, preset } = await loadCLIConfig(options.config, { + const { config, projectRoot, configPath, preset } = await loadCLIConfig(options.config, { preset: options.preset, }); - await linkContent(path.join(projectRoot, 'content')); + await linkContent(projectRoot, config); console.log(chalk.cyan('Building for production...')); const { createBuilder } = await import('vite'); const { createViteConfig } = await import('@/server/vite-config'); - const config = await createViteConfig({ + const viteConfig = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot, configPath, preset }); - const builder = await createBuilder({ ...config, builder: {} }); + const builder = await createBuilder({ ...viteConfig, builder: {} }); await builder.buildApp(); console.log(chalk.green('Build complete')); diff --git a/packages/chronicle/src/cli/commands/dev.ts b/packages/chronicle/src/cli/commands/dev.ts index 20dec32..80a19f9 100644 --- a/packages/chronicle/src/cli/commands/dev.ts +++ b/packages/chronicle/src/cli/commands/dev.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import chalk from 'chalk'; import { Command } from 'commander'; import { loadCLIConfig } from '@/cli/utils/config'; @@ -11,20 +10,20 @@ export const devCommand = new Command('dev') .option('--config ', 'Path to chronicle.yaml') .option('--host ', 'Host address', 'localhost') .action(async options => { - const { projectRoot, configPath } = await loadCLIConfig(options.config); + const { config, projectRoot, configPath } = await loadCLIConfig(options.config); const port = parseInt(options.port, 10); - await linkContent(path.join(projectRoot, 'content')); + await linkContent(projectRoot, config); console.log(chalk.cyan('Starting dev server...')); const { createServer } = await import('vite'); const { createViteConfig } = await import('@/server/vite-config'); - const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot, configPath }); + const viteConfig = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot, configPath }); const server = await createServer({ - ...config, - server: { ...config.server, port, host: options.host } + ...viteConfig, + server: { ...viteConfig.server, port, host: options.host } }); await server.listen(); diff --git a/packages/chronicle/src/cli/commands/serve.ts b/packages/chronicle/src/cli/commands/serve.ts index 302ee57..8a5adcf 100644 --- a/packages/chronicle/src/cli/commands/serve.ts +++ b/packages/chronicle/src/cli/commands/serve.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import chalk from 'chalk'; import { Command } from 'commander'; import { loadCLIConfig } from '@/cli/utils/config'; @@ -15,16 +14,16 @@ export const serveCommand = new Command('serve') 'Deploy preset (vercel, cloudflare, node-server)' ) .action(async options => { - const { projectRoot, configPath, preset } = await loadCLIConfig(options.config, { + const { config, projectRoot, configPath, preset } = await loadCLIConfig(options.config, { preset: options.preset, }); const port = parseInt(options.port, 10); - await linkContent(path.join(projectRoot, 'content')); + await linkContent(projectRoot, config); const { build, preview } = await import('vite'); const { createViteConfig } = await import('@/server/vite-config'); - const config = await createViteConfig({ + const viteConfig = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot, configPath, @@ -32,11 +31,11 @@ export const serveCommand = new Command('serve') }); console.log(chalk.cyan('Building for production...')); - await build(config); + await build(viteConfig); console.log(chalk.cyan('Starting production server...')); const server = await preview({ - ...config, + ...viteConfig, preview: { port, host: options.host } }); diff --git a/packages/chronicle/src/cli/commands/start.ts b/packages/chronicle/src/cli/commands/start.ts index 0e15725..7630ad6 100644 --- a/packages/chronicle/src/cli/commands/start.ts +++ b/packages/chronicle/src/cli/commands/start.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import chalk from 'chalk'; import { Command } from 'commander'; import { loadCLIConfig } from '@/cli/utils/config'; @@ -10,18 +9,18 @@ export const startCommand = new Command('start') .option('-p, --port ', 'Port number', '3000') .option('--host ', 'Host address', 'localhost') .action(async options => { - const { projectRoot, configPath } = await loadCLIConfig(); + const { config, projectRoot, configPath } = await loadCLIConfig(); const port = parseInt(options.port, 10); - await linkContent(path.join(projectRoot, 'content')); + await linkContent(projectRoot, config); console.log(chalk.cyan('Starting production server...')); const { preview } = await import('vite'); const { createViteConfig } = await import('@/server/vite-config'); - const config = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot, configPath }); + const viteConfig = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot, configPath }); const server = await preview({ - ...config, + ...viteConfig, preview: { port, host: options.host } }); diff --git a/packages/chronicle/src/cli/utils/scaffold.ts b/packages/chronicle/src/cli/utils/scaffold.ts index a27af8b..e91c0e0 100644 --- a/packages/chronicle/src/cli/utils/scaffold.ts +++ b/packages/chronicle/src/cli/utils/scaffold.ts @@ -1,18 +1,45 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import type { ChronicleConfig } from '@/types'; +import { getLatestContentRoots, getVersionContentRoots } from '@/lib/config'; import { PACKAGE_ROOT } from './resolve'; -export async function linkContent(contentDir: string): Promise { - const linkPath = path.join(PACKAGE_ROOT, '.content'); - const target = path.resolve(contentDir); +export async function linkContent( + projectRoot: string, + config: ChronicleConfig, +): Promise { + const mirrorRoot = path.join(PACKAGE_ROOT, '.content'); + await removeMirror(mirrorRoot); + await fs.mkdir(mirrorRoot, { recursive: true }); + + for (const root of getLatestContentRoots(config)) { + const target = path.resolve(projectRoot, root.fsPath); + const linkPath = path.join(mirrorRoot, root.contentDir); + await fs.symlink(target, linkPath); + } + + for (const version of config.versions ?? []) { + const versionMirror = path.join(mirrorRoot, version.dir); + await fs.mkdir(versionMirror, { recursive: true }); + + for (const root of getVersionContentRoots(config, version.dir)) { + const target = path.resolve(projectRoot, root.fsPath); + const linkPath = path.join(versionMirror, root.contentDir); + await fs.symlink(target, linkPath); + } + } +} + +async function removeMirror(mirrorRoot: string): Promise { try { - const existing = await fs.readlink(linkPath); - if (existing === target) return; - await fs.unlink(linkPath); + const stat = await fs.lstat(mirrorRoot); + if (stat.isSymbolicLink() || stat.isFile()) { + await fs.unlink(mirrorRoot); + } else if (stat.isDirectory()) { + await fs.rm(mirrorRoot, { recursive: true, force: true }); + } } catch { - // link doesn't exist + // mirror doesn't exist } - - await fs.symlink(target, linkPath); } From 1a290c84ee0c868e0a636408a9c2447b6b93a4cb Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 12:09:11 +0530 Subject: [PATCH 08/55] feat: add version-aware selectors to source Pure tree/page filters live in src/lib/version-source.ts, keyed off the config's versions[]. source.ts exposes thin wrappers: - getPageTreeForVersion(ctx) returns a subtree scoped to the version - getPagesForVersion(ctx) returns pages filtered to the version - getVersionContextForUrl resolves URL -> VersionContext Latest (ctx.dir === null) excludes anything under //*, while a version returns only its subtree. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/source.ts | 23 ++++++ packages/chronicle/src/lib/version-source.ts | 85 ++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 packages/chronicle/src/lib/version-source.ts diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index 0d9e155..b32748b 100644 --- a/packages/chronicle/src/lib/source.ts +++ b/packages/chronicle/src/lib/source.ts @@ -3,6 +3,13 @@ import type { Root, Node, Folder } from 'fumadocs-core/page-tree'; import type { MDXContent } from 'mdx/types'; import type { TableOfContents } from 'fumadocs-core/toc'; import type { Frontmatter } from '@/types'; +import { loadConfig } from './config'; +import { + filterPagesByVersion, + filterPageTreeByVersion, + resolveVersionFromUrl, + type VersionContext, +} from './version-source'; const CONTENT_PREFIX = '../../.content/'; @@ -105,6 +112,22 @@ export async function getPage(slugs?: string[]) { return s.getPage(slugs); } +export async function getPageTreeForVersion(ctx: VersionContext): Promise { + const tree = await getPageTree(); + return filterPageTreeByVersion(tree, ctx, loadConfig()); +} + +export async function getPagesForVersion(ctx: VersionContext) { + const pages = await getPages(); + return filterPagesByVersion(pages, ctx, loadConfig()); +} + +export function getVersionContextForUrl(url: string): VersionContext { + return resolveVersionFromUrl(url, loadConfig()); +} + +export type { VersionContext } from './version-source'; + export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: string): Frontmatter { const d = page.data as Record; return { diff --git a/packages/chronicle/src/lib/version-source.ts b/packages/chronicle/src/lib/version-source.ts new file mode 100644 index 0000000..5e474df --- /dev/null +++ b/packages/chronicle/src/lib/version-source.ts @@ -0,0 +1,85 @@ +import type { Folder, Node, Root } from 'fumadocs-core/page-tree' +import type { ChronicleConfig } from '@/types' + +export interface VersionContext { + dir: string | null + urlPrefix: string +} + +export const LATEST_CONTEXT: VersionContext = { dir: null, urlPrefix: '' } + +export function resolveVersionFromUrl( + url: string, + config: ChronicleConfig, +): VersionContext { + for (const v of config.versions ?? []) { + const prefix = `/${v.dir}` + if (url === prefix || url.startsWith(`${prefix}/`)) { + return { dir: v.dir, urlPrefix: prefix } + } + } + return LATEST_CONTEXT +} + +function versionPrefixes(config: ChronicleConfig): string[] { + return (config.versions ?? []).map((v) => `/${v.dir}`) +} + +function isUnderPrefix(url: string, prefix: string): boolean { + return url === prefix || url.startsWith(`${prefix}/`) +} + +export function filterPagesByVersion( + pages: T[], + ctx: VersionContext, + config: ChronicleConfig, +): T[] { + if (ctx.dir !== null) { + return pages.filter((p) => isUnderPrefix(p.url, ctx.urlPrefix)) + } + const prefixes = versionPrefixes(config) + return pages.filter((p) => !prefixes.some((pre) => isUnderPrefix(p.url, pre))) +} + +function nodeUrls(node: Node): string[] { + if (node.type === 'page') return [node.url] + if (node.type === 'folder') { + const urls: string[] = [] + if (node.index) urls.push(node.index.url) + for (const child of node.children) urls.push(...nodeUrls(child)) + return urls + } + return [] +} + +function nodeMatchesVersion( + node: Node, + ctx: VersionContext, + config: ChronicleConfig, +): boolean { + const urls = nodeUrls(node) + if (urls.length === 0) return ctx.dir === null + if (ctx.dir !== null) { + return urls.every((u) => isUnderPrefix(u, ctx.urlPrefix)) + } + const prefixes = versionPrefixes(config) + return urls.every((u) => !prefixes.some((pre) => isUnderPrefix(u, pre))) +} + +export function filterPageTreeByVersion( + tree: Root, + ctx: VersionContext, + config: ChronicleConfig, +): Root { + if (ctx.dir !== null) { + const versionFolder = tree.children.find( + (n): n is Folder => + n.type === 'folder' && nodeMatchesVersion(n, ctx, config), + ) + return { ...tree, children: versionFolder ? versionFolder.children : [] } + } + return { + ...tree, + children: tree.children.filter((n) => nodeMatchesVersion(n, ctx, config)), + } +} From bcf18e608e19b8ffd8c03eb9a7794a4676233b65 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 12:10:31 +0530 Subject: [PATCH 09/55] test: cover content mirror scaffold Extract buildContentMirror(mirrorRoot, projectRoot, config) as the pure unit; linkContent stays a thin wrapper binding PACKAGE_ROOT/.content. Tests use tmpdir to exercise: - single and multi content latest layouts - nested versioned layout - idempotency on re-run - stale entries wiped when config shrinks - legacy single-symlink mirror replaced by directory + child symlinks Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chronicle/src/cli/utils/scaffold.test.ts | 146 ++++++++++++++++++ packages/chronicle/src/cli/utils/scaffold.ts | 16 +- 2 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 packages/chronicle/src/cli/utils/scaffold.test.ts diff --git a/packages/chronicle/src/cli/utils/scaffold.test.ts b/packages/chronicle/src/cli/utils/scaffold.test.ts new file mode 100644 index 0000000..649ca90 --- /dev/null +++ b/packages/chronicle/src/cli/utils/scaffold.test.ts @@ -0,0 +1,146 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { chronicleConfigSchema } from '@/types' +import { buildContentMirror } from './scaffold' + +let tmp: string +let projectRoot: string +let mirrorRoot: string + +async function seedContent(relPath: string, file = 'index.mdx'): Promise { + const dir = path.join(projectRoot, relPath) + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, file), `---\ntitle: ${relPath}\n---\n`) +} + +async function readMirror(relPath: string): Promise { + return fs.readlink(path.join(mirrorRoot, relPath)) +} + +beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'chronicle-scaffold-')) + projectRoot = path.join(tmp, 'project') + mirrorRoot = path.join(tmp, 'mirror') + await fs.mkdir(projectRoot, { recursive: true }) +}) + +afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }) +}) + +describe('buildContentMirror', () => { + test('creates symlinks for single-content latest config', async () => { + await seedContent('content/docs') + const config = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + }) + + await buildContentMirror(mirrorRoot, projectRoot, config) + + expect(await readMirror('docs')).toBe(path.join(projectRoot, 'content/docs')) + const entries = await fs.readdir(mirrorRoot) + expect(entries.sort()).toEqual(['docs']) + }) + + test('creates symlinks for multi-content latest config', async () => { + await seedContent('content/docs') + await seedContent('content/dev') + const config = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [ + { dir: 'docs', label: 'Docs' }, + { dir: 'dev', label: 'Dev' }, + ], + }) + + await buildContentMirror(mirrorRoot, projectRoot, config) + + expect(await readMirror('docs')).toBe(path.join(projectRoot, 'content/docs')) + expect(await readMirror('dev')).toBe(path.join(projectRoot, 'content/dev')) + }) + + test('creates nested symlinks for versioned config', async () => { + await seedContent('content/docs') + await seedContent('versions/v1/docs') + await seedContent('versions/v1/dev') + const config = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + latest: { label: '2.0' }, + versions: [ + { + dir: 'v1', + label: '1.0', + content: [ + { dir: 'docs', label: 'Docs' }, + { dir: 'dev', label: 'Dev' }, + ], + }, + ], + }) + + await buildContentMirror(mirrorRoot, projectRoot, config) + + expect(await readMirror('docs')).toBe(path.join(projectRoot, 'content/docs')) + expect(await readMirror('v1/docs')).toBe( + path.join(projectRoot, 'versions/v1/docs'), + ) + expect(await readMirror('v1/dev')).toBe( + path.join(projectRoot, 'versions/v1/dev'), + ) + }) + + test('is idempotent — re-running yields the same mirror', async () => { + await seedContent('content/docs') + const config = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + }) + + await buildContentMirror(mirrorRoot, projectRoot, config) + await buildContentMirror(mirrorRoot, projectRoot, config) + + expect(await readMirror('docs')).toBe(path.join(projectRoot, 'content/docs')) + }) + + test('wipes stale entries when config changes', async () => { + await seedContent('content/docs') + await seedContent('content/dev') + const before = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [ + { dir: 'docs', label: 'Docs' }, + { dir: 'dev', label: 'Dev' }, + ], + }) + + await buildContentMirror(mirrorRoot, projectRoot, before) + expect((await fs.readdir(mirrorRoot)).sort()).toEqual(['dev', 'docs']) + + const after = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + }) + await buildContentMirror(mirrorRoot, projectRoot, after) + + expect(await fs.readdir(mirrorRoot)).toEqual(['docs']) + }) + + test('replaces a legacy single-symlink mirror', async () => { + await seedContent('content/docs') + await fs.symlink(path.join(projectRoot, 'content/docs'), mirrorRoot) + + const config = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + }) + await buildContentMirror(mirrorRoot, projectRoot, config) + + const stat = await fs.lstat(mirrorRoot) + expect(stat.isDirectory()).toBe(true) + expect(await readMirror('docs')).toBe(path.join(projectRoot, 'content/docs')) + }) +}) diff --git a/packages/chronicle/src/cli/utils/scaffold.ts b/packages/chronicle/src/cli/utils/scaffold.ts index e91c0e0..dd7f71e 100644 --- a/packages/chronicle/src/cli/utils/scaffold.ts +++ b/packages/chronicle/src/cli/utils/scaffold.ts @@ -4,12 +4,11 @@ import type { ChronicleConfig } from '@/types'; import { getLatestContentRoots, getVersionContentRoots } from '@/lib/config'; import { PACKAGE_ROOT } from './resolve'; -export async function linkContent( +export async function buildContentMirror( + mirrorRoot: string, projectRoot: string, config: ChronicleConfig, ): Promise { - const mirrorRoot = path.join(PACKAGE_ROOT, '.content'); - await removeMirror(mirrorRoot); await fs.mkdir(mirrorRoot, { recursive: true }); @@ -31,6 +30,17 @@ export async function linkContent( } } +export function linkContent( + projectRoot: string, + config: ChronicleConfig, +): Promise { + return buildContentMirror( + path.join(PACKAGE_ROOT, '.content'), + projectRoot, + config, + ); +} + async function removeMirror(mirrorRoot: string): Promise { try { const stat = await fs.lstat(mirrorRoot); From 913e8d006fbc59cf74db5d7dda61c402f4f87e51 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 12:13:32 +0530 Subject: [PATCH 10/55] test: cover version-aware source filters - resolveVersionFromUrl: matches versions exactly, falls back to latest (no false positive on substring overlap e.g. /v1 vs /v1beta) - filterPagesByVersion: latest excludes versioned pages, version scopes to its prefix - filterPageTreeByVersion: latest strips version folders, version unwraps its folder, absent version returns empty Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chronicle/src/lib/version-source.test.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 packages/chronicle/src/lib/version-source.test.ts diff --git a/packages/chronicle/src/lib/version-source.test.ts b/packages/chronicle/src/lib/version-source.test.ts new file mode 100644 index 0000000..99088ea --- /dev/null +++ b/packages/chronicle/src/lib/version-source.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from 'bun:test' +import type { Folder, Item, Root } from 'fumadocs-core/page-tree' +import { type ChronicleConfig, chronicleConfigSchema } from '@/types' +import { + filterPagesByVersion, + filterPageTreeByVersion, + LATEST_CONTEXT, + resolveVersionFromUrl, +} from './version-source' + +function makeConfig(): ChronicleConfig { + return chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + latest: { label: '3.0' }, + versions: [ + { + dir: 'v2', + label: '2.0', + content: [{ dir: 'docs', label: 'Docs' }], + }, + { + dir: 'v1', + label: '1.0', + content: [ + { dir: 'docs', label: 'Docs' }, + { dir: 'dev', label: 'Dev' }, + ], + }, + ], + }) +} + +function page(url: string): Item { + return { type: 'page', name: url, url } +} + +function folder(name: string, children: (Item | Folder)[]): Folder { + return { type: 'folder', name, children } +} + +describe('resolveVersionFromUrl', () => { + const config = makeConfig() + + test('returns latest context for unprefixed URLs', () => { + expect(resolveVersionFromUrl('/docs/getting-started', config)).toEqual( + LATEST_CONTEXT, + ) + expect(resolveVersionFromUrl('/', config)).toEqual(LATEST_CONTEXT) + }) + + test('returns version context when URL matches a version prefix', () => { + expect(resolveVersionFromUrl('/v1/docs/intro', config)).toEqual({ + dir: 'v1', + urlPrefix: '/v1', + }) + expect(resolveVersionFromUrl('/v2', config)).toEqual({ + dir: 'v2', + urlPrefix: '/v2', + }) + }) + + test('does not match a version when prefix is only a substring', () => { + expect(resolveVersionFromUrl('/v1beta/docs', config)).toEqual(LATEST_CONTEXT) + }) +}) + +describe('filterPagesByVersion', () => { + const config = makeConfig() + const pages = [ + { url: '/docs/a' }, + { url: '/docs/b' }, + { url: '/v1/docs/a' }, + { url: '/v1/dev/b' }, + { url: '/v2/docs/a' }, + ] + + test('latest excludes all versioned pages', () => { + expect(filterPagesByVersion(pages, LATEST_CONTEXT, config)).toEqual([ + { url: '/docs/a' }, + { url: '/docs/b' }, + ]) + }) + + test('version returns only pages under its prefix', () => { + expect( + filterPagesByVersion(pages, { dir: 'v1', urlPrefix: '/v1' }, config), + ).toEqual([{ url: '/v1/docs/a' }, { url: '/v1/dev/b' }]) + }) +}) + +describe('filterPageTreeByVersion', () => { + const config = makeConfig() + const latestDocs = folder('docs', [page('/docs/a'), page('/docs/b')]) + const v1Folder = folder('v1', [ + folder('docs', [page('/v1/docs/a')]), + folder('dev', [page('/v1/dev/a')]), + ]) + const v2Folder = folder('v2', [folder('docs', [page('/v2/docs/a')])]) + + const tree: Root = { + name: 'root', + children: [latestDocs, v1Folder, v2Folder], + } + + test('latest drops version folders', () => { + const filtered = filterPageTreeByVersion(tree, LATEST_CONTEXT, config) + expect(filtered.children).toEqual([latestDocs]) + }) + + test('version returns the inner children of its folder', () => { + const filtered = filterPageTreeByVersion( + tree, + { dir: 'v1', urlPrefix: '/v1' }, + config, + ) + expect(filtered.children).toEqual(v1Folder.children) + }) + + test('version returns empty children when the version folder is absent', () => { + const filtered = filterPageTreeByVersion( + { name: 'root', children: [latestDocs] }, + { dir: 'v1', urlPrefix: '/v1' }, + config, + ) + expect(filtered.children).toEqual([]) + }) +}) From 88255cd033e661b9518ab16bd9a71afa84e622c6 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 13:46:36 +0530 Subject: [PATCH 11/55] feat: add pure route resolver for multi-content + versions resolveRoute(pathname, config) classifies a URL into: - redirect single-content root -> / or // - docs-index multi-content root (latest or versioned) for landing - docs-page slug is the full URL slug incl. version prefix (fumadocs page URLs already include // so the mirror + loader handle lookup without stripping) - api-index / api-page /apis or //apis routes Resolver stays classifier-only; invalid content dirs fall through to docs-page and let page lookup return 404 downstream. Tests cover single/multi/versioned configs, trailing slash, and version-shaped-but-unknown prefixes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chronicle/src/lib/route-resolver.test.ts | 153 ++++++++++++++++++ packages/chronicle/src/lib/route-resolver.ts | 48 ++++++ 2 files changed, 201 insertions(+) create mode 100644 packages/chronicle/src/lib/route-resolver.test.ts create mode 100644 packages/chronicle/src/lib/route-resolver.ts diff --git a/packages/chronicle/src/lib/route-resolver.test.ts b/packages/chronicle/src/lib/route-resolver.test.ts new file mode 100644 index 0000000..2d28197 --- /dev/null +++ b/packages/chronicle/src/lib/route-resolver.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from 'bun:test' +import { type ChronicleConfig, chronicleConfigSchema } from '@/types' +import { resolveRoute } from './route-resolver' +import { LATEST_CONTEXT } from './version-source' + +function singleContent(): ChronicleConfig { + return chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + }) +} + +function multiContent(): ChronicleConfig { + return chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [ + { dir: 'docs', label: 'Docs' }, + { dir: 'dev', label: 'Dev' }, + ], + }) +} + +function versioned(): ChronicleConfig { + return chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + latest: { label: '3.0' }, + versions: [ + { + dir: 'v2', + label: '2.0', + content: [{ dir: 'docs', label: 'Docs' }], + }, + { + dir: 'v1', + label: '1.0', + content: [ + { dir: 'docs', label: 'Docs' }, + { dir: 'dev', label: 'Dev' }, + ], + }, + ], + }) +} + +describe('resolveRoute — root', () => { + test('redirects single-content latest root to /', () => { + expect(resolveRoute('/', singleContent())).toEqual({ + type: 'redirect', + to: '/docs', + status: 302, + }) + }) + + test('docs-index for multi-content latest root', () => { + expect(resolveRoute('/', multiContent())).toEqual({ + type: 'docs-index', + version: LATEST_CONTEXT, + }) + }) + + test('redirects single-content version root to //', () => { + expect(resolveRoute('/v2', versioned())).toEqual({ + type: 'redirect', + to: '/v2/docs', + status: 302, + }) + }) + + test('docs-index for multi-content version root', () => { + expect(resolveRoute('/v1', versioned())).toEqual({ + type: 'docs-index', + version: { dir: 'v1', urlPrefix: '/v1' }, + }) + }) +}) + +describe('resolveRoute — docs pages', () => { + test('latest docs page returns full slug and latest context', () => { + expect(resolveRoute('/docs/getting-started', singleContent())).toEqual({ + type: 'docs-page', + version: LATEST_CONTEXT, + slug: ['docs', 'getting-started'], + }) + }) + + test('versioned docs page returns full slug and version context', () => { + expect(resolveRoute('/v1/dev/intro', versioned())).toEqual({ + type: 'docs-page', + version: { dir: 'v1', urlPrefix: '/v1' }, + slug: ['v1', 'dev', 'intro'], + }) + }) + + test('unrecognized first segment stays latest (page lookup handles 404)', () => { + expect(resolveRoute('/foo/bar', singleContent())).toEqual({ + type: 'docs-page', + version: LATEST_CONTEXT, + slug: ['foo', 'bar'], + }) + }) +}) + +describe('resolveRoute — APIs', () => { + test('latest api index', () => { + expect(resolveRoute('/apis', singleContent())).toEqual({ + type: 'api-index', + version: LATEST_CONTEXT, + }) + }) + + test('latest api page', () => { + expect(resolveRoute('/apis/petstore/getPetById', singleContent())).toEqual({ + type: 'api-page', + version: LATEST_CONTEXT, + slug: ['petstore', 'getPetById'], + }) + }) + + test('versioned api index', () => { + expect(resolveRoute('/v1/apis', versioned())).toEqual({ + type: 'api-index', + version: { dir: 'v1', urlPrefix: '/v1' }, + }) + }) + + test('versioned api page', () => { + expect( + resolveRoute('/v1/apis/petstore/getPetById', versioned()), + ).toEqual({ + type: 'api-page', + version: { dir: 'v1', urlPrefix: '/v1' }, + slug: ['petstore', 'getPetById'], + }) + }) +}) + +describe('resolveRoute — edge cases', () => { + test('trailing slash is normalized', () => { + expect(resolveRoute('/v1/', versioned())).toEqual({ + type: 'docs-index', + version: { dir: 'v1', urlPrefix: '/v1' }, + }) + }) + + test('version-shaped path without a matching version stays latest', () => { + expect(resolveRoute('/v9/docs', versioned())).toEqual({ + type: 'docs-page', + version: LATEST_CONTEXT, + slug: ['v9', 'docs'], + }) + }) +}) diff --git a/packages/chronicle/src/lib/route-resolver.ts b/packages/chronicle/src/lib/route-resolver.ts new file mode 100644 index 0000000..86331a9 --- /dev/null +++ b/packages/chronicle/src/lib/route-resolver.ts @@ -0,0 +1,48 @@ +import type { ChronicleConfig } from '@/types' +import { type VersionContext, resolveVersionFromUrl } from './version-source' + +export type Route = + | { type: 'redirect'; to: string; status: 302 } + | { type: 'docs-index'; version: VersionContext } + | { type: 'docs-page'; version: VersionContext; slug: string[] } + | { type: 'api-index'; version: VersionContext } + | { type: 'api-page'; version: VersionContext; slug: string[] } + +function contentDirsFor( + config: ChronicleConfig, + version: VersionContext, +): string[] { + if (version.dir === null) return config.content.map((c) => c.dir) + const v = config.versions?.find((x) => x.dir === version.dir) + return v?.content.map((c) => c.dir) ?? [] +} + +export function resolveRoute( + pathname: string, + config: ChronicleConfig, +): Route { + const parts = pathname.split('/').filter(Boolean) + const version = resolveVersionFromUrl(pathname, config) + const remainder = + version.dir !== null && parts[0] === version.dir ? parts.slice(1) : parts + + if (remainder[0] === 'apis') { + const slug = remainder.slice(1) + if (slug.length === 0) return { type: 'api-index', version } + return { type: 'api-page', version, slug } + } + + if (remainder.length === 0) { + const dirs = contentDirsFor(config, version) + if (dirs.length === 1) { + return { + type: 'redirect', + to: `${version.urlPrefix}/${dirs[0]}`, + status: 302, + } + } + return { type: 'docs-index', version } + } + + return { type: 'docs-page', version, slug: parts } +} From e8bb0c5efb2c60bcfab0d61f24489b117e062dda Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 13:51:56 +0530 Subject: [PATCH 12/55] refactor: expose RouteType enum for route classifier Replace string literal types with a `RouteType` const-object so callers can reference RouteType.DocsPage etc. instead of repeating hyphenated strings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chronicle/src/lib/route-resolver.test.ts | 28 ++++++++--------- packages/chronicle/src/lib/route-resolver.ts | 30 ++++++++++++------- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/packages/chronicle/src/lib/route-resolver.test.ts b/packages/chronicle/src/lib/route-resolver.test.ts index 2d28197..4881689 100644 --- a/packages/chronicle/src/lib/route-resolver.test.ts +++ b/packages/chronicle/src/lib/route-resolver.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import { type ChronicleConfig, chronicleConfigSchema } from '@/types' -import { resolveRoute } from './route-resolver' +import { resolveRoute, RouteType } from './route-resolver' import { LATEST_CONTEXT } from './version-source' function singleContent(): ChronicleConfig { @@ -46,7 +46,7 @@ function versioned(): ChronicleConfig { describe('resolveRoute — root', () => { test('redirects single-content latest root to /', () => { expect(resolveRoute('/', singleContent())).toEqual({ - type: 'redirect', + type: RouteType.Redirect, to: '/docs', status: 302, }) @@ -54,14 +54,14 @@ describe('resolveRoute — root', () => { test('docs-index for multi-content latest root', () => { expect(resolveRoute('/', multiContent())).toEqual({ - type: 'docs-index', + type: RouteType.DocsIndex, version: LATEST_CONTEXT, }) }) test('redirects single-content version root to //', () => { expect(resolveRoute('/v2', versioned())).toEqual({ - type: 'redirect', + type: RouteType.Redirect, to: '/v2/docs', status: 302, }) @@ -69,7 +69,7 @@ describe('resolveRoute — root', () => { test('docs-index for multi-content version root', () => { expect(resolveRoute('/v1', versioned())).toEqual({ - type: 'docs-index', + type: RouteType.DocsIndex, version: { dir: 'v1', urlPrefix: '/v1' }, }) }) @@ -78,7 +78,7 @@ describe('resolveRoute — root', () => { describe('resolveRoute — docs pages', () => { test('latest docs page returns full slug and latest context', () => { expect(resolveRoute('/docs/getting-started', singleContent())).toEqual({ - type: 'docs-page', + type: RouteType.DocsPage, version: LATEST_CONTEXT, slug: ['docs', 'getting-started'], }) @@ -86,7 +86,7 @@ describe('resolveRoute — docs pages', () => { test('versioned docs page returns full slug and version context', () => { expect(resolveRoute('/v1/dev/intro', versioned())).toEqual({ - type: 'docs-page', + type: RouteType.DocsPage, version: { dir: 'v1', urlPrefix: '/v1' }, slug: ['v1', 'dev', 'intro'], }) @@ -94,7 +94,7 @@ describe('resolveRoute — docs pages', () => { test('unrecognized first segment stays latest (page lookup handles 404)', () => { expect(resolveRoute('/foo/bar', singleContent())).toEqual({ - type: 'docs-page', + type: RouteType.DocsPage, version: LATEST_CONTEXT, slug: ['foo', 'bar'], }) @@ -104,14 +104,14 @@ describe('resolveRoute — docs pages', () => { describe('resolveRoute — APIs', () => { test('latest api index', () => { expect(resolveRoute('/apis', singleContent())).toEqual({ - type: 'api-index', + type: RouteType.ApiIndex, version: LATEST_CONTEXT, }) }) test('latest api page', () => { expect(resolveRoute('/apis/petstore/getPetById', singleContent())).toEqual({ - type: 'api-page', + type: RouteType.ApiPage, version: LATEST_CONTEXT, slug: ['petstore', 'getPetById'], }) @@ -119,7 +119,7 @@ describe('resolveRoute — APIs', () => { test('versioned api index', () => { expect(resolveRoute('/v1/apis', versioned())).toEqual({ - type: 'api-index', + type: RouteType.ApiIndex, version: { dir: 'v1', urlPrefix: '/v1' }, }) }) @@ -128,7 +128,7 @@ describe('resolveRoute — APIs', () => { expect( resolveRoute('/v1/apis/petstore/getPetById', versioned()), ).toEqual({ - type: 'api-page', + type: RouteType.ApiPage, version: { dir: 'v1', urlPrefix: '/v1' }, slug: ['petstore', 'getPetById'], }) @@ -138,14 +138,14 @@ describe('resolveRoute — APIs', () => { describe('resolveRoute — edge cases', () => { test('trailing slash is normalized', () => { expect(resolveRoute('/v1/', versioned())).toEqual({ - type: 'docs-index', + type: RouteType.DocsIndex, version: { dir: 'v1', urlPrefix: '/v1' }, }) }) test('version-shaped path without a matching version stays latest', () => { expect(resolveRoute('/v9/docs', versioned())).toEqual({ - type: 'docs-page', + type: RouteType.DocsPage, version: LATEST_CONTEXT, slug: ['v9', 'docs'], }) diff --git a/packages/chronicle/src/lib/route-resolver.ts b/packages/chronicle/src/lib/route-resolver.ts index 86331a9..dd5b571 100644 --- a/packages/chronicle/src/lib/route-resolver.ts +++ b/packages/chronicle/src/lib/route-resolver.ts @@ -1,12 +1,22 @@ import type { ChronicleConfig } from '@/types' import { type VersionContext, resolveVersionFromUrl } from './version-source' +export const RouteType = { + Redirect: 'redirect', + DocsIndex: 'docs-index', + DocsPage: 'docs-page', + ApiIndex: 'api-index', + ApiPage: 'api-page', +} as const + +export type RouteType = (typeof RouteType)[keyof typeof RouteType] + export type Route = - | { type: 'redirect'; to: string; status: 302 } - | { type: 'docs-index'; version: VersionContext } - | { type: 'docs-page'; version: VersionContext; slug: string[] } - | { type: 'api-index'; version: VersionContext } - | { type: 'api-page'; version: VersionContext; slug: string[] } + | { type: typeof RouteType.Redirect; to: string; status: 302 } + | { type: typeof RouteType.DocsIndex; version: VersionContext } + | { type: typeof RouteType.DocsPage; version: VersionContext; slug: string[] } + | { type: typeof RouteType.ApiIndex; version: VersionContext } + | { type: typeof RouteType.ApiPage; version: VersionContext; slug: string[] } function contentDirsFor( config: ChronicleConfig, @@ -28,21 +38,21 @@ export function resolveRoute( if (remainder[0] === 'apis') { const slug = remainder.slice(1) - if (slug.length === 0) return { type: 'api-index', version } - return { type: 'api-page', version, slug } + if (slug.length === 0) return { type: RouteType.ApiIndex, version } + return { type: RouteType.ApiPage, version, slug } } if (remainder.length === 0) { const dirs = contentDirsFor(config, version) if (dirs.length === 1) { return { - type: 'redirect', + type: RouteType.Redirect, to: `${version.urlPrefix}/${dirs[0]}`, status: 302, } } - return { type: 'docs-index', version } + return { type: RouteType.DocsIndex, version } } - return { type: 'docs-page', version, slug: parts } + return { type: RouteType.DocsPage, version, slug: parts } } From 0e7c0020a75f1f08a919158e06fb7e78b13f0964 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 13:52:09 +0530 Subject: [PATCH 13/55] feat: apply version-aware routing in SSR + client - entry-server.tsx uses resolveRoute: - RouteType.Redirect returns 302 with Location (single-content root -> /; version root -> //) - RouteType.DocsPage loads page + version-scoped tree via getPageTreeForVersion; missing page -> 404 - RouteType.DocsIndex returns 404 today (multi-content landing lands in phase 3B) - API routes load specs only when the URL is actually an API route instead of on every request - PageProvider now takes initialVersion and exposes it via context; client nav re-runs resolveRoute on pathname changes so the version ctx stays in sync - App.tsx delegates to the shared resolver and updates Head/JSON-LD references to read config.site.title (schema change from phase 1) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/page-context.tsx | 56 +++++++++++++------ packages/chronicle/src/server/App.tsx | 32 ++++------- .../chronicle/src/server/entry-server.tsx | 35 ++++++++---- 3 files changed, 76 insertions(+), 47 deletions(-) diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index a421459..c0b820f 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -7,6 +7,9 @@ import { } from 'react'; import { useLocation } from 'react-router'; import type { ApiSpec } from '@/lib/openapi'; +import { resolveRoute, RouteType } from '@/lib/route-resolver'; +import type { VersionContext } from '@/lib/version-source'; +import { LATEST_CONTEXT } from '@/lib/version-source'; import type { ChronicleConfig, Frontmatter, Root, TableOfContents } from '@/types'; export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>; @@ -24,6 +27,7 @@ interface PageContextValue { page: PageData | null; errorStatus: number | null; apiSpecs: ApiSpec[]; + version: VersionContext; } const PageContext = createContext(null); @@ -33,11 +37,12 @@ export function usePageContext(): PageContextValue { if (!ctx) { console.error('usePageContext: no context found!'); return { - config: { title: 'Documentation' }, + config: { site: { title: 'Documentation' }, content: [{ dir: 'docs', label: 'Docs' }] }, tree: { name: 'root', children: [] } as Root, page: null, errorStatus: null, - apiSpecs: [] + apiSpecs: [], + version: LATEST_CONTEXT, }; } return ctx; @@ -48,17 +53,21 @@ interface PageProviderProps { initialTree: Root; initialPage: PageData | null; initialApiSpecs: ApiSpec[]; + initialVersion: VersionContext; loadMdx: MdxLoader; children: ReactNode; } -function isApisRoute(pathname: string): boolean { - return pathname === '/apis' || pathname.startsWith('/apis/'); -} - -function getInitialErrorStatus(page: PageData | null, pathname: string): number | null { +function getInitialErrorStatus( + page: PageData | null, + config: ChronicleConfig, + pathname: string, +): number | null { if (page) return null; - if (pathname === '/' || isApisRoute(pathname)) return null; + const route = resolveRoute(pathname, config); + if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) return null; + if (route.type === RouteType.Redirect) return null; + if (route.type === RouteType.DocsIndex) return 404; return 404; } @@ -67,39 +76,52 @@ export function PageProvider({ initialTree, initialPage, initialApiSpecs, + initialVersion, loadMdx, children }: PageProviderProps) { const { pathname } = useLocation(); const [tree] = useState(initialTree); const [page, setPage] = useState(initialPage); - const [errorStatus, setErrorStatus] = useState(getInitialErrorStatus(initialPage, pathname)); + const [errorStatus, setErrorStatus] = useState( + getInitialErrorStatus(initialPage, initialConfig, pathname), + ); const [apiSpecs, setApiSpecs] = useState(initialApiSpecs); + const [version, setVersion] = useState(initialVersion); const [currentPath, setCurrentPath] = useState(pathname); useEffect(() => { if (pathname === currentPath) return; setCurrentPath(pathname); + const route = resolveRoute(pathname, initialConfig); + setVersion(route.version); + const cancelled = { current: false }; - if (isApisRoute(pathname)) { + if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) { if (apiSpecs.length === 0) { fetch('/api/specs') .then(res => res.json()) .then(specs => { if (!cancelled.current) setApiSpecs(specs); }) - .catch(() => {}); + .catch(() => { + // swallow — api specs are best-effort on client nav + }); } return () => { cancelled.current = true; }; } - const slug = pathname === '/' - ? [] - : pathname.slice(1).split('/').filter(Boolean); + if (route.type !== RouteType.DocsPage) { + setPage(null); + setErrorStatus(route.type === RouteType.DocsIndex ? 404 : null); + return () => { cancelled.current = true; }; + } - const apiPath = slug.length === 0 ? '/api/page' : `/api/page?slug=${slug.join(',')}`; + const apiPath = route.slug.length === 0 + ? '/api/page' + : `/api/page?slug=${route.slug.join(',')}`; fetch(apiPath) .then(res => { @@ -117,7 +139,7 @@ export function PageProvider({ const { content, toc } = await loadMdx(data.originalPath || data.relativePath); if (cancelled.current) return; setErrorStatus(null); - setPage({ slug, frontmatter: data.frontmatter, content, toc }); + setPage({ slug: route.slug, frontmatter: data.frontmatter, content, toc }); }) .catch(() => { if (!cancelled.current) { @@ -131,7 +153,7 @@ export function PageProvider({ return ( {children} diff --git a/packages/chronicle/src/server/App.tsx b/packages/chronicle/src/server/App.tsx index 05f8d81..83537ba 100644 --- a/packages/chronicle/src/server/App.tsx +++ b/packages/chronicle/src/server/App.tsx @@ -4,6 +4,7 @@ import { ThemeProvider } from '@raystack/apsara'; import { useLocation } from 'react-router'; import { Head } from '@/lib/head'; import { usePageContext } from '@/lib/page-context'; +import { resolveRoute, RouteType } from '@/lib/route-resolver'; import { ApiLayout } from '@/pages/ApiLayout'; import { ApiPage } from '@/pages/ApiPage'; import { DocsLayout } from '@/pages/DocsLayout'; @@ -11,39 +12,30 @@ import { DocsPage } from '@/pages/DocsPage'; import type { ChronicleConfig } from '@/types'; import { getThemeConfig } from '@/themes/registry'; -function resolveRoute(pathname: string) { - if (pathname.startsWith('/apis')) { - const slug = pathname - .replace(/^\/apis\/?/, '') - .split('/') - .filter(Boolean); - return { type: 'api' as const, slug }; - } - - const slug = - pathname === '/' ? [] : pathname.slice(1).split('/').filter(Boolean); - return { type: 'docs' as const, slug }; -} - export function App() { const { pathname } = useLocation(); const { config } = usePageContext(); - const route = resolveRoute(pathname); + const route = resolveRoute(pathname, config); const themeConfig = getThemeConfig(config.theme?.name); + const isApi = + route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage; + const apiSlug = route.type === RouteType.ApiPage ? route.slug : []; + const docsSlug = route.type === RouteType.DocsPage ? route.slug : []; + return ( - {route.type === 'api' ? ( + {isApi ? ( - + ) : ( - + )} @@ -53,7 +45,7 @@ export function App() { function RootHead({ config }: { config: ChronicleConfig }) { return ( []) : []; const [tree, page] = await Promise.all([ - getPageTree(), - getPage(slug), + getPageTreeForVersion(route.version), + route.type === RouteType.DocsPage ? getPage(route.slug) : Promise.resolve(null), ]); const relativePath = page ? getRelativePath(page) : null; @@ -37,8 +49,8 @@ export default { const pageData = page ? { - slug, - frontmatter: extractFrontmatter(page, slug[slug.length - 1]), + slug: pageSlug, + frontmatter: extractFrontmatter(page, pageSlug[pageSlug.length - 1]), content: mdxModule?.default ? React.createElement(mdxModule.default, { components: mdxComponents }) : null, @@ -49,7 +61,8 @@ export default { const embeddedData = { config, tree, - slug, + slug: pageSlug, + version: route.version, frontmatter: pageData?.frontmatter ?? null, relativePath, originalPath, @@ -82,6 +95,7 @@ export default { initialTree={tree} initialPage={pageData} initialApiSpecs={apiSpecs} + initialVersion={route.version} loadMdx={async () => ({ content: null, toc: [] })} > @@ -95,8 +109,9 @@ export default { const renderDuration = performance.now() - renderStart; - const isApiRoute = pathname.startsWith('/apis'); - const status = !page && !isApiRoute && slug.length > 0 ? 404 : 200; + const status = route.type === RouteType.DocsPage && !page ? 404 + : route.type === RouteType.DocsIndex ? 404 + : 200; useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration); From 8f6cfedfd870d91b766f540a0bb494a9dd69daae Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 13:53:46 +0530 Subject: [PATCH 14/55] fix: wire initialVersion into hydration + guard redirect narrowing - entry-client now forwards embedded.version into PageProvider and uses the shared resolveRoute to decide when to fetch /api/specs (previously hard-coded to pathname.startsWith('/apis'), which missed //apis) - PageProvider only pushes version state when the route actually carries one (Redirect has no version payload) - Default config fallback matches the new {site, content[]} shape Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/page-context.tsx | 2 +- .../chronicle/src/server/entry-client.tsx | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index c0b820f..dea8080 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -95,7 +95,7 @@ export function PageProvider({ setCurrentPath(pathname); const route = resolveRoute(pathname, initialConfig); - setVersion(route.version); + if (route.type !== RouteType.Redirect) setVersion(route.version); const cancelled = { current: false }; diff --git a/packages/chronicle/src/server/entry-client.tsx b/packages/chronicle/src/server/entry-client.tsx index f972f85..dbad5ab 100644 --- a/packages/chronicle/src/server/entry-client.tsx +++ b/packages/chronicle/src/server/entry-client.tsx @@ -5,6 +5,8 @@ import { BrowserRouter } from 'react-router'; import { ReactRouterProvider } from 'fumadocs-core/framework/react-router'; import { mdxComponents } from '@/components/mdx'; import { PageProvider } from '@/lib/page-context'; +import { resolveRoute, RouteType } from '@/lib/route-resolver'; +import { LATEST_CONTEXT, type VersionContext } from '@/lib/version-source'; import type { ChronicleConfig, Frontmatter, Root, TableOfContents } from '@/types'; import type { ApiSpec } from '@/lib/openapi'; import type { ReactNode } from 'react'; @@ -14,11 +16,17 @@ interface EmbeddedData { config: ChronicleConfig; tree: Root; slug: string[]; + version: VersionContext; frontmatter: Frontmatter; relativePath: string; originalPath?: string; } +const defaultConfig: ChronicleConfig = { + site: { title: 'Documentation' }, + content: [{ dir: 'docs', label: 'Docs' }], +}; + const contentModules = import.meta.glob<{ default?: React.ComponentType; toc?: TableOfContents }>( '../../.content/**/*.{mdx,md}' ); @@ -43,12 +51,14 @@ async function hydrate() { window as unknown as { __PAGE_DATA__?: EmbeddedData } ).__PAGE_DATA__; - const config: ChronicleConfig = embedded?.config ?? { - title: 'Documentation' - }; + const config: ChronicleConfig = embedded?.config ?? defaultConfig; const tree: Root = embedded?.tree ?? { name: 'root', children: [] }; + const version: VersionContext = embedded?.version ?? LATEST_CONTEXT; + + const route = resolveRoute(window.location.pathname, config); const isApiPage = - window.location.pathname.startsWith('/apis') && !!config.api?.length; + (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) && + !!config.api?.length; const apiSpecs: ApiSpec[] = isApiPage ? await fetch('/api/specs') .then(r => r.json()) @@ -73,6 +83,7 @@ async function hydrate() { initialTree={tree} initialPage={page} initialApiSpecs={apiSpecs} + initialVersion={version} loadMdx={loadMdxModule} > From 8a145eae051a854ffa80bf667805d2fa1e00d3a6 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 13:59:06 +0530 Subject: [PATCH 15/55] feat: multi-content landing page for docs-index route - getLandingEntries(config, versionDir) returns {label, href, contentDir} per content root (null = latest) so both UI and tests share the same source of truth - LandingPage renders a grid of cards from getLandingEntries; it reads version via usePageContext so a versioned / root (multi-content) shows that version's dirs + labels - App.tsx routes RouteType.DocsIndex to LandingPage inside DocsLayout; entry-server returns 200 and PageProvider stops forcing a 404 for docs-index - Tests: getLandingEntries covers latest, versioned, and unknown version cases Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/config.test.ts | 47 ++++++++++++++++ packages/chronicle/src/lib/config.ts | 22 ++++++++ packages/chronicle/src/lib/page-context.tsx | 4 +- .../src/pages/LandingPage.module.css | 56 +++++++++++++++++++ packages/chronicle/src/pages/LandingPage.tsx | 38 +++++++++++++ packages/chronicle/src/server/App.tsx | 4 +- .../chronicle/src/server/entry-server.tsx | 5 +- 7 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 packages/chronicle/src/pages/LandingPage.module.css create mode 100644 packages/chronicle/src/pages/LandingPage.tsx diff --git a/packages/chronicle/src/lib/config.test.ts b/packages/chronicle/src/lib/config.test.ts index af24032..8b53269 100644 --- a/packages/chronicle/src/lib/config.test.ts +++ b/packages/chronicle/src/lib/config.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { chronicleConfigSchema } from '@/types' import { getAllVersions, + getLandingEntries, getLatestContentRoots, getVersionContentRoots, loadConfig, @@ -274,6 +275,52 @@ describe('getAllVersions', () => { }) }) +describe('getLandingEntries', () => { + test('returns labels + unprefixed hrefs for latest', () => { + const cfg = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [ + { dir: 'docs', label: 'Docs' }, + { dir: 'dev', label: 'Dev' }, + ], + }) + expect(getLandingEntries(cfg, null)).toEqual([ + { label: 'Docs', href: '/docs', contentDir: 'docs' }, + { label: 'Dev', href: '/dev', contentDir: 'dev' }, + ]) + }) + + test('returns versioned hrefs for a version', () => { + const cfg = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + latest: { label: '3.0' }, + versions: [ + { + dir: 'v1', + label: '1.0', + content: [ + { dir: 'dev', label: 'Developer Guide' }, + { dir: 'docs', label: 'Docs' }, + ], + }, + ], + }) + expect(getLandingEntries(cfg, 'v1')).toEqual([ + { label: 'Developer Guide', href: '/v1/dev', contentDir: 'dev' }, + { label: 'Docs', href: '/v1/docs', contentDir: 'docs' }, + ]) + }) + + test('returns empty array for unknown version', () => { + const cfg = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + }) + expect(getLandingEntries(cfg, 'v9')).toEqual([]) + }) +}) + describe('loadConfig', () => { beforeEach(() => { delete g.__CHRONICLE_CONFIG_RAW__ diff --git a/packages/chronicle/src/lib/config.ts b/packages/chronicle/src/lib/config.ts index 1b1cbbb..13bdbc9 100644 --- a/packages/chronicle/src/lib/config.ts +++ b/packages/chronicle/src/lib/config.ts @@ -68,6 +68,28 @@ export interface VersionDescriptor { isLatest: boolean } +export interface LandingEntry { + label: string + href: string + contentDir: string +} + +export function getLandingEntries( + config: ChronicleConfig, + versionDir: string | null, +): LandingEntry[] { + const roots = + versionDir === null + ? getLatestContentRoots(config) + : getVersionContentRoots(config, versionDir) + + return roots.map((r) => ({ + label: r.contentLabel, + href: r.urlPrefix, + contentDir: r.contentDir, + })) +} + export function getAllVersions(config: ChronicleConfig): VersionDescriptor[] { const result: VersionDescriptor[] = [] diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index dea8080..a3697ba 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -67,7 +67,7 @@ function getInitialErrorStatus( const route = resolveRoute(pathname, config); if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) return null; if (route.type === RouteType.Redirect) return null; - if (route.type === RouteType.DocsIndex) return 404; + if (route.type === RouteType.DocsIndex) return null; return 404; } @@ -115,7 +115,7 @@ export function PageProvider({ if (route.type !== RouteType.DocsPage) { setPage(null); - setErrorStatus(route.type === RouteType.DocsIndex ? 404 : null); + setErrorStatus(null); return () => { cancelled.current = true; }; } diff --git a/packages/chronicle/src/pages/LandingPage.module.css b/packages/chronicle/src/pages/LandingPage.module.css new file mode 100644 index 0000000..a18659b --- /dev/null +++ b/packages/chronicle/src/pages/LandingPage.module.css @@ -0,0 +1,56 @@ +.root { + display: flex; + flex-direction: column; + gap: var(--rs-space-8); + padding: var(--rs-space-9) var(--rs-space-7); + max-width: 960px; + margin: 0 auto; +} + +.title { + font-size: var(--rs-font-size-h3); + font-weight: 600; + color: var(--rs-color-foreground-base-primary); + margin: 0; +} + +.description { + font-size: var(--rs-font-size-regular); + color: var(--rs-color-foreground-base-secondary); + margin: 0; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: var(--rs-space-6); +} + +.card { + display: flex; + flex-direction: column; + gap: var(--rs-space-3); + padding: var(--rs-space-6); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-3); + text-decoration: none; + color: inherit; + background: var(--rs-color-background-base-primary); + transition: border-color 0.15s ease, background 0.15s ease; +} + +.card:hover { + border-color: var(--rs-color-border-accent-primary); + background: var(--rs-color-background-neutral-secondary); +} + +.cardLabel { + font-size: var(--rs-font-size-large); + font-weight: 500; +} + +.cardHref { + font-size: var(--rs-font-size-small); + color: var(--rs-color-foreground-base-secondary); + font-family: var(--rs-font-family-mono); +} diff --git a/packages/chronicle/src/pages/LandingPage.tsx b/packages/chronicle/src/pages/LandingPage.tsx new file mode 100644 index 0000000..d6de902 --- /dev/null +++ b/packages/chronicle/src/pages/LandingPage.tsx @@ -0,0 +1,38 @@ +import { getLandingEntries } from '@/lib/config'; +import { usePageContext } from '@/lib/page-context'; +import styles from './LandingPage.module.css'; + +export function LandingPage() { + const { config, version } = usePageContext(); + const entries = getLandingEntries(config, version.dir); + + const heading = version.dir === null + ? config.site.title + : `${config.site.title} — ${versionLabel(config, version.dir)}`; + + return ( +
+

{heading}

+ {config.description ? ( +

{config.description}

+ ) : null} +
+ {entries.map((entry) => ( + + {entry.label} + {entry.href} + + ))} +
+
+ ); +} + +function versionLabel( + config: ReturnType['config'], + versionDir: string, +): string { + return ( + config.versions?.find((v) => v.dir === versionDir)?.label ?? versionDir + ); +} diff --git a/packages/chronicle/src/server/App.tsx b/packages/chronicle/src/server/App.tsx index 83537ba..6d5157b 100644 --- a/packages/chronicle/src/server/App.tsx +++ b/packages/chronicle/src/server/App.tsx @@ -9,6 +9,7 @@ import { ApiLayout } from '@/pages/ApiLayout'; import { ApiPage } from '@/pages/ApiPage'; import { DocsLayout } from '@/pages/DocsLayout'; import { DocsPage } from '@/pages/DocsPage'; +import { LandingPage } from '@/pages/LandingPage'; import type { ChronicleConfig } from '@/types'; import { getThemeConfig } from '@/themes/registry'; @@ -22,6 +23,7 @@ export function App() { route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage; const apiSlug = route.type === RouteType.ApiPage ? route.slug : []; const docsSlug = route.type === RouteType.DocsPage ? route.slug : []; + const isLanding = route.type === RouteType.DocsIndex; return ( ) : ( - + {isLanding ? : } )} diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx index c9bef25..fe4d6e5 100644 --- a/packages/chronicle/src/server/entry-server.tsx +++ b/packages/chronicle/src/server/entry-server.tsx @@ -109,10 +109,9 @@ export default { const renderDuration = performance.now() - renderStart; - const status = route.type === RouteType.DocsPage && !page ? 404 - : route.type === RouteType.DocsIndex ? 404 - : 200; + const status = route.type === RouteType.DocsPage && !page ? 404 : 200; + // biome-ignore lint/correctness/useHookAtTopLevel: useNitroApp is a Nitro DI accessor, not a React hook useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration); return new Response(stream, { From 4067ff82a180aed5708f2d944ad89e03f6f36192 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 14:02:57 +0530 Subject: [PATCH 16/55] feat: per-version API specs - getApiConfigsForVersion(config, dir|null) picks config.api for latest or versions[].api for a version; tests cover both - /api/specs accepts ?version=; entry-server and entry-client pass the active version so /apis and //apis resolve to their own spec set - page-context always refetches on api-nav so switching versions from the api page updates the spec list Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/config.test.ts | 46 +++++++++++++++++++ packages/chronicle/src/lib/config.ts | 11 +++++ packages/chronicle/src/lib/page-context.tsx | 21 +++++---- packages/chronicle/src/server/api/specs.ts | 13 ++++-- .../chronicle/src/server/entry-client.tsx | 16 +++++-- .../chronicle/src/server/entry-server.tsx | 9 ++-- 6 files changed, 94 insertions(+), 22 deletions(-) diff --git a/packages/chronicle/src/lib/config.test.ts b/packages/chronicle/src/lib/config.test.ts index 8b53269..119b135 100644 --- a/packages/chronicle/src/lib/config.test.ts +++ b/packages/chronicle/src/lib/config.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { chronicleConfigSchema } from '@/types' import { getAllVersions, + getApiConfigsForVersion, getLandingEntries, getLatestContentRoots, getVersionContentRoots, @@ -321,6 +322,51 @@ describe('getLandingEntries', () => { }) }) +describe('getApiConfigsForVersion', () => { + const apiFixture = { + name: 'Petstore', + spec: './petstore.json', + basePath: '/apis', + server: { url: 'https://petstore.example.com' }, + } + + test('returns config.api for latest (null)', () => { + const cfg = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + api: [apiFixture], + }) + expect(getApiConfigsForVersion(cfg, null)).toEqual([apiFixture]) + }) + + test('returns versions[].api for a matching version', () => { + const versionedApi = { ...apiFixture, spec: './v1-petstore.json' } + const cfg = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + latest: { label: '3.0' }, + versions: [ + { + dir: 'v1', + label: '1.0', + content: [{ dir: 'docs', label: 'Docs' }], + api: [versionedApi], + }, + ], + }) + expect(getApiConfigsForVersion(cfg, 'v1')).toEqual([versionedApi]) + }) + + test('returns [] for unknown version or missing api', () => { + const cfg = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + }) + expect(getApiConfigsForVersion(cfg, 'v9')).toEqual([]) + expect(getApiConfigsForVersion(cfg, null)).toEqual([]) + }) +}) + describe('loadConfig', () => { beforeEach(() => { delete g.__CHRONICLE_CONFIG_RAW__ diff --git a/packages/chronicle/src/lib/config.ts b/packages/chronicle/src/lib/config.ts index 13bdbc9..c4f66b1 100644 --- a/packages/chronicle/src/lib/config.ts +++ b/packages/chronicle/src/lib/config.ts @@ -1,6 +1,7 @@ import path from 'node:path' import { parse } from 'yaml' import { + type ApiConfig, type BadgeConfig, type ChronicleConfig, chronicleConfigSchema, @@ -90,6 +91,16 @@ export function getLandingEntries( })) } +export function getApiConfigsForVersion( + config: ChronicleConfig, + versionDir: string | null, +): ApiConfig[] { + if (versionDir === null) return config.api ?? [] + return ( + config.versions?.find((v) => v.dir === versionDir)?.api ?? [] + ) +} + export function getAllVersions(config: ChronicleConfig): VersionDescriptor[] { const result: VersionDescriptor[] = [] diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index a3697ba..1506cdd 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -100,16 +100,17 @@ export function PageProvider({ const cancelled = { current: false }; if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) { - if (apiSpecs.length === 0) { - fetch('/api/specs') - .then(res => res.json()) - .then(specs => { - if (!cancelled.current) setApiSpecs(specs); - }) - .catch(() => { - // swallow — api specs are best-effort on client nav - }); - } + const specsUrl = route.version.dir + ? `/api/specs?version=${encodeURIComponent(route.version.dir)}` + : '/api/specs'; + fetch(specsUrl) + .then(res => res.json()) + .then(specs => { + if (!cancelled.current) setApiSpecs(specs); + }) + .catch(() => { + // swallow — api specs are best-effort on client nav + }); return () => { cancelled.current = true; }; } diff --git a/packages/chronicle/src/server/api/specs.ts b/packages/chronicle/src/server/api/specs.ts index 3d4f1f7..3af3b2a 100644 --- a/packages/chronicle/src/server/api/specs.ts +++ b/packages/chronicle/src/server/api/specs.ts @@ -1,9 +1,14 @@ import { defineHandler } from 'nitro'; -import { loadConfig } from '@/lib/config'; +import { getApiConfigsForVersion, loadConfig } from '@/lib/config'; import { loadApiSpecs } from '@/lib/openapi'; -export default defineHandler(async () => { +export default defineHandler(async event => { + const versionParam = event.url.searchParams.get('version'); + const versionDir = versionParam === null || versionParam === '' ? null : versionParam; + const config = loadConfig(); - const specs = config.api?.length ? await loadApiSpecs(config.api) : []; - return specs; + const apiConfigs = getApiConfigsForVersion(config, versionDir); + if (!apiConfigs.length) return []; + + return loadApiSpecs(apiConfigs); }); diff --git a/packages/chronicle/src/server/entry-client.tsx b/packages/chronicle/src/server/entry-client.tsx index dbad5ab..ac4fb99 100644 --- a/packages/chronicle/src/server/entry-client.tsx +++ b/packages/chronicle/src/server/entry-client.tsx @@ -4,6 +4,7 @@ import { hydrateRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router'; import { ReactRouterProvider } from 'fumadocs-core/framework/react-router'; import { mdxComponents } from '@/components/mdx'; +import { getApiConfigsForVersion } from '@/lib/config'; import { PageProvider } from '@/lib/page-context'; import { resolveRoute, RouteType } from '@/lib/route-resolver'; import { LATEST_CONTEXT, type VersionContext } from '@/lib/version-source'; @@ -56,11 +57,16 @@ async function hydrate() { const version: VersionContext = embedded?.version ?? LATEST_CONTEXT; const route = resolveRoute(window.location.pathname, config); - const isApiPage = - (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) && - !!config.api?.length; - const apiSpecs: ApiSpec[] = isApiPage - ? await fetch('/api/specs') + const isApiRoute = + route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage; + const apiConfigs = isApiRoute + ? getApiConfigsForVersion(config, version.dir) + : []; + const specsUrl = version.dir + ? `/api/specs?version=${encodeURIComponent(version.dir)}` + : '/api/specs'; + const apiSpecs: ApiSpec[] = apiConfigs.length + ? await fetch(specsUrl) .then(r => r.json()) .catch(() => []) : []; diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx index fe4d6e5..fe8bb68 100644 --- a/packages/chronicle/src/server/entry-server.tsx +++ b/packages/chronicle/src/server/entry-server.tsx @@ -5,7 +5,7 @@ import { renderToReadableStream } from 'react-dom/server.edge'; import { StaticRouter } from 'react-router'; import { ReactRouterProvider } from 'fumadocs-core/framework/react-router'; import { mdxComponents } from '@/components/mdx'; -import { loadConfig } from '@/lib/config'; +import { getApiConfigsForVersion, loadConfig } from '@/lib/config'; import { loadApiSpecs } from '@/lib/openapi'; import { PageProvider } from '@/lib/page-context'; import { resolveRoute, RouteType } from '@/lib/route-resolver'; @@ -34,8 +34,11 @@ export default { const isApiRoute = route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage; const pageSlug = route.type === RouteType.DocsPage ? route.slug : []; - const apiSpecs = isApiRoute && config.api?.length - ? await loadApiSpecs(config.api).catch(() => []) + const apiConfigs = isApiRoute + ? getApiConfigsForVersion(config, route.version.dir) + : []; + const apiSpecs = apiConfigs.length + ? await loadApiSpecs(apiConfigs).catch(() => []) : []; const [tree, page] = await Promise.all([ From ef102e8f795ace7cd6dcbb413d7ff6e7dafb2165 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 14:06:45 +0530 Subject: [PATCH 17/55] feat: version-aware server routes (llms, sitemap, og) - buildLlmsTxt(config, pages, version) centralises the llms.txt rendering so /llms.txt and //llms.txt share one codepath - /llms.txt now scopes to LATEST_CONTEXT via getPagesForVersion - New //llms.txt handler looks up the version from config, 404s for unknown, and emits pages from getPagesForVersion(ctx) - sitemap.xml iterates getAllVersions and emits //apis/... for each version's api specs (latest stays unprefixed) - og.tsx reads config.site.title (schema change from phase 1) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/llms.test.ts | 67 +++++++++++++++++++ packages/chronicle/src/lib/llms.ts | 37 ++++++++++ .../src/server/routes/[version]/llms.txt.ts | 29 ++++++++ .../chronicle/src/server/routes/llms.txt.ts | 17 ++--- packages/chronicle/src/server/routes/og.tsx | 4 +- .../src/server/routes/sitemap.xml.ts | 20 ++++-- 6 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 packages/chronicle/src/lib/llms.test.ts create mode 100644 packages/chronicle/src/lib/llms.ts create mode 100644 packages/chronicle/src/server/routes/[version]/llms.txt.ts diff --git a/packages/chronicle/src/lib/llms.test.ts b/packages/chronicle/src/lib/llms.test.ts new file mode 100644 index 0000000..30e92fa --- /dev/null +++ b/packages/chronicle/src/lib/llms.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' +import { chronicleConfigSchema } from '@/types' +import { buildLlmsTxt } from './llms' +import { LATEST_CONTEXT } from './version-source' + +describe('buildLlmsTxt', () => { + test('uses site.title and appends latest label when set', () => { + const config = chronicleConfigSchema.parse({ + site: { title: 'My Docs' }, + content: [{ dir: 'docs', label: 'Docs' }], + latest: { label: '3.0' }, + versions: [ + { + dir: 'v1', + label: '1.0', + content: [{ dir: 'docs', label: 'Docs' }], + }, + ], + }) + + const out = buildLlmsTxt( + config, + [ + { url: '/docs/a', title: 'A' }, + { url: '/', title: 'Home' }, + ], + LATEST_CONTEXT, + ) + + expect(out).toContain('# My Docs — 3.0') + expect(out).toContain('- [A](/docs/a.md)') + expect(out).toContain('- [Home](/index.md)') + }) + + test('heading has no version label when latest is absent and ctx is latest', () => { + const config = chronicleConfigSchema.parse({ + site: { title: 'My Docs' }, + content: [{ dir: 'docs', label: 'Docs' }], + }) + + const out = buildLlmsTxt(config, [], LATEST_CONTEXT) + expect(out.startsWith('# My Docs\n')).toBe(true) + }) + + test('uses the version label for a versioned ctx', () => { + const config = chronicleConfigSchema.parse({ + site: { title: 'My Docs' }, + content: [{ dir: 'docs', label: 'Docs' }], + latest: { label: '3.0' }, + versions: [ + { + dir: 'v1', + label: '1.0', + content: [{ dir: 'docs', label: 'Docs' }], + }, + ], + }) + + const out = buildLlmsTxt( + config, + [{ url: '/v1/docs/a', title: 'A' }], + { dir: 'v1', urlPrefix: '/v1' }, + ) + expect(out).toContain('# My Docs — 1.0') + expect(out).toContain('- [A](/v1/docs/a.md)') + }) +}) diff --git a/packages/chronicle/src/lib/llms.ts b/packages/chronicle/src/lib/llms.ts new file mode 100644 index 0000000..24d1974 --- /dev/null +++ b/packages/chronicle/src/lib/llms.ts @@ -0,0 +1,37 @@ +import type { ChronicleConfig } from '@/types' +import type { VersionContext } from './version-source' + +export interface LlmsPage { + url: string + title: string +} + +export function buildLlmsTxt( + config: ChronicleConfig, + pages: LlmsPage[], + version: VersionContext, +): string { + const versionLabel = getVersionLabel(config, version) + const heading = versionLabel + ? `# ${config.site.title} — ${versionLabel}` + : `# ${config.site.title}` + + const description = config.description ?? '' + + const index = pages + .map((p) => { + const mdUrl = p.url === '/' ? '/index.md' : `${p.url}.md` + return `- [${p.title}](${mdUrl})` + }) + .join('\n') + + return `${heading}\n\n${description}\n\n${index}` +} + +function getVersionLabel( + config: ChronicleConfig, + version: VersionContext, +): string | null { + if (version.dir === null) return config.latest?.label ?? null + return config.versions?.find((v) => v.dir === version.dir)?.label ?? null +} diff --git a/packages/chronicle/src/server/routes/[version]/llms.txt.ts b/packages/chronicle/src/server/routes/[version]/llms.txt.ts new file mode 100644 index 0000000..31540d4 --- /dev/null +++ b/packages/chronicle/src/server/routes/[version]/llms.txt.ts @@ -0,0 +1,29 @@ +import { defineHandler, HTTPError } from 'nitro'; +import { loadConfig } from '@/lib/config'; +import { buildLlmsTxt } from '@/lib/llms'; +import { extractFrontmatter, getPagesForVersion } from '@/lib/source'; + +export default defineHandler(async event => { + const config = loadConfig(); + + if (!config.llms?.enabled) { + throw new HTTPError({ status: 404, message: 'Not Found' }); + } + + const versionDir = event.params?.version; + const version = config.versions?.find(v => v.dir === versionDir); + if (!version) { + throw new HTTPError({ status: 404, message: 'Not Found' }); + } + + const ctx = { dir: version.dir, urlPrefix: `/${version.dir}` }; + const pages = await getPagesForVersion(ctx); + const body = buildLlmsTxt( + config, + pages.map(p => ({ url: p.url, title: extractFrontmatter(p).title })), + ctx, + ); + + event.res.headers.set('Content-Type', 'text/plain'); + return body; +}); diff --git a/packages/chronicle/src/server/routes/llms.txt.ts b/packages/chronicle/src/server/routes/llms.txt.ts index 46fb6e0..a0939f6 100644 --- a/packages/chronicle/src/server/routes/llms.txt.ts +++ b/packages/chronicle/src/server/routes/llms.txt.ts @@ -1,6 +1,8 @@ import { defineHandler, HTTPError } from 'nitro'; import { loadConfig } from '@/lib/config'; -import { getPages, extractFrontmatter } from '@/lib/source'; +import { buildLlmsTxt } from '@/lib/llms'; +import { extractFrontmatter, getPagesForVersion } from '@/lib/source'; +import { LATEST_CONTEXT } from '@/lib/version-source'; export default defineHandler(async event => { const config = loadConfig(); @@ -9,13 +11,12 @@ export default defineHandler(async event => { throw new HTTPError({ status: 404, message: 'Not Found' }); } - const pages = await getPages(); - const index = pages.map(p => { - const fm = extractFrontmatter(p); - const mdUrl = p.url === '/' ? '/index.md' : `${p.url}.md`; - return `- [${fm.title}](${mdUrl})`; - }).join('\n'); - const body = `# ${config.title}\n\n${config.description ?? ''}\n\n${index}`; + const pages = await getPagesForVersion(LATEST_CONTEXT); + const body = buildLlmsTxt( + config, + pages.map(p => ({ url: p.url, title: extractFrontmatter(p).title })), + LATEST_CONTEXT, + ); event.res.headers.set('Content-Type', 'text/plain'); return body; diff --git a/packages/chronicle/src/server/routes/og.tsx b/packages/chronicle/src/server/routes/og.tsx index 2fb15ca..30d647e 100644 --- a/packages/chronicle/src/server/routes/og.tsx +++ b/packages/chronicle/src/server/routes/og.tsx @@ -22,9 +22,9 @@ async function loadFont(): Promise { export default defineHandler(async event => { const config = loadConfig(); - const title = event.url.searchParams.get('title') ?? config.title; + const title = event.url.searchParams.get('title') ?? config.site.title; const description = event.url.searchParams.get('description') ?? ''; - const siteName = config.title; + const siteName = config.site.title; const font = await loadFont(); diff --git a/packages/chronicle/src/server/routes/sitemap.xml.ts b/packages/chronicle/src/server/routes/sitemap.xml.ts index ea93437..41ecf65 100644 --- a/packages/chronicle/src/server/routes/sitemap.xml.ts +++ b/packages/chronicle/src/server/routes/sitemap.xml.ts @@ -1,6 +1,6 @@ import { defineHandler } from 'nitro'; import { buildApiRoutes } from '@/lib/api-routes'; -import { loadConfig } from '@/lib/config'; +import { getAllVersions, getApiConfigsForVersion, loadConfig } from '@/lib/config'; import { loadApiSpecs } from '@/lib/openapi'; import { getPages } from '@/lib/source'; @@ -23,11 +23,19 @@ export default defineHandler(async event => { return `${baseUrl}/${page.slugs.join('/')}${lastmod}`; }); - const apiPages = config.api?.length - ? buildApiRoutes(await loadApiSpecs(config.api)).map( - route => `${baseUrl}/apis/${route.slug.join('/')}` - ) - : []; + const apiPages: string[] = []; + for (const v of getAllVersions(config)) { + const versionDir = v.isLatest ? null : v.dir; + const apiConfigs = getApiConfigsForVersion(config, versionDir); + if (!apiConfigs.length) continue; + const prefix = versionDir ? `/${versionDir}` : ''; + const routes = buildApiRoutes(await loadApiSpecs(apiConfigs)); + for (const route of routes) { + apiPages.push( + `${baseUrl}${prefix}/apis/${route.slug.join('/')}`, + ); + } + } const xml = ` From ab59b24a70af7f6a4bd153d341298b73250c214e Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 14:10:38 +0530 Subject: [PATCH 18/55] fix: remaining config.title references + init template - head.tsx, default/paper Layout.tsx, ApiPage.tsx read config.site.title - init.ts defaultConfig matches the new {site, content[]} schema and scaffolds content//index.mdx so chronicle dev finds the mirror entry; drops --content flag that no longer maps to the config shape - [version]/llms.txt.ts reads the version param via h3's getRouterParam (nitro doesn't re-export it) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/cli/commands/init.ts | 9 +++++---- packages/chronicle/src/lib/head.tsx | 4 ++-- packages/chronicle/src/pages/ApiPage.tsx | 2 +- .../chronicle/src/server/routes/[version]/llms.txt.ts | 3 ++- packages/chronicle/src/themes/default/Layout.tsx | 2 +- packages/chronicle/src/themes/paper/Layout.tsx | 2 +- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/chronicle/src/cli/commands/init.ts b/packages/chronicle/src/cli/commands/init.ts index d72734d..2893ca7 100644 --- a/packages/chronicle/src/cli/commands/init.ts +++ b/packages/chronicle/src/cli/commands/init.ts @@ -6,8 +6,9 @@ import { stringify } from 'yaml'; import type { ChronicleConfig } from '@/types'; const defaultConfig: ChronicleConfig = { - title: 'My Documentation', + site: { title: 'My Documentation' }, description: 'Documentation powered by Chronicle', + content: [{ dir: 'docs', label: 'Docs' }], theme: { name: 'default' }, search: { enabled: true, placeholder: 'Search documentation...' } }; @@ -25,10 +26,10 @@ This is your documentation home page. export const initCommand = new Command('init') .description('Initialize a new Chronicle project') - .option('-c, --content ', 'Content directory name', 'content') - .action(options => { + .action(() => { const projectDir = process.cwd(); - const contentDir = path.join(projectDir, options.content); + const defaultDir = defaultConfig.content[0].dir; + const contentDir = path.join(projectDir, 'content', defaultDir); if (!fs.existsSync(contentDir)) { fs.mkdirSync(contentDir, { recursive: true }); diff --git a/packages/chronicle/src/lib/head.tsx b/packages/chronicle/src/lib/head.tsx index 74b0621..7f4df01 100644 --- a/packages/chronicle/src/lib/head.tsx +++ b/packages/chronicle/src/lib/head.tsx @@ -8,7 +8,7 @@ export interface HeadProps { } export function Head({ title, description, config, jsonLd }: HeadProps) { - const fullTitle = `${title} | ${config.title}`; + const fullTitle = `${title} | ${config.site.title}`; const ogParams = new URLSearchParams({ title }); if (description) ogParams.set('description', description); @@ -23,7 +23,7 @@ export function Head({ title, description, config, jsonLd }: HeadProps) { {description && ( )} - + diff --git a/packages/chronicle/src/pages/ApiPage.tsx b/packages/chronicle/src/pages/ApiPage.tsx index 84858c5..8143ea2 100644 --- a/packages/chronicle/src/pages/ApiPage.tsx +++ b/packages/chronicle/src/pages/ApiPage.tsx @@ -18,7 +18,7 @@ export function ApiPage({ slug }: ApiPageProps) { <> diff --git a/packages/chronicle/src/server/routes/[version]/llms.txt.ts b/packages/chronicle/src/server/routes/[version]/llms.txt.ts index 31540d4..b0e8603 100644 --- a/packages/chronicle/src/server/routes/[version]/llms.txt.ts +++ b/packages/chronicle/src/server/routes/[version]/llms.txt.ts @@ -1,3 +1,4 @@ +import { getRouterParam } from 'h3'; import { defineHandler, HTTPError } from 'nitro'; import { loadConfig } from '@/lib/config'; import { buildLlmsTxt } from '@/lib/llms'; @@ -10,7 +11,7 @@ export default defineHandler(async event => { throw new HTTPError({ status: 404, message: 'Not Found' }); } - const versionDir = event.params?.version; + const versionDir = getRouterParam(event, 'version'); const version = config.versions?.find(v => v.dir === versionDir); if (!version) { throw new HTTPError({ status: 404, message: 'Not Found' }); diff --git a/packages/chronicle/src/themes/default/Layout.tsx b/packages/chronicle/src/themes/default/Layout.tsx index 625784d..aa90d32 100644 --- a/packages/chronicle/src/themes/default/Layout.tsx +++ b/packages/chronicle/src/themes/default/Layout.tsx @@ -65,7 +65,7 @@ export function Layout({ style={{ textDecoration: 'none', color: 'inherit' }} > - {config.title} + {config.site.title} diff --git a/packages/chronicle/src/themes/paper/Layout.tsx b/packages/chronicle/src/themes/paper/Layout.tsx index 805dcd2..ec4ffac 100644 --- a/packages/chronicle/src/themes/paper/Layout.tsx +++ b/packages/chronicle/src/themes/paper/Layout.tsx @@ -23,7 +23,7 @@ export function Layout({ as='h1' className={styles.title} > - {config.title} + {config.site.title} From 9ad768defe73e49fe72dc487b67705e1b9f74460 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 14:16:09 +0530 Subject: [PATCH 19/55] feat!: move description under site description is now a site-level field in chronicleConfigSchema. Callers (llms.ts, LandingPage, App Head/JSON-LD, init template, page-context fallback) read config.site.description. BREAKING CHANGE: top-level `description` is no longer accepted; move it under `site:` in chronicle.yaml. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/{ => content/docs}/cli.mdx | 0 docs/{ => content/docs}/components.mdx | 0 docs/{ => content/docs}/configuration.mdx | 0 docs/{ => content/docs}/docker.mdx | 0 docs/{ => content/docs}/frontmatter.mdx | 0 docs/{ => content/docs}/index.mdx | 0 docs/{ => content/docs}/themes.mdx | 0 packages/chronicle/src/cli/commands/init.ts | 6 ++++-- packages/chronicle/src/lib/llms.ts | 2 +- packages/chronicle/src/lib/page-context.tsx | 5 ++++- packages/chronicle/src/pages/LandingPage.tsx | 4 ++-- packages/chronicle/src/server/App.tsx | 4 ++-- packages/chronicle/src/types/config.ts | 4 ++-- 13 files changed, 15 insertions(+), 10 deletions(-) rename docs/{ => content/docs}/cli.mdx (100%) rename docs/{ => content/docs}/components.mdx (100%) rename docs/{ => content/docs}/configuration.mdx (100%) rename docs/{ => content/docs}/docker.mdx (100%) rename docs/{ => content/docs}/frontmatter.mdx (100%) rename docs/{ => content/docs}/index.mdx (100%) rename docs/{ => content/docs}/themes.mdx (100%) diff --git a/docs/cli.mdx b/docs/content/docs/cli.mdx similarity index 100% rename from docs/cli.mdx rename to docs/content/docs/cli.mdx diff --git a/docs/components.mdx b/docs/content/docs/components.mdx similarity index 100% rename from docs/components.mdx rename to docs/content/docs/components.mdx diff --git a/docs/configuration.mdx b/docs/content/docs/configuration.mdx similarity index 100% rename from docs/configuration.mdx rename to docs/content/docs/configuration.mdx diff --git a/docs/docker.mdx b/docs/content/docs/docker.mdx similarity index 100% rename from docs/docker.mdx rename to docs/content/docs/docker.mdx diff --git a/docs/frontmatter.mdx b/docs/content/docs/frontmatter.mdx similarity index 100% rename from docs/frontmatter.mdx rename to docs/content/docs/frontmatter.mdx diff --git a/docs/index.mdx b/docs/content/docs/index.mdx similarity index 100% rename from docs/index.mdx rename to docs/content/docs/index.mdx diff --git a/docs/themes.mdx b/docs/content/docs/themes.mdx similarity index 100% rename from docs/themes.mdx rename to docs/content/docs/themes.mdx diff --git a/packages/chronicle/src/cli/commands/init.ts b/packages/chronicle/src/cli/commands/init.ts index 2893ca7..4cc45a7 100644 --- a/packages/chronicle/src/cli/commands/init.ts +++ b/packages/chronicle/src/cli/commands/init.ts @@ -6,8 +6,10 @@ import { stringify } from 'yaml'; import type { ChronicleConfig } from '@/types'; const defaultConfig: ChronicleConfig = { - site: { title: 'My Documentation' }, - description: 'Documentation powered by Chronicle', + site: { + title: 'My Documentation', + description: 'Documentation powered by Chronicle', + }, content: [{ dir: 'docs', label: 'Docs' }], theme: { name: 'default' }, search: { enabled: true, placeholder: 'Search documentation...' } diff --git a/packages/chronicle/src/lib/llms.ts b/packages/chronicle/src/lib/llms.ts index 24d1974..0a17726 100644 --- a/packages/chronicle/src/lib/llms.ts +++ b/packages/chronicle/src/lib/llms.ts @@ -16,7 +16,7 @@ export function buildLlmsTxt( ? `# ${config.site.title} — ${versionLabel}` : `# ${config.site.title}` - const description = config.description ?? '' + const description = config.site.description ?? '' const index = pages .map((p) => { diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index 1506cdd..8597838 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -37,7 +37,10 @@ export function usePageContext(): PageContextValue { if (!ctx) { console.error('usePageContext: no context found!'); return { - config: { site: { title: 'Documentation' }, content: [{ dir: 'docs', label: 'Docs' }] }, + config: { + site: { title: 'Documentation' }, + content: [{ dir: 'docs', label: 'Docs' }], + }, tree: { name: 'root', children: [] } as Root, page: null, errorStatus: null, diff --git a/packages/chronicle/src/pages/LandingPage.tsx b/packages/chronicle/src/pages/LandingPage.tsx index d6de902..c84a43b 100644 --- a/packages/chronicle/src/pages/LandingPage.tsx +++ b/packages/chronicle/src/pages/LandingPage.tsx @@ -13,8 +13,8 @@ export function LandingPage() { return (

{heading}

- {config.description ? ( -

{config.description}

+ {config.site.description ? ( +

{config.site.description}

) : null}
{entries.map((entry) => ( diff --git a/packages/chronicle/src/server/App.tsx b/packages/chronicle/src/server/App.tsx index 6d5157b..292430c 100644 --- a/packages/chronicle/src/server/App.tsx +++ b/packages/chronicle/src/server/App.tsx @@ -48,7 +48,7 @@ function RootHead({ config }: { config: ChronicleConfig }) { return ( (items: T[], key: (item: T) => string): boolean => export const chronicleConfigSchema = z .object({ site: siteSchema, - description: z.string().optional(), url: z.string().optional(), content: z.array(contentEntrySchema).min(1), latest: latestSchema.optional(), From 2e445b2b8e76e098dcc26d249c3f01720e9e6d10 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 14:16:26 +0530 Subject: [PATCH 20/55] chore: migrate docs/ to new content layout - Content moved under docs/content/docs/; chronicle.yaml uses {site, content[]} shape with description nested under site - docs URL shape unchanged (/docs/) since content dir is `docs` and existing cross-links already use /docs/ prefix Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/chronicle.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/chronicle.yaml b/docs/chronicle.yaml index 5a3de82..9a6479f 100644 --- a/docs/chronicle.yaml +++ b/docs/chronicle.yaml @@ -1,6 +1,10 @@ -title: Chronicle -description: Config-driven documentation framework -content: . +site: + title: Chronicle + description: Config-driven documentation framework + +content: + - dir: docs + label: Docs theme: name: paper From 80eb1204385d4e461fc332a787bae39c54037a7b Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 14:23:54 +0530 Subject: [PATCH 21/55] feat: inject synthetic meta.json per content root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime-only — no filesystem writes. buildFiles() emits a meta entry for each (version, contentDir) pair using the config label and root:true so fumadocs treats the folder as a root and renders its children at the top of the sidebar instead of wrapping them under the folder index's frontmatter title. User-provided meta.json files at the same path still win (synthetic is skipped when a user entry exists). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/source.ts | 42 +++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index b32748b..e528c8d 100644 --- a/packages/chronicle/src/lib/source.ts +++ b/packages/chronicle/src/lib/source.ts @@ -3,7 +3,11 @@ import type { Root, Node, Folder } from 'fumadocs-core/page-tree'; import type { MDXContent } from 'mdx/types'; import type { TableOfContents } from 'fumadocs-core/toc'; import type { Frontmatter } from '@/types'; -import { loadConfig } from './config'; +import { + getLatestContentRoots, + getVersionContentRoots, + loadConfig, +} from './config'; import { filterPagesByVersion, filterPageTreeByVersion, @@ -40,14 +44,50 @@ function buildFiles() { }); } + const userMetaPaths = new Set(); for (const [key, data] of Object.entries(metaGlob)) { const relativePath = key.slice(CONTENT_PREFIX.length); + userMetaPaths.add(relativePath); files.push({ type: 'meta', path: relativePath, data: data ?? {} }); } + for (const entry of buildSyntheticMeta()) { + if (userMetaPaths.has(entry.path)) continue; + files.push(entry); + } + return files; } +function buildSyntheticMeta(): { + type: 'meta'; + path: string; + data: Record; +}[] { + const config = loadConfig(); + const entries: { type: 'meta'; path: string; data: Record }[] = []; + + for (const root of getLatestContentRoots(config)) { + entries.push({ + type: 'meta', + path: `${root.contentDir}/meta.json`, + data: { title: root.contentLabel, root: true }, + }); + } + + for (const version of config.versions ?? []) { + for (const root of getVersionContentRoots(config, version.dir)) { + entries.push({ + type: 'meta', + path: `${version.dir}/${root.contentDir}/meta.json`, + data: { title: root.contentLabel, root: true }, + }); + } + } + + return entries; +} + let cachedSource: ReturnType | null = null; async function getSource() { From 5530ca85a0a116b691c8aaedb2c8c76961600a3b Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 14:36:55 +0530 Subject: [PATCH 22/55] feat: nav helpers for version + content-dir selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getActiveContentDir(url, config) extracts the active content dir from the URL after stripping any version prefix - getVersionHomeHref(config, versionDir) returns the href a version switcher should point to — single content collapses to /, multi content returns the version root so the landing renders - splitContentButtons(items, max) splits a list into visible + overflow for the default theme's 3-button-plus-more layout Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/navigation.test.ts | 94 +++++++++++++++++++ packages/chronicle/src/lib/navigation.ts | 47 ++++++++++ 2 files changed, 141 insertions(+) create mode 100644 packages/chronicle/src/lib/navigation.test.ts create mode 100644 packages/chronicle/src/lib/navigation.ts diff --git a/packages/chronicle/src/lib/navigation.test.ts b/packages/chronicle/src/lib/navigation.test.ts new file mode 100644 index 0000000..0042ed2 --- /dev/null +++ b/packages/chronicle/src/lib/navigation.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from 'bun:test' +import { type ChronicleConfig, chronicleConfigSchema } from '@/types' +import { + getActiveContentDir, + getVersionHomeHref, + splitContentButtons, +} from './navigation' + +function versioned(): ChronicleConfig { + return chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [ + { dir: 'docs', label: 'Docs' }, + { dir: 'dev', label: 'Dev' }, + ], + latest: { label: '3.0' }, + versions: [ + { + dir: 'v1', + label: '1.0', + content: [ + { dir: 'docs', label: 'Docs' }, + { dir: 'dev', label: 'Dev' }, + ], + }, + ], + }) +} + +describe('getActiveContentDir', () => { + test('returns latest content dir from URL', () => { + expect(getActiveContentDir('/docs/intro', versioned())).toBe('docs') + expect(getActiveContentDir('/dev/setup', versioned())).toBe('dev') + }) + + test('returns versioned content dir from URL', () => { + expect(getActiveContentDir('/v1/docs/intro', versioned())).toBe('docs') + expect(getActiveContentDir('/v1/dev/setup', versioned())).toBe('dev') + }) + + test('returns null for root and api routes', () => { + expect(getActiveContentDir('/', versioned())).toBeNull() + expect(getActiveContentDir('/v1', versioned())).toBeNull() + expect(getActiveContentDir('/apis/x', versioned())).toBeNull() + expect(getActiveContentDir('/v1/apis/x', versioned())).toBeNull() + }) + + test('returns null for unknown content dir', () => { + expect(getActiveContentDir('/random', versioned())).toBeNull() + expect(getActiveContentDir('/v1/random', versioned())).toBeNull() + }) +}) + +describe('getVersionHomeHref', () => { + test('single content dir returns /', () => { + const cfg = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + }) + expect(getVersionHomeHref(cfg, null)).toBe('/docs') + }) + + test('multi content dir returns / (version root for landing)', () => { + expect(getVersionHomeHref(versioned(), null)).toBe('/') + }) + + test('versioned multi content returns /', () => { + expect(getVersionHomeHref(versioned(), 'v1')).toBe('/v1') + }) + + test('unknown version returns / fallback', () => { + expect(getVersionHomeHref(versioned(), 'v9')).toBe('/v9') + }) +}) + +describe('splitContentButtons', () => { + test('all visible when length <= max', () => { + expect(splitContentButtons([1, 2, 3], 3)).toEqual({ + visible: [1, 2, 3], + overflow: [], + }) + }) + + test('first max visible, rest overflow', () => { + expect(splitContentButtons([1, 2, 3, 4, 5], 3)).toEqual({ + visible: [1, 2, 3], + overflow: [4, 5], + }) + }) + + test('empty input returns empty arrays', () => { + expect(splitContentButtons([], 3)).toEqual({ visible: [], overflow: [] }) + }) +}) diff --git a/packages/chronicle/src/lib/navigation.ts b/packages/chronicle/src/lib/navigation.ts new file mode 100644 index 0000000..1525024 --- /dev/null +++ b/packages/chronicle/src/lib/navigation.ts @@ -0,0 +1,47 @@ +import type { ChronicleConfig } from '@/types' +import { getLandingEntries } from './config' +import { resolveVersionFromUrl } from './version-source' + +export function getActiveContentDir( + url: string, + config: ChronicleConfig, +): string | null { + const version = resolveVersionFromUrl(url, config) + const parts = url.split('/').filter(Boolean) + const remainder = + version.dir !== null && parts[0] === version.dir ? parts.slice(1) : parts + + if (remainder.length === 0) return null + if (remainder[0] === 'apis') return null + + const dirs = + version.dir === null + ? config.content.map((c) => c.dir) + : config.versions?.find((v) => v.dir === version.dir)?.content.map( + (c) => c.dir, + ) ?? [] + + return dirs.includes(remainder[0]) ? remainder[0] : null +} + +export function getVersionHomeHref( + config: ChronicleConfig, + versionDir: string | null, +): string { + const entries = getLandingEntries(config, versionDir) + if (entries.length === 1) return entries[0].href + return versionDir ? `/${versionDir}` : '/' +} + +export interface ContentButtonSplit { + visible: T[] + overflow: T[] +} + +export function splitContentButtons( + items: T[], + max: number, +): ContentButtonSplit { + if (items.length <= max) return { visible: items, overflow: [] } + return { visible: items.slice(0, max), overflow: items.slice(max) } +} From f5259640d79b2f7b7cfe51774c00df25e75542d1 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 14:40:15 +0530 Subject: [PATCH 23/55] feat: version switcher + content-dir buttons in default theme - VersionSwitcher: Apsara Menu triggered from a navbar Button; shows active version's label + optional Badge, hides when no versions - ContentDirButtons: first 3 content dirs render as Buttons (active = solid, rest = outline); overflow collapses into a More Menu; hides when the active version has <= 1 content dir - Both hook into usePageContext for the active version and navigate with react-router; neither component adds state Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/themes/default/ContentDirButtons.tsx | 66 +++++++++++++++++++ .../chronicle/src/themes/default/Layout.tsx | 4 ++ .../src/themes/default/VersionSwitcher.tsx | 59 +++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 packages/chronicle/src/themes/default/ContentDirButtons.tsx create mode 100644 packages/chronicle/src/themes/default/VersionSwitcher.tsx diff --git a/packages/chronicle/src/themes/default/ContentDirButtons.tsx b/packages/chronicle/src/themes/default/ContentDirButtons.tsx new file mode 100644 index 0000000..d36e4f9 --- /dev/null +++ b/packages/chronicle/src/themes/default/ContentDirButtons.tsx @@ -0,0 +1,66 @@ +import { ChevronDownIcon } from '@heroicons/react/24/outline'; +import { Button, Flex, Menu } from '@raystack/apsara'; +import { Link as RouterLink, useLocation, useNavigate } from 'react-router'; +import { getLandingEntries } from '@/lib/config'; +import { getActiveContentDir, splitContentButtons } from '@/lib/navigation'; +import { usePageContext } from '@/lib/page-context'; + +const MAX_VISIBLE = 3; + +export function ContentDirButtons() { + const { config, version } = usePageContext(); + const { pathname } = useLocation(); + const navigate = useNavigate(); + + const entries = getLandingEntries(config, version.dir); + if (entries.length <= 1) return null; + + const active = getActiveContentDir(pathname, config); + const { visible, overflow } = splitContentButtons(entries, MAX_VISIBLE); + + return ( + + {visible.map(entry => ( + + + + ))} + {overflow.length > 0 ? ( + + } + /> + } + > + More + + + {overflow.map(entry => ( + navigate(entry.href)} + > + {entry.label} + + ))} + + + ) : null} + + ); +} diff --git a/packages/chronicle/src/themes/default/Layout.tsx b/packages/chronicle/src/themes/default/Layout.tsx index aa90d32..17ab4d4 100644 --- a/packages/chronicle/src/themes/default/Layout.tsx +++ b/packages/chronicle/src/themes/default/Layout.tsx @@ -16,7 +16,9 @@ import { Footer } from '@/components/ui/footer'; import { Search } from '@/components/ui/search'; import type { Node } from 'fumadocs-core/page-tree'; import type { ThemeLayoutProps } from '@/types'; +import { ContentDirButtons } from './ContentDirButtons'; import styles from './Layout.module.css'; +import { VersionSwitcher } from './VersionSwitcher'; const iconMap: Record = { 'rectangle-stack': , @@ -71,6 +73,8 @@ export function Layout({ + + {config.api?.map(api => ( + v.isLatest ? version.dir === null : v.dir === version.dir, + ); + + return ( + + } + /> + } + > + + {active?.label ?? 'Version'} + {active?.badge ? ( + + {active.badge.label} + + ) : null} + + + + {versions.map(v => ( + navigate(getVersionHomeHref(config, v.dir))} + > + + {v.label} + {v.badge ? ( + + {v.badge.label} + + ) : null} + + + ))} + + + ); +} From 64043550959a3d3ba63a29299823ea9e01ef77a1 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 14:41:44 +0530 Subject: [PATCH 24/55] feat: version switcher + content-dir dropdown in paper theme Paper theme puts both selectors in the sidebar (not the navbar): - VersionSwitcher: full-width Apsara Menu; active version label + badge - ContentDirDropdown: full-width Menu listing config.content[] entries - Both hide when there are no versions / single content dir - Layout stacks them above ChapterNav with a new .nav group Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/themes/paper/ContentDirDropdown.tsx | 46 ++++++++++++++ .../src/themes/paper/Layout.module.css | 7 +++ .../chronicle/src/themes/paper/Layout.tsx | 6 ++ .../src/themes/paper/VersionSwitcher.tsx | 60 +++++++++++++++++++ 4 files changed, 119 insertions(+) create mode 100644 packages/chronicle/src/themes/paper/ContentDirDropdown.tsx create mode 100644 packages/chronicle/src/themes/paper/VersionSwitcher.tsx diff --git a/packages/chronicle/src/themes/paper/ContentDirDropdown.tsx b/packages/chronicle/src/themes/paper/ContentDirDropdown.tsx new file mode 100644 index 0000000..ba8cec8 --- /dev/null +++ b/packages/chronicle/src/themes/paper/ContentDirDropdown.tsx @@ -0,0 +1,46 @@ +import { ChevronDownIcon } from '@heroicons/react/24/outline'; +import { Button, Menu } from '@raystack/apsara'; +import { useLocation, useNavigate } from 'react-router'; +import { getLandingEntries } from '@/lib/config'; +import { getActiveContentDir } from '@/lib/navigation'; +import { usePageContext } from '@/lib/page-context'; + +export function ContentDirDropdown() { + const { config, version } = usePageContext(); + const { pathname } = useLocation(); + const navigate = useNavigate(); + + const entries = getLandingEntries(config, version.dir); + if (entries.length <= 1) return null; + + const activeDir = getActiveContentDir(pathname, config); + const activeEntry = entries.find(e => e.contentDir === activeDir) ?? entries[0]; + + return ( + + } + /> + } + > + {activeEntry.label} + + + {entries.map(entry => ( + navigate(entry.href)} + > + {entry.label} + + ))} + + + ); +} diff --git a/packages/chronicle/src/themes/paper/Layout.module.css b/packages/chronicle/src/themes/paper/Layout.module.css index 673716e..8054a61 100644 --- a/packages/chronicle/src/themes/paper/Layout.module.css +++ b/packages/chronicle/src/themes/paper/Layout.module.css @@ -25,6 +25,13 @@ margin-bottom: var(--rs-space-7); } +.nav { + display: flex; + flex-direction: column; + gap: var(--rs-space-3); + margin-bottom: var(--rs-space-7); +} + .content { flex: 1; overflow-y: auto; diff --git a/packages/chronicle/src/themes/paper/Layout.tsx b/packages/chronicle/src/themes/paper/Layout.tsx index ec4ffac..9fc0d6c 100644 --- a/packages/chronicle/src/themes/paper/Layout.tsx +++ b/packages/chronicle/src/themes/paper/Layout.tsx @@ -5,7 +5,9 @@ import { cx } from 'class-variance-authority'; import { Footer } from '@/components/ui/footer'; import type { ThemeLayoutProps } from '@/types'; import { ChapterNav } from './ChapterNav'; +import { ContentDirDropdown } from './ContentDirDropdown'; import styles from './Layout.module.css'; +import { VersionSwitcher } from './VersionSwitcher'; export function Layout({ children, @@ -25,6 +27,10 @@ export function Layout({ > {config.site.title} +
+ + +
diff --git a/packages/chronicle/src/themes/paper/VersionSwitcher.tsx b/packages/chronicle/src/themes/paper/VersionSwitcher.tsx new file mode 100644 index 0000000..686c7b4 --- /dev/null +++ b/packages/chronicle/src/themes/paper/VersionSwitcher.tsx @@ -0,0 +1,60 @@ +import { ChevronDownIcon } from '@heroicons/react/24/outline'; +import { Badge, Button, Flex, Menu } from '@raystack/apsara'; +import { useNavigate } from 'react-router'; +import { getAllVersions } from '@/lib/config'; +import { getVersionHomeHref } from '@/lib/navigation'; +import { usePageContext } from '@/lib/page-context'; + +export function VersionSwitcher() { + const { config, version } = usePageContext(); + const navigate = useNavigate(); + + if (!config.versions?.length) return null; + + const versions = getAllVersions(config); + const active = versions.find(v => + v.isLatest ? version.dir === null : v.dir === version.dir, + ); + + return ( + + } + /> + } + > + + {active?.label ?? 'Version'} + {active?.badge ? ( + + {active.badge.label} + + ) : null} + + + + {versions.map(v => ( + navigate(getVersionHomeHref(config, v.dir))} + > + + {v.label} + {v.badge ? ( + + {v.badge.label} + + ) : null} + + + ))} + + + ); +} From 5735642f080d2a6983a518a58f73f6ea86213227 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:07:55 +0530 Subject: [PATCH 25/55] feat: opt-in landing page via latest.landing / versions[].landing - Add optional boolean `landing` to latestSchema and versionSchema - Route resolver checks the flag first: landing=true -> DocsIndex, otherwise redirects to the first content dir (default behaviour when the field is absent) - Resolver tests updated: multi-content-no-landing now redirects, new case covers the default-redirect path, versioned fixture sets landing:true on v1 to keep the DocsIndex assertion Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chronicle/src/lib/route-resolver.test.ts | 24 ++++++++++++++++-- packages/chronicle/src/lib/route-resolver.ts | 25 +++++++++++++------ packages/chronicle/src/types/config.ts | 2 ++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/chronicle/src/lib/route-resolver.test.ts b/packages/chronicle/src/lib/route-resolver.test.ts index 4881689..ccc2ba3 100644 --- a/packages/chronicle/src/lib/route-resolver.test.ts +++ b/packages/chronicle/src/lib/route-resolver.test.ts @@ -11,6 +11,17 @@ function singleContent(): ChronicleConfig { } function multiContent(): ChronicleConfig { + return chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [ + { dir: 'docs', label: 'Docs' }, + { dir: 'dev', label: 'Dev' }, + ], + latest: { label: '3.0', landing: true }, + }) +} + +function multiContentNoLanding(): ChronicleConfig { return chronicleConfigSchema.parse({ site: { title: 'x' }, content: [ @@ -34,6 +45,7 @@ function versioned(): ChronicleConfig { { dir: 'v1', label: '1.0', + landing: true, content: [ { dir: 'docs', label: 'Docs' }, { dir: 'dev', label: 'Dev' }, @@ -52,13 +64,21 @@ describe('resolveRoute — root', () => { }) }) - test('docs-index for multi-content latest root', () => { + test('docs-index for latest root when latest.landing = true', () => { expect(resolveRoute('/', multiContent())).toEqual({ type: RouteType.DocsIndex, version: LATEST_CONTEXT, }) }) + test('redirect for multi-content latest root when landing is not set', () => { + expect(resolveRoute('/', multiContentNoLanding())).toEqual({ + type: RouteType.Redirect, + to: '/docs', + status: 302, + }) + }) + test('redirects single-content version root to //', () => { expect(resolveRoute('/v2', versioned())).toEqual({ type: RouteType.Redirect, @@ -67,7 +87,7 @@ describe('resolveRoute — root', () => { }) }) - test('docs-index for multi-content version root', () => { + test('docs-index for version root when versions[].landing = true', () => { expect(resolveRoute('/v1', versioned())).toEqual({ type: RouteType.DocsIndex, version: { dir: 'v1', urlPrefix: '/v1' }, diff --git a/packages/chronicle/src/lib/route-resolver.ts b/packages/chronicle/src/lib/route-resolver.ts index dd5b571..a1e423c 100644 --- a/packages/chronicle/src/lib/route-resolver.ts +++ b/packages/chronicle/src/lib/route-resolver.ts @@ -27,6 +27,16 @@ function contentDirsFor( return v?.content.map((c) => c.dir) ?? [] } +function isLandingEnabled( + config: ChronicleConfig, + version: VersionContext, +): boolean { + if (version.dir === null) return config.latest?.landing === true + return ( + config.versions?.find((v) => v.dir === version.dir)?.landing === true + ) +} + export function resolveRoute( pathname: string, config: ChronicleConfig, @@ -43,15 +53,16 @@ export function resolveRoute( } if (remainder.length === 0) { + if (isLandingEnabled(config, version)) { + return { type: RouteType.DocsIndex, version } + } const dirs = contentDirsFor(config, version) - if (dirs.length === 1) { - return { - type: RouteType.Redirect, - to: `${version.urlPrefix}/${dirs[0]}`, - status: 302, - } + if (dirs.length === 0) return { type: RouteType.DocsIndex, version } + return { + type: RouteType.Redirect, + to: `${version.urlPrefix}/${dirs[0]}`, + status: 302, } - return { type: RouteType.DocsIndex, version } } return { type: RouteType.DocsPage, version, slug: parts } diff --git a/packages/chronicle/src/types/config.ts b/packages/chronicle/src/types/config.ts index 6643437..4a56936 100644 --- a/packages/chronicle/src/types/config.ts +++ b/packages/chronicle/src/types/config.ts @@ -102,12 +102,14 @@ const badgeSchema = z.object({ const latestSchema = z.object({ label: z.string().min(1), + landing: z.boolean().optional(), }) const versionSchema = z.object({ dir: z.string().min(1), label: z.string().min(1), badge: badgeSchema.optional(), + landing: z.boolean().optional(), content: z.array(contentEntrySchema).min(1), api: z.array(apiSchema).optional(), }) From 6d1945ccf05217fbe05d7543b35f4d7bad01c3af Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:09:17 +0530 Subject: [PATCH 26/55] feat: chromeless landing page (no sidebar) Add hideSidebar flag to ThemeLayoutProps; both theme Layouts skip their sidebar when set. App.tsx passes hideSidebar=true for the DocsIndex (landing) route so /<...> landing pages render without the sidebar chrome while keeping navbar/footer. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/pages/DocsLayout.tsx | 19 +++++++++-- packages/chronicle/src/server/App.tsx | 2 +- .../chronicle/src/themes/default/Layout.tsx | 33 ++++++++++--------- .../chronicle/src/themes/paper/Layout.tsx | 33 ++++++++++--------- packages/chronicle/src/types/theme.ts | 1 + 5 files changed, 54 insertions(+), 34 deletions(-) diff --git a/packages/chronicle/src/pages/DocsLayout.tsx b/packages/chronicle/src/pages/DocsLayout.tsx index da59652..1f8f5e9 100644 --- a/packages/chronicle/src/pages/DocsLayout.tsx +++ b/packages/chronicle/src/pages/DocsLayout.tsx @@ -1,17 +1,30 @@ import type { ReactNode } from 'react'; +import { useLocation } from 'react-router'; import { usePageContext } from '@/lib/page-context'; +import { getActiveContentDir } from '@/lib/navigation'; +import { filterPageTreeByContentDir } from '@/lib/version-source'; import { getTheme } from '@/themes/registry'; interface DocsLayoutProps { children: ReactNode; + hideSidebar?: boolean; } -export function DocsLayout({ children }: DocsLayoutProps) { - const { config, tree } = usePageContext(); +export function DocsLayout({ children, hideSidebar }: DocsLayoutProps) { + const { config, tree, version } = usePageContext(); + const { pathname } = useLocation(); const { Layout, className } = getTheme(config.theme?.name); + const activeContentDir = getActiveContentDir(pathname, config); + const scopedTree = filterPageTreeByContentDir(tree, version, activeContentDir); + return ( - + {children} ); diff --git a/packages/chronicle/src/server/App.tsx b/packages/chronicle/src/server/App.tsx index 292430c..e8aca5a 100644 --- a/packages/chronicle/src/server/App.tsx +++ b/packages/chronicle/src/server/App.tsx @@ -36,7 +36,7 @@ export function App() { ) : ( - + {isLanding ? : } )} diff --git a/packages/chronicle/src/themes/default/Layout.tsx b/packages/chronicle/src/themes/default/Layout.tsx index 17ab4d4..3c8f1e7 100644 --- a/packages/chronicle/src/themes/default/Layout.tsx +++ b/packages/chronicle/src/themes/default/Layout.tsx @@ -35,6 +35,7 @@ export function Layout({ children, config, tree, + hideSidebar, classNames }: ThemeLayoutProps) { const { pathname } = useLocation(); @@ -95,21 +96,23 @@ export function Layout({ - - - {tree.children.map((item, i) => ( - - ))} - - + {hideSidebar ? null : ( + + + {tree.children.map((item, i) => ( + + ))} + + + )}
{children}
diff --git a/packages/chronicle/src/themes/paper/Layout.tsx b/packages/chronicle/src/themes/paper/Layout.tsx index 9fc0d6c..604f2bc 100644 --- a/packages/chronicle/src/themes/paper/Layout.tsx +++ b/packages/chronicle/src/themes/paper/Layout.tsx @@ -13,26 +13,29 @@ export function Layout({ children, config, tree, + hideSidebar, classNames }: ThemeLayoutProps) { return ( - + {hideSidebar ? null : ( + + )}
{children}
diff --git a/packages/chronicle/src/types/theme.ts b/packages/chronicle/src/types/theme.ts index c948bd1..cfcdd68 100644 --- a/packages/chronicle/src/types/theme.ts +++ b/packages/chronicle/src/types/theme.ts @@ -7,6 +7,7 @@ export interface ThemeLayoutProps { children: ReactNode config: ChronicleConfig tree: Root + hideSidebar?: boolean classNames?: { layout?: string; body?: string; sidebar?: string; content?: string } } From 0c44a6dcbdb0d8279cce2b6656cc32878ef6b349 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:09:37 +0530 Subject: [PATCH 27/55] chore: versioned example opts into landing pages latest + v1 set landing:true so / and /v1 show the chromeless landing; v2 leaves it default so /v2 redirects to /v2/docs. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/versioned/chronicle.yaml | 42 +++++++++++++++++++ examples/versioned/content/dev/api.mdx | 8 ++++ examples/versioned/content/dev/index.mdx | 8 ++++ examples/versioned/content/docs/guide.mdx | 8 ++++ examples/versioned/content/docs/index.mdx | 9 ++++ examples/versioned/versions/v1/dev/index.mdx | 8 ++++ examples/versioned/versions/v1/docs/index.mdx | 8 ++++ examples/versioned/versions/v2/docs/guide.mdx | 8 ++++ examples/versioned/versions/v2/docs/index.mdx | 8 ++++ 9 files changed, 107 insertions(+) create mode 100644 examples/versioned/chronicle.yaml create mode 100644 examples/versioned/content/dev/api.mdx create mode 100644 examples/versioned/content/dev/index.mdx create mode 100644 examples/versioned/content/docs/guide.mdx create mode 100644 examples/versioned/content/docs/index.mdx create mode 100644 examples/versioned/versions/v1/dev/index.mdx create mode 100644 examples/versioned/versions/v1/docs/index.mdx create mode 100644 examples/versioned/versions/v2/docs/guide.mdx create mode 100644 examples/versioned/versions/v2/docs/index.mdx diff --git a/examples/versioned/chronicle.yaml b/examples/versioned/chronicle.yaml new file mode 100644 index 0000000..818fae1 --- /dev/null +++ b/examples/versioned/chronicle.yaml @@ -0,0 +1,42 @@ +site: + title: Versioned Example + description: Multi-content + multi-version sample + +theme: + name: default + +content: + - dir: docs + label: Docs + - dir: dev + label: Dev Docs + +latest: + label: "3.0" + landing: true + +versions: + - dir: v2 + label: "2.0" + content: + - dir: docs + label: Docs + + - dir: v1 + label: "1.0" + landing: true + badge: + label: deprecated + variant: warning + content: + - dir: dev + label: Developer Guide + - dir: docs + label: Docs + +search: + enabled: true + placeholder: Search... + +llms: + enabled: true diff --git a/examples/versioned/content/dev/api.mdx b/examples/versioned/content/dev/api.mdx new file mode 100644 index 0000000..f1afad0 --- /dev/null +++ b/examples/versioned/content/dev/api.mdx @@ -0,0 +1,8 @@ +--- +title: API notes +order: 2 +--- + +# Dev API notes — latest + +Latest `/dev/api`. diff --git a/examples/versioned/content/dev/index.mdx b/examples/versioned/content/dev/index.mdx new file mode 100644 index 0000000..bff0547 --- /dev/null +++ b/examples/versioned/content/dev/index.mdx @@ -0,0 +1,8 @@ +--- +title: Dev home (3.0) +order: 1 +--- + +# Dev — latest + +Latest `/dev`. diff --git a/examples/versioned/content/docs/guide.mdx b/examples/versioned/content/docs/guide.mdx new file mode 100644 index 0000000..96a4f64 --- /dev/null +++ b/examples/versioned/content/docs/guide.mdx @@ -0,0 +1,8 @@ +--- +title: Guide +order: 2 +--- + +# Guide — latest docs + +Latest `/docs/guide`. diff --git a/examples/versioned/content/docs/index.mdx b/examples/versioned/content/docs/index.mdx new file mode 100644 index 0000000..6da7902 --- /dev/null +++ b/examples/versioned/content/docs/index.mdx @@ -0,0 +1,9 @@ +--- +title: Docs home (3.0) +description: Latest docs landing +order: 1 +--- + +# Docs — latest + +This is `/docs` on latest (3.0). diff --git a/examples/versioned/versions/v1/dev/index.mdx b/examples/versioned/versions/v1/dev/index.mdx new file mode 100644 index 0000000..e92a6fe --- /dev/null +++ b/examples/versioned/versions/v1/dev/index.mdx @@ -0,0 +1,8 @@ +--- +title: Developer Guide (1.0) +order: 1 +--- + +# Developer Guide — v1 + +In v1, `dev/` was relabeled "Developer Guide" and came first. diff --git a/examples/versioned/versions/v1/docs/index.mdx b/examples/versioned/versions/v1/docs/index.mdx new file mode 100644 index 0000000..1da0591 --- /dev/null +++ b/examples/versioned/versions/v1/docs/index.mdx @@ -0,0 +1,8 @@ +--- +title: Docs home (1.0) +order: 2 +--- + +# Docs — v1 + +`/v1/docs` landing. Legacy version. diff --git a/examples/versioned/versions/v2/docs/guide.mdx b/examples/versioned/versions/v2/docs/guide.mdx new file mode 100644 index 0000000..100aced --- /dev/null +++ b/examples/versioned/versions/v2/docs/guide.mdx @@ -0,0 +1,8 @@ +--- +title: Guide (v2) +order: 2 +--- + +# Guide — v2 + +`/v2/docs/guide`. diff --git a/examples/versioned/versions/v2/docs/index.mdx b/examples/versioned/versions/v2/docs/index.mdx new file mode 100644 index 0000000..bb623bb --- /dev/null +++ b/examples/versioned/versions/v2/docs/index.mdx @@ -0,0 +1,8 @@ +--- +title: Docs home (2.0) +order: 1 +--- + +# Docs — v2 + +`/v2/docs` landing. From 8e0d069105efc434119cb24eaf9b3dc96d382428 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:11:29 +0530 Subject: [PATCH 28/55] fix: drop node:path from config.ts for browser compat config.ts runs both server- and client-side (imported by LandingPage/ContentDirButtons via getLandingEntries). Vite externalises node:path on the client, so path.join(...) threw at runtime. Replace with forward-slash template literals; scaffold still normalises via path.resolve when it consumes fsPath. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/config.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/chronicle/src/lib/config.ts b/packages/chronicle/src/lib/config.ts index c4f66b1..ed5e8f6 100644 --- a/packages/chronicle/src/lib/config.ts +++ b/packages/chronicle/src/lib/config.ts @@ -1,4 +1,3 @@ -import path from 'node:path' import { parse } from 'yaml' import { type ApiConfig, @@ -40,7 +39,7 @@ export function getLatestContentRoots(config: ChronicleConfig): ContentRoot[] { versionLabel: config.latest?.label ?? null, contentDir: c.dir, contentLabel: c.label, - fsPath: path.join('content', c.dir), + fsPath: `content/${c.dir}`, urlPrefix: `/${c.dir}`, })) } @@ -57,7 +56,7 @@ export function getVersionContentRoots( versionLabel: version.label, contentDir: c.dir, contentLabel: c.label, - fsPath: path.join('versions', version.dir, c.dir), + fsPath: `versions/${version.dir}/${c.dir}`, urlPrefix: `/${version.dir}/${c.dir}`, })) } From c61e30242092c250b8b03e075dcf9572ce413880 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:11:36 +0530 Subject: [PATCH 29/55] fix: switchers use DropdownMenu (apsara 0.55 export) Apsara 0.55 exposes DropdownMenu, not Menu. Replace Menu + render prop with DropdownMenu + asChild pattern in both themes' switchers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/themes/default/ContentDirButtons.tsx | 36 ++++++------- .../src/themes/default/VersionSwitcher.tsx | 50 +++++++++--------- .../src/themes/paper/ContentDirDropdown.tsx | 41 +++++++-------- .../src/themes/paper/VersionSwitcher.tsx | 52 +++++++++---------- 4 files changed, 86 insertions(+), 93 deletions(-) diff --git a/packages/chronicle/src/themes/default/ContentDirButtons.tsx b/packages/chronicle/src/themes/default/ContentDirButtons.tsx index d36e4f9..8513e46 100644 --- a/packages/chronicle/src/themes/default/ContentDirButtons.tsx +++ b/packages/chronicle/src/themes/default/ContentDirButtons.tsx @@ -1,5 +1,5 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline'; -import { Button, Flex, Menu } from '@raystack/apsara'; +import { Button, DropdownMenu, Flex } from '@raystack/apsara'; import { Link as RouterLink, useLocation, useNavigate } from 'react-router'; import { getLandingEntries } from '@/lib/config'; import { getActiveContentDir, splitContentButtons } from '@/lib/navigation'; @@ -36,30 +36,28 @@ export function ContentDirButtons() { ))} {overflow.length > 0 ? ( - - } - /> - } - > - More - - + + + + + {overflow.map(entry => ( - navigate(entry.href)} > {entry.label} - + ))} - - + + ) : null}
); diff --git a/packages/chronicle/src/themes/default/VersionSwitcher.tsx b/packages/chronicle/src/themes/default/VersionSwitcher.tsx index 3e084ba..3bce5d6 100644 --- a/packages/chronicle/src/themes/default/VersionSwitcher.tsx +++ b/packages/chronicle/src/themes/default/VersionSwitcher.tsx @@ -1,5 +1,5 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline'; -import { Badge, Button, Flex, Menu } from '@raystack/apsara'; +import { Badge, Button, DropdownMenu, Flex } from '@raystack/apsara'; import { useNavigate } from 'react-router'; import { getAllVersions } from '@/lib/config'; import { getVersionHomeHref } from '@/lib/navigation'; @@ -17,29 +17,27 @@ export function VersionSwitcher() { ); return ( - - } - /> - } - > - - {active?.label ?? 'Version'} - {active?.badge ? ( - - {active.badge.label} - - ) : null} - - - + + + + + {versions.map(v => ( - navigate(getVersionHomeHref(config, v.dir))} > @@ -51,9 +49,9 @@ export function VersionSwitcher() { ) : null} - + ))} - - + + ); } diff --git a/packages/chronicle/src/themes/paper/ContentDirDropdown.tsx b/packages/chronicle/src/themes/paper/ContentDirDropdown.tsx index ba8cec8..714a8cd 100644 --- a/packages/chronicle/src/themes/paper/ContentDirDropdown.tsx +++ b/packages/chronicle/src/themes/paper/ContentDirDropdown.tsx @@ -1,5 +1,5 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline'; -import { Button, Menu } from '@raystack/apsara'; +import { Button, DropdownMenu } from '@raystack/apsara'; import { useLocation, useNavigate } from 'react-router'; import { getLandingEntries } from '@/lib/config'; import { getActiveContentDir } from '@/lib/navigation'; @@ -14,33 +14,32 @@ export function ContentDirDropdown() { if (entries.length <= 1) return null; const activeDir = getActiveContentDir(pathname, config); - const activeEntry = entries.find(e => e.contentDir === activeDir) ?? entries[0]; + const activeEntry = + entries.find(e => e.contentDir === activeDir) ?? entries[0]; return ( - - } - /> - } - > - {activeEntry.label} - - + + + + + {entries.map(entry => ( - navigate(entry.href)} > {entry.label} - + ))} - - + + ); } diff --git a/packages/chronicle/src/themes/paper/VersionSwitcher.tsx b/packages/chronicle/src/themes/paper/VersionSwitcher.tsx index 686c7b4..ac845e2 100644 --- a/packages/chronicle/src/themes/paper/VersionSwitcher.tsx +++ b/packages/chronicle/src/themes/paper/VersionSwitcher.tsx @@ -1,5 +1,5 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline'; -import { Badge, Button, Flex, Menu } from '@raystack/apsara'; +import { Badge, Button, DropdownMenu, Flex } from '@raystack/apsara'; import { useNavigate } from 'react-router'; import { getAllVersions } from '@/lib/config'; import { getVersionHomeHref } from '@/lib/navigation'; @@ -17,30 +17,28 @@ export function VersionSwitcher() { ); return ( - - } - /> - } - > - - {active?.label ?? 'Version'} - {active?.badge ? ( - - {active.badge.label} - - ) : null} - - - + + + + + {versions.map(v => ( - navigate(getVersionHomeHref(config, v.dir))} > @@ -52,9 +50,9 @@ export function VersionSwitcher() { ) : null} - + ))} - - + + ); } From 30db12a8d92b299faf1db95c602c79efba17caa4 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:11:43 +0530 Subject: [PATCH 30/55] feat: filterPageTreeByContentDir for per-content sidebar scope Adds a pure helper that trims the page tree down to a single content folder's children given the active (version, contentDir) pair. DocsLayout already calls this so the sidebar at /docs shows only docs pages, /dev only dev pages, etc. Tests cover latest, missing content dir, and versioned disambiguation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chronicle/src/lib/version-source.test.ts | 35 +++++++++++++++++++ packages/chronicle/src/lib/version-source.ts | 16 +++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/chronicle/src/lib/version-source.test.ts b/packages/chronicle/src/lib/version-source.test.ts index 99088ea..2953c10 100644 --- a/packages/chronicle/src/lib/version-source.test.ts +++ b/packages/chronicle/src/lib/version-source.test.ts @@ -3,6 +3,7 @@ import type { Folder, Item, Root } from 'fumadocs-core/page-tree' import { type ChronicleConfig, chronicleConfigSchema } from '@/types' import { filterPagesByVersion, + filterPageTreeByContentDir, filterPageTreeByVersion, LATEST_CONTEXT, resolveVersionFromUrl, @@ -126,3 +127,37 @@ describe('filterPageTreeByVersion', () => { expect(filtered.children).toEqual([]) }) }) + +describe('filterPageTreeByContentDir', () => { + const latestDocs = folder('docs', [page('/docs/a'), page('/docs/b')]) + const latestDev = folder('dev', [page('/dev/x')]) + const latestTree: Root = { + name: 'root', + children: [latestDocs, latestDev], + } + + test('null contentDir returns tree unchanged', () => { + const out = filterPageTreeByContentDir(latestTree, LATEST_CONTEXT, null) + expect(out).toBe(latestTree) + }) + + test('returns just the matching content folder children (latest)', () => { + const out = filterPageTreeByContentDir(latestTree, LATEST_CONTEXT, 'docs') + expect(out.children).toEqual(latestDocs.children) + }) + + test('returns empty children when content dir is absent', () => { + const out = filterPageTreeByContentDir(latestTree, LATEST_CONTEXT, 'missing') + expect(out.children).toEqual([]) + }) + + test('uses version urlPrefix to disambiguate within a version', () => { + const v1Docs = folder('docs', [page('/v1/docs/a')]) + const v1Dev = folder('dev', [page('/v1/dev/x')]) + const ctx = { dir: 'v1', urlPrefix: '/v1' } + const tree: Root = { name: 'root', children: [v1Docs, v1Dev] } + expect( + filterPageTreeByContentDir(tree, ctx, 'dev').children, + ).toEqual(v1Dev.children) + }) +}) diff --git a/packages/chronicle/src/lib/version-source.ts b/packages/chronicle/src/lib/version-source.ts index 5e474df..a8198db 100644 --- a/packages/chronicle/src/lib/version-source.ts +++ b/packages/chronicle/src/lib/version-source.ts @@ -83,3 +83,19 @@ export function filterPageTreeByVersion( children: tree.children.filter((n) => nodeMatchesVersion(n, ctx, config)), } } + +export function filterPageTreeByContentDir( + tree: Root, + ctx: VersionContext, + contentDir: string | null, +): Root { + if (contentDir === null) return tree + const expectedPrefix = `${ctx.urlPrefix}/${contentDir}` + const match = tree.children.find( + (n): n is Folder => + n.type === 'folder' && + nodeUrls(n).every((u) => isUnderPrefix(u, expectedPrefix)), + ) + if (!match) return { ...tree, children: [] } + return { ...tree, children: match.children } +} From 6b7a8d81d5da198b3ca801fe2468890e2114507d Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:11:48 +0530 Subject: [PATCH 31/55] chore: add dev/build scripts for examples Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 6421a8f..5ccda8b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ "build:cli": "bun run --filter @raystack/chronicle build:cli", "dev:docs": "./packages/chronicle/bin/chronicle.js dev --config docs/chronicle.yaml", "start:docs": "./packages/chronicle/bin/chronicle.js start --config docs/chronicle.yaml", - "build:docs": "./packages/chronicle/bin/chronicle.js build --config docs/chronicle.yaml" + "build:docs": "./packages/chronicle/bin/chronicle.js build --config docs/chronicle.yaml", + "dev:examples:basic": "./packages/chronicle/bin/chronicle.js dev --config examples/basic/chronicle.yaml", + "build:examples:basic": "./packages/chronicle/bin/chronicle.js build --config examples/basic/chronicle.yaml", + "dev:examples:versioned": "./packages/chronicle/bin/chronicle.js dev --config examples/versioned/chronicle.yaml", + "build:examples:versioned": "./packages/chronicle/bin/chronicle.js build --config examples/versioned/chronicle.yaml" } } From 9bcb75e4904411afcaa405baa8c8910d27206828 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:17:03 +0530 Subject: [PATCH 32/55] feat: per-version search index - /api/search reads a `tag` param (used by fumadocs' fetchClient) to resolve a VersionContext; unknown/missing tag falls back to latest - Pages come from getPagesForVersion, api docs from getApiConfigsForVersion + the version's urlPrefix so /apis entries for v1 link to /v1/apis/... - Indexes and raw docs cached per version key so switching versions doesn't rebuild latest and vice-versa - Search component pulls active version from usePageContext and forwards it as `tag` to useDocsSearch Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chronicle/src/components/ui/search.tsx | 3 + packages/chronicle/src/server/api/search.ts | 68 ++++++++++++------- 2 files changed, 48 insertions(+), 23 deletions(-) diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index 293893d..370c4ce 100644 --- a/packages/chronicle/src/components/ui/search.tsx +++ b/packages/chronicle/src/components/ui/search.tsx @@ -6,6 +6,7 @@ import { useDocsSearch } from 'fumadocs-core/search/client'; import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import { MethodBadge } from '@/components/api/method-badge'; +import { usePageContext } from '@/lib/page-context'; import styles from './search.module.css'; function SearchShortcutKey({ className }: { className?: string }) { @@ -30,10 +31,12 @@ interface SearchProps { export function Search({ className }: SearchProps) { const [open, setOpen] = useState(false); const navigate = useNavigate(); + const { version } = usePageContext(); const { search, setSearch, query } = useDocsSearch({ type: 'fetch', api: '/api/search', + tag: version.dir ?? undefined, delayMs: 100, allowEmpty: true }); diff --git a/packages/chronicle/src/server/api/search.ts b/packages/chronicle/src/server/api/search.ts index 86d2166..57b1793 100644 --- a/packages/chronicle/src/server/api/search.ts +++ b/packages/chronicle/src/server/api/search.ts @@ -2,9 +2,10 @@ import MiniSearch from 'minisearch'; import { defineHandler } from 'nitro'; import type { OpenAPIV3 } from 'openapi-types'; import { getSpecSlug } from '@/lib/api-routes'; -import { loadConfig } from '@/lib/config'; +import { getApiConfigsForVersion, loadConfig } from '@/lib/config'; import { loadApiSpecs } from '@/lib/openapi'; -import { getPages, extractFrontmatter } from '@/lib/source'; +import { extractFrontmatter, getPagesForVersion } from '@/lib/source'; +import { LATEST_CONTEXT, type VersionContext } from '@/lib/version-source'; interface SearchDocument { id: string; @@ -14,8 +15,12 @@ interface SearchDocument { type: 'page' | 'api'; } -let searchIndex: MiniSearch | null = null; -let cachedDocs: SearchDocument[] | null = null; +const indexCache = new Map>(); +const docsCache = new Map(); + +function keyFor(ctx: VersionContext): string { + return ctx.dir ?? '__latest__'; +} function createIndex(docs: SearchDocument[]): MiniSearch { const index = new MiniSearch({ @@ -31,8 +36,8 @@ function createIndex(docs: SearchDocument[]): MiniSearch { return index; } -async function scanContent(): Promise { - const pages = await getPages(); +async function scanContent(ctx: VersionContext): Promise { + const pages = await getPagesForVersion(ctx); return pages.map(p => { const fm = extractFrontmatter(p); return { @@ -45,12 +50,13 @@ async function scanContent(): Promise { }); } -async function buildApiDocs(): Promise { +async function buildApiDocs(ctx: VersionContext): Promise { const config = loadConfig(); - if (!config.api?.length) return []; + const apiConfigs = getApiConfigsForVersion(config, ctx.dir); + if (!apiConfigs.length) return []; const docs: SearchDocument[] = []; - const specs = await loadApiSpecs(config.api); + const specs = await loadApiSpecs(apiConfigs); for (const spec of specs) { const specSlug = getSpecSlug(spec); @@ -60,7 +66,7 @@ async function buildApiDocs(): Promise { for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) { const op = pathItem[method] as OpenAPIV3.OperationObject | undefined; if (!op?.operationId) continue; - const url = `/apis/${specSlug}/${encodeURIComponent(op.operationId)}`; + const url = `${ctx.urlPrefix}/apis/${specSlug}/${encodeURIComponent(op.operationId)}`; docs.push({ id: url, url, @@ -75,29 +81,45 @@ async function buildApiDocs(): Promise { return docs; } -async function getDocs(): Promise { - if (cachedDocs) return cachedDocs; +async function getDocs(ctx: VersionContext): Promise { + const key = keyFor(ctx); + const cached = docsCache.get(key); + if (cached) return cached; const [contentDocs, apiDocs] = await Promise.all([ - scanContent(), - buildApiDocs() + scanContent(ctx), + buildApiDocs(ctx) ]); - cachedDocs = [...contentDocs, ...apiDocs]; - return cachedDocs; + const docs = [...contentDocs, ...apiDocs]; + docsCache.set(key, docs); + return docs; } -async function getIndex(): Promise> { - if (searchIndex) return searchIndex; - const docs = await getDocs(); - searchIndex = createIndex(docs); - return searchIndex; +async function getIndex(ctx: VersionContext): Promise> { + const key = keyFor(ctx); + const cached = indexCache.get(key); + if (cached) return cached; + const docs = await getDocs(ctx); + const index = createIndex(docs); + indexCache.set(key, index); + return index; +} + +function resolveCtx(tag: string | null): VersionContext { + if (!tag) return LATEST_CONTEXT; + const config = loadConfig(); + const version = config.versions?.find(v => v.dir === tag); + if (!version) return LATEST_CONTEXT; + return { dir: version.dir, urlPrefix: `/${version.dir}` }; } export default defineHandler(async event => { const query = event.url.searchParams.get('query') ?? ''; - const index = await getIndex(); + const tag = event.url.searchParams.get('tag'); + const ctx = resolveCtx(tag); + const index = await getIndex(ctx); if (!query) { - const docs = await getDocs(); + const docs = await getDocs(ctx); return docs .filter(d => d.type === 'page') .slice(0, 8) From b99f487fd508658488a22e9addfe68a3e1651dd8 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:18:46 +0530 Subject: [PATCH 33/55] feat: canonical URL in Head Head now reads pathname via useLocation and emits plus og:url when config.url is set. Since pathname already carries the version prefix, versioned pages get distinct canonical URLs (e.g. https://site.example.com/v1/docs/intro). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/head.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/chronicle/src/lib/head.tsx b/packages/chronicle/src/lib/head.tsx index 7f4df01..f96a2a4 100644 --- a/packages/chronicle/src/lib/head.tsx +++ b/packages/chronicle/src/lib/head.tsx @@ -1,3 +1,4 @@ +import { useLocation } from 'react-router'; import type { ChronicleConfig } from '@/types'; export interface HeadProps { @@ -8,14 +9,19 @@ export interface HeadProps { } export function Head({ title, description, config, jsonLd }: HeadProps) { + const { pathname } = useLocation(); const fullTitle = `${title} | ${config.site.title}`; const ogParams = new URLSearchParams({ title }); if (description) ogParams.set('description', description); + const canonical = config.url + ? `${config.url.replace(/\/$/, '')}${pathname}` + : null; return ( <> {fullTitle} {description && } + {canonical && } {config.url && ( <> @@ -25,6 +31,7 @@ export function Head({ title, description, config, jsonLd }: HeadProps) { )} + {canonical && } From 629856d1d063e53c5710db08fb6f3434a84a41ce Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:25:18 +0530 Subject: [PATCH 34/55] test: cover chronicle init scaffolding Refactor init into a pure runInit(projectDir) that returns a list of events (created/skipped/updated); the Command only wires stdout. This makes init unit-testable. Tests cover: - empty projects scaffolding content/docs, chronicle.yaml (with a sample index.mdx), and .gitignore - emitted chronicle.yaml round-tripping through chronicleConfigSchema - existing chronicle.yaml is left untouched - existing content/docs with files skips the sample index.mdx - .gitignore with partial entries gets the missing ones appended Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chronicle/src/cli/commands/init.test.ts | 62 +++++++++++ packages/chronicle/src/cli/commands/init.ts | 103 +++++++++++------- 2 files changed, 127 insertions(+), 38 deletions(-) create mode 100644 packages/chronicle/src/cli/commands/init.test.ts diff --git a/packages/chronicle/src/cli/commands/init.test.ts b/packages/chronicle/src/cli/commands/init.test.ts new file mode 100644 index 0000000..9998363 --- /dev/null +++ b/packages/chronicle/src/cli/commands/init.test.ts @@ -0,0 +1,62 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { parse } from 'yaml' +import { chronicleConfigSchema } from '@/types' +import { runInit } from './init' + +let tmp: string + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'chronicle-init-')) +}) + +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +describe('runInit', () => { + test('scaffolds content//, chronicle.yaml and .gitignore from empty', () => { + const events = runInit(tmp) + const created = events.filter(e => e.type === 'created').map(e => e.path) + expect(created).toContain(path.join(tmp, 'content/docs')) + expect(created).toContain(path.join(tmp, 'chronicle.yaml')) + expect(created).toContain(path.join(tmp, 'content/docs/index.mdx')) + expect(created).toContain(path.join(tmp, '.gitignore')) + }) + + test('emitted chronicle.yaml passes the schema', () => { + runInit(tmp) + const raw = fs.readFileSync(path.join(tmp, 'chronicle.yaml'), 'utf-8') + const parsed = chronicleConfigSchema.parse(parse(raw)) + expect(parsed.site.title).toBe('My Documentation') + expect(parsed.content).toEqual([{ dir: 'docs', label: 'Docs' }]) + }) + + test('skips chronicle.yaml when it already exists', () => { + fs.writeFileSync(path.join(tmp, 'chronicle.yaml'), 'site:\n title: Mine\n') + const events = runInit(tmp) + const yamlEvent = events.find(e => e.path.endsWith('chronicle.yaml')) + expect(yamlEvent?.type).toBe('skipped') + const raw = fs.readFileSync(path.join(tmp, 'chronicle.yaml'), 'utf-8') + expect(raw).toBe('site:\n title: Mine\n') + }) + + test('does not overwrite an index.mdx already present in content/docs', () => { + fs.mkdirSync(path.join(tmp, 'content/docs'), { recursive: true }) + fs.writeFileSync(path.join(tmp, 'content/docs/existing.mdx'), '# Keep') + runInit(tmp) + expect(fs.existsSync(path.join(tmp, 'content/docs/index.mdx'))).toBe(false) + }) + + test('appends missing entries to an existing .gitignore', () => { + fs.writeFileSync(path.join(tmp, '.gitignore'), 'node_modules\n') + const events = runInit(tmp) + const gitignoreEvent = events.find(e => e.path.endsWith('.gitignore')) + expect(gitignoreEvent?.type).toBe('updated') + const contents = fs.readFileSync(path.join(tmp, '.gitignore'), 'utf-8') + expect(contents).toContain('dist') + expect(contents).toContain('.output') + }) +}) diff --git a/packages/chronicle/src/cli/commands/init.ts b/packages/chronicle/src/cli/commands/init.ts index 4cc45a7..77feb3c 100644 --- a/packages/chronicle/src/cli/commands/init.ts +++ b/packages/chronicle/src/cli/commands/init.ts @@ -5,7 +5,7 @@ import { Command } from 'commander'; import { stringify } from 'yaml'; import type { ChronicleConfig } from '@/types'; -const defaultConfig: ChronicleConfig = { +export const defaultInitConfig: ChronicleConfig = { site: { title: 'My Documentation', description: 'Documentation powered by Chronicle', @@ -26,47 +26,74 @@ order: 1 This is your documentation home page. `; -export const initCommand = new Command('init') - .description('Initialize a new Chronicle project') - .action(() => { - const projectDir = process.cwd(); - const defaultDir = defaultConfig.content[0].dir; - const contentDir = path.join(projectDir, 'content', defaultDir); +const GITIGNORE_ENTRIES = ['node_modules', 'dist', '.output']; - if (!fs.existsSync(contentDir)) { - fs.mkdirSync(contentDir, { recursive: true }); - console.log(chalk.green('\u2713'), 'Created', contentDir); - } +export interface InitEvent { + type: 'created' | 'skipped' | 'updated'; + path: string; + detail?: string; +} - const configPath = path.join(projectDir, 'chronicle.yaml'); - if (!fs.existsSync(configPath)) { - fs.writeFileSync(configPath, stringify(defaultConfig)); - console.log(chalk.green('\u2713'), 'Created', configPath); - } else { - console.log(chalk.yellow('\u26a0'), configPath, 'already exists'); - } +export function runInit(projectDir: string): InitEvent[] { + const events: InitEvent[] = []; + const defaultDir = defaultInitConfig.content[0].dir; + const contentDir = path.join(projectDir, 'content', defaultDir); - const contentFiles = fs.readdirSync(contentDir); - if (contentFiles.length === 0) { - const indexPath = path.join(contentDir, 'index.mdx'); - fs.writeFileSync(indexPath, sampleMdx); - console.log(chalk.green('\u2713'), 'Created', indexPath); - } + if (!fs.existsSync(contentDir)) { + fs.mkdirSync(contentDir, { recursive: true }); + events.push({ type: 'created', path: contentDir }); + } + + const configPath = path.join(projectDir, 'chronicle.yaml'); + if (!fs.existsSync(configPath)) { + fs.writeFileSync(configPath, stringify(defaultInitConfig)); + events.push({ type: 'created', path: configPath }); + } else { + events.push({ type: 'skipped', path: configPath, detail: 'already exists' }); + } - const gitignorePath = path.join(projectDir, '.gitignore'); - const gitignoreEntries = ['node_modules', 'dist', '.output']; - if (fs.existsSync(gitignorePath)) { - const existing = fs.readFileSync(gitignorePath, 'utf-8'); - const missing = gitignoreEntries.filter(e => !existing.includes(e)); - if (missing.length > 0) { - fs.appendFileSync(gitignorePath, `\n${missing.join('\n')}\n`); - console.log(chalk.green('\u2713'), 'Added', missing.join(', '), 'to .gitignore'); - } - } else { - fs.writeFileSync(gitignorePath, `${gitignoreEntries.join('\n')}\n`); - console.log(chalk.green('\u2713'), 'Created .gitignore'); + const contentFiles = fs.readdirSync(contentDir); + if (contentFiles.length === 0) { + const indexPath = path.join(contentDir, 'index.mdx'); + fs.writeFileSync(indexPath, sampleMdx); + events.push({ type: 'created', path: indexPath }); + } + + const gitignorePath = path.join(projectDir, '.gitignore'); + if (fs.existsSync(gitignorePath)) { + const existing = fs.readFileSync(gitignorePath, 'utf-8'); + const missing = GITIGNORE_ENTRIES.filter(e => !existing.includes(e)); + if (missing.length > 0) { + fs.appendFileSync(gitignorePath, `\n${missing.join('\n')}\n`); + events.push({ type: 'updated', path: gitignorePath, detail: missing.join(', ') }); } + } else { + fs.writeFileSync(gitignorePath, `${GITIGNORE_ENTRIES.join('\n')}\n`); + events.push({ type: 'created', path: gitignorePath }); + } + + return events; +} + +function formatEvent(e: InitEvent): string { + if (e.type === 'skipped') { + return `${chalk.yellow('⚠')} ${e.path}${e.detail ? ` ${e.detail}` : ''}`; + } + if (e.type === 'updated') { + return `${chalk.green('✓')} Updated ${e.path}${e.detail ? ` (+${e.detail})` : ''}`; + } + return `${chalk.green('✓')} Created ${e.path}`; +} - console.log(chalk.green('\n\u2713 Chronicle initialized!')); - console.log('\nRun', chalk.cyan('chronicle dev'), 'to start development server'); +export const initCommand = new Command('init') + .description('Initialize a new Chronicle project') + .action(() => { + const events = runInit(process.cwd()); + for (const e of events) console.log(formatEvent(e)); + console.log(chalk.green('\n✓ Chronicle initialized!')); + console.log( + '\nRun', + chalk.cyan('chronicle dev'), + 'to start development server', + ); }); From 0b5d138dd7b39bbb85a6c15abcdeae12229427a5 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:29:06 +0530 Subject: [PATCH 35/55] chore: migrate examples/basic to new content layout - chronicle.yaml uses {site, content[]} shape; title -> site.title, description -> site.description, content string removed in favour of the `docs` content root - All mdx moved into content/docs/ (api/, guides/, etc. preserved under the same dir); petstore.json and frontier.yaml stay at the project root so config.api spec paths keep resolving Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/basic/chronicle.yaml | 16 +++++++++++++--- .../basic/{ => content/docs}/api/endpoints.mdx | 0 .../basic/{ => content/docs}/api/overview.mdx | 0 .../basic/{ => content/docs}/getting-started.mdx | 0 .../{ => content/docs}/guides/configuration.mdx | 0 .../{ => content/docs}/guides/installation.mdx | 0 examples/basic/{ => content/docs}/index.mdx | 0 7 files changed, 13 insertions(+), 3 deletions(-) rename examples/basic/{ => content/docs}/api/endpoints.mdx (100%) rename examples/basic/{ => content/docs}/api/overview.mdx (100%) rename examples/basic/{ => content/docs}/getting-started.mdx (100%) rename examples/basic/{ => content/docs}/guides/configuration.mdx (100%) rename examples/basic/{ => content/docs}/guides/installation.mdx (100%) rename examples/basic/{ => content/docs}/index.mdx (100%) diff --git a/examples/basic/chronicle.yaml b/examples/basic/chronicle.yaml index a14bf5d..2282d33 100644 --- a/examples/basic/chronicle.yaml +++ b/examples/basic/chronicle.yaml @@ -1,12 +1,20 @@ -title: My Documentation -description: Documentation powered by Chronicle +site: + title: My Documentation + description: Documentation powered by Chronicle + url: https://docs.example.com -content: . + +content: + - dir: docs + label: Docs + theme: name: default + search: enabled: true placeholder: Search documentation... + api: - name: Petstore spec: ./petstore.json @@ -24,10 +32,12 @@ api: server: url: https://frontier.raystack.org description: Frontier Server + analytics: enabled: false googleAnalytics: measurementId: G-XXXXXXXXXX + footer: copyright: "© 2024 Chronicle. All rights reserved." links: diff --git a/examples/basic/api/endpoints.mdx b/examples/basic/content/docs/api/endpoints.mdx similarity index 100% rename from examples/basic/api/endpoints.mdx rename to examples/basic/content/docs/api/endpoints.mdx diff --git a/examples/basic/api/overview.mdx b/examples/basic/content/docs/api/overview.mdx similarity index 100% rename from examples/basic/api/overview.mdx rename to examples/basic/content/docs/api/overview.mdx diff --git a/examples/basic/getting-started.mdx b/examples/basic/content/docs/getting-started.mdx similarity index 100% rename from examples/basic/getting-started.mdx rename to examples/basic/content/docs/getting-started.mdx diff --git a/examples/basic/guides/configuration.mdx b/examples/basic/content/docs/guides/configuration.mdx similarity index 100% rename from examples/basic/guides/configuration.mdx rename to examples/basic/content/docs/guides/configuration.mdx diff --git a/examples/basic/guides/installation.mdx b/examples/basic/content/docs/guides/installation.mdx similarity index 100% rename from examples/basic/guides/installation.mdx rename to examples/basic/content/docs/guides/installation.mdx diff --git a/examples/basic/index.mdx b/examples/basic/content/docs/index.mdx similarity index 100% rename from examples/basic/index.mdx rename to examples/basic/content/docs/index.mdx From d03cc939075ff4ded29fc8752bf9b55a63795844 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:30:16 +0530 Subject: [PATCH 36/55] docs: rewrite configuration reference for new schema - Adds project-layout section showing content/ + versions/ roots - Drops removed top-level title/description/content-string, adds new site, content[], latest, versions sections with landing + badge - Notes search scoping per version and llms.txt per-version output Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/content/docs/configuration.mdx | 181 ++++++++++++++++++++++------ 1 file changed, 143 insertions(+), 38 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 1c8ccb7..9eca5d7 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -6,15 +6,34 @@ order: 3 # Configuration -All site configuration lives in a single `chronicle.yaml` file in your project root. The config is validated using Zod — invalid fields will produce clear error messages at startup. +All site configuration lives in a single `chronicle.yaml` file in your project root. The config is validated using Zod — invalid fields produce clear errors at startup. -## Full Example +## Project layout + +``` +my-docs-site/ +├── chronicle.yaml +├── content/ ← latest +│ ├── docs/ +│ └── dev/ +└── versions/ ← only if versions: is declared + ├── v2/ + │ └── docs/ + └── v1/ + ├── docs/ + └── dev/ +``` + +Content dirs declared in `content:` are resolved under `content/` for the latest version; each `versions[].content[]` entry is resolved under `versions//`. + +## Full example ```yaml -title: My Project Docs -description: Documentation for My Project +site: + title: My Project Docs + description: Documentation for My Project + url: https://docs.example.com -content: docs preset: vercel logo: @@ -24,12 +43,45 @@ logo: theme: name: default +content: + - dir: docs + label: Docs + - dir: dev + label: Dev Docs + +latest: + label: "3.0" + landing: true + +versions: + - dir: v2 + label: "2.0" + content: + - dir: docs + label: Docs + + - dir: v1 + label: "1.0" + landing: true + badge: + label: deprecated + variant: warning + content: + - dir: dev + label: Developer Guide + - dir: docs + label: Docs + api: + - name: REST API (v1) + spec: ./v1-openapi.yaml + basePath: /apis + server: + url: https://api.example.com/v1 + navigation: links: - label: GitHub href: https://github.com/myorg/myproject - - label: Blog - href: https://blog.example.com search: enabled: true @@ -40,8 +92,6 @@ footer: links: - label: GitHub href: https://github.com/myorg/myproject - - label: License - href: /license api: - name: REST API @@ -71,17 +121,24 @@ telemetry: ## Reference -### title +### site -**Required.** The site title displayed in the navbar and browser tab. +**Required.** Site-level metadata. ```yaml -title: My Documentation +site: + title: My Documentation + description: Documentation powered by Chronicle ``` +| Field | Type | Description | +|-------|------|-------------| +| `title` | `string` | Site title (navbar, browser tab, canonical metadata). Required. | +| `description` | `string` | Meta description for SEO and OG. | + ### url -Optional site URL. Used for SEO metadata, sitemap, and canonical URLs. +Optional site URL used for the sitemap and canonical URLs. ```yaml url: https://docs.example.com @@ -89,31 +146,83 @@ url: https://docs.example.com ### content -Optional content directory path. Can be overridden by the `--content` CLI flag. +**Required.** Content dirs for the latest version. Each entry maps to `content//` on disk and to `//...` in URLs. ```yaml -content: docs +content: + - dir: docs + label: Docs + - dir: dev + label: Dev Docs ``` -### preset +| Field | Type | Description | +|-------|------|-------------| +| `dir` | `string` | Folder name under `content/`. Must be unique. | +| `label` | `string` | Display label in navigation and landing pages. | + +### latest -Optional deploy preset. Can be overridden by the `--preset` CLI flag. +Optional metadata for the latest version. Required when `versions:` is declared. ```yaml -preset: vercel # vercel, cloudflare, or node-server +latest: + label: "3.0" + landing: true ``` -### description +| Field | Type | Description | Default | +|-------|------|-------------|---------| +| `label` | `string` | Version label (e.g. `3.0`). Shown in the version switcher. | — | +| `landing` | `boolean` | `true` → `/` renders a landing page listing content dirs. `false` (default) → `/` 302s to the first content dir. | `false` | -Optional meta description for SEO. +### versions + +Optional list of older versions. Each entry lives under `versions//` on disk and is reachable at `//...` in URLs. ```yaml -description: Documentation powered by Chronicle +versions: + - dir: v1 + label: "1.0" + landing: true + badge: + label: deprecated + variant: warning + content: + - dir: dev + label: Developer Guide + - dir: docs + label: Docs + api: + - name: REST API (v1) + spec: ./v1-openapi.yaml + basePath: /apis + server: + url: https://api.example.com/v1 +``` + +| Field | Type | Description | +|-------|------|-------------| +| `dir` | `string` | Folder name under `versions/`. Doubles as URL prefix. Must be unique. | +| `label` | `string` | Version label. Shown in the switcher. | +| `landing` | `boolean` | `true` → `/` renders a landing page; otherwise 302s to the version's first content dir. Default `false`. | +| `badge` | `object` | Optional Apsara badge next to the version label. | +| `badge.label` | `string` | Badge text. | +| `badge.variant` | `"accent" \| "warning" \| "danger" \| "success" \| "neutral" \| "gradient"` | Badge colour. Default `accent`. | +| `content` | `{dir, label}[]` | Content dirs for this version. Entries may rename, reorder, or omit top-level content dirs. | +| `api` | `ApiConfig[]` | Version-scoped API specs, rendered at `//apis/...`. Same shape as top-level `api:`. | + +### preset + +Optional deploy preset. Can be overridden by `--preset`. + +```yaml +preset: vercel # vercel, cloudflare, or node-server ``` ### logo -Logo configuration with theme-aware variants. +Logo with theme-aware variants. ```yaml logo: @@ -151,13 +260,9 @@ navigation: links: - label: GitHub href: https://github.com/myorg/myproject - - label: API - href: /apis social: - type: github href: https://github.com/myorg/myproject - - type: discord - href: https://discord.gg/example ``` **navigation.links** @@ -176,7 +281,7 @@ navigation: ### search -Search functionality powered by Fumadocs. +Search functionality powered by Fumadocs. Automatically scoped to the active version. ```yaml search: @@ -189,7 +294,7 @@ search: | `enabled` | `boolean` | Enable/disable search | `true` | | `placeholder` | `string` | Search input placeholder | `Search...` | -When enabled, search is accessible via the navbar button or keyboard shortcut `Cmd+K` / `Ctrl+K`. +When enabled, search is accessible via the navbar button or keyboard shortcut `Cmd+K` / `Ctrl+K`. Active version comes from the URL; switching versions scopes the index. ### footer @@ -212,7 +317,7 @@ footer: ### api -OpenAPI specification configuration for interactive API documentation. +OpenAPI specification configuration at the top level applies to the latest version (served at `/apis/...`). Version-scoped specs live under each `versions[].api`. ```yaml api: @@ -228,8 +333,6 @@ api: placeholder: Enter your API key ``` -Each entry in the `api` array creates a section of API documentation. - | Field | Type | Description | |-------|------|-------------| | `name` | `string` | API display name | @@ -241,11 +344,9 @@ Each entry in the `api` array creates a section of API documentation. | `auth.header` | `string` | Header name for auth token | | `auth.placeholder` | `string` | Placeholder text in auth input | -API pages include a "Try it out" panel that uses the configured server URL and auth settings. - ### llms -Configuration for LLM-friendly content generation. When enabled, Chronicle generates `/llms.txt` and `/llms-full.txt` endpoints. +Per-version `llms.txt` generation. ```yaml llms: @@ -254,7 +355,7 @@ llms: | Field | Type | Description | Default | |-------|------|-------------|---------| -| `enabled` | `boolean` | Enable/disable LLM content endpoints | `false` | +| `enabled` | `boolean` | Emit `/llms.txt` and `//llms.txt` | `false` | ### analytics @@ -274,7 +375,7 @@ analytics: ### telemetry -Prometheus metrics export via OpenTelemetry. When enabled, metrics are served on a separate port. +Prometheus metrics export via OpenTelemetry. Served on a separate port. ```yaml telemetry: @@ -293,10 +394,14 @@ Metrics are available at `http://localhost:/metrics` in Prometheus exposit ## Defaults -If `chronicle.yaml` is missing or fields are omitted, these defaults apply: +When `chronicle.yaml` is missing or fields are omitted, these defaults apply: ```yaml -title: Documentation +site: + title: Documentation +content: + - dir: docs + label: Docs theme: name: default search: From 8b95ee8a3f665d01f60085d7fba7879da306d1b2 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:30:48 +0530 Subject: [PATCH 37/55] docs: add migration guide Step-by-step walk-through for upgrading a legacy Chronicle project: before/after config and filesystem diffs, content move, optional landing + version setup, breaking-change table. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/content/docs/migration.mdx | 170 ++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/content/docs/migration.mdx diff --git a/docs/content/docs/migration.mdx b/docs/content/docs/migration.mdx new file mode 100644 index 0000000..9031299 --- /dev/null +++ b/docs/content/docs/migration.mdx @@ -0,0 +1,170 @@ +--- +title: Migration Guide +description: Upgrade an existing Chronicle project to the multi-content + versioning layout. +order: 8 +--- + +# Migration Guide + +This guide walks through upgrading a pre-versioning Chronicle project to the new `{site, content[]}` schema and nested content layout. + +Chronicle does not ship a `chronicle migrate` command — migration is a small set of manual edits. + +## Before & after + +### Old + +```yaml +# chronicle.yaml (legacy) +title: My Docs +description: Documentation powered by Chronicle +content: . + +theme: + name: default +``` + +Filesystem: + +``` +my-docs/ +├── chronicle.yaml +├── index.mdx +├── guide.mdx +└── api/ + └── overview.mdx +``` + +### New + +```yaml +# chronicle.yaml +site: + title: My Docs + description: Documentation powered by Chronicle + +content: + - dir: docs + label: Docs + +theme: + name: default +``` + +Filesystem: + +``` +my-docs/ +├── chronicle.yaml +└── content/ + └── docs/ + ├── index.mdx + ├── guide.mdx + └── api/ + └── overview.mdx +``` + +## Step-by-step + +### 1. Move content under `content//` + +Pick a content dir name (typically `docs`) and move every `.mdx` / `.md` file under `content//`. Subfolders are preserved. + +``` +git mv index.mdx content/docs/index.mdx +git mv guide.mdx content/docs/guide.mdx +git mv api content/docs/api +``` + +If cross-links use unprefixed URLs like `/guide`, update them to `/docs/guide` (matching the new content dir). + +### 2. Rewrite `chronicle.yaml` + +- Move `title` and `description` under `site:`. +- Replace `content: ` with `content:` as a list of `{dir, label}`. +- Drop the `--content` CLI flag — content location is config-driven now. + +```yaml +site: + title: My Docs + description: Documentation powered by Chronicle + +content: + - dir: docs + label: Docs +``` + +### 3. (Optional) Add more content dirs + +Multiple dirs at the top level are supported. Each one becomes its own URL prefix. + +```yaml +content: + - dir: docs + label: Docs + - dir: dev + label: Dev Docs +``` + +Move files into `content/dev/` alongside `content/docs/`. + +### 4. (Optional) Opt into a landing page + +By default, `/` 302s to the first content dir's index. Set `latest.landing: true` to show a chromeless landing page listing each content dir. + +```yaml +latest: + label: "3.0" + landing: true +``` + +### 5. (Optional) Add a version + +Create `versions///` on disk and declare it in config: + +```yaml +latest: + label: "2.0" + +versions: + - dir: v1 + label: "1.0" + badge: + label: deprecated + variant: warning + content: + - dir: docs + label: Docs +``` + +Filesystem: + +``` +my-docs/ +├── content/ +│ └── docs/ +└── versions/ + └── v1/ + └── docs/ +``` + +Old-version URLs are prefixed (`/v1/docs/...`); latest stays unprefixed. + +### 6. Run `chronicle dev` + +The CLI rebuilds the internal `.content/` mirror automatically on start. No source-tree changes or codegen are needed. + +## Reference + +- [Configuration](/docs/configuration) — full schema reference. +- [CLI](/docs/cli) — command list; note that the `--content` flag has been removed. + +## Breaking changes summary + +| Change | Before | After | +|--------|--------|-------| +| Site title | `title:` at root | `site.title` | +| Site description | `description:` at root | `site.description` | +| Content location | `content: ` (any path) | `content: [{dir, label}]` rooted at `content/` | +| CLI flag | `--content ` | removed (config-driven) | +| Default landing | Implicit from content count | Explicit `landing: true` on `latest` / `versions[]` | From 5f4ca2e4be683fee9ca94a8b56506d1867bd610e Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:33:03 +0530 Subject: [PATCH 38/55] revert: drop migration guide from published docs Migration notes stay in the local VERSIONING_MIGRATION.md instead of shipping as a docs page. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/content/docs/migration.mdx | 170 -------------------------------- 1 file changed, 170 deletions(-) delete mode 100644 docs/content/docs/migration.mdx diff --git a/docs/content/docs/migration.mdx b/docs/content/docs/migration.mdx deleted file mode 100644 index 9031299..0000000 --- a/docs/content/docs/migration.mdx +++ /dev/null @@ -1,170 +0,0 @@ ---- -title: Migration Guide -description: Upgrade an existing Chronicle project to the multi-content + versioning layout. -order: 8 ---- - -# Migration Guide - -This guide walks through upgrading a pre-versioning Chronicle project to the new `{site, content[]}` schema and nested content layout. - -Chronicle does not ship a `chronicle migrate` command — migration is a small set of manual edits. - -## Before & after - -### Old - -```yaml -# chronicle.yaml (legacy) -title: My Docs -description: Documentation powered by Chronicle -content: . - -theme: - name: default -``` - -Filesystem: - -``` -my-docs/ -├── chronicle.yaml -├── index.mdx -├── guide.mdx -└── api/ - └── overview.mdx -``` - -### New - -```yaml -# chronicle.yaml -site: - title: My Docs - description: Documentation powered by Chronicle - -content: - - dir: docs - label: Docs - -theme: - name: default -``` - -Filesystem: - -``` -my-docs/ -├── chronicle.yaml -└── content/ - └── docs/ - ├── index.mdx - ├── guide.mdx - └── api/ - └── overview.mdx -``` - -## Step-by-step - -### 1. Move content under `content//` - -Pick a content dir name (typically `docs`) and move every `.mdx` / `.md` file under `content//`. Subfolders are preserved. - -``` -git mv index.mdx content/docs/index.mdx -git mv guide.mdx content/docs/guide.mdx -git mv api content/docs/api -``` - -If cross-links use unprefixed URLs like `/guide`, update them to `/docs/guide` (matching the new content dir). - -### 2. Rewrite `chronicle.yaml` - -- Move `title` and `description` under `site:`. -- Replace `content: ` with `content:` as a list of `{dir, label}`. -- Drop the `--content` CLI flag — content location is config-driven now. - -```yaml -site: - title: My Docs - description: Documentation powered by Chronicle - -content: - - dir: docs - label: Docs -``` - -### 3. (Optional) Add more content dirs - -Multiple dirs at the top level are supported. Each one becomes its own URL prefix. - -```yaml -content: - - dir: docs - label: Docs - - dir: dev - label: Dev Docs -``` - -Move files into `content/dev/` alongside `content/docs/`. - -### 4. (Optional) Opt into a landing page - -By default, `/` 302s to the first content dir's index. Set `latest.landing: true` to show a chromeless landing page listing each content dir. - -```yaml -latest: - label: "3.0" - landing: true -``` - -### 5. (Optional) Add a version - -Create `versions///` on disk and declare it in config: - -```yaml -latest: - label: "2.0" - -versions: - - dir: v1 - label: "1.0" - badge: - label: deprecated - variant: warning - content: - - dir: docs - label: Docs -``` - -Filesystem: - -``` -my-docs/ -├── content/ -│ └── docs/ -└── versions/ - └── v1/ - └── docs/ -``` - -Old-version URLs are prefixed (`/v1/docs/...`); latest stays unprefixed. - -### 6. Run `chronicle dev` - -The CLI rebuilds the internal `.content/` mirror automatically on start. No source-tree changes or codegen are needed. - -## Reference - -- [Configuration](/docs/configuration) — full schema reference. -- [CLI](/docs/cli) — command list; note that the `--content` flag has been removed. - -## Breaking changes summary - -| Change | Before | After | -|--------|--------|-------| -| Site title | `title:` at root | `site.title` | -| Site description | `description:` at root | `site.description` | -| Content location | `content: ` (any path) | `content: [{dir, label}]` rooted at `content/` | -| CLI flag | `--content ` | removed (config-driven) | -| Default landing | Implicit from content count | Explicit `landing: true` on `latest` / `versions[]` | From 70f144056f3bc33100d5604c8feeff2cb8070de3 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:35:22 +0530 Subject: [PATCH 39/55] ci: add lint + test workflow for PRs Runs bun lint and bun test on every pull request and push to main from packages/chronicle. Uses the same Bun runtime as the release workflow. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9b3aef8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: ./packages/chronicle + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + working-directory: . + + - name: Lint + run: bun run lint + + - name: Test + run: bun test From aa74f41024170b225166d87813c6772a74bffc16 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:51:15 +0530 Subject: [PATCH 40/55] fix: mirror content with per-file symlinks so prod build finds pages Vite's build-time import.meta.glob doesn't descend into symlinked directories, so the previous dir-level symlinks made the production bundle ship an empty page tree (/docs etc. 404). Replace buildContentMirror's dir symlinks with a recursive walk that mkdirs each subfolder in the mirror and symlinks files individually; dev live-reload is preserved, and vite build now walks real dirs. Tests updated to assert on real dirs + per-file symlinks. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chronicle/src/cli/utils/scaffold.test.ts | 99 ++++++++++++------- packages/chronicle/src/cli/utils/scaffold.ts | 31 ++++-- 2 files changed, 91 insertions(+), 39 deletions(-) diff --git a/packages/chronicle/src/cli/utils/scaffold.test.ts b/packages/chronicle/src/cli/utils/scaffold.test.ts index 649ca90..e2c9823 100644 --- a/packages/chronicle/src/cli/utils/scaffold.test.ts +++ b/packages/chronicle/src/cli/utils/scaffold.test.ts @@ -15,8 +15,14 @@ async function seedContent(relPath: string, file = 'index.mdx'): Promise { await fs.writeFile(path.join(dir, file), `---\ntitle: ${relPath}\n---\n`) } -async function readMirror(relPath: string): Promise { - return fs.readlink(path.join(mirrorRoot, relPath)) +async function isDir(p: string): Promise { + return (await fs.lstat(p)).isDirectory() +} + +async function fileSymlinkTarget(p: string): Promise { + const st = await fs.lstat(p) + expect(st.isSymbolicLink()).toBe(true) + return fs.readlink(p) } beforeEach(async () => { @@ -31,8 +37,27 @@ afterEach(async () => { }) describe('buildContentMirror', () => { - test('creates symlinks for single-content latest config', async () => { - await seedContent('content/docs') + test('single-content latest: mirrors as real dirs with per-file symlinks', async () => { + await seedContent('content/docs', 'index.mdx') + await seedContent('content/docs', 'guide.mdx') + const config = chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + }) + + await buildContentMirror(mirrorRoot, projectRoot, config) + + expect(await isDir(path.join(mirrorRoot, 'docs'))).toBe(true) + expect(await fileSymlinkTarget(path.join(mirrorRoot, 'docs/index.mdx'))).toBe( + path.join(projectRoot, 'content/docs/index.mdx'), + ) + expect(await fileSymlinkTarget(path.join(mirrorRoot, 'docs/guide.mdx'))).toBe( + path.join(projectRoot, 'content/docs/guide.mdx'), + ) + }) + + test('preserves nested subdirectories via recursive mirror', async () => { + await seedContent('content/docs/guides', 'install.mdx') const config = chronicleConfigSchema.parse({ site: { title: 'x' }, content: [{ dir: 'docs', label: 'Docs' }], @@ -40,14 +65,16 @@ describe('buildContentMirror', () => { await buildContentMirror(mirrorRoot, projectRoot, config) - expect(await readMirror('docs')).toBe(path.join(projectRoot, 'content/docs')) - const entries = await fs.readdir(mirrorRoot) - expect(entries.sort()).toEqual(['docs']) + const nested = path.join(mirrorRoot, 'docs/guides/install.mdx') + expect(await fileSymlinkTarget(nested)).toBe( + path.join(projectRoot, 'content/docs/guides/install.mdx'), + ) + expect(await isDir(path.join(mirrorRoot, 'docs/guides'))).toBe(true) }) - test('creates symlinks for multi-content latest config', async () => { - await seedContent('content/docs') - await seedContent('content/dev') + test('multi-content latest produces one real dir per content entry', async () => { + await seedContent('content/docs', 'index.mdx') + await seedContent('content/dev', 'index.mdx') const config = chronicleConfigSchema.parse({ site: { title: 'x' }, content: [ @@ -58,14 +85,17 @@ describe('buildContentMirror', () => { await buildContentMirror(mirrorRoot, projectRoot, config) - expect(await readMirror('docs')).toBe(path.join(projectRoot, 'content/docs')) - expect(await readMirror('dev')).toBe(path.join(projectRoot, 'content/dev')) + expect(await isDir(path.join(mirrorRoot, 'docs'))).toBe(true) + expect(await isDir(path.join(mirrorRoot, 'dev'))).toBe(true) + expect( + await fileSymlinkTarget(path.join(mirrorRoot, 'dev/index.mdx')), + ).toBe(path.join(projectRoot, 'content/dev/index.mdx')) }) - test('creates nested symlinks for versioned config', async () => { - await seedContent('content/docs') - await seedContent('versions/v1/docs') - await seedContent('versions/v1/dev') + test('versioned mirror nests version dir then content dir', async () => { + await seedContent('content/docs', 'index.mdx') + await seedContent('versions/v1/docs', 'index.mdx') + await seedContent('versions/v1/dev', 'api.mdx') const config = chronicleConfigSchema.parse({ site: { title: 'x' }, content: [{ dir: 'docs', label: 'Docs' }], @@ -84,17 +114,17 @@ describe('buildContentMirror', () => { await buildContentMirror(mirrorRoot, projectRoot, config) - expect(await readMirror('docs')).toBe(path.join(projectRoot, 'content/docs')) - expect(await readMirror('v1/docs')).toBe( - path.join(projectRoot, 'versions/v1/docs'), - ) - expect(await readMirror('v1/dev')).toBe( - path.join(projectRoot, 'versions/v1/dev'), - ) + expect(await isDir(path.join(mirrorRoot, 'v1/docs'))).toBe(true) + expect( + await fileSymlinkTarget(path.join(mirrorRoot, 'v1/docs/index.mdx')), + ).toBe(path.join(projectRoot, 'versions/v1/docs/index.mdx')) + expect( + await fileSymlinkTarget(path.join(mirrorRoot, 'v1/dev/api.mdx')), + ).toBe(path.join(projectRoot, 'versions/v1/dev/api.mdx')) }) - test('is idempotent — re-running yields the same mirror', async () => { - await seedContent('content/docs') + test('is idempotent — re-running yields the same tree', async () => { + await seedContent('content/docs', 'index.mdx') const config = chronicleConfigSchema.parse({ site: { title: 'x' }, content: [{ dir: 'docs', label: 'Docs' }], @@ -103,12 +133,14 @@ describe('buildContentMirror', () => { await buildContentMirror(mirrorRoot, projectRoot, config) await buildContentMirror(mirrorRoot, projectRoot, config) - expect(await readMirror('docs')).toBe(path.join(projectRoot, 'content/docs')) + expect( + await fileSymlinkTarget(path.join(mirrorRoot, 'docs/index.mdx')), + ).toBe(path.join(projectRoot, 'content/docs/index.mdx')) }) - test('wipes stale entries when config changes', async () => { - await seedContent('content/docs') - await seedContent('content/dev') + test('wipes stale entries when config shrinks', async () => { + await seedContent('content/docs', 'index.mdx') + await seedContent('content/dev', 'index.mdx') const before = chronicleConfigSchema.parse({ site: { title: 'x' }, content: [ @@ -130,7 +162,7 @@ describe('buildContentMirror', () => { }) test('replaces a legacy single-symlink mirror', async () => { - await seedContent('content/docs') + await seedContent('content/docs', 'index.mdx') await fs.symlink(path.join(projectRoot, 'content/docs'), mirrorRoot) const config = chronicleConfigSchema.parse({ @@ -139,8 +171,9 @@ describe('buildContentMirror', () => { }) await buildContentMirror(mirrorRoot, projectRoot, config) - const stat = await fs.lstat(mirrorRoot) - expect(stat.isDirectory()).toBe(true) - expect(await readMirror('docs')).toBe(path.join(projectRoot, 'content/docs')) + expect(await isDir(mirrorRoot)).toBe(true) + expect( + await fileSymlinkTarget(path.join(mirrorRoot, 'docs/index.mdx')), + ).toBe(path.join(projectRoot, 'content/docs/index.mdx')) }) }) diff --git a/packages/chronicle/src/cli/utils/scaffold.ts b/packages/chronicle/src/cli/utils/scaffold.ts index dd7f71e..fb4185d 100644 --- a/packages/chronicle/src/cli/utils/scaffold.ts +++ b/packages/chronicle/src/cli/utils/scaffold.ts @@ -13,9 +13,9 @@ export async function buildContentMirror( await fs.mkdir(mirrorRoot, { recursive: true }); for (const root of getLatestContentRoots(config)) { - const target = path.resolve(projectRoot, root.fsPath); - const linkPath = path.join(mirrorRoot, root.contentDir); - await fs.symlink(target, linkPath); + const source = path.resolve(projectRoot, root.fsPath); + const dest = path.join(mirrorRoot, root.contentDir); + await mirrorTree(source, dest); } for (const version of config.versions ?? []) { @@ -23,9 +23,9 @@ export async function buildContentMirror( await fs.mkdir(versionMirror, { recursive: true }); for (const root of getVersionContentRoots(config, version.dir)) { - const target = path.resolve(projectRoot, root.fsPath); - const linkPath = path.join(versionMirror, root.contentDir); - await fs.symlink(target, linkPath); + const source = path.resolve(projectRoot, root.fsPath); + const dest = path.join(versionMirror, root.contentDir); + await mirrorTree(source, dest); } } } @@ -41,6 +41,25 @@ export function linkContent( ); } +async function mirrorTree(source: string, dest: string): Promise { + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(source, { withFileTypes: true }); + } catch { + return; + } + await fs.mkdir(dest, { recursive: true }); + for (const entry of entries) { + const sourcePath = path.join(source, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + await mirrorTree(sourcePath, destPath); + } else if (entry.isFile() || entry.isSymbolicLink()) { + await fs.symlink(sourcePath, destPath); + } + } +} + async function removeMirror(mirrorRoot: string): Promise { try { const stat = await fs.lstat(mirrorRoot); From c09b0f92df0c06231429c47752e9f9c391d7b55f Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:51:22 +0530 Subject: [PATCH 41/55] fix: add --config flag to chronicle start The start command lost --config in the phase-2 CLI refactor; pass the user's chronicle.yaml path through loadCLIConfig so npm-script workflows like `chronicle start --config docs/...` resolve the right config. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/cli/commands/start.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/chronicle/src/cli/commands/start.ts b/packages/chronicle/src/cli/commands/start.ts index 7630ad6..7be31f2 100644 --- a/packages/chronicle/src/cli/commands/start.ts +++ b/packages/chronicle/src/cli/commands/start.ts @@ -7,9 +7,10 @@ import { linkContent } from '@/cli/utils/scaffold'; export const startCommand = new Command('start') .description('Start production server') .option('-p, --port ', 'Port number', '3000') + .option('--config ', 'Path to chronicle.yaml') .option('--host ', 'Host address', 'localhost') .action(async options => { - const { config, projectRoot, configPath } = await loadCLIConfig(); + const { config, projectRoot, configPath } = await loadCLIConfig(options.config); const port = parseInt(options.port, 10); await linkContent(projectRoot, config); From cde1931cb522d265755b638628fe152d94f5b107 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 15:59:10 +0530 Subject: [PATCH 42/55] feat: reject '.', '..', and path-shaped dir names Content + version dir names now fail schema validation unless they are simple folder names. Rejects '.', '..', and anything containing '/' or '\\' so neither fsPath nor urlPrefix can resolve to a path traversal or produce a broken URL like '/./.'. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/config.test.ts | 28 +++++++++++++++++++++++ packages/chronicle/src/types/config.ts | 11 +++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/chronicle/src/lib/config.test.ts b/packages/chronicle/src/lib/config.test.ts index 119b135..3310c06 100644 --- a/packages/chronicle/src/lib/config.test.ts +++ b/packages/chronicle/src/lib/config.test.ts @@ -91,6 +91,34 @@ describe('chronicleConfigSchema', () => { ).toThrow() }) + test('rejects "." or ".." or path-shaped content dir names', () => { + for (const dir of ['.', '..', 'foo/bar', 'foo\\bar']) { + expect(() => + chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir, label: 'Docs' }], + }), + ).toThrow(/simple folder name/) + } + }) + + test('rejects "." or ".." version dir', () => { + expect(() => + chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + latest: { label: '2.0' }, + versions: [ + { + dir: '.', + label: '1.0', + content: [{ dir: 'docs', label: 'Docs' }], + }, + ], + }), + ).toThrow(/simple folder name/) + }) + test('rejects versions without latest', () => { expect(() => chronicleConfigSchema.parse({ diff --git a/packages/chronicle/src/types/config.ts b/packages/chronicle/src/types/config.ts index 4a56936..3d9b158 100644 --- a/packages/chronicle/src/types/config.ts +++ b/packages/chronicle/src/types/config.ts @@ -79,8 +79,15 @@ const siteSchema = z.object({ description: z.string().optional(), }) +const dirNameSchema = z + .string() + .min(1) + .refine((s) => s !== '.' && s !== '..' && !s.includes('/') && !s.includes('\\'), { + message: 'dir must be a simple folder name (not ".", "..", or a path)', + }) + const contentEntrySchema = z.object({ - dir: z.string().min(1), + dir: dirNameSchema, label: z.string().min(1), }) @@ -106,7 +113,7 @@ const latestSchema = z.object({ }) const versionSchema = z.object({ - dir: z.string().min(1), + dir: dirNameSchema, label: z.string().min(1), badge: badgeSchema.optional(), landing: z.boolean().optional(), From 8fcd6c75ed71e8a336608c80aff7e361cfab366a Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 16:02:02 +0530 Subject: [PATCH 43/55] fix: update vercel output path --- docs/chronicle.yaml | 2 ++ vercel.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/chronicle.yaml b/docs/chronicle.yaml index 9a6479f..627ae8f 100644 --- a/docs/chronicle.yaml +++ b/docs/chronicle.yaml @@ -24,6 +24,8 @@ llms: telemetry: enabled: true +preset: "vercel" + footer: copyright: "© 2026 Raystack. All rights reserved." links: diff --git a/vercel.json b/vercel.json index 47e057a..0ee2ca7 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,5 @@ { "installCommand": "bun install", "buildCommand": "bun run build:cli && bun run build:docs -- --preset vercel", - "outputDirectory": ".vercel/output" + "outputDirectory": "docs/.vercel/output" } From 477e8f67425ef9de84460b5bf8b2e9fb5a4b5712 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 17:59:10 +0530 Subject: [PATCH 44/55] feat: validate content/version dir overlap + reserved route segments - versions[].dir can no longer collide with a top-level content[].dir; the URL segment would otherwise shadow the content root - 'apis' (and future RESERVED_ROUTE_SEGMENTS entries) are rejected as content or version dir names to avoid colliding with built-in routes - Tests cover both refines Addresses coderabbit review on types/config.ts:166. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/config.test.ts | 41 +++++++++++++++++++++++ packages/chronicle/src/types/config.ts | 30 +++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/packages/chronicle/src/lib/config.test.ts b/packages/chronicle/src/lib/config.test.ts index 3310c06..0b4aa64 100644 --- a/packages/chronicle/src/lib/config.test.ts +++ b/packages/chronicle/src/lib/config.test.ts @@ -102,6 +102,47 @@ describe('chronicleConfigSchema', () => { } }) + test('rejects version dir overlapping a top-level content dir', () => { + expect(() => + chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'v1', label: 'V1 docs' }], + latest: { label: '2.0' }, + versions: [ + { + dir: 'v1', + label: '1.0', + content: [{ dir: 'docs', label: 'Docs' }], + }, + ], + }), + ).toThrow(/must not overlap/) + }) + + test('rejects reserved route segments as dir names', () => { + expect(() => + chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'apis', label: 'API' }], + }), + ).toThrow(/reserved route segment/) + + expect(() => + chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir: 'docs', label: 'Docs' }], + latest: { label: '2.0' }, + versions: [ + { + dir: 'apis', + label: '1.0', + content: [{ dir: 'docs', label: 'Docs' }], + }, + ], + }), + ).toThrow(/reserved route segment/) + }) + test('rejects "." or ".." version dir', () => { expect(() => chronicleConfigSchema.parse({ diff --git a/packages/chronicle/src/types/config.ts b/packages/chronicle/src/types/config.ts index 3d9b158..c0722c7 100644 --- a/packages/chronicle/src/types/config.ts +++ b/packages/chronicle/src/types/config.ts @@ -124,6 +124,8 @@ const versionSchema = z.object({ const allUnique = (items: T[], key: (item: T) => string): boolean => uniqBy(items, key).length === items.length +const RESERVED_ROUTE_SEGMENTS = ['apis'] as const + export const chronicleConfigSchema = z .object({ site: siteSchema, @@ -164,6 +166,34 @@ export const chronicleConfigSchema = z message: 'latest is required when versions are declared', path: ['latest'], }) + .refine( + (cfg) => { + if (!cfg.versions) return true + const contentDirs = new Set(cfg.content.map((c) => c.dir)) + return !cfg.versions.some((v) => contentDirs.has(v.dir)) + }, + { + message: + 'versions[].dir must not overlap with content[].dir — the URL segment would be shadowed', + path: ['versions'], + }, + ) + .refine( + (cfg) => { + const reserved = new Set(RESERVED_ROUTE_SEGMENTS) + const topLevel = cfg.content.map((c) => c.dir) + const versionDirs = cfg.versions?.map((v) => v.dir) ?? [] + const versionContent = + cfg.versions?.flatMap((v) => v.content.map((c) => c.dir)) ?? [] + return ![...topLevel, ...versionDirs, ...versionContent].some((d) => + reserved.has(d), + ) + }, + { + message: `dir must not be a reserved route segment: ${RESERVED_ROUTE_SEGMENTS.join(', ')}`, + path: ['content'], + }, + ) export type ChronicleConfig = z.infer export type SiteConfig = z.infer From df168667f07a7bc59ce194a5b5c398e2fbe1d4f1 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 17:59:18 +0530 Subject: [PATCH 45/55] fix: harden buildLlmsTxt against missing title + empty description - LlmsPage.title is now optional; buildLlmsTxt falls back to the page URL when title is missing or whitespace-only (extractFrontmatter defaults to 'Untitled' today but the helper should defend itself) - Empty description no longer produces a stray blank line between the heading and the index - Tests cover both cases Addresses coderabbit review on routes/llms.txt.ts:22. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/llms.test.ts | 27 +++++++++++++++++++++++++ packages/chronicle/src/lib/llms.ts | 10 ++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/chronicle/src/lib/llms.test.ts b/packages/chronicle/src/lib/llms.test.ts index 30e92fa..d45ac91 100644 --- a/packages/chronicle/src/lib/llms.test.ts +++ b/packages/chronicle/src/lib/llms.test.ts @@ -42,6 +42,33 @@ describe('buildLlmsTxt', () => { expect(out.startsWith('# My Docs\n')).toBe(true) }) + test('falls back to page url when title is missing or empty', () => { + const config = chronicleConfigSchema.parse({ + site: { title: 'Docs' }, + content: [{ dir: 'docs', label: 'Docs' }], + }) + const out = buildLlmsTxt( + config, + [ + { url: '/docs/untitled' }, + { url: '/docs/blank', title: ' ' }, + ], + LATEST_CONTEXT, + ) + expect(out).toContain('- [/docs/untitled](/docs/untitled.md)') + expect(out).toContain('- [/docs/blank](/docs/blank.md)') + }) + + test('omits the description line when description is empty', () => { + const config = chronicleConfigSchema.parse({ + site: { title: 'Docs' }, + content: [{ dir: 'docs', label: 'Docs' }], + }) + const out = buildLlmsTxt(config, [{ url: '/a', title: 'A' }], LATEST_CONTEXT) + // heading immediately followed by a single blank line then the index + expect(out).toBe('# Docs\n\n- [A](/a.md)') + }) + test('uses the version label for a versioned ctx', () => { const config = chronicleConfigSchema.parse({ site: { title: 'My Docs' }, diff --git a/packages/chronicle/src/lib/llms.ts b/packages/chronicle/src/lib/llms.ts index 0a17726..1797488 100644 --- a/packages/chronicle/src/lib/llms.ts +++ b/packages/chronicle/src/lib/llms.ts @@ -3,7 +3,7 @@ import type { VersionContext } from './version-source' export interface LlmsPage { url: string - title: string + title?: string } export function buildLlmsTxt( @@ -21,11 +21,15 @@ export function buildLlmsTxt( const index = pages .map((p) => { const mdUrl = p.url === '/' ? '/index.md' : `${p.url}.md` - return `- [${p.title}](${mdUrl})` + const title = p.title?.trim() || p.url + return `- [${title}](${mdUrl})` }) .join('\n') - return `${heading}\n\n${description}\n\n${index}` + const parts = [heading] + if (description) parts.push(description) + parts.push(index) + return parts.join('\n\n') } function getVersionLabel( From 1abbf2b53b91ccc221c0bd6ff759d19086e92104 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 17:59:46 +0530 Subject: [PATCH 46/55] refactor: address coderabbit review findings - scaffold.ts: mirrorTree/removeMirror only swallow ENOENT now; other fs errors (permission, read failures) propagate - navigation.ts + route-resolver.ts: resolve content dirs through getLatestContentRoots / getVersionContentRoots so the mirror, source, nav, and resolver all agree on how content roots are derived - page-context.tsx: clear stale page + errorStatus when entering an API route so a prior 404 doesn't linger on the API screen - LandingPage.tsx: switch content-dir cards from to RouterLink to keep navigation SPA-internal and preserve hydrated state Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/cli/utils/scaffold.ts | 10 ++++++---- packages/chronicle/src/lib/navigation.ts | 14 +++++++++----- packages/chronicle/src/lib/page-context.tsx | 2 ++ packages/chronicle/src/lib/route-resolver.ts | 10 +++++++--- packages/chronicle/src/pages/LandingPage.tsx | 5 +++-- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/packages/chronicle/src/cli/utils/scaffold.ts b/packages/chronicle/src/cli/utils/scaffold.ts index fb4185d..72dd93b 100644 --- a/packages/chronicle/src/cli/utils/scaffold.ts +++ b/packages/chronicle/src/cli/utils/scaffold.ts @@ -45,8 +45,9 @@ async function mirrorTree(source: string, dest: string): Promise { let entries: import('node:fs').Dirent[]; try { entries = await fs.readdir(source, { withFileTypes: true }); - } catch { - return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; } await fs.mkdir(dest, { recursive: true }); for (const entry of entries) { @@ -68,7 +69,8 @@ async function removeMirror(mirrorRoot: string): Promise { } else if (stat.isDirectory()) { await fs.rm(mirrorRoot, { recursive: true, force: true }); } - } catch { - // mirror doesn't exist + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; } } diff --git a/packages/chronicle/src/lib/navigation.ts b/packages/chronicle/src/lib/navigation.ts index 1525024..b889432 100644 --- a/packages/chronicle/src/lib/navigation.ts +++ b/packages/chronicle/src/lib/navigation.ts @@ -1,5 +1,9 @@ import type { ChronicleConfig } from '@/types' -import { getLandingEntries } from './config' +import { + getLandingEntries, + getLatestContentRoots, + getVersionContentRoots, +} from './config' import { resolveVersionFromUrl } from './version-source' export function getActiveContentDir( @@ -16,10 +20,10 @@ export function getActiveContentDir( const dirs = version.dir === null - ? config.content.map((c) => c.dir) - : config.versions?.find((v) => v.dir === version.dir)?.content.map( - (c) => c.dir, - ) ?? [] + ? getLatestContentRoots(config).map((root) => root.contentDir) + : getVersionContentRoots(config, version.dir).map( + (root) => root.contentDir, + ) return dirs.includes(remainder[0]) ? remainder[0] : null } diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index 8597838..9487a9d 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -103,6 +103,8 @@ export function PageProvider({ const cancelled = { current: false }; if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) { + setPage(null); + setErrorStatus(null); const specsUrl = route.version.dir ? `/api/specs?version=${encodeURIComponent(route.version.dir)}` : '/api/specs'; diff --git a/packages/chronicle/src/lib/route-resolver.ts b/packages/chronicle/src/lib/route-resolver.ts index a1e423c..59fa99c 100644 --- a/packages/chronicle/src/lib/route-resolver.ts +++ b/packages/chronicle/src/lib/route-resolver.ts @@ -1,4 +1,5 @@ import type { ChronicleConfig } from '@/types' +import { getLatestContentRoots, getVersionContentRoots } from './config' import { type VersionContext, resolveVersionFromUrl } from './version-source' export const RouteType = { @@ -22,9 +23,12 @@ function contentDirsFor( config: ChronicleConfig, version: VersionContext, ): string[] { - if (version.dir === null) return config.content.map((c) => c.dir) - const v = config.versions?.find((x) => x.dir === version.dir) - return v?.content.map((c) => c.dir) ?? [] + if (version.dir === null) { + return getLatestContentRoots(config).map((root) => root.contentDir) + } + return getVersionContentRoots(config, version.dir).map( + (root) => root.contentDir, + ) } function isLandingEnabled( diff --git a/packages/chronicle/src/pages/LandingPage.tsx b/packages/chronicle/src/pages/LandingPage.tsx index c84a43b..158840f 100644 --- a/packages/chronicle/src/pages/LandingPage.tsx +++ b/packages/chronicle/src/pages/LandingPage.tsx @@ -1,3 +1,4 @@ +import { Link as RouterLink } from 'react-router'; import { getLandingEntries } from '@/lib/config'; import { usePageContext } from '@/lib/page-context'; import styles from './LandingPage.module.css'; @@ -18,10 +19,10 @@ export function LandingPage() { ) : null}
From aeaacbe3d2b636a78f109d2de958a525fb83a1ba Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 18:02:57 +0530 Subject: [PATCH 47/55] ci: pin bun to 1.3.9 to keep lockfile reproducible Bun 1.3.13 rolled out mid-PR and resolves the lockfile slightly differently from 1.3.9, causing --frozen-lockfile to fail even without any dep change. Pin the setup-bun action to 1.3.9 so CI matches the lockfile that was committed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b3aef8..7cc9e45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.9 - name: Install dependencies run: bun install --frozen-lockfile From c223ba89475540e7759f2700966a0639c87611cf Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 18:05:07 +0530 Subject: [PATCH 48/55] chore: sync lockfile after rebase on main Main picked up fumadocs-core/mdx bumps (#39) while this branch was in flight; regenerate bun.lock against the current package.json so bun install --frozen-lockfile passes on CI's merge-ref checkout. Co-Authored-By: Claude Opus 4.7 (1M context) --- bun.lock | 224 +++++++++++++++++++++++-------------------------------- 1 file changed, 92 insertions(+), 132 deletions(-) diff --git a/bun.lock b/bun.lock index b872104..15feacd 100644 --- a/bun.lock +++ b/bun.lock @@ -29,8 +29,8 @@ "class-variance-authority": "^0.7.1", "codemirror": "^6.0.2", "commander": "^14.0.2", - "fumadocs-core": "16.6.15", - "fumadocs-mdx": "14.2.6", + "fumadocs-core": "16.8.1", + "fumadocs-mdx": "14.3.1", "glob": "^11.0.0", "gray-matter": "^4.0.3", "h3": "^2.0.1-rc.16", @@ -134,57 +134,57 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], @@ -194,66 +194,12 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.1.0", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg=="], - - "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.8.1", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.0", "tslib": "^2.8.1" } }, "sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA=="], - "@heroicons/react": ["@heroicons/react@2.2.0", "", { "peerDependencies": { "react": ">= 16 || ^19.0.0-rc" } }, "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ=="], "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], - "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], - - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], - - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], - - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], - - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], - - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], - - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="], @@ -272,24 +218,6 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], - "@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], - - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], - - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="], - - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="], - - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="], - - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="], - - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="], - - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="], - - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], "@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], @@ -480,8 +408,6 @@ "@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], - "@shikijs/transformers": ["@shikijs/transformers@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/types": "4.0.2" } }, "sha512-1+L0gf9v+SdDXs08vjaLb3mBFa8U7u37cwcBQIv/HCocLwX69Tt6LpUCjtB+UUTvQxI7BnjZKhN/wMjhHBcJGg=="], - "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -490,8 +416,6 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], - "@tanstack/match-sorter-utils": ["@tanstack/match-sorter-utils@8.19.4", "", { "dependencies": { "remove-accents": "0.5.0" } }, "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg=="], "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], @@ -616,14 +540,10 @@ "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], - "brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="], - "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -644,8 +564,6 @@ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], - "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], @@ -798,7 +716,7 @@ "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], - "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -838,9 +756,9 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "fumadocs-core": ["fumadocs-core@16.6.15", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.8.1", "@orama/orama": "^3.1.18", "@shikijs/rehype": "^4.0.2", "@shikijs/transformers": "^4.0.2", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "image-size": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "negotiator": "^1.0.0", "npm-to-yarn": "^3.0.1", "path-to-regexp": "^8.3.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.0.2", "tinyglobby": "^0.2.15", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "^0.46.0", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "^0.26.0 || ^0.27.0 || ^1.0.0", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-N6gbXicmaylWeaEFu9vpw25dZK29rPPjalrcIqDRgDklCFkxHn0fsagDMZiSjFBn4RfWRErL6mYmu24WSwosew=="], + "fumadocs-core": ["fumadocs-core@16.8.1", "", { "dependencies": { "@orama/orama": "^3.1.18", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "js-yaml": "^4.1.1", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.0.2", "tinyglobby": "^0.2.16", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "0.x.x", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "^0.26.0 || ^0.27.0 || ^1.0.0", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-NsyGZ075E7cS1RdHImuvglC8jX3v+/FRJ+Uf0r3vp+mAvc8FFnunYfq0oSLl++XdtZSsT1/27mZqBzAGL7nsDg=="], - "fumadocs-mdx": ["fumadocs-mdx@14.2.6", "", { "dependencies": { "@mdx-js/mdx": "^3.1.1", "@standard-schema/spec": "^1.1.0", "chokidar": "^5.0.0", "esbuild": "^0.27.2", "estree-util-value-to-estree": "^3.5.0", "js-yaml": "^4.1.1", "mdast-util-to-markdown": "^2.1.2", "picocolors": "^1.1.1", "picomatch": "^4.0.3", "remark-mdx": "^3.1.1", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3", "zod": "^4.3.5" }, "peerDependencies": { "@fumadocs/mdx-remote": "^1.4.0", "@types/react": "*", "fumadocs-core": "^15.0.0 || ^16.0.0", "next": "^15.3.0 || ^16.0.0", "react": "*", "vite": "6.x.x || 7.x.x" }, "optionalPeers": ["@fumadocs/mdx-remote", "@types/react", "next", "react", "vite"], "bin": { "fumadocs-mdx": "dist/bin.js" } }, "sha512-T8i5IllZ6OGaZ3/4Wwjl1zovvypSsr6Cco9ZACvoABLqpqTQ2TDfrW1nBt1o9YUKyfzkwDnjKdrnrq/nDexfcg=="], + "fumadocs-mdx": ["fumadocs-mdx@14.3.1", "", { "dependencies": { "@mdx-js/mdx": "^3.1.1", "@standard-schema/spec": "^1.1.0", "chokidar": "^5.0.0", "esbuild": "^0.28.0", "estree-util-value-to-estree": "^3.5.0", "js-yaml": "^4.1.1", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "picocolors": "^1.1.1", "picomatch": "^4.0.4", "tinyexec": "^1.1.1", "tinyglobby": "^0.2.16", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", "zod": "^4.3.6" }, "peerDependencies": { "@types/mdast": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "^15.0.0 || ^16.0.0", "mdast-util-directive": "*", "next": "^15.3.0 || ^16.0.0", "react": "^19.2.0", "vite": "6.x.x || 7.x.x || 8.x.x" }, "optionalPeers": ["@types/mdast", "@types/mdx", "@types/react", "mdast-util-directive", "next", "react", "vite"], "bin": { "fumadocs-mdx": "dist/bin.js" } }, "sha512-0u2eXvYrZtrJB14y6fDhP0hhxLgmH8JOmRv6IVHALt5MqR9JIJxV5LJYlho8g8CJXRE8w12rVNFZN0rtUVAqGw=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], @@ -874,8 +792,6 @@ "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="], - "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], @@ -1072,16 +988,10 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - - "next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], - "nf3": ["nf3@0.3.13", "", {}, "sha512-drDt0yl4d/yUhlpD0GzzqahSpA5eUNeIfFq0/aoZb0UlPY0ZwP4u1EfREVvZrYdEnJ3OU9Le9TrzbvWgEkkeKw=="], "nitro": ["nitro@3.0.260311-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.4", "db0": "^0.3.4", "env-runner": "^0.1.6", "h3": "^2.0.1-rc.16", "hookable": "^6.0.1", "nf3": "^0.3.11", "ocache": "^0.1.2", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.8", "srvx": "^0.11.9", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.6" }, "peerDependencies": { "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.59.0", "vite": "^7 || ^8 || >=8.0.0-0", "xml2js": "^0.6.2", "zephyr-agent": "^0.1.15" }, "optionalPeers": ["dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-0o0fJ9LUh4WKUqJNX012jyieUOtMCnadkNDWr0mHzdraoHpJP/1CGNefjRyZyMXSpoJfwoWdNEZu2iGf35TUvQ=="], - "npm-to-yarn": ["npm-to-yarn@3.0.1", "", {}, "sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A=="], - "ocache": ["ocache@0.1.4", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-e7geNdWjxSnvsSgvLuPvgKgu7ubM10ZmTPOgpr7mz2BXYtvjMKTiLhjFi/gWU8chkuP6hNkZBsa9LzOusyaqkQ=="], "ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="], @@ -1110,8 +1020,6 @@ "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], - "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1210,8 +1118,6 @@ "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1246,15 +1152,13 @@ "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], - "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], - "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], - "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], @@ -1334,6 +1238,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@antfu/install-pkg/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-alert-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -1430,18 +1336,18 @@ "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="], - "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "vite/rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="], + "vite/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], @@ -1450,6 +1356,58 @@ "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="], @@ -1483,5 +1441,7 @@ "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="], "vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="], + + "vite/tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], } } From 5fdec37bfc0bd17dd598e61df96826291433d363 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 21 Apr 2026 18:09:45 +0530 Subject: [PATCH 49/55] refactor: address minor coderabbit review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - init.test.ts: the "preserve existing index.mdx" case now actually writes an index.mdx and asserts its contents are untouched - head.tsx: og:image + twitter:image are absolute URLs when config.url is set (crawlers require absolute); relative fallback otherwise - api/search.ts: unknown ?tag= now returns HTTP 400 instead of silently folding into LATEST_CONTEXT — easier to spot client bugs - entry-server.tsx: the chronicle:ssr-rendered hook now fires on 302 redirects too so analytics/metrics don't under-count them - types/config.ts: dirNameSchema accepts only /^[a-zA-Z0-9][\w.-]*$/ so hidden (".git"), whitespace, and control-char names are rejected Tests cover the new accept/reject sets. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chronicle/src/cli/commands/init.test.ts | 9 +++++++-- packages/chronicle/src/lib/config.test.ts | 20 +++++++++++++++---- packages/chronicle/src/lib/head.tsx | 12 ++++++----- packages/chronicle/src/server/api/search.ts | 9 +++++++-- .../chronicle/src/server/entry-server.tsx | 2 ++ packages/chronicle/src/types/config.ts | 7 +++++-- 6 files changed, 44 insertions(+), 15 deletions(-) diff --git a/packages/chronicle/src/cli/commands/init.test.ts b/packages/chronicle/src/cli/commands/init.test.ts index 9998363..d2b9f01 100644 --- a/packages/chronicle/src/cli/commands/init.test.ts +++ b/packages/chronicle/src/cli/commands/init.test.ts @@ -45,9 +45,14 @@ describe('runInit', () => { test('does not overwrite an index.mdx already present in content/docs', () => { fs.mkdirSync(path.join(tmp, 'content/docs'), { recursive: true }) - fs.writeFileSync(path.join(tmp, 'content/docs/existing.mdx'), '# Keep') + const existing = '---\ntitle: Keep\n---\n# Keep\n' + fs.writeFileSync(path.join(tmp, 'content/docs/index.mdx'), existing) runInit(tmp) - expect(fs.existsSync(path.join(tmp, 'content/docs/index.mdx'))).toBe(false) + const contents = fs.readFileSync( + path.join(tmp, 'content/docs/index.mdx'), + 'utf-8', + ) + expect(contents).toBe(existing) }) test('appends missing entries to an existing .gitignore', () => { diff --git a/packages/chronicle/src/lib/config.test.ts b/packages/chronicle/src/lib/config.test.ts index 0b4aa64..8eed6d8 100644 --- a/packages/chronicle/src/lib/config.test.ts +++ b/packages/chronicle/src/lib/config.test.ts @@ -91,14 +91,26 @@ describe('chronicleConfigSchema', () => { ).toThrow() }) - test('rejects "." or ".." or path-shaped content dir names', () => { - for (const dir of ['.', '..', 'foo/bar', 'foo\\bar']) { + test('rejects invalid dir names (hidden, path-shaped, whitespace)', () => { + const bad = ['.', '..', 'foo/bar', 'foo\\bar', '.hidden', ' docs', 'docs ', 'do cs', ''] + for (const dir of bad) { expect(() => chronicleConfigSchema.parse({ site: { title: 'x' }, content: [{ dir, label: 'Docs' }], }), - ).toThrow(/simple folder name/) + ).toThrow() + } + }) + + test('accepts standard dir names (letters, digits, dash, underscore)', () => { + for (const dir of ['docs', 'dev-docs', 'v1', 'v1_beta', 'api2']) { + expect(() => + chronicleConfigSchema.parse({ + site: { title: 'x' }, + content: [{ dir, label: 'x' }], + }), + ).not.toThrow() } }) @@ -157,7 +169,7 @@ describe('chronicleConfigSchema', () => { }, ], }), - ).toThrow(/simple folder name/) + ).toThrow() }) test('rejects versions without latest', () => { diff --git a/packages/chronicle/src/lib/head.tsx b/packages/chronicle/src/lib/head.tsx index f96a2a4..ebdc967 100644 --- a/packages/chronicle/src/lib/head.tsx +++ b/packages/chronicle/src/lib/head.tsx @@ -13,9 +13,11 @@ export function Head({ title, description, config, jsonLd }: HeadProps) { const fullTitle = `${title} | ${config.site.title}`; const ogParams = new URLSearchParams({ title }); if (description) ogParams.set('description', description); - const canonical = config.url - ? `${config.url.replace(/\/$/, '')}${pathname}` - : null; + const siteUrl = config.url ? config.url.replace(/\/$/, '') : null; + const canonical = siteUrl ? `${siteUrl}${pathname}` : null; + const ogImage = siteUrl + ? `${siteUrl}/og?${ogParams.toString()}` + : `/og?${ogParams.toString()}`; return ( <> @@ -32,7 +34,7 @@ export function Head({ title, description, config, jsonLd }: HeadProps) { {canonical && } - + @@ -41,7 +43,7 @@ export function Head({ title, description, config, jsonLd }: HeadProps) { {description && ( )} - + )} diff --git a/packages/chronicle/src/server/api/search.ts b/packages/chronicle/src/server/api/search.ts index 57b1793..5120a51 100644 --- a/packages/chronicle/src/server/api/search.ts +++ b/packages/chronicle/src/server/api/search.ts @@ -1,5 +1,5 @@ import MiniSearch from 'minisearch'; -import { defineHandler } from 'nitro'; +import { defineHandler, HTTPError } from 'nitro'; import type { OpenAPIV3 } from 'openapi-types'; import { getSpecSlug } from '@/lib/api-routes'; import { getApiConfigsForVersion, loadConfig } from '@/lib/config'; @@ -108,7 +108,12 @@ function resolveCtx(tag: string | null): VersionContext { if (!tag) return LATEST_CONTEXT; const config = loadConfig(); const version = config.versions?.find(v => v.dir === tag); - if (!version) return LATEST_CONTEXT; + if (!version) { + throw new HTTPError({ + status: 400, + message: `Unknown version tag: ${tag}`, + }); + } return { dir: version.dir, urlPrefix: `/${version.dir}` }; } diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx index fe8bb68..54d98ef 100644 --- a/packages/chronicle/src/server/entry-server.tsx +++ b/packages/chronicle/src/server/entry-server.tsx @@ -25,6 +25,8 @@ export default { const route = resolveRoute(pathname, config); if (route.type === RouteType.Redirect) { + // biome-ignore lint/correctness/useHookAtTopLevel: useNitroApp is a Nitro DI accessor, not a React hook + useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, route.status, 0); return new Response(null, { status: route.status, headers: { Location: route.to }, diff --git a/packages/chronicle/src/types/config.ts b/packages/chronicle/src/types/config.ts index c0722c7..601bee9 100644 --- a/packages/chronicle/src/types/config.ts +++ b/packages/chronicle/src/types/config.ts @@ -79,11 +79,14 @@ const siteSchema = z.object({ description: z.string().optional(), }) +const DIR_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/ + const dirNameSchema = z .string() .min(1) - .refine((s) => s !== '.' && s !== '..' && !s.includes('/') && !s.includes('\\'), { - message: 'dir must be a simple folder name (not ".", "..", or a path)', + .refine((s) => DIR_NAME_PATTERN.test(s) && s !== '.' && s !== '..', { + message: + 'dir must start with a letter or digit and contain only letters, digits, ".", "_", or "-"', }) const contentEntrySchema = z.object({ From de0efc9b9815a6e135e93dd02d08104e9a923de4 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 22 Apr 2026 09:28:50 +0530 Subject: [PATCH 50/55] refactor: address copilot review findings - config.ts: RESERVED_ROUTE_SEGMENTS now includes every top-level server-owned route (api, apis, og, llms.txt, robots.txt, sitemap.xml), and the reserved-segment check uses superRefine so zod points at the offending path (content[i].dir, versions[vi].dir, or versions[vi].content[ci].dir) instead of a generic 'content' path - api/specs.ts: unknown ?version= returns HTTP 400 to match the search endpoint's behaviour - entry-client.tsx: api spec resolution + specsUrl now read route.version.dir so versioned URLs fetch their own specs even when embedded data is missing; embedded.version still wins for initialVersion when present - App.tsx: RouteType.Redirect renders react-router's so client nav (e.g. clicking the title to /) follows the same 302 target the server would emit Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/server/App.tsx | 6 ++- packages/chronicle/src/server/api/specs.ts | 9 +++- .../chronicle/src/server/entry-client.tsx | 11 ++-- packages/chronicle/src/types/config.ts | 53 +++++++++++++------ 4 files changed, 56 insertions(+), 23 deletions(-) diff --git a/packages/chronicle/src/server/App.tsx b/packages/chronicle/src/server/App.tsx index e8aca5a..16db22e 100644 --- a/packages/chronicle/src/server/App.tsx +++ b/packages/chronicle/src/server/App.tsx @@ -1,7 +1,7 @@ import '@raystack/apsara/normalize.css'; import '@raystack/apsara/style.css'; import { ThemeProvider } from '@raystack/apsara'; -import { useLocation } from 'react-router'; +import { Navigate, useLocation } from 'react-router'; import { Head } from '@/lib/head'; import { usePageContext } from '@/lib/page-context'; import { resolveRoute, RouteType } from '@/lib/route-resolver'; @@ -19,6 +19,10 @@ export function App() { const route = resolveRoute(pathname, config); const themeConfig = getThemeConfig(config.theme?.name); + if (route.type === RouteType.Redirect) { + return ; + } + const isApi = route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage; const apiSlug = route.type === RouteType.ApiPage ? route.slug : []; diff --git a/packages/chronicle/src/server/api/specs.ts b/packages/chronicle/src/server/api/specs.ts index 3af3b2a..00f6903 100644 --- a/packages/chronicle/src/server/api/specs.ts +++ b/packages/chronicle/src/server/api/specs.ts @@ -1,4 +1,4 @@ -import { defineHandler } from 'nitro'; +import { defineHandler, HTTPError } from 'nitro'; import { getApiConfigsForVersion, loadConfig } from '@/lib/config'; import { loadApiSpecs } from '@/lib/openapi'; @@ -7,6 +7,13 @@ export default defineHandler(async event => { const versionDir = versionParam === null || versionParam === '' ? null : versionParam; const config = loadConfig(); + if (versionDir && !config.versions?.some(v => v.dir === versionDir)) { + throw new HTTPError({ + status: 400, + message: `Unknown version: ${versionDir}`, + }); + } + const apiConfigs = getApiConfigsForVersion(config, versionDir); if (!apiConfigs.length) return []; diff --git a/packages/chronicle/src/server/entry-client.tsx b/packages/chronicle/src/server/entry-client.tsx index ac4fb99..f2da622 100644 --- a/packages/chronicle/src/server/entry-client.tsx +++ b/packages/chronicle/src/server/entry-client.tsx @@ -54,16 +54,19 @@ async function hydrate() { const config: ChronicleConfig = embedded?.config ?? defaultConfig; const tree: Root = embedded?.tree ?? { name: 'root', children: [] }; - const version: VersionContext = embedded?.version ?? LATEST_CONTEXT; const route = resolveRoute(window.location.pathname, config); + const routeVersion: VersionContext = + route.type === RouteType.Redirect ? LATEST_CONTEXT : route.version; + const version: VersionContext = embedded?.version ?? routeVersion; + const isApiRoute = route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage; const apiConfigs = isApiRoute - ? getApiConfigsForVersion(config, version.dir) + ? getApiConfigsForVersion(config, routeVersion.dir) : []; - const specsUrl = version.dir - ? `/api/specs?version=${encodeURIComponent(version.dir)}` + const specsUrl = routeVersion.dir + ? `/api/specs?version=${encodeURIComponent(routeVersion.dir)}` : '/api/specs'; const apiSpecs: ApiSpec[] = apiConfigs.length ? await fetch(specsUrl) diff --git a/packages/chronicle/src/types/config.ts b/packages/chronicle/src/types/config.ts index 601bee9..72e3916 100644 --- a/packages/chronicle/src/types/config.ts +++ b/packages/chronicle/src/types/config.ts @@ -127,7 +127,14 @@ const versionSchema = z.object({ const allUnique = (items: T[], key: (item: T) => string): boolean => uniqBy(items, key).length === items.length -const RESERVED_ROUTE_SEGMENTS = ['apis'] as const +const RESERVED_ROUTE_SEGMENTS = [ + 'api', + 'apis', + 'og', + 'llms.txt', + 'robots.txt', + 'sitemap.xml', +] as const export const chronicleConfigSchema = z .object({ @@ -181,22 +188,34 @@ export const chronicleConfigSchema = z path: ['versions'], }, ) - .refine( - (cfg) => { - const reserved = new Set(RESERVED_ROUTE_SEGMENTS) - const topLevel = cfg.content.map((c) => c.dir) - const versionDirs = cfg.versions?.map((v) => v.dir) ?? [] - const versionContent = - cfg.versions?.flatMap((v) => v.content.map((c) => c.dir)) ?? [] - return ![...topLevel, ...versionDirs, ...versionContent].some((d) => - reserved.has(d), - ) - }, - { - message: `dir must not be a reserved route segment: ${RESERVED_ROUTE_SEGMENTS.join(', ')}`, - path: ['content'], - }, - ) + .superRefine((cfg, ctx) => { + const reserved = new Set(RESERVED_ROUTE_SEGMENTS) + const message = `dir must not be a reserved route segment: ${RESERVED_ROUTE_SEGMENTS.join(', ')}` + + cfg.content.forEach((c, i) => { + if (reserved.has(c.dir)) { + ctx.addIssue({ code: 'custom', message, path: ['content', i, 'dir'] }) + } + }) + cfg.versions?.forEach((v, vi) => { + if (reserved.has(v.dir)) { + ctx.addIssue({ + code: 'custom', + message, + path: ['versions', vi, 'dir'], + }) + } + v.content.forEach((c, ci) => { + if (reserved.has(c.dir)) { + ctx.addIssue({ + code: 'custom', + message, + path: ['versions', vi, 'content', ci, 'dir'], + }) + } + }) + }) + }) export type ChronicleConfig = z.infer export type SiteConfig = z.infer From f55df4cca34433a5b70bcca2db787aa1ec6c7b14 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 22 Apr 2026 09:46:24 +0530 Subject: [PATCH 51/55] fix: fail fast on missing content roots + guard empty-folder match - scaffold.mirrorTree now rethrows ENOENT as 'Content directory not found: ' so a config that points at a non-existent dir errors out instead of silently building an empty mirror - filterPageTreeByContentDir requires the candidate folder to carry at least one URL before checking the prefix, otherwise an empty top-level folder's vacuous every() match would shadow the actual content folder Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/cli/utils/scaffold.ts | 5 ++++- packages/chronicle/src/lib/version-source.ts | 10 +++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/chronicle/src/cli/utils/scaffold.ts b/packages/chronicle/src/cli/utils/scaffold.ts index 72dd93b..931f68c 100644 --- a/packages/chronicle/src/cli/utils/scaffold.ts +++ b/packages/chronicle/src/cli/utils/scaffold.ts @@ -46,7 +46,10 @@ async function mirrorTree(source: string, dest: string): Promise { try { entries = await fs.readdir(source, { withFileTypes: true }); } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') { + throw new Error(`Content directory not found: ${source}`); + } throw error; } await fs.mkdir(dest, { recursive: true }); diff --git a/packages/chronicle/src/lib/version-source.ts b/packages/chronicle/src/lib/version-source.ts index a8198db..1de0685 100644 --- a/packages/chronicle/src/lib/version-source.ts +++ b/packages/chronicle/src/lib/version-source.ts @@ -91,11 +91,11 @@ export function filterPageTreeByContentDir( ): Root { if (contentDir === null) return tree const expectedPrefix = `${ctx.urlPrefix}/${contentDir}` - const match = tree.children.find( - (n): n is Folder => - n.type === 'folder' && - nodeUrls(n).every((u) => isUnderPrefix(u, expectedPrefix)), - ) + const match = tree.children.find((n): n is Folder => { + if (n.type !== 'folder') return false + const urls = nodeUrls(n) + return urls.length > 0 && urls.every((u) => isUnderPrefix(u, expectedPrefix)) + }) if (!match) return { ...tree, children: [] } return { ...tree, children: match.children } } From c8a6a14c93b1e590f4a5423918d4d37aa7a3b322 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 22 Apr 2026 09:46:34 +0530 Subject: [PATCH 52/55] fix: preserve defaults + always include latest in getAllVersions - loadConfig shallow-merges user config over defaultConfig so an omitted theme or search still falls back to the defaults that defaultConfig defines (previously dropped on any user config load) - getAllVersions always emits the latest entry (even when config.latest is absent and versions[] is empty), so consumers like sitemap.xml don't miss /apis when there are no explicit versions Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/lib/config.test.ts | 6 ++++-- packages/chronicle/src/lib/config.ts | 20 ++++++++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/chronicle/src/lib/config.test.ts b/packages/chronicle/src/lib/config.test.ts index 8eed6d8..13fdfba 100644 --- a/packages/chronicle/src/lib/config.test.ts +++ b/packages/chronicle/src/lib/config.test.ts @@ -351,9 +351,11 @@ describe('getAllVersions', () => { ]) }) - test('returns empty when no latest and no versions', () => { + test('always includes the latest entry, even without latest or versions', () => { const cfg = chronicleConfigSchema.parse(minimal) - expect(getAllVersions(cfg)).toEqual([]) + expect(getAllVersions(cfg)).toEqual([ + { dir: null, label: '', isLatest: true }, + ]) }) }) diff --git a/packages/chronicle/src/lib/config.ts b/packages/chronicle/src/lib/config.ts index ed5e8f6..c6dabd3 100644 --- a/packages/chronicle/src/lib/config.ts +++ b/packages/chronicle/src/lib/config.ts @@ -21,7 +21,13 @@ export function loadConfig(): ChronicleConfig { if (!raw) return defaultConfig - return chronicleConfigSchema.parse(parse(raw)) + const parsed = chronicleConfigSchema.parse(parse(raw)) + return { + ...defaultConfig, + ...parsed, + theme: { ...defaultConfig.theme, ...parsed.theme }, + search: { ...defaultConfig.search, ...parsed.search }, + } } export interface ContentRoot { @@ -101,11 +107,13 @@ export function getApiConfigsForVersion( } export function getAllVersions(config: ChronicleConfig): VersionDescriptor[] { - const result: VersionDescriptor[] = [] - - if (config.latest) { - result.push({ dir: null, label: config.latest.label, isLatest: true }) - } + const result: VersionDescriptor[] = [ + { + dir: null, + label: config.latest?.label ?? '', + isLatest: true, + }, + ] for (const v of config.versions ?? []) { result.push({ From c1df5b118689e3b63e4be8409e0d9a5b87369ea8 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 22 Apr 2026 09:46:40 +0530 Subject: [PATCH 53/55] fix: match .gitignore entries by line in chronicle init Substring match treats 'dist' as already present when the existing .gitignore only has 'distribution'. Split on newlines and compare trimmed lines exactly; added regression test covering that case. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/cli/commands/init.test.ts | 10 ++++++++++ packages/chronicle/src/cli/commands/init.ts | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/chronicle/src/cli/commands/init.test.ts b/packages/chronicle/src/cli/commands/init.test.ts index d2b9f01..4ca08e4 100644 --- a/packages/chronicle/src/cli/commands/init.test.ts +++ b/packages/chronicle/src/cli/commands/init.test.ts @@ -64,4 +64,14 @@ describe('runInit', () => { expect(contents).toContain('dist') expect(contents).toContain('.output') }) + + test('matches .gitignore entries by line, not substring', () => { + fs.writeFileSync(path.join(tmp, '.gitignore'), 'distribution\n') + runInit(tmp) + const contents = fs.readFileSync(path.join(tmp, '.gitignore'), 'utf-8') + // `distribution` must not satisfy the `dist` requirement + const lines = contents.split(/\r?\n/).map(l => l.trim()).filter(Boolean) + expect(lines).toContain('distribution') + expect(lines).toContain('dist') + }) }) diff --git a/packages/chronicle/src/cli/commands/init.ts b/packages/chronicle/src/cli/commands/init.ts index 77feb3c..39fe84b 100644 --- a/packages/chronicle/src/cli/commands/init.ts +++ b/packages/chronicle/src/cli/commands/init.ts @@ -62,7 +62,10 @@ export function runInit(projectDir: string): InitEvent[] { const gitignorePath = path.join(projectDir, '.gitignore'); if (fs.existsSync(gitignorePath)) { const existing = fs.readFileSync(gitignorePath, 'utf-8'); - const missing = GITIGNORE_ENTRIES.filter(e => !existing.includes(e)); + const existingLines = new Set( + existing.split(/\r?\n/).map(l => l.trim()).filter(Boolean), + ); + const missing = GITIGNORE_ENTRIES.filter(e => !existingLines.has(e)); if (missing.length > 0) { fs.appendFileSync(gitignorePath, `\n${missing.join('\n')}\n`); events.push({ type: 'updated', path: gitignorePath, detail: missing.join(', ') }); From b9c0bcbfb549f5e743bd09572ea709122150b18f Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 22 Apr 2026 09:46:51 +0530 Subject: [PATCH 54/55] feat: SSR-full page tree + client-side version+content filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching versions client-side (e.g. via VersionSwitcher) previously kept the SSR's version-scoped tree in PageContext, leaving the sidebar showing the wrong version's pages. Now: - entry-server.tsx emits the full unfiltered pageTree - DocsLayout runs filterPageTreeByVersion followed by filterPageTreeByContentDir per render, so nav across versions re-derives the sidebar from pathname + active context - entry-client.tsx resolves routeVersion via resolveVersionFromUrl (not LATEST_CONTEXT) so a direct client hit to a redirect target like /v1 hydrates with the correct version ctx Also: entry-server no longer catches loadApiSpecs failures — broken API specs now surface as server errors instead of rendering an empty API page with a 200. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/chronicle/src/pages/DocsLayout.tsx | 12 ++++++++++-- packages/chronicle/src/server/entry-client.tsx | 10 +++++++--- packages/chronicle/src/server/entry-server.tsx | 8 +++----- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/chronicle/src/pages/DocsLayout.tsx b/packages/chronicle/src/pages/DocsLayout.tsx index 1f8f5e9..26cb0b7 100644 --- a/packages/chronicle/src/pages/DocsLayout.tsx +++ b/packages/chronicle/src/pages/DocsLayout.tsx @@ -2,7 +2,10 @@ import type { ReactNode } from 'react'; import { useLocation } from 'react-router'; import { usePageContext } from '@/lib/page-context'; import { getActiveContentDir } from '@/lib/navigation'; -import { filterPageTreeByContentDir } from '@/lib/version-source'; +import { + filterPageTreeByContentDir, + filterPageTreeByVersion, +} from '@/lib/version-source'; import { getTheme } from '@/themes/registry'; interface DocsLayoutProps { @@ -16,7 +19,12 @@ export function DocsLayout({ children, hideSidebar }: DocsLayoutProps) { const { Layout, className } = getTheme(config.theme?.name); const activeContentDir = getActiveContentDir(pathname, config); - const scopedTree = filterPageTreeByContentDir(tree, version, activeContentDir); + const versionScoped = filterPageTreeByVersion(tree, version, config); + const scopedTree = filterPageTreeByContentDir( + versionScoped, + version, + activeContentDir, + ); return ( /v1/docs) where route.version isn't on the union. + const routeVersion: VersionContext = resolveVersionFromUrl( + window.location.pathname, + config, + ); const version: VersionContext = embedded?.version ?? routeVersion; const isApiRoute = diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx index 54d98ef..e5721d7 100644 --- a/packages/chronicle/src/server/entry-server.tsx +++ b/packages/chronicle/src/server/entry-server.tsx @@ -9,7 +9,7 @@ import { getApiConfigsForVersion, loadConfig } from '@/lib/config'; import { loadApiSpecs } from '@/lib/openapi'; import { PageProvider } from '@/lib/page-context'; import { resolveRoute, RouteType } from '@/lib/route-resolver'; -import { getPage, getPageTreeForVersion, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source'; +import { getPage, getPageTree, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source'; import { useNitroApp } from 'nitro/app'; import { App } from './App'; @@ -39,12 +39,10 @@ export default { const apiConfigs = isApiRoute ? getApiConfigsForVersion(config, route.version.dir) : []; - const apiSpecs = apiConfigs.length - ? await loadApiSpecs(apiConfigs).catch(() => []) - : []; + const apiSpecs = apiConfigs.length ? await loadApiSpecs(apiConfigs) : []; const [tree, page] = await Promise.all([ - getPageTreeForVersion(route.version), + getPageTree(), route.type === RouteType.DocsPage ? getPage(route.slug) : Promise.resolve(null), ]); From 96f6c8b240db347dfd9524f0d89ab5020b6b8a64 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 22 Apr 2026 09:46:57 +0530 Subject: [PATCH 55/55] docs: clarify versioned content path in configuration reference Make it explicit that versions[].content[].dir is rooted at versions///, not directly under versions//. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/content/docs/configuration.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 9eca5d7..c5d3dc5 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -24,7 +24,7 @@ my-docs-site/ └── dev/ ``` -Content dirs declared in `content:` are resolved under `content/` for the latest version; each `versions[].content[]` entry is resolved under `versions//`. +Content dirs declared in top-level `content:` are resolved under `content//` for the latest version; each `versions[].content[].dir` is resolved under `versions///`. ## Full example