@raystack/chronicle 0.12.3 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/cli/index.js +1763 -252
  2. package/package.json +8 -2
  3. package/src/cli/commands/build.ts +36 -7
  4. package/src/cli/commands/static-generate.ts +1071 -0
  5. package/src/cli/utils/mdx-error-report.ts +48 -0
  6. package/src/components/api/playground-dialog.tsx +65 -14
  7. package/src/components/mdx/code.tsx +8 -3
  8. package/src/components/mdx/image.tsx +15 -1
  9. package/src/components/mdx/index.tsx +10 -3
  10. package/src/components/ui/search.tsx +226 -64
  11. package/src/lib/data-urls.ts +23 -0
  12. package/src/lib/head.tsx +2 -1
  13. package/src/lib/mdx-component-names.ts +15 -0
  14. package/src/lib/mdx-error.test.ts +78 -0
  15. package/src/lib/mdx-error.ts +91 -0
  16. package/src/lib/openapi.ts +1 -1
  17. package/src/lib/page-context.tsx +29 -14
  18. package/src/lib/preload.ts +4 -2
  19. package/src/lib/remark-validate-mdx.test.ts +95 -0
  20. package/src/lib/remark-validate-mdx.ts +85 -0
  21. package/src/lib/route-resolver.ts +7 -0
  22. package/src/lib/static-mode.ts +3 -0
  23. package/src/pages/DocsPage.tsx +3 -2
  24. package/src/pages/NotFound.module.css +10 -0
  25. package/src/pages/RenderError.tsx +18 -0
  26. package/src/server/App.tsx +16 -12
  27. package/src/server/api/apis-proxy.ts +32 -7
  28. package/src/server/dev-error-page.ts +133 -0
  29. package/src/server/entry-server.tsx +6 -0
  30. package/src/server/entry-static.tsx +151 -0
  31. package/src/server/error.ts +13 -1
  32. package/src/server/plugins/telemetry.ts +3 -2
  33. package/src/server/vite-config.ts +22 -9
  34. package/src/themes/default/Page.module.css +2 -0
  35. package/src/themes/paper/Layout.module.css +0 -4
  36. package/src/themes/paper/Layout.tsx +0 -2
  37. package/src/themes/paper/Page.module.css +2 -0
