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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public/docs-manifest.json
public/sitemap.xml
public/llms.txt
public/llms-full.txt
public/feed.xml
public/md/

# Playwright
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ React + Tamagui app you fully own and can edit.
- **AI-native** — generates `llms.txt`, per-page "Copy as Markdown", and "Open in ChatGPT/Claude".
- **Edit this page** — every doc links to its source on GitHub (menu + below
the article), so readers can send fixes.
- **Changelog** — dated release notes in `content/changelog/` get their own
tab, a newest-first listing, and an RSS feed.
- **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.
Expand Down
20 changes: 20 additions & 0 deletions content/changelog/v0-1-0.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
title: v0.1.0 — Initial release
date: "2026-06-15"
description: The first release of Capsa — a self-hosted documentation platform you own end to end.
---

# v0.1.0 — Initial release

The first public release of Capsa.

## Added

- MDX content pipeline with callouts, tabbed code samples, API method badges,
and see-also cards.
- ⌘K command palette over a build-time search index.
- Interactive OpenAPI reference powered by Scalar.
- Four theme styles (steel, aurora, retro, shadcn) × light/dark, token-driven.
- AI-native exports: `llms.txt`, per-page "Copy as Markdown", open-in-ChatGPT/Claude.
- Optional WorkOS authentication for private docs.
- One-command deploy to Cloudflare Pages (or any static host).
29 changes: 29 additions & 0 deletions content/changelog/v0-2-0.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
title: v0.2.0 — Prerendering, search, and a front door
date: "2026-07-10"
description: Full static prerendering, Pagefind search, social cards, a landing page, and edit-this-page links.
---

# v0.2.0 — Prerendering, search, and a front door

The release that makes Capsa sites fully static-first.

## Added

- **Full prerendering (SSG)** — every doc ships as real HTML with the complete
article and crawlable nav links, then hydrates into the SPA. `PRERENDER=0`
opts out.
- **Pagefind search** — the ⌘K palette now runs on a typo-tolerant static
index built from the prerendered pages; no search service required.
- **Social cards** — a branded `og:image` PNG is generated per page at build
time.
- **Landing page** — drop in `content/home.mdx` and `/` becomes a real home
page with hero, feature-grid, and CTA components.
- **Edit this page** — every doc links to its source on GitHub.

## Fixed

- Body text rendered semi-bold site-wide due to size-linked font weights; all
body sizes now default to weight 400.
- Theme styles now apply their full semantic token sets (accents, sidebar
colors, radii) in both JS and CSS.
2 changes: 1 addition & 1 deletion content/configuration/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ All are optional. A bare deploy needs none of them.
| `VITE_SITE_NAME` | Brand shown in the top bar, titles, footer. Defaults to `Capsa`. |
| `VITE_SITE_ORG` | Footer copyright name. Defaults to `VITE_SITE_NAME`. |
| `VITE_DEFAULT_THEME_STYLE` | Pin a theme (`steel`, `aurora`, `retro`, `shadcn`) and hide the switcher. |
| `VITE_SITE_URL` | Base URL — enables `sitemap.xml`, canonical + `og:image` tags, absolute links in `llms.txt`. |
| `VITE_SITE_URL` | Base URL — enables `sitemap.xml`, canonical + `og:image` tags, the changelog `feed.xml`, absolute links in `llms.txt`. |
| `VITE_PRODUCT` | Scope nav + search to one product folder (multi-product setups). |
| `VITE_GITHUB_URL` / `VITE_STATUS_URL` / `VITE_SUPPORT_URL` | Footer links — each renders only if set. `VITE_GITHUB_URL` also powers the "Edit this page" links. |
| `VITE_GITHUB_BRANCH` | Branch the "Edit this page" links target. Defaults to `main`. |
Expand Down
2 changes: 1 addition & 1 deletion content/getting-started/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ edit.
- **MDX content** — write Markdown, drop in React components when you need them.
- **⌘K command palette** — instant, keyboard-first search over a build-time index.
- **OpenAPI reference** — a full interactive API explorer (Scalar) from your spec.
- **Theming** — five built-in styles × light/dark, all token-driven and brandable.
- **Theming** — four built-in styles × light/dark, all token-driven and brandable.
- **AI-native** — generates `llms.txt`, a per-page "Copy as Markdown", and "Open in ChatGPT/Claude".
- **Authoring components** — callouts, tabbed code samples, API method badges, "see also" cards.
- **Public by default** — auth is opt-in; a fresh clone runs with no configuration.
Expand Down
49 changes: 49 additions & 0 deletions content/guides/changelog.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
title: Changelog
description: Publish dated release notes with a listing page and an RSS feed.
order: 6
---

