Skip to content
Merged
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
30 changes: 30 additions & 0 deletions docs-info.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,36 @@ becomes
npm <command> <package>
```

### 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
<!-- ::start:tabs variant="bundler" -->

# 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({})
```

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

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.
Expand Down
94 changes: 94 additions & 0 deletions src/components/markdown/BundlerTabs.tsx
Original file line number Diff line number Diff line change
@@ -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<Bundler>({
storageKey: 'bundler',
values: BUNDLERS,
defaultValue: DEFAULT_BUNDLER,
})

export type BundlerTabsProps = {
tabs: Array<{ slug: string; name: string }>
panelContent?: Record<string, 'code-only' | 'mixed'>
children: Array<React.ReactNode> | 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<string, React.ReactNode>()
tabs.forEach((tab, index) => {
map.set(tab.slug, childrenArray[index])
})
return map
}, [tabs, childrenArray])

const tabDefinitions = React.useMemo<Array<TabDefinition>>(
() =>
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 (
<Tabs
tabs={tabDefinitions}
activeSlug={resolvedActiveSlug}
onTabChange={handleTabChange}
panelContent={panelContent}
>
{orderedChildren.map((child, index) => (
<React.Fragment key={tabDefinitions[index]?.slug ?? index}>
{child}
</React.Fragment>
))}
</Tabs>
)
}
2 changes: 1 addition & 1 deletion src/components/markdown/CodeBlockView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function CodeBlockView({
return (
<div
className={twMerge(
'codeblock w-full max-w-full relative not-prose border border-gray-500/20 rounded-md [&_pre]:rounded-md',
'codeblock w-full max-w-full relative not-prose border border-gray-500/20 rounded-md overflow-hidden [&_pre]:rounded-none',
className,
)}
style={style}
Expand Down
7 changes: 5 additions & 2 deletions src/components/markdown/FileTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,15 @@ export function FileTabs({ tabs, children }: FileTabsProps) {
{childrenArray.map((child, index) => {
const tab = tabs[index]
if (!tab) return null
const isActive = tab.slug === activeSlug
return (
<div
key={`${id}-${tab.slug}-panel`}
data-tab={tab.slug}
hidden={tab.slug !== activeSlug}
className="file-tabs-panel"
data-content="code-only"
className={`border border-t-0 border-gray-500/20 rounded-b-md overflow-hidden ${
isActive ? '' : 'hidden'
}`}
>
{child}
</div>
Expand Down
43 changes: 43 additions & 0 deletions src/components/markdown/MdComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, 'code-only' | 'mixed'> = {}
const childrenBySlug = new Map<string, React.ReactNode>()
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 (
<BundlerTabs tabs={tabs} panelContent={panelContent}>
{tabs.map((tab) => (
<React.Fragment key={tab.slug}>
{childrenBySlug.get(tab.slug)}
</React.Fragment>
))}
</BundlerTabs>
)
}

const parsedFilesMeta = parseJson(filesMeta)

if (
Expand Down Expand Up @@ -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
}

Expand Down
46 changes: 25 additions & 21 deletions src/components/markdown/PackageManagerTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PackageManager>({
storageKey: 'packageManager',
values: PACKAGE_MANAGERS,
defaultValue: DEFAULT_PACKAGE_MANAGER,
})

type PackageManagerTabsProps = {
children?: React.ReactNode
Expand All @@ -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()
Expand All @@ -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]

Expand All @@ -98,7 +101,8 @@ export function PackageManagerTabs({ children }: PackageManagerTabsProps) {
<Tabs
tabs={tabs}
activeSlug={selectedPackageManager}
onTabChange={(slug) => setPackageManager(slug as PackageManager)}
onTabChange={handleTabChange}
panelContent="code-only"
>
{packageManagerPanels.map((panel) => panel.props.children)}
</Tabs>
Expand Down
Loading
Loading