'), 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,
+};