# Changelog

Add dated MDX files under `content/changelog/` and Capsa gives you a
**Changelog tab**, a newest-first listing at `/docs/changelog`, per-entry pages,
and an RSS feed — no configuration.

## Writing an entry

```mdx title="content/changelog/v1-2-0.mdx"
---
title: v1.2.0 — Webhooks
date: "2026-08-01"
description: Outbound webhooks, retries, and a new signing scheme.
---

# v1.2.0 — Webhooks

## Added

- Outbound webhooks with automatic retries.
```

- `date` (ISO `YYYY-MM-DD`, quoted) drives the listing order and shows next to
the breadcrumb on the entry page.
- `title` and `description` appear on the listing card, in search, and in the
RSS feed.
- Entries are otherwise **normal docs**: searchable, prerendered, social-carded,
with edit-on-GitHub links — everything a page under `content/` gets.

## How it behaves

- Entries never appear in the Documentation sidebar — the changelog is its own
tab (an `href` tab in `src/navigation.ts`, like the API Reference).
- The listing page at `/docs/changelog` sorts entries newest-first by `date`.
- Remove the `content/changelog/` folder and the tab simply 404s — delete the
tab entry in `src/navigation.ts` to hide it entirely.

## RSS

On deploys with `VITE_SITE_URL` set, the build emits `feed.xml` (RSS 2.0) with
every dated entry, and the site `<head>` advertises it via
`<link rel="alternate">`. Readers and release-watchers can subscribe to
`https://your-site/feed.xml`.
44 changes: 44 additions & 0 deletions e2e/prerender.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,50 @@ test('docs pages link to GitHub editing and back home', async ({ page, request }
await expect(page.locator('#root h1')).toContainText('Documentation that ships itself');
});

test('changelog listing is prerendered newest-first', async ({ request }) => {
const html = await (await request.get('/docs/changelog')).text();
expect(html).toContain('<title>Changelog — Capsa</title>');
const root = html.split('<div id="root">')[1] ?? '';
const v2 = root.indexOf('v0.2.0');
const v1 = root.indexOf('v0.1.0');
expect(v2).toBeGreaterThan(-1);
expect(v1).toBeGreaterThan(-1);
expect(v2, 'newest entry listed first').toBeLessThan(v1);
expect(root).toContain('Jul 10, 2026'); // TZ-free formatted date
});

test('changelog entry renders as a doc with its date and hydrates', async ({ page, request }) => {
const html = await (await request.get('/docs/changelog/v0-2-0')).text();
const root = html.split('<div id="root">')[1] ?? '';
expect(root).toContain('Jul 10, 2026'); // breadcrumb date
expect(root).toContain('Pagefind search'); // article body prerendered
await expectHydrated(page, '/docs/changelog/v0-2-0/');
});

test('changelog stays out of the Documentation sidebar and docs index', async ({ request }) => {
const doc = await (await request.get('/docs/guides/theming')).text();
const sidebar = doc.split('<div id="root">')[1] ?? '';
expect(sidebar).not.toContain('v0.1.0');
const index = await (await request.get('/docs/')).text();
const indexRoot = index.split('<div id="root">')[1] ?? '';
// No changelog CATEGORY CARD on the docs index (cards render <h3> labels;
// the TopNav tab and the "Changelog" guide's sidebar links are spans).
expect(indexRoot).not.toMatch(/<h3[^>]*>Changelog</);
expect(indexRoot).toMatch(/<h3[^>]*>Guides</); // cards themselves render
});

