STEP 8 docs/step-8-learn-site.md

Step 8 — /learn Documentation Site + PDF Export ✅ (2026-10-19)

Verified by: node scripts/build-learn.js run twice in a row (identical output, no drift) · one page per source document plus a landing page and the print guide (13 documents + README → 16 pages) · ./scripts/build-pdf.sh rendering learn/gamehub-guide.pdf (68 A4 pages) through headless Chrome · a spot-check that the GitHub-style tables in the step notes render as real tables, not pipe soup · the CI drift check detecting a stale site when a one-line README edit is not regenerated, and passing on the committed learn/.

What was built

The step notes in docs/ and the README.md used to live only as Markdown in the repository. They are now a small static site under learn/, plus a single printable guide, generated by two dependency-free scripts.

PieceImplementationWhy not the alternative
Site generator (scripts/build-learn.js)Node 22 CommonJS, built-ins only (node:fs, node:path) — reads README.md + docs/*.md, writes learn/index.html, one learn/<slug>.html per document, learn/guide.html, learn/style.css and learn/fonts/A static-site framework (Eleventy, Jekyll, VitePress) → hundreds of transitive packages and a build toolchain to keep updated for a handful of Markdown files; rendering Markdown in the browser → every visitor downloads a parser and the page is empty without JavaScript
Hand-written Markdown renderer (~200 lines)Headings, paragraphs, bold/italic/inline code, fenced code, links, autolinks, images, ordered/unordered lists, nested lists, blockquotes, task-list checkboxes, horizontal rules and GitHub-style tablesmarked / markdown-it → a runtime dependency (and a supply-chain surface) for rules this project's docs actually use; the docs lean on tables, so table support is the part that had to be right
EscapingEvery block escapes text (&, <, >, ") before inline formatting; code spans are extracted first and re-escaped on restoreTrusting the source → README.md legitimately contains <https://…> links and <canvas> inside code spans; the renderer has to show them literally without ever emitting raw injected HTML
PDF export (scripts/build-pdf.sh)Runs the generator, then prints learn/guide.html with headless Chrome/Chromium (--print-to-pdf) into learn/gamehub-guide.pdfA PDF library (PDFKit, Puppeteer) → Puppeteer bundles a browser; PDFKit lay out Markdown by hand. The machine already has a browser, so the browser does the page layout
Design (learn/style.css)The app's own arcade language — dark panels, cyan/magenta accents, Chakra Petch + Press Start 2P — with a sidebar listing every document and a PDF download buttonGitHub-flavoured CSS → the docs are part of this project and should look like it

The generated learn/ directory is committed, not ignored: it is a build output, but a small, reviewable one, and it means the site can be published without running a generator in the deploy path.

How the guide becomes a PDF

learn/guide.html is a second rendering of the same documents, print-optimised rather than screen-optimised: an A4 @page rule, a cover page with the project title, a contents list, and each document starting on a fresh page with its <h1> as the section break. scripts/build-pdf.sh locates the browser (/Applications/Google Chrome.app/… on macOS, then chromium/google-chrome on Linux, overridable with CHROME_BIN) and prints that one file. Because the guide reuses the same renderer as the site, the PDF cannot drift from what the site shows.

Things that had to be got right

  1. Tables are the whole point. The step notes and the README are mostly | … | … | tables — what was built, why, and why not the alternative. A renderer without GFM table support would have produced an unreadable site, so the table parser (header, separator, alignment from :---) was written and checked first.
  2. Inline code inside emphasis. **GET /api/games/:id** must produce a <strong> wrapping a <code>, not literal asterisks. Code spans are pulled out into placeholders before escaping and put back at the end, so they can never be re-parsed as emphasis.
  3. Fonts stay self-hosted. The site and the PDF both reference fonts/press-start-2p-latin-400.woff2 and the Chakra Petch faces relatively, and the generator copies them out of frontend/fonts/ into learn/fonts/. There is no Google Fonts import anywhere — the same GDPR reasoning as Phase 2.
  4. A second run changes nothing. The generator is deterministic: the same Markdown produces the same bytes, so re-running it never shows up as a spurious diff and CI could gate on it if the docs were generated there.
  5. The success status was the bug. /learn/ answered 200 with the game hub's landing page, and the deploy's smoke test passed on it because it asserted a string (GameHub) that both pages contain. The check is now specific: it looks for readme.html, which is on the docs landing page and nowhere else. A verification step is only as strong as its most specific assertion.

Concepts to understand cold

  • A hand-written renderer vs a framework: what you actually own when you write ~200 lines of Markdown parsing, and when that trade stops being worth it
  • Escaping order: why you extract code spans, escape, then format in a single left-to-right pass (so generated HTML is never re-scanned)
  • Print CSS: @page size: A4, break-before: page, and break-inside: avoid on table rows — how a web page becomes a document that paginates
  • Static hosting with relative paths: why every asset is referenced relatively (style.css, fonts/…, gamehub-guide.pdf) so the same files work at / locally and under /learn/ on CloudFront

How /learn/ is served

The site is plain static files with relative references, so it is published under the learn/ prefix of the same S3 bucket that serves the app, and the same CloudFront distribution serves it at <distribution>/learn/ — no second bucket, no second CDN, no new origin. CloudFront already knows how to serve objects the bucket does not make public (private bucket + Origin Access Control), so the documentation inherits that.

scripts/build-frontend.sh copies learn/ into build/frontend/learn/, so the deploy's root sync (which uses --delete) publishes the docs and keeps them: the prefix is part of the artifact being synced on every build, which is why no separate prefix-scoped sync is needed and why a delete-sync of the root cannot remove the docs by accident.

Two guards keep the published site truthful:

  • A drift check in CI: the manifests job re-runs the generator and fails on any diff against the committed learn/, and asserts that every document in README.md and docs/*.md has a page. A generated artifact that is committed and never checked is a generated artifact that goes stale — and this one already had, when README.de.md and the step notes were committed without regenerating it.
  • The PDF is excluded from that comparison, because it is rendered by a headless browser and is not byte-reproducible on a runner. make learn regenerates the site and the PDF together, and the deploy publishes whatever is committed.

A directory URL is not a file

/learn/ first answered 200 with the game hub's landing page. An S3 origin reached through an Origin Access Control is a REST origin: it has no notion of an index document, so a request for the key learn/ is a 403 — and the distribution's custom error responses turn a 403 into /index.html with a 200. A wrong page wearing a success status is worse than an error, because nothing reports it; the deploy's own smoke test passed on it.

The fix is a twelve-line CloudFront Function on the viewer request of the static cache behaviour: a URI ending in / gets index.html appended, and an extensionless last segment is treated as a directory so /learn works as well as /learn/. It returns /api/* untouched, so its correctness does not depend on cache-behaviour routing. Twelve URI cases are exercised locally before the function is deployed, and the reasoning — including why a real learn/ object in the bucket was rejected — is ADR-017 in decisions.md.

Interview questions this unlocks

  1. "You generated documentation from Markdown — why not use a static-site framework?" (dependency budget vs the rules this project actually needs)
  2. "How do you stop user- or author-supplied Markdown from injecting HTML?" (escape order and code-span placeholders)
  3. "How did you turn a web page into a paginated A4 PDF?" (print CSS + a headless browser that already exists on the machine)
  4. "Two independent websites live in one S3 bucket — how do you deploy one without deleting the other?" (prefix-scoped sync; never --delete the root)
  5. "Your health check passes and the page is wrong. How is that possible?" (a custom error response turning an origin 403 into a 200, and a smoke test whose only assertion held for both pages)