Skip to content

Commit f020b5e

Browse files
committed
feat: add package manager tabs component and integrate with Markdown parser
1 parent b10692f commit f020b5e

4 files changed

Lines changed: 284 additions & 21 deletions

File tree

src/components/Markdown.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { renderMarkdown } from '~/utils/markdown'
1313
import { getNetlifyImageUrl } from '~/utils/netlifyImage'
1414
import { Tabs } from '~/components/Tabs'
1515
import { CodeBlock } from './CodeBlock'
16+
import { PackageManagerTabs } from './PackageManagerTabs'
17+
import type { Framework } from '~/libraries/types'
1618

1719
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
1820

@@ -112,6 +114,7 @@ const options: HTMLReactParserOptions = {
112114
if (domNode.name === 'md-comment-component') {
113115
const componentName = domNode.attribs['data-component']
114116
const rawAttributes = domNode.attribs['data-attributes']
117+
const pmMeta = domNode.attribs['data-package-manager-meta']
115118
const attributes: Record<string, any> = {}
116119
try {
117120
Object.assign(attributes, JSON.parse(rawAttributes))
@@ -121,9 +124,39 @@ const options: HTMLReactParserOptions = {
121124

122125
switch (componentName?.toLowerCase()) {
123126
case 'tabs': {
127+
// Check if this is a package-manager tabs (has metadata)
128+
if (pmMeta) {
129+
try {
130+
const { packagesByFramework, mode } = JSON.parse(pmMeta)
131+
const id =
132+
attributes.id ||
133+
`package-manager-tabs-${Math.random().toString(36).slice(2, 9)}`
134+
const frameworks = Object.keys(
135+
packagesByFramework,
136+
) as Framework[]
137+
138+
return (
139+
<PackageManagerTabs
140+
id={id}
141+
packagesByFramework={packagesByFramework}
142+
mode={mode}
143+
frameworks={frameworks}
144+
/>
145+
)
146+
} catch {
147+
// Fall through to default tabs if parsing fails
148+
}
149+
}
150+
151+
// Default tabs variant
124152
const tabs = attributes.tabs
125153
const id =
126154
attributes.id || `tabs-${Math.random().toString(36).slice(2, 9)}`
155+
156+
if (!tabs || !Array.isArray(tabs)) {
157+
return null
158+
}
159+
127160
const panelElements = domNode.children?.filter(
128161
(child): child is Element =>
129162
child instanceof Element && child.name === 'md-tab-panel',
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import * as React from 'react'
2+
import { useCurrentFramework } from './FrameworkSelect'
3+
import { useLocalStorage } from '~/utils/useLocalStorage'
4+
import { Tabs, type TabDefinition } from './Tabs'
5+
import { CodeBlock } from './CodeBlock'
6+
import type { Framework } from '~/libraries/types'
7+
8+
type PackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn'
9+
type InstallMode = 'install' | 'dev-install'
10+
11+
type PackageManagerTabsProps = {
12+
id: string
13+
packagesByFramework: Record<string, string>
14+
mode: InstallMode
15+
frameworks: Framework[]
16+
}
17+
18+
const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun']
19+
20+
function getInstallCommand(
21+
packageManager: PackageManager,
22+
packages: string,
23+
mode: InstallMode,
24+
): string {
25+
if (mode === 'dev-install') {
26+
switch (packageManager) {
27+
case 'npm':
28+
return `npm i -D ${packages}`
29+
case 'pnpm':
30+
return `pnpm add -D ${packages}`
31+
case 'yarn':
32+
return `yarn add -D ${packages}`
33+
case 'bun':
34+
return `bun add -d ${packages}`
35+
}
36+
}
37+
38+
// install mode
39+
switch (packageManager) {
40+
case 'npm':
41+
return `npm i ${packages}`
42+
case 'pnpm':
43+
return `pnpm add ${packages}`
44+
case 'yarn':
45+
return `yarn add ${packages}`
46+
case 'bun':
47+
return `bun add ${packages}`
48+
}
49+
}
50+
51+
export function PackageManagerTabs({
52+
id,
53+
packagesByFramework,
54+
mode,
55+
frameworks,
56+
}: PackageManagerTabsProps) {
57+
const { framework: currentFramework } = useCurrentFramework(frameworks)
58+
const [storedPackageManager, setStoredPackageManager] =
59+
useLocalStorage<PackageManager>('packageManager', PACKAGE_MANAGERS[0])
60+
61+
// Normalize framework key to lowercase for lookup
62+
const normalizedFramework = currentFramework.toLowerCase()
63+
const packages = packagesByFramework[normalizedFramework]
64+
65+
// Hide component if current framework not in package list
66+
if (!packages) {
67+
return null
68+
}
69+
70+
// Use stored package manager if valid, otherwise default to first one
71+
const selectedPackageManager = PACKAGE_MANAGERS.includes(storedPackageManager)
72+
? storedPackageManager
73+
: PACKAGE_MANAGERS[0]
74+
75+
// Generate tabs for each package manager
76+
const tabs: TabDefinition[] = PACKAGE_MANAGERS.map((pm) => ({
77+
slug: pm,
78+
name: pm,
79+
headers: [],
80+
}))
81+
82+
// Generate children (command blocks) for each package manager
83+
const children = PACKAGE_MANAGERS.map((pm) => {
84+
const command = getInstallCommand(pm, packages, mode)
85+
return (
86+
<CodeBlock key={pm}>
87+
<code className="language-bash">{command}</code>
88+
</CodeBlock>
89+
)
90+
})
91+
92+
return (
93+
<Tabs
94+
id={id}
95+
tabs={tabs}
96+
children={children}
97+
activeSlug={selectedPackageManager}
98+
onTabChange={(slug) => setStoredPackageManager(slug as PackageManager)}
99+
/>
100+
)
101+
}

src/components/Tabs.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,28 @@ export type TabsProps = {
1212
tabs: Array<TabDefinition>
1313
children: Array<React.ReactNode>
1414
id: string
15+
activeSlug?: string
16+
onTabChange?: (slug: string) => void
1517
}
1618

17-
export function Tabs({ tabs, id, children }: TabsProps) {
19+
export function Tabs({ tabs, id, children, activeSlug: controlledActiveSlug, onTabChange }: TabsProps) {
1820
const params = useParams({ strict: false })
1921
const framework = 'framework' in params ? params.framework : undefined
2022

21-
const [activeSlug, setActiveSlug] = React.useState(
23+
const [internalActiveSlug, setInternalActiveSlug] = React.useState(
2224
() => tabs.find((tab) => tab.slug === framework)?.slug || tabs[0].slug,
2325
)
2426

27+
// Use controlled state if provided, otherwise use internal state
28+
const activeSlug = controlledActiveSlug ?? internalActiveSlug
29+
const setActiveSlug = React.useCallback((slug: string) => {
30+
if (onTabChange) {
31+
onTabChange(slug)
32+
} else {
33+
setInternalActiveSlug(slug)
34+
}
35+
}, [onTabChange])
36+
2537
return (
2638
<div className="not-prose my-4">
2739
<div className="flex items-center justify-start gap-2 rounded-t-md border-1 border-b-none border-gray-200 dark:border-gray-800 overflow-x-auto overflow-y-hidden bg-white dark:bg-gray-950">
@@ -65,7 +77,7 @@ const Tab = ({
6577
id: string
6678
tab: TabDefinition
6779
activeSlug: string
68-
setActiveSlug: React.Dispatch<React.SetStateAction<string>>
80+
setActiveSlug: (slug: string) => void
6981
}) => {
7082
const option = React.useMemo(
7183
() => frameworkOptions.find((o) => o.value === tab.slug),

src/utils/markdown/plugins/transformTabsComponent.ts

Lines changed: 135 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,135 @@
1-
import { visit } from 'unist-util-visit'
21
import { toString } from 'hast-util-to-string'
32

43
import { headingLevel, isHeading, slugify } from './helpers'
54

5+
type InstallMode = 'install' | 'dev-install'
6+
67
type HastNode = {
78
type: string
89
tagName: string
910
properties?: Record<string, unknown>
1011
children?: HastNode[]
1112
}
1213

13-
type HastElement = HastNode
14-
1514
type TabDescriptor = {
1615
slug: string
1716
name: string
18-
headers: string[]
1917
}
2018

2119
type TabExtraction = {
2220
tabs: TabDescriptor[]
2321
panels: HastNode[][]
2422
}
2523

24+
type PackageManagerExtraction = {
25+
packagesByFramework: Record<string, string>
26+
mode: InstallMode
27+
}
28+
29+
function parseAttributes(node: HastNode): Record<string, string> {
30+
const rawAttributes = node.properties?.['data-attributes']
31+
if (typeof rawAttributes === 'string') {
32+
try {
33+
return JSON.parse(rawAttributes)
34+
} catch {
35+
return {}
36+
}
37+
}
38+
return {}
39+
}
40+
41+
42+
function resolveMode(attributes: Record<string, string>): InstallMode {
43+
const mode = attributes.mode?.toLowerCase()
44+
return mode === 'dev-install' ? 'dev-install' : 'install'
45+
}
46+
47+
function normalizeFrameworkKey(key: string): string {
48+
return key.trim().toLowerCase()
49+
}
50+
51+
/**
52+
* Parse a line like "react: @tanstack/react-query @tanstack/react-query-devtools"
53+
* Returns { framework: 'react', packages: '@tanstack/react-query @tanstack/react-query-devtools' }
54+
*/
55+
function parseFrameworkLine(text: string): {
56+
framework: string
57+
packages: string
58+
} | null {
59+
const colonIndex = text.indexOf(':')
60+
if (colonIndex === -1) {
61+
return null
62+
}
63+
64+
const framework = normalizeFrameworkKey(text.slice(0, colonIndex))
65+
const packages = text.slice(colonIndex + 1).trim()
66+
67+
if (!framework || !packages) {
68+
return null
69+
}
70+
71+
return { framework, packages }
72+
}
73+
74+
function extractPackageManagerData(
75+
node: HastNode,
76+
mode: InstallMode,
77+
): PackageManagerExtraction | null {
78+
const children = node.children ?? []
79+
const packagesByFramework: Record<string, string> = {}
80+
81+
// Recursively extract text from all children (including nested in <p> tags)
82+
function extractText(nodes: any[]): string {
83+
let text = ''
84+
for (const node of nodes) {
85+
if (node.type === 'text') {
86+
text += node.value
87+
} else if (node.type === 'element' && node.children) {
88+
text += extractText(node.children)
89+
}
90+
}
91+
return text
92+
}
93+
94+
const allText = extractText(children)
95+
const lines = allText.split('\n')
96+
97+
for (const line of lines) {
98+
const trimmed = line.trim()
99+
if (!trimmed) continue
100+
101+
const parsed = parseFrameworkLine(trimmed)
102+
if (parsed) {
103+
packagesByFramework[parsed.framework] = parsed.packages
104+
}
105+
}
106+
107+
if (Object.keys(packagesByFramework).length === 0) {
108+
return null
109+
}
110+
111+
return { packagesByFramework, mode }
112+
}
113+
114+
function createPackageManagerHeadings(): HastNode[] {
115+
const packageManagers = ['npm', 'pnpm', 'yarn', 'bun']
116+
const nodes: HastNode[] = []
117+
118+
for (const pm of packageManagers) {
119+
// Create heading for package manager
120+
const heading: any = {
121+
type: 'element',
122+
tagName: 'h1',
123+
properties: { id: pm },
124+
children: [{ type: 'text', value: pm }],
125+
}
126+
127+
nodes.push(heading)
128+
}
129+
130+
return nodes
131+
}
132+
26133
function extractTabPanels(node: HastNode): TabExtraction | null {
27134
const children = node.children ?? []
28135
const headings = children.filter(isHeading)
@@ -60,7 +167,6 @@ function extractTabPanels(node: HastNode): TabExtraction | null {
60167
tabs.push({
61168
slug: headingId,
62169
name: toString(child as any),
63-
headers: [],
64170
})
65171

66172
currentPanel = []
@@ -84,24 +190,35 @@ function extractTabPanels(node: HastNode): TabExtraction | null {
84190
return null
85191
}
86192

87-
panels.forEach((panelChildren, index) => {
88-
const nestedHeadings: string[] = []
89-
visit(
90-
{ type: 'root', children: panelChildren },
91-
'element',
92-
(child: HastNode) => {
93-
if (isHeading(child) && typeof child.properties?.id === 'string') {
94-
nestedHeadings.push(String(child.properties.id))
95-
}
96-
},
97-
)
98-
tabs[index]!.headers = nestedHeadings
99-
})
100193

101194
return { tabs, panels }
102195
}
103196

104197
export function transformTabsComponent(node: HastNode) {
198+
const attributes = parseAttributes(node)
199+
const variant = attributes.variant?.toLowerCase()
200+
201+
// Handle package-manager variant
202+
if (variant === 'package-manager') {
203+
const mode = resolveMode(attributes)
204+
const result = extractPackageManagerData(node, mode)
205+
206+
if (!result) {
207+
return
208+
}
209+
210+
// Replace children with package manager headings
211+
node.children = createPackageManagerHeadings()
212+
213+
// Store metadata for the React component
214+
node.properties = node.properties || {}
215+
node.properties['data-package-manager-meta'] = JSON.stringify({
216+
packagesByFramework: result.packagesByFramework,
217+
mode: result.mode,
218+
})
219+
}
220+
221+
// Handle default tabs variant
105222
const result = extractTabPanels(node)
106223
if (!result) {
107224
return

0 commit comments

Comments
 (0)