test('changelog entries are searchable via pagefind', async ({ page }) => {
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('front door');
const pages = await Promise.all(results.map((r: { data: () => Promise<{ url: string }> }) => r.data()));
return pages.map((p) => p.url);
});
expect(hits).toContain('/docs/changelog/v0-2-0/');
});

test('Scalar route client-renders via the marker-mismatch fallback', async ({ page, request }) => {
// /docs/api is not prerendered; static hosts serve the landing-page HTML as
// the SPA fallback. The route marker ("/") must not match, forcing a clean
Expand Down
4 changes: 4 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import LoginPage from './pages/authentication/LoginPage';
import HomePage from './pages/HomePage';
import DocPage from './pages/docs/DocPage';
import DocsIndex from './pages/docs/DocsIndex';
import ChangelogIndex from './pages/docs/ChangelogIndex';
import { HAS_HOME } from './hooks/useDocs';

// Lazy so Scalar (heavy) is split out of the main bundle.
Expand Down Expand Up @@ -51,6 +52,9 @@ const App: FC = () => {
</Suspense>
),
},
// Exact segment only — /docs/changelog/<entry> still hits the splat
// below and renders as a normal doc.
{ path: 'changelog', element: <ChangelogIndex /> },
{ path: '*', element: <DocPage /> },
],
},
Expand Down
1 change: 1 addition & 0 deletions src/hooks/useDocs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface ManifestEntry {
description: string;
order: number;
product: string;
date?: string; // ISO YYYY-MM-DD — set on changelog entries
}

// Optional per-product scope (4.1): when set, only that product's docs show.
Expand Down
12 changes: 12 additions & 0 deletions src/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,15 @@ export interface TocEntry {
text: string;
level: number;
}

// Format an ISO date string (YYYY-MM-DD) as "Jul 10, 2026" WITHOUT going
// through Date/toLocaleDateString: UTC-midnight parsing shifts a day across
// timezones, and prerendered pages must render the identical string during
// hydration on any client. Pure string math is deterministic everywhere.
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export function formatDate(iso: string): string {
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) return iso;
const month = MONTHS[Number(m[2]) - 1] ?? m[2];
return `${month} ${Number(m[3])}, ${m[1]}`;
}
5 changes: 5 additions & 0 deletions src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,10 @@ export const navigation: NavConfig = {
icon: 'code',
href: '/docs/api',
},
{
tab: 'Changelog',
icon: 'file',
href: '/docs/changelog',
},
],
};
71 changes: 71 additions & 0 deletions src/pages/docs/ChangelogIndex.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useNavigate } from 'react-router-dom';
import { Helmet } from 'react-helmet-async';
import { YStack, H1, H3, Paragraph, Text } from 'tamagui';

import { useDocsList } from '../../hooks/useDocs';
import { anchorNavProps } from '../../lib/navLink';
import { formatDate } from '../../lib/markdown';
import { SITE_NAME } from '../../lib/site';

// Newest-first listing of content/changelog/ entries. Entries themselves are
// ordinary docs (rendered by DocPage via the * splat); this page just lists
// them by their `date` frontmatter.
export default function ChangelogIndex() {
const { docs } = useDocsList();
const navigate = useNavigate();

const entries = docs
.filter((d) => d.category === 'changelog')
.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''));

