Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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) ─────────────────────────────────
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ public/md/
# Playwright
test-results/
playwright-report/

# SSR prerender bundle (intermediate build output)
dist-ssr/
62 changes: 45 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down
171 changes: 171 additions & 0 deletions e2e/prerender.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <h1> 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('<div id="root">')[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(/<title>/g)).toHaveLength(1);
expect(html.match(/property="og:title"/g)).toHaveLength(1);
expect(html).toContain('<title>Theming — Capsa</title>');

// 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('<div id="root">')[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 <Theme> 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('<div id="root">')[1] ?? '';
// No prerendered content for the API route — it renders client-side.
expect(root.slice(0, 20).trim().startsWith('</div>')).toBe(true);
});
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading
Loading