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