diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index e72ebbd0b..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,4 +81,30 @@ 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 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. - Markdown is prettier-formatted (`pnpm format:check` covers `md`/`mdx`). diff --git a/docs/docs/guides/intro.md b/docs/docs/guides/intro.md index 5ad1e80e0..07848c327 100644 --- a/docs/docs/guides/intro.md +++ b/docs/docs/guides/intro.md @@ -29,10 +29,14 @@ 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 Import the combined stylesheet in your application entry point (e.g. `main.jsx`, `main.tsx`, `index.js`): 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..f86b2be13 100644 --- a/docs/scripts/package-manager-translate.test.mjs +++ b/docs/scripts/package-manager-translate.test.mjs @@ -12,9 +12,9 @@ import { splitSegments, translateSegment, renderCommand, + unrenderPnpm, 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']); @@ -38,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', ], [ @@ -144,22 +147,40 @@ 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. +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(unrenderPnpm(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(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 new file mode 100644 index 000000000..0d0634384 --- /dev/null +++ b/docs/src/components/PackageManagerTabs/index.js @@ -0,0 +1,140 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import CodeBlock from '@theme/CodeBlock'; +import { + PACKAGE_MANAGERS, + DEFAULT_PACKAGE_MANAGER, + TAB_GROUP_ID, + renderCommand, + unrenderPnpm, + lintCommand, +} from './translate.mjs'; +import styles from './styles.module.css'; + +/** + * One install command, shown for pnpm, npm, yarn and bun. + * + * 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 + * 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. + */ + +/** + * 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 — and + // because the prerender covers every page, this can never fire in a browser on + // a site that built successfully. + 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' + ); + } + + // 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`). + 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 + // 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: 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.' + ); + } + + 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..f63b5048a --- /dev/null +++ b/docs/src/components/PackageManagerTabs/styles.module.css @@ -0,0 +1,29 @@ +/* + * 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__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..1dce0d2ef 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. @@ -27,9 +29,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: 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'; + +/** + * 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']); @@ -47,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; @@ -63,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': @@ -73,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': @@ -116,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. 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, +};