Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/guides/docs-preset.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<!-- ::start:tabs variant="package-manager" mode="local-install" -->

@tanstack/intent@latest list
@tanstack/intent@latest validate
@tanstack/intent@latest review

<!-- ::end:tabs -->
```

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 `<package>#<skill>` 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
26 changes: 17 additions & 9 deletions src/extensions/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,27 +51,35 @@ export function transformFileTabs(node: ComponentNode): ComponentNode {

export function transformPackageManagerTabs(node: ComponentNode): ComponentNode {
const packagesByFramework: Record<string, string[][]> = {}
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),
}),
},
Expand Down
114 changes: 114 additions & 0 deletions tests/docs-extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,120 @@ solid: @tanstack/solid-query
expect(html).not.toContain('@tanstack/react-query</p>')
})

it('keeps unprefixed package-manager commands on separate shared lines', () => {
const document = parseMarkdown(
`<!-- ::start:tabs variant="package-manager" mode="local-install" -->

@tanstack/intent@latest list
@tanstack/intent@latest validate
@tanstack/intent@latest review

<!-- ::end:tabs -->`,
{ 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(
`<!-- ::start:tabs variant="package-manager" -->

react:react-first
React : react-second
solid:

<!-- ::end:tabs -->`,
{ 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(
`<!-- ::start:tabs variant="package-manager" -->

react: react-only
shared-first
solid: solid-only
shared-last
react: react-last

<!-- ::end:tabs -->`,
{ 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(
`<!-- ::start:tabs variant="package-manager" mode="${mode}" -->

\`\`\`text
@tanstack/intent@latest load <package>#<skill>
@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
\`\`\`

<!-- ::end:tabs -->`,
{ extensions: docs, allowHtml: true },
)

expect(document.children[0]).toMatchObject({
properties: {
'data-package-manager-meta': JSON.stringify({
packagesByFramework: {
'': [
['@tanstack/intent@latest', 'load', '<package>#<skill>'],
['@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(
`<!-- ::start:framework -->
Expand Down
Loading