This page is also available in Italiano.

Continua in italiano

Building a documentation site without a framework

Published on July 24, 2026

This site — the one you're reading this on — has no build framework underneath it. No 11ty, no Astro, no Next. docs-app/build.mjs is a few hundred lines of plain Node that reads content modules and template functions and writes plain HTML/CSS/JS into dist/. That was a deliberate reading of the spec's constraint, and it produced its own share of very real bugs, all found the same way: opening the site in an actual browser.

What "no framework" actually meant

The spec ruled out a runtime framework for the shipped site — nothing client-side doing routing, hydration, or rendering. It did not rule out a small build-time script that turns structured content into static files, which is a different thing entirely from what a reader's browser has to execute.

docs-app/src/lib/highlight.mjs (a hand-rolled regex tokenizer for syntax highlighting) and src/scripts/search.js (a hand-written substring scorer over a prebuilt JSON index) follow the same reading: no Fuse.js, no client-side highlighting library, both done at build time or with plain vanilla JS.

Content is data, not markup

Every doc and blog page is a small .mjs module exporting meta and a blocks array — a tiny DSL (heading/p/list/code/callout/…) that a single renderBlocks(blocks, lang) function turns into HTML. The same function extracts the search-index chunks (one per heading) from the exact same source used to render the page, so content and search index structurally cannot drift apart the way two independently maintained sources would.

Three bugs found only by opening the browser

None of these three showed up from reading the code. All three were caught by actually clicking around the rendered site:

  • Unsized SVG icons. The GitHub icon in the header rendered at its raw ~300×150 intrinsic size instead of a normal icon size, because nothing set an explicit width/height on it. Fixed with a defensive fallback rule, svg { width: 1em; height: 1em; }, in base.css — every icon still needs an explicit size rule somewhere, but a missing one now fails quietly instead of loudly.
  • IntersectionObserver throwing a silent SyntaxError. The "on this page" scroll-spy was first attempted with rootMargin: '-5rem'. rootMargin only accepts pixels or percent, never rem — the browser throws, but only to the console, not anywhere a page reload would surface. It was rewritten as a plain requestAnimationFrame-throttled scroll listener instead, which also fixed a UX issue: a TOC entry now stays active for the whole time its section is on screen, not only the instant its heading crosses the viewport.
  • A hardcoded English H1 on the Italian homepage. A content bug, not a logic bug — the hero heading had an English string baked in even when serving /it/. Only visible by actually loading the Italian page.

A pattern worth naming

All three bugs above — plus a fourth found later (a native and a custom "clear" button both rendering on the search input) — share one property: they were invisible in the source and only visible once rendered. Type checking and unit tests do not catch a missing CSS size rule or a browser-specific API contract violation. This is the concrete argument, from this project's own history, for actually opening the page instead of trusting a clean lint/type-check run.

One deliberate compromise: no language auto-redirect

The chosen language persists in localStorage, but a mismatched page only ever shows a dismissible banner ("Continue in English" / "Continua in italiano") — it never force-redirects. A forced redirect is bad for deep links, breaks back-navigation, and actively fights crawlers trying to evaluate hreflang tags correctly. This was a conscious trade-off between convenience and correctness, and correctness won.

These three were not the last bugs this site produced this way, either. A later pass, after the site actually went live, turned up a further, unrelated round, covered in the release post — a clean build is not the same thing as a working page, and this project needed reminding twice.

to navigate to select Esc to close