From 8f3ce31c4b4a0eb1ef89dfea271fa407b37d05c1 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 3 Jun 2026 13:20:22 +0530 Subject: [PATCH 1/5] docs: add static site mode design spec --- .../2026-06-03-static-site-mode-design.md | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-03-static-site-mode-design.md diff --git a/docs/superpowers/specs/2026-06-03-static-site-mode-design.md b/docs/superpowers/specs/2026-06-03-static-site-mode-design.md new file mode 100644 index 00000000..eb7d57ef --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-static-site-mode-design.md @@ -0,0 +1,153 @@ +# Static Site Mode for Chronicle + +## Overview + +A `--preset static` build mode that produces a fully self-contained SPA deployable to any static host (GitHub Pages, S3, Netlify, Cloudflare Pages). No server, no SSR, no database. + +## Build Pipeline + +``` +chronicle build --preset static + │ + ▼ +┌──────────────────────┐ +│ Phase 1: Vite Build │ ← builds client JS/CSS bundle +│ (client bundle only) │ +└──────────┬───────────┘ + ▼ +┌───────────────────────────┐ +│ Phase 2: Static Generate │ +│ │ +│ ├─ SPA index.html shell │ +│ ├─ /data/pages/*.json │ ← replaces /api/page +│ ├─ /data/search.json │ ← replaces /api/search +│ ├─ /data/specs/*.json │ ← replaces /api/specs +│ ├─ /sitemap.xml │ +│ ├─ /robots.txt │ +│ ├─ /llms.txt │ +│ ├─ /og/*.png │ ← pre-generated OG images +│ └─ /_content/** │ ← optimized images (webp) +└────────────────────────────┘ +``` + +## Component Changes + +### 1. Static generator — `src/cli/commands/static-generate.ts` (new) + +Runs after Vite client build. Loads config + content via `source.ts` / `config.ts`. Generates: + +- **Page metadata JSON**: One file per page at `data/pages/.json`. Keyed by comma-separated slug (same format as existing `/api/page?slug=` query param). Contains `{ frontmatter, relativePath, originalPath, images, prev, next }`. +- **Search index**: `data/search.json` — array of `{ id, url, title, headings, body, type, section }`. Same shape as `build-search-index.ts` but includes full body text. +- **API specs**: `data/specs/.json` — serialized `ApiSpec[]` per version. `data/specs/latest.json` for the default. +- **Static routes**: sitemap.xml, robots.txt, llms.txt — same logic as current route handlers, written to files. +- **OG images**: Per page, Satori SVG → Sharp PNG. Written to `og/.png`. +- **Image optimization**: Walk all page images, convert to webp via Sharp at default width/quality. + +### 2. SPA shell — `index.html` + +Minimal HTML that: +- Loads the Vite client bundle (JS + CSS) +- Embeds config and full page tree as `window.__PAGE_DATA__` (tree only, no per-page content) +- Sets `window.__STATIC_MODE__ = true` +- Client router resolves routes and fetches page JSON on navigation + +### 3. Client entry — `src/server/entry-static.tsx` (new) + +New entry point for static builds: +- Uses `createRoot` + `render` (no hydration, no `hydrateRoot`) +- On route change, fetches `/data/pages/.json` instead of `/api/page` +- Loads MDX modules from the bundle via `import.meta.glob` +- Embeds MiniSearch for search + +### 4. Page context — `src/lib/page-context.tsx` + +When `window.__STATIC_MODE__` is true: +- `fetchPageData` reads from `/data/pages/.json` +- `fetchApiSpecs` reads from `/data/specs/.json` +- No `/api/*` calls + +### 5. Search — `src/components/ui/search.tsx` + +When static mode: +- On first search dialog open, fetch `/data/search.json` and build MiniSearch index in memory +- Query locally, return same result shape +- Same UI, different data source + +### 6. API playground — `src/components/api/playground-dialog.tsx` + +When static mode: +- Construct full URL from `spec.server.url` + operation path +- Send request directly via `fetch()` with user's auth headers +- No proxy — browser talks to API server directly +- API server must have CORS configured for the docs origin +- Show helpful error message on CORS failures + +### 7. Build command — `src/cli/commands/build.ts` + +When `isStaticPreset(preset)`: +- Run Vite client build only (skip Nitro server build) +- Run static generation phase +- Output to `.output/public/` +- Skip database config, telemetry + +### 8. Vite config — `src/server/vite-config.ts` + +When static preset: +- Use `entry-static.tsx` as client entry +- Add MiniSearch to bundle dependencies +- Skip Nitro server build +- No database/storage config + +### 9. Image handling + +`remark-resolve-images` already disables `/api/image` rewriting for static presets — images stay as `/_content/...` paths. The static generator optimizes them with Sharp and writes to the output's `_content/` directory. + +## Output Structure + +``` +.output/public/ +├── index.html +├── assets/ (Vite bundle) +├── data/ +│ ├── pages/ +│ │ ├── docs,getting-started.json +│ │ └── ... +│ ├── search.json +│ └── specs/ +│ └── latest.json +├── _content/ (optimized images) +├── og/ +│ ├── docs,getting-started.png +│ └── ... +├── sitemap.xml +├── robots.txt +└── llms.txt +``` + +## What stays the same + +- MDX content bundled via `import.meta.glob` +- Route resolution (`route-resolver.ts`) — pure function +- Theme system, MDX components, page tree building +- All existing SSR/server behavior when not using static preset + +## API playground: proxy vs direct + +| Deployment | Playground behavior | +|------------|-------------------| +| Server (default, vercel, bun, etc.) | Requests go through `/api/apis-proxy` (existing) | +| Static (static, github-pages, etc.) | Browser sends requests directly to API server | + +## Verification + +Playwright tests against the `basic` example built with `--preset static`, served via a simple HTTP server: + +1. Page renders correct content +2. Client-side navigation (sidebar clicks) +3. Search returns results (client-side MiniSearch) +4. API docs page renders operations +5. API playground opens, shows direct-request mode +6. OG meta tags present with correct image paths +7. Images load (webp optimized) +8. 404 handling for unknown routes +9. Version switching (versioned example) From fe0aee422e28f206bf442a8c1c9e30dd7c5aafcc Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 3 Jun 2026 16:58:18 +0530 Subject: [PATCH 2/5] feat: add static site generation mode with --preset static --- bun.lock | 12 + packages/chronicle/package.json | 2 + packages/chronicle/playwright.config.ts | 12 + packages/chronicle/src/cli/commands/build.ts | 34 +- .../src/cli/commands/static-generate.ts | 1074 +++++++++++++++++ .../src/components/api/playground-dialog.tsx | 82 +- .../chronicle/src/components/ui/search.tsx | 134 +- packages/chronicle/src/lib/openapi.ts | 2 +- packages/chronicle/src/lib/page-context.tsx | 39 +- packages/chronicle/src/lib/preload.ts | 13 +- .../chronicle/src/server/entry-static.tsx | 159 +++ packages/chronicle/src/server/vite-config.ts | 29 +- .../tests/e2e/check-broken-assets.e2e.ts | 83 ++ .../tests/e2e/compare-static-ssr.e2e.ts | 121 ++ .../chronicle/tests/e2e/static-site.e2e.ts | 129 ++ 15 files changed, 1886 insertions(+), 39 deletions(-) create mode 100644 packages/chronicle/playwright.config.ts create mode 100644 packages/chronicle/src/cli/commands/static-generate.ts create mode 100644 packages/chronicle/src/server/entry-static.tsx create mode 100644 packages/chronicle/tests/e2e/check-broken-assets.e2e.ts create mode 100644 packages/chronicle/tests/e2e/compare-static-ssr.e2e.ts create mode 100644 packages/chronicle/tests/e2e/static-site.e2e.ts 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..0bf05f6b --- /dev/null +++ b/packages/chronicle/src/cli/commands/static-generate.ts @@ -0,0 +1,1074 @@ +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 => { + const lastmod = page.frontmatter.lastModified + ? `${new Date(page.frontmatter.lastModified).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', + ); + 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\//, ''); + + if (isSvg(imgUrl)) { + // Copy SVGs as-is + const srcPath = path.resolve(contentDir, relativePath); + 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; + } + + // Convert raster images to webp + const srcPath = path.resolve(contentDir, relativePath); + 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; + } + } + } + + 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/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index 5830f7e4..e96078d8 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.startsWith(`/${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.startsWith(`/${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..2292c1b2 --- /dev/null +++ b/packages/chronicle/tests/e2e/check-broken-assets.e2e.ts @@ -0,0 +1,83 @@ +import { test, expect } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; + +const STATIC_URL = 'http://localhost:4174'; +const pagesDir = '/Users/rohil/Projects/github.com/pixxelhq/documentation/.output/public/data/pages'; + +const pageFiles = fs.readdirSync(pagesDir).filter(f => f.endsWith('.json')); +const pageUrls = pageFiles.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 broken: { url: string; images: string[] }[] = []; + const failedRequests: { url: string; resource: string; status: number }[] = []; + + page.on('requestfailed', req => { + const resourceUrl = req.url(); + if (resourceUrl.includes('/favicon')) return; + failedRequests.push({ + url: page.url(), + resource: resourceUrl, + status: 0, + }); + }); + + 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 }); + + const report = { + totalPages: pageUrls.length, + pagesWithBrokenImages: broken.length, + totalBrokenImages: broken.reduce((sum, b) => sum + b.images.length, 0), + failedRequests: failedRequests.length, + broken, + failedRequestDetails: failedRequests.slice(0, 50), + }; + + fs.writeFileSync( + path.join(outDir, 'broken-assets-report.json'), + JSON.stringify(report, null, 2), + ); + + console.log(`Checked ${pageUrls.length} pages`); + console.log(`Pages with broken images: ${broken.length}`); + console.log(`Total broken images: ${report.totalBrokenImages}`); + console.log(`Failed network requests: ${failedRequests.length}`); + + if (broken.length > 0) { + console.log('\nBroken images:'); + for (const b of broken) { + console.log(` ${b.url}:`); + for (const img of b.images) { + console.log(` - ${img}`); + } + } + } + + if (failedRequests.length > 0) { + console.log('\nFailed requests:'); + for (const f of failedRequests.slice(0, 20)) { + console.log(` ${f.url} -> ${f.resource}`); + } + } +}); diff --git a/packages/chronicle/tests/e2e/compare-static-ssr.e2e.ts b/packages/chronicle/tests/e2e/compare-static-ssr.e2e.ts new file mode 100644 index 00000000..eb682cda --- /dev/null +++ b/packages/chronicle/tests/e2e/compare-static-ssr.e2e.ts @@ -0,0 +1,121 @@ +import { test, expect } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; + +const STATIC_URL = 'http://localhost:4174'; +const SSR_URL = 'http://localhost:3005'; + +const PAGES_TO_COMPARE = [ + { name: 'docs-overview', path: '/documentation/satellite_and_imagery/pixxel_overview' }, + { name: 'docs-getting-started', path: '/documentation/getting_started/pricingandrefunds/pricingoverview' }, + { name: 'developer-intro', path: '/developer/gettingstarted/introduction' }, + { name: 'developer-auth', path: '/developer/gettingstarted/authentication' }, + { name: 'api-ping', path: '/apis/pixxel/get_ping' }, + { name: 'api-list-projects', path: '/apis/pixxel/list_projects' }, + { name: 'api-search-images', path: '/apis/pixxel/search_satellite_images' }, +]; + +const outDir = path.resolve(import.meta.dirname, '../../test-results/compare'); + +test.beforeAll(() => { + fs.mkdirSync(outDir, { recursive: true }); +}); + +for (const pg of PAGES_TO_COMPARE) { + test(`compare: ${pg.name}`, async ({ browser }) => { + const ctxSSR = await browser.newContext(); + const ctxStatic = await browser.newContext(); + const pageSSR = await ctxSSR.newPage(); + const pageStatic = await ctxStatic.newPage(); + + await Promise.all([ + pageSSR.goto(`${SSR_URL}${pg.path}`, { waitUntil: 'networkidle' }), + pageStatic.goto(`${STATIC_URL}${pg.path}`, { waitUntil: 'networkidle' }), + ]); + + await Promise.all([ + pageSSR.waitForTimeout(3000), + pageStatic.waitForTimeout(3000), + ]); + + const ssrSnap = await pageSSR.evaluate(() => { + function extractContent(el: Element): any { + const result: any = { tag: el.tagName.toLowerCase() }; + const text = el.childNodes.length === 1 && el.childNodes[0].nodeType === 3 + ? el.childNodes[0].textContent?.trim() : null; + if (text) result.text = text; + + const classes = el.className; + if (classes && typeof classes === 'string') { + const meaningful = classes.split(' ').filter(c => + !c.match(/^_[a-zA-Z]+_[a-z0-9]+_/) && !c.match(/^[a-zA-Z]+-[a-zA-Z0-9]{6,}/) + ); + if (meaningful.length) result.class = meaningful.join(' '); + } + + if (el.tagName === 'A') result.href = (el as HTMLAnchorElement).getAttribute('href'); + if (el.tagName === 'IMG') result.src = (el as HTMLImageElement).getAttribute('src'); + + const children: any[] = []; + for (const child of el.children) { + children.push(extractContent(child)); + } + if (children.length) result.children = children; + return result; + } + + const main = document.querySelector('article') || document.querySelector('[data-article-content]') || document.querySelector('main') || document.querySelector('#root'); + return main ? extractContent(main) : null; + }); + + const staticSnap = await pageStatic.evaluate(() => { + function extractContent(el: Element): any { + const result: any = { tag: el.tagName.toLowerCase() }; + const text = el.childNodes.length === 1 && el.childNodes[0].nodeType === 3 + ? el.childNodes[0].textContent?.trim() : null; + if (text) result.text = text; + + const classes = el.className; + if (classes && typeof classes === 'string') { + const meaningful = classes.split(' ').filter(c => + !c.match(/^_[a-zA-Z]+_[a-z0-9]+_/) && !c.match(/^[a-zA-Z]+-[a-zA-Z0-9]{6,}/) + ); + if (meaningful.length) result.class = meaningful.join(' '); + } + + if (el.tagName === 'A') result.href = (el as HTMLAnchorElement).getAttribute('href'); + if (el.tagName === 'IMG') result.src = (el as HTMLImageElement).getAttribute('src'); + + const children: any[] = []; + for (const child of el.children) { + children.push(extractContent(child)); + } + if (children.length) result.children = children; + return result; + } + + const main = document.querySelector('article') || document.querySelector('[data-article-content]') || document.querySelector('main') || document.querySelector('#root'); + return main ? extractContent(main) : null; + }); + + fs.writeFileSync( + path.join(outDir, `${pg.name}-ssr.json`), + JSON.stringify(ssrSnap, null, 2) + ); + fs.writeFileSync( + path.join(outDir, `${pg.name}-static.json`), + JSON.stringify(staticSnap, null, 2) + ); + + // Take screenshots too + await pageSSR.screenshot({ path: path.join(outDir, `${pg.name}-ssr.png`), fullPage: true }); + await pageStatic.screenshot({ path: path.join(outDir, `${pg.name}-static.png`), fullPage: true }); + + await ctxSSR.close(); + await ctxStatic.close(); + + // Both should have content + expect(ssrSnap).not.toBeNull(); + expect(staticSnap).not.toBeNull(); + }); +} 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..0d87d90c --- /dev/null +++ b/packages/chronicle/tests/e2e/static-site.e2e.ts @@ -0,0 +1,129 @@ +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(); + if (await installationLink.isVisible()) { + 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+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('Meta+k'); + await page.waitForTimeout(500); + + const searchInput = page.locator('input[placeholder="Search"]').first(); + if (await searchInput.isVisible()) { + 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'); + }); +}); From 9d53a97ea4699acef7a0ac685ae81db13a1f8a71 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 3 Jun 2026 17:03:17 +0530 Subject: [PATCH 3/5] chore: remove design spec from tracked files --- .../2026-06-03-static-site-mode-design.md | 153 ------------------ 1 file changed, 153 deletions(-) delete mode 100644 docs/superpowers/specs/2026-06-03-static-site-mode-design.md diff --git a/docs/superpowers/specs/2026-06-03-static-site-mode-design.md b/docs/superpowers/specs/2026-06-03-static-site-mode-design.md deleted file mode 100644 index eb7d57ef..00000000 --- a/docs/superpowers/specs/2026-06-03-static-site-mode-design.md +++ /dev/null @@ -1,153 +0,0 @@ -# Static Site Mode for Chronicle - -## Overview - -A `--preset static` build mode that produces a fully self-contained SPA deployable to any static host (GitHub Pages, S3, Netlify, Cloudflare Pages). No server, no SSR, no database. - -## Build Pipeline - -``` -chronicle build --preset static - │ - ▼ -┌──────────────────────┐ -│ Phase 1: Vite Build │ ← builds client JS/CSS bundle -│ (client bundle only) │ -└──────────┬───────────┘ - ▼ -┌───────────────────────────┐ -│ Phase 2: Static Generate │ -│ │ -│ ├─ SPA index.html shell │ -│ ├─ /data/pages/*.json │ ← replaces /api/page -│ ├─ /data/search.json │ ← replaces /api/search -│ ├─ /data/specs/*.json │ ← replaces /api/specs -│ ├─ /sitemap.xml │ -│ ├─ /robots.txt │ -│ ├─ /llms.txt │ -│ ├─ /og/*.png │ ← pre-generated OG images -│ └─ /_content/** │ ← optimized images (webp) -└────────────────────────────┘ -``` - -## Component Changes - -### 1. Static generator — `src/cli/commands/static-generate.ts` (new) - -Runs after Vite client build. Loads config + content via `source.ts` / `config.ts`. Generates: - -- **Page metadata JSON**: One file per page at `data/pages/.json`. Keyed by comma-separated slug (same format as existing `/api/page?slug=` query param). Contains `{ frontmatter, relativePath, originalPath, images, prev, next }`. -- **Search index**: `data/search.json` — array of `{ id, url, title, headings, body, type, section }`. Same shape as `build-search-index.ts` but includes full body text. -- **API specs**: `data/specs/.json` — serialized `ApiSpec[]` per version. `data/specs/latest.json` for the default. -- **Static routes**: sitemap.xml, robots.txt, llms.txt — same logic as current route handlers, written to files. -- **OG images**: Per page, Satori SVG → Sharp PNG. Written to `og/.png`. -- **Image optimization**: Walk all page images, convert to webp via Sharp at default width/quality. - -### 2. SPA shell — `index.html` - -Minimal HTML that: -- Loads the Vite client bundle (JS + CSS) -- Embeds config and full page tree as `window.__PAGE_DATA__` (tree only, no per-page content) -- Sets `window.__STATIC_MODE__ = true` -- Client router resolves routes and fetches page JSON on navigation - -### 3. Client entry — `src/server/entry-static.tsx` (new) - -New entry point for static builds: -- Uses `createRoot` + `render` (no hydration, no `hydrateRoot`) -- On route change, fetches `/data/pages/.json` instead of `/api/page` -- Loads MDX modules from the bundle via `import.meta.glob` -- Embeds MiniSearch for search - -### 4. Page context — `src/lib/page-context.tsx` - -When `window.__STATIC_MODE__` is true: -- `fetchPageData` reads from `/data/pages/.json` -- `fetchApiSpecs` reads from `/data/specs/.json` -- No `/api/*` calls - -### 5. Search — `src/components/ui/search.tsx` - -When static mode: -- On first search dialog open, fetch `/data/search.json` and build MiniSearch index in memory -- Query locally, return same result shape -- Same UI, different data source - -### 6. API playground — `src/components/api/playground-dialog.tsx` - -When static mode: -- Construct full URL from `spec.server.url` + operation path -- Send request directly via `fetch()` with user's auth headers -- No proxy — browser talks to API server directly -- API server must have CORS configured for the docs origin -- Show helpful error message on CORS failures - -### 7. Build command — `src/cli/commands/build.ts` - -When `isStaticPreset(preset)`: -- Run Vite client build only (skip Nitro server build) -- Run static generation phase -- Output to `.output/public/` -- Skip database config, telemetry - -### 8. Vite config — `src/server/vite-config.ts` - -When static preset: -- Use `entry-static.tsx` as client entry -- Add MiniSearch to bundle dependencies -- Skip Nitro server build -- No database/storage config - -### 9. Image handling - -`remark-resolve-images` already disables `/api/image` rewriting for static presets — images stay as `/_content/...` paths. The static generator optimizes them with Sharp and writes to the output's `_content/` directory. - -## Output Structure - -``` -.output/public/ -├── index.html -├── assets/ (Vite bundle) -├── data/ -│ ├── pages/ -│ │ ├── docs,getting-started.json -│ │ └── ... -│ ├── search.json -│ └── specs/ -│ └── latest.json -├── _content/ (optimized images) -├── og/ -│ ├── docs,getting-started.png -│ └── ... -├── sitemap.xml -├── robots.txt -└── llms.txt -``` - -## What stays the same - -- MDX content bundled via `import.meta.glob` -- Route resolution (`route-resolver.ts`) — pure function -- Theme system, MDX components, page tree building -- All existing SSR/server behavior when not using static preset - -## API playground: proxy vs direct - -| Deployment | Playground behavior | -|------------|-------------------| -| Server (default, vercel, bun, etc.) | Requests go through `/api/apis-proxy` (existing) | -| Static (static, github-pages, etc.) | Browser sends requests directly to API server | - -## Verification - -Playwright tests against the `basic` example built with `--preset static`, served via a simple HTTP server: - -1. Page renders correct content -2. Client-side navigation (sidebar clicks) -3. Search returns results (client-side MiniSearch) -4. API docs page renders operations -5. API playground opens, shows direct-request mode -6. OG meta tags present with correct image paths -7. Images load (webp optimized) -8. 404 handling for unknown routes -9. Version switching (versioned example) From be08bd638498613813413f91c579d3073b12be41 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 3 Jun 2026 22:56:25 +0530 Subject: [PATCH 4/5] fix: address review feedback for static site mode --- .../src/cli/commands/static-generate.ts | 19 ++- .../chronicle/src/components/ui/search.tsx | 4 +- .../tests/e2e/check-broken-assets.e2e.ts | 71 ++++------ .../tests/e2e/compare-static-ssr.e2e.ts | 121 ------------------ .../chronicle/tests/e2e/static-site.e2e.ts | 28 ++-- 5 files changed, 51 insertions(+), 192 deletions(-) delete mode 100644 packages/chronicle/tests/e2e/compare-static-ssr.e2e.ts diff --git a/packages/chronicle/src/cli/commands/static-generate.ts b/packages/chronicle/src/cli/commands/static-generate.ts index 0bf05f6b..7c916d4f 100644 --- a/packages/chronicle/src/cli/commands/static-generate.ts +++ b/packages/chronicle/src/cli/commands/static-generate.ts @@ -541,9 +541,11 @@ async function generateSitemap( const baseUrl = config.url.replace(/\/$/, ''); const docPages = pages.map(page => { - const lastmod = page.frontmatter.lastModified - ? `${new Date(page.frontmatter.lastModified).toISOString()}` - : ''; + 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}`; }); @@ -618,6 +620,7 @@ async function generateOgImages( try { const response = await fetch( 'https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hiA.woff2', + { signal: AbortSignal.timeout(10000) }, ); fontData = await response.arrayBuffer(); } catch { @@ -694,10 +697,10 @@ async function optimizeImages( 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)) { - // Copy SVGs as-is - const srcPath = path.resolve(contentDir, relativePath); const destPath = path.join(outputDir, '_content', relativePath); try { await fs.mkdir(path.dirname(destPath), { recursive: true }); @@ -709,8 +712,6 @@ async function optimizeImages( continue; } - // Convert raster images to webp - const srcPath = path.resolve(contentDir, relativePath); const webpRelative = relativePath.replace(/\.[^.]+$/, '.webp'); const destPath = path.join(outputDir, '_content', webpRelative); @@ -831,6 +832,10 @@ async function generateSpaIndex( } } + 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, diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index e96078d8..f1d9699f 100644 --- a/packages/chronicle/src/components/ui/search.tsx +++ b/packages/chronicle/src/components/ui/search.tsx @@ -116,7 +116,7 @@ async function searchStatic(query: string, tag?: string): Promise d.type === 'page'); - if (tag) docs = docs.filter(d => d.url.startsWith(`/${tag}/`) || d.url.startsWith(`/${tag}`)); + 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, @@ -130,7 +130,7 @@ async function searchStatic(query: string, tag?: string): Promise { const url = r.url as string; - return url.startsWith(`/${tag}/`) || url.startsWith(`/${tag}`); + return url === `/${tag}` || url.startsWith(`/${tag}/`); }); } diff --git a/packages/chronicle/tests/e2e/check-broken-assets.e2e.ts b/packages/chronicle/tests/e2e/check-broken-assets.e2e.ts index 2292c1b2..4b838bea 100644 --- a/packages/chronicle/tests/e2e/check-broken-assets.e2e.ts +++ b/packages/chronicle/tests/e2e/check-broken-assets.e2e.ts @@ -2,28 +2,35 @@ import { test, expect } from '@playwright/test'; import fs from 'node:fs'; import path from 'node:path'; -const STATIC_URL = 'http://localhost:4174'; -const pagesDir = '/Users/rohil/Projects/github.com/pixxelhq/documentation/.output/public/data/pages'; - -const pageFiles = fs.readdirSync(pagesDir).filter(f => f.endsWith('.json')); -const pageUrls = pageFiles.map(f => { - const slug = f.replace('.json', ''); - if (slug === 'index') return '/'; - return '/' + slug.split(',').join('/'); -}); +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: { url: string; resource: string; status: number }[] = []; + const failedRequests: { pageUrl: string; resource: string }[] = []; page.on('requestfailed', req => { const resourceUrl = req.url(); if (resourceUrl.includes('/favicon')) return; - failedRequests.push({ - url: page.url(), - resource: resourceUrl, - status: 0, - }); + if (resourceUrl.includes('google-analytics')) return; + failedRequests.push({ pageUrl: page.url(), resource: resourceUrl }); }); for (const pageUrl of pageUrls) { @@ -44,40 +51,10 @@ test('check all pages for broken images and assets', async ({ page }) => { const outDir = path.resolve(import.meta.dirname, '../../test-results'); fs.mkdirSync(outDir, { recursive: true }); - - const report = { - totalPages: pageUrls.length, - pagesWithBrokenImages: broken.length, - totalBrokenImages: broken.reduce((sum, b) => sum + b.images.length, 0), - failedRequests: failedRequests.length, - broken, - failedRequestDetails: failedRequests.slice(0, 50), - }; - fs.writeFileSync( path.join(outDir, 'broken-assets-report.json'), - JSON.stringify(report, null, 2), + JSON.stringify({ totalPages: pageUrls.length, broken, failedRequests }, null, 2), ); - console.log(`Checked ${pageUrls.length} pages`); - console.log(`Pages with broken images: ${broken.length}`); - console.log(`Total broken images: ${report.totalBrokenImages}`); - console.log(`Failed network requests: ${failedRequests.length}`); - - if (broken.length > 0) { - console.log('\nBroken images:'); - for (const b of broken) { - console.log(` ${b.url}:`); - for (const img of b.images) { - console.log(` - ${img}`); - } - } - } - - if (failedRequests.length > 0) { - console.log('\nFailed requests:'); - for (const f of failedRequests.slice(0, 20)) { - console.log(` ${f.url} -> ${f.resource}`); - } - } + expect(broken, `Broken images found on ${broken.length} pages`).toHaveLength(0); }); diff --git a/packages/chronicle/tests/e2e/compare-static-ssr.e2e.ts b/packages/chronicle/tests/e2e/compare-static-ssr.e2e.ts deleted file mode 100644 index eb682cda..00000000 --- a/packages/chronicle/tests/e2e/compare-static-ssr.e2e.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { test, expect } from '@playwright/test'; -import fs from 'node:fs'; -import path from 'node:path'; - -const STATIC_URL = 'http://localhost:4174'; -const SSR_URL = 'http://localhost:3005'; - -const PAGES_TO_COMPARE = [ - { name: 'docs-overview', path: '/documentation/satellite_and_imagery/pixxel_overview' }, - { name: 'docs-getting-started', path: '/documentation/getting_started/pricingandrefunds/pricingoverview' }, - { name: 'developer-intro', path: '/developer/gettingstarted/introduction' }, - { name: 'developer-auth', path: '/developer/gettingstarted/authentication' }, - { name: 'api-ping', path: '/apis/pixxel/get_ping' }, - { name: 'api-list-projects', path: '/apis/pixxel/list_projects' }, - { name: 'api-search-images', path: '/apis/pixxel/search_satellite_images' }, -]; - -const outDir = path.resolve(import.meta.dirname, '../../test-results/compare'); - -test.beforeAll(() => { - fs.mkdirSync(outDir, { recursive: true }); -}); - -for (const pg of PAGES_TO_COMPARE) { - test(`compare: ${pg.name}`, async ({ browser }) => { - const ctxSSR = await browser.newContext(); - const ctxStatic = await browser.newContext(); - const pageSSR = await ctxSSR.newPage(); - const pageStatic = await ctxStatic.newPage(); - - await Promise.all([ - pageSSR.goto(`${SSR_URL}${pg.path}`, { waitUntil: 'networkidle' }), - pageStatic.goto(`${STATIC_URL}${pg.path}`, { waitUntil: 'networkidle' }), - ]); - - await Promise.all([ - pageSSR.waitForTimeout(3000), - pageStatic.waitForTimeout(3000), - ]); - - const ssrSnap = await pageSSR.evaluate(() => { - function extractContent(el: Element): any { - const result: any = { tag: el.tagName.toLowerCase() }; - const text = el.childNodes.length === 1 && el.childNodes[0].nodeType === 3 - ? el.childNodes[0].textContent?.trim() : null; - if (text) result.text = text; - - const classes = el.className; - if (classes && typeof classes === 'string') { - const meaningful = classes.split(' ').filter(c => - !c.match(/^_[a-zA-Z]+_[a-z0-9]+_/) && !c.match(/^[a-zA-Z]+-[a-zA-Z0-9]{6,}/) - ); - if (meaningful.length) result.class = meaningful.join(' '); - } - - if (el.tagName === 'A') result.href = (el as HTMLAnchorElement).getAttribute('href'); - if (el.tagName === 'IMG') result.src = (el as HTMLImageElement).getAttribute('src'); - - const children: any[] = []; - for (const child of el.children) { - children.push(extractContent(child)); - } - if (children.length) result.children = children; - return result; - } - - const main = document.querySelector('article') || document.querySelector('[data-article-content]') || document.querySelector('main') || document.querySelector('#root'); - return main ? extractContent(main) : null; - }); - - const staticSnap = await pageStatic.evaluate(() => { - function extractContent(el: Element): any { - const result: any = { tag: el.tagName.toLowerCase() }; - const text = el.childNodes.length === 1 && el.childNodes[0].nodeType === 3 - ? el.childNodes[0].textContent?.trim() : null; - if (text) result.text = text; - - const classes = el.className; - if (classes && typeof classes === 'string') { - const meaningful = classes.split(' ').filter(c => - !c.match(/^_[a-zA-Z]+_[a-z0-9]+_/) && !c.match(/^[a-zA-Z]+-[a-zA-Z0-9]{6,}/) - ); - if (meaningful.length) result.class = meaningful.join(' '); - } - - if (el.tagName === 'A') result.href = (el as HTMLAnchorElement).getAttribute('href'); - if (el.tagName === 'IMG') result.src = (el as HTMLImageElement).getAttribute('src'); - - const children: any[] = []; - for (const child of el.children) { - children.push(extractContent(child)); - } - if (children.length) result.children = children; - return result; - } - - const main = document.querySelector('article') || document.querySelector('[data-article-content]') || document.querySelector('main') || document.querySelector('#root'); - return main ? extractContent(main) : null; - }); - - fs.writeFileSync( - path.join(outDir, `${pg.name}-ssr.json`), - JSON.stringify(ssrSnap, null, 2) - ); - fs.writeFileSync( - path.join(outDir, `${pg.name}-static.json`), - JSON.stringify(staticSnap, null, 2) - ); - - // Take screenshots too - await pageSSR.screenshot({ path: path.join(outDir, `${pg.name}-ssr.png`), fullPage: true }); - await pageStatic.screenshot({ path: path.join(outDir, `${pg.name}-static.png`), fullPage: true }); - - await ctxSSR.close(); - await ctxStatic.close(); - - // Both should have content - expect(ssrSnap).not.toBeNull(); - expect(staticSnap).not.toBeNull(); - }); -} diff --git a/packages/chronicle/tests/e2e/static-site.e2e.ts b/packages/chronicle/tests/e2e/static-site.e2e.ts index 0d87d90c..cf784143 100644 --- a/packages/chronicle/tests/e2e/static-site.e2e.ts +++ b/packages/chronicle/tests/e2e/static-site.e2e.ts @@ -31,30 +31,28 @@ test.describe('Static site mode', () => { await page.waitForTimeout(2000); const installationLink = page.locator('a[href="/docs/guides/installation"]').first(); - if (await installationLink.isVisible()) { - await installationLink.click(); - await page.waitForTimeout(2000); - expect(page.url()).toContain('/docs/guides/installation'); - const body = await page.textContent('body'); - expect(body).toContain('Installation'); - } + 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+K', async ({ page }) => { + 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('Meta+k'); + await page.keyboard.press('ControlOrMeta+k'); await page.waitForTimeout(500); const searchInput = page.locator('input[placeholder="Search"]').first(); - if (await searchInput.isVisible()) { - await searchInput.fill('install'); - await page.waitForTimeout(1000); - const results = await page.textContent('body'); - expect(results).toContain('Install'); - } + 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 }) => { From 075cd86b592cd3f06ebc300b0d17c788157363e1 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Thu, 4 Jun 2026 01:34:23 +0530 Subject: [PATCH 5/5] perf: add picture tag with webp source for static mode and lazy-load mermaid --- .../chronicle/src/components/mdx/code.tsx | 11 ++++++++--- .../chronicle/src/components/mdx/image.tsx | 19 ++++++++++++++++++- .../chronicle/src/components/mdx/index.tsx | 11 ++++++++--- 3 files changed, 34 insertions(+), 7 deletions(-) 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'