From 23c998921c6ca4887c5b387951009b74a26f62eb Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Thu, 30 Jul 2026 08:33:53 -0400 Subject: [PATCH 1/4] feat(docs): add PackageManagerTabs and register it globally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component half of #402. Renders one install command for pnpm, npm, yarn and bun, consuming the translate.mjs vocabulary added alongside the flattener. Registered in a new src/theme/MDXComponents.js rather than imported per page. ~20 pages will use it and none of the 135 .md files carries an import today, so per-file imports would put churn in exactly the diffs a reviewer needs to read closely — the ones nested in numbered steps. A missing global registration is still a loud failure (MDX throws during SSR prerender, naming the file), so this costs no safety. Tabs share groupId="package-manager", which is the reason to use @theme/Tabs rather than hand-rolling: Docusaurus persists the choice, so picking npm on one page selects npm everywhere, and the homepage hero switcher can later read the same slot. Converts one column-0 fence (guides/intro.md "Install Dependencies") to prove the pipeline end to end rather than landing the component unused. Verified in the browser: four tabs render with pnpm active by default, each tab shows the right translation, the selection persists across a reload, no console errors or hydration warnings, the swizzled CodeBlock's copy button still appears, and at 375px in dark mode all four labels fit on one line without overflowing. Verified in the artifacts: the flattened guides/intro.md twin reproduces the original fence exactly, so the LLM output is byte-identical to a pre-conversion baseline build. The conversion is invisible to agents, which is the invariant every later batch will be checked against. --- docs/docs/guides/intro.md | 4 +- .../components/PackageManagerTabs/index.js | 45 +++++++++++++++++++ .../PackageManagerTabs/styles.module.css | 20 +++++++++ docs/src/theme/MDXComponents.js | 19 ++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 docs/src/components/PackageManagerTabs/index.js create mode 100644 docs/src/components/PackageManagerTabs/styles.module.css create mode 100644 docs/src/theme/MDXComponents.js diff --git a/docs/docs/guides/intro.md b/docs/docs/guides/intro.md index 5ad1e80e0..a301156a9 100644 --- a/docs/docs/guides/intro.md +++ b/docs/docs/guides/intro.md @@ -29,9 +29,7 @@ That's it! Visit http://localhost:5173 to see your app. Skip ahead to [Next Step ### Install Dependencies -```bash -pnpm add @allxsmith/bestax-bulma -``` + ### Add Bestax CSS diff --git a/docs/src/components/PackageManagerTabs/index.js b/docs/src/components/PackageManagerTabs/index.js new file mode 100644 index 000000000..c9feb28dc --- /dev/null +++ b/docs/src/components/PackageManagerTabs/index.js @@ -0,0 +1,45 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import CodeBlock from '@theme/CodeBlock'; +import { PACKAGE_MANAGERS, renderCommand, lintCommand } from './translate.mjs'; +import styles from './styles.module.css'; + +/** + * One install command, shown for pnpm, npm, yarn and bun. + * + * `command` is authored in pnpm's verb vocabulary — `add foo`, `add -D foo`, + * `install`, `create x`, `dlx x`, `run dev` — and translate.mjs derives the other + * three. Several lines go in one `command`, separated by `;`; segments that + * aren't a known verb (`cd my-app`, a `#` comment) pass through unchanged. See + * translate.mjs for why pnpm is the authoring vocabulary and the first tab. + * + * The shared `groupId` is the point of using @theme/Tabs rather than hand-rolling: + * Docusaurus persists the choice in localStorage, so picking npm on one page + * selects npm on every other page and in the homepage hero switcher. The tab + * `value`s must stay exactly the PACKAGE_MANAGERS strings — Docusaurus silently + * discards a stored value that isn't valid for the group. + * + * Registered globally in src/theme/MDXComponents.js, so docs pages use it without + * an import. + */ +export default function PackageManagerTabs({ command }) { + if (process.env.NODE_ENV === 'development') { + for (const warning of lintCommand(command)) { + console.warn(`PackageManagerTabs: ${warning}`); + } + } + + return ( +
+ + {PACKAGE_MANAGERS.map(manager => ( + + + {renderCommand(command, manager)} + + + ))} + +
+ ); +} diff --git a/docs/src/components/PackageManagerTabs/styles.module.css b/docs/src/components/PackageManagerTabs/styles.module.css new file mode 100644 index 000000000..82ef6fdcd --- /dev/null +++ b/docs/src/components/PackageManagerTabs/styles.module.css @@ -0,0 +1,20 @@ +/* + * Most of these commands sit inside a numbered step in the getting-started + * guides, where Docusaurus's default tab spacing leaves the block floating well + * clear of the step text. Tighten it so the tabs read as part of the step. + */ +.tabs { + margin-bottom: var(--ifm-leading); +} + +/* Monospace the tab labels — they are command names, not prose. */ +.tabs :global(.tabs__item) { + font-family: var(--ifm-font-family-monospace); + font-size: 0.875rem; + padding: 0.4rem 0.9rem; +} + +/* The wrapper owns the bottom margin, so the panel's code block shouldn't. */ +.tabs :global([class*='tabItem_']) > *:last-child { + margin-bottom: 0; +} diff --git a/docs/src/theme/MDXComponents.js b/docs/src/theme/MDXComponents.js new file mode 100644 index 000000000..8c703f87d --- /dev/null +++ b/docs/src/theme/MDXComponents.js @@ -0,0 +1,19 @@ +import MDXComponents from '@theme-original/MDXComponents'; +import PackageManagerTabs from '@site/src/components/PackageManagerTabs'; + +/** + * Make PackageManagerTabs available to every docs page without an import. + * + * ~20 pages carry install commands, and none of the 135 `.md` files has an import + * line today — adding one to each would put churn in exactly the diffs a reviewer + * needs to read closely (the ones nested in numbered steps). Spreading + * @theme-original keeps Docusaurus's own mapping intact. + * + * Forgetting a global registration is a loud failure — MDX throws "Expected + * component `X` to be defined" during the SSR prerender, naming the file — so + * this trades no safety for the reduced churn. + */ +export default { + ...MDXComponents, + PackageManagerTabs, +}; From 5b0d3e68389998b35a436ab6e90cc46e68949a37 Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sat, 1 Aug 2026 12:15:13 -0400 Subject: [PATCH 2/4] fix(docs): harden PackageManagerTabs and document how to author it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #433. An omitted or empty `command` now throws instead of rendering. Deliberately not gated on NODE_ENV: `docusaurus build` prerenders every page with NODE_ENV=production, so an unconditional throw is what turns an authoring slip into a failed build naming the page — a dev-only check would miss it in CI. The flatten gate already catches a *missing* attribute, since its regex requires a literal command="…"; it cannot see command="" or command=" ; ", which flatten to a silent empty bash fence. Because the prerender covers every page, this can never fire in a browser on a site that built successfully. The default tab is now a named DEFAULT_PACKAGE_MANAGER rather than PACKAGE_MANAGERS[0], so tab order and the default can move independently. It is not merely cosmetic: the flattener collapses every tab group to the pnpm rendering, so the default is what makes the page agree with the artifact. TAB_GROUP_ID and TAB_STORAGE_KEY move into translate.mjs. The group id and the localStorage key it derives were about to be hardcoded in two places — the tab group here and the homepage hero switcher in #434 — where a rename would silently degrade to "hero and docs no longer share a choice" with nothing failing. Also documents the authoring convention in docs/CLAUDE.md, which described the component but never said how to write one, and records that .md renders JSX here because markdown.format defaults to mdx. Claude-Session: https://claude.ai/code/session_01TGA6sFTUGsJ6oXhfpjKEnh --- docs/CLAUDE.md | 13 ++++++ .../components/PackageManagerTabs/index.js | 40 +++++++++++++++---- .../PackageManagerTabs/styles.module.css | 11 ++++- .../PackageManagerTabs/translate.mjs | 27 ++++++++++++- 4 files changed, 82 insertions(+), 9 deletions(-) diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index e72ebbd0b..50c26e815 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -71,4 +71,17 @@ it, so a novel non-standard `package.json` key and extra release churn weren't w space children with `m*`/`p*`. - Code examples must compile against the current library API; when a component changes, its docs page changes in the same PR (CONTRIBUTING requires docs before approval). +- **Install and run commands go through ``**, not a bare + ` ```bash ` fence, so npm/yarn/bun readers don't translate by hand. It is registered globally + in `src/theme/MDXComponents.js` — no import. Author `command` in **pnpm's** verb vocabulary + (`add foo`, `add -D foo`, `install`, `remove`, `create x`, `dlx x`, `run dev`); the other + three managers are derived. Separate multiple lines with `;` + (`create bestax@latest my-app; cd my-app; install; run dev`); a segment that isn't a known + verb (`cd my-app`, a `#` comment) passes through unchanged. Put the tag on its own line at + column 0 with a double-quoted `command` — that is the shape the flattener matches, and + anything else fails the build rather than shipping raw JSX. An empty or missing `command` + throws during the prerender. +- **`.md` files here render JSX**, because `markdown.format` defaults to `mdx` and + `docusaurus.config.js` does not override it. That is load-bearing but easy to miss: setting + `format: 'detect'` would make every `.md` page render its tags as literal text. - Markdown is prettier-formatted (`pnpm format:check` covers `md`/`mdx`). diff --git a/docs/src/components/PackageManagerTabs/index.js b/docs/src/components/PackageManagerTabs/index.js index c9feb28dc..bb5fd9dec 100644 --- a/docs/src/components/PackageManagerTabs/index.js +++ b/docs/src/components/PackageManagerTabs/index.js @@ -1,7 +1,14 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; import CodeBlock from '@theme/CodeBlock'; -import { PACKAGE_MANAGERS, renderCommand, lintCommand } from './translate.mjs'; +import { + PACKAGE_MANAGERS, + DEFAULT_PACKAGE_MANAGER, + TAB_GROUP_ID, + renderCommand, + lintCommand, + splitSegments, +} from './translate.mjs'; import styles from './styles.module.css'; /** @@ -13,16 +20,35 @@ import styles from './styles.module.css'; * aren't a known verb (`cd my-app`, a `#` comment) pass through unchanged. See * translate.mjs for why pnpm is the authoring vocabulary and the first tab. * - * The shared `groupId` is the point of using @theme/Tabs rather than hand-rolling: - * Docusaurus persists the choice in localStorage, so picking npm on one page - * selects npm on every other page and in the homepage hero switcher. The tab - * `value`s must stay exactly the PACKAGE_MANAGERS strings — Docusaurus silently - * discards a stored value that isn't valid for the group. + * The shared `TAB_GROUP_ID` is the point of using @theme/Tabs rather than + * hand-rolling: Docusaurus persists the choice in localStorage, so picking npm on + * one page selects npm on every other page and in the homepage hero switcher. The + * tab `value`s must stay exactly the PACKAGE_MANAGERS strings — Docusaurus + * silently discards a stored value that isn't valid for the group. * * Registered globally in src/theme/MDXComponents.js, so docs pages use it without * an import. */ export default function PackageManagerTabs({ command }) { + // Throws rather than rendering something wrong, and deliberately not gated on + // NODE_ENV: `docusaurus build` prerenders every page with NODE_ENV=production, + // so an unconditional throw is what turns an authoring slip into a failed build + // that names the page. A dev-only check would miss it in CI entirely. + // + // The flatten gate in flatten-llms-tabs.mjs already catches a *missing* + // attribute — its regex requires a literal command="…", so + // survives as raw JSX and fails verifyArtifact. It cannot see command="" or + // command=" ; ", which flatten to a silent empty ```bash fence. This closes that. + // + // Because the prerender covers every page, this can never fire in a browser on + // a site that built successfully. + if (typeof command !== 'string' || splitSegments(command).length === 0) { + throw new Error( + `PackageManagerTabs: \`command\` must be a non-empty string, got ${JSON.stringify(command)}. ` + + 'Author it in pnpm verb vocabulary, e.g. command="add @allxsmith/bestax-bulma".' + ); + } + if (process.env.NODE_ENV === 'development') { for (const warning of lintCommand(command)) { console.warn(`PackageManagerTabs: ${warning}`); @@ -31,7 +57,7 @@ export default function PackageManagerTabs({ command }) { return (
- + {PACKAGE_MANAGERS.map(manager => ( diff --git a/docs/src/components/PackageManagerTabs/styles.module.css b/docs/src/components/PackageManagerTabs/styles.module.css index 82ef6fdcd..f63b5048a 100644 --- a/docs/src/components/PackageManagerTabs/styles.module.css +++ b/docs/src/components/PackageManagerTabs/styles.module.css @@ -14,7 +14,16 @@ padding: 0.4rem 0.9rem; } -/* The wrapper owns the bottom margin, so the panel's code block shouldn't. */ +/* + * The wrapper owns the bottom margin, so the panel's code block shouldn't. + * + * `.tabs__item` above is a stable Infima class, but the tab *panel* has no such + * class — Docusaurus only gives it a hashed CSS-module name, so this matches the + * `tabItem_` prefix. That couples it to a theme internal: if a Docusaurus upgrade + * renames the module, this rule silently stops applying and the block regains a + * bottom margin. Purely cosmetic, and visible the moment anyone looks at a page, + * which is why it is matched loosely rather than pinned or reimplemented. + */ .tabs :global([class*='tabItem_']) > *:last-child { margin-bottom: 0; } diff --git a/docs/src/components/PackageManagerTabs/translate.mjs b/docs/src/components/PackageManagerTabs/translate.mjs index d37d9a89c..777d32f29 100644 --- a/docs/src/components/PackageManagerTabs/translate.mjs +++ b/docs/src/components/PackageManagerTabs/translate.mjs @@ -27,9 +27,34 @@ * on internal alignment surviving. */ -/** Tab order. pnpm is first, and therefore the default. */ +/** Tab order. */ export const PACKAGE_MANAGERS = ['pnpm', 'npm', 'yarn', 'bun']; +/** + * The tab selected when the reader has no stored preference. + * + * Named rather than taken as `PACKAGE_MANAGERS[0]` so tab *order* and the + * *default* can move independently — and because this value is not merely + * cosmetic: `flatten-llms-tabs.mjs` collapses every `` to + * the pnpm rendering, so the default tab is what makes the page a reader sees + * agree with the artifact an agent reads. Changing it desyncs the two. + */ +export const DEFAULT_PACKAGE_MANAGER = 'pnpm'; + +/** + * The `groupId` of the Docusaurus `` group, and the localStorage key it + * derives — `tabsUtils.js` builds the key as `docusaurus.tab.${groupId}`. + * + * Both live here because two separate surfaces have to agree on them: the tab + * group in this directory's index.js, and the homepage hero switcher in + * src/pages/index.js, which reads the slot directly via `useStorageSlot` to + * stay in sync with the docs. Hardcoding the strings in both places means a + * rename silently degrades to "hero and docs no longer share a choice", with + * nothing failing. + */ +export const TAB_GROUP_ID = 'package-manager'; +export const TAB_STORAGE_KEY = `docusaurus.tab.${TAB_GROUP_ID}`; + /** Verbs we know how to translate. Anything else is a passthrough line. */ const VERBS = new Set(['add', 'install', 'remove', 'create', 'dlx', 'run']); From a0935ce0e8bcbc19bb7ea055d9e2a2fdc5cf4cbc Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sat, 1 Aug 2026 15:13:04 -0400 Subject: [PATCH 3/4] refactor(docs): author PackageManagerTabs as children, drop the flattener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin that generates llms.txt strips PascalCase JSX tags and keeps their inner text. Content in a *prop* is not inner text — it goes with the tag — so a self-closing left an empty section in every generated artifact while the rendered site looked fine. Rather than post-process the artifacts, put the command where the rule already preserves it. A page now wraps the pnpm fence it would have written anyway: ```bash pnpm add @allxsmith/bestax-bulma ``` The plugin removes the wrapper and leaves exactly that fence, so the artifact is correct by construction — no build step, no config, nothing to keep in sync. Confirmed by running the plugin's own cleanMarkdownContent over both shapes: the prop form yields an empty section, the children form yields the fence. The fence is the single source of truth. The component recovers the authoring vocabulary from it by stripping the `pnpm ` prefix line-wise, derives npm, yarn and bun from that, and asserts the round trip renders back to the fence exactly — so a non-canonical fence fails the prerender instead of producing three tabs derived from something the page never showed. That makes scripts/flatten-llms-tabs.mjs dead: its PackageManagerTabs path is now redundant with the plugin, and its path can no longer work either, since the labels it linearizes are props the plugin removes before the script runs. Deleted with its four test suites, including the indentation suite whose entire reason for existing was re-indenting a fence the script used to synthesize. The two flattener assertions in the translate suite are replaced by the identity the design now rests on: the pnpm rendering round-trips back to the authored command. Known and accepted: labels still vanish from the artifacts, because label is Docusaurus's own prop and cannot move to children. Documented in docs/CLAUDE.md and filed upstream as rachfop/docusaurus-plugin-llms#64. Claude-Session: https://claude.ai/code/session_01TGA6sFTUGsJ6oXhfpjKEnh --- docs/CLAUDE.md | 57 ++- docs/docs/guides/intro.md | 8 +- docs/package.json | 2 +- docs/scripts/flatten-llms-corpus.test.mjs | 77 --- docs/scripts/flatten-llms-gate.test.mjs | 150 ------ .../scripts/flatten-llms-tabs.indent.test.mjs | 349 -------------- docs/scripts/flatten-llms-tabs.mjs | 456 ------------------ docs/scripts/flatten-llms-tabs.test.mjs | 265 ---------- .../package-manager-translate.test.mjs | 34 +- .../components/PackageManagerTabs/index.js | 95 +++- .../PackageManagerTabs/translate.mjs | 18 +- 11 files changed, 158 insertions(+), 1353 deletions(-) delete mode 100644 docs/scripts/flatten-llms-corpus.test.mjs delete mode 100644 docs/scripts/flatten-llms-gate.test.mjs delete mode 100644 docs/scripts/flatten-llms-tabs.indent.test.mjs delete mode 100644 docs/scripts/flatten-llms-tabs.mjs delete mode 100644 docs/scripts/flatten-llms-tabs.test.mjs diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 50c26e815..39d357922 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -24,13 +24,23 @@ The build runs `docusaurus-plugin-llms` (configured in `docusaurus.config.js`), - `/llms.txt` (curated index) and `/llms-full.txt` (full concatenation) - a per-page `.md` twin for every doc page (so `llms.txt` links resolve) -The plugin reads **source** markdown and has no transform hook, so MDX tab JSX would land -verbatim in those artifacts. `scripts/flatten-llms-tabs.mjs` runs after `docusaurus build` -(chained in the `build` script — it can't be a plugin, since core runs every `postBuild` under -`Promise.all`) and flattens it: `` collapses to the pnpm -command, `` linearizes to `####` headings keeping every `TabItem` body. It never touches -code — fenced _or_ inline — because `docs/api/components/tabs.md` documents bestax-bulma's own -``/``. Covered by `scripts/flatten-llms-tabs.test.mjs` (`pnpm test`). +The plugin reads **source** markdown, and since 0.5.0 it strips PascalCase JSX tags while +keeping their **inner text**. That single rule decides how components must be authored if their +content is to reach an agent: + +- **Content in children survives.** A fenced code block wrapped in a component comes out the + other side as exactly that fence, wrapper gone. This is why `` takes a + pnpm fence as its children rather than a `command` prop — the artifact is then correct by + construction, with no build step to keep in sync. +- **Content in props does not.** It goes with the tag, silently. A self-closing + `` left an empty section in every artifact while the + rendered site looked fine. Before giving a docs component a prop that carries prose or a + command, check what the generated `.md` looks like. + +The known casualty is ``: the label is Docusaurus's own prop and can't move +to children, so `` collapses to its bodies with nothing marking which option is which. +`skills/theming.mdx` and `skills/custom-component.mdx` are affected. Tracked upstream at +rachfop/docusaurus-plugin-llms#64, which asks for a preserve-list or a pre-clean hook. `build` then chains `scripts/strip-generated-markers.mjs`, which removes the `` markers from the built `.md`/`.txt` only — the plugin does not @@ -71,16 +81,29 @@ it, so a novel non-standard `package.json` key and extra release churn weren't w space children with `m*`/`p*`. - Code examples must compile against the current library API; when a component changes, its docs page changes in the same PR (CONTRIBUTING requires docs before approval). -- **Install and run commands go through ``**, not a bare - ` ```bash ` fence, so npm/yarn/bun readers don't translate by hand. It is registered globally - in `src/theme/MDXComponents.js` — no import. Author `command` in **pnpm's** verb vocabulary - (`add foo`, `add -D foo`, `install`, `remove`, `create x`, `dlx x`, `run dev`); the other - three managers are derived. Separate multiple lines with `;` - (`create bestax@latest my-app; cd my-app; install; run dev`); a segment that isn't a known - verb (`cd my-app`, a `#` comment) passes through unchanged. Put the tag on its own line at - column 0 with a double-quoted `command` — that is the shape the flattener matches, and - anything else fails the build rather than shipping raw JSX. An empty or missing `command` - throws during the prerender. +- **Install and run commands go in ``**, wrapping the pnpm fence you would + have written anyway, so npm/yarn/bun readers don't translate by hand: + + ````md + + + ```bash + pnpm create bestax@latest my-app + cd my-app + pnpm install + ``` + + + ```` + + Registered globally in `src/theme/MDXComponents.js` — no import. Write the **pnpm** form + exactly, one command per line; npm, yarn and bun are derived from it, and a line that isn't a + pnpm verb (`cd my-app`, a `#` comment) passes through unchanged. Blank lines around the fence + are required — without them MDX treats it as literal text, not a code block. The component + derives the command back out of the fence and throws during the prerender if the round trip + isn't exact, so a non-canonical fence (`npm install foo`, odd spacing) fails the build rather + than rendering three tabs derived from something the page never showed. + - **`.md` files here render JSX**, because `markdown.format` defaults to `mdx` and `docusaurus.config.js` does not override it. That is load-bearing but easy to miss: setting `format: 'detect'` would make every `.md` page render its tags as literal text. diff --git a/docs/docs/guides/intro.md b/docs/docs/guides/intro.md index a301156a9..07848c327 100644 --- a/docs/docs/guides/intro.md +++ b/docs/docs/guides/intro.md @@ -29,7 +29,13 @@ That's it! Visit http://localhost:5173 to see your app. Skip ahead to [Next Step ### Install Dependencies - + + +```bash +pnpm add @allxsmith/bestax-bulma +``` + + ### Add Bestax CSS diff --git a/docs/package.json b/docs/package.json index c766b2c4d..ffea8f74f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,7 +6,7 @@ "docusaurus": "docusaurus", "docs": "docusaurus start", "start": "docusaurus start", - "build": "docusaurus build && node scripts/flatten-llms-tabs.mjs && node scripts/strip-generated-markers.mjs", + "build": "docusaurus build && node scripts/strip-generated-markers.mjs", "test": "node --test \"scripts/*.test.mjs\"", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", diff --git a/docs/scripts/flatten-llms-corpus.test.mjs b/docs/scripts/flatten-llms-corpus.test.mjs deleted file mode 100644 index f12b4c5a0..000000000 --- a/docs/scripts/flatten-llms-corpus.test.mjs +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Run the flattener over the real docs corpus. - * - * The unit tests use hand-written fixtures, so they only ever cover shapes - * someone thought to write down. This walks every page we actually ship and - * asserts the invariants that matter for the published artifacts — which means a - * future page that nests a component somewhere awkward fails here rather than - * silently corrupting llms-full.txt. - * - * Source-only: no build required, so it runs in the same fast `pnpm test`. - */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFile, readdir } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { transform, verifyArtifact } from './flatten-llms-tabs.mjs'; - -const DOCS_DIR = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../docs' -); - -async function* walk(dir) { - for (const entry of await readdir(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) yield* walk(full); - else if (/\.mdx?$/.test(entry.name)) yield full; - } -} - -const pages = []; -for await (const file of walk(DOCS_DIR)) pages.push(file); - -test('the corpus is non-empty (guards a bad DOCS_DIR)', () => { - assert.ok(pages.length > 100, `expected >100 pages, found ${pages.length}`); -}); - -test('every page survives the flattener cleanly', async () => { - const failures = []; - - for (const file of pages) { - const src = await readFile(file, 'utf8'); - const out = transform(src); - const rel = path.relative(DOCS_DIR, file); - - for (const problem of verifyArtifact(out)) { - failures.push(`${rel}: ${problem}`); - } - - // An empty emitted block means a command was swallowed. Anchored to line - // starts and to the indentation we emit — a loose regex false-positives on - // the inner fence of a ```` block (docs/tutorial-basics/markdown-features). - if (/^([ \t]*)```bash\n\1```$/m.test(out)) { - failures.push(`${rel}: emitted an empty bash fence`); - } - - // Idempotency on real content, not just fixtures. - if (transform(out) !== out) { - failures.push(`${rel}: transform is not idempotent`); - } - } - - assert.deepEqual(failures, []); -}); - -test('pages with no tab JSX are passed through untouched', async () => { - const changed = []; - - for (const file of pages) { - const src = await readFile(file, 'utf8'); - if (/ { - const result = await runGate({ - 'llms-full.txt': '# Docs\n\n\n', - }); - - assert.ok(result.ok, `expected success, got: ${result.stderr}`); - assert.match(result.stdout, /rewrote 1 file/); - - const out = await readFile(path.join(result.dir, 'llms-full.txt'), 'utf8'); - assert.match(out, /```bash\npnpm add foo\n```/); -}); - -test('the gate fails on a mid-line tag even though the transform is a no-op', async () => { - // The regression. transform() leaves this untouched, so the old `after === - // before` early-continue skipped verification entirely and the build passed. - const result = await runGate({ - 'llms-full.txt': - 'Install it with today.\n', - }); - - assert.equal(result.ok, false, 'expected the build to fail'); - assert.match(result.stderr, /failed verification/); - assert.match(result.stderr, /unflattened tab JSX/); - assert.match(result.stderr, /llms-full\.txt/); -}); - -test('the gate fails on a dedented fence body', async () => { - const result = await runGate({ - 'docs/guide.md': [ - '1. **Step:**', - '', - ' ```bash', - 'pnpm add foo', - '```', - '', - '', - ].join('\n'), - }); - - assert.equal(result.ok, false); - assert.match(result.stderr, /dedented below its opening fence/); -}); - -test('the gate fails on a dedented fence body nested past 3 spaces', async () => { - // Two list levels deep, and with a following page whose fence pairs with the - // stray column-0 one — the shape that used to reach publication silently, - // because a fence opened at 4+ spaces was invisible to the check entirely. - const result = await runGate({ - 'llms-full.txt': [ - '1. Outer step:', - '', - ' - Inner step:', - '', - ' ```bash', - 'pnpm add foo', - '```', - '', - '# Next page', - '', - '```ts', - 'const a = 1;', - '```', - '', - '', - ].join('\n'), - }); - - assert.equal(result.ok, false, 'expected the build to fail'); - assert.match(result.stderr, /dedented below its opening fence/); -}); - -test('the gate passes an indented code block that quotes a fence', async () => { - // The other side of that widening. At the top level this is indented code, not - // a nested fence, so neither its unclosed ``` nor its column-0 neighbours may - // be read as damage — otherwise closing the gap above would redden valid pages. - const result = await runGate({ - 'docs/guide.md': [ - 'An indented code block showing a fence:', - '', - ' ```bash', - ' pnpm add foo', - '', - '', - ].join('\n'), - }); - - assert.ok(result.ok, `expected success, got: ${result.stderr}`); - assert.match(result.stdout, /rewrote 1 file/); -}); - -test('artifacts with no tab JSX are not even read for problems', async () => { - // The NEEDS_FLATTENING prefilter still applies — an unrelated malformed page - // must not start failing the docs build. - const result = await runGate({ - 'docs/unrelated.md': '# Title\n\n```bash\nnever closed\n', - }); - - assert.ok(result.ok, `expected success, got: ${result.stderr}`); - assert.match(result.stdout, /rewrote 0 file/); -}); - -test('non-artifact files are ignored', async () => { - const result = await runGate({ - 'robots.txt': ' mid line\n', - 'assets/app.js': 'const x = "";\n', - }); - - assert.ok(result.ok, `expected success, got: ${result.stderr}`); - assert.match(result.stdout, /rewrote 0 file/); -}); diff --git a/docs/scripts/flatten-llms-tabs.indent.test.mjs b/docs/scripts/flatten-llms-tabs.indent.test.mjs deleted file mode 100644 index 5de284d26..000000000 --- a/docs/scripts/flatten-llms-tabs.indent.test.mjs +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Indentation and multi-segment coverage for the flattener. - * - * Split from flatten-llms-tabs.test.mjs because these cover a distinct class of - * defect: the flattener replaces JSX *in place*, so every line it emits has to - * carry the indentation of the tag it replaced. A component nested in a list - * item that emits its fence body at column 0 terminates the list item, turns the - * command into prose, and leaves a stray ``` that opens a new block — and since - * llms-full.txt is one concatenated document, the damage runs into the next page. - */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { transform, verifyArtifact } from './flatten-llms-tabs.mjs'; - -/** Every line of a fenced block the flattener emitted, in order. */ -function fenceLines(out) { - return out.split('\n').filter(line => line.trim() !== ''); -} - -const indents = [ - { label: '2 spaces (bulleted list)', indent: ' ' }, - { label: '3 spaces (numbered list)', indent: ' ' }, - { label: '6 spaces (nested list)', indent: ' ' }, -]; - -for (const { label, indent } of indents) { - test(`indentation is carried onto every emitted line — ${label}`, () => { - const out = transform(`${indent}`); - const lines = fenceLines(out); - - assert.equal(lines.length, 3, 'fence open, body, fence close'); - for (const line of lines) { - assert.ok( - line.startsWith(indent), - `expected line to start with ${indent.length} spaces, got ${JSON.stringify(line)}` - ); - assert.ok( - !line.startsWith(indent + ' '), - `expected exactly ${indent.length} spaces, got ${JSON.stringify(line)}` - ); - } - assert.equal(lines[0].trim(), '```bash'); - assert.equal(lines[1].trim(), 'pnpm add foo'); - assert.equal(lines[2].trim(), '```'); - }); -} - -test('an indented component keeps the surrounding list intact', () => { - const src = [ - '1. **Create it:**', - '', - ' ', - '', - '2. **Then install:**', - '', - ' ', - '', - ].join('\n'); - - const out = transform(src); - - // Both list markers survive at column 0 and neither command leaks out of its item. - assert.match(out, /^1\. \*\*Create it:\*\*$/m); - assert.match(out, /^2\. \*\*Then install:\*\*$/m); - assert.match(out, /^ {3}```bash\n {3}pnpm add foo\n {3}```$/m); - assert.match(out, /^ {3}```bash\n {3}pnpm install\n {3}```$/m); - // No fence line may sit at column 0 — that is what ejects the block. - assert.doesNotMatch(out, /^```/m); -}); - -test('an unindented component is unaffected by indentation handling', () => { - const out = transform(''); - assert.equal(out.trim(), '```bash\npnpm add foo\n```'); -}); - -test('a component mid-line is left alone rather than mangled', () => { - // Only a tag that starts its own line can be indented correctly. Anything else - // should fail loudly as untouched JSX rather than emit a broken fence inline. - const src = 'See above.'; - assert.equal(transform(src), src); -}); - -// --- multi-segment `command` ------------------------------------------------- - -test('semicolons split a command into one line per segment', () => { - const out = transform( - '' - ); - - assert.equal( - out.trim(), - [ - '```bash', - 'pnpm create bestax@latest my-app', - 'cd my-app', - 'pnpm install', - '```', - ].join('\n') - ); -}); - -test('segments that are not package-manager verbs pass through verbatim', () => { - const out = transform( - '' - ); - - const lines = fenceLines(out); - assert.equal(lines[1], '# Remove the old dependency'); - assert.equal(lines[2], 'pnpm remove node-sass'); - assert.equal(lines[3], 'pnpm add sass'); -}); - -test('a trailing comment survives on a translated segment', () => { - const out = transform( - '' - ); - assert.match(out, /^pnpm dlx bestax-migrate src\/ --dry # preview$/m); -}); - -test('irregular spacing and a trailing semicolon produce no blank lines', () => { - const out = transform( - '' - ); - - assert.equal( - out.trim(), - ['```bash', 'pnpm add foo', 'cd app', 'pnpm install', '```'].join('\n') - ); -}); - -test('indentation and multi-segment combine', () => { - const src = [ - '1. **Scaffold:**', - '', - ' ', - ].join('\n'); - - const out = transform(src); - - assert.match( - out, - /^ {3}```bash\n {3}pnpm create vite@latest my-app -- --template react\n {3}cd my-app\n {3}pnpm install\n {3}```$/m - ); - assert.doesNotMatch(out, /^```/m); -}); - -test('transform stays idempotent when indented and multi-segment', () => { - const src = [ - '1. **Scaffold:**', - '', - ' ', - '', - '2. Done.', - ].join('\n'); - - const once = transform(src); - assert.equal(transform(once), once); -}); - -test('an indented component flattens with no import line present', () => { - // Global MDXComponents registration means pages carry no import at all. - const src = [ - '- Note:', - '', - ' ', - ].join('\n'); - const out = transform(src); - - assert.doesNotMatch(out, / { - const out = transform(' '); - assert.deepEqual(verifyArtifact(out), []); -}); - -test('verifyArtifact catches tab JSX that reached the artifact', () => { - // The shape that slips past the line-anchored regex. - const problems = verifyArtifact( - 'See above.' - ); - assert.equal(problems.length, 1); - assert.match(problems[0], /unflattened tab JSX/); -}); - -test('verifyArtifact ignores tab JSX inside code, as the flattener does', () => { - const src = [ - '```mdx', - '', - '```', - ].join('\n'); - assert.deepEqual(verifyArtifact(src), []); - assert.deepEqual(verifyArtifact('Prose about `` and ``.'), []); -}); - -test('verifyArtifact catches an unterminated fence', () => { - // What a wrongly-indented emission looks like downstream. - const problems = verifyArtifact('```bash\npnpm add foo\n'); - assert.deepEqual(problems, ['unterminated code fence']); -}); - -test('verifyArtifact catches the pre-fix corruption shape', () => { - // Exactly what the old flattener produced for an indented component: opening - // fence at 3 spaces, body and closer at column 0. Note the fences still *pair* - // — CommonMark lets a closer sit at 0-3 spaces regardless of the opening indent - // — so parity alone sees nothing wrong. The dedent check is what catches it. - const corrupted = [ - '1. **Create it:**', - '', - ' ```bash', - 'pnpm add foo', - '```', - '', - '2. **Then install:**', - ].join('\n'); - - assert.deepEqual(verifyArtifact(corrupted), [ - 'fenced block dedented below its opening fence', - ]); -}); - -test('a correctly indented fence is not flagged as dedented', () => { - const fine = [ - '1. **Create it:**', - '', - ' ```bash', - ' pnpm add foo', - ' ```', - '', - '2. **Then install:**', - ].join('\n'); - - assert.deepEqual(verifyArtifact(fine), []); -}); - -test('a blank line inside an indented fence is not a dedent', () => { - const fine = [ - ' ```ts', - ' const a = 1;', - '', - ' const b = 2;', - ' ```', - ].join('\n'); - assert.deepEqual(verifyArtifact(fine), []); -}); - -// --- the gate past the 3-space break ----------------------------------------- - -test('verifyArtifact catches the corruption shape nested past 3 spaces', () => { - // The same damage as above, one list level deeper, and inside a *concatenated* - // artifact — which is the case that used to escape entirely. CommonMark reads - // an opening fence at 4+ spaces as indented code, so the dedent check could not - // see this fence at all; the stray column-0 ``` then paired with the next - // page's fence instead, masking the whole region as code. Nothing was reported. - const corrupted = [ - '1. Outer step:', - '', - ' - Inner step:', - '', - ' ```bash', - 'pnpm add foo', - '```', - '', - '# Next page', - '', - '```ts', - 'const a = 1;', - '```', - ].join('\n'); - - assert.deepEqual(verifyArtifact(corrupted), [ - 'fenced block dedented below its opening fence', - ]); -}); - -test('a correctly indented fence past 3 spaces is not flagged', () => { - // Also pins the closing fence rule: a closer at 6 spaces has to pair with an - // opener at 6 spaces, or this would report an unterminated fence instead. - const fine = [ - '1. Outer step:', - '', - ' - Inner step:', - '', - ' ```bash', - ' pnpm add foo', - ' ```', - '', - '2. Done.', - ].join('\n'); - - assert.deepEqual(verifyArtifact(fine), []); -}); - -test('an unclosed fence past 3 spaces is not reported as unterminated', () => { - // Deliberate: at the top level this is an indented code block that happens to - // contain a ``` line, which is valid and harms nothing. The wide view is - // trusted with the dedent signal only, so it cannot redden a build over this. - const indentedCodeBlock = [ - 'An indented code block showing a fence:', - '', - ' ```bash', - ' pnpm add foo', - '', - '', - ].join('\n'); - - assert.deepEqual(verifyArtifact(transform(indentedCodeBlock)), []); -}); - -test('a tab-indented fence line is indented code, not a fence', () => { - // A tab advances to the next multiple of four, so this sits at column 4 — the - // same indented-code reading as four spaces. Measuring it as one column would - // open a fence here and report the document as unterminated. - assert.deepEqual(verifyArtifact('\t```bash\n\tpnpm add foo\n'), []); -}); - -test('the JSX scan keeps the transform view of code, not the wide one', () => { - // A tag inside a fence opened at 4 spaces *is* rewritten by transform (that - // fence is indented code in the top-level reading), so the residual-JSX scan - // has to read the document the same way. Scanning it through the wide view - // would mask this region and call a genuine miss clean. - const src = [' ```mdx', ' ', ' ', ' ```'].join('\n'); - - assert.match(transform(src), /^\s*```mdx$/m); - assert.doesNotMatch(transform(src), //); - assert.deepEqual(verifyArtifact(transform(src)), []); -}); - -// --- generic in a list item ------------------------------------------- - -test('an indented generic Tabs block keeps its indentation', () => { - const src = [ - '1. Pick one:', - '', - ' ', - ' body a', - ' body b', - ' ', - ].join('\n'); - - const out = transform(src); - - assert.match(out, /^ {3}#### A$/m); - assert.match(out, /^ {3}#### B$/m); - assert.doesNotMatch(out, /^#### /m); -}); diff --git a/docs/scripts/flatten-llms-tabs.mjs b/docs/scripts/flatten-llms-tabs.mjs deleted file mode 100644 index 99d8a01c0..000000000 --- a/docs/scripts/flatten-llms-tabs.mjs +++ /dev/null @@ -1,456 +0,0 @@ -#!/usr/bin/env node -/** - * Flatten MDX tab JSX in the generated LLM artifacts. - * - * `docusaurus-plugin-llms` reads the *source* markdown, not the rendered HTML, - * and its only cleanup (`cleanMarkdownContent`) strips a fixed allowlist of - * plain HTML tags. `` / `` are not in that list and its options - * expose no transform hook, so tab JSX lands verbatim in llms.txt, - * llms-full.txt and every per-page .md twin. - * - * This runs after `docusaurus build` and rewrites those artifacts only — the - * HTML site is untouched. It cannot be a Docusaurus plugin: core runs every - * plugin's `postBuild` under `Promise.all`, so a sibling plugin would race - * docusaurus-plugin-llms nondeterministically. - * - * Two transforms, because there are two kinds of tabs: - * - * 1. `` — four *equivalent* commands. - * Collapse to a single fenced pnpm block. What an agent copies out of - * llms.txt has to match what a reader sees on the default tab, and pnpm is - * the default tab — so the pnpm rendering is imported from the component's - * own `translate.mjs` rather than reimplemented here. A `command` may hold - * several `;`-separated lines. - * - * 2. `` / `` — *complementary* content (the skills - * pages tab between ProfileCard.tsx, _profilecard.scss and Usage). Keep - * every body and promote the label to a heading. Keeping only the first - * tab here would silently delete documentation. - * - * Both transforms run only outside code — see `outsideCode` for why that is not - * optional. - */ -import { readFile, writeFile, readdir } from 'node:fs/promises'; -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { renderCommand } from '../src/components/PackageManagerTabs/translate.mjs'; - -/** Heading level that a flattened `` is promoted to. */ -const TAB_HEADING = '####'; - -/** Sentinel wrapping masked code so a placeholder can never occur in prose. */ -const NUL = String.fromCharCode(0); - -/** - * Emit a fenced block, indented to match the JSX tag it replaces. - * - * Every line needs the prefix, not just the opening fence. A component nested in - * a list item whose body lands at column 0 terminates the list item: the command - * becomes prose and the closing ``` opens a new block instead — and because - * llms-full.txt is one concatenated document, that runs on into the next page. - */ -function fenced(indent, body) { - const lines = body.split('\n').map(line => indent + line); - return [`${indent}\`\`\`bash`, ...lines, `${indent}\`\`\``].join('\n'); -} - -/** - * Quoted `command` only. A braced template — command={`add foo`} — is not - * supported on purpose: its backticks are indistinguishable from an inline code - * span, and code masking has to run first (see `outsideCode`), so the attribute - * would be masked before this ever saw it. Author the prop as a plain string. - * - * Anchored to the start of a line so the tag's own indentation can be captured. - * A tag sitting mid-sentence therefore doesn't match and survives as raw JSX — - * loud and visible, rather than an inline fence that corrupts the document. - * - * The pnpm rendering comes from the component's own module, so llms.txt and the - * default tab cannot drift apart. - */ -function flattenPackageManagerTabs(src) { - return src.replace( - /(^|\n)([ \t]*)]*?\bcommand=(?:"([^"]*)"|'([^']*)')[^>]*?\/>/g, - (_match, lead, indent, dq, sq) => - lead + fenced(indent, renderCommand((dq ?? sq).trim(), 'pnpm')) - ); -} - -/** - * Re-anchor a block of text to `indent`: strip the indentation its lines share, - * then apply the target. Dedent-then-indent rather than prefixing, so a tab body - * that already carries the source indentation doesn't end up doubly indented - * (which at 4+ spaces would silently become a markdown code block). Identity - * when there is nothing to strip and nothing to add. - */ -function reindentBlock(text, indent) { - const lines = text.split('\n'); - const widths = lines - .filter(line => line.trim() !== '') - .map(line => line.match(/^[ \t]*/)[0].length); - const shared = widths.length ? Math.min(...widths) : 0; - - if (shared === 0 && indent === '') return text; - - return lines - .map(line => (line.trim() === '' ? '' : indent + line.slice(shared))) - .join('\n'); -} - -function attrLabel(attrs) { - for (const key of ['label', 'value']) { - const m = attrs.match(new RegExp(`\\b${key}=(?:"([^"]*)"|'([^']*)')`)); - if (m) return m[1] ?? m[2] ?? ''; - } - return ''; -} - -function flattenGenericTabs(src) { - // `])`, never ``, which - // is bestax-bulma's own Tabs component as documented in docs/api/components. - // Indentation is captured for the same reason as flattenPackageManagerTabs: - // a `####` heading emitted at column 0 breaks out of an enclosing list item. - return src.replace( - /(^|\n)([ \t]*)])[^>]*>([\s\S]*?)<\/Tabs>/g, - (_m, lead, indent, inner) => { - const items = [ - ...inner.matchAll(/]*)>([\s\S]*?)<\/TabItem>/g), - ]; - const reindent = text => reindentBlock(text, indent); - - if (items.length === 0) return lead + reindent(inner.trim()); - - return ( - lead + - items - .map(([, attrs, body]) => { - const label = attrLabel(attrs); - const text = reindent(body.trim()); - return label ? `${indent}${TAB_HEADING} ${label}\n\n${text}` : text; - }) - .join('\n\n') - ); - } - ); -} - -/** A fence line at any indentation; how far in it may sit is decided per mode. */ -const FENCE = /^[ \t]*(`{3,}|~{3,})(.*)$/; - -/** - * Column width of a prefix, with a tab advancing to the next multiple of four as - * CommonMark expands it. Counting a tab as one character would read a - * tab-indented fence as sitting at column 1 — a fence — when it is four columns - * in and therefore indented code. - */ -function widthOf(prefix) { - let columns = 0; - for (const char of prefix) columns += char === '\t' ? 4 - (columns % 4) : 1; - return columns; -} - -/** Indentation of a line, in columns. */ -const indentOf = line => widthOf(line.match(/^[ \t]*/)[0]); - -/** - * Track the content column of the innermost open list item; 0 at the top level. - * - * CommonMark's "an opening fence may be indented 0-3 spaces" is relative to the - * *enclosing container*, not to the document, so a fence nested two list levels - * deep legitimately opens at 4+ spaces. Without the container column there is no - * way to tell that fence from a top-level indented code block that happens to - * contain a ``` line — and the two want opposite treatment. - * - * Deliberately a small approximation of block parsing, not an implementation of - * it: enough for that one distinction, which is all the caller asks of it. Lazy - * continuation lines and non-list containers (blockquotes) are not modelled, and - * only `verifyArtifact`'s structural pass consults it. - */ -function listColumnTracker() { - const columns = []; - - return line => { - // A blank line does not end a list item — indented content may follow it. - if (line.trim() === '') return columns.at(-1) ?? 0; - - const indent = indentOf(line); - while (columns.length && indent < columns.at(-1)) columns.pop(); - - const marker = line.match(/^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+(?=\S)/); - if (marker) columns.push(widthOf(marker[0])); - - return columns.at(-1) ?? 0; - }; -} - -/** - * Mask fenced code blocks, per CommonMark rather than by regex. - * - * A regex can't do this: fences are variable-length and come in two characters, - * and the closing fence only has to be *at least* as long as the opening one. - * `docs/api/helpers/theme.md` and `docs/tutorial-basics/markdown-features.mdx` - * both wrap ``` examples in ```` fences, so mis-pairing is not hypothetical. - * - * `listAware` widens which lines can open a fence, from "0-3 spaces" to "0-3 - * spaces past the enclosing list item's content column". Off (the default) is - * the reading that decides what `transform` may rewrite, and is exactly the - * top-level CommonMark rule; on, it lets the dedent signal below see a fence - * nested past the 3-space break at all. See `verifyArtifact`. - */ -function maskFences(src, hold, report, { listAware = false } = {}) { - const out = []; - const listColumn = listColumnTracker(); - let open = null; - let buf = []; - - for (const line of src.split('\n')) { - if (!open) { - // Container column of *this* line, so the fence is judged in its own - // context. Fed only outside fences: a `1.` inside a code sample is not a - // list item, and letting one in would move the column under the fence. - const column = listAware ? listColumn(line) : 0; - const fence = line.match(FENCE); - // A backtick fence's info string may not itself contain a backtick. - const validOpen = - fence && - indentOf(line) <= column + 3 && - !(fence[1][0] === '`' && fence[2].includes('`')); - if (validOpen) { - open = { - char: fence[1][0], - len: fence[1].length, - indent: indentOf(line), - column, - }; - buf = [line]; - } else { - out.push(line); - } - continue; - } - - buf.push(line); - // A closing fence may be indented up to three spaces past its container — - // the same allowance the opener got, which is why it is measured against - // `open.column` and not against `open.indent`. So a closer may legally sit - // *below* the opening fence, and does in the corruption shape below. - const close = line.match(/^[ \t]*(`{3,}|~{3,})[ \t]*$/); - if ( - close && - close[1][0] === open.char && - close[1].length >= open.len && - indentOf(line) <= open.column + 3 - ) { - // A fence opened inside a list item whose body or closer is *less* - // indented than the opening fence. CommonMark still pairs the two (the - // closer gets its own allowance, as above), so parity looks fine — but the - // under-indented lines have terminated the list item, ejecting the command - // into prose and swallowing whatever followed. This is the exact signature - // of a rewrite that forgot to carry the tag's indentation. - if (report && open.indent > 0) { - const dedented = buf - .slice(1) - .some(body => body.trim() !== '' && indentOf(body) < open.indent); - if (dedented) report.dedentedFenceBody = true; - } - out.push(hold(buf.join('\n'))); - open = null; - buf = []; - } - } - - // An unterminated fence runs to end of document (CommonMark). Mask it: never - // mutate content we can't confidently parse. Callers that are verifying rather - // than transforming want to know, because in a generated artifact it means an - // earlier rewrite emitted a fence line at the wrong indentation. - if (open) { - if (report) report.unterminatedFence = true; - out.push(hold(buf.join('\n'))); - } - - return out.join('\n'); -} - -/** - * Apply `fn` only outside code — fenced blocks *and* inline spans. - * - * Both exclusions are load-bearing: `docs/api/components/tabs.md` documents - * bestax-bulma's own `` / `` inside tsx fences, and the - * migration guides mention the Tabs component inline in prose. Neither is a - * Docusaurus theme tab and neither may be rewritten. - * - * Mask rather than split: a `` block *wraps* fenced code, so the document - * has to stay contiguous for `…` to match across it. - * - * Inline spans are matched single-line only. CommonMark allows one to wrap, but - * an unpaired backtick in prose would then swallow the rest of the document — - * the failure mode of guessing wrong here is much worse than the miss. - */ -function outsideCode(src, fn, report) { - const held = []; - const hold = chunk => { - held.push(chunk); - return NUL + (held.length - 1) + NUL; - }; - - const masked = maskFences(src, hold, report).replace( - /(`+)(?:(?!\1)[^\n])*?\1/g, - hold - ); - - const restore = new RegExp(NUL + '(\\d+)' + NUL, 'g'); - return fn(masked).replace(restore, (_m, i) => held[Number(i)]); -} - -/** - * Rewrite one artifact's contents. Idempotent. - * - * Every rewrite happens inside `outsideCode` — including the import and blank - * line cleanup. Running those after restoration would reach into fenced code and - * strip `import Tabs …` lines out of examples, or collapse deliberate blank - * lines in a code block. - */ -export function transform(src) { - return outsideCode(src, chunk => - flattenGenericTabs(flattenPackageManagerTabs(chunk)) - .replace(/^[ \t]*import\s+(?:Tabs|TabItem|PackageManagerTabs)\b.*$/gm, '') - .replace(/\n{3,}/g, '\n\n') - ); -} - -/** - * Cheap prefilter for `main`. Matches the imports too — `transform` strips - * orphaned tab imports, so an artifact carrying only those still has work to do. - */ -export const NEEDS_FLATTENING = - / { - for (const [tag] of chunk.matchAll( - /])| chunk, deep, { listAware: true }); - - if (residual.size) { - problems.push( - `unflattened tab JSX outside code: ${[...residual].join(', ')}` - ); - } - if (report.dedentedFenceBody || deep.dedentedFenceBody) { - problems.push('fenced block dedented below its opening fence'); - } - if (report.unterminatedFence) { - problems.push('unterminated code fence'); - } - return problems; -} - -/** True when a file is one of the artifacts this script owns. */ -export function isLlmArtifact(file) { - const base = path.basename(file); - if (base.endsWith('.md')) return true; - return base.endsWith('.txt') && base.startsWith('llms'); -} - -async function* walk(dir) { - for (const entry of await readdir(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) yield* walk(full); - else yield full; - } -} - -async function main(buildDir) { - let changed = 0; - const failures = []; - - for await (const file of walk(buildDir)) { - if (!isLlmArtifact(file)) continue; - const before = await readFile(file, 'utf8'); - if (!NEEDS_FLATTENING.test(before)) continue; - const after = transform(before); - - // Verify whether or not anything changed. A no-op transform is exactly the - // case worth checking: a mid-line tag that the line-anchored pattern - // deliberately skips leaves the artifact untouched, so gating verification on - // `after !== before` would silently exempt the very shape this catches. - for (const problem of verifyArtifact(after)) { - failures.push(`${path.relative(buildDir, file)}: ${problem}`); - } - - if (after === before) continue; - await writeFile(file, after); - changed += 1; - } - - console.log( - `flatten-llms-tabs: rewrote ${changed} file(s) under ${buildDir}` - ); - - // Fail the build. These artifacts are what agents consume, and every failure - // mode here is invisible in the rendered site. - if (failures.length) { - throw new Error( - `${failures.length} artifact(s) failed verification:\n ${failures.join('\n ')}` - ); - } -} - -const invokedDirectly = - process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; - -if (invokedDirectly) { - const buildDir = process.argv[2] ?? 'build'; - main(buildDir).catch(error => { - console.error(`flatten-llms-tabs: ${error.message}`); - process.exitCode = 1; - }); -} diff --git a/docs/scripts/flatten-llms-tabs.test.mjs b/docs/scripts/flatten-llms-tabs.test.mjs deleted file mode 100644 index 3c6d51f00..000000000 --- a/docs/scripts/flatten-llms-tabs.test.mjs +++ /dev/null @@ -1,265 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { - transform, - isLlmArtifact, - NEEDS_FLATTENING, -} from './flatten-llms-tabs.mjs'; - -const fence = (lang, body) => '```' + lang + '\n' + body + '\n```'; - -test('PackageManagerTabs collapses to a single pnpm block', () => { - const out = transform( - '' - ); - assert.equal(out.trim(), fence('bash', 'pnpm add @allxsmith/bestax-bulma')); -}); - -test('PackageManagerTabs passes the pnpm verb vocabulary through', () => { - for (const [command, expected] of [ - ['create bestax@latest my-app', 'pnpm create bestax@latest my-app'], - [ - 'dlx create-next-app@latest my-app', - 'pnpm dlx create-next-app@latest my-app', - ], - ['add -D typescript', 'pnpm add -D typescript'], - ['install', 'pnpm install'], - ]) { - const out = transform(``); - assert.equal(out.trim(), fence('bash', expected)); - } -}); - -test('PackageManagerTabs accepts a single-quoted command', () => { - assert.match( - transform(""), - /pnpm add foo/ - ); -}); - -test('a braced-template command is left alone, by design', () => { - // Its backticks are indistinguishable from an inline code span, and masking - // has to run first. Authors use a plain string; this asserts we fail loudly - // (untouched JSX in the artifact) rather than silently mangling it. - const src = ''; - assert.equal(transform(src), src); -}); - -test('generic Tabs keep every body and promote labels to headings', () => { - const src = [ - '', - '', - '', - fence('tsx', 'const x = 1;'), - '', - '', - '', - '', - fence('tsx', 'const y = 2;'), - '', - '', - '', - ].join('\n'); - - const out = transform(src); - - // Complementary content — dropping non-first tabs would delete documentation. - assert.match(out, /^#### ProfileCard\.tsx$/m); - assert.match(out, /^#### Usage$/m); - assert.match(out, /const x = 1;/); - assert.match(out, /const y = 2;/); - assert.doesNotMatch(out, / { - const src = '\n\n\nbody\n\n\n'; - assert.match(transform(src), /^#### scss$/m); -}); - -test("bestax-bulma's own Tabs component survives inside fences", () => { - // Regression: `', - ' Home', - '', - ].join('\n') - ), - ].join('\n'); - - assert.equal(transform(src), src); -}); - -test('Tabs mentioned in inline code spans in prose survive', () => { - const src = - 'You can use helper props with `` and its subcomponents.\n\n' + - 'The legacy `` API is preserved, and `` now provides context.'; - - assert.equal(transform(src), src); -}); - -test('a fenced Tabs example is not flattened by a later real Tabs block', () => { - const src = [ - fence('mdx', '\nx\n'), - '', - '', - 'real body', - '', - ].join('\n'); - - const out = transform(src); - - assert.match( - out, - /```mdx\n\nx<\/TabItem>\n<\/Tabs>\n```/ - ); - assert.match(out, /^#### Real$/m); - assert.match(out, /real body/); -}); - -test('orphaned tab imports are dropped', () => { - const src = [ - "import Tabs from '@theme/Tabs';", - "import TabItem from '@theme/TabItem';", - "import PackageManagerTabs from '@site/src/components/PackageManagerTabs';", - "import { ExampleMeta } from '@site/src/components/SkillExamples';", - '', - '# Title', - ].join('\n'); - - const out = transform(src); - - assert.doesNotMatch(out, /@theme\/Tabs|@theme\/TabItem|PackageManagerTabs/); - // Unrelated imports are left alone — the llms plugin owns that stripping. - assert.match(out, /ExampleMeta/); -}); - -test('transform is idempotent', () => { - const src = [ - '', - '', - '', - '', - '', - fence('ts', 'const a = 1;'), - '', - '', - '', - '', - 'Prose about `` stays put.', - ].join('\n'); - - const once = transform(src); - assert.equal(transform(once), once); -}); - -test('content is never lost to placeholder collisions', () => { - // Masked code is NUL-delimited, so digit runs in prose cannot be mistaken - // for a placeholder on restore. - const src = 'Release 5 8 0 shipped.\n\n' + fence('bash', 'pnpm add foo'); - assert.equal(transform(src), src); -}); - -test('a longer fence wrapping shorter fences is masked as one block', () => { - // Real shape: docs/api/helpers/theme.md and tutorial-basics/markdown-features - // wrap ``` examples in ```` fences. Mis-pairing here shifts every mask - // boundary after it in llms-full.txt. - const src = [ - '````md', - '```tsx', - '', - 'not a real tab', - '', - '```', - '````', - '', - '', - 'real body', - '', - ].join('\n'); - - const out = transform(src); - - assert.match(out, /````md\n```tsx\n\n/); - assert.match(out, /^#### Real$/m); - assert.doesNotMatch(out, /#### Nope/); -}); - -test('tilde fences are masked', () => { - const src = [ - '~~~tsx', - '', - 'y', - '', - '~~~', - ].join('\n'); - assert.equal(transform(src), src); -}); - -test('a tilde fence is not closed by a backtick fence', () => { - const src = [ - '~~~md', - '```', - 'still inside the tilde fence', - '```', - '~~~', - ].join('\n'); - assert.equal(transform(src), src); -}); - -test('an indented fence (up to 3 spaces) still opens a block', () => { - const src = [' ```tsx', ' x', ' ```'].join('\n'); - assert.equal(transform(src), src); -}); - -test('imports inside a fenced example are preserved', () => { - // Regression: the cleanup used to run after code was restored, so a fenced - // example demonstrating the import lost the line. - const src = [ - 'Import it first:', - '', - fence( - 'tsx', - "import Tabs from '@theme/Tabs';\n\nexport default function Demo() {}" - ), - ].join('\n'); - - assert.equal(transform(src), src); -}); - -test('deliberate blank lines inside a fence are preserved', () => { - const src = [ - '', - '', - fence('ts', 'const a = 1;\n\n\nconst b = 2;'), - ].join('\n'); - - const out = transform(src); - - assert.match(out, /const a = 1;\n\n\nconst b = 2;/); - assert.match(out, /pnpm add foo/); -}); - -test('an unterminated fence is masked rather than rewritten', () => { - const src = ['```tsx', '', 'y'].join('\n'); - assert.equal(transform(src), src); -}); - -test('NEEDS_FLATTENING matches import-only artifacts', () => { - assert.equal(NEEDS_FLATTENING.test("import Tabs from '@theme/Tabs';"), true); - assert.equal(NEEDS_FLATTENING.test(''), true); - assert.equal(NEEDS_FLATTENING.test('# Just a heading'), false); -}); - -test('isLlmArtifact selects .md twins and llms*.txt only', () => { - assert.equal(isLlmArtifact('build/docs/guides/intro.md'), true); - assert.equal(isLlmArtifact('build/llms.txt'), true); - assert.equal(isLlmArtifact('build/llms-full.txt'), true); - assert.equal(isLlmArtifact('build/robots.txt'), false); - assert.equal(isLlmArtifact('build/assets/js/main.js'), false); - assert.equal(isLlmArtifact('build/index.html'), false); -}); diff --git a/docs/scripts/package-manager-translate.test.mjs b/docs/scripts/package-manager-translate.test.mjs index e5ad22be6..49794b5a5 100644 --- a/docs/scripts/package-manager-translate.test.mjs +++ b/docs/scripts/package-manager-translate.test.mjs @@ -14,7 +14,6 @@ import { renderCommand, lintCommand, } from '../src/components/PackageManagerTabs/translate.mjs'; -import { transform } from './flatten-llms-tabs.mjs'; test('pnpm is first, so it is the default tab', () => { assert.deepEqual(PACKAGE_MANAGERS, ['pnpm', 'npm', 'yarn', 'bun']); @@ -144,22 +143,35 @@ test('the pnpm rendering is always a pure prefix', () => { } }); -test('the flattener emits exactly the pnpm tab', () => { - // The single assertion that mechanically keeps llms.txt and the default tab in - // sync. If these ever diverge, agents copy a command no reader is shown. +/** + * Strip the `pnpm ` prefix line-wise — the inverse PackageManagerTabs applies to + * recover the authoring vocabulary from the fence it is given. + */ +const unrender = pnpmForm => + pnpmForm + .split('\n') + .map(line => line.replace(/^pnpm /, '')) + .join('; '); + +test('the pnpm rendering round-trips back to the authored command', () => { + // The identity the whole design rests on now. The component is handed a pnpm + // fence, derives the authored command from it, and renders the other three + // managers off that — so if this inverse were lossy, npm/yarn/bun users would + // get a command derived from something the page never showed. The component + // asserts the same equality at prerender, which turns a bad fence into a build + // failure rather than three wrong tabs. for (const [authored] of TABLE) { - const out = transform(``); - const body = out.trim().split('\n').slice(1, -1).join('\n'); - assert.equal(body, renderCommand(authored, 'pnpm')); + const pnpmForm = renderCommand(authored, 'pnpm'); + assert.equal(renderCommand(unrender(pnpmForm), 'pnpm'), pnpmForm); } }); -test('the flattener emits the pnpm tab for multi-segment commands too', () => { +test('the round trip holds for multi-segment commands too', () => { const authored = 'create vite@latest my-app -- --template react; cd my-app; install'; - const out = transform(``); - const body = out.trim().split('\n').slice(1, -1).join('\n'); - assert.equal(body, renderCommand(authored, 'pnpm')); + const pnpmForm = renderCommand(authored, 'pnpm'); + assert.equal(unrender(pnpmForm), authored); + assert.equal(renderCommand(unrender(pnpmForm), 'pnpm'), pnpmForm); }); test('lintCommand flags a command that already names a package manager', () => { diff --git a/docs/src/components/PackageManagerTabs/index.js b/docs/src/components/PackageManagerTabs/index.js index bb5fd9dec..105e19c7e 100644 --- a/docs/src/components/PackageManagerTabs/index.js +++ b/docs/src/components/PackageManagerTabs/index.js @@ -7,18 +7,41 @@ import { TAB_GROUP_ID, renderCommand, lintCommand, - splitSegments, } from './translate.mjs'; import styles from './styles.module.css'; /** * One install command, shown for pnpm, npm, yarn and bun. * - * `command` is authored in pnpm's verb vocabulary — `add foo`, `add -D foo`, - * `install`, `create x`, `dlx x`, `run dev` — and translate.mjs derives the other - * three. Several lines go in one `command`, separated by `;`; segments that - * aren't a known verb (`cd my-app`, a `#` comment) pass through unchanged. See - * translate.mjs for why pnpm is the authoring vocabulary and the first tab. + * Authored as a plain pnpm code fence wrapped in the component: + * + * + * + * ```bash + * pnpm add @allxsmith/bestax-bulma + * ``` + * + * + * + * The fence is the single source of truth. npm, yarn and bun are derived from it + * by translate.mjs; several lines are just several lines in the fence, and a line + * that isn't a pnpm verb (`cd my-app`, a `#` comment) passes through unchanged. + * + * **Why children and not a `command` prop.** `docusaurus-plugin-llms` reads source + * markdown to build llms.txt and the per-page .md twins, and since 0.5.0 it strips + * PascalCase JSX tags while "keeping their inner text content". Content in a *prop* + * is not inner text — it goes with the tag. A self-closing + * `` therefore left an empty section in every + * artifact, silently: the site rendered fine and only the machine-readable copy + * lost the command. Putting the fence in the children means the plugin's strip + * removes the wrapper and leaves exactly the pnpm block behind, so the artifact is + * correct by construction with no build step to keep in sync. See + * rachfop/docusaurus-plugin-llms#64. + * + * That is also why the pnpm tab is written out rather than derived: what an agent + * reads in llms.txt is literally the text authored here, so the default tab a + * reader sees and the artifact an agent copies cannot drift. The assertion below + * enforces the other half — that the derived pnpm rendering matches the fence. * * The shared `TAB_GROUP_ID` is the point of using @theme/Tabs rather than * hand-rolling: Docusaurus persists the choice in localStorage, so picking npm on @@ -29,23 +52,59 @@ import styles from './styles.module.css'; * Registered globally in src/theme/MDXComponents.js, so docs pages use it without * an import. */ -export default function PackageManagerTabs({ command }) { + +/** + * Flatten MDX children to their text. + * + * A fenced block reaches us as an element tree (`pre` → `code` → string, through + * whatever the theme maps those to — this site swizzles CodeBlock). Walking + * `props.children` recursively avoids depending on that shape, so a theme upgrade + * that adds a wrapper doesn't silently return the wrong text. + */ +function textOf(node) { + if (node === null || node === undefined || typeof node === 'boolean') + return ''; + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(textOf).join(''); + if (node.props) return textOf(node.props.children); + return ''; +} + +export default function PackageManagerTabs({ children }) { + // The fence as authored, minus the trailing newline MDX leaves on a code block. + const authored = textOf(children).replace(/\s+$/, ''); + // Throws rather than rendering something wrong, and deliberately not gated on // NODE_ENV: `docusaurus build` prerenders every page with NODE_ENV=production, // so an unconditional throw is what turns an authoring slip into a failed build - // that names the page. A dev-only check would miss it in CI entirely. - // - // The flatten gate in flatten-llms-tabs.mjs already catches a *missing* - // attribute — its regex requires a literal command="…", so - // survives as raw JSX and fails verifyArtifact. It cannot see command="" or - // command=" ; ", which flatten to a silent empty ```bash fence. This closes that. - // - // Because the prerender covers every page, this can never fire in a browser on + // that names the page. A dev-only check would miss it in CI entirely — and + // because the prerender covers every page, this can never fire in a browser on // a site that built successfully. - if (typeof command !== 'string' || splitSegments(command).length === 0) { + if (!authored) { + throw new Error( + 'PackageManagerTabs: expected a pnpm code fence as its children, e.g.\n' + + '\n\n```bash\npnpm add @allxsmith/bestax-bulma\n```\n\n' + ); + } + + // Recover the authoring vocabulary translate.mjs expects (`add foo`) from the + // rendered pnpm form (`pnpm add foo`). Lines without the prefix are passthrough + // (`cd my-app`) and survive as themselves. + const command = authored + .split('\n') + .map(line => line.replace(/^pnpm /, '')) + .join('; '); + + // The round trip has to be exact, or the other three tabs are derived from + // something the reader never saw. This fires on a fence that isn't canonical + // pnpm — `npm install foo`, odd spacing, a stray blank line — naming the page. + const roundTrip = renderCommand(command, DEFAULT_PACKAGE_MANAGER); + if (roundTrip !== authored) { throw new Error( - `PackageManagerTabs: \`command\` must be a non-empty string, got ${JSON.stringify(command)}. ` + - 'Author it in pnpm verb vocabulary, e.g. command="add @allxsmith/bestax-bulma".' + `PackageManagerTabs: the fence is not a canonical pnpm command.\n` + + ` authored: ${JSON.stringify(authored)}\n` + + ` round trip: ${JSON.stringify(roundTrip)}\n` + + 'Write the pnpm form exactly, one command per line.' ); } diff --git a/docs/src/components/PackageManagerTabs/translate.mjs b/docs/src/components/PackageManagerTabs/translate.mjs index 777d32f29..fb2b5ee92 100644 --- a/docs/src/components/PackageManagerTabs/translate.mjs +++ b/docs/src/components/PackageManagerTabs/translate.mjs @@ -7,11 +7,13 @@ * lets the pnpm rendering be a pure prefix: `renderCommand(cmd, 'pnpm')` is * always `pnpm ${cmd}`. * - * That identity is load-bearing. `docs/scripts/flatten-llms-tabs.mjs` collapses - * `` to a single pnpm block in the published LLM artifacts, - * and it imports this module rather than reimplementing the rule — so what an - * agent copies out of llms.txt is byte-identical to what a reader sees on the - * default tab. + * That identity is load-bearing, and it runs in both directions. Pages author the + * pnpm form as a real code fence inside ``; the component + * strips the `pnpm ` prefix line-wise to recover the authored command, then + * derives the other three from it. Because the fence is ordinary markdown, it is + * also exactly what survives into llms.txt — so what an agent copies out of the + * artifact is byte-identical to what a reader sees on the default tab, without + * anything having to keep the two in sync. * * `.mjs`, not `.js`: `docs/package.json` has no `"type": "module"`, so node * would load a `.js` file as CommonJS and `node --test` could not import it. @@ -35,9 +37,9 @@ export const PACKAGE_MANAGERS = ['pnpm', 'npm', 'yarn', 'bun']; * * Named rather than taken as `PACKAGE_MANAGERS[0]` so tab *order* and the * *default* can move independently — and because this value is not merely - * cosmetic: `flatten-llms-tabs.mjs` collapses every `` to - * the pnpm rendering, so the default tab is what makes the page a reader sees - * agree with the artifact an agent reads. Changing it desyncs the two. + * cosmetic: pages author the pnpm form, and that fence is what survives into + * llms.txt, so the default tab is what makes the page a reader sees agree with + * the artifact an agent reads. Changing it desyncs the two. */ export const DEFAULT_PACKAGE_MANAGER = 'pnpm'; From 1883de3ddea548e13e022a8f40787cf9624de243 Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sat, 1 Aug 2026 16:46:03 -0400 Subject: [PATCH 4/4] fix(docs): correct the frozen-install translation and reject leaked fences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #433. **`install --frozen-lockfile` was wrong for npm and yarn.** npm has no such flag — `npm ci` *is* the frozen install — so `npm install --frozen-lockfile` did a normal, lockfile-mutating install: the opposite of what the reader asked for, and silent. guides/security.md already tells readers the npm equivalent is `npm ci`, so converting that page would have made it contradict itself. Yarn now gets Berry's `--immutable`, matching the `dlx` line that already targets Berry over Classic. bun takes the flag as written. No page emits this yet; it would have surfaced on the first conversion PR. **A leaked fence delimiter now throws.** If MDX doesn't parse the block — almost always the missing blank lines around it — the ``` delimiters arrive as text. The round-trip assertion cannot catch that: ```bash is not a known verb, so it passes through untouched on every tab and the equality still holds, while the tabs render fence markers inside the code block. Checked explicitly, with a message naming the actual mistake. There is a test asserting the round trip provably does *not* catch this, so the guard can't be removed as redundant. **The pnpm inverse moves into translate.mjs as `unrenderPnpm`.** It was inlined in a JSX file, where `node --test` cannot reach it — the round trip is the design's central invariant and its inverse had no direct coverage. Component and tests now share one definition. Not addressed, deliberately: loss (already documented here and filed upstream), and full unit coverage of the component itself, which would need a jest/RTL harness `docs` does not have — the prerender throws run over every page on every build, which is why they are unconditional. Claude-Session: https://claude.ai/code/session_01TGA6sFTUGsJ6oXhfpjKEnh --- .../package-manager-translate.test.mjs | 39 +++++++++------ .../components/PackageManagerTabs/index.js | 22 ++++++--- .../PackageManagerTabs/translate.mjs | 48 +++++++++++++++++-- 3 files changed, 84 insertions(+), 25 deletions(-) diff --git a/docs/scripts/package-manager-translate.test.mjs b/docs/scripts/package-manager-translate.test.mjs index 49794b5a5..f86b2be13 100644 --- a/docs/scripts/package-manager-translate.test.mjs +++ b/docs/scripts/package-manager-translate.test.mjs @@ -12,6 +12,7 @@ import { splitSegments, translateSegment, renderCommand, + unrenderPnpm, lintCommand, } from '../src/components/PackageManagerTabs/translate.mjs'; @@ -37,10 +38,13 @@ const TABLE = [ ], ['install', 'pnpm install', 'npm install', 'yarn', 'bun install'], [ + // npm has no --frozen-lockfile; `npm ci` is the frozen install, and + // guides/security.md tells readers exactly that. Berry spells it + // --immutable. bun takes the flag as written. 'install --frozen-lockfile', 'pnpm install --frozen-lockfile', - 'npm install --frozen-lockfile', - 'yarn install --frozen-lockfile', + 'npm ci', + 'yarn install --immutable', 'bun install --frozen-lockfile', ], [ @@ -143,16 +147,6 @@ test('the pnpm rendering is always a pure prefix', () => { } }); -/** - * Strip the `pnpm ` prefix line-wise — the inverse PackageManagerTabs applies to - * recover the authoring vocabulary from the fence it is given. - */ -const unrender = pnpmForm => - pnpmForm - .split('\n') - .map(line => line.replace(/^pnpm /, '')) - .join('; '); - test('the pnpm rendering round-trips back to the authored command', () => { // The identity the whole design rests on now. The component is handed a pnpm // fence, derives the authored command from it, and renders the other three @@ -162,7 +156,7 @@ test('the pnpm rendering round-trips back to the authored command', () => { // failure rather than three wrong tabs. for (const [authored] of TABLE) { const pnpmForm = renderCommand(authored, 'pnpm'); - assert.equal(renderCommand(unrender(pnpmForm), 'pnpm'), pnpmForm); + assert.equal(renderCommand(unrenderPnpm(pnpmForm), 'pnpm'), pnpmForm); } }); @@ -170,8 +164,23 @@ test('the round trip holds for multi-segment commands too', () => { const authored = 'create vite@latest my-app -- --template react; cd my-app; install'; const pnpmForm = renderCommand(authored, 'pnpm'); - assert.equal(unrender(pnpmForm), authored); - assert.equal(renderCommand(unrender(pnpmForm), 'pnpm'), pnpmForm); + assert.equal(unrenderPnpm(pnpmForm), authored); + assert.equal(renderCommand(unrenderPnpm(pnpmForm), 'pnpm'), pnpmForm); +}); + +test('a fence delimiter that leaks into the text is invisible to the round trip', () => { + // Why the component checks for ``` separately instead of relying on the round + // trip. If MDX doesn't parse the block (missing blank lines around it), the + // delimiters arrive as text; they aren't known verbs, so they pass through on + // every tab and the equality still holds — while the rendered tabs would show + // fence markers inside the code block. + const leaked = '```bash\npnpm add foo\n```'; + assert.equal( + renderCommand(unrenderPnpm(leaked), 'pnpm'), + leaked, + 'round trip passes, so it cannot be the guard for this case' + ); + assert.match(renderCommand(unrenderPnpm(leaked), 'npm'), /^```bash$/m); }); test('lintCommand flags a command that already names a package manager', () => { diff --git a/docs/src/components/PackageManagerTabs/index.js b/docs/src/components/PackageManagerTabs/index.js index 105e19c7e..0d0634384 100644 --- a/docs/src/components/PackageManagerTabs/index.js +++ b/docs/src/components/PackageManagerTabs/index.js @@ -6,6 +6,7 @@ import { DEFAULT_PACKAGE_MANAGER, TAB_GROUP_ID, renderCommand, + unrenderPnpm, lintCommand, } from './translate.mjs'; import styles from './styles.module.css'; @@ -87,13 +88,22 @@ export default function PackageManagerTabs({ children }) { ); } + // A fence delimiter reaching us as *text* means MDX didn't parse the block — + // almost always the missing blank lines around it. Worth its own check because + // the round trip below cannot catch it: ```bash is not a known verb, so it + // passes through untouched on every tab and the equality still holds. The tabs + // would then render the fence markers inside the code block, silently. + if (/^\s*(?:```|~~~)/m.test(authored)) { + throw new Error( + 'PackageManagerTabs: the code fence was not parsed as markdown — its\n' + + 'delimiters arrived as text. Put a blank line before and after it:\n' + + '\n\n```bash\npnpm add @allxsmith/bestax-bulma\n```\n\n' + ); + } + // Recover the authoring vocabulary translate.mjs expects (`add foo`) from the - // rendered pnpm form (`pnpm add foo`). Lines without the prefix are passthrough - // (`cd my-app`) and survive as themselves. - const command = authored - .split('\n') - .map(line => line.replace(/^pnpm /, '')) - .join('; '); + // rendered pnpm form (`pnpm add foo`). + const command = unrenderPnpm(authored); // The round trip has to be exact, or the other three tabs are derived from // something the reader never saw. This fires on a fence that isn't canonical diff --git a/docs/src/components/PackageManagerTabs/translate.mjs b/docs/src/components/PackageManagerTabs/translate.mjs index fb2b5ee92..1dce0d2ef 100644 --- a/docs/src/components/PackageManagerTabs/translate.mjs +++ b/docs/src/components/PackageManagerTabs/translate.mjs @@ -74,12 +74,20 @@ export function splitSegments(command) { /** * Translate one segment for one package manager. * - * Three rules go beyond swapping the prefix: + * Four rules go beyond swapping the prefix: * - `dlx` is a whole-prefix replacement (`npx`, `bunx`), not a verb swap. * - bun spells the dev-dependency flag `-d`, not `-D`. * - npm needs `--` to pass flags through a `create` scaffolder; yarn and bun * forward arguments directly and would hand the `--` to the scaffolder. + * - `--frozen-lockfile` is pnpm's spelling of "install exactly the lockfile". + * npm has no such flag — it is a whole different verb, `npm ci` — and Berry + * spells it `--immutable`. bun takes the flag as-is. Getting this wrong is + * silent: `npm install --frozen-lockfile` does a normal, lockfile-mutating + * install, which is the opposite of what the reader asked for, and + * `guides/security.md` already tells readers the npm equivalent is `npm ci`. */ +const FROZEN = '--frozen-lockfile'; + export function translateSegment(segment, manager) { const tokens = segment.split(' '); const [verb, ...rest] = tokens; @@ -90,6 +98,8 @@ export function translateSegment(segment, manager) { const join = parts => parts.filter(Boolean).join(' '); const withoutDoubleDash = () => rest.filter(token => token !== '--'); const bunFlags = () => rest.map(token => (token === '-D' ? '-d' : token)); + const frozen = rest.includes(FROZEN); + const withoutFrozen = () => rest.filter(token => token !== FROZEN); switch (manager) { case 'npm': @@ -100,15 +110,26 @@ export function translateSegment(segment, manager) { return join(['npm', 'uninstall', ...rest]); case 'dlx': return join(['npx', ...rest]); + case 'install': + // `npm ci` *is* the frozen install; the flag has no npm spelling. + return frozen + ? join(['npm', 'ci', ...withoutFrozen()]) + : join(['npm', 'install', ...rest]); default: - // install / create / run keep npm's own verb, and `create` keeps `--`. + // create / run keep npm's own verb, and `create` keeps `--`. return join(['npm', verb, ...rest]); } case 'yarn': switch (verb) { - case 'install': + case 'install': { + // Berry spells the frozen install `--immutable`. Chosen to match the + // `dlx` line below, which already targets Berry over Classic. + const flags = frozen + ? [...withoutFrozen(), '--immutable'] + : [...rest]; // Bare `yarn` is the idiomatic install; with flags it needs the verb. - return rest.length ? join(['yarn', 'install', ...rest]) : 'yarn'; + return flags.length ? join(['yarn', 'install', ...flags]) : 'yarn'; + } case 'create': return join(['yarn', 'create', ...withoutDoubleDash()]); case 'run': @@ -143,6 +164,25 @@ export function renderCommand(command, manager) { .join('\n'); } +/** + * The inverse of `renderCommand(…, 'pnpm')`: recover the authored command from a + * rendered pnpm block. + * + * Pages author the pnpm form as a real code fence (see the component), so this is + * how the component gets back to the vocabulary the translators expect. Lines + * without the prefix are passthrough (`cd my-app`) and survive as themselves. + * + * Exported so it is unit-testable and so the component and its tests share one + * definition — the round trip is the design's central invariant, and an inverse + * that only existed inside a JSX file could not be exercised by `node --test`. + */ +export function unrenderPnpm(pnpmForm) { + return pnpmForm + .split('\n') + .map(line => line.replace(/^pnpm /, '')) + .join('; '); +} + /** * Authoring mistakes that render identically on all four tabs, and so would * otherwise ship unnoticed. Surfaced as a dev-only warning by the component.