diff --git a/docs/guides/docs-preset.md b/docs/guides/docs-preset.md
index 9c4db0e..aeb65f2 100644
--- a/docs/guides/docs-preset.md
+++ b/docs/guides/docs-preset.md
@@ -84,11 +84,29 @@ 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
+
+
+```
+
+A line is a framework line when it starts with a name made of letters, digits, `_`, or `-` followed by a colon, so both `react:package` and `react: package` select the `react` group. A colon that starts a URL or path does not begin a framework prefix: `https://example.com/package.tgz`, `file:../local-package`, and `git+ssh://` specifiers are shared commands. A framework line can still install a protocol package, as in `react: file:../local-package`.
+
+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..44fb7a6 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. A framework prefix is a name of letters, digits, `_`, or `-` followed by a colon that is not directly followed by `/` or `.`, so URL and path specifiers such as `https://` and `file:../` stay shared. 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/skills/docs-features/SKILL.md b/skills/docs-features/SKILL.md
index 6d8867e..4268ee1 100644
--- a/skills/docs-features/SKILL.md
+++ b/skills/docs-features/SKILL.md
@@ -126,7 +126,7 @@ main {
````
-Package-manager tabs consume `framework: package...` lines and remove their source children after creating metadata:
+Package-manager tabs consume `framework: package...` lines and shared `package...` lines, then remove their source children after creating metadata:
```md
diff --git a/skills/docs-features/references/docs-metadata.md b/skills/docs-features/references/docs-metadata.md
index 4de29cb..6cd2976 100644
--- a/skills/docs-features/references/docs-metadata.md
+++ b/skills/docs-features/references/docs-metadata.md
@@ -194,13 +194,14 @@ No direct code children means the original component is returned.
### Package-manager tabs
-Accepted variants are `package-manager` and `package-managers`. Each nonempty source line uses:
+Accepted variants are `package-manager` and `package-managers`. Each nonempty source line is either a framework line or a shared line:
```text
framework: package-one package-two
+package-three
```
-Framework names become lowercase. Repeated framework lines append package arrays rather than merging them.
+A framework prefix is a name of letters, digits, `_`, or `-` followed by a colon that is not directly followed by `/` or `.`, so `https://` and `file:../` specifiers are shared lines. Framework names become lowercase. Repeated framework lines append package arrays rather than merging them. Shared lines are stored under the empty string key, which is emitted first, and are also appended to every framework group in source order.
The root sets:
@@ -217,7 +218,7 @@ interface PackageManagerProperties {
`data-package-manager-meta` is JSON-encoded `PackageManagerMetadata`. Only `dev-install` and `local-install` are preserved; an omitted, differently cased, or unknown mode resolves after lowercasing to `install`. Successful transformation replaces all children with an empty array and emits no `md-tab-panel` children.
-No valid `framework: packages` line means the original component is returned.
+No valid framework or shared line means the original component is returned.
### Bundler tabs
diff --git a/src/extensions/shared.ts b/src/extensions/shared.ts
index 459e45a..cb267e3 100644
--- a/src/extensions/shared.ts
+++ b/src/extensions/shared.ts
@@ -2,7 +2,7 @@ import type { BlockNode, ComponentNode } from '../types.js'
import { plainText } from '../utils.js'
export interface HeadingSection {
- id?: string
+ id?: string | undefined
name: string
children: BlockNode[]
}
@@ -33,19 +33,12 @@ export function blocksToText(blocks: BlockNode[]): string {
export function splitByHeading(children: BlockNode[], forcedDepth?: number): HeadingSection[] {
const depth = forcedDepth ?? children.reduce((depth, child) => child.type === 'heading' ? Math.min(depth, child.depth) : depth, Infinity)
- if (!Number.isFinite(depth)) return []
-
const sections: HeadingSection[] = []
let current: HeadingSection | undefined
for (const child of children) {
if (child.type === 'heading' && child.depth === depth) {
- current = {
- name: plainText(child.children),
- children: [],
- }
- if (child.id) current.id = child.id
- sections.push(current)
+ sections.push((current = { id: child.id, name: plainText(child.children), children: [] }))
continue
}
if (current) current.children.push(child)
@@ -60,8 +53,7 @@ export function slugify(value: string, fallback: string) {
.trim()
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
- .replace(/\s+/g, '-')
- .replace(/-+/g, '-')
+ .replace(/[\s-]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 64) || fallback
)
diff --git a/src/extensions/tabs.ts b/src/extensions/tabs.ts
index 28aba43..ecbd69d 100644
--- a/src/extensions/tabs.ts
+++ b/src/extensions/tabs.ts
@@ -1,8 +1,6 @@
import type { BlockNode, ComponentNode } from '../types.js'
import { blocksToText, slugify, splitByHeading } from './shared.js'
-const bundlers = ['vite', 'rsbuild'] as const
-
export function transformTabsComponent(node: ComponentNode): ComponentNode {
const variant = node.attributes.variant?.toLowerCase()
@@ -25,7 +23,7 @@ export function transformFileTabs(node: ComponentNode): ComponentNode {
return {
...node,
properties: {
- ...(node.properties ?? {}),
+ ...node.properties,
'data-attributes': JSON.stringify({ tabs }),
'data-files-meta': JSON.stringify({
files: files.map(file => ({
@@ -41,8 +39,8 @@ export function transformFileTabs(node: ComponentNode): ComponentNode {
tagName: 'md-tab-panel',
attributes: {},
properties: {
- 'data-tab-slug': `file-${index}`,
- 'data-tab-index': String(index),
+ 'data-tab-slug': tabs[index]!.slug,
+ 'data-tab-index': `${index}`,
},
children: [file],
})),
@@ -50,24 +48,26 @@ export function transformFileTabs(node: ComponentNode): ComponentNode {
}
export function transformPackageManagerTabs(node: ComponentNode): ComponentNode {
+ // Shared lines live under the empty key and are also appended to every framework group in source order.
const packagesByFramework: Record = Object.create(null)
-
- for (const line of blocksToText(node.children).split('\n')) {
- const colon = line.indexOf(':')
- if (colon === -1) continue
- const framework = line.slice(0, colon).trim().toLowerCase()
- const packages = line.slice(colon + 1).trim().split(/\s+/).filter(Boolean)
- if (!framework || packages.length === 0) continue
- packagesByFramework[framework] ??= []
- packagesByFramework[framework]!.push(packages)
+ const shared: string[][] = (packagesByFramework[''] = [])
+
+ // A framework prefix is a word followed by a colon that does not start a URL or path, so `react:pkg`
+ // and `react: pkg` are framework lines while `https://host/pkg.tgz` and `file:../pkg` are shared commands.
+ for (const [, framework, rest] of blocksToText(node.children).matchAll(/^(?:\s*([\w-]+)\s*:(?![/.]))?(.*)/gm)) {
+ const packages = rest!.match(/\S+/g)
+ if (!packages) continue
+ if (framework) (packagesByFramework[framework.toLowerCase()] ??= shared.slice()).push(packages)
+ else for (const key in packagesByFramework) packagesByFramework[key]!.push(packages)
}
+ if (!shared.length) delete packagesByFramework['']
if (!Object.keys(packagesByFramework).length) return node
return {
...node,
properties: {
- ...(node.properties ?? {}),
+ ...node.properties,
'data-package-manager-meta': JSON.stringify({
packagesByFramework,
mode: resolveInstallMode(node.attributes.mode),
@@ -79,20 +79,20 @@ export function transformPackageManagerTabs(node: ComponentNode): ComponentNode
export function transformBundlerTabs(node: ComponentNode): ComponentNode {
const sections = splitByHeading(node.children)
- const selected = bundlers.flatMap(bundler => {
+ const selected = (['vite', 'rsbuild'] as const).flatMap(bundler => {
const section = sections.find(section => section.name.toLowerCase() === bundler)
- return section ? [section] : []
+ return section ? [{ ...section, name: bundler }] : []
})
if (!selected.length) return node
- const tabs = selected.map(section => ({ slug: section.name.toLowerCase(), name: section.name.toLowerCase() }))
+ const tabs = selected.map(section => ({ slug: section.name, name: section.name }))
return {
...node,
properties: {
- ...(node.properties ?? {}),
+ ...node.properties,
'data-attributes': JSON.stringify({ tabs }),
- 'data-bundler-meta': JSON.stringify({ bundlers: tabs.map(tab => tab.slug) }),
+ 'data-bundler-meta': JSON.stringify({ bundlers: selected.map(section => section.name) }),
},
children: selected.map((section, index): ComponentNode => {
return {
@@ -101,8 +101,8 @@ export function transformBundlerTabs(node: ComponentNode): ComponentNode {
tagName: 'md-tab-panel',
attributes: {},
properties: {
- 'data-tab-slug': section.name.toLowerCase(),
- 'data-tab-index': String(index),
+ 'data-tab-slug': section.name,
+ 'data-tab-index': `${index}`,
'data-content': section.children.length === 1 && section.children[0]?.type === 'code' ? 'code-only' : 'mixed',
},
children: section.children,
@@ -123,7 +123,7 @@ export function transformHeadingTabs(node: ComponentNode): ComponentNode {
return {
...node,
properties: {
- ...(node.properties ?? {}),
+ ...node.properties,
'data-attributes': JSON.stringify({ tabs }),
},
children: sections.map((section, index): ComponentNode => ({
@@ -132,8 +132,8 @@ export function transformHeadingTabs(node: ComponentNode): ComponentNode {
tagName: 'md-tab-panel',
attributes: {},
properties: {
- 'data-tab-slug': tabs[index]?.slug ?? `tab-${index + 1}`,
- 'data-tab-index': String(index),
+ 'data-tab-slug': tabs[index]!.slug,
+ 'data-tab-index': `${index}`,
},
children: section.children,
})),
diff --git a/tests/docs-extensions.test.ts b/tests/docs-extensions.test.ts
index e866250..405c397 100644
--- a/tests/docs-extensions.test.ts
+++ b/tests/docs-extensions.test.ts
@@ -90,6 +90,149 @@ 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('keeps package protocols as shared commands and framework prefixes as framework lines', () => {
+ const document = parseMarkdown(
+ `
+
+https://example.com/package.tgz
+file:../local-package
+git+ssh://git@example.com/org/package.git
+react:@tanstack/react-query
+solid: file:../solid-package
+
+`,
+ { extensions: docs },
+ )
+
+ const shared = [['https://example.com/package.tgz'], ['file:../local-package'], ['git+ssh://git@example.com/org/package.git']]
+ expect(document.children[0]).toMatchObject({
+ properties: {
+ 'data-package-manager-meta': JSON.stringify({
+ packagesByFramework: {
+ '': shared,
+ react: [...shared, ['@tanstack/react-query']],
+ solid: [...shared, ['file:../solid-package']],
+ },
+ mode: 'install',
+ }),
+ },
+ })
+ })
+
it('transforms framework panels and skips tab headings in collected headings', () => {
const document = parseMarkdown(
`