From 46e516dcf4c309f895d49615274e18a6e9efd7ce Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sun, 24 May 2026 11:20:31 -0700 Subject: [PATCH] bundler tabs --- docs-info.md | 30 +++ src/components/markdown/BundlerTabs.tsx | 94 +++++++++ src/components/markdown/CodeBlockView.tsx | 2 +- src/components/markdown/FileTabs.tsx | 7 +- src/components/markdown/MdComponents.tsx | 43 +++++ .../markdown/PackageManagerTabs.tsx | 46 +++-- src/components/markdown/Tabs.tsx | 99 +++++----- src/components/markdown/index.ts | 1 + .../markdown/usePersistedEnumStore.ts | 68 +++++++ src/styles/app.css | 49 ++--- src/utils/markdown/bundler.ts | 19 ++ src/utils/markdown/installCommand.ts | 10 +- src/utils/markdown/plugins/helpers.ts | 15 +- .../plugins/transformTabsComponent.ts | 179 ++++++++++++++---- 14 files changed, 503 insertions(+), 159 deletions(-) create mode 100644 src/components/markdown/BundlerTabs.tsx create mode 100644 src/components/markdown/usePersistedEnumStore.ts create mode 100644 src/utils/markdown/bundler.ts diff --git a/docs-info.md b/docs-info.md index 272cf32a5..a13e6ef65 100644 --- a/docs-info.md +++ b/docs-info.md @@ -204,6 +204,36 @@ becomes npm ``` +### Bundler tabs + +Bundler tabs render a compact tab row (like package-manager tabs) but accept rich markdown content per bundler (like the framework component). The user's bundler choice is persisted to `localStorage` and synced across every bundler tab block on the page. + +Inside `variant="bundler"`, each top-level heading whose text matches a known bundler starts a new section, and the following nodes (prose, code blocks, etc.) become that bundler's panel. The transformer uses the largest heading level present in the block, so `# Vite` / `# Rsbuild` and `## Vite` / `## Rsbuild` both work — just be consistent within a single block. + +````md + + +# Vite + +```ts title="vite.config.ts" +import { defineConfig } from 'vite' + +export default defineConfig({}) +``` + +# Rsbuild + +```ts title="rsbuild.config.ts" +import { defineConfig } from '@rsbuild/cli' + +export default defineConfig({}) +``` + + +```` + +Supported bundlers: `vite`, `rsbuild`. Heading text is matched case-insensitively. Both sections should be defined; if the user's selected bundler isn't present in a particular block, the first defined panel is shown as a fallback. + ## Framework component Framework blocks let one markdown source contain React, Solid, or other framework-specific content. Internally, the transformer looks for h1 headings inside the framework block and treats each `# Heading` as a framework section boundary. It then stores framework metadata and rewrites the block into separate framework panels. diff --git a/src/components/markdown/BundlerTabs.tsx b/src/components/markdown/BundlerTabs.tsx new file mode 100644 index 000000000..574686f2b --- /dev/null +++ b/src/components/markdown/BundlerTabs.tsx @@ -0,0 +1,94 @@ +'use client' + +import * as React from 'react' +import { Tabs, type TabDefinition } from './Tabs' +import { createPersistedEnumStore } from './usePersistedEnumStore' +import { + BUNDLERS, + BUNDLER_LABELS, + DEFAULT_BUNDLER, + isBundler, + type Bundler, +} from '~/utils/markdown/bundler' + +const bundlerStore = createPersistedEnumStore({ + storageKey: 'bundler', + values: BUNDLERS, + defaultValue: DEFAULT_BUNDLER, +}) + +export type BundlerTabsProps = { + tabs: Array<{ slug: string; name: string }> + panelContent?: Record + children: Array | React.ReactNode +} + +export function BundlerTabs({ + tabs, + panelContent, + children, +}: BundlerTabsProps) { + bundlerStore.useHydrate() + + const activeBundler = bundlerStore((s) => s.value) + const setBundler = bundlerStore((s) => s.setValue) + + const childrenArray = React.Children.toArray(children) + + const panelsBySlug = React.useMemo(() => { + const map = new Map() + tabs.forEach((tab, index) => { + map.set(tab.slug, childrenArray[index]) + }) + return map + }, [tabs, childrenArray]) + + const tabDefinitions = React.useMemo>( + () => + tabs + .filter((tab) => isBundler(tab.slug)) + .map((tab) => ({ + slug: tab.slug, + name: BUNDLER_LABELS[tab.slug as Bundler] ?? tab.name, + headers: [], + })), + [tabs], + ) + + const orderedChildren = React.useMemo( + () => tabDefinitions.map((tab) => panelsBySlug.get(tab.slug)), + [tabDefinitions, panelsBySlug], + ) + + const resolvedActiveSlug = tabDefinitions.some( + (tab) => tab.slug === activeBundler, + ) + ? activeBundler + : (tabDefinitions[0]?.slug ?? activeBundler) + + const handleTabChange = React.useCallback( + (slug: string) => { + if (isBundler(slug)) { + setBundler(slug) + } + }, + [setBundler], + ) + + if (tabDefinitions.length === 0) return null + + return ( + + {orderedChildren.map((child, index) => ( + + {child} + + ))} + + ) +} diff --git a/src/components/markdown/CodeBlockView.tsx b/src/components/markdown/CodeBlockView.tsx index 06c21a311..d796c0a61 100644 --- a/src/components/markdown/CodeBlockView.tsx +++ b/src/components/markdown/CodeBlockView.tsx @@ -31,7 +31,7 @@ export function CodeBlockView({ return (
{ const tab = tabs[index] if (!tab) return null + const isActive = tab.slug === activeSlug return ( diff --git a/src/components/markdown/MdComponents.tsx b/src/components/markdown/MdComponents.tsx index 91bcebe7b..99f888458 100644 --- a/src/components/markdown/MdComponents.tsx +++ b/src/components/markdown/MdComponents.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { FileTabs } from './FileTabs' import { FrameworkContent } from './FrameworkContent' import { PackageManagerTabs } from './PackageManagerTabs' +import { BundlerTabs } from './BundlerTabs' import { CodeBlock } from './CodeBlock.server' import { Tabs } from './Tabs' import { @@ -27,6 +28,7 @@ type MdCommentComponentProps = { 'data-component'?: string 'data-files-meta'?: string 'data-package-manager-meta'?: string + 'data-bundler-meta'?: string preserveTabPanels?: boolean children?: React.ReactNode } @@ -67,6 +69,7 @@ export function MdCommentComponent({ 'data-component': componentName, 'data-files-meta': filesMeta, 'data-package-manager-meta': packageManagerMeta, + 'data-bundler-meta': bundlerMeta, preserveTabPanels = false, children, }: MdCommentComponentProps) { @@ -123,6 +126,43 @@ export function MdCommentComponent({ const childArray = React.Children.toArray(children) const panels = childArray.filter(isMdTabPanelElement) + const parsedBundlerMeta = parseJson(bundlerMeta) + + if ( + parsedBundlerMeta && + typeof parsedBundlerMeta === 'object' && + panels.length + ) { + const tabs = Array.isArray((attributes as { tabs?: unknown }).tabs) + ? ((attributes as { tabs: Array<{ name: string; slug: string }> }) + .tabs ?? []) + : [] + + const panelContent: Record = {} + const childrenBySlug = new Map() + panels.forEach((panel, index) => { + const slug = panel.props['data-tab-slug'] + if (!slug) return + const content = panel.props['data-content'] + if (content === 'code-only' || content === 'mixed') { + panelContent[slug] = content + } + childrenBySlug.set(slug, panel.props.children) + // Preserve insertion order for tabs that came in without metadata + void index + }) + + return ( + + {tabs.map((tab) => ( + + {childrenBySlug.get(tab.slug)} + + ))} + + ) + } + const parsedFilesMeta = parseJson(filesMeta) if ( @@ -164,6 +204,9 @@ export function MdCommentComponent({ } type MdTabPanelProps = { + 'data-tab-slug'?: string + 'data-tab-index'?: string + 'data-content'?: 'code-only' | 'mixed' children?: React.ReactNode } diff --git a/src/components/markdown/PackageManagerTabs.tsx b/src/components/markdown/PackageManagerTabs.tsx index 5718cfec8..31cc5a8b7 100644 --- a/src/components/markdown/PackageManagerTabs.tsx +++ b/src/components/markdown/PackageManagerTabs.tsx @@ -4,29 +4,22 @@ import { useLocalCurrentFramework } from '../FrameworkSelect' import { useCurrentUserQuery } from '~/hooks/useCurrentUser' import { useParams } from '@tanstack/react-router' import * as React from 'react' -import { create } from 'zustand' import { Tabs, type TabDefinition } from './Tabs' +import { createPersistedEnumStore } from './usePersistedEnumStore' import type { Framework } from '~/libraries/types' import { PACKAGE_MANAGERS, + isPackageManager, type PackageManager, } from '~/utils/markdown/installCommand' -// Use zustand for cross-component synchronization -// This ensures all PackageManagerTabs instances on the page stay in sync -const usePackageManagerStore = create<{ - packageManager: PackageManager - setPackageManager: (pm: PackageManager) => void -}>((set) => ({ - packageManager: - typeof document !== 'undefined' - ? (localStorage.getItem('packageManager') as PackageManager) || 'npm' - : 'npm', - setPackageManager: (pm: PackageManager) => { - localStorage.setItem('packageManager', pm) - set({ packageManager: pm }) - }, -})) +const DEFAULT_PACKAGE_MANAGER: PackageManager = 'npm' + +const packageManagerStore = createPersistedEnumStore({ + storageKey: 'packageManager', + values: PACKAGE_MANAGERS, + defaultValue: DEFAULT_PACKAGE_MANAGER, +}) type PackageManagerTabsProps = { children?: React.ReactNode @@ -50,8 +43,10 @@ function isPackageManagerPanel( } export function PackageManagerTabs({ children }: PackageManagerTabsProps) { - const { packageManager: storedPackageManager, setPackageManager } = - usePackageManagerStore() + packageManagerStore.useHydrate() + + const storedPackageManager = packageManagerStore((s) => s.value) + const setPackageManager = packageManagerStore((s) => s.setValue) const { framework: paramsFramework } = useParams({ strict: false }) const localCurrentFramework = useLocalCurrentFramework() @@ -74,12 +69,20 @@ export function PackageManagerTabs({ children }: PackageManagerTabsProps) { return child.props['data-framework'] === availableFramework }) + const handleTabChange = React.useCallback( + (slug: string) => { + if (isPackageManager(slug)) { + setPackageManager(slug) + } + }, + [setPackageManager], + ) + if (!packageManagerPanels.length) { return null } - // Use stored package manager if valid, otherwise default to first one - const selectedPackageManager = PACKAGE_MANAGERS.includes(storedPackageManager) + const selectedPackageManager = isPackageManager(storedPackageManager) ? storedPackageManager : PACKAGE_MANAGERS[0] @@ -98,7 +101,8 @@ export function PackageManagerTabs({ children }: PackageManagerTabsProps) { setPackageManager(slug as PackageManager)} + onTabChange={handleTabChange} + panelContent="code-only" > {packageManagerPanels.map((panel) => panel.props.children)} diff --git a/src/components/markdown/Tabs.tsx b/src/components/markdown/Tabs.tsx index 949c30227..83b3ea077 100644 --- a/src/components/markdown/Tabs.tsx +++ b/src/components/markdown/Tabs.tsx @@ -15,6 +15,7 @@ export type TabsProps = { children?: Array | React.ReactNode activeSlug?: string onTabChange?: (slug: string) => void + panelContent?: Record | string } export function Tabs({ @@ -22,16 +23,19 @@ export function Tabs({ children: childrenProp, activeSlug: controlledActiveSlug, onTabChange, + panelContent, }: TabsProps) { + const isControlled = controlledActiveSlug !== undefined const id = React.useId() const childrenArray = React.Children.toArray(childrenProp) const params = useParams({ strict: false }) - const framework = params?.framework ?? undefined + const frameworkParam = isControlled ? undefined : params?.framework const [internalActiveSlug, setInternalActiveSlug] = React.useState(() => { - const match = framework - ? tabsProp.find((tab) => tab.slug === framework) + if (isControlled) return '' + const match = frameworkParam + ? tabsProp.find((tab) => tab.slug === frameworkParam) : undefined return match?.slug ?? tabsProp[0]?.slug ?? '' }) @@ -65,18 +69,23 @@ export function Tabs({ ) })}
-
+
{childrenArray.map((child, index) => { const tab = tabsProp[index] if (!tab) return null + const isActive = tab.slug === activeSlug + const content = + typeof panelContent === 'string' + ? panelContent + : panelContent?.[tab.slug] return ( @@ -87,42 +96,40 @@ export function Tabs({ ) } -const Tab = React.memo( - ({ - tab, - activeSlug, - setActiveSlug, - }: { - id?: string - tab: TabDefinition - activeSlug: string - setActiveSlug: (slug: string) => void - }) => { - const option = React.useMemo( - () => - frameworkOptions.find( - (o) => - o.value === tab.slug.toLowerCase() || - o.label.toLowerCase() === tab.name.toLowerCase(), - ), - [tab.slug, tab.name], - ) +const Tab = React.memo(function Tab({ + tab, + activeSlug, + setActiveSlug, +}: { + id?: string + tab: TabDefinition + activeSlug: string + setActiveSlug: (slug: string) => void +}) { + const option = React.useMemo( + () => + frameworkOptions.find( + (o) => + o.value === tab.slug.toLowerCase() || + o.label.toLowerCase() === tab.name.toLowerCase(), + ), + [tab.slug, tab.name], + ) - return ( - - ) - }, -) + return ( + + ) +}) diff --git a/src/components/markdown/index.ts b/src/components/markdown/index.ts index da180de47..ca0f7034f 100644 --- a/src/components/markdown/index.ts +++ b/src/components/markdown/index.ts @@ -8,4 +8,5 @@ export { CodeBlock } from './CodeBlock' export { Tabs } from './Tabs' export { FileTabs } from './FileTabs' export { PackageManagerTabs } from './PackageManagerTabs' +export { BundlerTabs } from './BundlerTabs' export { FrameworkContent } from './FrameworkContent' diff --git a/src/components/markdown/usePersistedEnumStore.ts b/src/components/markdown/usePersistedEnumStore.ts new file mode 100644 index 000000000..f9669457d --- /dev/null +++ b/src/components/markdown/usePersistedEnumStore.ts @@ -0,0 +1,68 @@ +'use client' + +import * as React from 'react' +import { create, type StoreApi, type UseBoundStore } from 'zustand' + +type EnumStore = { + value: TValue + setValue: (value: TValue) => void +} + +export type PersistedEnumStore = UseBoundStore< + StoreApi> +> & { + useHydrate: () => void +} + +/** + * Creates a zustand store backed by `localStorage` that holds a value from a + * fixed enum (e.g. `'vite' | 'rsbuild'`). + * + * Hydration is deferred to a `useEffect` to avoid SSR/CSR markup mismatches: + * the server and the first client render both see `defaultValue`, then the + * client swaps in the persisted value on mount. + */ +export function createPersistedEnumStore(options: { + storageKey: string + values: ReadonlyArray + defaultValue: TValue +}): PersistedEnumStore { + const { storageKey, values, defaultValue } = options + + const isValid = (candidate: string): candidate is TValue => + (values as ReadonlyArray).includes(candidate) + + const store = create>((set) => ({ + value: defaultValue, + setValue: (value) => { + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem(storageKey, value) + } catch { + // Ignore quota / privacy-mode errors; in-memory state still updates. + } + } + set({ value }) + }, + })) as PersistedEnumStore + + let hasHydrated = false + + store.useHydrate = function useHydrate() { + React.useEffect(() => { + if (hasHydrated) return + hasHydrated = true + if (typeof window === 'undefined') return + try { + const stored = window.localStorage.getItem(storageKey) + if (stored && isValid(stored)) { + store.setState({ value: stored }) + } + } catch { + // Ignore storage access errors. + } + }, []) + } + + return store +} diff --git a/src/styles/app.css b/src/styles/app.css index da9c42bda..d67695f92 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -1179,7 +1179,6 @@ mark { height: 1em; } -/* Consecutive code blocks - no gap when under headings */ [data-tab] .codeblock + .codeblock { @apply mt-0 border-t-0 rounded-t-none; } @@ -1187,58 +1186,40 @@ mark { [data-tab] .codeblock:has(+ .codeblock) { @apply rounded-b-none; } -/* File tabs variant - minimal code blocks with floating copy button */ -.file-tabs-panel .codeblock { - @apply rounded-t-none border-t-0 my-0; -} -/* Hide the title bar but keep copy button accessible */ -.file-tabs-panel .codeblock > div:first-child { - @apply absolute right-2 top-2 bg-transparent border-none p-0 z-10; +[data-tab] { + @apply px-4 py-4; } -/* Hide the title text, keep only the button */ -.file-tabs-panel .codeblock > div:first-child > div:first-child { - @apply hidden; +[data-tab][data-content='code-only'] { + @apply p-0; } -/* Package manager tabs - minimal code blocks with floating copy button */ -.package-manager-tabs [data-tab] .codeblock { - @apply rounded-t-none border-t-0 my-0; +[data-tab][data-content='code-only'] .codeblock { + @apply rounded-none border-none my-0; } -.package-manager-tabs [data-tab] .codeblock > div:first-child { - @apply absolute right-2 top-2 bg-transparent border-none p-0 z-10; +[data-tab][data-content='code-only'] .codeblock pre { + border-radius: 0; } -.package-manager-tabs .codeblock { - border: none; +[data-tab][data-content='code-only'] .codeblock > div:first-child { + @apply absolute right-2 top-2 bg-transparent border-none p-0 z-10; } -.package-manager-tabs - [data-tab] +[data-tab][data-content='code-only'] .codeblock > div:first-child > div:first-child { @apply hidden; } -/* Remove padding from tab content wrapper for package manager */ -.package-manager-tabs .not-prose > div:last-child { - @apply p-0 border-none rounded-b-none bg-transparent; -} - -/* Restore bottom border radius on the code block itself */ -.package-manager-tabs [data-tab] .codeblock { - @apply rounded-b-md; +[data-tab][data-content='mixed'] > p, +[data-tab][data-content='mixed'] > ul, +[data-tab][data-content='mixed'] > ol { + @apply text-sm leading-relaxed text-gray-700 dark:text-gray-300; } -/* Framework code blocks - single blocks look like regular code blocks */ .framework-code-block > .codeblock { @apply my-4; } - -/* Tab content - add padding when it's not just a code block */ -[data-tab]:not(:has(> .codeblock:only-child)) { - @apply px-4; -} diff --git a/src/utils/markdown/bundler.ts b/src/utils/markdown/bundler.ts new file mode 100644 index 000000000..d22e6f9fc --- /dev/null +++ b/src/utils/markdown/bundler.ts @@ -0,0 +1,19 @@ +/** + * Shared types and constants for bundler tabs. + * Used by both the rehype tabs transform and the BundlerTabs component. + */ + +export const BUNDLERS = ['vite', 'rsbuild'] as const + +export type Bundler = (typeof BUNDLERS)[number] + +export const DEFAULT_BUNDLER: Bundler = 'vite' + +export const BUNDLER_LABELS: Record = { + vite: 'Vite', + rsbuild: 'Rsbuild', +} + +export function isBundler(value: string): value is Bundler { + return (BUNDLERS as ReadonlyArray).includes(value) +} diff --git a/src/utils/markdown/installCommand.ts b/src/utils/markdown/installCommand.ts index 9d74ca518..5d78f60c5 100644 --- a/src/utils/markdown/installCommand.ts +++ b/src/utils/markdown/installCommand.ts @@ -3,9 +3,13 @@ * Used by both server-side markdown filtering and client-side PackageManagerTabs. */ -export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' +export const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'] as const -export const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'] +export type PackageManager = (typeof PACKAGE_MANAGERS)[number] + +export function isPackageManager(value: string): value is PackageManager { + return (PACKAGE_MANAGERS as ReadonlyArray).includes(value) +} export type InstallMode = | 'install' @@ -20,7 +24,7 @@ export type InstallMode = export function getPackageManager( pm: string | null | undefined, ): PackageManager { - if (pm && PACKAGE_MANAGERS.includes(pm as PackageManager)) { + if (pm && (PACKAGE_MANAGERS as ReadonlyArray).includes(pm)) { return pm as PackageManager } return 'npm' // default diff --git a/src/utils/markdown/plugins/helpers.ts b/src/utils/markdown/plugins/helpers.ts index a0b4b1aad..6534d9b8a 100644 --- a/src/utils/markdown/plugins/helpers.ts +++ b/src/utils/markdown/plugins/helpers.ts @@ -1,11 +1,5 @@ import { isElement } from 'hast-util-is-element' - -type HastElement = { - type: string - tagName: string - properties?: Record - children?: unknown[] -} +import type { Element, Node } from 'hast' export const normalizeComponentName = (name: string) => name.toLowerCase() @@ -26,8 +20,7 @@ export const slugify = (value: string, fallback: string) => { ) } -export const isHeading = (node: unknown): node is HastElement => - isElement(node as any) && /^h[1-6]$/.test((node as HastElement).tagName) +export const isHeading = (node: unknown): node is Element => + isElement(node as Node) && /^h[1-6]$/.test((node as Element).tagName) -export const headingLevel = (node: HastElement) => - Number(node.tagName.substring(1)) +export const headingLevel = (node: Element) => Number(node.tagName.substring(1)) diff --git a/src/utils/markdown/plugins/transformTabsComponent.ts b/src/utils/markdown/plugins/transformTabsComponent.ts index 58181780f..a5ee89b03 100644 --- a/src/utils/markdown/plugins/transformTabsComponent.ts +++ b/src/utils/markdown/plugins/transformTabsComponent.ts @@ -1,6 +1,8 @@ import { toString } from 'hast-util-to-string' +import type { Element, ElementContent } from 'hast' import { headingLevel, isHeading, slugify } from './helpers' +import { BUNDLERS, isBundler, type Bundler } from '../bundler' export type VariantHandler = ( node: HastNode, @@ -9,12 +11,7 @@ export type VariantHandler = ( type InstallMode = 'install' | 'dev-install' | 'local-install' -type HastNode = { - type: string - tagName: string - properties?: Record - children?: HastNode[] -} +type HastNode = Element type TabDescriptor = { slug: string @@ -23,7 +20,7 @@ type TabDescriptor = { type TabExtraction = { tabs: TabDescriptor[] - panels: HastNode[][] + panels: ElementContent[][] } type PackageManagerExtraction = { @@ -36,7 +33,7 @@ type FilesExtraction = { title: string code: string language: string - preNode: HastNode + preNode: Element }> } @@ -45,7 +42,15 @@ function parseAttributes(node: HastNode): Record { if (typeof rawAttributes === 'string') { try { return JSON.parse(rawAttributes) - } catch { + } catch (error) { + if (import.meta.env?.DEV) { + // eslint-disable-next-line no-console + console.warn( + '[transformTabsComponent] Failed to parse data-attributes JSON:', + rawAttributes, + error, + ) + } return {} } } @@ -64,7 +69,7 @@ function normalizeFrameworkKey(key: string): string { } // Helper to extract text from nodes (used for code content) -function extractText(nodes: any[]): string { +function extractText(nodes: ReadonlyArray): string { let text = '' for (const node of nodes) { if (node.type === 'text') { @@ -143,14 +148,12 @@ function extractCodeBlockData(preNode: HastNode): { title: string code: string } | null { - // Find the child const codeNode = preNode.children?.find( - (c: HastNode) => c.type === 'element' && c.tagName === 'code', + (c): c is Element => c.type === 'element' && c.tagName === 'code', ) if (!codeNode) return null - // Extract language from className let language = 'plaintext' const className = codeNode.properties?.className if (Array.isArray(className)) { @@ -163,16 +166,15 @@ function extractCodeBlockData(preNode: HastNode): { let title = '' const props = preNode.properties || {} if (typeof props['dataCodeTitle'] === 'string') { - title = props['dataCodeTitle'] as string + title = props['dataCodeTitle'] } else if (typeof props['data-code-title'] === 'string') { title = props['data-code-title'] } else if (typeof props['dataFilename'] === 'string') { - title = props['dataFilename'] as string + title = props['dataFilename'] } else if (typeof props['data-filename'] === 'string') { title = props['data-filename'] } - // Extract code content const code = extractText(codeNode.children || []) return { language, title, code } @@ -207,21 +209,78 @@ function extractFilesData(node: HastNode): FilesExtraction | null { return { files } } +/** + * Extract bundler tab data. Splits children by headings whose text matches a + * known bundler (e.g. `# Vite`, `## Rsbuild`). Uses the largest heading level + * present, mirroring `extractTabPanels`. Unknown headings are ignored; content + * before any recognized heading is dropped. + */ +function extractBundlerData(node: HastNode): TabExtraction | null { + const children = node.children ?? [] + const headings = children.filter(isHeading) + + if (headings.length === 0) { + return null + } + + let largestHeadingLevel = Infinity + for (const heading of headings) { + largestHeadingLevel = Math.min(largestHeadingLevel, headingLevel(heading)) + } + + const panelsByBundler = new Map() + let currentBundler: Bundler | null = null + + for (const child of children) { + if (isHeading(child) && headingLevel(child) === largestHeadingLevel) { + const headingText = toString(child).trim().toLowerCase() + if (isBundler(headingText)) { + currentBundler = headingText + if (!panelsByBundler.has(currentBundler)) { + panelsByBundler.set(currentBundler, []) + } + continue + } + currentBundler = null + continue + } + + if (currentBundler) { + panelsByBundler.get(currentBundler)!.push(child) + } + } + + if (panelsByBundler.size === 0) { + return null + } + + const tabs: TabDescriptor[] = [] + const panels: ElementContent[][] = [] + for (const bundler of BUNDLERS) { + const panel = panelsByBundler.get(bundler) + if (!panel) continue + tabs.push({ slug: bundler, name: bundler }) + panels.push(panel) + } + + return { tabs, panels } +} + function extractTabPanels(node: HastNode): TabExtraction | null { const children = node.children ?? [] const headings = children.filter(isHeading) let sectionStarted = false let largestHeadingLevel = Infinity - headings.forEach((heading: HastNode) => { + headings.forEach((heading) => { largestHeadingLevel = Math.min(largestHeadingLevel, headingLevel(heading)) }) const tabs: TabDescriptor[] = [] - const panels: HastNode[][] = [] - let currentPanel: HastNode[] | null = null + const panels: ElementContent[][] = [] + let currentPanel: ElementContent[] | null = null - children.forEach((child: any) => { + children.forEach((child) => { if (isHeading(child)) { const level = headingLevel(child) if (!sectionStarted) { @@ -239,11 +298,11 @@ function extractTabPanels(node: HastNode): TabExtraction | null { const headingId = typeof child.properties?.id === 'string' ? child.properties.id - : slugify(toString(child as any), `tab-${tabs.length + 1}`) + : slugify(toString(child), `tab-${tabs.length + 1}`) tabs.push({ slug: headingId, - name: toString(child as any), + name: toString(child), }) currentPanel = [] @@ -322,16 +381,52 @@ export function transformTabsComponent(node: HastNode) { node.properties['data-attributes'] = JSON.stringify({ tabs }) // Create panel elements with original preNodes - node.children = result.files.map((file, index) => ({ - type: 'element', - tagName: 'md-tab-panel', - properties: { - 'data-tab-slug': `file-${index}`, - 'data-tab-index': String(index), - }, - // Use the original preNode which already has data-code-title from rehypeCodeMeta - children: [file.preNode], - })) + node.children = result.files.map( + (file, index): Element => ({ + type: 'element', + tagName: 'md-tab-panel', + properties: { + 'data-tab-slug': `file-${index}`, + 'data-tab-index': String(index), + }, + // Use the original preNode which already has data-code-title from rehypeCodeMeta + children: [file.preNode], + }), + ) + return + } + + // Handle bundler variant + if (variant === 'bundler') { + const result = extractBundlerData(node) + + if (!result) { + return + } + + node.properties = node.properties || {} + node.properties['data-bundler-meta'] = JSON.stringify({ + bundlers: result.tabs.map((t) => t.slug), + }) + node.properties['data-attributes'] = JSON.stringify({ tabs: result.tabs }) + + node.children = result.panels.map((panelChildren, index): Element => { + const isCodeOnly = + panelChildren.length === 1 && + panelChildren[0]?.type === 'element' && + panelChildren[0]?.tagName === 'pre' + + return { + type: 'element', + tagName: 'md-tab-panel', + properties: { + 'data-tab-slug': result.tabs[index]?.slug ?? `bundler-${index + 1}`, + 'data-tab-index': String(index), + 'data-content': isCodeOnly ? 'code-only' : 'mixed', + }, + children: panelChildren, + } + }) return } @@ -341,15 +436,17 @@ export function transformTabsComponent(node: HastNode) { return } - const panelElements = result.panels.map((panelChildren, index) => ({ - type: 'element', - tagName: 'md-tab-panel', - properties: { - 'data-tab-slug': result.tabs[index]?.slug ?? `tab-${index + 1}`, - 'data-tab-index': String(index), - }, - children: panelChildren, - })) + const panelElements = result.panels.map( + (panelChildren, index): Element => ({ + type: 'element', + tagName: 'md-tab-panel', + properties: { + 'data-tab-slug': result.tabs[index]?.slug ?? `tab-${index + 1}`, + 'data-tab-index': String(index), + }, + children: panelChildren, + }), + ) node.properties = { ...node.properties,