From b07cbc6541a1f09edc90e0738301d6a07762e66f Mon Sep 17 00:00:00 2001 From: Dan Zyto Date: Fri, 14 Aug 2026 13:54:30 +0200 Subject: [PATCH] docs(runner): make the guide tell the truth, and make drift fail a test (DEV-2203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parity audit read the five guide tracks against the product they describe and found fourteen places where they disagree. The guide loses every one of those arguments, so the text moves; and four new parity tests in guide-tracks.test.mjs read the product's own files so the next drift fails CI instead of a reader. Stale status: - everyone: the 'landing shortly' block for updating by asking is gone — PATCH /api/mcp/demos/:id is merged, so asking works now, with the real boundary stated (MCP-created demos only; browser demos answer 403 and point at /edit). Dead URLs: - ?docs= examples pointed at guides/columns/column-adding and guides/rows/row-sorting, which exist in no bucket; every printed URL now names rows-sorting/react/exampleSortingDemo.tsx, which is in 18.0. - the versioned ?docs= example used v=17.1.0, a version with no docs bucket; it pins 18.0.0. - /all-demos?owner= used a hyphenated slug; the slug is the email local part, dots included. Over-promises: - the budget prose claimed saving and forking survive the ceiling; a Save or Fork boots a build container and the top tiers refuse it, so the guide now promises only what R2 serves — existing links and embeds. - 'imports and file drops with no Handsontable are refused' — only imports are guarded; the drop path has no such check. - 'node_modules, build output and lockfiles are refused, not dropped' — true on the MCP path only; the file drop skips them quietly and stops at 50 files, saying where it stopped. - Download sat in the signed-in Files list; it has never been auth-gated. Naming: - the guide handed /d// out as 'the client link', but both product surfaces (Share's 'Public client link', My demos' Copy link) hand out /share/; the guide now describes what the product does, with /d// as the bare static page the playground displays. - 'This demo is not available' is a string nowhere in the product; the real one is 'This demo is unavailable.' Precision: - the MCP path requires a description (400 without one); said so. - bare integers: 15-19 are majors, 20-999 are refused, 1000 and up are pkg.pr.new refs — 'below 1000 is a version' was two-thirds wrong. - the 'Container (tier 2)' list named four tier-1 starters; the section speaks in engines now, and admits React (Vite, JS) is a container too. - /d// 'cannot be framed at all, on any site' — frame-ancestors is 'self', which is how /share displays it; now 'on any other site'. The four new tests: every printed ?docs= path must exist in the 18.0 manifest; every stated number must equal the product constant it came from (MCP caps, description ceiling, major range, PR-ref floor, payload TTL); every ?example= key must be in the catalog; and every starter the container list names must have engine 'container' in catalog.json, with catalog-tier vocabulary banned from that section outright. --- runner/docs/guide/developers.md | 38 +++--- runner/docs/guide/devrel.md | 11 +- runner/docs/guide/everyone.md | 27 ++-- runner/docs/guide/overview.md | 30 +++-- runner/docs/guide/support.md | 17 ++- runner/pipeline/guide-tracks.test.mjs | 182 +++++++++++++++++++++++++- 6 files changed, 242 insertions(+), 63 deletions(-) diff --git a/runner/docs/guide/developers.md b/runner/docs/guide/developers.md index d72c5aa9..b5d1fe5c 100644 --- a/runner/docs/guide/developers.md +++ b/runner/docs/guide/developers.md @@ -21,7 +21,7 @@ Or skip the clicking: ``` https://demos.handsontable.com/?example=javascript&v=13191 -https://demos.handsontable.com/?docs=guides/rows/row-sorting/react/example1.tsx&v=13191 +https://demos.handsontable.com/?docs=guides/rows/rows-sorting/react/exampleSortingDemo.tsx&v=13191 https://demos.handsontable.com/?example=react&v=https://pkg.pr.new/handsontable@13191 ``` @@ -52,26 +52,30 @@ majors are **15–19**; anything outside that is refused rather than half-workin Two edges worth knowing: -- **Integers below 1000 are read as a version, not a PR.** `18` means 18.0.0. Real - Handsontable PR numbers are five digits, so this only bites on toy input. +- **A bare integer is only a PR ref from 1000 up.** `15`–`19` are read as majors — + `18` means 18.0.0 — and `20`–`999` are refused outright rather than guessed at. + Real Handsontable PR numbers are five digits, so this only bites on toy input. - **The build has to exist.** If CI has not published yet, or the PR comes from a fork that cannot publish, the install fails and the preview says so. `curl -sI https://pkg.pr.new/handsontable@` answering `200` is the check. ## Where a demo runs, and why some are slow -Two tiers, and which one you get is decided by the starter: +Two engines, and which one you get is decided by the starter: -- **In-browser (tier 1)** — the blank templates, JavaScript, TypeScript, React, Vue. +- **In-browser** — the blank templates, JavaScript, TypeScript, React, Vue. Bundled in the page by Sandpack. Fast, cheap, no container, and unaffected by the monthly budget. -- **Container (tier 2)** — Angular, Next.js, Nuxt, Astro, Remix, and the UI-library +- **Container** — Angular, Next.js, Nuxt, Astro, Remix, and the UI-library starters (MUI, Ant Design, Fluent UI, Base Web). A real dev server in a Cloudflare Sandbox, with a real `install`. The first load is slow by construction, and these are - what the budget ceiling pauses. + what the budget ceiling pauses. One surprise in the list: React (Vite, JS) runs in a + container too, despite being a React starter — the instant React is the TypeScript + one. -If you are demonstrating grid behaviour rather than framework integration, pick a tier-1 -starter: it costs nothing, starts instantly, and a PR build resolves in it just as well. +If you are demonstrating grid behaviour rather than framework integration, pick an +in-browser starter: it costs nothing, starts instantly, and a PR build resolves in it +just as well. ## Publish an example from your own machine @@ -107,7 +111,8 @@ is the [Everyone track](/guide/everyone); it applies to you too. ## What the runner accepts -The same limits on every path — file drop, plugin, MCP: +The same rules on every path — file drop, plugin, MCP — with the per-path differences +called out: - **Text files only.** Source (`.js .jsx .mjs .cjs .ts .tsx .vue .svelte .astro`), markup and styles (`.html .css .scss .sass .less .svg`), data and config @@ -116,8 +121,10 @@ The same limits on every path — file drop, plugin, MCP: URL, or inline a data URI. - **`.env` and `.env.*` are never accepted**, on any path, and that is deliberate rather than incidental. -- **`node_modules`, build output and lockfiles are refused, not dropped** — you find out - instead of wondering why the tree looks odd. +- **`node_modules`, build output and lockfiles never make it in**, but the two paths + say so differently. The MCP refuses the whole payload, with the reason. A file drop + skips them quietly — nobody means to drop `node_modules` — and stops at 50 files, + saying where it stopped. - **About 50 files and 256 KB of source** on the MCP path. If an example is bigger than that, it is a project: trim it to the grid, its data and its configuration. @@ -140,9 +147,10 @@ bridge back to a local reproduction: unzip, install, run. version does not have. That is a real answer to a compatibility question, not a broken demo. - **A PR build fails to install.** See above: the build is probably not published yet. -- **"Live editing is paused…"** is the monthly budget on the container tier. Tier-1 - starters keep working, and saving, forking, client links and embeds are unaffected — - they are static builds. +- **"Live editing is paused…"** is the monthly budget on the container starters. + In-browser starters keep working, and existing links and embeds are unaffected — + they are static builds. A Save or a Fork boots a build container, though, so the + top budget tiers refuse those too. - **An import was refused as not-Handsontable.** The guard wants a `handsontable` dependency, an import of it, or a CDN tag. A project that uses it only through your own wrapper package will trip this; add the dependency, or drop the files in instead. diff --git a/runner/docs/guide/devrel.md b/runner/docs/guide/devrel.md index 39baac6c..c165e245 100644 --- a/runner/docs/guide/devrel.md +++ b/runner/docs/guide/devrel.md @@ -55,8 +55,9 @@ changes and download a `.zip`, but cannot save over yours or change its version. article, that is the link for "open this in the playground" under an embed — the embed shows the result, the playground shows how. -The **client link** (`/d//`) cannot be framed at all, on any site. Use it in prose, -never in an `iframe`. +The bare demo page (`/d//`) cannot be framed on any *other* site — it answers with +`frame-ancestors 'self'`, which is exactly how `/share/` and the full-window view +display it here. Use it in prose, never in an `iframe` of your own. ## Full-window preview, for screenshots and recordings @@ -65,7 +66,7 @@ you give it. It works on any starting point, which makes it the tool for a clean screenshot or a screen recording: ``` -https://demos.handsontable.com/?docs=guides/rows/row-sorting/react/example1.tsx&v=18.0.0&mode=full +https://demos.handsontable.com/?docs=guides/rows/rows-sorting/react/exampleSortingDemo.tsx&v=18.0.0&mode=full https://demos.handsontable.com/share/ab12cd34 ``` @@ -87,8 +88,8 @@ live on an older docs branch. Any example from the guides opens here directly: ``` -https://demos.handsontable.com/?docs=guides/columns/column-adding/react/example1.tsx -https://demos.handsontable.com/?docs=…&v=17.1.0 +https://demos.handsontable.com/?docs=guides/rows/rows-sorting/react/exampleSortingDemo.tsx +https://demos.handsontable.com/?docs=guides/rows/rows-sorting/react/exampleSortingDemo.tsx&v=18.0.0 ``` Fork it, extend it into something that carries the article's story — realistic columns, diff --git a/runner/docs/guide/everyone.md b/runner/docs/guide/everyone.md index a3457420..a8dace45 100644 --- a/runner/docs/guide/everyone.md +++ b/runner/docs/guide/everyone.md @@ -106,9 +106,11 @@ Plain language is enough. What actually changes the result: a real container. - **The Handsontable version.** Say nothing and you get the current release. Name a version — "on 17.1.0" — if the conversation is about that one. -- **The description.** Claude writes one, because whoever opens the link later was - not in your conversation. Tell it what to say if the demo is going into a ticket or - a customer thread — a link back to that thread is usually the useful part. +- **The description.** Required on this route: the runner refuses a demo without + one, because whoever opens the link later was not in your conversation. (The + browser editor treats it as optional; here it is not.) Claude writes it — tell it + what to say if the demo is going into a ticket or a customer thread, because a link + back to that thread is usually the useful part. ## Open the link before you send it @@ -139,7 +141,7 @@ So open the client link and look for the grid: at startup. Paste the link back to Claude, say the grid does not render, and ask it to fix and re-save. The link stays the same, so anything you already sent starts working once it is fixed. -- **"This demo is not available" or a 404?** The build failed. Ask Claude to try again. +- **"This demo is unavailable." or a 404?** The build failed. Ask Claude to try again. Claude often **cannot open the link itself** — the sandbox it runs in is not allowed to reach demos.handsontable.com. If it says it could not verify the demo, that is not @@ -174,19 +176,12 @@ meantime. You do not have to start again to change one. Tell Claude what to fix or add — "make the Overdue rows red", "add a VAT column", "the grid does not render, please fix it" — and it updates **the demo you already have**, at the same links. Anything you have already sent -keeps working, and starts showing the new version. +keeps working, and starts showing the new version. "Make me one" and "change it" are the +same conversation, and neither needs the browser. -**Status: landing shortly.** The endpoint behind it is in review -(`handsontable/examples` #177). Until it deploys, Claude can still write the change, and -you apply it one of two ways: - -- Open **`/edit/`**, paste in what Claude gives you, press **Save** — same link, same - demo. Anyone technical can do this in a minute if you would rather not. -- Or ask Claude for a new demo and **delete the old one** in My demos — but only if you - have not sent the first link to anybody. Deleting revokes it for good. - -Once it is live, "make me one" and "change it" are the same conversation, and neither needs -the browser. +One boundary: asking works on **demos the MCP created**. A demo you built in the browser +answers with a refusal — "this demo was not created through the MCP; edit it at +`/edit/` in the browser" — which is where you change those anyway. ## Where your demos live diff --git a/runner/docs/guide/overview.md b/runner/docs/guide/overview.md index c61acfeb..d9fd06a2 100644 --- a/runner/docs/guide/overview.md +++ b/runner/docs/guide/overview.md @@ -25,8 +25,8 @@ A saved demo is reachable four ways, and picking the right one is most of what | Link | What it is | Send it to | | --- | --- | --- | -| `/d//` | the **client link** — a permanent static page of the demo alone | a customer | -| `/share/` | the **read-only playground** — the code, editable in place, not savable | someone who asked "how is it done?" | +| `/share/` | the **public client link** — the read-only playground: the running demo plus its code, editable in place, not savable | a customer, or someone who asked "how is it done?" | +| `/d//` | the **bare demo page** — the permanent static build alone, which the playground displays inside itself | a link in prose, when only the grid should show | | `/embed//` | the **docs embed** — renders only inside `handsontable.com` | an `iframe` in the docs or a blog post | | `/edit/` | the **editor** — yours to change, if you own it | nobody; it is your own bookmark | @@ -38,15 +38,15 @@ Anything you can reach by clicking, you can also link to directly: |-----|-------| | `/?example=blank` | a blank template (also `blank-ts`, `blank-react`) | | `/?example=react` | a framework starter (`javascript`, `typescript`, `vue`, `angular`, `next.js`, `nuxt`, `astro`, `remix`, `mui`, `ant-design`, `fluent-ui`, `base-web`) | -| `/?docs=guides/columns/column-adding/react/example1.tsx` | a documentation example | +| `/?docs=guides/rows/rows-sorting/react/exampleSortingDemo.tsx` | a documentation example | | `/?v=17.1.0` | the same page at a chosen version (combines with the above) | | `/?v=13191` | the same page built from pull request 13191 | | `/?import=` | the import flow for a JSFiddle or StackBlitz URL | | `/?payload=` | a project handed over from the Theme Builder (24 hours) | | `/?mode=full` | the preview alone, no editor chrome | | `/edit/` | your saved demo, editable | -| `/share/` | the read-only playground for a demo | -| `/d//` | the built client page | +| `/share/` | the read-only playground for a demo — the public client link | +| `/d//` | the bare static page a demo builds to | | `/embed//` | the docs embed | | `/my-demos`, `/all-demos` | your demos; everyone's (`?owner=` filters) | | `/settings`, `/guide` | your profile; this guide | @@ -60,9 +60,9 @@ The same four rules whichever route you take: `/share/`. Regenerate the data first, and say in the description that you did. - **Never a licence key, a token, or a `.env` file.** The runner refuses `.env*` outright, but that is a backstop, not your check. -- **Handsontable demos only.** Imports and file drops with no Handsontable in them - are refused by design; this playground is not general hosting. An application with - a backend, a login or its own storage belongs behind the `publish-app` flow instead. +- **Handsontable demos only.** An import with no Handsontable in it is refused by + design; this playground is not general hosting. An application with a backend, a + login or its own storage belongs behind the `publish-app` flow instead. - **Pin the version that matters.** A demo runs at one Handsontable version. If you are reproducing a bug, pin the version it was reported against — that is the whole point of the demo. @@ -78,12 +78,14 @@ back is the fix. **"Live editing is paused until the monthly budget resets…"** The live containers and the AI features cost money per use, so they stop when the month's ceiling is reached -rather than running up a bill. Saved demos, client links and embeds are unaffected — -they are static builds, and saving or forking still works. The in-browser examples -(the blank templates, JavaScript, TypeScript, React, Vue) keep working too. What -pauses is everything that runs in a container: Angular, Next, Nuxt, Astro and Remix, -and also the UI-library starters — MUI, Ant Design, Fluent UI, Base Web — which look -instant but are running a real dev server. +rather than running up a bill. Reading is unaffected: saved demos, their links and +embeds are static builds and keep serving. The in-browser examples (the blank +templates, JavaScript, TypeScript, React, Vue) keep working too. What pauses is +everything that runs in a container: Angular, Next, Nuxt, Astro and Remix, and also +the UI-library starters — MUI, Ant Design, Fluent UI, Base Web — which look instant +but are running a real dev server. And because a Save or a Fork builds in a container +too, the top budget tiers refuse those as well: existing links keep working, new ones +wait for the reset. **"This example is unavailable for Handsontable *x.y.z*."** The example does not exist for the version you picked. Documentation examples travel with their version: diff --git a/runner/docs/guide/support.md b/runner/docs/guide/support.md index 2df704db..8197d565 100644 --- a/runner/docs/guide/support.md +++ b/runner/docs/guide/support.md @@ -61,7 +61,9 @@ Signed in, Files also lets you: in; drop on a folder row to go inside it. Existing paths ask before they are replaced. - **Rename or delete** a file from the row's own controls. -- **Download** the whole workspace, including your edits, as a `.zip`. + +**Download** needs no sign-in at all: the button in the top bar hands anyone the whole +workspace, including unsaved edits, as a `.zip`. Two things the drop will refuse, on purpose. **Binaries** — images, fonts, videos, archives — because a demo's files are text all the way through to the build, so there @@ -111,7 +113,7 @@ the demo's own URL is the one to keep. **All demos** lists everything the team has published, and the **Owner** dropdown filters it to one person — the filter is in the URL -(`/all-demos?owner=marek-martuszewski`), so you can paste the filtered view to +(`/all-demos?owner=marek.martuszewski`), so you can paste the filtered view to somebody. ![All demos with the Owner dropdown open, listing each teammate and their demo count](/guide/all-demos-owner-filter.jpg) @@ -120,7 +122,7 @@ somebody. - **Open** takes you to the read-only playground: read the code, try changes, download a `.zip`. -- **Copy link** gives you the client link, ready to send. +- **Copy link** gives you the public client link (`/share/`), ready to send. - **Fork** gives you your own editable copy. Rename, save and delete stay with the owner, and an `/edit/` link to a demo that is @@ -179,10 +181,11 @@ to send. - **Save** (on a demo you own) writes your edits and rebuilds the shared page. - **Fork** takes whatever is open — a starter, a docs example, someone else's demo, an import — and makes it a new demo owned by you. -- **Share** gives you the links. For a customer, that is the **client link** - (`/d//`): a permanent static page, safe to send, and it costs nothing to keep - online because it is built once rather than run live. If they want to read the code, - send `/share/` instead. +- **Share** gives you the links. For a customer, copy the **Public client link** + (`/share/`): the read-only playground, safe to send — they see the running demo + and can read the code, but cannot save over yours. The dialog's other fields are + the full-window view (`/share/?mode=full`) and the docs embed. The bare static + page behind them all is `/d//`, which the playground displays inside itself. ![The Share this demo dialog listing the client link, the full-window link and the docs embed URL](/guide/share-dialog.jpg) diff --git a/runner/pipeline/guide-tracks.test.mjs b/runner/pipeline/guide-tracks.test.mjs index 3efcb1c0..ed05a719 100644 --- a/runner/pipeline/guide-tracks.test.mjs +++ b/runner/pipeline/guide-tracks.test.mjs @@ -1,10 +1,13 @@ -// The guide's routing and anchor slugs (DEV-2522). +// The guide's routing and anchor slugs (DEV-2522), and its parity with the +// product it describes (DEV-2203). // -// Two things are worth pinning here. Routing, because a stale `/guide/` -// link must land on the overview rather than a blank page. And the anchors, because +// Three things are worth pinning here. Routing, because a stale `/guide/` +// link must land on the overview rather than a blank page. The anchors, because // the page's contents list and the rendered headings get their ids from the same // function by position — if that ordering or the de-duplication drifts, every -// deeplink in the guide silently scrolls to the wrong section. +// deeplink in the guide silently scrolls to the wrong section. And the facts, +// because the guide prints URLs, limits and starter names that live in the +// product — a number that drifts from its constant is a lie with a byline. import test from "node:test"; import assert from "node:assert/strict"; @@ -23,10 +26,29 @@ import { isGuideTrackSlug, parseGuideRoute, } from "../apps/authoring/src/guideTracks.ts"; +import { MAX_MCP_BYTES, MAX_MCP_FILES } from "../workers/api/src/mcp-create.ts"; +import { MAX_DESCRIPTION } from "../workers/api/src/demo-info.ts"; +// Built output, the way version.test.mjs imports it: version.ts pulls in `semver` +// via `./types.js` specifiers that --experimental-strip-types cannot resolve. +import { DEFAULT_MAX_MAJOR, DEFAULT_MIN_MAJOR } from "../packages/runtime/dist/version.js"; const here = path.dirname(fileURLToPath(import.meta.url)); const docs = path.join(here, "../docs/guide"); +/** Every track plus the overview, read once — the parity tests below scan them all. */ +const DOC_NAMES = [...GUIDE_TRACKS.map((t) => t.slug), "overview"]; +const readDoc = (name) => fs.readFileSync(path.join(docs, `${name}.md`), "utf8"); + +/** A constant that is not exported (or lives in a module the test runner cannot + * import, like the worker entry) is read out of the source instead — the + * arrangement theme-tokens.test.mjs uses for its generated-file guards. */ +function sourceConst(relPath, name) { + const source = fs.readFileSync(path.join(here, relPath), "utf8"); + const m = source.match(new RegExp(`const ${name} = ([^;]+);`)); + assert.ok(m, `${relPath} no longer declares ${name} — update the guide and this test`); + return m[1]; +} + test("the four tracks are the four routes, least technical first", () => { assert.deepEqual( GUIDE_TRACKS.map((t) => t.slug), @@ -222,9 +244,157 @@ test("no HTML entities in the guide's markdown", () => { // The renderer prints text verbatim — it builds React elements and never touches // innerHTML, which is what makes it safe for model output. So `—` reaches the // reader as "—". Write the character. - for (const name of [...GUIDE_TRACKS.map((t) => t.slug), "overview"]) { - const md = fs.readFileSync(path.join(docs, `${name}.md`), "utf8"); + for (const name of DOC_NAMES) { + const md = readDoc(name); const found = [...md.matchAll(/&[a-zA-Z]+;/g)].map((m) => m[0]); assert.deepEqual(found, [], `${name}.md contains HTML entities: ${found.join(", ")}`); } }); + +// ---- Parity with the product (DEV-2203) -------------------------------------- +// +// The guide asserts facts about the runner: which URLs open, which limits apply, +// which starters run where. Those facts live in the product's own files, so the +// guide is tested against them — a reader pasting an example URL out of the guide +// must land on a page that exists. + +test("every ?docs= URL the guide prints exists in the release bucket", () => { + // The bucket the guide's unversioned URLs resolve to: the current release line. + const manifest = JSON.parse( + fs.readFileSync( + path.join(here, "../apps/authoring/public/docs-examples/18.0/manifest.json"), + "utf8", + ), + ); + const known = new Set(manifest.examples.map((e) => e.docsPath)); + + let seen = 0; + for (const name of DOC_NAMES) { + // The path ends at the next query parameter (`&v=`, `&mode=`), closing + // backtick (the URL tables), pipe, or whitespace (the code fences). + for (const m of readDoc(name).matchAll(/[?&]docs=([^&\s)`|]+)/g)) { + seen += 1; + const docsPath = decodeURIComponent(m[1]); + assert.ok( + known.has(docsPath), + `${name}.md prints ?docs=${docsPath}, which is not in the 18.0 bucket`, + ); + } + } + // The guide leans on ?docs= URLs as the way in — them all vanishing would mean + // the extraction regex broke, not that the guide went quiet. + assert.ok(seen >= 4, `expected the guide to print ?docs= URLs, found ${seen}`); +}); + +test("the guide's numbers are the product's numbers", () => { + const all = DOC_NAMES.map(readDoc).join("\n"); + + // The MCP caps (everyone.md's "what it will not do", developers.md's limits). + assert.match(all, new RegExp(`\\b${MAX_MCP_FILES} (?:text )?files\\b`)); + assert.match(all, new RegExp(`\\b${MAX_MCP_BYTES / 1024} KB\\b`)); + + // The description field's ceiling (support.md's title-and-description section). + assert.match(all, new RegExp(`\\b${MAX_DESCRIPTION.toLocaleString("en-US")} characters\\b`)); + + // The version range and the bare-integer rule (developers.md). The guide commits + // to the exact split: majors read as versions, the refused gap, the PR-ref floor. + const minBare = Number(sourceConst("../packages/runtime/src/version.ts", "MIN_BARE_NUMERIC_PKG_PR_NEW_REF")); + assert.match(all, new RegExp(`\\*\\*${DEFAULT_MIN_MAJOR}–${DEFAULT_MAX_MAJOR}\\*\\*`)); + assert.match(all, new RegExp(`≥ ${minBare}\\b`)); + assert.match(all, new RegExp(`from ${minBare} up`)); + assert.match(all, new RegExp(`\`${DEFAULT_MAX_MAJOR + 1}\`–\`${minBare - 1}\``)); + + // The Theme Builder handover TTL (overview.md's URL table, support.md). The worker + // entry cannot be imported here, so the declaration is pinned instead: if the TTL + // stops being 24 hours, this fails and the guide gets rewritten with it. + assert.equal(sourceConst("../workers/api/src/index.ts", "PAYLOAD_TTL_SECONDS"), "24 * 60 * 60"); + assert.match(all, /\b24 hours\b/); +}); + +test("the guide's starter keys match the catalog", () => { + const catalog = JSON.parse(fs.readFileSync(path.join(here, "../catalog.json"), "utf8")); + const keys = new Set(catalog.examples.map((e) => e.framework)); + + let seen = 0; + for (const name of DOC_NAMES) { + const md = readDoc(name); + // Every ?example= the guide prints must open something. + for (const m of md.matchAll(/[?&]example=([A-Za-z0-9.-]+)/g)) { + seen += 1; + assert.ok(keys.has(m[1]), `${name}.md prints ?example=${m[1]}, which is not a catalog key`); + } + // The URL table lists alternates as bare code spans next to the ?example= rows + // ("also `blank-ts`, `blank-react`") — those are keys too, and rename with them. + for (const line of md.split("\n")) { + if (!line.includes("?example=")) continue; + for (const m of line.matchAll(/`([a-z][a-z0-9.-]*)`/g)) { + seen += 1; + assert.ok(keys.has(m[1]), `${name}.md lists \`${m[1]}\` as a starter, which is not a catalog key`); + } + } + } + assert.ok(seen >= 5, `expected the guide to name starters, found ${seen}`); +}); + +test("the guide's container claims match the catalog engines", () => { + const catalog = JSON.parse(fs.readFileSync(path.join(here, "../catalog.json"), "utf8")); + const byKey = new Map(catalog.examples.map((e) => [e.framework, e])); + + // The names the prose uses, mapped to catalog keys — the display names carry + // qualifiers ("React (Vite, JS)") that the slugs do not. + const NAME_TO_KEY = [ + ["Angular", "angular"], + ["Next.js", "next.js"], + ["Nuxt", "nuxt"], + ["Astro", "astro"], + ["Remix", "remix"], + ["MUI", "mui"], + ["Ant Design", "ant-design"], + ["Fluent UI", "fluent-ui"], + ["Base Web", "base-web"], + ["React (Vite, JS)", "react-js"], + ]; + const IN_BROWSER_NAMES = [ + ["JavaScript", "javascript"], + ["TypeScript", "typescript"], + ["React", "react"], + ["Vue", "vue"], + ]; + + const md = readDoc("developers"); + const start = md.indexOf("## Where a demo runs"); + assert.ok(start >= 0, "developers.md lost its 'Where a demo runs' section"); + const nextHeading = md.indexOf("\n## ", start + 1); + const section = md.slice(start, nextHeading === -1 ? undefined : nextHeading); + + // The catalog's `tier` field does not track the engine split (the UI-library + // starters are tier 1 *and* engine "container"), which is exactly how the guide + // once drifted. The section speaks in engines; tiers stay out of it. + assert.ok(!/tier[- ]?\d/i.test(section), "developers.md claims catalog tiers; speak in engines"); + + const split = section.indexOf("**Container**"); + assert.ok(split >= 0, "developers.md lost its container bullet"); + const inBrowserPart = section.slice(0, split); + const containerPart = section.slice(split); + + let found = 0; + for (const [displayName, key] of NAME_TO_KEY) { + if (!containerPart.includes(displayName)) continue; + found += 1; + assert.equal( + byKey.get(key)?.engine, + "container", + `developers.md lists ${displayName} as a container starter, but catalog.json says engine=${byKey.get(key)?.engine}`, + ); + } + assert.ok(found >= 9, `expected the container list to name the container starters, found ${found}`); + + for (const [displayName, key] of IN_BROWSER_NAMES) { + assert.ok(inBrowserPart.includes(displayName), `the in-browser bullet lost ${displayName}`); + assert.equal( + byKey.get(key)?.engine, + "sandpack", + `developers.md lists ${displayName} as in-browser, but catalog.json says engine=${byKey.get(key)?.engine}`, + ); + } +});