diff --git a/.env.example b/.env.example index d20c34b..18016ff 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,8 @@ VITE_SITE_NAME="Capsa" # VITE_DEFAULT_THEME_STYLE="steel" # ── SEO ───────────────────────────────────────────────────────────────────── -# Base URL — enables sitemap.xml and absolute URLs in llms.txt. +# Base URL — enables sitemap.xml, canonical/og:url tags, per-page og:image +# social cards, and absolute URLs in llms.txt. # VITE_SITE_URL="https://docs.example.com" # ── Footer links (each renders only if set) ───────────────────────────────── diff --git a/.gitignore b/.gitignore index f6c212c..dbda677 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ public/md/ # Playwright test-results/ playwright-report/ + +# SSR prerender bundle (intermediate build output) +dist-ssr/ diff --git a/README.md b/README.md index fece128..8180cd6 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,10 @@ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE) [![Made with Vite](https://img.shields.io/badge/Vite-React_19-646cff.svg)](https://vitejs.dev) -Capsa renders MDX into a polished docs site — command palette, OpenAPI reference, -theming, AI-friendly exports — and deploys to Cloudflare Pages (or any static -host) for free. It's a Vite + React + Tamagui app you fully own and can edit. +Capsa renders MDX into a polished docs site — prerendered static HTML, command +palette search, OpenAPI reference, theming, social cards, AI-friendly exports — +and deploys to Cloudflare Pages (or any static host) for free. It's a Vite + +React + Tamagui app you fully own and can edit. **[Live demo](https://capsa.romans.dev)** · **[Documentation](https://capsa.romans.dev/docs)** @@ -24,13 +25,24 @@ host) for free. It's a Vite + React + Tamagui app you fully own and can edit. ## Features - **MDX content** — Markdown with React components when you need them. -- **⌘K command palette** — keyboard-first search over a build-time index. -- **OpenAPI reference** — an interactive API explorer (Scalar) from your spec. -- **Theming** — five styles × light/dark, token-driven and brandable. +- **Prerendered (SSG)** — every doc ships as real static HTML with the full + article and crawlable nav links, then hydrates into the SPA. SEO-complete + out of the box: titles, descriptions, OpenGraph, canonical URLs, sitemap. +- **⌘K command palette** — keyboard-first search powered by + [Pagefind](https://pagefind.app) over the prerendered pages (typo-tolerant, + fully static, no search service), with a build-time JSON index as fallback. +- **Social cards** — a branded 1200×630 `og:image` PNG is generated per page + at build time, so shared links unfurl with real previews. +- **OpenAPI reference** — an interactive API explorer (Scalar) from your spec, + fully themed to match the site. +- **Theming** — four styles × light/dark, token-driven and brandable, with + zero-flash persistence. - **AI-native** — generates `llms.txt`, per-page "Copy as Markdown", and "Open in ChatGPT/Claude". - **Authoring components** — callouts, tabbed code samples, API method badges, see-also cards. - **Multi-deploy** — env-driven, so one codebase powers many branded docs sites. - **Public by default** — auth is opt-in; a fresh clone runs with zero config. + (Auth-gated deploys automatically skip prerendering so gated content never + lands in static HTML.) ## Quickstart @@ -47,11 +59,26 @@ in `src/navigation.ts`. See the live docs (this repo _is_ a Capsa site): ## Build ```bash -pnpm build # output → dist +pnpm build # SSR bundle → prerendered client build → dist ``` -The build also emits the search index, sitemap, `llms.txt`, and per-page Markdown -into `public/`. +One command runs the whole pipeline: an SSR bundle is built first, then the +client build prerenders every doc route into static HTML (with per-page head +tags, social-card PNGs, and the Pagefind search index in `dist/`). The search +index JSON, sitemap, `llms.txt`, and per-page Markdown land in `public/`. + +Set `PRERENDER=0` to skip prerendering and ship the plain SPA with head-only +per-route pages (the previous behavior — also the automatic fallback if any +prerender step fails). + +## Test + +```bash +pnpm test:e2e # Playwright smoke tests against the production build +``` + +Covers theming, Scalar integration, prerender/hydration integrity, search, +and social-card output. Uses your installed Chrome — no browser download. ## Deploy @@ -69,19 +96,20 @@ Full guide (Wrangler, custom domain, Docker self-host) is in the docs under All optional, all env vars — a bare deploy needs none: -| Variable | Effect | -| -------------------------- | ------------------------------------------------- | -| `VITE_SITE_NAME` | Brand name (default `Capsa`) | -| `VITE_DEFAULT_THEME_STYLE` | Pin a theme + hide the switcher | -| `VITE_SITE_URL` | Enables `sitemap.xml` + absolute `llms.txt` links | -| `VITE_POSTHOG_KEY` | Enables analytics (off by default) | -| `VITE_WORKOS_CLIENT_ID` | Opt into auth (public by default) | +| Variable | Effect | +| -------------------------- | ----------------------------------------------------------------- | +| `VITE_SITE_NAME` | Brand name (default `Capsa`) | +| `VITE_DEFAULT_THEME_STYLE` | Pin a theme + hide the switcher | +| `VITE_SITE_URL` | Enables `sitemap.xml`, canonical + `og:image` tags, absolute URLs | +| `VITE_POSTHOG_KEY` | Enables analytics (off by default) | +| `VITE_WORKOS_CLIENT_ID` | Opt into auth (public by default; disables prerendering) | +| `PRERENDER=0` | Build-time: skip SSG, ship the plain SPA | See **Configuration** in the docs for the full list. ## Stack -Vite · React 19 · TypeScript · Tamagui · MDX · Scalar (OpenAPI). +Vite · React 19 · TypeScript · Tamagui · MDX · Scalar (OpenAPI) · Pagefind (search) · Satori (social cards). ## License diff --git a/e2e/prerender.spec.ts b/e2e/prerender.spec.ts new file mode 100644 index 0000000..197a52a --- /dev/null +++ b/e2e/prerender.spec.ts @@ -0,0 +1,171 @@ +import { test, expect, type Page } from '@playwright/test'; + +// Prerendering (SSG) integrity. Three things must stay true: +// 1. Doc routes ship real body content in the static HTML (what no-JS +// crawlers and bots see) with exactly one set of head tags. +// 2. The browser HYDRATES that HTML — reusing the DOM, not discarding it — +// with zero React hydration errors, in light AND dark mode (dark once +// regressed via Tamagui's inverse-theme wrapper). +// 3. The Scalar route stays a client-rendered shell. + +async function seedTheme(page: Page, mode: 'light' | 'dark', style: string) { + await page.addInitScript( + ([m, s]) => { + localStorage.setItem('capsa-theme-mode', m); + localStorage.setItem('capsa-theme-style', s); + }, + [mode, style], + ); +} + +// Stamp the first prerendered

