module.f.mjs is a NodeProgram that generates the site from the repository it sits in: one page per directory, each carrying that directory's contents and the proofs of its subtree, plus the one stylesheet they all link. It is run by npm run website, and its whole output is gitignored.
This file holds the decisions the generator rests on — the ones that are about the site rather than about any one function. What each function does, and why it does it that way, is in its own JSDoc.
wrangler.jsonc publishes the repository folder itself: "assets": { "directory": "." }. There is no separate output tree. A generated page sits in the directory it describes, and .gitignore keeps index.html and the _-prefixed files out of the tree.
The one build that does not run in the repository's development environment is this one. Cloudflare's Workers Builds generates the site on its own image and takes its Node version from .node-version at the repository root — the file exists for that reader alone, and nothing else in the repository consults it. The rest of what the build needs, the build command installs first (the build command).
That is what lets a page load any repository file by its path at run time. A page's proofs are the real .f.mjs and .f.js modules under it, imported by the browser from where they actually live; the source and documentation views planned in todo/ fetch the same files. None of that works from a directory of copies.
emergent_testing/todo/browser-testing.md describes an eventual isolated application root that exposes HTML and JavaScript only and deliberately does not serve the working tree. That is that issue's concern, for automated runners. Module pages do not depend on it, and moving the site under such a root would break every fetch they make — so the two are separate, and this one is not quietly migrated into the other.
Two controls beside Demo distinguish Copy section link (plain chain) from Copy with inputs (chain with a visible text label). The section link always copies #demo without a query; input links use #demo for defaults. Example links such as /fjs/compiler/?example=hex-escape#demo restore a stable example; custom inputs use individual query parameters such as ?compiler=source#demo. Restore validates all inputs before replacing state and never presses action buttons. Each settled edit pushes a history entry carrying the same query, so the address bar is always the current link and Back walks the edits. The encoded query plus #demo has a 16 KiB limit. New demos with inputs must declare defaults, choices and an atomic sharing adapter; textDemo supplies one automatically. See the sharing contract for encodings, validation, clipboard feedback, startup edits and history navigation.
Generated module pages expose #demo and #emergent-testing targets. README headings keep their GitHub-style IDs, including numeric suffixes for repeated headings. The page reserves demo, emergent-testing, directory-drawer, and drawer-title; a conflicting README heading gets the first available numeric suffix. Empty heading slugs start at section. Heading text is unchanged. Demo IDs and local references use a separate, per-instance namespace at prerender and runtime; see DOM identifiers.
A native permalink beside each documentation or main section heading works without JavaScript. Once the shared fragment runtime binds, a keyboard-accessible Copy link button replaces that fallback. It copies the current page URL with the heading fragment, reports clipboard success or failure, and leaves both the address bar and section's open state alone. Controls sit outside the heading's accessible name.
Initial fragment navigation, hash changes, back/forward navigation, and repeated links to the current fragment open collapsed ancestors before scrolling. Encoded IDs are decoded once; malformed escapes and missing targets leave the page usable. Navigating to Emergent Testing never presses Run or starts tests.
Shareable demo inputs use query parameters, leaving fragments for section navigation. The navigation layer reveals the target immediately; the restoration runtime calls revealFragment again after applying the complete input set so scrolling uses the final layout.
/cli/ is the standalone FunctionalScript CLI reference, linked as CLI from the shared header. The generator reserves root cli/ alongside prs/ and excludes both from source discovery on repeated builds. /fjs/cli/ and /fjs/website/cli/ remain ordinary source-directory pages. A clean build creates cli/index.html; a leftover reserved directory from a branch switch is never catalogued as source.
cli/module.f.mjs imports the real commands export from fjs/module.f.mjs. It walks nested command tables and appends the dispatcher's shared helpMeta at each table level. Imports construct data and effect descriptions without calling handlers or interpreting effects. Names, alias order, descriptions and nesting therefore have one source of truth, including implicit help behavior. Descendant aliases include every parent spelling: cas add also accepts s add, and nested help includes s help, s h and s ?. Canonical paths continue to identify sections and bookmark links.
Usage, examples, explanatory notes and documentation paths live beside each handler in the CLI's DocumentedCommands tables. The type requires these fields for every built-in command and nested subcommand. Implicit help reference text comes from helpReference in fjs/cli/module.f.mjs, using the canonical parent path. The website consumes these fields directly; it has no separate guide list or name-based documentation lookup. A command metadata change therefore appears in the next website build, including displayed snippets and copied text.
All commands are visible in anchored semantic sections without JavaScript. Copy buttons enhance the static code blocks, using the shared clipboard helper and existing styles; they appear only after binding and report success, failure or clipboard unavailability. No command runs in the browser. File documentation links use the same commit-aware policy as directory listings.
The CLI catalogue progressively enhances into a searchable command list and selected detail pane. Search matches canonical names, aliases and descriptions without changing the selected command. Native fragment links preserve bookmarking and browser history; without JavaScript, every detail section remains visible.
/prs/ shows the open pull requests in functionalscript/functionalscript: their linked number, author, and open/draft state with a CI/check summary. The shared header links it as PRs. The page uses the same frame and stylesheet as the directory pages.
The browser loads pull requests and checks only when Refresh is clicked. Opening the page does not load that list. Visitors can read public results without logging in. Optional Log in with GitHub uses the visitor's API rate limit. No polling or stored snapshot is needed. Checks are read for each PR's current head commit, combining latest check runs and commit statuses; a passing summary does not assert that the PR is mergeable or that every branch protection requirement is met.
A failed read is shown as a failure, not an empty list or a passing result. When refreshing the PR list fails, retained rows are marked stale. GitHub's unauthenticated API limit applies; the page reports limits and keeps a direct link to GitHub available.
The generator reserves the root prs/ output directory and excludes it from source discovery. The implementation's fjs/website/prs/ directory remains an ordinary source page. A repeated build produces the same catalogue.
prs/load/ defines pagination, rate-limit refusals, worker limits and refresh feedback as pure decisions with co-located proofs. The browser adapter reads responses, performs requests and DOM updates, and supplies browser clock and date-formatting results to that policy.
playground/ holds games, one subdirectory each, each a demo. The shared header links it as Playground.
It needs no page of its own. Every directory gets a page, so the playground's page lists its games, and a directory says it holds a demo, so each game carries a play mark there: a new game is a new subdirectory, and nothing else changes. A game is an application of the modules it plays with, not part of them, which is why it lives here rather than beside them: a module's own demo explains the module, a game is played with it.
/cargo.doc/ is rustdoc's output for the workspace's crates. Each crate's directory page links its part of it as API documentation, and the directory above the crates — nanvm/ — links the index as Rust API documentation. A directory is a crate's when it has a Cargo.toml and its path, with / and - read as _, names a crate rustdoc documented: nanvm/lib is nanvm_lib.
The generator copies target/doc there unchanged and writes one page of its own, cargo.doc/index.html, listing the crates: stable rustdoc writes no root page. It does not run cargo — whoever builds the site runs cargo doc --no-deps --workspace first, and a build without target/doc writes no cargo.doc/ and no links. target/ itself is never served: it holds build artifacts beside the docs, and .assetsignore keeps it out of every upload, whichever build made it.
rustdoc's pages carry the site's favicon, header and footer. npm run rustdoc-bars writes them, from the same builders every page uses, as _rustdoc-header.html and _rustdoc-footer.html, with _rustdoc.css for their look. It also writes _rustdoc-head.html from the shared favicon links; cargo doc puts them on each page it writes when it is given them. They have to exist before cargo doc runs and the site is generated after it, so a build with the favicon and bars is:
npm run rustdoc-bars
cargo clean --doc
RUSTDOCFLAGS="--html-in-header $PWD/_rustdoc-head.html --html-before-content $PWD/_rustdoc-header.html --html-after-content $PWD/_rustdoc-footer.html --extend-css $PWD/_rustdoc.css" cargo doc --no-deps --workspace
npm run website
rustdoc-bars is a program of its own (rustdoc-bars/) so the site is generated once: it reads the build the pages are built from — a preview's branch and commit, the funding channels — and walks nothing.
cargo clean --doc is not optional: cargo reuses earlier docs when its flags are unchanged, even though the files they name have changed. Two rules in _rustdoc.css lean on rustdoc's own layout, and its JSDoc says which; the rest of rustdoc's look is its own (rustdoc-site-theme).
--html-in-header adds the site’s ICO and SVG links after rustdoc’s own icons in the pinned rustdoc, and the card tags (A shared link shows a card). The original links remain; the HTML icon selection rule prefers the last equally appropriate icon. Verify the tab icon after a hard refresh when changing the rustdoc pin.
cargo.doc/ is not cleaned between local builds, as no generated page is: the generator only writes. A build without target/doc leaves an earlier copy in place, and a copy over an earlier one keeps files rustdoc no longer writes — its search index's files are named by content hash, so every rebuild adds some. Nothing links the leftovers, and a deploy builds from a fresh checkout, which has no cargo.doc/ at all, so none of them ships. git clean -Xd removes them locally.
cargo.doc/ is the one generated name outside index.html and the _-prefixed files, so .gitignore and tsconfig.json name it, and the walk excludes it as it excludes prs/. The deploy installs the pinned Rust and runs these steps, without cargo clean --doc, which a fresh checkout does not need (the build command).
The generated Website workflow (.github/workflows/gen.website.yml) builds the WASM demos, styled Rust documentation and website in its own website job with the committed mise lock. Pull requests, including drafts and forks, build the website; same-repository PRs upload previews. Fork PRs build without uploading. Pushes to main deploy production. Other branches need an open PR (a draft is enough) for a preview.
The original CI workflow keeps all nine test jobs on PRs and merge-group entries, using its existing cache. It has no deployment gates or push trigger. Website uploads have their own concurrency and do not affect test jobs. The website workflow does not run in the merge queue; the website check is not a required queue check. Wrangler is pinned to 4.74.0, and builds keep the existing WORKERS_CI_COMMIT_SHA and WORKERS_CI_BRANCH metadata inputs.
Cloudflare Workers Builds is disconnected. Every same-repository pull-request run uses wrangler versions upload --preview-alias after its own successful build. The website job uses wrangler deploy only on pushes to main, after changes pass review and required checks, then merge through the queue. An active main upload is not cancelled by a newer push. No enable-switch variables are required. The account ID is committed in wrangler.jsonc. Its keep_vars: true setting retains existing Worker variables, and Wrangler version uploads retain existing secrets. Actions does not send empty OAuth overrides or require copies of the OAuth settings in GitHub. The legacy Workers Builds helper keeps its existing explicit bindings during the transition. Verify inherited GitHub sign-in on the resulting preview as part of the upload check.
Configure one repository secret in GitHub → Settings → Secrets and variables → Actions:
| Kind | Name | Value |
|---|---|---|
| Secret | CLOUDFLARE_API_TOKEN | Scoped token with Workers Scripts edit access for the existing Cloudflare account; add the route/zone permissions required by any configured routes. |
The upload step receives the token only after the website build succeeds. A missing token fails the upload instead of opening an interactive login.
Branch aliases are lowercased, with runs of punctuation replaced by hyphens and leading/trailing hyphens stripped. There is no temporary actions- prefix: Workers Builds is disconnected. The alias, separating dash and Worker name must together fit a 63-character DNS label, giving the functionalscript Worker a 46-character branch-alias budget. Aliases must begin with a letter; empty aliases and names starting with a digit are refused before upload. For example, codex/cloudflare-preview-comment becomes codex-cloudflare-preview-comment, giving the preview URL https://codex-cloudflare-preview-comment-functionalscript.functionalscript.workers.dev. Different branch names can normalize to the same alias, so keep them distinct. The upload logs report the version and alias URLs. After a successful Actions preview upload, github-actions[bot] posts an Open preview link on the PR and updates that same comment on later pushes. It reads the actual alias URL from Wrangler output and uses the automatic Actions token with pull-request write permission; no additional secret, action, or setting is required. Forks and merge-group runs never post preview comments. Check OAuth on the alias URL as well as the commit preview URL.
Cutover status and verification:
CLOUDFLARE_API_TOKENis configured. The owner verified GitHub sign-in on the Actions preview and disconnected Cloudflare Workers Builds.- Production deployment is enabled on main pushes. After changing deployment workflows, verify the production URL, commit links, Rust docs, demos and GitHub sign-in on the next main upload.
- The temporary preview prefix is removed now that Workers Builds is disconnected. Retain the legacy build script and Cloudflare-only Node pin until rollback no longer needs them.
To roll back, revert the production step before reconnecting Workers Builds with the commands below. Restore a known-good Worker deployment from the Cloudflare dashboard if needed. Worker secrets and OAuth callbacks stay intact.
Cloudflare's build command runs sh ./gen.cloudflare/build.sh, a generated script: it installs what the build image lacks, then runs the WASM demo build, npm run rustdoc-bars, cargo doc and npm run website, which publishes the Rust API reference (the Rust API reference). The command itself is set in the Cloudflare dashboard, so the script is what keeps its steps in the repository, where they are reviewed and have a history. npm run website stays the generator alone: a checkout first builds the WASM assets with ./dev.sh sh ./nanvm/demos/gen.build.sh, then runs npm run website. Only the deploy carries the installs. The dashboard's command falls back to npm run website where the script is absent (Configure Cloudflare), so a branch from before the script keeps its preview.
The build has the Rust CI tests with. The build image has Node but no Rust, and the script installs one with rustup's installer, at exactly rust from fjs/ci/config/module.f.js — the one constant the shared shell and the platform matrix also take. The script is generated from that constant by cloudflare/module.f.mjs, so the version is written in the script in full, a bump of the pin is a diff the drift check guards, and nothing in the dashboard names a version that could drift. The toolchain goes into the build container's home, and its cargo is on the script's PATH. target/, which cargo doc writes beside the docs, is in .assetsignore and never served.
Installing Rust in the build was chosen over the alternative, building and deploying from CI in the development environment with wrangler, for what it keeps: Cloudflare builds every branch and gives each its preview URL, and no Cloudflare token goes to GitHub. The alternative would have one environment for everything, but would move branch previews into the generated workflow and leave a fork's pull request, which gets no secrets, without one. The build installs only the tools it needs within the deployment time limit. What this choice costs is rustup's download and install on every deploy. The repository owner approved the script's invocation of curl, rustup's installer, npm and cargo (AGENTS.md §6).
One rule rather than two: whatever a directory holds, it gets an index.html listing its subdirectories, its files, the issues in its todo/, and the proofs of its subtree — directories first, as GitHub and a file manager list them. Sections with nothing in them are omitted.
Because every directory has a page, every subdirectory link on every page resolves, which is what makes the tree walkable from the root. The root page is that page for the repository root — it carries the project's name and the same catalogue — rather than a special case beside the rule.
Contents and Issues use the left margin. Both sections live in one catalogue beside the main column, with Contents open and Issues collapsed by default. The catalogue stays in view while the demo or test report grows, and can scroll when its links are taller than the viewport. On desktop it takes a 16rem column, with a full-height divider and content filling the remaining width; at 48rem and below a full-width Contents / Issues bar above the breadcrumb opens a modal drawer at the selected section. Issues shows its count; Issues · 0 stays visible and disabled when empty. Escape, the close button, and the backdrop dismiss it; resizing to desktop restores the sidebar. Without JavaScript the catalogue stays above the page. A page with no catalogue keeps its centered column. Release pages and the pull request queue keep their own layout.
The one exclusion is a todo/ subtree, by path segment: its issues are its parent's open work, so a page of its own would hold nothing else. It is neither generated nor linked, so nothing points at a page that was never written.
Issues are every .md under the todo/ but README.md, at any depth, as todo/README.md defines them: a subfolder's are listed with the rest, named by their path inside the todo/ — blocked/later.md — and a README.md documents its directory rather than being one. Each is read for its header by issue, and one whose status or priority cannot be read stops the build, naming the file, rather than being listed with a guess — the answer a release file that does not parse already gets. Reading them costs the build about two seconds for the few hundred issues the tree holds (at 6d511a0, 366 issues: 17 s against 15 s).
An issue shows its priority as a badge and its status as its icon — P1 ◐ name — and the list is ordered by priority, then status, then name. The P1s, the issues that block everything else, lead the list rather than sitting wherever their names sort, and the badges are its left column, so a reader sees where each priority starts. Within one priority, status is ordered as issue's statuses lists it: work in progress, then work not started, then work waiting or deferred, then the record, then what will not be done. Status first was the other order: it grouped the list by icon and sank the records to the bottom, but put every work in progress, a P5 included, above an open P1.
The status is data-issue-status on the link, beside data-kind, so its icon's text alternative — "blocked issue" — is part of the link's name; not data-status, which the test runner's report already gives passed and failed. The badge, data-priority, is outside the link, before it: it is read as a word of its own, and the link's name and underline are the issue's alone. Its emphasis falls with the priority rather than changing hue: P1 is filled in the failure colour, the conventional one for blocking; P2 in the value colour; P3 outlined; P4 plain muted text; P5 the same, fainter. A hue per level would say nothing about order, and would compete with the status icons. issue's demo draws every status and every priority, the one page that shows the whole set.
A page is the browser test runner with a shorter list, never a second runner. The list is decided at build time — which proofs are under this directory, and which of them a browser can link — and written into the page, so what reaches the browser is an answer rather than a rule to apply.
A proof has one name. A page names each proof relative to itself, which is exactly what fjs t prints when run from that directory: ./proof.f.mjs on the directory that holds it, ./fjs/types/list/proof.f.mjs at the root. Three spellings of one test is the problem this repository has been removing (the two runners).
A proof a browser cannot link is named on its page with what blocks it, rather than dropped, so an empty list means "no proofs here" and nothing else. Where every proof of a subtree is blocked the page keeps that list and offers no control: a run over an empty source list reports passed, and a green verdict for a subtree that ran nothing is the plausible wrong value DESIGN.md §10 refuses.
Nothing starts on load, as browser-test-controls requires. A page binds the runner to its button.
A run's report is grouped by module, and what passed folds away. The root page runs thousands of proofs, and one list of that many identical rows gave a reader no way to find the one that failed. Each module run is a group whose folded line is a green or red dot, the module's path, and its counts at the right edge, failures first — 1 failed · 14 passed — and a group closes as soon as the run moves past it having passed, so what stays open is exactly what needs reading. The whole run's counts sit in the section's title, green and red, with the time. A failure's message and stack are a tinted box of their own under its row. Two runs of the same module with another run between them are two groups; two with nothing between them share one, because a result carries no run identity to tell them apart — and a generated page names each proof once, so it never meets that case. The list of proof sources under the report is shown until a run puts results in it; from then on an entry that produced results is hidden, since it is a group above. Two kinds stay, because no group stands for either: a blocked proof, with its reason, and a proof that ran and reported no tests, which the runner marks after the run.
The runner's own page shows a failure on purpose. A green suite never shows the report's failure state, and breaking a real proof would turn every run red. So fjs/emergent_testing/browser has a demo that runs a small example suite — one module passing, one failing, one with no tests — through the same walk, sandbox and report views a real run uses, and after the run lists the module that reported nothing the way a real page does. It is a demo, never a proof, so no real run sees it. It draws into data-example-* hooks, not data-test-*, because the runner looks those up across the whole page and the demo renders above the suite.
The PR page redirects to GitHub's official authorization page with prompt=select_account, so GitHub shows its account picker even when the browser already has a signed-in account. The request includes a random state and an S256 PKCE challenge. Its /prs/ callback consumes a pending login from tab-local sessionStorage, validates state and its ten-minute lifetime, and removes authorization parameters from the address bar before making requests. Pages set no-referrer before loading subresources.
The Cloudflare Worker in github-worker.mjs exposes only /auth/github/config and /auth/github/token. It exchanges the code with GitHub using the application secret, the original PKCE verifier, and the same deployment's /prs/ callback. The exchange accepts same-origin JSON POSTs only; responses are never cached. Other routes remain static assets. No KV, D1, cookies, user sessions, or token database are required.
After the exchange, the browser verifies the account directly with GitHub and shows Logged in as @username with a profile link and Log out. The page explicitly shows Not logged in to GitHub until that verification succeeds. It holds the access token only in page memory. Subsequent PR and check requests go directly to api.github.com. Log out, a rejected token, a reload, or leaving the page ends this local login. Logging out does not revoke GitHub's app authorization; users can revoke it in GitHub's application settings. The application requests no additional OAuth scopes for the public PR page and account identity.
The Worker forwards a token only when GitHub returns an exact empty scope string. GitHub may reuse permissions granted to this app previously; if it returns those permissions, revoke the app in GitHub’s authorized OAuth apps before logging in again. Missing or malformed scope information is rejected.
Failed login distinguishes authorization-code exchange from account verification. The Worker maps GitHub's documented credential, callback, code, and email errors to a small allowlist of reasons; the browser shows fixed messages and HTTP status. Unknown provider responses and thrown errors remain generic. Provider descriptions, URLs, authorization codes, and credentials never appear in these diagnostics.
Register a GitHub OAuth App. For this Worker's production and PR deployments, set:
GitHub setting Value Homepage URL https://functionalscript.com/Production callback URL https://functionalscript.com/prs/(wildcard matching disabled)Additional Workers callback URL https://functionalscript.workers.dev/prs/(wildcard matching enabled)The Workers callback value is the account's parent hostname, without a literal
*. GitHub's wildcard setting permits callbacks on its subdomains. The actual callback always stays on the deployment where login began, for examplehttps://functionalscript.functionalscript.workers.dev/prs/orhttps://codex-github-login-functionalscript.functionalscript.workers.dev/prs/. The parent URL is a registration rule; users do not navigate to it. GitHub supports multiple callback URLs. The custom production domain uses its exact callback; the Workers production URL and all PR branch previews use the additional callback rule. PR previews usehttps://{branch}-functionalscript.functionalscript.workers.dev/prs/, where{branch}is Cloudflare's normalized branch alias.Set
GITHUB_CLIENT_ID,GITHUB_REDIRECT_URI, and the optionalGITHUB_PREVIEW_REDIRECT_URIas Text Cloudflare Worker runtime variables in Settings → Variables and Secrets, outside the Builds section. Build variables alone do not become Worker bindings. The redirect must be an HTTPS URL ending in/prs/, with no query, fragment, or embedded credentials. Use the same values on production and every preview:GITHUB_REDIRECT_URI=https://functionalscript.com/prs/ GITHUB_PREVIEW_REDIRECT_URI=https://functionalscript.workers.dev/prs/Omit
GITHUB_PREVIEW_REDIRECT_URIwhen only one callback rule is needed. The root Wrangler configuration setskeep_vars: trueso redeploying preserves variables configured in the dashboard.Set
GITHUB_CLIENT_SECRETas an encrypted Cloudflare Worker secret, using the dashboard orwrangler secret put GITHUB_CLIENT_SECRET. Do not place it in source, public assets, or a build-time browser variable.Deploy the Worker and generated assets using the root
wrangler.jsonc.
Until those bindings exist, the public PR list works and the login control reports that login is unavailable. Each configured callback permits its own origin. An HTTPS callback at <account>.workers.dev allows that origin and deployments exactly one DNS label below it, with the same port. Other configured callbacks allow only their exact origin. Other accounts, nested subdomains, and lookalike suffixes are refused. Each deployment receives its own callback URL in /auth/github/config, and the Worker derives the identical URL during token exchange; the browser cannot supply an override. Only deploy trusted code with these app bindings: all allowed previews can run the login flow and have access to the Worker secret. Local development may use HTTP on localhost with its own app; .dev.vars and .env files are excluded from uploads by .assetsignore.
Open /auth/github/config on the site to check the deployed configuration. HTTP 200 returns the public client ID and callback URL; HTTP 503 means a binding is missing or the callback URL is invalid. Its missingBindings array names absent or empty GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_REDIRECT_URI bindings; invalidRedirectUri is true when a nonempty primary callback fails validation. When a nonempty optional preview callback is configured, invalidPreviewRedirectUri reports its validation result. An invalid callback rule disables authentication. These diagnostics contain names and booleans, never binding values. HTTP 403 means the site origin is outside the configured callbacks' permitted deployment origins. This endpoint never returns the application secret. After changing bindings, deploy a version with those bindings to the preview being tested.
See GitHub's OAuth documentation for registration, the account picker, PKCE, and callback wildcard settings. Changing only the Homepage URL does not change callback matching. After updating GitHub's callback, update the Cloudflare bindings and rebuild every preview being tested so its uploaded version receives the new configuration.
Cloudflare disables the runtime bindings editor for an asset-only production deployment, even when a preview version contains the new Worker code. Use github-preview.mjs to attach the app bindings while uploading that preview, without deploying the preview to production:
In Settings → Builds → Variables and secrets, set
GITHUB_CLIENT_ID,GITHUB_REDIRECT_URI, andGITHUB_PREVIEW_REDIRECT_URIas variables, andGITHUB_CLIENT_SECRETas a Secret. Use the same app and the two callback values above to support both production domains and every preview.Set the build command to:
if [ -f gen.cloudflare/build.sh ]; then sh gen.cloudflare/build.sh; else npm run website; fiThe fallback preserves builds for older branches that lack the script.
Change the version command to:
if [ -f fjs/website/github-preview.mjs ]; then node fjs/website/github-preview.mjs; else npx wrangler versions upload; fiThe fallback preserves uploads for older branches that lack the helper.
Rebuild the preview branch after saving those settings.
The repository owner approved this helper’s invocation of npx wrangler versions upload as the Cloudflare Builds version command, and the build command's sh gen.cloudflare/build.sh.
The upload helper passes the public values, including the optional preview callback when configured, as explicit Wrangler variables. It writes the app secret to a private temporary JSON file outside the asset directory, uploads it with wrangler versions upload --secrets-file as an encrypted Worker binding, and removes the file after the upload attempt. The secret never appears in command arguments or the helper's error messages. This uses Wrangler 4.74.0 or newer; see its version upload options.
A module page can say what a module is and whether it passes. A demo is the third thing: a hash function is best understood by typing into a field and watching the digest change.
Discovery is by export, exactly as it is for a proof. A module is a demo module if and only if it exports demo, so a demo may live in demo.f.mjs or inline beside the implementation, and no filename decides it (the proof export). Two demo modules in one directory is refused rather than resolved: a page has one demo section, and a precedence rule would decide silently which of them a reader is looking at. One a browser cannot link is dropped and said on the console — unlike a proof it has nowhere on the page to be listed with its blocker.
This section is how a demo runs. How one looks — its lead, fields, captions, output boxes and refusals — is demo/README.md, so that every demo reads the same.
A demo is pure, and asks for what it needs. It exports init, update and view (demo/types.ts); view answers a media/html tree and update answers an Effect, so a demo never touches the DOM, registers a listener, reads a clock or fetches. One impure runtime, demo-runtime.mjs, renders what a demo describes and hands each event back. A demo is therefore provable like any other .f.mjs, and its author writes no host code.
update returns Effect<O, State, never>, and the never is a claim: a demo has no error display apart from what it renders, so a recoverable failure is absorbed into State where view can show it.
What a demo may name is what the runtime implements, which is sandbox and catch from effects/common: host-neutral operations a browser has as surely as Node does, so the first demo to need one needed no browser vocabulary at all. fjs/types/bigint measures two log2 implementations by asking sandbox to run each and report how long it took; nothing in the page knows what is being measured.
A command named in the vocabulary with no handler answers notImplemented through the demo's own channel, which is what never obliges the demo to absorb — the bigint demo turns it into a row saying so, and its proof drives exactly that runner. A command not named is a malformed node and panics: a vocabulary and a handler map are two different things, which is why the runtime declares both.
The browser's own operations are effects/browser's: fetchText, a file from the site as text, and instantiate, a WebAssembly module from the site with how long loading it took. The WASM demo pages (todo/wasm-demos.md) are the first demos to need them.
A page already shows its demo's first view. The build imports each demo and writes view(init) into its section, so the demo appears with the page. Before this, it appeared only once the runtime and the demo's whole module graph had loaded, one import after another: nine levels deep for the runtime, then as many as twenty-six for fjs/compiler's demo. That was a visible lag on every demo page. The view is readable, selectable and editable at once, and the runtime takes it over as it finds it rather than rendering it again: a render would put every field back to its markup, and lose what the reader typed while the modules loaded. What the reader changed is handed to the demo as input events after start, as if typed then. A click leaves nothing to read back, so the build disables every control the runtime hears a click on: disabled on a button, and aria-disabled on an SVG control marked data-demo-click or data-demo-drag, which the stylesheet then keeps from taking a pointer. The first render after start enables them: a button that is not ready says so, rather than taking a click and dropping it. Marking the section inert until then was the other way to keep that input, and it hid the view from screen readers, selection and find-in-page for as long as it lasted, and for good without JavaScript. A demo the build cannot render is named on the build's console, and its page keeps an empty section for the runtime to fill: one that will not import, whose view throws, or whose view a page cannot hold (pageRefusal in media/html): a script, which would run, or a tag or attribute name a parser would read as other markup. A page is written as a string, chunked as it is encoded, because a demo's first view can be larger than one Vec: the module grammar's railroad diagram is about 250 KiB.
Outside incremental work, an update (including the initial start event) marks the section and disables its buttons, and the stylesheet writes Working… under the demo. Only the runtime can say this: a demo renders once, after its effect has finished, so it cannot paint "still going" itself. The word is general because the runtime is — the next demo may be waiting on a network rather than on arithmetic — and a demo wanting its own wording should say so in a field rather than have the runtime guess. Raising the flag is followed by a return to the event loop, because a demo's work runs on the thread that paints and a flag raised and blocked in one task is a flag nobody sees.
A demo can request incremental work through nextEvent(state): return an event to run on a later browser turn, or null to stop. These automatic turns and reader events during incremental work keep controls available, so a reader can stop the work or edit an input. Updates during incremental work must be small: pure computation or a bounded synchronous sandbox call for timing. Do not request blocking I/O on these turns. Queuing a reader event cancels a scheduled turn; the latest rendered state decides whether to schedule another one.
nextDelay(state), when a demo has it, is how many milliseconds the runtime waits before that turn: a demo that animates holds each step on screen for that long. Where the reader has asked for less motion (prefers-reduced-motion: reduce) the runtime does not wait, and the steps pass at once. It is scheduling rather than an operation — the runtime already owns when a turn runs — so a demo needs no effect to wait, and a reader's event cancels the wait as it cancels any scheduled turn.
Events are serialized: one update at a time, the next queued behind it. That is what makes a demo's state a fold over its events in the order they happened, which is the property its proof relies on. start arrives once, after the first render, so a demo needing an operation before it can show anything has somewhere to ask without init becoming an effect.
A demo's output should be checkable from outside. The first one, crypto/sha2, lets readers select SHA-224, SHA-256, SHA-384, SHA-512, SHA-512/224, or SHA-512/256 and shows the digest in hex. The matching OpenSSL command includes the current input, so readers can check the result themselves. Encoding it with this repository's own cBase32 was the first attempt: it made the demo partly about basen, and left a reader no way to tell whether the page was right. Being checkable is not theoretical — the digest is padded to the selected algorithm's hash length divided by four: 56, 64, 96, or 128 hex characters. Converting the digest to a number drops leading zeros, but those zeros still belong in the output. One in sixteen digests begins with a zero hex digit; someone typing 1234 into the original SHA-256 demo found that case.
crypto/sha1 follows the same pattern with a 40-character SHA-1 digest and a warning that its collision resistance is broken. Both demos preserve multiline text with the shared textarea helpers and use website/demo/hash for padded digests and OpenSSL output. website/demo/code owns the copyable code blocks and literal shell arguments. A POSIX shell argument cannot contain NUL (U+0000), so that input still gets a digest but a visible refusal replaces the command; this is a permanent shell limitation.
crypto/hmac adds a UTF-8 key and message to this pattern, with SHA-1 and all six SHA-2 variants. Its OpenSSL command quotes both inputs and uses the selected hash with -hmac. Its message uses the shared multiline field; the key remains single-line and fills the available width. NUL in either field replaces the OpenSSL command with an explanation, just as in the hash demos.
crypto/pow compares the SHA-256 hash of a multiline UTF-8 input plus a decimal nonce with a compact nBits target. Its button advances one nonce at a time, or runs automatically until success or Stop. Starting Auto-run from a valid nonce searches for the next valid one. Its copyable outputs show the actual preimage, target, and digest, alongside the attempt count and failed nonce range. A collapsed “Expected search effort” section below nBits compares the average attempts for several targets. This illustrates the module's single-hash contract rather than Bitcoin block-header mining.
A demo is on its module's page, and every demo sits several levels below the root. So a directory's entry in Contents carries a play mark after its name when a demo is anywhere in its subtree — its own or one further down. From any page, the marks are a trail to every demo below it.
- Under it, not in it. A mark only on a demo's own directory would leave the root page, where a new reader lands, with nothing marked and nothing to follow.
- A mark, not a section. A separate list of demos was the alternative. It needs a name, an order and a caption for each demo; the mark reuses the list a reader already walks, and the directory's name is the caption.
- After the name. The icon in front of an entry is what the entry is; this is something about it. It says "has a demo" to a screen reader, as a kind icon names its kind.
- A play button in a circle, muted, at the kind icons' size. Six looks were rendered side by side — a
demopill, this, a[demo]tag, a folder icon with a play cut-out, a flask, and→ demo— after a bare green triangle was rejected. This one is drawn the way the kind icons are, so the list keeps one set of marks rather than gaining a second style. Its triangle is as large as the ring allows: the first, smaller one shrank to a few pixels on an ordinary monitor.
/_main.css, /fjs/types/index.html, /fjs/emergent_testing/browser/module.mjs — a page at any depth writes the same href for the same target, because a link is built from the repository path and never from where the page sits. The exception is a page's own proof sources, which are relative by design: that is what makes their names the ones fjs t uses.
a, a:visited { color: var(--link) } in style/module.f.js. Nearly every word on this site is a link into the tree — a breadcrumb, a file, a subdirectory, an issue — so a list of them turned two-toned as it was read, and the second colour said only where this reader has been, nothing about the file it names. That distinction earns its keep in a bibliography, deciding what is left to read; these lists are a directory's contents, navigated by structure, and a reader returns to one to go somewhere else from it. The underline stays, so nothing about being a link depends on the colour.
--link is green, and its own token even though it starts at --pass's values. The site names every other colour in :root — background, text, muted text, border, pass, fail — and left links to the browser's blue, the one colour it never chose. Green reads as this site's own: everything is set in a monospace face already (One face, the whole site), and green is the colour that face suggests, a terminal's.
One green cannot serve both colour schemes. The terminal green this was after, #00ff00, is 13.65:1 against the dark background and 1.37:1 against white, where WCAG AA asks 4.5:1 for text — unreadable in the light scheme. So, like every other colour here, it is a pair: #137333 (5.95:1) in :root, #81c995 (9.56:1) under prefers-color-scheme: dark. Quieter candidates toward the terminal green were considered for the dark value — #3fb950 (7.37:1) — and set aside for the same reason the light value was never #0f5132 (9.36:1, the darkest that still passes): the pair the site already had, --pass, passed both, and taking it was the decision to make deliberately rather than by reaching for the nearest green.
That is also why --link is not simply --pass. In the test report green means passed — a module's dot and counts are green, red is a failure — and a link in that same green would make one colour mean both "this went well" and "go here" on the page where both appear. --link keeping --pass's starting values is coincidence, not aliasing: moving one later must not drag the other with it.
Graph hover and focus use --graph-highlight, amber #b45309 on the light canvas and gold #fbbf24 on the dark one. --graph-highlight-bg adds a faint fill to the selected port and title blocks. The thicker arrow and outlines also distinguish the selection; labels keep their normal text colours. The tint is opaque, so several incoming connections cannot darken the same title by stacking translucent fills.
When a row has both a key and a schema reference, hovering either cell activates the connection and highlights both cells as one rounded source block. A port with no separate reference cell keeps its single-cell interaction. Inline-only values do not activate a connection.
The whole visible title cell, including blank space above and below its text, activates incoming-connection highlighting. Its hover area stays within the title column and does not cover adjacent port cells.
These tokens are independent of --value: selecting a connection must not recolour the schema values. Both schemes live in the shared stylesheet, alongside the rest of the site's palette.
favicon.ico at the repository root, favicon.svg in fjs/website/, next to the generator that links it. The site serves the repository directory itself, so a file is served from where it sits — there is nothing to generate, and the mark will not change often enough for a build step to buy anything.
The mark is the project's old "fs" logo, traced, with a "j" added. It spells the name the CLI and the directory already carry. The "f" and the "s" are traced from that logo's PNG — no font it could be set in has since been identified, and a traced path keeps its exact shape rather than approximating it. The "j" is the traced "f" below the x-height: the same stem and the same tail, the crossbar cut back to the stem, a round dot on the stem's axis — so the three letters share one hand without a font to share it. The letters sit 24 units apart at their nearest points, in a 555-unit square.
Paths, not text. Text set in a font renders as whatever the browser resolves that font to, a different shape depending on what is installed; filled paths render the same everywhere, like every other mark on this site drawn as geometry. The file takes --link's two colours, #137333 light and #81c995 dark, via the SVG's own prefers-color-scheme query: the one mark on the page that is this site's own colour and nothing else's. The .ico holds the SVG rendered at 16 and 32 pixels in the light colour.
Both files, because declaring one ends the implicit lookup. /favicon.ico is what a browser asks for when a document declares no icon at all; once a page declares the SVG, a browser that recognizes rel="icon" but cannot render SVG has no reason to go looking for the .ico — the fallback would never be requested in the one case it exists for. So both are declared, and the SVG's type tells a browser which one it can skip. The .ico cannot carry the prefers-color-scheme query itself, so it is fixed to the light value — the fallback for a browser that reads neither the SVG nor the scheme it would have picked.
Slack, Telegram, Discord, X, Facebook and LinkedIn build a card for a pasted link from Open Graph and Twitter <meta> tags, which previewMeta in page/ writes into every page's <head> through shell. They are in the static HTML because a crawler runs no script.
A title and a description on every page. A directory's description is the first sentence of the first paragraph of prose in its README. A paragraph of badges has no text, and a field such as **Status:** implemented, which opens most READMEs under spec/, is a label rather than prose, so both are skipped. A sentence ends at a ., ! or ? outside code, since in code those are operators. A sentence longer than 160 characters, about what a search result shows, is cut at a word and ends with "…"; otherwise each platform cuts it mid-sentence at a length of its own. A release names its version, and every other page uses the site's own sentence.
rustdoc's pages carry what does not vary by page. cargo doc writes the Rust API reference, and the site adds one fixed fragment to every page's <head> there, rustdocHead. It carries siteMeta, the tags shell's pages share: the site's name, the page kind, the card type and, where the build has an origin, the root page's card image. Each page's title and description are rustdoc's own <title> and <meta name="description">, which a card falls back to; og:title and og:url, which Open Graph requires, are missing (rustdoc-page-cards).
A URL and an image wherever the build has an origin. Open Graph wants both absolute, so a page needs to know where it is published, which origin/ says from the build's branch. Production deploys only from a push to main, so a build of main is at https://functionalscript.com. Any other branch is its preview, at the alias the upload names it by, which is computed in that same module so the two cannot disagree. Both write og:url, og:image and the large card, so a preview's card can be checked before it merges. A local build names no branch, has no address anyone can fetch, and gets neither and the small card.
A page with a README shows it as its card. The image is the README as its page shows it, without the header, the catalogue, the breadcrumb or the footer, so a link to a directory says in the card what the directory holds. Every other page — the PR queue, the CLI reference, the changelog's, a README the reader refused, and rustdoc's — shows the root page's card, whose alt text is the site's name; a page's own card is described by its title.
- A screenshot, taken at build time. Only a browser lays out the site's own CSS, so the card cannot drift from the page. The generator writes a card page,
_card.html, beside each page that has a card, and lists them in_cards.json(card/). After it, the website workflow runscards.mjs, which serves the built tree and has headless Chrome screenshot each as_card.png. It drives Chrome over the DevTools protocol with Node's ownWebSocket, so the step adds no npm dependency;CHROME_PATHnames another browser. A local build writes the card pages and no images, as it writes noog:image. The root always has a card, since every other page falls back to it: its README, or the site's name and description where the README is not rendered. - 1200×630 at 1×, light. The size the large cards show uncropped, and those platforms refuse SVG. A card cannot follow the viewer's dark mode.
- The whole README, faded at the bottom. A card that stopped at the first heading would be empty for a README whose intro is a line or none, so it runs on, and the last 140px fade into the background rather than cutting a line in half.
- Trimmed to even margins. 40px above the first heading and 48px on each side; the README's reading width does not apply on a card.
- No controls. Permalinks and buttons are hidden; a picture cannot use them.
- An image from another site gets five seconds. One that has not loaded by then, or fails, is removed before the screenshot, so a slow badge server costs the card its badge and never draws a broken image. A badge shows its value at the build, and the cards are rebuilt with every deploy.
Checking a card. View a page's source, or paste its URL into a preview debugger such as opengraph.xyz. Platforms cache cards, so a link shared before a change keeps its old card for a while.
The logo and the site's name, linking home; then CLI, PRs, Releases, Playground and GitHub. It is one element, header in page/module.f.mjs, and every page carries it — the root, a directory's page, the PR queue, and the changelog's. Before it, the root page had its own "GitHub Repository" and "Releases" links and no other page had either.
- The header says where a reader can go; the breadcrumb says where they are. Both stay: the header is the same on every page, and the breadcrumb is this page's path.
- The logo is the favicon's file,
logoPathinstyle/module.f.js, so the tab and the page cannot show two marks. Itsaltis empty because the name beside it is the link's text. - It spans the window, with one rule under it, and the menu is bold and a step larger than the text, so the header reads as the site's own bar rather than as the first line of the page. Its contents go to the window's edges — the logo at the left, the links at the right — because the bar belongs to the site and the column to the page under it. That is why the column is set on
mainand not onbody: a header inside the column's body could not be wider than the column. Four looks were rendered side by side — a rule under the header only, a framed box, GitHub-style tabs with icons, and bordered buttons — and the box won, then went full width. - It wraps rather than hiding behind a menu button. In a monospace face a phone has no room for the name and all five links on one line; a line break costs no script, on a site that is otherwise static files. The five links are one group, so they wrap together under the name rather than one beside it and one below.
- A header link is not underlined. Its place in the header is what says it is a link, as a site's own navigation does everywhere; the underline comes back on hover. Every link in the page keeps its underline (A link keeps its colour).
- A preview says which build it is. A build from any branch but
mainadds a full-width strip under the menu, its text at the right under the links, as a status bar's is:Preview: <branch> @ <commit>, each linked on GitHub, the commit shortened to seven characters. The branch isWORKERS_CI_BRANCH, which Cloudflare's Workers Builds sets besideWORKERS_CI_COMMIT_SHA. The published site is built frommainand a local build names no branch, so neither shows the strip.
A directory's page, a release's page and the release index open with one line, root / fjs / rtti: each ancestor a link, the page itself as text marked aria-current="page". It is breadcrumb in page/module.f.mjs, a nav holding the page's h1.
- One line, not two. The page used to carry a breadcrumb,
root / fjs, and under it anh1,fjs/rtti— the same path twice, the second time without its root. - It is still the
h1. Only the duplicate is gone, not the heading: a screen reader's list of headings, a search engine and a reader mode take the page's topic from it, and a page with none would give them nothing. Anh1may sit in anav, not the other way round, so thenavis outside. Browsers that still apply the old rule shrinking anh1inside anavshow it smaller; current ones no longer do, so the stylesheet does not set its size. - Every part is heading-sized. A variant setting the ancestors at body size, so the page's own name stood out, was rendered beside it, and the plain heading won.
- A page with no ancestors keeps a plain
h1: the root page, the pull requests and the CLI reference have no path to show.
One line under a rule at the bottom of every page — Support FunctionalScript: and a link per channel — built by footer in page/module.f.mjs into the frame every page shares.
- The footer, not the header. The header says where a reader can go on this site; a funding page is somewhere else. Five placements were rendered side by side — a header link, a section on the root page, this footer, a page of its own, and a strip under the header — and the footer won: it is on every page, where a reader arriving from a search lands, without competing with the site's navigation.
- Read from
funding.json, byfunding/module.f.mjs: one link per channel, itsdescriptionthe text and itsaddressthe target..github/FUNDING.ymlholds only usernames, and turning those into links would mean copying GitHub's unpublished per-platform URL patterns; it stays for GitHub's own Sponsor button, and the two files are kept in step by hand. - A channel the footer cannot link stops the build — no
description, an empty or blank one, or an address that is nothttps://— rather than being left out. A tree withoutfunding.jsonhas no footer, as a tree withoutchangelog/has no release pages.
@media (any-pointer: coarse) {
[data-links] a { display: inline-block; padding-block: .25rem }
}
A page lists its files, directories and issues one link per line with nothing under it — the release index lists its releases the same way — and at d05b70ce, rendered at 390px, every listed link measured 19px tall — under the 24px minimum WCAG 2.2's Target Size (Minimum, AA) sets. These lists are exactly how a reader moves through the tree, so a target a finger cannot pick without risking its neighbour is the site's own navigation working against the reader.
A marker, not a position. A list of links carries data-links, and the rule matches that rather than where the list sits. It first matched [data-section] > ul a, which reached a directory's catalogue and nothing else: the release index lists its links straight under main, and stayed at 19px on a phone. A new list of links opts in by carrying the marker.
any-pointer, not pointer. pointer: coarse reads only the primary pointer, and a touch-screen laptop's primary pointer is its trackpad — fine, even though the screen a reader might tap is right there. That query would leave the laptop's lists at 19px for the one input the padding exists for. any-pointer: coarse asks whether a coarse pointer is available at all, so the laptop's touchscreen gets the same padding its trackpad doesn't need, and a device with no coarse pointer — an ordinary desktop and mouse — keeps the list exactly as dense as it was.
Padded, a link measures 27px, confirmed by rendering the generated site at 390px with a touch-capable viewport; an ordinary 1280px desktop viewport measured the original 19px, unchanged. Like the phone-fit rule (One face, the whole site), no proof holds this — target size is layout, which only a rendering browser can measure.
A listed file or issue links to https://github.com/functionalscript/functionalscript/blob/<commit>/<path> when the build knows its commit, and to its raw path on this site when it does not. Until the site has a source view of its own (todo/source-and-doc-view.md), GitHub is that view: source is highlighted and Markdown is rendered, where the raw file is neither.
- The commit, not the branch. A branch preview outlives its branch, which is usually deleted when the pull request merges, and a page's proofs ran that exact commit — a branch link would show whatever the branch holds today.
- Where it comes from:
WORKERS_CI_COMMIT_SHA, which Cloudflare's Workers Builds sets on every build. A value that is not a SHA-1 commit id is refused, and the build log'sfile links:line says which way a build went. - A local build keeps raw links. It has no such variable, and its unpushed commits would open nothing on GitHub.
- Only what a reader follows moves. A page's proofs and its demo are imported by the browser from this site, and stay there.
- Every link a reader follows is percent-encoded, segment by segment — file, issue and directory links, on this site and on GitHub. A space,
#,?or%in a name would otherwise end the path or change what it means, and the link would go somewhere else without saying so.
Everything is set in a monospace stack, ui-monospace first so each platform supplies its own UI monospace face rather than a terminal default. Nearly every word on this site is an identifier — a file name, a directory, a module path, a breadcrumb, a test name — and the report was already monospace because a test name is a path. Setting one face is what stops the site being two.
Some elements do not inherit it on their own, and each is given font: inherit. A browser's rule for pre names a monospace family, and naming one is what triggers the legacy shrink to 13.33px, so a report would otherwise be set in a nearly-matching face at a nearly-matching size. A form control is given the platform's UI face outright, so Run was Arial at 13.33px on a page otherwise set in monospace at 16px, and a demo's field was the same the moment the first demo landed. Inheriting is what makes "one face" true of the whole page rather than only of its text.
The measure is 63.25rem — GitHub's container-lg, 1012px at 16px, the column GitHub reads a rendered Markdown file in — on main rather than body so the header can span the window. A page here is as wide as the same directory's view on GitHub, which is where every file link on it opens. In this face at 16px it holds about 105 characters.
It was 48rem, GitHub's container-md: about eighty characters, the width this repository's source is written to. That suited a line of source and left most of a desktop screen empty beside a release's notes or a test report, which are prose and results, not source.
A line may break inside a word. A path has no space to break at, so on a phone a page's title, a proof's name or a digest was wider than the screen: the page scrolled sideways, or the report's panel clipped the line. At 360px, on d05b70ce, that was 63 of 216 pages. overflow-wrap: anywhere on body lets any line break where it has to; an identifier split mid-word is still read, and one cut off is not. Breaking a title after its / would read better, and needs the generator to mark where.
No proof holds this. Whether a line fits is layout, which only a browser that renders the page can measure; a proof asserting the rule's text would pass with the rule overridden and fail with it moved. That check belongs with the automated browser runners of emergent_testing/todo/browser-testing.md.
Prose is not exempt, and there is no prose yet. Rendering README.md files is still on todo/, and a long paragraph is slower to read in a monospace face than a proportional one. Whoever builds that will see it on their own page and can decide then whether rendered prose keeps a face of its own; deciding it now, for pages that do not exist, is guessing.
Which modules a browser can link is decided by reading their source, and reading a tree is readdir and readFile. So the whole generator is one effect and a proof drives it against effects/node/virtual's in-memory tree: a directory of fixtures in, a site out, no filesystem touched. What used to check this was running the command and reading a git diff.
The cost is measured and recorded rather than assumed — see the @module block in module.f.mjs and ../text/todo/utf8-to-string-cost.md.
todo/ — the umbrella list is generate-website.