@@ -0,0 +1,1071 @@
1
+ import fs from 'node:fs/promises';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import matter from 'gray-matter';
5
+ import chalk from 'chalk';
6
+ import type { OpenAPIV3 } from 'openapi-types';
7
+ import satori from 'satori';
8
+ import sharp from 'sharp';
9
+ import {
10
+ type ChronicleConfig,
11
+ SearchResultType,
12
+ } from '@/types';
13
+ import {
14
+ getAllVersions,
15
+ getApiConfigsForVersion,
16
+ getLatestContentRoots,
17
+ getVersionContentRoots,
18
+ } from '@/lib/config';
19
+ import { loadApiSpec, resolveDocument, type ApiSpec } from '@/lib/openapi';
20
+ import { buildApiRoutes, getSpecSlug } from '@/lib/api-routes';
21
+ import { buildLlmsTxt, type LlmsPage } from '@/lib/llms';
22
+ import { DEFAULT_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg } from '@/lib/image-utils';
23
+ import type { VersionContext } from '@/lib/version-source';
24
+ import type { Frontmatter, PageNavLink } from '@/types';
25
+
26
+ export interface StaticGenerateOptions {
27
+ projectRoot: string;
28
+ config: ChronicleConfig;
29
+ outputDir: string;
30
+ packageRoot: string;
31
+ }
32
+
33
+ // --- Filesystem-based content scanning (follows build-search-index.ts pattern) ---
34
+
35
+ interface ScannedPage {
36
+ slugs: string[];
37
+ url: string;
38
+ relativePath: string;
39
+ originalPath: string;
40
+ frontmatter: Frontmatter;
41
+ rawContent: string;
42
+ images: string[];
43
+ }
44
+
45
+ interface PageTreeNode {
46
+ type: 'page' | 'folder' | 'separator';
47
+ name: string;
48
+ url: string;
49
+ icon?: string;
50
+ $order?: number;
51
+ children?: PageTreeNode[];
52
+ index?: PageTreeNode;
53
+ }
54
+
55
+ interface PageTreeRoot {
56
+ name: string;
57
+ children: PageTreeNode[];
58
+ }
59
+
60
+ interface SearchDocument {
61
+ id: string;
62
+ url: string;
63
+ title: string;
64
+ headings: string;
65
+ body: string;
66
+ type: string;
67
+ section: string;
68
+ }
69
+
70
+ function extractHeadingsAndBody(markdown: string): { headings: string; body: string } {
71
+ const withoutFrontmatter = markdown.replace(/^---[\s\S]*?---/m, '');
72
+ const headings: string[] = [];
73
+ const lines: string[] = [];
74
+ for (const line of withoutFrontmatter.split('\n')) {
75
+ const headingMatch = line.match(/^#{1,6}\s+(.+)/);
76
+ if (headingMatch) {
77
+ headings.push(headingMatch[1]);
78
+ } else if (!line.startsWith('import ') && !line.startsWith('export ') && !line.startsWith('```')) {
79
+ const cleaned = line
80
+ .replace(/<[^>]+>/g, '')
81
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
82
+ .replace(/[*_~`]+/g, '')
83
+ .trim();
84
+ if (cleaned) lines.push(cleaned);
85
+ }
86
+ }
87
+ return { headings: headings.join('\n'), body: lines.join(' ') };
88
+ }
89
+
90
+ function extractImages(markdown: string): string[] {
91
+ const images: string[] = [];
92
+ const seen = new Set<string>();
93
+ function add(src: string) {
94
+ const clean = src.split('?')[0];
95
+ if (clean && !seen.has(clean)) {
96
+ seen.add(clean);
97
+ images.push(clean);
98
+ }
99
+ }
100
+ const mdRegex = /!\[[^\]]*\]\(([^)]+)\)/g;
101
+ let match: RegExpExecArray | null;
102
+ while ((match = mdRegex.exec(markdown)) !== null) add(match[1]);
103
+ const htmlRegex = /<img\b[^>]*\bsrc=["']([^"']+)["']/gi;
104
+ while ((match = htmlRegex.exec(markdown)) !== null) add(match[1]);
105
+ return images;
106
+ }
107
+
108
+ function titleFromSlug(slug: string): string {
109
+ return slug
110
+ .split('-')
111
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
112
+ .join(' ');
113
+ }
114
+
115
+ async function scanContentDir(
116
+ contentDir: string,
117
+ contentMirrorRoot: string,
118
+ prefix: string[] = [],
119
+ ): Promise<ScannedPage[]> {
120
+ const pages: ScannedPage[] = [];
121
+
122
+ async function scan(dir: string, slugPrefix: string[]) {
123
+ let entries: import('node:fs').Dirent[];
124
+ try {
125
+ entries = await fs.readdir(dir, { withFileTypes: true });
126
+ } catch {
127
+ return;
128
+ }
129
+
130
+ for (const entry of entries) {
131
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
132
+ const fullPath = path.join(dir, entry.name);
133
+
134
+ if (entry.isDirectory()) {
135
+ await scan(fullPath, [...slugPrefix, entry.name]);
136
+ continue;
137
+ }
138
+
139
+ if (!entry.name.endsWith('.mdx') && !entry.name.endsWith('.md')) continue;
140
+
141
+ const raw = await fs.readFile(fullPath, 'utf-8');
142
+ const { data: fm, content } = matter(raw);
143
+
144
+ if (fm.draft === true) continue;
145
+
146
+ const baseName = entry.name.replace(/\.(mdx|md)$/, '');
147
+ const isIndex = baseName === 'index' || baseName.toLowerCase() === 'readme';
148
+ const slugs = isIndex ? slugPrefix : [...slugPrefix, baseName];
149
+ const url = slugs.length === 0 ? '/' : `/${slugs.join('/')}`;
150
+
151
+ const originalRelative = path.relative(contentMirrorRoot, fullPath);
152
+ const normalizedRelative = isIndex && baseName.toLowerCase() === 'readme'
153
+ ? originalRelative.replace(/readme\.(mdx?)$/i, `index.$1`)
154
+ : originalRelative;
155
+
156
+ pages.push({
157
+ slugs,
158
+ url,
159
+ relativePath: normalizedRelative,
160
+ originalPath: originalRelative,
161
+ frontmatter: {
162
+ title: (fm.title as string) ?? titleFromSlug(slugs[slugs.length - 1] ?? 'Home'),
163
+ description: fm.description as string | undefined,
164
+ order: fm.order as number | undefined,
165
+ icon: fm.icon as string | undefined,
166
+ lastModified: fm.lastModified as string | undefined,
167
+ draft: fm.draft as boolean | undefined,
168
+ },
169
+ rawContent: content,
170
+ images: extractImages(content).map(img => {
171
+ if (img.startsWith('http')) return img;
172
+ if (img.startsWith('/')) return `/_content${img}`;
173
+ const dirRelative = path.dirname(normalizedRelative);
174
+ return `/_content/${path.join(dirRelative, img).replace(/\\/g, '/')}`;
175
+ }),
176
+ });
177
+ }
178
+ }
179
+
180
+ await scan(contentDir, prefix);
181
+
182
+ pages.sort((a, b) => {
183
+ const orderA = a.frontmatter.order ?? Number.MAX_SAFE_INTEGER;
184
+ const orderB = b.frontmatter.order ?? Number.MAX_SAFE_INTEGER;
185
+ if (orderA !== orderB) return orderA - orderB;
186
+ return a.url.localeCompare(b.url);
187
+ });
188
+
189
+ return pages;
190
+ }
191
+
192
+ async function scanAllContent(
193
+ projectRoot: string,
194
+ config: ChronicleConfig,
195
+ packageRoot: string,
196
+ ): Promise<ScannedPage[]> {
197
+ const contentMirror = path.resolve(packageRoot, '.content');
198
+ const pages: ScannedPage[] = [];
199
+
200
+ for (const root of getLatestContentRoots(config)) {
201
+ const contentDir = path.resolve(contentMirror, root.contentDir);
202
+ const scanned = await scanContentDir(contentDir, contentMirror, [root.contentDir]);
203
+ pages.push(...scanned);
204
+ }
205
+
206
+ for (const version of config.versions ?? []) {
207
+ for (const root of getVersionContentRoots(config, version.dir)) {
208
+ const contentDir = path.resolve(contentMirror, version.dir, root.contentDir);
209
+ const scanned = await scanContentDir(contentDir, contentMirror, [version.dir, root.contentDir]);
210
+ pages.push(...scanned);
211
+ }
212
+ }
213
+
214
+ return pages;
215
+ }
216
+
217
+ interface FolderMeta {
218
+ title?: string;
219
+ order?: number;
220
+ }
221
+
222
+ async function scanFolderMeta(
223
+ contentMirrorRoot: string,
224
+ config: ChronicleConfig,
225
+ ): Promise<Map<string, FolderMeta>> {
226
+ const metaMap = new Map<string, FolderMeta>();
227
+
228
+ async function scanDir(dir: string, slugPrefix: string[]) {
229
+ let entries: import('node:fs').Dirent[];
230
+ try {
231
+ entries = await fs.readdir(dir, { withFileTypes: true });
232
+ } catch {
233
+ return;
234
+ }
235
+ const metaPath = path.join(dir, 'meta.json');
236
+ try {
237
+ const raw = await fs.readFile(metaPath, 'utf-8');
238
+ const meta = JSON.parse(raw) as FolderMeta;
239
+ const folderPath = '/' + slugPrefix.join('/');
240
+ metaMap.set(folderPath, meta);
241
+ } catch {
242
+ // no meta.json
243
+ }
244
+ for (const entry of entries) {
245
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
246
+ await scanDir(path.join(dir, entry.name), [...slugPrefix, entry.name]);
247
+ }
248
+ }
249
+
250
+ for (const root of getLatestContentRoots(config)) {
251
+ const contentDir = path.resolve(contentMirrorRoot, root.contentDir);
252
+ await scanDir(contentDir, [root.contentDir]);
253
+ }
254
+ for (const version of config.versions ?? []) {
255
+ for (const root of getVersionContentRoots(config, version.dir)) {
256
+ const contentDir = path.resolve(contentMirrorRoot, version.dir, root.contentDir);
257
+ await scanDir(contentDir, [version.dir, root.contentDir]);
258
+ }
259
+ }
260
+
261
+ return metaMap;
262
+ }
263
+
264
+ function buildPageTree(pages: ScannedPage[], config: ChronicleConfig, folderMeta: Map<string, FolderMeta>): PageTreeRoot {
265
+ const tree: PageTreeRoot = { name: 'root', children: [] };
266
+ const folderMap = new Map<string, PageTreeNode>();
267
+
268
+ for (const page of pages) {
269
+ const segments = page.slugs;
270
+ if (segments.length === 0) continue;
271
+
272
+ let current = tree.children;
273
+ for (let i = 0; i < segments.length - 1; i++) {
274
+ const folderPath = '/' + segments.slice(0, i + 1).join('/');
275
+ let folder = folderMap.get(folderPath);
276
+ if (!folder) {
277
+ const meta = folderMeta.get(folderPath);
278
+ folder = {
279
+ type: 'folder',
280
+ name: meta?.title ?? titleFromSlug(segments[i]),
281
+ url: folderPath,
282
+ $order: meta?.order,
283
+ children: [],
284
+ };
285
+ folderMap.set(folderPath, folder);
286
+ current.push(folder);
287
+ }
288
+ current = folder.children!;
289
+ }
290
+
291
+ const pageNode: PageTreeNode = {
292
+ type: 'page',
293
+ name: page.frontmatter.title,
294
+ url: page.url,
295
+ icon: page.frontmatter.icon,
296
+ $order: page.frontmatter.order,
297
+ };
298
+
299
+ // Check if this is a folder index page
300
+ const folderPath = '/' + segments.slice(0, -1).join('/');
301
+ const parentFolder = folderMap.get(folderPath);
302
+ if (parentFolder && segments.length > 1) {
303
+ const isIndex = page.relativePath.match(/(index|readme)\.(mdx|md)$/i);
304
+ if (isIndex) {
305
+ parentFolder.index = pageNode;
306
+ parentFolder.name = page.frontmatter.title;
307
+ continue;
308
+ }
309
+ }
310
+
311
+ current.push(pageNode);
312
+ }
313
+
314
+ for (const root of getLatestContentRoots(config)) {
315
+ const rootFolder = folderMap.get(`/${root.contentDir}`);
316
+ if (rootFolder) {
317
+ rootFolder.name = root.contentLabel;
318
+ }
319
+ }
320
+
321
+ function sortChildren(children: PageTreeNode[]) {
322
+ children.sort((a, b) => {
323
+ const orderA = a.$order ?? Number.MAX_SAFE_INTEGER;
324
+ const orderB = b.$order ?? Number.MAX_SAFE_INTEGER;
325
+ if (orderA !== orderB) return orderA - orderB;
326
+ return a.name.localeCompare(b.name);
327
+ });
328
+ for (const child of children) {
329
+ if (child.children) sortChildren(child.children);
330
+ }
331
+ }
332
+ sortChildren(tree.children);
333
+
334
+ function stripOrder(node: PageTreeNode) {
335
+ delete node.$order;
336
+ if (node.children) node.children.forEach(stripOrder);
337
+ if (node.index) delete node.index.$order;
338
+ }
339
+ tree.children.forEach(stripOrder);
340
+
341
+ return tree;
342
+ }
343
+
344
+ function flattenTreeUrls(tree: PageTreeRoot): { url: string; title: string }[] {
345
+ const result: { url: string; title: string }[] = [];
346
+ function walk(nodes: PageTreeNode[]) {
347
+ for (const node of nodes) {
348
+ if (node.type === 'folder') {
349
+ if (node.index) result.push({ url: node.index.url, title: node.index.name });
350
+ if (node.children) walk(node.children);
351
+ } else if (node.type === 'page') {
352
+ result.push({ url: node.url, title: node.name });
353
+ }
354
+ }
355
+ }
356
+ walk(tree.children);
357
+ return result;
358
+ }
359
+
360
+ function computeNavigation(tree: PageTreeRoot): Map<string, { prev: PageNavLink | null; next: PageNavLink | null }> {
361
+ const navMap = new Map<string, { prev: PageNavLink | null; next: PageNavLink | null }>();
362
+ const ordered = flattenTreeUrls(tree);
363
+
364
+ for (let i = 0; i < ordered.length; i++) {
365
+ navMap.set(ordered[i].url, {
366
+ prev: i > 0
367
+ ? { url: ordered[i - 1].url, title: ordered[i - 1].title }
368
+ : null,
369
+ next: i < ordered.length - 1
370
+ ? { url: ordered[i + 1].url, title: ordered[i + 1].title }
371
+ : null,
372
+ });
373
+ }
374
+
375
+ return navMap;
376
+ }
377
+
378
+ async function loadSpecs(configs: import('@/types').ApiConfig[], projectRoot: string): Promise<ApiSpec[]> {
379
+ const results: ApiSpec[] = [];
380
+ for (const c of configs) {
381
+ try {
382
+ results.push(await loadApiSpec(c, projectRoot));
383
+ } catch {
384
+ try {
385
+ const specPath = path.resolve(projectRoot, c.spec);
386
+ const raw = await fs.readFile(specPath, 'utf-8');
387
+ const isYaml = specPath.endsWith('.yaml') || specPath.endsWith('.yml');
388
+ const rawDoc = isYaml ? (await import('yaml')).parse(raw) : JSON.parse(raw);
389
+ const doc = rawDoc.openapi?.startsWith('3.') ? resolveDocument(rawDoc) : rawDoc;
390
+ results.push({
391
+ name: c.name,
392
+ basePath: c.basePath,
393
+ server: { ...c.server, url: c.server.url },
394
+ auth: c.auth,
395
+ document: doc,
396
+ });
397
+ } catch {
398
+ console.log(chalk.yellow(` Warning: Skipping spec ${c.name}`));
399
+ }
400
+ }
401
+ }
402
+ return results;
403
+ }
404
+
405
+ // --- Generation steps ---
406
+
407
+ async function generatePageDataFiles(
408
+ pages: ScannedPage[],
409
+ navMap: Map<string, { prev: PageNavLink | null; next: PageNavLink | null }>,
410
+ outputDir: string,
411
+ ): Promise<void> {
412
+ const dataDir = path.join(outputDir, 'data', 'pages');
413
+ await fs.mkdir(dataDir, { recursive: true });
414
+
415
+ for (const page of pages) {
416
+ const slugKey = page.slugs.join(',') || 'index';
417
+ const nav = navMap.get(page.url) ?? { prev: null, next: null };
418
+
419
+ const data = {
420
+ frontmatter: page.frontmatter,
421
+ relativePath: page.relativePath,
422
+ originalPath: page.originalPath,
423
+ images: page.images,
424
+ prev: nav.prev,
425
+ next: nav.next,
426
+ };
427
+
428
+ const filePath = path.join(dataDir, `${slugKey}.json`);
429
+ await fs.writeFile(filePath, JSON.stringify(data));
430
+ }
431
+ }
432
+
433
+ async function generateSearchIndex(
434
+ pages: ScannedPage[],
435
+ config: ChronicleConfig,
436
+ outputDir: string,
437
+ projectRoot: string,
438
+ ): Promise<void> {
439
+ const docs: SearchDocument[] = [];
440
+ const contentEntries = config.content ?? [];
441
+
442
+ for (const page of pages) {
443
+ const { headings, body } = extractHeadingsAndBody(page.rawContent);
444
+ const dir = page.url.replace(/^\//, '').split('/')[0];
445
+ const entry = contentEntries.find(c => c.dir === dir);
446
+
447
+ docs.push({
448
+ id: page.url,
449
+ url: page.url,
450
+ title: page.frontmatter.title,
451
+ headings,
452
+ body: [page.frontmatter.description ?? '', body].join(' '),
453
+ type: SearchResultType.Page,
454
+ section: entry?.label ?? dir ?? '',
455
+ });
456
+ }
457
+
458
+ // Include API operations
459
+ const apiConfigs = config.api ?? [];
460
+ if (apiConfigs.length) {
461
+ try {
462
+ const specs = await loadSpecs(apiConfigs, projectRoot);
463
+ for (const spec of specs) {
464
+ const specSlug = getSpecSlug(spec);
465
+ const paths = spec.document.paths ?? {};
466
+ for (const [pathStr, pathItem] of Object.entries(paths)) {
467
+ if (!pathItem) continue;
468
+ for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
469
+ const op = pathItem[method] as OpenAPIV3.OperationObject | undefined;
470
+ if (!op) continue;
471
+ const opId = op.operationId ?? `${method}_${pathStr.replace(/[/{}\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`;
472
+ const url = `/apis/${specSlug}/${encodeURIComponent(opId)}`;
473
+ docs.push({
474
+ id: url,
475
+ url,
476
+ title: `${method.toUpperCase()} ${op.summary ?? opId}`,
477
+ headings: op.summary ?? opId,
478
+ body: [op.description ?? '', pathStr, method.toUpperCase()].join(' '),
479
+ type: SearchResultType.Api,
480
+ section: spec.name,
481
+ });
482
+ }
483
+ }
484
+ }
485
+ } catch {
486
+ console.log(chalk.yellow(' Warning: Failed to load API specs for search index'));
487
+ }
488
+ }
489
+
490
+ const dataDir = path.join(outputDir, 'data');
491
+ await fs.mkdir(dataDir, { recursive: true });
492
+ await fs.writeFile(path.join(dataDir, 'search.json'), JSON.stringify(docs));
493
+ }
494
+
495
+ async function generateApiSpecs(
496
+ config: ChronicleConfig,
497
+ outputDir: string,
498
+ projectRoot: string,
499
+ ): Promise<void> {
500
+ const specsDir = path.join(outputDir, 'data', 'specs');
501
+ await fs.mkdir(specsDir, { recursive: true });
502
+
503
+ // Generate latest specs
504
+ const latestConfigs = getApiConfigsForVersion(config, null);
505
+ if (latestConfigs.length) {
506
+ try {
507
+ const specs = await loadSpecs(latestConfigs, projectRoot);
508
+ await fs.writeFile(path.join(specsDir, 'latest.json'), JSON.stringify(specs));
509
+ } catch {
510
+ console.log(chalk.yellow(' Warning: Failed to load latest API specs'));
511
+ }
512
+ }
513
+
514
+ // Generate versioned specs
515
+ for (const version of config.versions ?? []) {
516
+ const versionConfigs = getApiConfigsForVersion(config, version.dir);
517
+ if (!versionConfigs.length) continue;
518
+ try {
519
+ const specs = await loadSpecs(versionConfigs, projectRoot);
520
+ await fs.writeFile(path.join(specsDir, `${version.dir}.json`), JSON.stringify(specs));
521
+ } catch {
522
+ console.log(chalk.yellow(` Warning: Failed to load API specs for version ${version.dir}`));
523
+ }
524
+ }
525
+ }
526
+
527
+ async function generateSitemap(
528
+ pages: ScannedPage[],
529
+ config: ChronicleConfig,
530
+ outputDir: string,
531
+ projectRoot: string,
532
+ ): Promise<void> {
533
+ if (!config.url) {
534
+ await fs.writeFile(
535
+ path.join(outputDir, 'sitemap.xml'),
536
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"/>',
537
+ );
538
+ return;
539
+ }
540
+
541
+ const baseUrl = config.url.replace(/\/$/, '');
542
+
543
+ const docPages = pages.map(page => {
544
+ let lastmod = '';
545
+ if (page.frontmatter.lastModified) {
546
+ const d = new Date(page.frontmatter.lastModified);
547
+ if (!Number.isNaN(d.getTime())) lastmod = `<lastmod>${d.toISOString()}</lastmod>`;
548
+ }
549
+ return `<url><loc>${baseUrl}/${page.slugs.join('/')}</loc>${lastmod}</url>`;
550
+ });
551
+
552
+ const apiPages: string[] = [];
553
+ for (const v of getAllVersions(config)) {
554
+ const versionDir = v.isLatest ? null : v.dir;
555
+ const apiConfigs = getApiConfigsForVersion(config, versionDir);
556
+ if (!apiConfigs.length) continue;
557
+ const prefix = versionDir ? `/${versionDir}` : '';
558
+ try {
559
+ const routes = buildApiRoutes(await loadSpecs(apiConfigs, projectRoot));
560
+ for (const route of routes) {
561
+ apiPages.push(
562
+ `<url><loc>${baseUrl}${prefix}/apis/${route.slug.join('/')}</loc></url>`,
563
+ );
564
+ }
565
+ } catch {
566
+ // skip if specs fail to load
567
+ }
568
+ }
569
+
570
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
571
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
572
+ <url><loc>${baseUrl}</loc></url>
573
+ ${[...docPages, ...apiPages].join('\n')}
574
+ </urlset>`;
575
+
576
+ await fs.writeFile(path.join(outputDir, 'sitemap.xml'), xml);
577
+ }
578
+
579
+ async function generateRobotsTxt(
580
+ config: ChronicleConfig,
581
+ outputDir: string,
582
+ ): Promise<void> {
583
+ const sitemap = config.url ? `\nSitemap: ${config.url}/sitemap.xml` : '';
584
+ const body = `User-agent: *\nAllow: /${sitemap}`;
585
+ await fs.writeFile(path.join(outputDir, 'robots.txt'), body);
586
+ }
587
+
588
+ async function generateLlmsTxt(
589
+ pages: ScannedPage[],
590
+ config: ChronicleConfig,
591
+ outputDir: string,
592
+ ): Promise<void> {
593
+ const latestCtx: VersionContext = { dir: null, urlPrefix: '' };
594
+
595
+ // Filter to only latest pages (not versioned)
596
+ const versionPrefixes = (config.versions ?? []).map(v => `/${v.dir}`);
597
+ const latestPages = pages.filter(
598
+ p => !versionPrefixes.some(pre => p.url === pre || p.url.startsWith(`${pre}/`)),
599
+ );
600
+
601
+ const llmsPages: LlmsPage[] = latestPages.map(p => ({
602
+ url: p.url,
603
+ title: p.frontmatter.title,
604
+ }));
605
+
606
+ const body = buildLlmsTxt(config, llmsPages, latestCtx);
607
+ await fs.writeFile(path.join(outputDir, 'llms.txt'), body);
608
+ }
609
+
610
+ async function generateOgImages(
611
+ pages: ScannedPage[],
612
+ config: ChronicleConfig,
613
+ outputDir: string,
614
+ packageRoot: string,
615
+ ): Promise<void> {
616
+ const ogDir = path.join(outputDir, 'og');
617
+ await fs.mkdir(ogDir, { recursive: true });
618
+
619
+ const { loadFont } = await import('@/server/routes/og-utils');
620
+ const fontData = await loadFont(packageRoot);
621
+
622
+ const siteName = config.site.title;
623
+
624
+ for (const page of pages) {
625
+ const title = page.frontmatter.title;
626
+ const description = page.frontmatter.description ?? '';
627
+ const slugKey = page.slugs.join(',') || 'index';
628
+
629
+ try {
630
+ // Using React.createElement since we can't use JSX in a CLI context
631
+ // without additional build config. Satori accepts React elements.
632
+ const { createElement: h } = await import('react');
633
+
634
+ const svg = await satori(
635
+ h('div', {
636
+ style: {
637
+ height: '100%',
638
+ width: '100%',
639
+ display: 'flex',
640
+ flexDirection: 'column',
641
+ justifyContent: 'center',
642
+ padding: '60px 80px',
643
+ backgroundColor: '#0a0a0a',
644
+ color: '#fafafa',
645
+ },
646
+ },
647
+ h('div', { style: { fontSize: 24, color: '#888', marginBottom: 16 } }, siteName),
648
+ h('div', {
649
+ style: {
650
+ fontSize: 56,
651
+ fontWeight: 700,
652
+ lineHeight: 1.2,
653
+ marginBottom: 24,
654
+ },
655
+ }, title),
656
+ description
657
+ ? h('div', { style: { fontSize: 24, color: '#999', lineHeight: 1.4 } }, description)
658
+ : null,
659
+ ),
660
+ {
661
+ width: 1200,
662
+ height: 630,
663
+ fonts: [
664
+ { name: 'Inter', data: fontData, weight: 400, style: 'normal' as const },
665
+ ],
666
+ },
667
+ );
668
+
669
+ const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
670
+ await fs.writeFile(path.join(ogDir, `${slugKey}.png`), pngBuffer);
671
+ } catch {
672
+ // Skip pages that fail OG generation
673
+ }
674
+ }
675
+ }
676
+
677
+ async function optimizeImages(
678
+ pages: ScannedPage[],
679
+ packageRoot: string,
680
+ outputDir: string,
681
+ ): Promise<void> {
682
+ const contentDir = path.resolve(packageRoot, '.content');
683
+ const seen = new Set<string>();
684
+ let optimized = 0;
685
+
686
+ for (const page of pages) {
687
+ for (const imgUrl of page.images) {
688
+ if (!isLocalImage(imgUrl) || seen.has(imgUrl)) continue;
689
+ seen.add(imgUrl);
690
+
691
+ const relativePath = imgUrl.replace(/^\/_content\//, '');
692
+ const srcPath = path.resolve(contentDir, relativePath);
693
+ if (!srcPath.startsWith(contentDir + path.sep) && srcPath !== contentDir) continue;
694
+
695
+ if (isSvg(imgUrl)) {
696
+ const destPath = path.join(outputDir, '_content', relativePath);
697
+ try {
698
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
699
+ await fs.copyFile(srcPath, destPath);
700
+ optimized++;
701
+ } catch {
702
+ // skip missing files
703
+ }
704
+ continue;
705
+ }
706
+
707
+ const webpRelative = relativePath.replace(/\.[^.]+$/, '.webp');
708
+ const destPath = path.join(outputDir, '_content', webpRelative);
709
+
710
+ try {
711
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
712
+ const source = await fs.readFile(srcPath);
713
+ const optimizedBuf = await sharp(source)
714
+ .resize({ width: DEFAULT_WIDTH, withoutEnlargement: true })
715
+ .webp({ quality: DEFAULT_QUALITY })
716
+ .toBuffer();
717
+ await fs.writeFile(destPath, optimizedBuf);
718
+
719
+ // Also copy original for fallback
720
+ const origDest = path.join(outputDir, '_content', relativePath);
721
+ await fs.mkdir(path.dirname(origDest), { recursive: true });
722
+ await fs.copyFile(srcPath, origDest);
723
+
724
+ optimized++;
725
+ } catch {
726
+ // skip unprocessable images
727
+ }
728
+ }
729
+ }
730
+
731
+ if (optimized > 0) {
732
+ console.log(chalk.gray(` Optimized ${optimized} images`));
733
+ }
734
+ }
735
+
736
+ async function copyPublicAssets(
737
+ projectRoot: string,
738
+ outputDir: string,
739
+ ): Promise<void> {
740
+ const publicDir = path.resolve(projectRoot, 'public');
741
+ if (!existsSync(publicDir)) return;
742
+
743
+ async function copyTree(src: string, dest: string) {
744
+ const entries = await fs.readdir(src, { withFileTypes: true });
745
+ await fs.mkdir(dest, { recursive: true });
746
+ for (const entry of entries) {
747
+ const srcPath = path.join(src, entry.name);
748
+ const destPath = path.join(dest, entry.name);
749
+ if (entry.isDirectory()) {
750
+ await copyTree(srcPath, destPath);
751
+ } else {
752
+ await fs.copyFile(srcPath, destPath);
753
+ }
754
+ }
755
+ }
756
+
757
+ await copyTree(publicDir, outputDir);
758
+ }
759
+
760
+ interface ViteManifestEntry {
761
+ file: string;
762
+ src?: string;
763
+ isEntry?: boolean;
764
+ css?: string[];
765
+ imports?: string[];
766
+ }
767
+
768
+ type ViteManifest = Record<string, ViteManifestEntry>;
769
+
770
+ function readViteManifest(outputDir: string): ViteManifest | null {
771
+ // Try multiple known locations for the Vite manifest
772
+ const candidates = [
773
+ path.join(outputDir, '.vite', 'manifest.json'),
774
+ path.join(outputDir, 'assets', '.vite', 'manifest.json'),
775
+ path.join(outputDir, '.vite/manifest.json'),
776
+ ];
777
+
778
+ for (const candidate of candidates) {
779
+ if (existsSync(candidate)) {
780
+ try {
781
+ const raw = readFileSync(candidate, 'utf-8');
782
+ return JSON.parse(raw) as ViteManifest;
783
+ } catch {
784
+ continue;
785
+ }
786
+ }
787
+ }
788
+
789
+ return null;
790
+ }
791
+
792
+ async function generateSpaIndex(
793
+ config: ChronicleConfig,
794
+ tree: PageTreeRoot,
795
+ outputDir: string,
796
+ ): Promise<void> {
797
+ const manifest = readViteManifest(outputDir);
798
+
799
+ let entryJs = '';
800
+ const cssFiles: string[] = [];
801
+ const preloadFiles: string[] = [];
802
+
803
+ if (manifest) {
804
+ // Find the entry point — look for the static entry or client entry
805
+ for (const [, entry] of Object.entries(manifest)) {
806
+ if (entry.isEntry) {
807
+ entryJs = `/${entry.file}`;
808
+ if (entry.css) {
809
+ cssFiles.push(...entry.css.map(f => `/${f}`));
810
+ }
811
+ if (entry.imports) {
812
+ for (const imp of entry.imports) {
813
+ const impEntry = manifest[imp];
814
+ if (impEntry) {
815
+ preloadFiles.push(`/${impEntry.file}`);
816
+ if (impEntry.css) {
817
+ cssFiles.push(...impEntry.css.map(f => `/${f}`));
818
+ }
819
+ }
820
+ }
821
+ }
822
+ break;
823
+ }
824
+ }
825
+ }
826
+
827
+ if (!entryJs) {
828
+ throw new Error('Could not determine Vite client entry from manifest — static index generation aborted');
829
+ }
830
+
831
+ const latestCtx: VersionContext = { dir: null, urlPrefix: '' };
832
+ const pageData = {
833
+ config,
834
+ tree,
835
+ version: latestCtx,
836
+ };
837
+ const safeJson = JSON.stringify(pageData).replace(/</g, '\\u003c');
838
+
839
+ const cssLinks = [...new Set(cssFiles)]
840
+ .map(f => ` <link rel="stylesheet" href="${f}">`)
841
+ .join('\n');
842
+
843
+ const preloadLinks = [...new Set(preloadFiles)]
844
+ .map(f => ` <link rel="modulepreload" href="${f}">`)
845
+ .join('\n');
846
+
847
+ const html = `<!DOCTYPE html>
848
+ <html lang="en">
849
+ <head>
850
+ <meta charset="UTF-8">
851
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
852
+ <link rel="icon" href="/favicon.ico">
853
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
854
+ ${cssLinks}
855
+ ${preloadLinks}
856
+ <script type="module" src="${entryJs}"></script>
857
+ <script>
858
+ window.__STATIC_MODE__ = true;
859
+ window.__PAGE_DATA__ = ${safeJson};
860
+ </script>
861
+ </head>
862
+ <body>
863
+ <div id="root"></div>
864
+ </body>
865
+ </html>`;
866
+
867
+ await fs.writeFile(path.join(outputDir, 'index.html'), html);
868
+ }
869
+
870
+ async function generateMarkdownFiles(
871
+ pages: ScannedPage[],
872
+ apiSpecs: ApiSpec[],
873
+ outputDir: string,
874
+ ): Promise<void> {
875
+ for (const page of pages) {
876
+ const mdPath = page.url === '/' ? '/index.md' : `${page.url}.md`;
877
+ const outPath = path.join(outputDir, mdPath);
878
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
879
+ await fs.writeFile(outPath, page.rawContent);
880
+ }
881
+
882
+ if (!apiSpecs.length) return;
883
+ const { flattenSchema, generateExampleJson } = await import('@/lib/schema');
884
+ const { generateCurl } = await import('@/lib/snippet-generators');
885
+
886
+ for (const spec of apiSpecs) {
887
+ const specSlug = getSpecSlug(spec);
888
+ const paths = spec.document.paths ?? {};
889
+ for (const [pathStr, pathItem] of Object.entries(paths)) {
890
+ if (!pathItem) continue;
891
+ for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
892
+ const op = pathItem[method] as import('openapi-types').OpenAPIV3.OperationObject | undefined;
893
+ if (!op) continue;
894
+ const opId = op.operationId ?? `${method}_${pathStr.replace(/[/{}\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`;
895
+ const mdPath = `/apis/${specSlug}/${encodeURIComponent(opId)}.md`;
896
+ const outPath = path.join(outputDir, mdPath);
897
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
898
+ const md = buildApiMd(method.toUpperCase(), pathStr, op, spec.server.url, spec.auth, flattenSchema, generateExampleJson, generateCurl);
899
+ await fs.writeFile(outPath, md);
900
+ }
901
+ }
902
+ }
903
+ }
904
+
905
+ function buildApiMd(
906
+ method: string,
907
+ apiPath: string,
908
+ operation: import('openapi-types').OpenAPIV3.OperationObject,
909
+ serverUrl: string,
910
+ auth: { type: string; header: string; placeholder?: string } | undefined,
911
+ flattenSchema: (s: any) => any[],
912
+ generateExampleJson: (s: any) => any,
913
+ generateCurl: (opts: any) => string,
914
+ ): string {
915
+ const lines: string[] = [];
916
+ const params = (operation.parameters ?? []) as import('openapi-types').OpenAPIV3.ParameterObject[];
917
+
918
+ lines.push(`# ${operation.summary ?? `${method} ${apiPath}`}`);
919
+ lines.push('');
920
+ if (operation.description) {
921
+ lines.push(operation.description);
922
+ lines.push('');
923
+ }
924
+ lines.push(`\`${method}\` \`${apiPath}\``);
925
+ lines.push('');
926
+
927
+ const headerParams = params.filter(p => p.in === 'header');
928
+ const pathParams = params.filter(p => p.in === 'path');
929
+ const queryParams = params.filter(p => p.in === 'query');
930
+
931
+ if (auth || headerParams.length > 0) {
932
+ lines.push('## Authorization');
933
+ lines.push('');
934
+ lines.push('| Header | Type | Required | Description |');
935
+ lines.push('| --- | --- | --- | --- |');
936
+ if (auth) lines.push(`| \`${auth.header}\` | string | Yes | ${auth.placeholder ?? 'API key'} |`);
937
+ for (const p of headerParams) {
938
+ const schema = (p.schema ?? {}) as any;
939
+ lines.push(`| \`${p.name}\` | ${schema.type ?? 'string'} | ${p.required ? 'Yes' : 'No'} | ${p.description ?? ''} |`);
940
+ }
941
+ lines.push('');
942
+ }
943
+
944
+ if (pathParams.length > 0) {
945
+ lines.push('## Path Parameters');
946
+ lines.push('');
947
+ lines.push('| Parameter | Type | Required | Description |');
948
+ lines.push('| --- | --- | --- | --- |');
949
+ for (const p of pathParams) {
950
+ const schema = (p.schema ?? {}) as any;
951
+ lines.push(`| \`${p.name}\` | ${schema.type ?? 'string'} | ${p.required ? 'Yes' : 'No'} | ${p.description ?? ''} |`);
952
+ }
953
+ lines.push('');
954
+ }
955
+
956
+ if (queryParams.length > 0) {
957
+ lines.push('## Query Parameters');
958
+ lines.push('');
959
+ lines.push('| Parameter | Type | Required | Description |');
960
+ lines.push('| --- | --- | --- | --- |');
961
+ for (const p of queryParams) {
962
+ const schema = (p.schema ?? {}) as any;
963
+ lines.push(`| \`${p.name}\` | ${schema.type ?? 'string'} | ${p.required ? 'Yes' : 'No'} | ${p.description ?? ''} |`);
964
+ }
965
+ lines.push('');
966
+ }
967
+
968
+ const requestBody = operation.requestBody as import('openapi-types').OpenAPIV3.RequestBodyObject | undefined;
969
+ if (requestBody?.content) {
970
+ const contentType = Object.keys(requestBody.content)[0];
971
+ const schema = contentType ? requestBody.content[contentType]?.schema : undefined;
972
+ if (schema) {
973
+ lines.push('## Request Body');
974
+ lines.push('');
975
+ lines.push(`Content-Type: \`${contentType}\``);
976
+ lines.push('');
977
+ const example = generateExampleJson(schema);
978
+ lines.push('```json');
979
+ lines.push(JSON.stringify(example, null, 2));
980
+ lines.push('```');
981
+ lines.push('');
982
+ }
983
+ }
984
+
985
+ const responses = operation.responses as Record<string, import('openapi-types').OpenAPIV3.ResponseObject> | undefined;
986
+ if (responses) {
987
+ lines.push('## Responses');
988
+ lines.push('');
989
+ for (const [status, resp] of Object.entries(responses)) {
990
+ lines.push(`### ${status}${resp.description ? ` — ${resp.description}` : ''}`);
991
+ lines.push('');
992
+ const content = resp.content ?? {};
993
+ const contentType = Object.keys(content)[0];
994
+ const schema = contentType ? content[contentType]?.schema : undefined;
995
+ if (schema) {
996
+ const example = generateExampleJson(schema);
997
+ lines.push('```json');
998
+ lines.push(JSON.stringify(example, null, 2));
999
+ lines.push('```');
1000
+ lines.push('');
1001
+ }
1002
+ }
1003
+ }
1004
+
1005
+ const headers: Record<string, string> = {};
1006
+ if (auth) headers[auth.header] = auth.placeholder ?? 'YOUR_API_KEY';
1007
+ lines.push('## cURL');
1008
+ lines.push('');
1009
+ lines.push('```bash');
1010
+ lines.push(generateCurl({ method, url: serverUrl + apiPath, headers }));
1011
+ lines.push('```');
1012
+
1013
+ return lines.join('\n');
1014
+ }
1015
+
1016
+ // --- Main export ---
1017
+
1018
+ export async function generateStaticSite(options: StaticGenerateOptions): Promise<void> {
1019
+ const { projectRoot, config, outputDir, packageRoot } = options;
1020
+
1021
+ console.log(chalk.cyan('\nGenerating static site...'));
1022
+
1023
+ // Scan all content from filesystem
1024
+ console.log(chalk.gray(' Scanning content...'));
1025
+ const pages = await scanAllContent(projectRoot, config, packageRoot);
1026
+ console.log(chalk.gray(` Found ${pages.length} pages`));
1027
+
1028
+ const contentMirror = path.resolve(packageRoot, '.content');
1029
+ const folderMeta = await scanFolderMeta(contentMirror, config);
1030
+ const tree = buildPageTree(pages, config, folderMeta);
1031
+ const navMap = computeNavigation(tree);
1032
+
1033
+ // Generate all static assets
1034
+ console.log(chalk.gray(' Generating page data files...'));
1035
+ await generatePageDataFiles(pages, navMap, outputDir);
1036
+
1037
+ console.log(chalk.gray(' Generating search index...'));
1038
+ await generateSearchIndex(pages, config, outputDir, projectRoot);
1039
+
1040
+ console.log(chalk.gray(' Generating API specs...'));
1041
+ await generateApiSpecs(config, outputDir, projectRoot);
1042
+
1043
+ const latestApiConfigs = config.api ?? [];
1044
+ const apiSpecsForMd = latestApiConfigs.length ? await loadSpecs(latestApiConfigs, projectRoot) : [];
1045
+
1046
+ console.log(chalk.gray(' Generating markdown files...'));
1047
+ await generateMarkdownFiles(pages, apiSpecsForMd, outputDir);
1048
+
1049
+ console.log(chalk.gray(' Generating sitemap.xml...'));
1050
+ await generateSitemap(pages, config, outputDir, projectRoot);
1051
+
1052
+ console.log(chalk.gray(' Generating robots.txt...'));
1053
+ await generateRobotsTxt(config, outputDir);
1054
+
1055
+ console.log(chalk.gray(' Generating llms.txt...'));
1056
+ await generateLlmsTxt(pages, config, outputDir);
1057
+
1058
+ console.log(chalk.gray(' Generating OG images...'));
1059
+ await generateOgImages(pages, config, outputDir, packageRoot);
1060
+
1061
+ console.log(chalk.gray(' Optimizing images...'));
1062
+ await optimizeImages(pages, packageRoot, outputDir);
1063
+
1064
+ console.log(chalk.gray(' Copying public assets...'));
1065
+ await copyPublicAssets(projectRoot, outputDir);
1066
+
1067
+ console.log(chalk.gray(' Generating SPA index.html...'));
1068
+ await generateSpaIndex(config, tree, outputDir);
1069
+
1070
+ console.log(chalk.green(` Static site generated: ${pages.length} pages`));
1071
+ }