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
20 changes: 19 additions & 1 deletion docs/guides/docs-preset.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<!-- ::start:tabs variant="package-manager" mode="local-install" -->

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

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

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 `<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. 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`

Expand Down
2 changes: 1 addition & 1 deletion skills/docs-features/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ main {
<!-- ::end:tabs -->
````

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
<!-- ::start:tabs variant="package-manager" mode="dev-install" -->
Expand Down
7 changes: 4 additions & 3 deletions skills/docs-features/references/docs-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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

Expand Down
14 changes: 3 additions & 11 deletions src/extensions/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
)
Expand Down
50 changes: 25 additions & 25 deletions src/extensions/tabs.ts
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -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 => ({
Expand All @@ -41,33 +39,35 @@ 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],
})),
}
}

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<string, string[][]> = 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),
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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 => ({
Expand All @@ -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,
})),
Expand Down
143 changes: 143 additions & 0 deletions tests/docs-extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,149 @@ 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('keeps package protocols as shared commands and framework prefixes as framework lines', () => {
const document = parseMarkdown(
`<!-- ::start:tabs variant="package-manager" -->

https://example.com/package.tgz
file:../local-package
git+ssh://git@example.com/org/package.git
react:@tanstack/react-query
solid: file:../solid-package

<!-- ::end:tabs -->`,
{ 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(
`<!-- ::start:framework -->
Expand Down
Loading