Tour

How the tour site is built, from meander submodule through post-process to [GitHub Pages](https://pages.github.com) deploy.

Topics: Anatomy Building Parsing Validation URL Ecosystems Comparison Security Architecture Builders Contributing Converters Hardening Release Tour VERS

Tour

How the tour site you are reading is built, from source markdown all the way to the URL in your browser. Read this if you are about to change anything under scripts/tour.mts, tour.json, upstream/meander/, .github/workflows/pages.yml, or the CSS/JS shims at the repo root.

Who this is for #

New contributors who need to touch the tour build pipeline and want to understand the full flow before changing something. No prior knowledge of meander, Val Town, or static-site generators required.

The two-sentence summary #

The tour is a pile of flat HTML files generated from annotated TypeScript source + a manifest, deployed to GitHub Pages. Most of the work is already done by meander (a submodule); this repo layers on top of meander's output with a post-process pass that adds Socket chrome, renames files for public URLs, injects security headers, and minifies everything.

A note on the naming #

Meander and this repo disagree a little on what to call things, and that is visible if you grep the tree. Here is the rule:

Concept Name Why
This build/system/brand tour Short, speakable, the public name
The config manifest tour.json Matches brand
The main build script scripts/tour.mts Matches brand
The pnpm commands pnpm tour:* Matches brand
The final output directory pages/ The finished site, ready to deploy
The generator submodule upstream/meander/ That is the upstream project name
The meander-emitted CSS walkthrough.css Meander hardcodes the filename; we rename to style.css
The meander-emitted parts walkthrough-part-N.html Meander hardcodes; we rename in post-process
Our CSS/JS shims at repo root overrides.css, drag.js, comments.js, sw.js Sources copied into the output
Our CSS class prefix wt-* Backronym: "walking tour"

Why the build happens in a scratch dir, not in pages/: Meander writes into <cwd>/walkthrough/ — a fixed name inside the submodule. scripts/tour.mts runs meander at repoRoot, then immediately moves the output into a private scratch directory (.tour-build-<uuid>/), does all post-processing there, and renames the finished tree to pages/ at the very end. The scratch dir is cleaned up in a finally block whether the build succeeds or fails. Net effect: pages/ only ever contains a complete, consistent site — there is no half-built state, and a concurrent pnpm tour:serve never sees mid-build flux.

Everything else (prose, branding, commands, CLI help) says "tour".

The ten-thousand-foot picture #

 ┌──────────────────────────────────────────────────────────────────┐
 │  source inputs                                                   │
 │                                                                  │
 │    src/*.ts               tour.json                  docs/*.md   │
 │    (annotated code)       (parts + docs manifest)    (prose)     │
 │                                                                  │
 └───────┬──────────────────────┬─────────────────────────┬─────────┘
         │                      │                         │
         ▼                      │                         │
 ┌──────────────┐               │                         │
 │   meander    │ reads src/*,  │                         │
 │  (submodule) │ emits HTML    │                         │
 │              │ using manifest│                         │
 └───────┬──────┘               │                         │
         │                      │                         │
         ▼                      ▼                         ▼
 ┌──────────────────────────────────────────────────────────────────┐
 │  .tour-build-<uuid>/walkthrough/ (private scratch -- cleaned     │
 │                                   up in a finally block)         │
 │    walkthrough-part-1.html          <-- meander writes this      │
 │    walkthrough-part-2.html              shape; we rename below.  │
 │    ...                                                           │
 │    index.html                                                    │
 │    manifest.json                                                 │
 │                                                                  │
 └───────┬──────────────────────────────────────────────────────────┘
         │
         ▼
 ┌──────────────────────────────────────────────────────────────────┐
 │  scripts/tour.mts post-process                                   │
 │                                                                  │
 │    1. Append Socket overrides to the emitted stylesheet          │
 │    2. Copy drag/comments/SW scripts                              │
 │    3. Copy favicons                                              │
 │    4. Render docs/*.md -> <filename>.html  (marked)              │
 │    5. Inject Topics section into index.html                      │
 │    6. Per-HTML loop: chrome, home link, part-pill aria,          │
 │       base-path rewrite, rename walkthrough-part-N.html ->       │
 │       <title-word>.html (anatomy.html, parsing.html, ...)        │
 │    7. CDN script malware audit (Socket SDK)                      │
 │    8. Rename walkthrough.css -> style.css                        │
 │    9. Minify style.css + shim JS                                 │
 │   10. SRI hash injection on every <script>/<link>                │
 │   11. CSP meta tag insertion                                     │
 │   12. Atomic swap: scratch/walkthrough/ -> pages/                │
 │                                                                  │
 └───────┬──────────────────────────────────────────────────────────┘
         │
         ▼
 ┌──────────────────────────────────────────────────────────────────┐
 │  final pages/ ready to deploy                                    │
 │                                                                  │
 │    anatomy.html, building.html, parsing.html,                    │
 │    validation.html, conversion.html, ecosystems.html,            │
 │    comparison.html, security.html,                               │
 │    architecture.html, builders.html, converters.html,            │
 │    safety.html, vers.html, tour.html,                            │
 │    contributing.html, release.html,                              │
 │    index.html, style.css (minified, with overrides),             │
 │    comments.js/drag.js/sw.js (minified), favicons                │
 │                                                                  │
 └───────┬──────────────────────────────────────────────────────────┘
         │
         ▼
 ┌──────────────────────────────────────────────────────────────────┐
 │  .github/workflows/pages.yml                                     │
 │                                                                  │
 │    - checkout + submodule init + pnpm install                    │
 │    - pnpm tour:build (CI env -> --prod preset)                   │
 │    - upload pages/ as Pages artifact                             │
 │    - actions/deploy-pages                                        │
 │                                                                  │
 └───────┬──────────────────────────────────────────────────────────┘
         │
         ▼
 ┌──────────────────────────────────────────────────────────────────┐
 │  https://socketdev.github.io/socket-packageurl-js/               │
 │    /anatomy.html, /parsing.html, /tour.html, ...                 │
 └──────────────────────────────────────────────────────────────────┘

The moving parts #

tour.json — the manifest #

Source of truth for everything the tour ships. Lives at the repo root. Structure:

{
  "slug": "socket-packageurl-js",
  "title": "Socket PackageURL.js Tour",
  "commentBackend": "https://socketdev--<val>.web.val.run",
  "parts": [
    {
      "id": 1,
      "title": "Anatomy of a PURL",
      "filename": "anatomy",
      "objective": "Understand the pkg:type/ns/name@version?q#sub shape ...",
      "keywords": ["purl", "package-url", "anatomy", "components"],
      "files": ["src/package-url.ts", "src/purl-component.ts", "src/constants.ts"]
    },
    ...
  ],
  "docs": [
    {
      "filename": "architecture",
      "title": "Architecture",
      "source": "docs/architecture.md",
      "summary": "Module map, data flow, and the key abstractions ..."
    },
    ...
  ]
}

Two notable fields per part:

And two notable fields per doc:

upstream/meander/ — the generator #

Meander is a walkthrough generator written by Dale Bustad. It reads the TypeScript files listed in each part's files and extracts every multiline comment block as an annotated section. The left pane shows the comment text (as markdown); the right pane shows the corresponding code chunk.

Meander is pulled in as a git submodule under upstream/meander (pinned to a specific commit in .gitmodules) because it is not published to npm. The pin is intentional: a random upstream change could silently break our build, so every update has to be a deliberate bump.

Meander is a generator only — it writes HTML into a scratch directory (.tour-build-<uuid>/walkthrough/) and does not know about our post-processing. That separation means we can evolve our chrome (CSP, SRI, TOC shape, doc rendering) without forking meander.

scripts/tour.mts — the orchestrator #

The single build script. Commands:

Preset flags:

.github/workflows/pages.yml — the deployer #

The workflow file that ships the tour to GitHub Pages on every push to main that touches tour sources. Paths that trigger it:

Anything not on that list (e.g. a README.md edit) skips the Pages job. That keeps the artifact deploys correlated with real content changes.

Two jobs:

  1. Build — checkout + submodule init + pnpm install + pnpm tour:build + upload pages/ as a Pages artifact.
  2. Deployactions/deploy-pages consumes the artifact and publishes.

Deploys are queued serially with cancel-in-progress: false so a newer commit never aborts a running deploy — half-deployed Pages is worse than a slightly stale deploy.

Why the filenames look the way they do #

When meander writes a part page, it emits walkthrough-part-<n>.html and bakes Val-Town-shaped href links like /<slug>/part/<n> into the HTML. Our post-process layer does two things:

  1. Renames walkthrough-part-<n>.html<filename>.html from the manifest (anatomy.html, parsing.html, …).
  2. Rewrites every /<slug>/part/<n> href → <basePath>/<filename>.html when a base path is set (CI / prod), leaves them alone otherwise (dev server translates them at request time).

Short filenames come from the content-filename-from-title skill, which codifies the "pick the domain noun" rule used to choose anatomy, parsing, conversion and friends over clunkier alternatives.

The dev server (routeToFile inside scripts/tour.mts) knows the same part-id → filename map, so when you navigate to http://127.0.0.1:8080/socket-packageurl-js/part/1 it translates that to the on-disk file anatomy.html — the hrefs in the dev build remain /<slug>/part/<n> and round-trip through the server.

Why we inject CSP and SRI #

Everything the browser loads on a deployed page (Socket CSS, the drag script, the comment shim, the service worker, highlight.js from unpkg, etc.) gets an SRI hash injected before deploy. The hash is computed over the exact bytes that ship (post-minify), so a compromised asset fails the integrity check and the browser refuses to run it.

The meta CSP tag lists each inline script's sha512 hash in script-src, rather than using 'unsafe-inline'. Together with the SRI hashes on external scripts, the page can execute only the exact bytes we signed off on — tampering anywhere breaks the page rather than silently running evil code.

See buildCspMeta and injectSri / sriForUrl in scripts/tour.mts if you need to change what gets allowed.

Why we ship a service worker #

The tour CSS weighs in around 35 KB, plus ~9 KB of drag script, ~1.8 MB of rendered HTML across all 16 pages, and highlight.js pulled from unpkg. On a cold load that's fine. On a return visit we want instant paint.

sw.js (served from pages/sw.js) implements:

Every deploy flips a __CACHE_VERSION__ sentinel (replaced at build time with the current git HEAD short SHA), so browsers detect a new SW and activate's prune-old-cache logic fires. Locally the SW unregisters itself on localhost / 127.0.0.1 so rapid iteration never fights stale SW caches.

The comment backend #

Comments are a separate story that only loosely touches this pipeline. The static site ships a tiny shim (comments.js) that talks to a Val Town HTTP function at commentBackend (configured in tour.json). The val/ tree under repo root is the implementation — Hono routes, libsql storage, AES-GCM encryption of comment bodies. The val is deployed separately via pnpm tour:valtown.

If you are only changing the static site's prose or chrome, you will never touch the val. If you are changing the comment shim's protocol (shape of what the browser sends), you will also be changing the val's routes to match.

Running locally, end to end #

# First-time setup — init the meander submodule, install its deps,
# build its dist/. Idempotent; subsequent runs detect and skip.
pnpm install

# Build the site once into ./pages/
pnpm tour:build

# Serve it on http://127.0.0.1:8080/
pnpm tour:serve

# Or build once + watch for source changes + serve in one command:
pnpm tour:watch

Ports and URLs:

Adding a new part #

  1. Pick a single-word lowercase filename per the content-filename-from-title skill (e.g. "provenance").
  2. Add a part entry to tour.json with id, title, filename, objective, keywords, files.
  3. Make sure every file in files has well-placed multiline comments — those become the annotated sections.
  4. pnpm tour:build and confirm the page emits at pages/<filename>.html.

Validator failures you might hit:

All validation errors name the offending part, the rule, and the fix, per the ERROR MESSAGES doctrine in CLAUDE.md.

Adding a new topic doc #

  1. Pick a single-word lowercase filename (same rules as parts).
  2. Write the markdown at docs/<filename>.md.
  3. Add a doc entry to tour.json with filename, title, source, summary.
  4. pnpm tour:build.

Markdown features supported (via marked with GFM enabled):

Markdown features not supported:

When meander breaks #

Meander is a small project and occasionally needs a fix upstream. The path of last resort:

  1. Fork divmain/meander.
  2. Point .gitmodules at your fork branch.
  3. Run pnpm tour:build --refresh to re-clone.
  4. Send a PR to divmain/meander for the fix; revert to upstream when merged.

Before forking: check if the issue is in our post-process layer instead — we own 2000+ lines of scripts/tour.mts and many bugs live there.

Where to look when something is off #