Skip to content

Commit 320867c

Browse files
[feat]: bundler tabs (#943)
1 parent 8735dde commit 320867c

14 files changed

Lines changed: 503 additions & 159 deletions

docs-info.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,36 @@ becomes
204204
npm <command> <package>
205205
```
206206

207+
### Bundler tabs
208+
209+
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.
210+
211+
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.
212+
213+
````md
214+
<!-- ::start:tabs variant="bundler" -->
215+
216+
# Vite
217+
218+
```ts title="vite.config.ts"
219+
import { defineConfig } from 'vite'
220+
221+
export default defineConfig({})
222+
```
223+
224+
# Rsbuild
225+
226+
```ts title="rsbuild.config.ts"
227+
import { defineConfig } from '@rsbuild/cli'
228+
229+
export default defineConfig({})
230+
```
231+
232+
<!-- ::end:tabs -->
233+
````
234+
235+
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.
236+
207237
## Framework component
208238

209239
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.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
import { Tabs, type TabDefinition } from './Tabs'
5+
import { createPersistedEnumStore } from './usePersistedEnumStore'
6+
import {
7+
BUNDLERS,
8+
BUNDLER_LABELS,
9+
DEFAULT_BUNDLER,
10+
isBundler,
11+
type Bundler,
12+
} from '~/utils/markdown/bundler'
13+
14+
const bundlerStore = createPersistedEnumStore<Bundler>({
15+
storageKey: 'bundler',
16+
values: BUNDLERS,
17+
defaultValue: DEFAULT_BUNDLER,
18+
})
19+
20+
export type BundlerTabsProps = {
21+
tabs: Array<{ slug: string; name: string }>
22+
panelContent?: Record<string, 'code-only' | 'mixed'>
23+
children: Array<React.ReactNode> | React.ReactNode
24+
}
25+
26+
export function BundlerTabs({
27+
tabs,
28+
panelContent,
29+
children,
30+
}: BundlerTabsProps) {
31+
bundlerStore.useHydrate()
32+
33+
const activeBundler = bundlerStore((s) => s.value)
34+
const setBundler = bundlerStore((s) => s.setValue)
35+
36+
const childrenArray = React.Children.toArray(children)
37+
38+
const panelsBySlug = React.useMemo(() => {
39+
const map = new Map<string, React.ReactNode>()
40+
tabs.forEach((tab, index) => {
41+
map.set(tab.slug, childrenArray[index])
42+
})
43+
return map
44+
}, [tabs, childrenArray])
45+
46+
const tabDefinitions = React.useMemo<Array<TabDefinition>>(
47+
() =>
48+
tabs
49+
.filter((tab) => isBundler(tab.slug))
50+
.map((tab) => ({
51+
slug: tab.slug,
52+
name: BUNDLER_LABELS[tab.slug as Bundler] ?? tab.name,
53+
headers: [],
54+
})),
55+
[tabs],
56+
)
57+
58+
const orderedChildren = React.useMemo(
59+
() => tabDefinitions.map((tab) => panelsBySlug.get(tab.slug)),
60+
[tabDefinitions, panelsBySlug],
61+
)
62+
63+
const resolvedActiveSlug = tabDefinitions.some(
64+
(tab) => tab.slug === activeBundler,
65+
)
66+
? activeBundler
67+
: (tabDefinitions[0]?.slug ?? activeBundler)
68+
69+
const handleTabChange = React.useCallback(
70+
(slug: string) => {
71+
if (isBundler(slug)) {
72+
setBundler(slug)
73+
}
74+
},
75+
[setBundler],
76+
)
77+
78+
if (tabDefinitions.length === 0) return null
79+
80+
return (
81+
<Tabs
82+
tabs={tabDefinitions}
83+
activeSlug={resolvedActiveSlug}
84+
onTabChange={handleTabChange}
85+
panelContent={panelContent}
86+
>
87+
{orderedChildren.map((child, index) => (
88+
<React.Fragment key={tabDefinitions[index]?.slug ?? index}>
89+
{child}
90+
</React.Fragment>
91+
))}
92+
</Tabs>
93+
)
94+
}

src/components/markdown/CodeBlockView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function CodeBlockView({
3131
return (
3232
<div
3333
className={twMerge(
34-
'codeblock w-full max-w-full relative not-prose border border-gray-500/20 rounded-md [&_pre]:rounded-md',
34+
'codeblock w-full max-w-full relative not-prose border border-gray-500/20 rounded-md overflow-hidden [&_pre]:rounded-none',
3535
className,
3636
)}
3737
style={style}

src/components/markdown/FileTabs.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,15 @@ export function FileTabs({ tabs, children }: FileTabsProps) {
4343
{childrenArray.map((child, index) => {
4444
const tab = tabs[index]
4545
if (!tab) return null
46+
const isActive = tab.slug === activeSlug
4647
return (
4748
<div
4849
key={`${id}-${tab.slug}-panel`}
4950
data-tab={tab.slug}
50-
hidden={tab.slug !== activeSlug}
51-
className="file-tabs-panel"
51+
data-content="code-only"
52+
className={`border border-t-0 border-gray-500/20 rounded-b-md overflow-hidden ${
53+
isActive ? '' : 'hidden'
54+
}`}
5255
>
5356
{child}
5457
</div>

src/components/markdown/MdComponents.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from 'react'
22
import { FileTabs } from './FileTabs'
33
import { FrameworkContent } from './FrameworkContent'
44
import { PackageManagerTabs } from './PackageManagerTabs'
5+
import { BundlerTabs } from './BundlerTabs'
56
import { CodeBlock } from './CodeBlock.server'
67
import { Tabs } from './Tabs'
78
import {
@@ -27,6 +28,7 @@ type MdCommentComponentProps = {
2728
'data-component'?: string
2829
'data-files-meta'?: string
2930
'data-package-manager-meta'?: string
31+
'data-bundler-meta'?: string
3032
preserveTabPanels?: boolean
3133
children?: React.ReactNode
3234
}
@@ -67,6 +69,7 @@ export function MdCommentComponent({
6769
'data-component': componentName,
6870
'data-files-meta': filesMeta,
6971
'data-package-manager-meta': packageManagerMeta,
72+
'data-bundler-meta': bundlerMeta,
7073
preserveTabPanels = false,
7174
children,
7275
}: MdCommentComponentProps) {
@@ -123,6 +126,43 @@ export function MdCommentComponent({
123126
const childArray = React.Children.toArray(children)
124127
const panels = childArray.filter(isMdTabPanelElement)
125128

129+
const parsedBundlerMeta = parseJson(bundlerMeta)
130+
131+
if (
132+
parsedBundlerMeta &&
133+
typeof parsedBundlerMeta === 'object' &&
134+
panels.length
135+
) {
136+
const tabs = Array.isArray((attributes as { tabs?: unknown }).tabs)
137+
? ((attributes as { tabs: Array<{ name: string; slug: string }> })
138+
.tabs ?? [])
139+
: []
140+
141+
const panelContent: Record<string, 'code-only' | 'mixed'> = {}
142+
const childrenBySlug = new Map<string, React.ReactNode>()
143+
panels.forEach((panel, index) => {
144+
const slug = panel.props['data-tab-slug']
145+
if (!slug) return
146+
const content = panel.props['data-content']
147+
if (content === 'code-only' || content === 'mixed') {
148+
panelContent[slug] = content
149+
}
150+
childrenBySlug.set(slug, panel.props.children)
151+
// Preserve insertion order for tabs that came in without metadata
152+
void index
153+
})
154+
155+
return (
156+
<BundlerTabs tabs={tabs} panelContent={panelContent}>
157+
{tabs.map((tab) => (
158+
<React.Fragment key={tab.slug}>
159+
{childrenBySlug.get(tab.slug)}
160+
</React.Fragment>
161+
))}
162+
</BundlerTabs>
163+
)
164+
}
165+
126166
const parsedFilesMeta = parseJson(filesMeta)
127167

128168
if (
@@ -164,6 +204,9 @@ export function MdCommentComponent({
164204
}
165205

166206
type MdTabPanelProps = {
207+
'data-tab-slug'?: string
208+
'data-tab-index'?: string
209+
'data-content'?: 'code-only' | 'mixed'
167210
children?: React.ReactNode
168211
}
169212

src/components/markdown/PackageManagerTabs.tsx

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,22 @@ import { useLocalCurrentFramework } from '../FrameworkSelect'
44
import { useCurrentUserQuery } from '~/hooks/useCurrentUser'
55
import { useParams } from '@tanstack/react-router'
66
import * as React from 'react'
7-
import { create } from 'zustand'
87
import { Tabs, type TabDefinition } from './Tabs'
8+
import { createPersistedEnumStore } from './usePersistedEnumStore'
99
import type { Framework } from '~/libraries/types'
1010
import {
1111
PACKAGE_MANAGERS,
12+
isPackageManager,
1213
type PackageManager,
1314
} from '~/utils/markdown/installCommand'
1415

15-
// Use zustand for cross-component synchronization
16-
// This ensures all PackageManagerTabs instances on the page stay in sync
17-
const usePackageManagerStore = create<{
18-
packageManager: PackageManager
19-
setPackageManager: (pm: PackageManager) => void
20-
}>((set) => ({
21-
packageManager:
22-
typeof document !== 'undefined'
23-
? (localStorage.getItem('packageManager') as PackageManager) || 'npm'
24-
: 'npm',
25-
setPackageManager: (pm: PackageManager) => {
26-
localStorage.setItem('packageManager', pm)
27-
set({ packageManager: pm })
28-
},
29-
}))
16+
const DEFAULT_PACKAGE_MANAGER: PackageManager = 'npm'
17+
18+
const packageManagerStore = createPersistedEnumStore<PackageManager>({
19+
storageKey: 'packageManager',
20+
values: PACKAGE_MANAGERS,
21+
defaultValue: DEFAULT_PACKAGE_MANAGER,
22+
})
3023

3124
type PackageManagerTabsProps = {
3225
children?: React.ReactNode
@@ -50,8 +43,10 @@ function isPackageManagerPanel(
5043
}
5144

5245
export function PackageManagerTabs({ children }: PackageManagerTabsProps) {
53-
const { packageManager: storedPackageManager, setPackageManager } =
54-
usePackageManagerStore()
46+
packageManagerStore.useHydrate()
47+
48+
const storedPackageManager = packageManagerStore((s) => s.value)
49+
const setPackageManager = packageManagerStore((s) => s.setValue)
5550

5651
const { framework: paramsFramework } = useParams({ strict: false })
5752
const localCurrentFramework = useLocalCurrentFramework()
@@ -74,12 +69,20 @@ export function PackageManagerTabs({ children }: PackageManagerTabsProps) {
7469
return child.props['data-framework'] === availableFramework
7570
})
7671

72+
const handleTabChange = React.useCallback(
73+
(slug: string) => {
74+
if (isPackageManager(slug)) {
75+
setPackageManager(slug)
76+
}
77+
},
78+
[setPackageManager],
79+
)
80+
7781
if (!packageManagerPanels.length) {
7882
return null
7983
}
8084

81-
// Use stored package manager if valid, otherwise default to first one
82-
const selectedPackageManager = PACKAGE_MANAGERS.includes(storedPackageManager)
85+
const selectedPackageManager = isPackageManager(storedPackageManager)
8386
? storedPackageManager
8487
: PACKAGE_MANAGERS[0]
8588

@@ -98,7 +101,8 @@ export function PackageManagerTabs({ children }: PackageManagerTabsProps) {
98101
<Tabs
99102
tabs={tabs}
100103
activeSlug={selectedPackageManager}
101-
onTabChange={(slug) => setPackageManager(slug as PackageManager)}
104+
onTabChange={handleTabChange}
105+
panelContent="code-only"
102106
>
103107
{packageManagerPanels.map((panel) => panel.props.children)}
104108
</Tabs>

0 commit comments

Comments
 (0)