diff --git a/bun.lock b/bun.lock index c84a7f2c..6ad64e09 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,7 @@ "http-status-codes": "^2.3.0", "lodash-es": "^4.17.23", "mermaid": "^11.13.0", + "minisearch": "^7.2.0", "nitro": "3.0.260311-beta", "openapi-types": "^12.1.3", "react": "^19.0.0", @@ -74,6 +75,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.13", + "@playwright/test": "^1.60.0", "@raystack/tools-config": "0.56.0", "@types/hast": "^3.0.4", "@types/lodash-es": "^4.17.12", @@ -325,6 +327,8 @@ "@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="], + "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], + "@radix-ui/react-icons": ["@radix-ui/react-icons@1.3.2", "", { "peerDependencies": { "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g=="], "@raystack/apsara": ["@raystack/apsara@1.0.0-rc.7", "", { "dependencies": { "@base-ui/react": "^1.4.1", "@base-ui/utils": "^0.2.6", "@radix-ui/react-icons": "^1.3.2", "@tanstack/match-sorter-utils": "^8.8.4", "@tanstack/react-table": "^8.9.2", "@tanstack/react-virtual": "^3.13.13", "@tanstack/table-core": "^8.9.2", "class-variance-authority": "^0.7.1", "color": "^5.0.0", "dayjs": "^1.11.11", "prism-react-renderer": "^2.4.1", "react-day-picker": "^9.6.7" }, "peerDependencies": { "@types/react": "^19", "react": "^19", "react-dom": "^19" }, "optionalPeers": ["@types/react"] }, "sha512-Rtg2BaehnAh1ypDmA5PEEkx5wNDnwZHaxoe+gBA7dkZB5YYaSiIdL1MPaGA7D2dahVl8aZ82vklfm0c9+xyRqw=="], @@ -963,6 +967,8 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], + "mlly": ["mlly@1.8.1", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -1009,6 +1015,10 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + + "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], @@ -1249,6 +1259,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="], "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=="], diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json index 4e79bde7..69d5f522 100644 --- a/packages/chronicle/package.json +++ b/packages/chronicle/package.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.13", + "@playwright/test": "^1.60.0", "@raystack/tools-config": "0.56.0", "@types/hast": "^3.0.4", "@types/lodash-es": "^4.17.12", @@ -68,6 +69,7 @@ "http-status-codes": "^2.3.0", "lodash-es": "^4.17.23", "mermaid": "^11.13.0", + "minisearch": "^7.2.0", "nitro": "3.0.260311-beta", "openapi-types": "^12.1.3", "react": "^19.0.0", diff --git a/packages/chronicle/playwright.config.ts b/packages/chronicle/playwright.config.ts new file mode 100644 index 00000000..cfdf4431 --- /dev/null +++ b/packages/chronicle/playwright.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.e2e.ts', + timeout: 30000, + retries: 0, + use: { + headless: true, + baseURL: 'http://localhost:4173', + }, +}); diff --git a/packages/chronicle/src/cli/commands/build.ts b/packages/chronicle/src/cli/commands/build.ts index 3ac71727..a3f42d42 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'; @@ -9,7 +10,7 @@ export const buildCommand = new Command('build') .option('--config ', 'Path to chronicle.yaml') .option( '--preset ', - 'Deploy preset (vercel, cloudflare, node-server)' + 'Deploy preset (vercel, cloudflare, node-server, static)' ) .action(async options => { const { config, projectRoot, configPath, preset } = await loadCLIConfig(options.config, { @@ -19,8 +20,8 @@ export const buildCommand = new Command('build') console.log(chalk.cyan('Building for production...')); - const { createBuilder } = await import('vite'); - const { createViteConfig } = await import('@/server/vite-config'); + const { createBuilder, build } = await import('vite'); + const { createViteConfig, isStaticPreset } = await import('@/server/vite-config'); const viteConfig = await createViteConfig({ packageRoot: PACKAGE_ROOT, @@ -29,9 +30,28 @@ export const buildCommand = new Command('build') preset }); - const builder = await createBuilder({ ...viteConfig, builder: {} }); - await builder.buildApp(); + if (isStaticPreset(preset)) { + await build(viteConfig); + } else { + const builder = await createBuilder({ ...viteConfig, builder: {} }); + await builder.buildApp(); + } - console.log(chalk.green('Build complete')); - console.log(chalk.cyan('Run `chronicle start` to start the server')); + if (isStaticPreset(preset)) { + const { generateStaticSite } = await import('@/cli/commands/static-generate'); + const outputDir = path.resolve(projectRoot, '.output/public'); + + await generateStaticSite({ + projectRoot, + config, + outputDir, + packageRoot: PACKAGE_ROOT, + }); + + console.log(chalk.green('Static build complete')); + console.log(chalk.cyan(`Output: ${outputDir}`)); + } else { + console.log(chalk.green('Build complete')); + console.log(chalk.cyan('Run `chronicle start` to start the server')); + } }); diff --git a/packages/chronicle/src/cli/commands/static-generate.ts b/packages/chronicle/src/cli/commands/static-generate.ts new file mode 100644 index 00000000..7c916d4f --- /dev/null +++ b/packages/chronicle/src/cli/commands/static-generate.ts @@ -0,0 +1,1079 @@ +import fs from 'node:fs/promises'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import matter from 'gray-matter'; +import chalk from 'chalk'; +import type { OpenAPIV3 } from 'openapi-types'; +import satori from 'satori'; +import sharp from 'sharp'; +import { + type ChronicleConfig, + SearchResultType, +} from '@/types'; +import { + getAllVersions, + getApiConfigsForVersion, + getLatestContentRoots, + getVersionContentRoots, +} from '@/lib/config'; +import { loadApiSpec, resolveDocument, type ApiSpec } from '@/lib/openapi'; +import { buildApiRoutes, getSpecSlug } from '@/lib/api-routes'; +import { buildLlmsTxt, type LlmsPage } from '@/lib/llms'; +import { DEFAULT_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg } from '@/lib/image-utils'; +import type { VersionContext } from '@/lib/version-source'; +import type { Frontmatter, PageNavLink } from '@/types'; + +export interface StaticGenerateOptions { + projectRoot: string; + config: ChronicleConfig; + outputDir: string; + packageRoot: string; +} + +// --- Filesystem-based content scanning (follows build-search-index.ts pattern) --- + +interface ScannedPage { + slugs: string[]; + url: string; + relativePath: string; + originalPath: string; + frontmatter: Frontmatter; + rawContent: string; + images: string[]; +} + +interface PageTreeNode { + type: 'page' | 'folder' | 'separator'; + name: string; + url: string; + icon?: string; + $order?: number; + children?: PageTreeNode[]; + index?: PageTreeNode; +} + +interface PageTreeRoot { + name: string; + children: PageTreeNode[]; +} + +interface SearchDocument { + id: string; + url: string; + title: string; + headings: string; + body: string; + type: string; + section: string; +} + +function extractHeadingsAndBody(markdown: string): { headings: string; body: string } { + const withoutFrontmatter = markdown.replace(/^---[\s\S]*?---/m, ''); + const headings: string[] = []; + const lines: string[] = []; + for (const line of withoutFrontmatter.split('\n')) { + const headingMatch = line.match(/^#{1,6}\s+(.+)/); + if (headingMatch) { + headings.push(headingMatch[1]); + } else if (!line.startsWith('import ') && !line.startsWith('export ') && !line.startsWith('```')) { + const cleaned = line + .replace(/<[^>]+>/g, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[*_~`]+/g, '') + .trim(); + if (cleaned) lines.push(cleaned); + } + } + return { headings: headings.join('\n'), body: lines.join(' ') }; +} + +function extractImages(markdown: string): string[] { + const images: string[] = []; + const seen = new Set(); + function add(src: string) { + const clean = src.split('?')[0]; + if (clean && !seen.has(clean)) { + seen.add(clean); + images.push(clean); + } + } + const mdRegex = /!\[[^\]]*\]\(([^)]+)\)/g; + let match: RegExpExecArray | null; + while ((match = mdRegex.exec(markdown)) !== null) add(match[1]); + const htmlRegex = /]*\bsrc=["']([^"']+)["']/gi; + while ((match = htmlRegex.exec(markdown)) !== null) add(match[1]); + return images; +} + +function titleFromSlug(slug: string): string { + return slug + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + +async function scanContentDir( + contentDir: string, + contentMirrorRoot: string, + prefix: string[] = [], +): Promise { + const pages: ScannedPage[] = []; + + async function scan(dir: string, slugPrefix: string[]) { + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + await scan(fullPath, [...slugPrefix, entry.name]); + continue; + } + + if (!entry.name.endsWith('.mdx') && !entry.name.endsWith('.md')) continue; + + const raw = await fs.readFile(fullPath, 'utf-8'); + const { data: fm, content } = matter(raw); + + if (fm.draft === true) continue; + + const baseName = entry.name.replace(/\.(mdx|md)$/, ''); + const isIndex = baseName === 'index' || baseName.toLowerCase() === 'readme'; + const slugs = isIndex ? slugPrefix : [...slugPrefix, baseName]; + const url = slugs.length === 0 ? '/' : `/${slugs.join('/')}`; + + const originalRelative = path.relative(contentMirrorRoot, fullPath); + const normalizedRelative = isIndex && baseName.toLowerCase() === 'readme' + ? originalRelative.replace(/readme\.(mdx?)$/i, `index.$1`) + : originalRelative; + + pages.push({ + slugs, + url, + relativePath: normalizedRelative, + originalPath: originalRelative, + frontmatter: { + title: (fm.title as string) ?? titleFromSlug(slugs[slugs.length - 1] ?? 'Home'), + description: fm.description as string | undefined, + order: fm.order as number | undefined, + icon: fm.icon as string | undefined, + lastModified: fm.lastModified as string | undefined, + draft: fm.draft as boolean | undefined, + }, + rawContent: content, + images: extractImages(content).map(img => { + if (img.startsWith('http')) return img; + if (img.startsWith('/')) return `/_content${img}`; + const dirRelative = path.dirname(normalizedRelative); + return `/_content/${path.join(dirRelative, img).replace(/\\/g, '/')}`; + }), + }); + } + } + + await scan(contentDir, prefix); + + pages.sort((a, b) => { + const orderA = a.frontmatter.order ?? Number.MAX_SAFE_INTEGER; + const orderB = b.frontmatter.order ?? Number.MAX_SAFE_INTEGER; + if (orderA !== orderB) return orderA - orderB; + return a.url.localeCompare(b.url); + }); + + return pages; +} + +async function scanAllContent( + projectRoot: string, + config: ChronicleConfig, + packageRoot: string, +): Promise { + const contentMirror = path.resolve(packageRoot, '.content'); + const pages: ScannedPage[] = []; + + for (const root of getLatestContentRoots(config)) { + const contentDir = path.resolve(contentMirror, root.contentDir); + const scanned = await scanContentDir(contentDir, contentMirror, [root.contentDir]); + pages.push(...scanned); + } + + for (const version of config.versions ?? []) { + for (const root of getVersionContentRoots(config, version.dir)) { + const contentDir = path.resolve(contentMirror, version.dir, root.contentDir); + const scanned = await scanContentDir(contentDir, contentMirror, [version.dir, root.contentDir]); + pages.push(...scanned); + } + } + + return pages; +} + +interface FolderMeta { + title?: string; + order?: number; +} + +async function scanFolderMeta( + contentMirrorRoot: string, + config: ChronicleConfig, +): Promise> { + const metaMap = new Map(); + + async function scanDir(dir: string, slugPrefix: string[]) { + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + const metaPath = path.join(dir, 'meta.json'); + try { + const raw = await fs.readFile(metaPath, 'utf-8'); + const meta = JSON.parse(raw) as FolderMeta; + const folderPath = '/' + slugPrefix.join('/'); + metaMap.set(folderPath, meta); + } catch { + // no meta.json + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + await scanDir(path.join(dir, entry.name), [...slugPrefix, entry.name]); + } + } + + for (const root of getLatestContentRoots(config)) { + const contentDir = path.resolve(contentMirrorRoot, root.contentDir); + await scanDir(contentDir, [root.contentDir]); + } + for (const version of config.versions ?? []) { + for (const root of getVersionContentRoots(config, version.dir)) { + const contentDir = path.resolve(contentMirrorRoot, version.dir, root.contentDir); + await scanDir(contentDir, [version.dir, root.contentDir]); + } + } + + return metaMap; +} + +function buildPageTree(pages: ScannedPage[], config: ChronicleConfig, folderMeta: Map): PageTreeRoot { + const tree: PageTreeRoot = { name: 'root', children: [] }; + const folderMap = new Map(); + + for (const page of pages) { + const segments = page.slugs; + if (segments.length === 0) continue; + + let current = tree.children; + for (let i = 0; i < segments.length - 1; i++) { + const folderPath = '/' + segments.slice(0, i + 1).join('/'); + let folder = folderMap.get(folderPath); + if (!folder) { + const meta = folderMeta.get(folderPath); + folder = { + type: 'folder', + name: meta?.title ?? titleFromSlug(segments[i]), + url: folderPath, + $order: meta?.order, + children: [], + }; + folderMap.set(folderPath, folder); + current.push(folder); + } + current = folder.children!; + } + + const pageNode: PageTreeNode = { + type: 'page', + name: page.frontmatter.title, + url: page.url, + icon: page.frontmatter.icon, + $order: page.frontmatter.order, + }; + + // Check if this is a folder index page + const folderPath = '/' + segments.slice(0, -1).join('/'); + const parentFolder = folderMap.get(folderPath); + if (parentFolder && segments.length > 1) { + const isIndex = page.relativePath.match(/(index|readme)\.(mdx|md)$/i); + if (isIndex) { + parentFolder.index = pageNode; + parentFolder.name = page.frontmatter.title; + continue; + } + } + + current.push(pageNode); + } + + for (const root of getLatestContentRoots(config)) { + const rootFolder = folderMap.get(`/${root.contentDir}`); + if (rootFolder) { + rootFolder.name = root.contentLabel; + } + } + + function sortChildren(children: PageTreeNode[]) { + children.sort((a, b) => { + const orderA = a.$order ?? Number.MAX_SAFE_INTEGER; + const orderB = b.$order ?? Number.MAX_SAFE_INTEGER; + if (orderA !== orderB) return orderA - orderB; + return a.name.localeCompare(b.name); + }); + for (const child of children) { + if (child.children) sortChildren(child.children); + } + } + sortChildren(tree.children); + + function stripOrder(node: PageTreeNode) { + delete node.$order; + if (node.children) node.children.forEach(stripOrder); + if (node.index) delete node.index.$order; + } + tree.children.forEach(stripOrder); + + return tree; +} + +function flattenTreeUrls(tree: PageTreeRoot): { url: string; title: string }[] { + const result: { url: string; title: string }[] = []; + function walk(nodes: PageTreeNode[]) { + for (const node of nodes) { + if (node.type === 'folder') { + if (node.index) result.push({ url: node.index.url, title: node.index.name }); + if (node.children) walk(node.children); + } else if (node.type === 'page') { + result.push({ url: node.url, title: node.name }); + } + } + } + walk(tree.children); + return result; +} + +function computeNavigation(tree: PageTreeRoot): Map { + const navMap = new Map(); + const ordered = flattenTreeUrls(tree); + + for (let i = 0; i < ordered.length; i++) { + navMap.set(ordered[i].url, { + prev: i > 0 + ? { url: ordered[i - 1].url, title: ordered[i - 1].title } + : null, + next: i < ordered.length - 1 + ? { url: ordered[i + 1].url, title: ordered[i + 1].title } + : null, + }); + } + + return navMap; +} + +async function loadSpecs(configs: import('@/types').ApiConfig[], projectRoot: string): Promise { + const results: ApiSpec[] = []; + for (const c of configs) { + try { + results.push(await loadApiSpec(c, projectRoot)); + } catch { + try { + const specPath = path.resolve(projectRoot, c.spec); + const raw = await fs.readFile(specPath, 'utf-8'); + const isYaml = specPath.endsWith('.yaml') || specPath.endsWith('.yml'); + const rawDoc = isYaml ? (await import('yaml')).parse(raw) : JSON.parse(raw); + const doc = rawDoc.openapi?.startsWith('3.') ? resolveDocument(rawDoc) : rawDoc; + results.push({ + name: c.name, + basePath: c.basePath, + server: { ...c.server, url: c.server.url }, + auth: c.auth, + document: doc, + }); + } catch { + console.log(chalk.yellow(` Warning: Skipping spec ${c.name}`)); + } + } + } + return results; +} + +// --- Generation steps --- + +async function generatePageDataFiles( + pages: ScannedPage[], + navMap: Map, + outputDir: string, +): Promise { + const dataDir = path.join(outputDir, 'data', 'pages'); + await fs.mkdir(dataDir, { recursive: true }); + + for (const page of pages) { + const slugKey = page.slugs.join(',') || 'index'; + const nav = navMap.get(page.url) ?? { prev: null, next: null }; + + const data = { + frontmatter: page.frontmatter, + relativePath: page.relativePath, + originalPath: page.originalPath, + images: page.images, + prev: nav.prev, + next: nav.next, + }; + + const filePath = path.join(dataDir, `${slugKey}.json`); + await fs.writeFile(filePath, JSON.stringify(data)); + } +} + +async function generateSearchIndex( + pages: ScannedPage[], + config: ChronicleConfig, + outputDir: string, + projectRoot: string, +): Promise { + const docs: SearchDocument[] = []; + const contentEntries = config.content ?? []; + + for (const page of pages) { + const { headings, body } = extractHeadingsAndBody(page.rawContent); + const dir = page.url.replace(/^\//, '').split('/')[0]; + const entry = contentEntries.find(c => c.dir === dir); + + docs.push({ + id: page.url, + url: page.url, + title: page.frontmatter.title, + headings, + body: [page.frontmatter.description ?? '', body].join(' '), + type: SearchResultType.Page, + section: entry?.label ?? dir ?? '', + }); + } + + // Include API operations + const apiConfigs = config.api ?? []; + if (apiConfigs.length) { + try { + const specs = await loadSpecs(apiConfigs, projectRoot); + for (const spec of specs) { + const specSlug = getSpecSlug(spec); + const paths = spec.document.paths ?? {}; + for (const [pathStr, pathItem] of Object.entries(paths)) { + if (!pathItem) continue; + for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) { + const op = pathItem[method] as OpenAPIV3.OperationObject | undefined; + if (!op) continue; + const opId = op.operationId ?? `${method}_${pathStr.replace(/[/{}\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`; + const url = `/apis/${specSlug}/${encodeURIComponent(opId)}`; + docs.push({ + id: url, + url, + title: `${method.toUpperCase()} ${op.summary ?? opId}`, + headings: op.summary ?? opId, + body: [op.description ?? '', pathStr, method.toUpperCase()].join(' '), + type: SearchResultType.Api, + section: spec.name, + }); + } + } + } + } catch { + console.log(chalk.yellow(' Warning: Failed to load API specs for search index')); + } + } + + const dataDir = path.join(outputDir, 'data'); + await fs.mkdir(dataDir, { recursive: true }); + await fs.writeFile(path.join(dataDir, 'search.json'), JSON.stringify(docs)); +} + +async function generateApiSpecs( + config: ChronicleConfig, + outputDir: string, + projectRoot: string, +): Promise { + const specsDir = path.join(outputDir, 'data', 'specs'); + await fs.mkdir(specsDir, { recursive: true }); + + // Generate latest specs + const latestConfigs = getApiConfigsForVersion(config, null); + if (latestConfigs.length) { + try { + const specs = await loadSpecs(latestConfigs, projectRoot); + await fs.writeFile(path.join(specsDir, 'latest.json'), JSON.stringify(specs)); + } catch { + console.log(chalk.yellow(' Warning: Failed to load latest API specs')); + } + } + + // Generate versioned specs + for (const version of config.versions ?? []) { + const versionConfigs = getApiConfigsForVersion(config, version.dir); + if (!versionConfigs.length) continue; + try { + const specs = await loadSpecs(versionConfigs, projectRoot); + await fs.writeFile(path.join(specsDir, `${version.dir}.json`), JSON.stringify(specs)); + } catch { + console.log(chalk.yellow(` Warning: Failed to load API specs for version ${version.dir}`)); + } + } +} + +async function generateSitemap( + pages: ScannedPage[], + config: ChronicleConfig, + outputDir: string, + projectRoot: string, +): Promise { + if (!config.url) { + await fs.writeFile( + path.join(outputDir, 'sitemap.xml'), + '', + ); + return; + } + + const baseUrl = config.url.replace(/\/$/, ''); + + const docPages = pages.map(page => { + let lastmod = ''; + if (page.frontmatter.lastModified) { + const d = new Date(page.frontmatter.lastModified); + if (!Number.isNaN(d.getTime())) lastmod = `${d.toISOString()}`; + } + return `${baseUrl}/${page.slugs.join('/')}${lastmod}`; + }); + + 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}` : ''; + try { + const routes = buildApiRoutes(await loadSpecs(apiConfigs, projectRoot)); + for (const route of routes) { + apiPages.push( + `${baseUrl}${prefix}/apis/${route.slug.join('/')}`, + ); + } + } catch { + // skip if specs fail to load + } + } + + const xml = ` + +${baseUrl} +${[...docPages, ...apiPages].join('\n')} +`; + + await fs.writeFile(path.join(outputDir, 'sitemap.xml'), xml); +} + +async function generateRobotsTxt( + config: ChronicleConfig, + outputDir: string, +): Promise { + const sitemap = config.url ? `\nSitemap: ${config.url}/sitemap.xml` : ''; + const body = `User-agent: *\nAllow: /${sitemap}`; + await fs.writeFile(path.join(outputDir, 'robots.txt'), body); +} + +async function generateLlmsTxt( + pages: ScannedPage[], + config: ChronicleConfig, + outputDir: string, +): Promise { + const latestCtx: VersionContext = { dir: null, urlPrefix: '' }; + + // Filter to only latest pages (not versioned) + const versionPrefixes = (config.versions ?? []).map(v => `/${v.dir}`); + const latestPages = pages.filter( + p => !versionPrefixes.some(pre => p.url === pre || p.url.startsWith(`${pre}/`)), + ); + + const llmsPages: LlmsPage[] = latestPages.map(p => ({ + url: p.url, + title: p.frontmatter.title, + })); + + const body = buildLlmsTxt(config, llmsPages, latestCtx); + await fs.writeFile(path.join(outputDir, 'llms.txt'), body); +} + +async function generateOgImages( + pages: ScannedPage[], + config: ChronicleConfig, + outputDir: string, +): Promise { + const ogDir = path.join(outputDir, 'og'); + await fs.mkdir(ogDir, { recursive: true }); + + // Load font — try local file first, then fetch from Google + let fontData: ArrayBuffer; + try { + const response = await fetch( + 'https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hiA.woff2', + { signal: AbortSignal.timeout(10000) }, + ); + fontData = await response.arrayBuffer(); + } catch { + fontData = new ArrayBuffer(0); + } + + const siteName = config.site.title; + + for (const page of pages) { + const title = page.frontmatter.title; + const description = page.frontmatter.description ?? ''; + const slugKey = page.slugs.join(',') || 'index'; + + try { + // Using React.createElement since we can't use JSX in a CLI context + // without additional build config. Satori accepts React elements. + const { createElement: h } = await import('react'); + + const svg = await satori( + h('div', { + style: { + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + padding: '60px 80px', + backgroundColor: '#0a0a0a', + color: '#fafafa', + }, + }, + h('div', { style: { fontSize: 24, color: '#888', marginBottom: 16 } }, siteName), + h('div', { + style: { + fontSize: 56, + fontWeight: 700, + lineHeight: 1.2, + marginBottom: 24, + }, + }, title), + description + ? h('div', { style: { fontSize: 24, color: '#999', lineHeight: 1.4 } }, description) + : null, + ), + { + width: 1200, + height: 630, + fonts: [ + { name: 'Inter', data: fontData, weight: 400, style: 'normal' as const }, + ], + }, + ); + + const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer(); + await fs.writeFile(path.join(ogDir, `${slugKey}.png`), pngBuffer); + } catch { + // Skip pages that fail OG generation + } + } +} + +async function optimizeImages( + pages: ScannedPage[], + packageRoot: string, + outputDir: string, +): Promise { + const contentDir = path.resolve(packageRoot, '.content'); + const seen = new Set(); + let optimized = 0; + + for (const page of pages) { + for (const imgUrl of page.images) { + if (!isLocalImage(imgUrl) || seen.has(imgUrl)) continue; + seen.add(imgUrl); + + const relativePath = imgUrl.replace(/^\/_content\//, ''); + const srcPath = path.resolve(contentDir, relativePath); + if (!srcPath.startsWith(contentDir + path.sep) && srcPath !== contentDir) continue; + + if (isSvg(imgUrl)) { + const destPath = path.join(outputDir, '_content', relativePath); + try { + await fs.mkdir(path.dirname(destPath), { recursive: true }); + await fs.copyFile(srcPath, destPath); + optimized++; + } catch { + // skip missing files + } + continue; + } + + const webpRelative = relativePath.replace(/\.[^.]+$/, '.webp'); + const destPath = path.join(outputDir, '_content', webpRelative); + + try { + await fs.mkdir(path.dirname(destPath), { recursive: true }); + const source = await fs.readFile(srcPath); + const optimizedBuf = await sharp(source) + .resize({ width: DEFAULT_WIDTH, withoutEnlargement: true }) + .webp({ quality: DEFAULT_QUALITY }) + .toBuffer(); + await fs.writeFile(destPath, optimizedBuf); + + // Also copy original for fallback + const origDest = path.join(outputDir, '_content', relativePath); + await fs.mkdir(path.dirname(origDest), { recursive: true }); + await fs.copyFile(srcPath, origDest); + + optimized++; + } catch { + // skip unprocessable images + } + } + } + + if (optimized > 0) { + console.log(chalk.gray(` Optimized ${optimized} images`)); + } +} + +async function copyPublicAssets( + projectRoot: string, + outputDir: string, +): Promise { + const publicDir = path.resolve(projectRoot, 'public'); + if (!existsSync(publicDir)) return; + + async function copyTree(src: string, dest: string) { + const entries = await fs.readdir(src, { withFileTypes: true }); + await fs.mkdir(dest, { recursive: true }); + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + await copyTree(srcPath, destPath); + } else { + await fs.copyFile(srcPath, destPath); + } + } + } + + await copyTree(publicDir, outputDir); +} + +interface ViteManifestEntry { + file: string; + src?: string; + isEntry?: boolean; + css?: string[]; + imports?: string[]; +} + +type ViteManifest = Record; + +function readViteManifest(outputDir: string): ViteManifest | null { + // Try multiple known locations for the Vite manifest + const candidates = [ + path.join(outputDir, '.vite', 'manifest.json'), + path.join(outputDir, 'assets', '.vite', 'manifest.json'), + path.join(outputDir, '.vite/manifest.json'), + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + try { + const raw = readFileSync(candidate, 'utf-8'); + return JSON.parse(raw) as ViteManifest; + } catch { + continue; + } + } + } + + return null; +} + +async function generateSpaIndex( + config: ChronicleConfig, + tree: PageTreeRoot, + outputDir: string, +): Promise { + const manifest = readViteManifest(outputDir); + + let entryJs = ''; + const cssFiles: string[] = []; + const preloadFiles: string[] = []; + + if (manifest) { + // Find the entry point — look for the static entry or client entry + for (const [, entry] of Object.entries(manifest)) { + if (entry.isEntry) { + entryJs = `/${entry.file}`; + if (entry.css) { + cssFiles.push(...entry.css.map(f => `/${f}`)); + } + if (entry.imports) { + for (const imp of entry.imports) { + const impEntry = manifest[imp]; + if (impEntry) { + preloadFiles.push(`/${impEntry.file}`); + if (impEntry.css) { + cssFiles.push(...impEntry.css.map(f => `/${f}`)); + } + } + } + } + break; + } + } + } + + if (!entryJs) { + throw new Error('Could not determine Vite client entry from manifest — static index generation aborted'); + } + + const latestCtx: VersionContext = { dir: null, urlPrefix: '' }; + const pageData = { + config, + tree, + version: latestCtx, + }; + const safeJson = JSON.stringify(pageData).replace(/ ` `) + .join('\n'); + + const preloadLinks = [...new Set(preloadFiles)] + .map(f => ` `) + .join('\n'); + + const html = ` + + + + + + +${cssLinks} +${preloadLinks} + + + + +
+ +`; + + await fs.writeFile(path.join(outputDir, 'index.html'), html); +} + +async function generateMarkdownFiles( + pages: ScannedPage[], + apiSpecs: ApiSpec[], + outputDir: string, +): Promise { + for (const page of pages) { + const mdPath = page.url === '/' ? '/index.md' : `${page.url}.md`; + const outPath = path.join(outputDir, mdPath); + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, page.rawContent); + } + + if (!apiSpecs.length) return; + const { flattenSchema, generateExampleJson } = await import('@/lib/schema'); + const { generateCurl } = await import('@/lib/snippet-generators'); + + for (const spec of apiSpecs) { + const specSlug = getSpecSlug(spec); + const paths = spec.document.paths ?? {}; + for (const [pathStr, pathItem] of Object.entries(paths)) { + if (!pathItem) continue; + for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) { + const op = pathItem[method] as import('openapi-types').OpenAPIV3.OperationObject | undefined; + if (!op) continue; + const opId = op.operationId ?? `${method}_${pathStr.replace(/[/{}\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`; + const mdPath = `/apis/${specSlug}/${encodeURIComponent(opId)}.md`; + const outPath = path.join(outputDir, mdPath); + await fs.mkdir(path.dirname(outPath), { recursive: true }); + const md = buildApiMd(method.toUpperCase(), pathStr, op, spec.server.url, spec.auth, flattenSchema, generateExampleJson, generateCurl); + await fs.writeFile(outPath, md); + } + } + } +} + +function buildApiMd( + method: string, + apiPath: string, + operation: import('openapi-types').OpenAPIV3.OperationObject, + serverUrl: string, + auth: { type: string; header: string; placeholder?: string } | undefined, + flattenSchema: (s: any) => any[], + generateExampleJson: (s: any) => any, + generateCurl: (opts: any) => string, +): string { + const lines: string[] = []; + const params = (operation.parameters ?? []) as import('openapi-types').OpenAPIV3.ParameterObject[]; + + lines.push(`# ${operation.summary ?? `${method} ${apiPath}`}`); + lines.push(''); + if (operation.description) { + lines.push(operation.description); + lines.push(''); + } + lines.push(`\`${method}\` \`${apiPath}\``); + lines.push(''); + + const headerParams = params.filter(p => p.in === 'header'); + const pathParams = params.filter(p => p.in === 'path'); + const queryParams = params.filter(p => p.in === 'query'); + + if (auth || headerParams.length > 0) { + lines.push('## Authorization'); + lines.push(''); + lines.push('| Header | Type | Required | Description |'); + lines.push('| --- | --- | --- | --- |'); + if (auth) lines.push(`| \`${auth.header}\` | string | Yes | ${auth.placeholder ?? 'API key'} |`); + for (const p of headerParams) { + const schema = (p.schema ?? {}) as any; + lines.push(`| \`${p.name}\` | ${schema.type ?? 'string'} | ${p.required ? 'Yes' : 'No'} | ${p.description ?? ''} |`); + } + lines.push(''); + } + + if (pathParams.length > 0) { + lines.push('## Path Parameters'); + lines.push(''); + lines.push('| Parameter | Type | Required | Description |'); + lines.push('| --- | --- | --- | --- |'); + for (const p of pathParams) { + const schema = (p.schema ?? {}) as any; + lines.push(`| \`${p.name}\` | ${schema.type ?? 'string'} | ${p.required ? 'Yes' : 'No'} | ${p.description ?? ''} |`); + } + lines.push(''); + } + + if (queryParams.length > 0) { + lines.push('## Query Parameters'); + lines.push(''); + lines.push('| Parameter | Type | Required | Description |'); + lines.push('| --- | --- | --- | --- |'); + for (const p of queryParams) { + const schema = (p.schema ?? {}) as any; + lines.push(`| \`${p.name}\` | ${schema.type ?? 'string'} | ${p.required ? 'Yes' : 'No'} | ${p.description ?? ''} |`); + } + lines.push(''); + } + + const requestBody = operation.requestBody as import('openapi-types').OpenAPIV3.RequestBodyObject | undefined; + if (requestBody?.content) { + const contentType = Object.keys(requestBody.content)[0]; + const schema = contentType ? requestBody.content[contentType]?.schema : undefined; + if (schema) { + lines.push('## Request Body'); + lines.push(''); + lines.push(`Content-Type: \`${contentType}\``); + lines.push(''); + const example = generateExampleJson(schema); + lines.push('```json'); + lines.push(JSON.stringify(example, null, 2)); + lines.push('```'); + lines.push(''); + } + } + + const responses = operation.responses as Record | undefined; + if (responses) { + lines.push('## Responses'); + lines.push(''); + for (const [status, resp] of Object.entries(responses)) { + lines.push(`### ${status}${resp.description ? ` — ${resp.description}` : ''}`); + lines.push(''); + const content = resp.content ?? {}; + const contentType = Object.keys(content)[0]; + const schema = contentType ? content[contentType]?.schema : undefined; + if (schema) { + const example = generateExampleJson(schema); + lines.push('```json'); + lines.push(JSON.stringify(example, null, 2)); + lines.push('```'); + lines.push(''); + } + } + } + + const headers: Record = {}; + if (auth) headers[auth.header] = auth.placeholder ?? 'YOUR_API_KEY'; + lines.push('## cURL'); + lines.push(''); + lines.push('```bash'); + lines.push(generateCurl({ method, url: serverUrl + apiPath, headers })); + lines.push('```'); + + return lines.join('\n'); +} + +// --- Main export --- + +export async function generateStaticSite(options: StaticGenerateOptions): Promise { + const { projectRoot, config, outputDir, packageRoot } = options; + + console.log(chalk.cyan('\nGenerating static site...')); + + // Scan all content from filesystem + console.log(chalk.gray(' Scanning content...')); + const pages = await scanAllContent(projectRoot, config, packageRoot); + console.log(chalk.gray(` Found ${pages.length} pages`)); + + const contentMirror = path.resolve(packageRoot, '.content'); + const folderMeta = await scanFolderMeta(contentMirror, config); + const tree = buildPageTree(pages, config, folderMeta); + const navMap = computeNavigation(tree); + + // Generate all static assets + console.log(chalk.gray(' Generating page data files...')); + await generatePageDataFiles(pages, navMap, outputDir); + + console.log(chalk.gray(' Generating search index...')); + await generateSearchIndex(pages, config, outputDir, projectRoot); + + console.log(chalk.gray(' Generating API specs...')); + await generateApiSpecs(config, outputDir, projectRoot); + + const latestApiConfigs = config.api ?? []; + const apiSpecsForMd = latestApiConfigs.length ? await loadSpecs(latestApiConfigs, projectRoot) : []; + + console.log(chalk.gray(' Generating markdown files...')); + await generateMarkdownFiles(pages, apiSpecsForMd, outputDir); + + console.log(chalk.gray(' Generating sitemap.xml...')); + await generateSitemap(pages, config, outputDir, projectRoot); + + console.log(chalk.gray(' Generating robots.txt...')); + await generateRobotsTxt(config, outputDir); + + console.log(chalk.gray(' Generating llms.txt...')); + await generateLlmsTxt(pages, config, outputDir); + + console.log(chalk.gray(' Generating OG images...')); + await generateOgImages(pages, config, outputDir); + + console.log(chalk.gray(' Optimizing images...')); + await optimizeImages(pages, packageRoot, outputDir); + + console.log(chalk.gray(' Copying public assets...')); + await copyPublicAssets(projectRoot, outputDir); + + console.log(chalk.gray(' Generating SPA index.html...')); + await generateSpaIndex(config, tree, outputDir); + + console.log(chalk.green(` Static site generated: ${pages.length} pages`)); +} diff --git a/packages/chronicle/src/components/api/playground-dialog.tsx b/packages/chronicle/src/components/api/playground-dialog.tsx index e0192df3..484f0158 100644 --- a/packages/chronicle/src/components/api/playground-dialog.tsx +++ b/packages/chronicle/src/components/api/playground-dialog.tsx @@ -11,6 +11,66 @@ import { generateCurl } from '@/lib/snippet-generators' import { JsonEditor } from '@/components/api/json-editor' import styles from './playground-dialog.module.css' +type ProxyResponse = { + status: number + statusText: string + body: unknown + headers?: Record +} + +function isStaticMode(): boolean { + return typeof window !== 'undefined' && '__STATIC_MODE__' in window && (window as unknown as Record).__STATIC_MODE__ === true +} + +async function sendViaProxy( + specName: string, + method: string, + path: string, + headers: Record, + body: unknown, +): Promise { + const res = await fetch('/api/apis-proxy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ specName, method, path, headers, body }), + }) + const data = await res.json() + if (data.status !== undefined) return data + return { status: res.status, statusText: res.statusText, body: data.error ?? data } +} + +async function sendDirect( + serverUrl: string, + method: string, + path: string, + headers: Record, + body: unknown, +): Promise { + const url = serverUrl + path + try { + const res = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + const contentType = res.headers.get('content-type') ?? '' + const responseBody = contentType.includes('application/json') + ? await res.json() + : await res.text() + const responseHeaders: Record = {} + res.headers.forEach((v, k) => { responseHeaders[k] = v }) + return { status: res.status, statusText: res.statusText, body: responseBody, headers: responseHeaders } + } catch (err) { + if (err instanceof TypeError) { + throw new Error( + `CORS Error: The API server at ${serverUrl} does not allow requests from this origin.\n` + + `Ask the API server administrator to add this site's origin to their CORS allowed origins.` + ) + } + throw err + } +} + type AuthScheme = { name: string type: 'apiKey' | 'bearer' | 'basic' | 'none' @@ -193,24 +253,18 @@ export function PlaygroundDialog({ } try { - const res = await fetch('/api/apis-proxy', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ specName, method, path: fullPath, headers: reqHeaders, body: reqBody }), - }) - const data = await res.json() + const data = isStaticMode() + ? await sendDirect(serverUrl, method, fullPath, reqHeaders, reqBody) + : await sendViaProxy(specName, method, fullPath, reqHeaders, reqBody) const elapsed = Math.round(performance.now() - startTime) - if (data.status !== undefined) { - setResponseData({ ...data, time: elapsed }) - } else { - setResponseData({ status: res.status, statusText: res.statusText, body: data.error ?? data, time: elapsed }) - } - } catch { - setResponseData({ status: 0, statusText: 'Error', body: 'Failed to send request', time: 0 }) + setResponseData({ ...data, time: elapsed }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to send request' + setResponseData({ status: 0, statusText: 'Error', body: message, time: 0 }) } finally { setLoading(false) } - }, [specName, method, path, pathValues, queryValues, getAuthHeaders, headerValues, bodyValues, body]) + }, [specName, method, path, serverUrl, pathValues, queryValues, getAuthHeaders, headerValues, bodyValues, body]) const responseJson = responseData ? (typeof responseData.body === 'string' ? responseData.body : JSON.stringify(responseData.body, null, 2)) diff --git a/packages/chronicle/src/components/mdx/code.tsx b/packages/chronicle/src/components/mdx/code.tsx index 82dd8b95..8b69590a 100644 --- a/packages/chronicle/src/components/mdx/code.tsx +++ b/packages/chronicle/src/components/mdx/code.tsx @@ -1,9 +1,10 @@ 'use client' -import { type ComponentProps, isValidElement, Children } from 'react' -import { Mermaid } from './mermaid' +import { type ComponentProps, isValidElement, lazy, Suspense } from 'react' import styles from './code.module.css' +const Mermaid = lazy(() => import('./mermaid').then(m => ({ default: m.Mermaid }))) + type PreProps = ComponentProps<'pre'> & { 'data-language'?: string title?: string @@ -21,7 +22,11 @@ export function MdxPre({ children, title, className, ...props }: PreProps) { if (isValidElement(children)) { const childProps = children.props as { className?: string; children?: string } if (childProps.className?.includes('language-mermaid') && typeof childProps.children === 'string') { - return + return ( + {childProps.children}}> + + + ) } } diff --git a/packages/chronicle/src/components/mdx/image.tsx b/packages/chronicle/src/components/mdx/image.tsx index 9b5d987e..c7deaa36 100644 --- a/packages/chronicle/src/components/mdx/image.tsx +++ b/packages/chronicle/src/components/mdx/image.tsx @@ -3,11 +3,28 @@ import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from '@/lib/ima type MDXImageProps = ComponentProps<'img'>; +function isStaticMode(): boolean { + return typeof window !== 'undefined' && '__STATIC_MODE__' in window && (window as unknown as Record).__STATIC_MODE__ === true; +} + +function webpUrl(src: string): string { + return src.replace(/\.[^.]+$/, '.webp'); +} + export function MDXImage({ src, alt, ...props }: MDXImageProps) { if (!src) return null; const optimize = isLocalImage(src) && !isSvg(src); - const imgSrc = optimize ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src; + if (optimize && isStaticMode()) { + return ( + + + {alt + + ); + } + + const imgSrc = optimize ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src; return {alt; } diff --git a/packages/chronicle/src/components/mdx/index.tsx b/packages/chronicle/src/components/mdx/index.tsx index ed538701..23343339 100644 --- a/packages/chronicle/src/components/mdx/index.tsx +++ b/packages/chronicle/src/components/mdx/index.tsx @@ -4,11 +4,12 @@ import { Link } from './link' import { MdxTable, MdxThead, MdxTbody, MdxTr, MdxTh, MdxTd } from './table' import { MdxPre, MdxCode } from './code' import { MdxDetails, MdxSummary } from './details' -import { Mermaid } from './mermaid' import { MdxParagraph } from './paragraph' import { CalloutContainer, CalloutTitle, CalloutDescription, MdxBlockquote } from '@/components/common/callout' import { Tabs } from '@raystack/apsara' -import { type ComponentProps, useEffect, useState } from 'react' +import { type ComponentProps, lazy, useEffect, useState, Suspense } from 'react' + +const LazyMermaid = lazy(() => import('./mermaid').then(m => ({ default: m.Mermaid }))) function ClientOnly({ children }: { children: React.ReactNode }) { const [mounted, setMounted] = useState(false) @@ -42,7 +43,11 @@ export const mdxComponents: MDXComponents = { CalloutTitle, CalloutDescription, Tabs: MdxTabs, - Mermaid, + Mermaid: (props: { chart: string }) => ( + {props.chart}}> + + + ), } export { MDXImage } from './image' diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index 5830f7e4..f1d9699f 100644 --- a/packages/chronicle/src/components/ui/search.tsx +++ b/packages/chronicle/src/components/ui/search.tsx @@ -7,7 +7,9 @@ import { } from '@heroicons/react/24/outline'; import { Command, IconButton, Text } from '@raystack/apsara'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import GithubSlugger from 'github-slugger'; import { debounce } from 'lodash-es'; +import MiniSearch from 'minisearch'; import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react'; import { useNavigate } from 'react-router'; import { MethodBadge } from '@/components/api/method-badge'; @@ -29,6 +31,131 @@ interface SearchProps { classNames?: { trigger?: string }; } +interface SearchDocument { + id: string; + url: string; + title: string; + headings: string; + body: string; + type: string; + section: string; +} + +function isStaticMode(): boolean { + return typeof window !== 'undefined' && (window as unknown as { __STATIC_MODE__?: boolean }).__STATIC_MODE__ === true; +} + +let miniSearchInstance: MiniSearch | null = null; +let miniSearchLoading: Promise> | null = null; +let searchDocuments: SearchDocument[] = []; + +function loadSearchIndex(): Promise> { + if (miniSearchInstance) return Promise.resolve(miniSearchInstance); + if (miniSearchLoading) return miniSearchLoading; + + miniSearchLoading = fetch('/data/search.json') + .then(res => { + if (!res.ok) throw new Error(`Failed to load search index: ${res.status}`); + return res.json() as Promise; + }) + .then(docs => { + searchDocuments = docs; + const ms = new MiniSearch({ + fields: ['title', 'headings', 'body'], + storeFields: ['url', 'title', 'headings', 'body', 'type', 'section'], + searchOptions: { + boost: { title: 10, headings: 5, body: 1 }, + prefix: true, + fuzzy: 0.2, + }, + }); + ms.addAll(docs); + miniSearchInstance = ms; + return ms; + }) + .catch(err => { + miniSearchLoading = null; + throw err; + }); + + return miniSearchLoading; +} + +function findMatch( + query: string, + title: string, + headings: string, + body: string, +): { match: SearchMatchType; snippet: string; slug?: string } { + if (title.toLowerCase().includes(query)) { + return { match: SearchMatchType.Title, snippet: title }; + } + + const slugger = new GithubSlugger(); + const headingList = headings.split('\n').filter(Boolean); + for (const h of headingList) { + const slug = slugger.slug(h); + if (h.toLowerCase().includes(query)) { + return { match: SearchMatchType.Heading, snippet: h, slug }; + } + } + + const idx = body.toLowerCase().indexOf(query); + if (idx >= 0) { + const start = Math.max(0, idx - 40); + const end = Math.min(body.length, idx + query.length + 80); + const snippet = (start > 0 ? '...' : '') + body.slice(start, end).trim() + (end < body.length ? '...' : ''); + return { match: SearchMatchType.Body, snippet }; + } + + return { match: SearchMatchType.Title, snippet: title }; +} + +async function searchStatic(query: string, tag?: string): Promise { + const ms = await loadSearchIndex(); + + if (!query) { + let docs = searchDocuments.filter(d => d.type === 'page'); + if (tag) docs = docs.filter(d => d.url === `/${tag}` || d.url.startsWith(`/${tag}/`)); + return docs.slice(0, 8).map(d => ({ + id: d.id, + url: d.url, + type: d.type, + content: d.title, + section: d.section || undefined, + })); + } + + let results = ms.search(query); + if (tag) { + results = results.filter(r => { + const url = r.url as string; + return url === `/${tag}` || url.startsWith(`/${tag}/`); + }); + } + + const queryLower = query.toLowerCase(); + return results.slice(0, 20).map(r => { + const { match, snippet, slug } = findMatch( + queryLower, + r.title as string, + r.headings as string, + r.body as string, + ); + const id = match === SearchMatchType.Heading && slug ? `${r.id}#${slug}` : r.id as string; + const url = match === SearchMatchType.Heading && slug ? `${r.url}#${slug}` : r.url as string; + return { + id, + url, + type: r.type as string, + content: r.title as string, + match, + snippet, + section: (r.section as string) || undefined, + }; + }); +} + function buildSearchUrl(query: string, tag?: string): string { const params = new URLSearchParams(); if (query) params.set('query', query); @@ -64,9 +191,14 @@ export function Search({ classNames }: SearchProps) { } }, [open, updateDebouncedSearch]); + const staticMode = isStaticMode(); + const { data = [], isLoading } = useQuery({ - queryKey: ['search', debouncedSearch, tag], + queryKey: ['search', debouncedSearch, tag, staticMode], queryFn: async ({ signal }) => { + if (staticMode) { + return searchStatic(debouncedSearch, tag); + } const res = await fetch(buildSearchUrl(debouncedSearch, tag), { signal }); if (!res.ok) throw new Error(String(res.status)); return res.json(); diff --git a/packages/chronicle/src/lib/openapi.ts b/packages/chronicle/src/lib/openapi.ts index 3116bf3e..5f351c58 100644 --- a/packages/chronicle/src/lib/openapi.ts +++ b/packages/chronicle/src/lib/openapi.ts @@ -95,7 +95,7 @@ function deepResolveRefs( return result } -function resolveDocument(doc: OpenAPIV3.Document): OpenAPIV3.Document { +export function resolveDocument(doc: OpenAPIV3.Document): OpenAPIV3.Document { const root = doc as unknown as JsonObject return deepResolveRefs(doc, root) as unknown as OpenAPIV3.Document } diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index 55268b84..6a91eded 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -7,7 +7,7 @@ import { useRef, useState } from 'react'; -import { useLocation } from 'react-router'; +import { useLocation, useNavigate } from 'react-router'; import type { ApiSpec } from '@/lib/openapi'; import { resolveRoute, RouteType } from '@/lib/route-resolver'; import type { VersionContext } from '@/lib/version-source'; @@ -60,6 +60,10 @@ interface PageProviderProps { children: ReactNode; } +function isStaticMode(): boolean { + return typeof window !== 'undefined' && (window as any).__STATIC_MODE__ === true; +} + function isApisRoute(pathname: string): boolean { return pathname === '/apis' || pathname.startsWith('/apis/'); } @@ -83,6 +87,7 @@ export function PageProvider({ children }: PageProviderProps) { const { pathname } = useLocation(); + const navigate = useNavigate(); const [tree] = useState(initialTree); const [page, setPage] = useState(initialPage); const [errorStatus, setErrorStatus] = useState(getInitialErrorStatus(initialPage, initialConfig, pathname)); @@ -94,9 +99,17 @@ export function PageProvider({ const fetchApiSpecs = useCallback(async (route: { version: VersionContext }, cancelled: { current: boolean }) => { setIsLoading(true); try { - const specsUrl = route.version.dir - ? `/api/specs?version=${encodeURIComponent(route.version.dir)}` - : '/api/specs'; + let specsUrl: string; + if (isStaticMode()) { + const file = route.version.dir + ? `${encodeURIComponent(route.version.dir)}.json` + : 'latest.json'; + specsUrl = `/data/specs/${file}`; + } else { + specsUrl = route.version.dir + ? `/api/specs?version=${encodeURIComponent(route.version.dir)}` + : '/api/specs'; + } const res = await fetch(specsUrl); const specs = await res.json(); if (!cancelled.current) setApiSpecs(specs); @@ -118,7 +131,13 @@ export function PageProvider({ const fetchPageData = useCallback(async (slug: string[]): Promise => { const key = slug.length === 0 ? '' : slug.map(s => encodeURIComponent(s)).join(','); - const apiPath = key ? `/api/page?slug=${key}` : '/api/page'; + let apiPath: string; + if (isStaticMode()) { + const file = key || 'index'; + apiPath = `/data/pages/${file}.json`; + } else { + apiPath = key ? `/api/page?slug=${key}` : '/api/page'; + } return queryClient.fetchQuery({ queryKey: ['pageData', key], queryFn: async () => { @@ -183,11 +202,19 @@ export function PageProvider({ return () => { cancelled.current = true; }; } + if (isStaticMode() && route.slug.length === 1) { + const entry = initialConfig.content?.find(c => c.dir === route.slug[0]); + if (entry?.index_page) { + navigate(`/${entry.dir}/${entry.index_page}`, { replace: true }); + return () => { cancelled.current = true; }; + } + } + setPage(null); setErrorStatus(null); loadDocsPage(route.slug, cancelled); return () => { cancelled.current = true; }; - }, [pathname, initialConfig, fetchApiSpecs, loadDocsPage]); + }, [pathname, initialConfig, fetchApiSpecs, loadDocsPage, navigate]); return ( encodeURIComponent(s)).join(','); - const apiPath = key ? `/api/page?slug=${key}` : '/api/page'; + let apiPath: string; + if (isStaticMode()) { + const file = key || 'index'; + apiPath = `/data/pages/${file}.json`; + } else { + apiPath = key ? `/api/page?slug=${key}` : '/api/page'; + } const res = await fetch(apiPath); if (!res.ok) throw new Error(String(res.status)); return res.json(); @@ -42,6 +52,7 @@ export function prefetchPageData(pathname: string) { } export function prefetchSearchSuggestions() { + if (isStaticMode()) return; queryClient.prefetchQuery({ queryKey: ['search', '', undefined], queryFn: async () => { diff --git a/packages/chronicle/src/server/entry-static.tsx b/packages/chronicle/src/server/entry-static.tsx new file mode 100644 index 00000000..2f3884e0 --- /dev/null +++ b/packages/chronicle/src/server/entry-static.tsx @@ -0,0 +1,159 @@ +import '@vitejs/plugin-react/preamble'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router'; +import { ReactRouterProvider } from 'fumadocs-core/framework/react-router'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { mdxComponents } from '@/components/mdx'; +import { getApiConfigsForVersion } from '@/lib/config'; +import { PageProvider } from '@/lib/page-context'; +import { queryClient } from '@/lib/preload'; +import { resolveRoute, RouteType } from '@/lib/route-resolver'; +import { resolveVersionFromUrl, type VersionContext } from '@/lib/version-source'; +import type { ChronicleConfig, Frontmatter, PageNavLink, Root, TableOfContents } from '@/types'; +import type { ApiSpec } from '@/lib/openapi'; +import type { ReactNode } from 'react'; +import { App } from './App'; + +interface StaticEmbeddedData { + config: ChronicleConfig; + tree: Root; + version: VersionContext; +} + +const defaultConfig: ChronicleConfig = { + site: { title: 'Documentation' }, + content: [{ dir: 'docs', label: 'Docs' }], +}; + +const contentModules = import.meta.glob<{ default?: React.ComponentType; toc?: TableOfContents }>( + '../../.content/**/*.{mdx,md}' +); + +async function loadMdxModule(relativePath: string): Promise<{ content: ReactNode; toc: TableOfContents }> { + const withoutExt = relativePath.replace(/\.(mdx|md)$/, ''); + const key = relativePath.endsWith('.md') + ? `../../.content/${withoutExt}.md` + : `../../.content/${withoutExt}.mdx`; + const loader = contentModules[key]; + if (!loader) return { content: null, toc: [] }; + const mod = await loader(); + const content = mod.default + ? React.createElement(mod.default, { components: mdxComponents }) + : null; + return { content, toc: mod.toc ?? [] }; +} + +async function fetchStaticPageData(slug: string[]) { + const key = slug.length === 0 ? 'index' : slug.map(s => encodeURIComponent(s)).join(','); + const res = await fetch(`/data/pages/${key}.json`); + if (!res.ok) throw new Error(String(res.status)); + const ct = res.headers.get('content-type') ?? ''; + if (!ct.includes('json')) throw new Error('404'); + return res.json(); +} + +async function fetchStaticApiSpecs(version: VersionContext): Promise { + const file = version.dir ? `${encodeURIComponent(version.dir)}.json` : 'latest.json'; + try { + const res = await fetch(`/data/specs/${file}`); + if (!res.ok) return []; + const ct = res.headers.get('content-type') ?? ''; + if (!ct.includes('json')) return []; + return res.json(); + } catch { + return []; + } +} + +function resolveContentRootRedirect(slug: string[], config: ChronicleConfig): string | null { + if (slug.length !== 1) return null; + const entry = config.content?.find(c => c.dir === slug[0]); + if (entry?.index_page) return `/${entry.dir}/${entry.index_page}`; + return null; +} + +async function mount() { + try { + const embedded = ( + window as unknown as { __PAGE_DATA__?: StaticEmbeddedData } + ).__PAGE_DATA__; + + const config: ChronicleConfig = embedded?.config ?? defaultConfig; + const tree: Root = embedded?.tree ?? { name: 'root', children: [] }; + + const route = resolveRoute(window.location.pathname, config); + + if (route.type === RouteType.Redirect) { + window.location.replace(route.to); + return; + } + + if (route.type === RouteType.DocsPage) { + const redirect = resolveContentRootRedirect(route.slug, config); + if (redirect) { + window.location.replace(redirect); + return; + } + } + + const routeVersion: VersionContext = resolveVersionFromUrl( + window.location.pathname, + config, + ); + const version: VersionContext = embedded?.version ?? routeVersion; + + const isApiRoute = + route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage; + const apiConfigs = isApiRoute + ? getApiConfigsForVersion(config, routeVersion.dir) + : []; + const apiSpecs: ApiSpec[] = apiConfigs.length + ? await fetchStaticApiSpecs(routeVersion) + : []; + + let page = null; + if (route.type === RouteType.DocsPage) { + try { + const data = await fetchStaticPageData(route.slug); + const mdxPath = data.originalPath || data.relativePath; + if (mdxPath && data.frontmatter) { + const { content, toc } = await loadMdxModule(mdxPath); + page = { + slug: route.slug, + frontmatter: data.frontmatter as Frontmatter, + prev: (data.prev as PageNavLink) ?? null, + next: (data.next as PageNavLink) ?? null, + content, + toc, + }; + } + } catch { + // page will remain null, context will show 404 + } + } + + createRoot(document.getElementById('root')!).render( + + + + + + + + + + ); + } catch (err) { + console.error('Static mount failed:', err); + } +} + +mount(); diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts index 88e2346b..0579a0c2 100644 --- a/packages/chronicle/src/server/vite-config.ts +++ b/packages/chronicle/src/server/vite-config.ts @@ -27,7 +27,7 @@ function getDatabaseConnector(preset?: string): { connector: string; options?: R const STATIC_PRESETS = new Set(['static', 'vercel-static', 'cloudflare-pages', 'github-pages']); -function isStaticPreset(preset?: string): boolean { +export function isStaticPreset(preset?: string): boolean { return !!preset && STATIC_PRESETS.has(preset); } @@ -72,8 +72,9 @@ export async function createViteConfig( plugins: [ nitro({ serverDir: path.resolve(packageRoot, 'src/server'), - ...(preset && { preset }), + ...(!isStaticPreset(preset) && preset && { preset }), ignore: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'], + ...(isStaticPreset(preset) && { prerender: { routes: [] } }), }), mdx({ default: defineFumadocsConfig({ @@ -145,8 +146,14 @@ export async function createViteConfig( environments: { client: { build: { + manifest: isStaticPreset(preset), rollupOptions: { - input: path.resolve(packageRoot, 'src/server/entry-client.tsx') + input: path.resolve( + packageRoot, + isStaticPreset(preset) + ? 'src/server/entry-static.tsx' + : 'src/server/entry-client.tsx', + ) } } } @@ -165,12 +172,16 @@ export async function createViteConfig( base: path.resolve(projectRoot, '.cache/images'), }, }, - experimental: { - database: true, - }, - database: { - default: getDatabaseConnector(preset), - }, + ...(isStaticPreset(preset) + ? {} + : { + experimental: { + database: true, + }, + database: { + default: getDatabaseConnector(preset), + }, + }), }, }; } diff --git a/packages/chronicle/tests/e2e/check-broken-assets.e2e.ts b/packages/chronicle/tests/e2e/check-broken-assets.e2e.ts new file mode 100644 index 00000000..4b838bea --- /dev/null +++ b/packages/chronicle/tests/e2e/check-broken-assets.e2e.ts @@ -0,0 +1,60 @@ +import { test, expect } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; + +const STATIC_URL = process.env.STATIC_URL || 'http://localhost:4173'; +const PAGES_DIR = process.env.PAGES_DIR || path.resolve( + import.meta.dirname, + '../../../../examples/basic/.output/public/data/pages', +); + +function getPageUrls(): string[] { + if (!fs.existsSync(PAGES_DIR)) return []; + return fs.readdirSync(PAGES_DIR) + .filter(f => f.endsWith('.json')) + .map(f => { + const slug = f.replace('.json', ''); + if (slug === 'index') return '/'; + return '/' + slug.split(',').join('/'); + }); +} + +test('check all pages for broken images and assets', async ({ page }) => { + const pageUrls = getPageUrls(); + test.skip(pageUrls.length === 0, 'No pages found — build the static site first'); + + const broken: { url: string; images: string[] }[] = []; + const failedRequests: { pageUrl: string; resource: string }[] = []; + + page.on('requestfailed', req => { + const resourceUrl = req.url(); + if (resourceUrl.includes('/favicon')) return; + if (resourceUrl.includes('google-analytics')) return; + failedRequests.push({ pageUrl: page.url(), resource: resourceUrl }); + }); + + for (const pageUrl of pageUrls) { + await page.goto(`${STATIC_URL}${pageUrl}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {}); + await page.waitForTimeout(1500); + + const brokenImages = await page.evaluate(() => { + const imgs = Array.from(document.querySelectorAll('img')); + return imgs + .filter(img => img.complete && img.naturalWidth === 0 && img.src && !img.src.includes('data:')) + .map(img => img.src); + }); + + if (brokenImages.length > 0) { + broken.push({ url: pageUrl, images: brokenImages }); + } + } + + const outDir = path.resolve(import.meta.dirname, '../../test-results'); + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync( + path.join(outDir, 'broken-assets-report.json'), + JSON.stringify({ totalPages: pageUrls.length, broken, failedRequests }, null, 2), + ); + + expect(broken, `Broken images found on ${broken.length} pages`).toHaveLength(0); +}); diff --git a/packages/chronicle/tests/e2e/static-site.e2e.ts b/packages/chronicle/tests/e2e/static-site.e2e.ts new file mode 100644 index 00000000..cf784143 --- /dev/null +++ b/packages/chronicle/tests/e2e/static-site.e2e.ts @@ -0,0 +1,127 @@ +import { test, expect } from '@playwright/test'; + +const BASE_URL = 'http://localhost:4173'; + +test.describe('Static site mode', () => { + test('index.html loads and renders the app shell', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForSelector('#root'); + const root = page.locator('#root'); + await expect(root).not.toBeEmpty(); + }); + + test('landing page shows content directory cards', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForSelector('[data-theme]', { timeout: 10000 }); + const body = await page.textContent('body'); + expect(body).toContain('My Documentation'); + }); + + test('navigates to a docs page and shows content', async ({ page }) => { + await page.goto(`${BASE_URL}/docs/getting-started`); + await page.waitForSelector('[data-theme]', { timeout: 10000 }); + await page.waitForTimeout(2000); + const body = await page.textContent('body'); + expect(body).toContain('Getting Started'); + }); + + test('sidebar navigation works (client-side nav)', async ({ page }) => { + await page.goto(`${BASE_URL}/docs/getting-started`); + await page.waitForSelector('[data-theme]', { timeout: 10000 }); + await page.waitForTimeout(2000); + + const installationLink = page.locator('a[href="/docs/guides/installation"]').first(); + await expect(installationLink).toBeVisible(); + await installationLink.click(); + await page.waitForTimeout(2000); + expect(page.url()).toContain('/docs/guides/installation'); + const body = await page.textContent('body'); + expect(body).toContain('Installation'); + }); + + test('search dialog opens with Ctrl/Cmd+K', async ({ page }) => { + await page.goto(`${BASE_URL}/docs/getting-started`); + await page.waitForSelector('[data-theme]', { timeout: 10000 }); + await page.waitForTimeout(1000); + + await page.keyboard.press('ControlOrMeta+k'); + await page.waitForTimeout(500); + + const searchInput = page.locator('input[placeholder="Search"]').first(); + await expect(searchInput).toBeVisible(); + await searchInput.fill('install'); + await page.waitForTimeout(1000); + const results = await page.textContent('body'); + expect(results).toContain('Install'); + }); + + test('page data JSON files are accessible', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/data/pages/docs,getting-started.json`); + expect(response?.status()).toBe(200); + const data = await response?.json(); + expect(data.frontmatter.title).toBe('Getting Started'); + expect(data.prev).toBeDefined(); + expect(data.next).toBeDefined(); + }); + + test('search index JSON is accessible', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/data/search.json`); + expect(response?.status()).toBe(200); + const data = await response?.json(); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThan(0); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('url'); + expect(data[0]).toHaveProperty('type'); + }); + + test('sitemap.xml is generated', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/sitemap.xml`); + expect(response?.status()).toBe(200); + const text = await response?.text(); + expect(text).toContain(' { + const response = await page.goto(`${BASE_URL}/robots.txt`); + expect(response?.status()).toBe(200); + const text = await response?.text(); + expect(text).toContain('User-agent: *'); + expect(text).toContain('Allow: /'); + }); + + test('llms.txt is generated', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/llms.txt`); + expect(response?.status()).toBe(200); + const text = await response?.text(); + expect(text).toContain('My Documentation'); + }); + + test('public assets are copied (logo)', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/logo.svg`); + expect(response?.status()).toBe(200); + }); + + test('__STATIC_MODE__ is set in window', async ({ page }) => { + await page.goto(BASE_URL); + const staticMode = await page.evaluate(() => (window as any).__STATIC_MODE__); + expect(staticMode).toBe(true); + }); + + test('__PAGE_DATA__ contains config and tree', async ({ page }) => { + await page.goto(BASE_URL); + const pageData = await page.evaluate(() => (window as any).__PAGE_DATA__); + expect(pageData.config.site.title).toBe('My Documentation'); + expect(pageData.tree).toBeDefined(); + expect(pageData.tree.children.length).toBeGreaterThan(0); + }); + + test('404 handling for unknown routes', async ({ page }) => { + await page.goto(`${BASE_URL}/nonexistent-page`); + await page.waitForSelector('[data-theme]', { timeout: 10000 }); + await page.waitForTimeout(2000); + const body = await page.textContent('body'); + expect(body?.toLowerCase()).toContain('not found'); + }); +});