as it streams in, before any JS runs. If +// hydration succeeds React adopts that exact node; if it fails, React replaces +// the DOM and the stamp is lost. +async function installHydrationProbe(page: Page) { + await page.addInitScript(() => { + const w = window as unknown as { __stampedH1?: Element }; + const observer = new MutationObserver(() => { + const h1 = document.querySelector('#root h1'); + if (h1 && !w.__stampedH1) { + w.__stampedH1 = h1; + observer.disconnect(); + } + }); + // Init scripts run at document-start, before documentElement exists — + // `document` itself is the only observable node this early. + observer.observe(document, { childList: true, subtree: true }); + }); +} + +function collectHydrationErrors(page: Page): string[] { + const errors: string[] = []; + const record = (text: string) => { + if (/hydrat/i.test(text) || /Minified React error #4(18|23|25)/.test(text)) { + errors.push(text); + } + }; + page.on('console', (msg) => record(msg.text())); + // React 19 surfaces hydration mismatches as uncaught page errors (#418). + page.on('pageerror', (err) => record(String(err))); + return errors; +} + +async function expectHydrated(page: Page, path: string) { + const errors = collectHydrationErrors(page); + await installHydrationProbe(page); + await page.goto(path, { waitUntil: 'networkidle' }); + const stampSurvived = await page.evaluate(() => { + const w = window as unknown as { __stampedH1?: Element }; + return !!w.__stampedH1 && document.contains(w.__stampedH1); + }); + expect(stampSurvived, 'prerendered DOM must be reused by hydration').toBe(true); + expect(errors, 'no React hydration errors on the console').toEqual([]); +} + +test('doc routes ship real body content for no-JS crawlers', async ({ request }) => { + const res = await request.get('/docs/guides/theming'); + expect(res.ok()).toBe(true); + const html = await res.text(); + + // Body content inside the root div — what a crawler without JS indexes. + const root = html.split('
')[1] ?? ''; + expect(root).toContain('four theme styles'); + expect(root).toContain('Quickstart'); // sidebar rendered too + // Nav renders as real anchors — crawlers can follow links between pages. + expect(root).toContain('href="/docs/getting-started/quickstart"'); + + // Exactly one set of head tags (helmet-duplicate strip regression). + expect(html.match(//g)).toHaveLength(1); + expect(html.match(/property="og:title"/g)).toHaveLength(1); + expect(html).toContain('<title>Theming — Capsa'); + + // Hydration payloads present. + expect(html).toContain('__CAPSA_MANIFEST__'); + expect(html).toContain('id="capsa-theme-vars"'); +}); + +test('docs index route is prerendered with category cards', async ({ request }) => { + const html = await (await request.get('/docs/')).text(); + const root = html.split('
')[1] ?? ''; + expect(root).toContain('Getting Started'); +}); + +test('prerendered page hydrates without discarding DOM (light)', async ({ page }) => { + await seedTheme(page, 'light', 'steel'); + await expectHydrated(page, '/docs/guides/theming/'); +}); + +test('prerendered page hydrates without discarding DOM (dark)', async ({ page }) => { + // Dark mode once caused a structural mismatch via Tamagui's inverse-theme + // wrapper — this is the regression test for the style-only fix. + await seedTheme(page, 'dark', 'steel'); + await expectHydrated(page, '/docs/guides/theming/'); +}); + +test('hydrated page is interactive (command palette opens)', async ({ page }) => { + await page.goto('/docs/guides/theming/', { waitUntil: 'networkidle' }); + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k'); + await expect(page.locator('.sid-cmdk-panel')).toBeVisible(); +}); + +test('pagefind index is emitted and scoped to article bodies', async ({ page, request }) => { + const res = await request.get('/pagefind/pagefind.js'); + expect(res.ok()).toBe(true); + + await page.goto('/docs/'); + const hits = await page.evaluate(async () => { + const pf = await import(/* @vite-ignore */ '/pagefind/pagefind.js'); + await pf.init?.(); + const { results } = await pf.search('theming'); + const pages = await Promise.all(results.map((r: { data: () => Promise<{ url: string }> }) => r.data())); + return pages.map((p) => p.url); + }); + expect(hits).toContain('/docs/guides/theming/'); + // "Theming" appears in every page's SIDEBAR — if body scoping broke, every + // doc page would match. Several pages mention theming in prose, so only a + // full sweep (all docs) indicates a leak. The docs index page has no + // data-pagefind-body and must never appear. + const totalDocs = 9; + expect(hits.length).toBeLessThan(totalDocs); + expect(hits).not.toContain('/docs/'); +}); + +test('command palette returns pagefind results', async ({ page }) => { + await page.goto('/docs/', { waitUntil: 'networkidle' }); + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k'); + await page.locator('.sid-cmdk-input').fill('retro'); + await expect(page.locator('.sid-cmdk-item').first()).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.sid-cmdk-item-title').first()).not.toBeEmpty(); +}); + +test('OG card images are generated per doc', async ({ request }) => { + const res = await request.get('/assets/og/guides/theming.png'); + expect(res.ok()).toBe(true); + expect(res.headers()['content-type']).toContain('image/png'); + const index = await request.get('/assets/og/docs-index.png'); + expect(index.ok()).toBe(true); +}); + +test('sidebar links are anchors but navigate client-side', async ({ page }) => { + await page.goto('/docs/guides/theming/', { waitUntil: 'networkidle' }); + // Mark the current document; a full page load would lose the marker. + await page.evaluate(() => { + (window as unknown as { __spaMarker?: boolean }).__spaMarker = true; + }); + const link = page.locator('a.sid-nav-link[href="/docs/getting-started/quickstart"]').first(); + await expect(link).toBeVisible(); + await link.click(); + await expect(page).toHaveURL(/\/docs\/getting-started\/quickstart$/); + await expect(page.locator('#root h1')).toContainText('Quickstart'); + const stillSpa = await page.evaluate( + () => (window as unknown as { __spaMarker?: boolean }).__spaMarker === true, + ); + expect(stillSpa, 'plain click must navigate client-side, not reload').toBe(true); +}); + +test('Scalar route stays a client-rendered shell', async ({ request }) => { + const html = await (await request.get('/docs/api')).text(); + const root = html.split('
')[1] ?? ''; + // No prerendered content for the API route — it renders client-side. + expect(root.slice(0, 20).trim().startsWith('
')).toBe(true); +}); diff --git a/index.html b/index.html index 50c7c98..ff7db8b 100644 --- a/index.html +++ b/index.html @@ -43,6 +43,6 @@
- + diff --git a/package.json b/package.json index 3d30404..d664426 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "preinstall": "npx only-allow pnpm", "predev": "node scripts/sync-openapi.mjs", "dev": "vite", - "build": "vite build", + "build": "vite build --ssr src/entry-server.tsx --outDir dist-ssr && vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", "lint": "eslint . --max-warnings 0", @@ -47,6 +47,7 @@ "devDependencies": { "@eslint/js": "^9.13.0", "@playwright/test": "^1.61.1", + "@resvg/resvg-js": "^2.6.2", "@tamagui/config": "^1.135.2", "@tamagui/vite-plugin": "^1.135.2", "@types/node": "^22.12.0", @@ -58,7 +59,9 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", "globals": "^15.11.0", + "pagefind": "^1.5.2", "prettier": "^3.3.3", + "satori": "^0.26.0", "typescript": "^5.7.3", "typescript-eslint": "^8.10.0", "vite": "^6.0.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d540012..af86e77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: '@playwright/test': specifier: ^1.61.1 version: 1.61.1 + '@resvg/resvg-js': + specifier: ^2.6.2 + version: 2.6.2 '@tamagui/config': specifier: ^1.135.2 version: 1.144.4(react-dom@19.0.0(react@19.0.0))(react-native-reanimated@4.4.1(react-native-worklets@0.9.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.0.0))(react@19.0.0))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.0.0))(react@19.0.0))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.0.0))(react@19.0.0) @@ -117,9 +120,15 @@ importers: globals: specifier: ^15.11.0 version: 15.15.0 + pagefind: + specifier: ^1.5.2 + version: 1.5.2 prettier: specifier: ^3.3.3 version: 3.8.4 + satori: + specifier: ^0.26.0 + version: 0.26.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -1016,6 +1025,41 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@pagefind/darwin-arm64@1.5.2': + resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} + cpu: [arm64] + os: [darwin] + + '@pagefind/darwin-x64@1.5.2': + resolution: {integrity: sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==} + cpu: [x64] + os: [darwin] + + '@pagefind/freebsd-x64@1.5.2': + resolution: {integrity: sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==} + cpu: [x64] + os: [freebsd] + + '@pagefind/linux-arm64@1.5.2': + resolution: {integrity: sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==} + cpu: [arm64] + os: [linux] + + '@pagefind/linux-x64@1.5.2': + resolution: {integrity: sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==} + cpu: [x64] + os: [linux] + + '@pagefind/windows-arm64@1.5.2': + resolution: {integrity: sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==} + cpu: [arm64] + os: [win32] + + '@pagefind/windows-x64@1.5.2': + resolution: {integrity: sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==} + cpu: [x64] + os: [win32] + '@phosphor-icons/core@2.1.1': resolution: {integrity: sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ==} @@ -1130,6 +1174,82 @@ packages: '@codemirror/state': ^6.0.0 '@codemirror/view': ^6.0.0 + '@resvg/resvg-js-android-arm-eabi@2.6.2': + resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@resvg/resvg-js-android-arm64@2.6.2': + resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@resvg/resvg-js-darwin-arm64@2.6.2': + resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@resvg/resvg-js-darwin-x64@2.6.2': + resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@resvg/resvg-js@2.6.2': + resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} + engines: {node: '>= 10'} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1360,6 +1480,11 @@ packages: resolution: {integrity: sha512-51jLDHeiaWC/rTySR+M+ok9wdQ6BYF+7wPT3WFK/dIuqTU5KtHA/nKQl4xfflVXLBqcJ6o6TDDVbNcTLFMVZmQ==} engines: {node: '>=22'} + '@shuding/opentype.js@1.4.0-beta.0': + resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} + engines: {node: '>= 8.0.0'} + hasBin: true + '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} @@ -2290,6 +2415,10 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2328,6 +2457,9 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} @@ -2450,9 +2582,26 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-background-parser@0.1.0: + resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==} + + css-box-shadow@1.0.0-3: + resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==} + + css-color-keywords@1.0.0: + resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} + engines: {node: '>=4'} + + css-gradient-parser@0.0.17: + resolution: {integrity: sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==} + engines: {node: '>=16'} + css-in-js-utils@3.1.0: resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + css-to-react-native@3.2.0: + resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2521,6 +2670,10 @@ packages: electron-to-chromium@1.5.372: resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==} + emoji-regex-xs@2.0.1: + resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} + engines: {node: '>=10.0.0'} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2743,6 +2896,9 @@ packages: fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.7.4: + resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2963,6 +3119,10 @@ packages: hermes-parser@0.36.0: resolution: {integrity: sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==} + hex-rgb@4.3.0: + resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} + engines: {node: '>=6'} + hey-listen@1.0.8: resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} @@ -3192,6 +3352,9 @@ packages: lighthouse-logger@1.4.2: resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -3622,10 +3785,20 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pagefind@1.5.2: + resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==} + hasBin: true + + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-css-color@0.2.1: + resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -3960,6 +4133,10 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + satori@0.26.0: + resolution: {integrity: sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA==} + engines: {node: '>=16'} + scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} @@ -4079,6 +4256,9 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string.prototype.codepointat@0.2.1: + resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -4169,6 +4349,9 @@ packages: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -4273,6 +4456,9 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -4485,6 +4671,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -5509,6 +5698,27 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@pagefind/darwin-arm64@1.5.2': + optional: true + + '@pagefind/darwin-x64@1.5.2': + optional: true + + '@pagefind/freebsd-x64@1.5.2': + optional: true + + '@pagefind/linux-arm64@1.5.2': + optional: true + + '@pagefind/linux-x64@1.5.2': + optional: true + + '@pagefind/windows-arm64@1.5.2': + optional: true + + '@pagefind/windows-x64@1.5.2': + optional: true + '@phosphor-icons/core@2.1.1': {} '@phosphor-icons/react@2.1.10(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': @@ -5675,6 +5885,57 @@ snapshots: '@codemirror/state': 6.6.0 '@codemirror/view': 6.43.1 + '@resvg/resvg-js-android-arm-eabi@2.6.2': + optional: true + + '@resvg/resvg-js-android-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-x64@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js@2.6.2': + optionalDependencies: + '@resvg/resvg-js-android-arm-eabi': 2.6.2 + '@resvg/resvg-js-android-arm64': 2.6.2 + '@resvg/resvg-js-darwin-arm64': 2.6.2 + '@resvg/resvg-js-darwin-x64': 2.6.2 + '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 + '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 + '@resvg/resvg-js-linux-arm64-musl': 2.6.2 + '@resvg/resvg-js-linux-x64-gnu': 2.6.2 + '@resvg/resvg-js-linux-x64-musl': 2.6.2 + '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 + '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 + '@resvg/resvg-js-win32-x64-msvc': 2.6.2 + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/pluginutils@5.4.0(rollup@4.61.1)': @@ -6087,6 +6348,11 @@ snapshots: transitivePeerDependencies: - typescript + '@shuding/opentype.js@1.4.0-beta.0': + dependencies: + fflate: 0.7.4 + string.prototype.codepointat: 0.2.1 + '@sinclair/typebox@0.27.10': {} '@standard-schema/spec@1.1.0': {} @@ -7671,6 +7937,8 @@ snapshots: balanced-match@4.0.4: {} + base64-js@0.0.8: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.37: {} @@ -7706,6 +7974,8 @@ snapshots: camelcase@6.3.0: {} + camelize@1.0.1: {} + caniuse-lite@1.0.30001799: {} ccount@2.0.1: {} @@ -7827,10 +8097,24 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-background-parser@0.1.0: {} + + css-box-shadow@1.0.0-3: {} + + css-color-keywords@1.0.0: {} + + css-gradient-parser@0.0.17: {} + css-in-js-utils@3.1.0: dependencies: hyphenate-style-name: 1.1.0 + css-to-react-native@3.2.0: + dependencies: + camelize: 1.0.1 + css-color-keywords: 1.0.0 + postcss-value-parser: 4.2.0 + csstype@3.2.3: {} cva@1.0.0-beta.4(typescript@5.9.3): @@ -7885,6 +8169,8 @@ snapshots: electron-to-chromium@1.5.372: {} + emoji-regex-xs@2.0.1: {} + emoji-regex@8.0.0: {} encodeurl@1.0.2: {} @@ -8142,6 +8428,8 @@ snapshots: fflate@0.4.8: {} + fflate@0.7.4: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -8476,6 +8764,8 @@ snapshots: dependencies: hermes-estree: 0.36.0 + hex-rgb@4.3.0: {} + hey-listen@1.0.8: {} highlight.js@11.11.1: {} @@ -8676,6 +8966,11 @@ snapshots: transitivePeerDependencies: - supports-color + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -9494,10 +9789,27 @@ snapshots: p-try@2.2.0: {} + pagefind@1.5.2: + optionalDependencies: + '@pagefind/darwin-arm64': 1.5.2 + '@pagefind/darwin-x64': 1.5.2 + '@pagefind/freebsd-x64': 1.5.2 + '@pagefind/linux-arm64': 1.5.2 + '@pagefind/linux-x64': 1.5.2 + '@pagefind/windows-arm64': 1.5.2 + '@pagefind/windows-x64': 1.5.2 + + pako@0.2.9: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 + parse-css-color@0.2.1: + dependencies: + color-name: 1.1.4 + hex-rgb: 4.3.0 + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -9997,6 +10309,20 @@ snapshots: dependencies: queue-microtask: 1.2.3 + satori@0.26.0: + dependencies: + '@shuding/opentype.js': 1.4.0-beta.0 + css-background-parser: 0.1.0 + css-box-shadow: 1.0.0-3 + css-gradient-parser: 0.0.17 + css-to-react-native: 3.2.0 + emoji-regex-xs: 2.0.1 + escape-html: 1.0.3 + linebreak: 1.1.0 + parse-css-color: 0.2.1 + postcss-value-parser: 4.2.0 + yoga-layout: 3.2.1 + scheduler@0.25.0: {} scheduler@0.27.0: {} @@ -10105,6 +10431,8 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string.prototype.codepointat@0.2.1: {} + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -10251,6 +10579,8 @@ snapshots: dependencies: convert-hrtime: 5.0.0 + tiny-inflate@1.0.3: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -10332,6 +10662,11 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -10514,6 +10849,8 @@ snapshots: yocto-queue@0.1.0: {} + yoga-layout@3.2.1: {} + zod@4.4.3: {} zwitch@2.0.4: {} diff --git a/src/AppRoot.tsx b/src/AppRoot.tsx new file mode 100644 index 0000000..5b8cac3 --- /dev/null +++ b/src/AppRoot.tsx @@ -0,0 +1,64 @@ +import { StrictMode } from 'react'; +import type { FC, ReactNode } from 'react'; +import { HelmetProvider } from 'react-helmet-async'; +import { AuthKitProvider } from '@workos-inc/authkit-react'; +import { TamaguiProvider, Theme } from 'tamagui'; + +import config from '@/theme/tamagui.config'; +import { ThemeProvider, useThemeController } from '@/theme/themeController'; +import App from './App'; +import { AuthProvider } from './contexts/AuthContext'; +import { LanguageProvider } from './contexts/LanguageContext'; +import { CommandPaletteProvider } from './components/layout/CommandPalette'; +import { AUTH_ENABLED } from './lib/auth'; + +// Shared root for both entries. The entry supplies the router — +// entry-client: +// entry-server: +// — so the provider tree is guaranteed identical between prerender and +// hydration. + +const Themed: FC<{ children: ReactNode }> = ({ children }) => { + const { themeStyle } = useThemeController(); + // Style-only theme name ("steel", never "dark_steel"): a scheme-bearing name + // makes Tamagui wrap users whose scheme differs from the provider default in + // an extra inverse — a structural hydration mismatch that would force + // a full client re-render for dark-mode visitors on prerendered pages. + // Light/dark resolves via the t_* classes on (pre-hydration script + + // themeController); rendered markup stays scheme-agnostic (CSS variables). + return ( + + {children} + + ); +}; + +export const AppRoot: FC<{ children: ReactNode }> = ({ children }) => ( + + + + {children} + + + +); + +// The app itself — must render INSIDE the router (auth uses router hooks). +export const InnerApp: FC = () => { + const app = ( + + + + + + ); + + // Auth providers mount only when configured — otherwise fully public. + return AUTH_ENABLED ? ( + + {app} + + ) : ( + app + ); +}; diff --git a/src/components/layout/DocsLayout.tsx b/src/components/layout/DocsLayout.tsx index 94c8e17..c6c1ebd 100644 --- a/src/components/layout/DocsLayout.tsx +++ b/src/components/layout/DocsLayout.tsx @@ -4,6 +4,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { XStack, YStack } from 'tamagui'; import { useNavigation } from '../../hooks/useNavigation'; +import { normalizeSlug } from '../../hooks/useDocs'; import Sidebar from './Sidebar'; import TopNav from './TopNav'; @@ -12,7 +13,7 @@ const DocsLayout: FC = () => { const navigate = useNavigate(); const [menuOpen, setMenuOpen] = useState(false); - const slug = pathname.startsWith('/docs/') ? pathname.slice('/docs/'.length) : undefined; + const slug = pathname.startsWith('/docs/') ? normalizeSlug(pathname.slice('/docs/'.length)) : undefined; const nav = useNavigation(slug, pathname); const onTabSelect = (i: number) => { diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e62d818..1690fe8 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,10 +1,12 @@ -import { useState } from 'react'; +import { useState, useSyncExternalStore } from 'react'; import type { FC } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { YStack, XStack, ScrollView, Text } from 'tamagui'; import { MagnifyingGlass, CaretRight } from '@phosphor-icons/react'; import type { ResolvedGroup, ResolvedItem } from '../../hooks/useNavigation'; +import { normalizeSlug } from '../../hooks/useDocs'; +import { anchorNavProps } from '../../lib/navLink'; import { NavIcon } from '../../lib/navIcons'; import { useCommandPalette } from './CommandPalette'; @@ -14,13 +16,24 @@ interface SidebarProps { onNavigate?: () => void; } -const isMac = typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform); +// Two-pass platform detection: the server (prerender) and first client render +// both say false ("Ctrl K"), then Macs flip to "⌘ K" — a module-scope +// navigator read would make the prerendered HTML mismatch Mac hydration. +const noopSubscribe = () => () => {}; +const useIsMac = () => + useSyncExternalStore( + noopSubscribe, + () => /Mac/i.test(navigator.platform), + () => false, + ); const Sidebar: FC = ({ groups, fullWidth = false, onNavigate }) => { const navigate = useNavigate(); - const { '*': currentSlug } = useParams(); + const { '*': rawSlug } = useParams(); + const currentSlug = normalizeSlug(rawSlug); const [collapsed, setCollapsed] = useState>(new Set()); const { open: openPalette } = useCommandPalette(); + const isMac = useIsMac(); const go = (slug: string, headingId?: string) => { navigate(`/docs/${slug}${headingId ? `#${headingId}` : ''}`); @@ -33,6 +46,7 @@ const Sidebar: FC = ({ groups, fullWidth = false, onNavigate }) => return ( go(slug))} alignItems="center" paddingVertical="$1.5" paddingRight="$2" @@ -44,7 +58,6 @@ const Sidebar: FC = ({ groups, fullWidth = false, onNavigate }) => borderLeftColor={active ? '$accent' : 'transparent'} backgroundColor={active ? '$color4' : 'transparent'} hoverStyle={{ backgroundColor: active ? '$color4' : '$color3' }} - onPress={() => go(slug)} > {title} diff --git a/src/docs.css b/src/docs.css index cebb78f..cf662a0 100644 --- a/src/docs.css +++ b/src/docs.css @@ -343,3 +343,11 @@ html { .sid-scalar .scalar-app .t-doc__sidebar { top: 0; } + +/* Nav elements rendered as real anchors (sidebar links, prev/next cards) — + crawlable + cmd-click friendly, without the browser's default link look. */ +a.sid-nav-link { + color: inherit; + text-decoration: none; + display: flex; +} diff --git a/src/entry-client.tsx b/src/entry-client.tsx new file mode 100644 index 0000000..bce0cdd --- /dev/null +++ b/src/entry-client.tsx @@ -0,0 +1,51 @@ +// Fonts are loaded by src/theme/fonts.ts (imported via the Tamagui config). +import './highlight.css'; +import './docs.css'; + +import { createRoot, hydrateRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; + +import { AppRoot, InnerApp } from './AppRoot'; +import { injectThemeVariables } from '@/theme/injectThemeVars'; +import { initAnalytics } from './lib/analytics'; +import { getManifestSync, loadManifest, preloadDoc } from './hooks/useDocs'; + +initAnalytics(); +injectThemeVariables(); + +const root = document.getElementById('root') as HTMLElement; +const tree = ( + + + + + +); + +// Prerendered pages need the manifest and the current route's doc component +// available SYNCHRONOUSLY at first render, or React's hydration pass would +// render a spinner against real content and throw the DOM away. +async function seedForHydration(): Promise { + if (!getManifestSync()) await loadManifest(); + const m = window.location.pathname.match(/^\/docs\/(.+?)\/?$/); + if (m && m[1] !== 'api') await preloadDoc(decodeURIComponent(m[1])); +} + +async function start(): Promise { + if (root.firstElementChild) { + // Prerendered HTML (see vite-plugins/prerender.ts) — seed, then hydrate. + try { + await seedForHydration(); + hydrateRoot(root, tree); + return; + } catch (err) { + // A blank-then-render beats hydrating against mismatched content. + console.warn('[capsa] hydration seeding failed — client rendering', err); + root.replaceChildren(); + } + } + // Dev server, /docs/api shell, 404s, or the fallback above. + createRoot(root).render(tree); +} + +void start(); diff --git a/src/entry-server.tsx b/src/entry-server.tsx new file mode 100644 index 0000000..3aa9a7a --- /dev/null +++ b/src/entry-server.tsx @@ -0,0 +1,43 @@ +// Server entry for build-time prerendering (see vite-plugins/prerender.ts). +// Bundled separately via `vite build --ssr src/entry-server.tsx`. +import { renderToString } from 'react-dom/server'; +import { StaticRouter } from 'react-router-dom/server'; +import type { ComponentType } from 'react'; + +import { AppRoot, InnerApp } from './AppRoot'; +import { seedDoc, seedManifest } from './hooks/useDocs'; +import type { ManifestEntry } from './hooks/useDocs'; +import { IS_PUBLIC } from './lib/auth'; + +export { buildThemeVariablesCSS } from '@/theme/injectThemeVars'; +export type { ManifestEntry }; + +// Every MDX doc, eagerly bundled — renderToString cannot await lazy chunks. +const eagerDocs = import.meta.glob<{ default: ComponentType }>('/content/**/*.mdx', { + eager: true, +}); + +export function seedServer(manifest: ManifestEntry[]): void { + // Security invariant: gated content must never be baked into world-readable + // static HTML. Effectively-public deploys (no auth, or explicit dev bypass) + // are fine. The prerender plugin also bails; this is belt-and-braces. + if (!IS_PUBLIC) { + throw new Error('[capsa] refusing to prerender an auth-gated deploy'); + } + seedManifest(manifest); + for (const [path, mod] of Object.entries(eagerDocs)) { + const slug = path.replace(/^\/content\//, '').replace(/\.mdx$/, ''); + seedDoc(slug, mod.default); + } +} + +export function render(url: string): { appHtml: string } { + const appHtml = renderToString( + + + + + , + ); + return { appHtml }; +} diff --git a/src/hooks/useDocs.ts b/src/hooks/useDocs.ts index d8569cd..ace2d24 100644 --- a/src/hooks/useDocs.ts +++ b/src/hooks/useDocs.ts @@ -33,6 +33,67 @@ export interface ManifestEntry { // Optional per-product scope (4.1): when set, only that product's docs show. const PRODUCT_SCOPE = import.meta.env.VITE_PRODUCT as string | undefined; +const scopeEntries = (entries: ManifestEntry[]): ManifestEntry[] => + PRODUCT_SCOPE ? entries.filter((e) => e.product === PRODUCT_SCOPE) : entries; + +// ── SSG / hydration caches ──────────────────────────────────────────────── +// Prerendering (vite-plugins/prerender.ts) renders routes with renderToString, +// which cannot wait for effects — so the hooks below check these module-level +// caches synchronously before falling back to their async paths. The server +// entry seeds them from an eager glob + the on-disk manifest; the client entry +// seeds the same caches BEFORE hydrateRoot so the first client render matches +// the prerendered HTML byte for byte. SPA navigation also benefits: once a doc +// has loaded it renders instantly on revisit. + +declare global { + interface Window { + /** Inlined by the prerender plugin so hydration needs no manifest fetch. */ + __CAPSA_MANIFEST__?: ManifestEntry[]; + } +} + +const docCache = new Map(); +let manifestCache: ManifestEntry[] | null = null; + +// Static hosts serve prerendered directories at trailing-slash URLs +// (/docs/guides/theming/), and the router splat keeps that slash — normalize +// so cache keys, module paths, and manifest lookups always match. +export const normalizeSlug = (slug: string | undefined): string | undefined => + slug ? slug.replace(/\/+$/, '') || undefined : undefined; + +function toLoadedDoc(slug: string, Component: ComponentType): LoadedDoc { + const parts = slug.split('/'); + return { + slug, + title: titleFromFilename(parts[parts.length - 1]), + category: parts.length > 1 ? parts[0] : 'general', + Component, + }; +} + +export function seedManifest(entries: ManifestEntry[]): void { + manifestCache = entries; + manifestPromise = Promise.resolve(entries); +} + +export function getManifestSync(): ManifestEntry[] | null { + return manifestCache; +} + +export function seedDoc(slug: string, Component: ComponentType): void { + docCache.set(slug, toLoadedDoc(slug, Component)); +} + +/** Resolve one doc's lazy chunk into the cache (used before hydrateRoot). */ +export async function preloadDoc(rawSlug: string): Promise { + const slug = normalizeSlug(rawSlug); + if (!slug || docCache.has(slug)) return; + const loader = docModules[`/content/${slug}.mdx`]; + if (!loader) return; + const mod = await loader(); + docCache.set(slug, toLoadedDoc(slug, mod.default)); +} + function buildNavTree(docs: ManifestEntry[]): NavCategory[] { const categories = new Map(); @@ -53,23 +114,36 @@ function buildNavTree(docs: ManifestEntry[]): NavCategory[] { let manifestPromise: Promise | null = null; function loadManifest(): Promise { if (!manifestPromise) { + // Prerendered pages inline the manifest — no fetch on the critical path. + const inline = typeof window !== 'undefined' ? window.__CAPSA_MANIFEST__ : undefined; + if (inline) { + seedManifest(inline); + return manifestPromise!; + } manifestPromise = fetch('/docs-manifest.json') .then((r) => (r.ok ? (r.json() as Promise) : [])) - .catch(() => []); + .catch(() => []) + .then((entries: ManifestEntry[]) => { + manifestCache = entries; + return entries; + }); } return manifestPromise; } export function useDocsList() { - const [docs, setDocs] = useState([]); - const [isLoading, setIsLoading] = useState(true); + // Cache-first so prerender (and hydration of prerendered pages) renders the + // real list synchronously; falls back to the async manifest fetch otherwise. + const cached = getManifestSync(); + const [docs, setDocs] = useState(() => (cached ? scopeEntries(cached) : [])); + const [isLoading, setIsLoading] = useState(cached === null); useEffect(() => { + if (getManifestSync()) return; let cancelled = false; loadManifest().then((entries) => { if (cancelled) return; - const scoped = PRODUCT_SCOPE ? entries.filter((e) => e.product === PRODUCT_SCOPE) : entries; - setDocs(scoped); + setDocs(scopeEntries(entries)); setIsLoading(false); }); return () => { @@ -85,22 +159,23 @@ export function useDocsList() { export { loadManifest }; // Per-doc metadata (title/description/...) for SEO tags — see DocPage. -export function useDocMeta(slug: string | undefined) { - const [meta, setMeta] = useState(null); +export function useDocMeta(rawSlug: string | undefined) { + const slug = normalizeSlug(rawSlug); + const [manifest, setManifest] = useState(() => getManifestSync()); useEffect(() => { - if (!slug) { - setMeta(null); - return; - } + if (manifest !== null) return; let cancelled = false; loadManifest().then((entries) => { - if (!cancelled) setMeta(entries.find((e) => e.slug === slug) ?? null); + if (!cancelled) setManifest(entries); }); return () => { cancelled = true; }; - }, [slug]); - return meta; + }, [manifest]); + return useMemo( + () => (slug && manifest ? (manifest.find((e) => e.slug === slug) ?? null) : null), + [manifest, slug], + ); } export interface LoadedDoc { @@ -110,9 +185,12 @@ export interface LoadedDoc { Component: ComponentType; } -export function useDoc(slug: string | undefined) { - const [doc, setDoc] = useState(null); - const [isLoading, setIsLoading] = useState(true); +export function useDoc(rawSlug: string | undefined) { + const slug = normalizeSlug(rawSlug); + // Cache-first: prerender and hydration render the doc synchronously; SPA + // navigation to an uncached doc takes the async loader path as before. + const [doc, setDoc] = useState(() => (slug ? (docCache.get(slug) ?? null) : null)); + const [isLoading, setIsLoading] = useState(() => !!slug && !docCache.has(slug)); const [error, setError] = useState(null); useEffect(() => { @@ -122,6 +200,14 @@ export function useDoc(slug: string | undefined) { return; } + const cached = docCache.get(slug); + if (cached) { + setDoc(cached); + setError(null); + setIsLoading(false); + return; + } + const filePath = `/content/${slug}.mdx`; const loader = docModules[filePath]; @@ -139,16 +225,9 @@ export function useDoc(slug: string | undefined) { loader() .then((mod) => { if (cancelled) return; - const parts = slug.split('/'); - const category = parts.length > 1 ? parts[0] : 'general'; - const filename = parts[parts.length - 1]; - - setDoc({ - slug, - title: titleFromFilename(filename), - category, - Component: mod.default, - }); + const loaded = toLoadedDoc(slug, mod.default); + docCache.set(slug, loaded); + setDoc(loaded); setIsLoading(false); }) .catch((err) => { @@ -204,6 +283,63 @@ function loadSearchIndex(): Promise { return searchIndexPromise; } +// ── Pagefind (production search) ───────────────────────────────────────── +// Production builds ship a Pagefind index over the prerendered article bodies +// (written by vite-plugins/prerender.ts). Its runtime loads lazily from +// /pagefind/pagefind.js; when absent (dev server, PRERENDER=0 builds) we fall +// back to the in-memory JSON index above. + +interface PagefindSubResult { + title: string; + url: string; +} +interface PagefindDocData { + url: string; + excerpt?: string; + meta?: { title?: string }; + sub_results?: PagefindSubResult[]; +} +interface PagefindApi { + init?: () => Promise; + search: (q: string) => Promise<{ results: { data: () => Promise }[] }>; +} + +let pagefindPromise: Promise | null = null; +function loadPagefind(): Promise { + if (!pagefindPromise) { + const runtimeUrl = '/pagefind/pagefind.js'; + pagefindPromise = import(/* @vite-ignore */ runtimeUrl) + .then(async (pf: PagefindApi) => { + await pf.init?.(); + return pf; + }) + .catch(() => null); + } + return pagefindPromise; +} + +const stripTags = (s: string) => s.replace(/<[^>]+>/g, ''); + +async function pagefindSearch(pf: PagefindApi, query: string): Promise { + const { results } = await pf.search(query); + const pages = await Promise.all(results.slice(0, 15).map((r) => r.data())); + return pages.map((d) => { + const slug = normalizeSlug(d.url.replace(/^\/docs\//, '').replace(/\.html$/, '')) ?? ''; + const title = d.meta?.title || titleFromFilename(slug.split('/').pop() ?? slug); + // Best matching section with an anchor (skip the page-title pseudo-section) + // so results deep-link like the JSON index did. + const sub = d.sub_results?.find((s) => s.url.includes('#') && s.title !== title); + return { + slug, + title, + category: slug.includes('/') ? slug.split('/')[0] : 'general', + excerpt: stripTags(d.excerpt ?? ''), + headingText: sub?.title, + headingId: sub?.url.split('#')[1], + }; + }); +} + function rankResults(entries: SearchEntry[], query: string): SearchResult[] { const q = query.toLowerCase(); const scored: { entry: SearchEntry; score: number; heading?: SearchHeading }[] = []; @@ -248,16 +384,25 @@ export function useDocSearch(query: string) { setIsSearching(true); let cancelled = false; - // Debounce keystrokes; the index fetch is cached after the first call. + // Debounce keystrokes; both backends cache after the first call. const handle = setTimeout(() => { - loadSearchIndex().then((entries) => { + void (async () => { + let ranked: SearchResult[] | null = null; + const pf = await loadPagefind(); + if (pf) { + try { + ranked = await pagefindSearch(pf, trimmed); + } catch { + ranked = null; // fall through to the JSON index + } + } + if (ranked === null) ranked = rankResults(await loadSearchIndex(), trimmed); if (cancelled) return; - const ranked = rankResults(entries, trimmed); setResults(ranked); setIsSearching(false); // High-signal analytics: queries that find nothing = content gaps. if (ranked.length === 0) track('docs_search_no_results', { query: trimmed }); - }); + })(); }, 150); return () => { diff --git a/src/hooks/useNavigation.ts b/src/hooks/useNavigation.ts index 2549422..8e8fdc5 100644 --- a/src/hooks/useNavigation.ts +++ b/src/hooks/useNavigation.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { navigation } from '../navigation'; import type { AutoPages, NavGroup, NavTab } from '../navigation'; -import { loadManifest } from './useDocs'; +import { loadManifest, getManifestSync } from './useDocs'; import type { ManifestEntry } from './useDocs'; import { titleFromFilename } from '../lib/markdown'; @@ -87,9 +87,11 @@ export interface NavData { // Resolve the navigation config against the manifest, and figure out which tab // the current route belongs to. export function useNavigation(currentSlug: string | undefined, currentPath: string): NavData { - const [manifest, setManifest] = useState(null); + // Cache-first (seeded by prerender/hydration); async fetch otherwise. + const [manifest, setManifest] = useState(() => getManifestSync()); useEffect(() => { + if (manifest !== null) return; let cancelled = false; loadManifest().then((m) => { if (!cancelled) setManifest(m); @@ -97,7 +99,7 @@ export function useNavigation(currentSlug: string | undefined, currentPath: stri return () => { cancelled = true; }; - }, []); + }, [manifest]); return useMemo(() => { const entries = (manifest ?? []).filter((e) => !PRODUCT_SCOPE || e.product === PRODUCT_SCOPE); diff --git a/src/index.tsx b/src/index.tsx deleted file mode 100644 index 5085506..0000000 --- a/src/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// Fonts are loaded by src/theme/fonts.ts (imported via the Tamagui config). -import './highlight.css'; -import './docs.css'; - -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; -import { HelmetProvider } from 'react-helmet-async'; -import { AuthKitProvider } from '@workos-inc/authkit-react'; -import { ThemeProvider, useThemeController } from '@/theme/themeController'; -import config from '@/theme/tamagui.config'; -import { TamaguiProvider, Theme } from 'tamagui'; - -import App from './App'; -import { AuthProvider } from './contexts/AuthContext'; -import { LanguageProvider } from './contexts/LanguageContext'; -import { CommandPaletteProvider } from './components/layout/CommandPalette'; -import { injectThemeVariables } from '@/theme/injectThemeVars'; -import { initAnalytics } from './lib/analytics'; -import { AUTH_ENABLED } from './lib/auth'; - -initAnalytics(); -injectThemeVariables(); - -function ThemedApp() { - const { resolvedTheme } = useThemeController(); - - const app = ( - - - - - - ); - - return ( - - - - {/* Auth providers mount only when configured — otherwise fully public. */} - {AUTH_ENABLED ? ( - - {app} - - ) : ( - app - )} - - - - ); -} - -createRoot(document.getElementById('root') as HTMLElement).render( - - - - - - - , -); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index e72ec13..3360705 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -1,30 +1,37 @@ -import posthog from 'posthog-js'; - // Privacy-respecting, env-gated analytics (PostHog). Nothing initializes // unless VITE_POSTHOG_KEY is set — so a default deploy ships zero tracking. // Custom events power the high-signal data: failed searches (content gaps), // 404s, per-doc feedback, and copy-page / open-in-LLM actions. +// +// posthog-js is loaded via dynamic import so it never enters the SSR module +// graph (this file is imported at module scope from App.tsx and useDocs.ts, +// which the prerender bundle also loads). Events fired before the import +// resolves are dropped — acceptable for the first milliseconds of a visit. + +type PostHogClient = (typeof import('posthog-js'))['default']; const KEY = import.meta.env.VITE_POSTHOG_KEY as string | undefined; const HOST = (import.meta.env.VITE_POSTHOG_HOST as string | undefined) || 'https://us.i.posthog.com'; -let enabled = false; +let client: PostHogClient | null = null; export function initAnalytics(): void { - if (!KEY || enabled) return; - posthog.init(KEY, { - api_host: HOST, - capture_pageview: false, // we fire $pageview on route change (SPA) - capture_pageleave: true, - autocapture: false, + if (!KEY || client || typeof window === 'undefined') return; + void import('posthog-js').then(({ default: posthog }) => { + posthog.init(KEY, { + api_host: HOST, + capture_pageview: false, // we fire $pageview on route change (SPA) + capture_pageleave: true, + autocapture: false, + }); + client = posthog; }); - enabled = true; } export function trackPageview(path: string): void { - if (enabled) posthog.capture('$pageview', { path }); + client?.capture('$pageview', { path }); } export function track(event: string, props?: Record): void { - if (enabled) posthog.capture(event, props); + client?.capture(event, props); } diff --git a/src/lib/navLink.ts b/src/lib/navLink.ts new file mode 100644 index 0000000..3dd234a --- /dev/null +++ b/src/lib/navLink.ts @@ -0,0 +1,24 @@ +import type { MouseEvent } from 'react'; + +// Nav elements render as real so prerendered pages are crawlable and +// cmd/ctrl/middle-click open-in-new-tab works — while plain left clicks stay +// client-side (no full reload). +export function spaClick(navigateTo: () => void) { + return (e: MouseEvent) => { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { + return; // let the browser handle new-tab/window/download clicks + } + e.preventDefault(); + navigateTo(); + }; +} + +/** Spread onto a Tamagui stack to render it as an SPA-friendly anchor. */ +export function anchorNavProps(href: string, navigateTo: () => void) { + return { + tag: 'a', + href, + className: 'sid-nav-link', + onClick: spaClick(navigateTo), + } as const; +} diff --git a/src/pages/docs/DocPage.tsx b/src/pages/docs/DocPage.tsx index 7d666ec..b807581 100644 --- a/src/pages/docs/DocPage.tsx +++ b/src/pages/docs/DocPage.tsx @@ -5,7 +5,8 @@ import { Helmet } from 'react-helmet-async'; import { XStack, YStack, Paragraph, Spinner, H1, Text } from 'tamagui'; import { ArrowLeft, ArrowRight } from '@phosphor-icons/react'; -import { useDoc, useDocMeta } from '../../hooks/useDocs'; +import { useDoc, useDocMeta, normalizeSlug } from '../../hooks/useDocs'; +import { anchorNavProps } from '../../lib/navLink'; import { useNavigation } from '../../hooks/useNavigation'; import type { TocEntry } from '../../lib/markdown'; import mdxComponents from '../../components/markdown/mdxComponents'; @@ -17,7 +18,10 @@ import { track } from '../../lib/analytics'; import { SITE_NAME } from '../../lib/site'; export default function DocPage() { - const { '*': slug } = useParams(); + const { '*': rawSlug } = useParams(); + // Trailing-slash URLs (static hosts serve prerendered dirs) keep the slash + // in the router splat — normalize before any lookups. + const slug = normalizeSlug(rawSlug); const { pathname, hash } = useLocation(); const navigate = useNavigate(); const { doc, isLoading, error } = useDoc(slug); @@ -108,7 +112,11 @@ export default function DocPage() { -
+ {/* data-pagefind-body scopes the build-time search index to the + article — without it Pagefind would index the sidebar/nav text + on every page. Pages lacking the attribute (e.g. the docs + index) are skipped entirely. */} +
@@ -122,6 +130,7 @@ export default function DocPage() { {prev ? ( navigate(`/docs/${prev.slug}`))} flex={1} minWidth={200} borderWidth={1} @@ -130,7 +139,6 @@ export default function DocPage() { padding="$3" cursor="pointer" hoverStyle={{ borderColor: '$accentBorder' }} - onPress={() => navigate(`/docs/${prev.slug}`)} > @@ -147,6 +155,7 @@ export default function DocPage() { )} {next && ( navigate(`/docs/${next.slug}`))} flex={1} minWidth={200} borderWidth={1} @@ -156,7 +165,6 @@ export default function DocPage() { cursor="pointer" alignItems="flex-end" hoverStyle={{ borderColor: '$accentBorder' }} - onPress={() => navigate(`/docs/${next.slug}`)} > diff --git a/src/theme/injectThemeVars.ts b/src/theme/injectThemeVars.ts index 598104f..1d8946e 100644 --- a/src/theme/injectThemeVars.ts +++ b/src/theme/injectThemeVars.ts @@ -11,9 +11,10 @@ import { config } from './tamagui.config'; // // The class is doubled (.t_dark_steel.t_dark_steel) so these rules beat // Tamagui's equal-specificity base rules regardless of style-tag order. -export function injectThemeVariables(): void { - if (typeof document === 'undefined') return; +export const THEME_VARS_STYLE_ID = 'capsa-theme-vars'; + +export function buildThemeVariablesCSS(): string { const themes = config.themes as Record>; let css = ''; for (const [name, theme] of Object.entries(themes)) { @@ -30,9 +31,17 @@ export function injectThemeVariables(): void { const cls = `t_${name}`; css += `:root.${cls}.${cls}{${decls}}\n`; } + return css; +} + +// Client-side injection. No-ops when the style tag already exists — prerendered +// pages ship it inline in (see vite-plugins/prerender.ts). +export function injectThemeVariables(): void { + if (typeof document === 'undefined') return; + if (document.getElementById(THEME_VARS_STYLE_ID)) return; const el = document.createElement('style'); - el.id = 'capsa-theme-vars'; - el.textContent = css; + el.id = THEME_VARS_STYLE_ID; + el.textContent = buildThemeVariablesCSS(); document.head.appendChild(el); } diff --git a/src/theme/themeController.tsx b/src/theme/themeController.tsx index c80d4a0..c4c6ca7 100644 --- a/src/theme/themeController.tsx +++ b/src/theme/themeController.tsx @@ -3,7 +3,16 @@ // pre-applied before hydration by the inline script in index.html, so there is // no flash of the wrong theme on load. Keep the storage keys and class logic // here in sync with that script. -import { createContext, useContext, useState, useEffect, useCallback, type FC, ReactNode } from 'react'; +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + useRef, + type FC, + ReactNode, +} from 'react'; // Base theme (light/dark) export type ThemeMode = 'light' | 'dark' | 'system'; @@ -95,23 +104,31 @@ const writeStored = (key: string, value: string) => { }; export const ThemeProvider: FC<{ children: ReactNode }> = ({ children }) => { - const [themeMode, setThemeModeState] = useState( - () => readStored(THEME_MODE_STORAGE_KEY, isThemeMode) ?? 'system', - ); - const [themeStyle, setThemeStyleState] = useState( - () => PINNED_THEME_STYLE ?? readStored(THEME_STYLE_STORAGE_KEY, isThemeStyle) ?? 'steel', - ); - const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme); - - // Listen for system theme changes + // Two-pass state for prerender/hydration safety: the server render and the + // first client render both use deterministic defaults (light, pinned-or- + // steel); the persisted values apply in a mount effect. Visuals are already + // correct before hydration because the index.html inline script sets the + // theme classes on — only state consumers (mode icon, isRetro + // conditionals) flip one frame after mount. + const [themeMode, setThemeModeState] = useState('system'); + const [themeStyle, setThemeStyleState] = useState(PINNED_THEME_STYLE ?? 'steel'); + const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>('light'); + + // Mount: adopt persisted + system values, then track system changes. useEffect(() => { - if (typeof window === 'undefined' || !window.matchMedia) return; - + const stored = readStored(THEME_MODE_STORAGE_KEY, isThemeMode); + if (stored) setThemeModeState(stored); + if (!PINNED_THEME_STYLE) { + const storedStyle = readStored(THEME_STYLE_STORAGE_KEY, isThemeStyle); + if (storedStyle) setThemeStyleState(storedStyle); + } + setSystemTheme(getSystemTheme()); + + if (!window.matchMedia) return; const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handleChange = (e: MediaQueryListEvent) => { setSystemTheme(e.matches ? 'dark' : 'light'); }; - mediaQuery.addEventListener('change', handleChange); return () => mediaQuery.removeEventListener('change', handleChange); }, []); @@ -126,7 +143,18 @@ export const ThemeProvider: FC<{ children: ReactNode }> = ({ children }) => { // the wrapper. Both the base class (t_dark) and the full theme class // (t_dark_steel) are applied; the full class must come last so its variables // win. Mirrors the pre-hydration script in index.html. + // + // The FIRST run is skipped entirely: it fires while state still holds the + // two-pass defaults, but the pre-hydration script has already applied the + // user's real theme to — writing defaults here would flash the wrong + // theme for a frame. Once the mount effect adopts the persisted values, any + // actual change re-runs this effect and writes normally. + const firstThemeSync = useRef(true); useEffect(() => { + if (firstThemeSync.current) { + firstThemeSync.current = false; + return; + } const root = document.documentElement; const stale = Array.from(root.classList).filter((c) => c.startsWith('t_')); root.classList.remove(...stale); diff --git a/vite-plugins/og-image.ts b/vite-plugins/og-image.ts new file mode 100644 index 0000000..06defa3 --- /dev/null +++ b/vite-plugins/og-image.ts @@ -0,0 +1,107 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +// Build-time OG card generation (1200×630 PNG per doc) — satori renders a +// flexbox object tree to SVG, resvg rasterizes it. Both are imported lazily so +// they only load during `vite build` (never on dev-server startup). + +export interface OgCardInput { + siteName: string; + title: string; + description: string; + category?: string; +} + +// Satori element tree (plain objects — no React needed in a build plugin). +type El = { type: string; props: Record }; +const el = (type: string, style: Record, children?: El[] | string): El => ({ + type, + props: { style, children }, +}); + +const clip = (s: string, max: number) => (s.length > max ? `${s.slice(0, max - 1)}…` : s); + +// Steel-dark brand card: graphite background, steel-blue accent bar, +// site name + category up top, big title, muted description. +function card({ siteName, title, description, category }: OgCardInput): El { + return el( + 'div', + { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + backgroundColor: '#0b0d0f', + padding: '72px 80px', + fontFamily: 'Space Grotesk', + }, + [ + el('div', { display: 'flex', alignItems: 'center', gap: 16 }, [ + el('div', { width: 20, height: 20, backgroundColor: '#5b86bd', borderRadius: 6 }), + el('div', { fontSize: 34, fontWeight: 700, color: '#f5f7fa' }, siteName), + ...(category + ? [ + el( + 'div', + { + fontSize: 24, + color: '#79808b', + textTransform: 'uppercase', + letterSpacing: 2, + marginLeft: 12, + }, + category.replace(/-/g, ' '), + ), + ] + : []), + ]), + el( + 'div', + { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + flexGrow: 1, + gap: 28, + }, + [ + el('div', { fontSize: 72, fontWeight: 700, color: '#f5f7fa', lineHeight: 1.15 }, clip(title, 70)), + el('div', { fontSize: 30, color: '#9aa1ac', lineHeight: 1.45 }, clip(description, 140)), + ], + ), + el('div', { display: 'flex', width: 220, height: 8, backgroundColor: '#5b86bd', borderRadius: 4 }), + ], + ); +} + +let fontsCache: { name: string; data: Buffer; weight: 400 | 700 }[] | null = null; +function loadFonts(root: string) { + if (!fontsCache) { + const dir = join(root, 'node_modules/@fontsource/space-grotesk/files'); + fontsCache = [ + { + name: 'Space Grotesk', + data: readFileSync(join(dir, 'space-grotesk-latin-400-normal.woff')), + weight: 400, + }, + { + name: 'Space Grotesk', + data: readFileSync(join(dir, 'space-grotesk-latin-700-normal.woff')), + weight: 700, + }, + ]; + } + return fontsCache; +} + +export async function generateOgImage(root: string, input: OgCardInput): Promise { + const { default: satori } = await import('satori'); + const { Resvg } = await import('@resvg/resvg-js'); + + const svg = await satori(card(input) as never, { + width: 1200, + height: 630, + fonts: loadFonts(root).map((f) => ({ ...f, style: 'normal' as const })), + }); + return new Resvg(svg).render().asPng() as Buffer; +} diff --git a/vite-plugins/page-html.ts b/vite-plugins/page-html.ts new file mode 100644 index 0000000..d5374c3 --- /dev/null +++ b/vite-plugins/page-html.ts @@ -0,0 +1,36 @@ +// Shared per-route HTML helpers, used by both the search-index plugin +// (head-only fallback files) and the prerender plugin (full SSG output). + +// Slim per-doc metadata for nav, SEO, and sitemap. +export interface ManifestEntry { + slug: string; + title: string; + category: string; + description: string; + order: number; + product: string; // top-level folder unless overridden in frontmatter +} + +export const escapeHtml = (s: string) => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +// Rewrite the SPA shell's head for one doc page: title, description, OG tags, +// plus canonical/og:url when the deploy has a public base URL. +export function pageHtml(shell: string, entry: ManifestEntry, siteName: string, siteUrl?: string): string { + const title = escapeHtml(`${entry.title} — ${siteName}`); + const desc = escapeHtml(entry.description || `${entry.title} — ${siteName} documentation.`); + let html = shell + .replace(/[^<]*<\/title>/, `<title>${title}`) + .replace(/(', + ` \n \n `, + ); + } + return html; +} diff --git a/vite-plugins/prerender.ts b/vite-plugins/prerender.ts new file mode 100644 index 0000000..327a5e2 --- /dev/null +++ b/vite-plugins/prerender.ts @@ -0,0 +1,206 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { Plugin, ResolvedConfig } from 'vite'; + +import { pageHtml } from './page-html'; +import type { ManifestEntry } from './page-html'; +import { generateOgImage } from './og-image'; + +// Build-time prerendering (SSG). Runs in the CLIENT build's closeBundle, +// registered AFTER searchIndexPlugin: by then the head-only per-route files +// exist on disk, and this plugin overwrites them with fully rendered bodies by +// importing the SSR bundle (built first — see the `build` script order: +// `vite build --ssr src/entry-server.tsx --outDir dist-ssr && vite build`). +// +// Every bail path below leaves the search-index head-only files in place, so a +// prerender failure degrades to today's behavior instead of breaking releases. +// Set PRERENDER=0 to skip deliberately. + +interface PrerenderOptions { + manifestFile: string; // docs-manifest.json (already regenerated this build) + ssrEntry?: string; // default: dist-ssr/entry-server.js + siteTitle?: string; +} + +interface ServerEntry { + render: (url: string) => { appHtml: string }; + seedServer: (manifest: ManifestEntry[]) => void; + buildThemeVariablesCSS: () => string; +} + +// The shell head (rewritten by pageHtml) is the single source of truth for +// title/meta. React 19 hoists DocPage's helmet tags into the rendered app +// fragment, which would duplicate them — strip those. +function stripHelmetDuplicates(appHtml: string): string { + return appHtml + .replace(/[^<]*<\/title>/g, '') + .replace(/<meta name="description"[^>]*\/?>/g, '') + .replace(/<meta property="og:[^"]*"[^>]*\/?>/g, ''); +} + +// renderToString has no <head>, so React 19 emits its hoisted <style +// data-precedence> tags (Tamagui's CSS) as the FIRST CHILDREN of the app +// fragment. Left inside #root they structurally break hydration (the client +// expects the app's first element there). Relocate them to <head> — that's +// where streaming SSR would put them, and React 19 dedupes hoistables by +// data-href against the whole document, so the client adopts them cleanly. +function extractHoistedStyles(appHtml: string): { body: string; styles: string } { + const styles: string[] = []; + const body = appHtml.replace(/<style [^>]*data-precedence[^>]*>[\s\S]*?<\/style>/g, (match) => { + styles.push(match); + return ''; + }); + return { body, styles: styles.join('\n') }; +} + +// Inline JSON must not be able to close its own <script> tag. +const toInlineJson = (value: unknown): string => JSON.stringify(value).replace(/</g, '\\u003c'); + +export function prerenderPlugin(options: PrerenderOptions): Plugin { + let resolved: ResolvedConfig | undefined; + + return { + name: 'capsa-prerender', + configResolved(config) { + resolved = config; + }, + async closeBundle() { + if (!resolved || resolved.command !== 'build' || resolved.build.ssr) return; + if (process.env.PRERENDER === '0') { + this.info?.('prerender: skipped (PRERENDER=0) — head-only pages remain'); + return; + } + const env = resolved.env ?? {}; + // Security invariant: never bake auth-gated content into public HTML. + if (env.VITE_WORKOS_CLIENT_ID && env.VITE_DEV_BYPASS_AUTH !== 'true') { + this.warn('prerender: skipped — auth-gated deploy (head-only pages remain)'); + return; + } + + const outDir = resolve(resolved.root, resolved.build.outDir); + const ssrEntry = resolve(resolved.root, options.ssrEntry ?? 'dist-ssr/entry-server.js'); + if (!existsSync(ssrEntry)) { + this.warn(`prerender: skipped — SSR bundle not found at ${ssrEntry}`); + return; + } + let shell: string; + try { + shell = readFileSync(join(outDir, 'index.html'), 'utf-8'); + } catch { + this.warn('prerender: skipped — no client index.html'); + return; + } + + const { render, seedServer, buildThemeVariablesCSS } = (await import( + pathToFileURL(ssrEntry).href + )) as ServerEntry; + + const manifest: ManifestEntry[] = JSON.parse(readFileSync(options.manifestFile, 'utf-8')); + const scope = env.VITE_PRODUCT as string | undefined; + const entries = scope ? manifest.filter((e) => e.product === scope) : manifest; + seedServer(entries); + + const siteName = env.VITE_SITE_NAME || options.siteTitle || 'Capsa'; + const siteUrl = (env.VITE_SITE_URL as string | undefined)?.replace(/\/$/, ''); + const siteDescription = + env.VITE_SITE_DESCRIPTION || `${siteName} documentation — guides and API reference.`; + + // Head additions shared by every prerendered page: the semantic theme + // variables (pre-JS style fidelity) and the inlined manifest (hydration + // seeds synchronously — no fetch on the critical path). + const headExtras = + ` <style id="capsa-theme-vars">${buildThemeVariablesCSS()}</style>\n` + + ` <script>window.__CAPSA_MANIFEST__ = ${toInlineJson(entries)};</script>\n </head>`; + + const ROOT_DIV = '<div id="root"></div>'; + if (!shell.includes(ROOT_DIV)) { + this.error(`prerender: shell is missing the exact marker ${ROOT_DIV}`); + return; + } + + // '' renders the /docs index (DocsIndex); slugs render DocPage. + const routes: Array<{ slug: string; entry: ManifestEntry | null }> = [ + { slug: '', entry: null }, + ...entries.map((e) => ({ slug: e.slug, entry: e })), + ]; + + let rendered = 0; + for (const { slug, entry } of routes) { + try { + const url = slug ? `/docs/${slug}` : '/docs'; + const { appHtml } = render(url); + const { body, styles } = extractHoistedStyles(stripHelmetDuplicates(appHtml)); + + // Social card PNG for this route. The file is always emitted; the + // og:image tags need an absolute URL, so they're only injected when + // the deploy has a public base URL (same rule as canonical). + let ogTags = ''; + try { + const png = await generateOgImage(resolved.root, { + siteName, + title: entry?.title ?? siteName, + description: entry?.description || siteDescription, + category: entry?.category, + }); + const relPath = slug ? `assets/og/${slug}.png` : 'assets/og/docs-index.png'; + const imgDest = join(outDir, relPath); + mkdirSync(dirname(imgDest), { recursive: true }); + writeFileSync(imgDest, png); + if (siteUrl) { + const abs = `${siteUrl}/${relPath}`; + ogTags = + `<meta property="og:image" content="${abs}" />\n` + + ` <meta property="og:image:width" content="1200" />\n` + + ` <meta property="og:image:height" content="630" />\n` + + ` <meta name="twitter:image" content="${abs}" />\n `; + } + } catch (err) { + this.warn(`prerender: og image for "${slug || '/docs'}" failed — ${(err as Error).message}`); + } + + let page = (entry ? pageHtml(shell, entry, siteName, siteUrl) : shell) + .replace('</head>', `${styles}\n ${ogTags}${headExtras}`) + .replace(ROOT_DIV, `<div id="root">${body}</div>`); + if (ogTags) { + page = page.replace( + '<meta name="twitter:card" content="summary" />', + '<meta name="twitter:card" content="summary_large_image" />', + ); + } + + const dirDest = slug + ? join(outDir, 'docs', slug, 'index.html') + : join(outDir, 'docs', 'index.html'); + mkdirSync(dirname(dirDest), { recursive: true }); + writeFileSync(dirDest, page, 'utf-8'); + if (slug) writeFileSync(join(outDir, 'docs', `${slug}.html`), page, 'utf-8'); + rendered++; + } catch (err) { + // Leave the head-only fallback for this route; keep going. + this.warn(`prerender: route "${slug || '/docs'}" failed — ${(err as Error).message}`); + } + } + this.info?.(`prerender: rendered ${rendered}/${routes.length} routes`); + + // Pagefind: index the prerendered article bodies (scoped by + // data-pagefind-body in DocPage) into dist/pagefind/. The ⌘K palette + // uses this at runtime, falling back to the JSON index when absent. + try { + const pagefind = await import('pagefind'); + const { index, errors: createErrors } = await pagefind.createIndex({}); + if (!index) throw new Error(createErrors?.join('; ') || 'createIndex failed'); + const { page_count, errors: addErrors } = await index.addDirectory({ + path: outDir, + glob: 'docs/**/index.html', + }); + if (addErrors?.length) throw new Error(addErrors.join('; ')); + await index.writeFiles({ outputPath: join(outDir, 'pagefind') }); + await pagefind.close(); + this.info?.(`prerender: pagefind indexed ${page_count} pages`); + } catch (err) { + this.warn(`prerender: pagefind indexing failed — ${(err as Error).message}`); + } + }, + }; +} diff --git a/vite-plugins/search-index.ts b/vite-plugins/search-index.ts index 1cb643f..8bcc524 100644 --- a/vite-plugins/search-index.ts +++ b/vite-plugins/search-index.ts @@ -4,6 +4,9 @@ import GithubSlugger from 'github-slugger'; import matter from 'gray-matter'; import type { Plugin, ResolvedConfig } from 'vite'; +import { escapeHtml, pageHtml } from './page-html'; +import type { ManifestEntry } from './page-html'; + // Build-time docs index. Walks the content/ tree and emits two files: // • search-index.json — full records (headings + body) for in-memory search // • docs-manifest.json — slim records for nav, SEO meta, and the sitemap @@ -26,15 +29,7 @@ export interface SearchHeading { id: string; } -// Slim per-doc metadata for nav, SEO, and sitemap. -export interface ManifestEntry { - slug: string; - title: string; - category: string; - description: string; - order: number; - product: string; // top-level folder unless overridden in frontmatter -} +export type { ManifestEntry }; export interface SearchEntry extends ManifestEntry { headings: SearchHeading[]; @@ -244,30 +239,6 @@ function generate(opts: Options): number { return visible.length; } -const escapeHtml = (s: string) => - s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); - -// Rewrite the SPA shell's head for one doc page: title, description, OG tags, -// plus canonical/og:url when the deploy has a public base URL. -function pageHtml(shell: string, entry: ManifestEntry, siteName: string, siteUrl?: string): string { - const title = escapeHtml(`${entry.title} — ${siteName}`); - const desc = escapeHtml(entry.description || `${entry.title} — ${siteName} documentation.`); - let html = shell - .replace(/<title>[^<]*<\/title>/, `<title>${title}`) - .replace(/(', - ` \n \n `, - ); - } - return html; -} - export function searchIndexPlugin(options: Options): Plugin { const isContentFile = (p: string) => p.startsWith(options.contentDir + sep) && /\.mdx?$/.test(p); let resolved: ResolvedConfig | undefined; diff --git a/vite.config.ts b/vite.config.ts index a2473ef..c2e86c5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,10 +15,16 @@ import tsconfigPaths from 'vite-tsconfig-paths'; import remarkCodeTitle from './src/lib/remark-code-title'; import remarkDirectiveCallouts from './src/lib/remark-directive-callouts'; import { searchIndexPlugin } from './vite-plugins/search-index'; +import { prerenderPlugin } from './vite-plugins/prerender'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -export default defineConfig({ +// Two builds share this config (see the `build` script): +// 1. `vite build --ssr src/entry-server.tsx` → dist-ssr (prerender bundle) +// 2. `vite build` → dist (client) +// The prerender plugin runs at the end of the client build and fills the +// per-route HTML with rendered bodies using the dist-ssr bundle. +export default defineConfig(({ isSsrBuild }) => ({ plugins: [ searchIndexPlugin({ contentDir: path.resolve(__dirname, './content'), @@ -64,6 +70,12 @@ export default defineConfig({ config: path.resolve(__dirname, './src/tamagui.config.ts'), }), tsconfigPaths(), + // Must come after searchIndexPlugin: it overwrites the head-only + // per-route files with fully prerendered ones (closeBundle order). + prerenderPlugin({ + manifestFile: path.resolve(__dirname, './public/docs-manifest.json'), + siteTitle: process.env.VITE_SITE_NAME || 'Capsa', + }), ], define: { @@ -77,30 +89,40 @@ export default defineConfig({ }, }, - build: { - outDir: 'dist', - sourcemap: true, - target: 'esnext', - rollupOptions: { - output: { - // Split the heavy framework deps out of the main entry. Scalar stays in - // its own lazy chunk (loaded only on the API route) — not matched here. - // remark/rehype/highlight.js run at build time in the MDX plugin, so - // they are not in the client bundle at all. - manualChunks(id) { - if (id.includes('node_modules')) { - if (/[\\/](react|react-dom|react-router|react-router-dom|scheduler)[\\/]/.test(id)) { - return 'vendor-react'; - } - if (id.includes('@tamagui') || /[\\/]tamagui[\\/]/.test(id)) return 'vendor-tamagui'; - if (id.includes('@tabler')) return 'vendor-icons'; - if (id.includes('@mdx-js')) return 'vendor-mdx'; - } - if (id.includes('/src/theme/')) return 'vendor-themes'; + // Bundle the UI stack into the SSR output rather than externalizing it — + // Node-resolving tamagui/react-native-web ESM at prerender time is fragile. + ssr: isSsrBuild ? { noExternal: [/tamagui/, /react-native/, /@fontsource/] } : undefined, + + build: isSsrBuild + ? { + outDir: 'dist-ssr', + sourcemap: false, + target: 'esnext', + } + : { + outDir: 'dist', + sourcemap: true, + target: 'esnext', + rollupOptions: { + output: { + // Split the heavy framework deps out of the main entry. Scalar stays in + // its own lazy chunk (loaded only on the API route) — not matched here. + // remark/rehype/highlight.js run at build time in the MDX plugin, so + // they are not in the client bundle at all. + manualChunks(id) { + if (id.includes('node_modules')) { + if (/[\\/](react|react-dom|react-router|react-router-dom|scheduler)[\\/]/.test(id)) { + return 'vendor-react'; + } + if (id.includes('@tamagui') || /[\\/]tamagui[\\/]/.test(id)) return 'vendor-tamagui'; + if (id.includes('@tabler')) return 'vendor-icons'; + if (id.includes('@mdx-js')) return 'vendor-mdx'; + } + if (id.includes('/src/theme/')) return 'vendor-themes'; + }, + }, }, }, - }, - }, server: { port: 3001, @@ -109,4 +131,4 @@ export default defineConfig({ allow: [searchForWorkspaceRoot(process.cwd()), path.resolve(__dirname, 'node_modules')], }, }, -}); +}));