Features & Build Notes · · 3 min read
Building this page's docs engine: registry + markdown, no framework
Documentation documenting itself: markdown as text modules, one registry driving sidebar/sitemap/hreflang, a ToC via regex anchor injection, and live examples from real components — no Docusaurus.
The Docs page you’re reading is built without a documentation framework — no Docusaurus, no VitePress. The whole engine: one TypeScript registry, per-language markdown files, and ~100 lines of JSX components. This is documentation documenting itself.
Why not a docs framework?
This site already has a server (Hono on Cloudflare Workers), a design system (Tailwind), and a markdown pipeline (for the blog). A docs framework would mean a second build step, a theme to reconcile, and a separate deployment. Piggybacking on existing infrastructure = full docs features at near-zero cost.
1. Markdown as modules, a registry as the truth
Wrangler can import .md files as strings (text modules). One registry file declares every doc — slug, category, bilingual titles, and per-language bodies:
// src/lib/docs.ts
import idHtmx from "../../content/docs/id/form-kontak-htmx-workers.md";
import enHtmx from "../../content/docs/en/form-kontak-htmx-workers.md";
export const docs: Doc[] = [
{
slug: "form-kontak-htmx-cloudflare-workers",
category: "features",
navLabel: { id: "Form kontak HTMX", en: "HTMX contact form" },
title: { id: "Membangun form kontak HTMX...", en: "Building an HTMX contact form..." },
stack: ["HTMX", "Hono", "Cloudflare Workers"],
body: { id: idHtmx, en: enHtmx },
preview: { path: "/contact", label: { id: "Lihat form-nya live", en: "See it live" } },
},
// ...
];
From this one array, everything is derived automatically: the category sidebar, the index page, prev/next, the sitemap, llms.txt, and hreflang. Adding a doc = 2 markdown files + 1 entry.
2. The “On this page” ToC: inject anchors at render time
markdown-it doesn’t give headings ids. Rather than adding a plugin, one regex pass after rendering injects an id into every <h2> while collecting the table of contents:
export function docHtml(doc: Doc, locale: Locale) {
const toc: TocEntry[] = [];
const html = renderMarkdown(pick(doc.body, locale)).replace(
/<h2>([\s\S]*?)<\/h2>/g,
(_m, inner: string) => {
const id = anchorSlug(inner);
toc.push({ id, text: stripDecode(inner) });
return `<h2 id="${id}">${inner}</h2>`;
},
);
return { html, toc }; // memoized per isolate — markdown is static per deploy
}
The right sidebar just renders toc as a list of #anchor links. The sticky positioning is pure CSS (sticky top-24).
3. Live examples: real components, not images
Component docs render the actual components on their page. The registry stores only a demo key (data stays serializable); the key → JSX mapping lives in the component:
// src/components/docs.tsx
const demos: Record<string, () => unknown> = {
"ui-components": () => (
<>
<BrowserFrame url="alifnugraha.my.id/preview">…</BrowserFrame>
<SystemFlow nodes={["Storefront", "Checkout", "AI chat", "RAG", "Ops"]} />
</>
),
};
{demo && <figure class="border border-line">{demo()}</figure>}
When the component changes, the docs example changes with it — documentation that cannot go stale.
4. Bilingual without duplicating structure
The markdown bodies are indeed two files (ID + EN), but structure, meta, sidebar, and schema are declared once with the type L = { id: string; en: string }. The routes ride the site’s existing mirror mechanism (/docs ↔ /en/docs) — hreflang and the language switcher come for free.
Lessons
- For personal/project docs, a registry + markdown beats a docs framework: one deployment, one design system, zero extra build steps.
- Keep data as data (a serializable registry); keep rendering as rendering (a separate JSX map). That boundary is what keeps both easy to change.
- The “fancy” docs features — ToC, prev/next, live examples — each turned out to be a dozen lines, not a reason to add a dependency.
Want something like this built for your business?