diff --git a/docs/guides/docs-preset.md b/docs/guides/docs-preset.md
index 1c21459..7aab742 100644
--- a/docs/guides/docs-preset.md
+++ b/docs/guides/docs-preset.md
@@ -82,11 +82,27 @@ The preset transforms these `tabs` variants:
| --- | --- | --- |
| default | Sections divided by the shallowest heading | tab names, slugs, and `md-tab-panel` children |
| `files` | Fenced code blocks with `file=` or `title=` | file metadata and one panel per file |
-| `package-manager` | `framework: package...` lines | package groups and install mode |
+| `package-manager` | Shared `package...` lines or `framework: package...` lines | package groups and install mode |
| `bundler` | Vite and Rsbuild heading sections | available bundlers and panel content |
The transforms emit custom element names and JSON `data-*` properties. Your application owns the components and behavior attached to that contract.
+Package-manager lines without a framework prefix apply to every framework. Each line is a separate command, so this block supplies three commands to the application's package-manager component:
+
+```md
+
+
+@tanstack/intent@latest list
+@tanstack/intent@latest validate
+@tanstack/intent@latest review
+
+
+```
+
+The `data-package-manager-meta` JSON keeps shared commands under the empty string key in `packagesByFramework`. This fallback group comes first. Named framework groups include shared lines in source order, so renderers can select a named group or fall back to the shared group. Framework discovery should ignore the empty key.
+
+Use a fenced text block inside the component when command arguments contain literal Markdown characters such as `#` or `*`.
+
## Framework panels
`framework` blocks split top-level framework headings into `md-framework-panel` elements. Nested headings receive a framework label, while top-level selector headings are omitted from collected table-of-contents data.
diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md
index 3215d15..63093c3 100644
--- a/docs/reference/extensions.md
+++ b/docs/reference/extensions.md
@@ -163,7 +163,7 @@ Turns code-block children into `md-tab-panel` elements and records file names, l
### `transformPackageManagerTabs`
-Parses `framework: package...` lines and records package groups plus `install`, `dev-install`, or `local-install` mode.
+Parses shared `package...` lines and `framework: package...` lines and records package groups plus `install`, `dev-install`, or `local-install` mode. Each line becomes a separate command group. Unprefixed groups appear under the empty string key in `packagesByFramework` and are also included in each named framework's groups, preserving source order.
### `transformBundlerTabs`
diff --git a/src/extensions/tabs.ts b/src/extensions/tabs.ts
index b7994cd..79b499c 100644
--- a/src/extensions/tabs.ts
+++ b/src/extensions/tabs.ts
@@ -51,27 +51,35 @@ export function transformFileTabs(node: ComponentNode): ComponentNode {
export function transformPackageManagerTabs(node: ComponentNode): ComponentNode {
const packagesByFramework: Record = {}
+ const sharedPackages: string[][] = []
for (const line of blocksToText(node.children).split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
- const colon = trimmed.indexOf(':')
- if (colon === -1) continue
- const framework = trimmed.slice(0, colon).trim().toLowerCase()
- const packages = trimmed.slice(colon + 1).trim().split(/\s+/).filter(Boolean)
- if (!framework || packages.length === 0) continue
- packagesByFramework[framework] ??= []
- packagesByFramework[framework]!.push(packages)
+ const match = /^([\w-]+)\s*:\s*(.*)$/.exec(trimmed)
+ const framework = match?.[1]?.toLowerCase()
+ const packages = (match?.[2] ?? trimmed).split(/\s+/).filter(Boolean)
+ if (!packages.length) continue
+ if (framework) {
+ packagesByFramework[framework] ??= [...sharedPackages]
+ packagesByFramework[framework]!.push(packages)
+ } else {
+ sharedPackages.push(packages)
+ for (const groups of Object.values(packagesByFramework)) groups.push(packages)
+ }
}
- if (!Object.keys(packagesByFramework).length) return node
+ if (!sharedPackages.length && !Object.keys(packagesByFramework).length) return node
return {
...node,
properties: {
...(node.properties ?? {}),
'data-package-manager-meta': JSON.stringify({
- packagesByFramework,
+ // The empty key is the fallback for frameworks without their own lines.
+ packagesByFramework: sharedPackages.length
+ ? { '': sharedPackages, ...packagesByFramework }
+ : packagesByFramework,
mode: resolveInstallMode(node.attributes.mode),
}),
},
diff --git a/tests/docs-extensions.test.ts b/tests/docs-extensions.test.ts
index e866250..3f9d1f3 100644
--- a/tests/docs-extensions.test.ts
+++ b/tests/docs-extensions.test.ts
@@ -90,6 +90,120 @@ solid: @tanstack/solid-query
expect(html).not.toContain('@tanstack/react-query
')
})
+ it('keeps unprefixed package-manager commands on separate shared lines', () => {
+ const document = parseMarkdown(
+ `
+
+@tanstack/intent@latest list
+@tanstack/intent@latest validate
+@tanstack/intent@latest review
+
+`,
+ { extensions: docs },
+ )
+
+ expect(document.children[0]).toMatchObject({
+ type: 'component',
+ children: [],
+ properties: {
+ 'data-package-manager-meta': JSON.stringify({
+ packagesByFramework: {
+ '': [
+ ['@tanstack/intent@latest', 'list'],
+ ['@tanstack/intent@latest', 'validate'],
+ ['@tanstack/intent@latest', 'review'],
+ ],
+ },
+ mode: 'local-install',
+ }),
+ },
+ })
+ })
+
+ it('preserves framework prefix whitespace handling and ignores empty framework lines', () => {
+ const document = parseMarkdown(
+ `
+
+react:react-first
+React : react-second
+solid:
+
+`,
+ { extensions: docs },
+ )
+
+ expect(document.children[0]).toMatchObject({
+ properties: {
+ 'data-package-manager-meta': JSON.stringify({
+ packagesByFramework: {
+ react: [['react-first'], ['react-second']],
+ },
+ mode: 'install',
+ }),
+ },
+ })
+ })
+
+ it('includes shared commands in each framework in source order', () => {
+ const document = parseMarkdown(
+ `
+
+react: react-only
+shared-first
+solid: solid-only
+shared-last
+react: react-last
+
+`,
+ { extensions: docs },
+ )
+
+ expect(document.children[0]).toMatchObject({
+ properties: {
+ 'data-package-manager-meta': JSON.stringify({
+ packagesByFramework: {
+ '': [['shared-first'], ['shared-last']],
+ react: [['react-only'], ['shared-first'], ['shared-last'], ['react-last']],
+ solid: [['shared-first'], ['solid-only'], ['shared-last']],
+ },
+ mode: 'install',
+ }),
+ },
+ })
+ })
+
+ it.each(['install', 'dev-install', 'local-install'])('preserves literal shared commands in %s mode', mode => {
+ const document = parseMarkdown(
+ `
+
+\`\`\`text
+@tanstack/intent@latest load #
+@tanstack/intent@latest exclude add package#experimental-*
+@tanstack/intent@latest review --base refs/heads/main > .intent/review.json
+tool --registry https://registry.example.com --filter name:value
+\`\`\`
+
+`,
+ { extensions: docs, allowHtml: true },
+ )
+
+ expect(document.children[0]).toMatchObject({
+ properties: {
+ 'data-package-manager-meta': JSON.stringify({
+ packagesByFramework: {
+ '': [
+ ['@tanstack/intent@latest', 'load', '#'],
+ ['@tanstack/intent@latest', 'exclude', 'add', 'package#experimental-*'],
+ ['@tanstack/intent@latest', 'review', '--base', 'refs/heads/main', '>', '.intent/review.json'],
+ ['tool', '--registry', 'https://registry.example.com', '--filter', 'name:value'],
+ ],
+ },
+ mode,
+ }),
+ },
+ })
+ })
+
it('transforms framework panels and skips tab headings in collected headings', () => {
const document = parseMarkdown(
`