Skip to content

Commit 72f03f1

Browse files
committed
2 parents 4b82568 + 0ccc5db commit 72f03f1

File tree

11 files changed

+666
-143
lines changed

11 files changed

+666
-143
lines changed

src/components/CodeBlock.tsx

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import type { Mermaid } from 'mermaid'
66
import { transformerNotationDiff } from '@shikijs/transformers'
77
import { createHighlighter, type HighlighterGeneric } from 'shiki'
88
import { Button } from './Button'
9-
import { ButtonGroup } from './ButtonGroup'
109

1110
// Language aliases mapping
1211
const LANG_ALIASES: Record<string, string> = {
@@ -99,6 +98,16 @@ export function CodeBlock({
9998
isEmbedded?: boolean
10099
showTypeCopyButton?: boolean
101100
}) {
101+
// Extract title from data-code-title attribute, handling both camelCase and kebab-case
102+
const rawTitle = ((props as any)?.dataCodeTitle ||
103+
(props as any)?.['data-code-title']) as string | undefined
104+
105+
// Filter out "undefined" strings, null, and empty strings
106+
const title =
107+
rawTitle && rawTitle !== 'undefined' && rawTitle.trim().length > 0
108+
? rawTitle.trim()
109+
: undefined
110+
102111
const childElement = props.children as
103112
| undefined
104113
| { props?: { className?: string; children?: string } }
@@ -123,14 +132,9 @@ export function CodeBlock({
123132
const code = children?.props.children
124133

125134
const [codeElement, setCodeElement] = React.useState(
126-
<>
127-
<pre ref={ref} className={`shiki github-light h-full`}>
128-
<code>{lang === 'mermaid' ? <svg /> : code}</code>
129-
</pre>
130-
<pre className={`shiki vitesse-dark`}>
131-
<code>{lang === 'mermaid' ? <svg /> : code}</code>
132-
</pre>
133-
</>,
135+
<pre ref={ref} className={`shiki h-full github-light dark:vitesse-dark`}>
136+
<code>{lang === 'mermaid' ? <svg /> : code}</code>
137+
</pre>,
134138
)
135139

136140
React[
@@ -189,18 +193,14 @@ export function CodeBlock({
189193
)}
190194
style={props.style}
191195
>
192-
{showTypeCopyButton ? (
193-
<ButtonGroup
194-
className={twMerge(
195-
'absolute z-10 text-sm',
196-
isEmbedded ? 'top-2 right-4' : '-top-3 right-2',
197-
)}
198-
>
199-
{lang ? (
200-
<span className="px-2 py-1 text-xs font-medium">{lang}</span>
201-
) : null}
196+
{(title || showTypeCopyButton) && (
197+
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-500/20 bg-gray-50 dark:bg-gray-900">
198+
<div className="text-xs text-gray-700 dark:text-gray-300">
199+
{title || (lang?.toLowerCase() === 'bash' ? 'sh' : (lang ?? ''))}
200+
</div>
201+
202202
<Button
203-
className="border-0 rounded-none"
203+
className={twMerge('border-0 rounded-md transition-opacity')}
204204
onClick={() => {
205205
let copyContent =
206206
typeof ref.current?.innerText === 'string'
@@ -215,20 +215,24 @@ export function CodeBlock({
215215
setCopied(true)
216216
setTimeout(() => setCopied(false), 2000)
217217
notify(
218-
<div>
219-
<div className="font-medium">Copied code</div>
220-
<div className="text-gray-500 dark:text-gray-400 text-xs">
218+
<div className="flex flex-col">
219+
<span className="font-medium">Copied code</span>
220+
<span className="text-gray-500 dark:text-gray-400 text-xs">
221221
Code block copied to clipboard
222-
</div>
222+
</span>
223223
</div>,
224224
)
225225
}}
226226
aria-label="Copy code to clipboard"
227227
>
228-
{copied ? <span className="text-xs">Copied!</span> : <Copy />}
228+
{copied ? (
229+
<span className="text-xs">Copied!</span>
230+
) : (
231+
<Copy className="w-4 h-4" />
232+
)}
229233
</Button>
230-
</ButtonGroup>
231-
) : null}
234+
</div>
235+
)}
232236
{codeElement}
233237
</div>
234238
)

src/components/FileTabs.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import * as React from 'react'
2+
3+
export type FileTabDefinition = {
4+
slug: string
5+
name: string
6+
}
7+
8+
export type FileTabsProps = {
9+
tabs: Array<FileTabDefinition>
10+
children: Array<React.ReactNode> | React.ReactNode
11+
id: string
12+
}
13+
14+
export function FileTabs({ tabs, id, children }: FileTabsProps) {
15+
const childrenArray = React.Children.toArray(children)
16+
const [activeSlug, setActiveSlug] = React.useState(tabs[0]?.slug ?? '')
17+
18+
if (tabs.length === 0) return null
19+
20+
return (
21+
<div className="not-prose my-4">
22+
<div className="flex items-center justify-start gap-0 overflow-x-auto overflow-y-hidden bg-gray-100 dark:bg-gray-900 border border-b-0 border-gray-500/20 rounded-t-md">
23+
{tabs.map((tab) => (
24+
<button
25+
key={`${id}-${tab.slug}`}
26+
type="button"
27+
onClick={() => setActiveSlug(tab.slug)}
28+
aria-label={tab.name}
29+
title={tab.name}
30+
className={`px-3 py-1.5 text-sm font-medium transition-colors border-b-2 -mb-[1px] ${
31+
activeSlug === tab.slug
32+
? 'border-current text-current bg-white dark:bg-gray-950'
33+
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-800'
34+
}`}
35+
>
36+
{tab.name}
37+
</button>
38+
))}
39+
</div>
40+
<div>
41+
{childrenArray.map((child, index) => {
42+
const tab = tabs[index]
43+
if (!tab) return null
44+
return (
45+
<div
46+
key={`${id}-${tab.slug}-panel`}
47+
data-tab={tab.slug}
48+
hidden={tab.slug !== activeSlug}
49+
className="file-tabs-panel"
50+
>
51+
{child}
52+
</div>
53+
)
54+
})}
55+
</div>
56+
</div>
57+
)
58+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import * as React from 'react'
2+
import { useLocalCurrentFramework } from './FrameworkSelect'
3+
import { useCurrentUserQuery } from '~/hooks/useCurrentUser'
4+
import { useParams } from '@tanstack/react-router'
5+
import { FileTabs } from './FileTabs'
6+
import type { Framework } from '~/libraries/types'
7+
8+
type CodeBlockMeta = {
9+
title: string
10+
code: string
11+
language: string
12+
}
13+
14+
type FrameworkCodeBlockProps = {
15+
id: string
16+
codeBlocksByFramework: Record<string, CodeBlockMeta[]>
17+
availableFrameworks: string[]
18+
/** Pre-rendered React children for each framework (from domToReact) */
19+
panelsByFramework: Record<string, React.ReactNode>
20+
}
21+
22+
/**
23+
* Renders code blocks for the currently selected framework.
24+
* - If no blocks for framework: shows nothing
25+
* - If 1 block: shows just the code block (minimal style)
26+
* - If multiple blocks: shows as FileTabs (file tabs with names)
27+
*/
28+
export function FrameworkCodeBlock({
29+
id,
30+
codeBlocksByFramework,
31+
panelsByFramework,
32+
}: FrameworkCodeBlockProps) {
33+
const { framework: paramsFramework } = useParams({ strict: false })
34+
const localCurrentFramework = useLocalCurrentFramework()
35+
const userQuery = useCurrentUserQuery()
36+
const userFramework = userQuery.data?.lastUsedFramework
37+
38+
const actualFramework = (paramsFramework ||
39+
userFramework ||
40+
localCurrentFramework.currentFramework ||
41+
'react') as Framework
42+
43+
const normalizedFramework = actualFramework.toLowerCase()
44+
45+
// Find the framework's code blocks
46+
const frameworkBlocks = codeBlocksByFramework[normalizedFramework]
47+
const frameworkPanel = panelsByFramework[normalizedFramework]
48+
49+
if (!frameworkBlocks || frameworkBlocks.length === 0 || !frameworkPanel) {
50+
return null
51+
}
52+
53+
if (frameworkBlocks.length === 1) {
54+
return <div className="framework-code-block">{frameworkPanel}</div>
55+
}
56+
57+
const tabs = frameworkBlocks.map((block, index) => ({
58+
slug: `file-${index}`,
59+
name: block.title || 'Untitled',
60+
}))
61+
62+
const childrenArray = React.Children.toArray(frameworkPanel)
63+
64+
return (
65+
<div className="framework-code-block">
66+
<FileTabs id={`${id}-${normalizedFramework}`} tabs={tabs}>
67+
{childrenArray}
68+
</FileTabs>
69+
</div>
70+
)
71+
}

src/components/Markdown.tsx

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { Tabs } from '~/components/Tabs'
1515
import { CodeBlock } from './CodeBlock'
1616
import { PackageManagerTabs } from './PackageManagerTabs'
1717
import type { Framework } from '~/libraries/types'
18+
import { FileTabs } from './FileTabs'
19+
import { FrameworkCodeBlock } from './FrameworkCodeBlock'
1820

1921
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
2022

@@ -124,7 +126,6 @@ const options: HTMLReactParserOptions = {
124126

125127
switch (componentName?.toLowerCase()) {
126128
case 'tabs': {
127-
// Check if this is a package-manager tabs (has metadata)
128129
if (pmMeta) {
129130
try {
130131
const { packagesByFramework, mode } = JSON.parse(pmMeta)
@@ -148,7 +149,73 @@ const options: HTMLReactParserOptions = {
148149
}
149150
}
150151

151-
// Default tabs variant
152+
// Check if this is files variant
153+
const filesMeta = domNode.attribs['data-files-meta']
154+
if (filesMeta) {
155+
try {
156+
const tabs = attributes.tabs || []
157+
const id =
158+
attributes.id ||
159+
`files-tabs-${Math.random().toString(36).slice(2, 9)}`
160+
161+
const panelElements = domNode.children?.filter(
162+
(child): child is Element =>
163+
child instanceof Element && child.name === 'md-tab-panel',
164+
)
165+
166+
const children = panelElements?.map((panel) =>
167+
domToReact(panel.children as any, options),
168+
)
169+
170+
return (
171+
<FileTabs id={id} tabs={tabs} children={children as any} />
172+
)
173+
} catch {
174+
// Fall through to default tabs if parsing fails
175+
}
176+
}
177+
178+
const frameworkMeta = domNode.attribs['data-framework-meta']
179+
if (frameworkMeta) {
180+
try {
181+
const { codeBlocksByFramework } = JSON.parse(frameworkMeta)
182+
const availableFrameworks = JSON.parse(
183+
domNode.attribs['data-available-frameworks'] || '[]',
184+
)
185+
const id =
186+
attributes.id ||
187+
`framework-${Math.random().toString(36).slice(2, 9)}`
188+
189+
const panelElements = domNode.children?.filter(
190+
(child): child is Element =>
191+
child instanceof Element && child.name === 'md-tab-panel',
192+
)
193+
194+
// Build panelsByFramework map
195+
const panelsByFramework: Record<string, React.ReactNode> = {}
196+
panelElements?.forEach((panel) => {
197+
const fw = panel.attribs['data-framework']
198+
if (fw) {
199+
panelsByFramework[fw] = domToReact(
200+
panel.children as any,
201+
options,
202+
)
203+
}
204+
})
205+
206+
return (
207+
<FrameworkCodeBlock
208+
id={id}
209+
codeBlocksByFramework={codeBlocksByFramework}
210+
availableFrameworks={availableFrameworks}
211+
panelsByFramework={panelsByFramework}
212+
/>
213+
)
214+
} catch {
215+
// Fall through to default tabs if parsing fails
216+
}
217+
}
218+
152219
const tabs = attributes.tabs
153220
const id =
154221
attributes.id || `tabs-${Math.random().toString(36).slice(2, 9)}`
@@ -162,9 +229,11 @@ const options: HTMLReactParserOptions = {
162229
child instanceof Element && child.name === 'md-tab-panel',
163230
)
164231

165-
const children = panelElements?.map((panel) =>
166-
domToReact(panel.children as any, options),
167-
)
232+
const children = panelElements?.map((panel) => {
233+
const result = domToReact(panel.children as any, options)
234+
// Wrap in fragment to ensure it's a single React node
235+
return <>{result}</>
236+
})
168237

169238
return <Tabs id={id} tabs={tabs} children={children as any} />
170239
}

src/components/PackageManagerTabs.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,14 @@ export function PackageManagerTabs({
131131
})
132132

133133
return (
134-
<Tabs
135-
id={id}
136-
tabs={tabs}
137-
children={children}
138-
activeSlug={selectedPackageManager}
139-
onTabChange={(slug) => setPackageManager(slug as PackageManager)}
140-
/>
134+
<div className="package-manager-tabs">
135+
<Tabs
136+
id={id}
137+
tabs={tabs}
138+
children={children}
139+
activeSlug={selectedPackageManager}
140+
onTabChange={(slug) => setPackageManager(slug as PackageManager)}
141+
/>
142+
</div>
141143
)
142144
}

0 commit comments

Comments
 (0)