return (
<YStack flex={1} padding="$6" paddingTop="$4" overflow="scroll" alignItems="center">
<Helmet>
<title>{`Changelog — ${SITE_NAME}`}</title>
</Helmet>
<YStack width="100%" maxWidth={760} gap="$5">
<YStack gap="$2">
<H1 fontWeight="700">Changelog</H1>
<Paragraph size="$5" color="$colorPress">
Release notes and updates for {SITE_NAME}.
</Paragraph>
</YStack>

<YStack gap="$4">
{entries.map((entry) => (
<YStack
key={entry.slug}
{...anchorNavProps(`/docs/${entry.slug}`, () => navigate(`/docs/${entry.slug}`))}
padding="$4"
gap="$1.5"
borderWidth={1}
borderColor="$borderColor"
borderRadius="$lg"
cursor="pointer"
hoverStyle={{ borderColor: '$accentBorder' }}
>
{entry.date && (
<Text fontSize={12} color="$colorPress">
{formatDate(entry.date)}
</Text>
)}
<H3 fontWeight="600" fontSize={18}>
{entry.title}
</H3>
{entry.description && (
<Paragraph size="$4" color="$color11">
{entry.description}
</Paragraph>
)}
</YStack>
))}
{entries.length === 0 && (
<Paragraph color="$colorPress">
No entries yet. Add dated MDX files under <code>content/changelog/</code>.
</Paragraph>
)}
</YStack>
</YStack>
</YStack>
);
}
17 changes: 14 additions & 3 deletions src/pages/docs/DocPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ArrowLeft, ArrowRight, PencilSimple } from '@phosphor-icons/react';
import { useDoc, useDocMeta, normalizeSlug } from '../../hooks/useDocs';
import { anchorNavProps } from '../../lib/navLink';
import { useNavigation } from '../../hooks/useNavigation';
import { formatDate } from '../../lib/markdown';
import type { TocEntry } from '../../lib/markdown';
import mdxComponents from '../../components/markdown/mdxComponents';
import TableOfContents from '../../components/layout/TableOfContents';
Expand Down Expand Up @@ -81,12 +82,16 @@ export default function DocPage() {
const prev = idx > 0 ? orderedPages[idx - 1] : undefined;
const next = idx >= 0 && idx < orderedPages.length - 1 ? orderedPages[idx + 1] : undefined;

// Frontmatter title (via the manifest) beats the filename-derived one —
// e.g. "v0.2.0 — …" instead of "V0 2 0" for changelog entries.
const pageTitle = meta?.title ?? doc.title;

return (
<XStack flex={1} height="100%" minHeight={0}>
<Helmet>
<title>{`${doc.title} — ${SITE_NAME}`}</title>
<title>{`${pageTitle} — ${SITE_NAME}`}</title>
{meta?.description && <meta name="description" content={meta.description} />}
<meta property="og:title" content={doc.title} />
<meta property="og:title" content={pageTitle} />
{meta?.description && <meta property="og:description" content={meta.description} />}
<meta property="og:type" content="article" />
</Helmet>
Expand All @@ -105,8 +110,14 @@ export default function DocPage() {
/
</Text>
<Text fontSize={12} color="$color11" fontWeight="500">
{doc.title}
{pageTitle}
</Text>
{/* Dated docs (changelog entries) show their date inline. */}
{meta?.date && (
<Text fontSize={12} color="$colorPress">
· {formatDate(meta.date)}
</Text>
)}
<XStack marginLeft="auto">
<PageActions slug={slug ?? ''} />
</XStack>
Expand Down
4 changes: 3 additions & 1 deletion src/pages/docs/DocsIndex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { useDocsList } from '../../hooks/useDocs';
import { SITE_NAME } from '../../lib/site';

export default function DocsIndex() {
const { navTree } = useDocsList();
const { navTree: fullTree } = useDocsList();
const navigate = useNavigate();
// Changelog entries have their own tab + listing — no category card here.
const navTree = fullTree.filter((category) => category.name !== 'changelog');

return (
<YStack flex={1} padding="$6" paddingTop="$4" gap="$6" overflow="scroll">
Expand Down
1 change: 1 addition & 0 deletions vite-plugins/page-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ManifestEntry {
description: string;
order: number;
product: string; // top-level folder unless overridden in frontmatter
date?: string; // ISO YYYY-MM-DD — set on changelog entries
}

export const escapeHtml = (s: string) =>
Expand Down
Loading
Loading