Docs menu

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.

TypeScriptmarkdown-itHono JSXCloudflare Workers

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?