Skip to content

Commit 2a43713

Browse files
Framework component supports headings (#644)
* feat: add framework support to documentation components * fix: add padding to tab content when not only displaying a code block * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 0bd8b98 commit 2a43713

File tree

7 files changed

+143
-47
lines changed

7 files changed

+143
-47
lines changed

src/components/Doc.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { renderMarkdown } from '~/utils/markdown'
99
import { DocBreadcrumb } from './DocBreadcrumb'
1010
import { MarkdownContent } from '~/components/markdown'
1111
import type { ConfigSchema } from '~/utils/config'
12+
import { useLocalCurrentFramework } from './FrameworkSelect'
13+
import { useParams } from '@tanstack/react-router'
1214

1315
type DocProps = {
1416
title: string
@@ -28,6 +30,8 @@ type DocProps = {
2830
config?: ConfigSchema
2931
// Footer content rendered after markdown
3032
footer?: React.ReactNode
33+
// Optional framework to use (overrides URL and local storage)
34+
framework?: string
3135
}
3236

3337
export function Doc({
@@ -45,13 +49,26 @@ export function Doc({
4549
pagePath,
4650
config,
4751
footer,
52+
framework: frameworkProp,
4853
}: DocProps) {
4954
// Extract headings synchronously during render to avoid hydration mismatch
5055
const { headings, markup } = React.useMemo(
5156
() => renderMarkdown(content),
5257
[content],
5358
)
5459

60+
// Get current framework from prop, URL params, or local storage
61+
const { framework: paramsFramework } = useParams({ strict: false })
62+
const localCurrentFramework = useLocalCurrentFramework()
63+
const currentFramework = React.useMemo(() => {
64+
const fw =
65+
frameworkProp ||
66+
paramsFramework ||
67+
localCurrentFramework.currentFramework ||
68+
'react'
69+
return typeof fw === 'string' ? fw.toLowerCase() : fw
70+
}, [frameworkProp, paramsFramework, localCurrentFramework.currentFramework])
71+
5572
const isTocVisible = shouldRenderToc && headings.length > 1
5673

5774
const markdownContainerRef = React.useRef<HTMLDivElement>(null)
@@ -170,6 +187,7 @@ export function Doc({
170187
colorFrom={colorFrom}
171188
colorTo={colorTo}
172189
textColor={textColor}
190+
currentFramework={currentFramework}
173191
/>
174192
</div>
175193
)}

src/components/Toc.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,29 @@ type TocProps = {
1818
colorTo?: string
1919
textColor?: string
2020
activeHeadings: Array<string>
21+
currentFramework?: string
2122
}
2223

23-
export function Toc({ headings, textColor, activeHeadings }: TocProps) {
24+
export function Toc({
25+
headings,
26+
textColor,
27+
activeHeadings,
28+
currentFramework,
29+
}: TocProps) {
30+
// Filter headings based on framework scope
31+
const visibleHeadings = React.useMemo(() => {
32+
return headings.filter((heading) => {
33+
console.log(heading)
34+
if (heading.framework) {
35+
return (
36+
currentFramework &&
37+
heading.framework === currentFramework.toLowerCase()
38+
)
39+
}
40+
// If no framework attribute, always show (not framework-scoped)
41+
return true
42+
})
43+
}, [headings, currentFramework])
2444
return (
2545
<nav className="flex flex-col sticky top-[var(--navbar-height)] max-h-[calc(100dvh-var(--navbar-height))] overflow-hidden">
2646
<div className="py-1">
@@ -33,7 +53,7 @@ export function Toc({ headings, textColor, activeHeadings }: TocProps) {
3353
'py-1 flex flex-col overflow-y-auto text-[.6em] lg:text-[.65em] xl:text-[.7em] 2xl:text-[.75em]',
3454
)}
3555
>
36-
{headings?.map((heading) => (
56+
{visibleHeadings?.map((heading) => (
3757
<li
3858
key={heading.id}
3959
className={twMerge('w-full', headingLevels[heading.level])}

src/routes/$libraryId/$version.docs.framework.$framework.$.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export const Route = createFileRoute(
6767
function Docs() {
6868
const { title, content, filePath } = Route.useLoaderData()
6969
const { config } = docsRouteApi.useLoaderData()
70-
const { version, libraryId } = Route.useParams()
70+
const { version, libraryId, framework } = Route.useParams()
7171
const library = getLibrary(libraryId)
7272
const branch = getBranch(library, version)
7373
const location = useLocation()
@@ -89,6 +89,7 @@ function Docs() {
8989
libraryVersion={version === 'latest' ? library.latestVersion : version}
9090
pagePath={location.pathname}
9191
config={config}
92+
framework={framework}
9293
/>
9394
</DocContainer>
9495
)

src/styles/app.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,3 +981,8 @@ mark {
981981
.framework-code-block > .codeblock {
982982
@apply my-4;
983983
}
984+
985+
/* Tab content - add padding when it's not just a code block */
986+
[data-tab]:not(:has(> .codeblock:only-child)) {
987+
@apply px-4;
988+
}

src/utils/markdown/plugins/collectHeadings.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type MarkdownHeading = {
77
id: string
88
text: string
99
level: number
10+
framework?: string
1011
}
1112

1213
type HastElement = {
@@ -38,6 +39,14 @@ const isTabsAncestor = (ancestor: HastElement) => {
3839
return typeof component === 'string' && component.toLowerCase() === 'tabs'
3940
}
4041

42+
const isFrameworkPanelAncestor = (ancestor: HastElement) => {
43+
if (ancestor.type !== 'element') {
44+
return false
45+
}
46+
47+
return ancestor.tagName === 'md-framework-panel'
48+
}
49+
4150
export function rehypeCollectHeadings(initialHeadings?: MarkdownHeading[]) {
4251
const headings = initialHeadings ?? []
4352

@@ -62,10 +71,29 @@ export function rehypeCollectHeadings(initialHeadings?: MarkdownHeading[]) {
6271
return
6372
}
6473

74+
let currentFramework: string | undefined
75+
76+
const headingDataFramework = node.properties?.['data-framework']
77+
if (typeof headingDataFramework === 'string') {
78+
currentFramework = headingDataFramework
79+
} else if (Array.isArray(ancestors)) {
80+
const frameworkPanel = ancestors.find((ancestor) =>
81+
isFrameworkPanelAncestor(ancestor as HastElement),
82+
) as HastElement | undefined
83+
84+
if (frameworkPanel) {
85+
const dataFramework = frameworkPanel.properties?.['data-framework']
86+
if (typeof dataFramework === 'string') {
87+
currentFramework = dataFramework
88+
}
89+
}
90+
}
91+
6592
headings.push({
6693
id,
6794
level: Number(node.tagName.substring(1)),
6895
text: toString(node as any).trim(),
96+
framework: currentFramework,
6997
})
7098
})
7199

src/utils/markdown/plugins/transformFrameworkComponent.ts

Lines changed: 66 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,35 @@
11
import { toString } from 'hast-util-to-string'
22
import { visit } from 'unist-util-visit'
33

4-
import { isHeading, normalizeComponentName } from './helpers'
4+
import { normalizeComponentName } from './helpers'
55

66
type HastNode = {
77
type: string
8-
tagName: string
8+
tagName?: string
99
properties?: Record<string, unknown>
1010
children?: HastNode[]
11+
value?: string
1112
}
1213

1314
type FrameworkCodeBlock = {
1415
title: string
1516
code: string
1617
language: string
17-
preNode: HastNode
1818
}
1919

2020
type FrameworkExtraction = {
2121
codeBlocksByFramework: Record<string, FrameworkCodeBlock[]>
2222
contentByFramework: Record<string, HastNode[]>
2323
}
2424

25-
// Helper to extract text from nodes (used for code content)
26-
function extractText(nodes: any[]): string {
27-
let text = ''
28-
for (const node of nodes) {
29-
if (node.type === 'text') {
30-
text += node.value
31-
} else if (node.type === 'element' && node.children) {
32-
text += extractText(node.children)
33-
}
34-
}
35-
return text
36-
}
37-
3825
/**
3926
* Extract code block data (language, title, code) from a <pre> element.
40-
* Extracts title from data-code-title (set by rehypeCodeMeta).
4127
*/
4228
function extractCodeBlockData(preNode: HastNode): {
4329
language: string
4430
title: string
4531
code: string
4632
} | null {
47-
// Find the <code> child
4833
const codeNode = preNode.children?.find(
4934
(c: HastNode) => c.type === 'element' && c.tagName === 'code',
5035
)
@@ -61,6 +46,7 @@ function extractCodeBlockData(preNode: HastNode): {
6146
}
6247
}
6348

49+
// Extract title from data attributes
6450
let title = ''
6551
const props = preNode.properties || {}
6652
if (typeof props['dataCodeTitle'] === 'string') {
@@ -73,57 +59,98 @@ function extractCodeBlockData(preNode: HastNode): {
7359
title = props['data-filename']
7460
}
7561

76-
// Extract code content
62+
// Extract code text
63+
const extractText = (nodes: HastNode[]): string => {
64+
let text = ''
65+
for (const node of nodes) {
66+
if (node.type === 'text' && node.value) {
67+
text += node.value
68+
} else if (node.type === 'element' && node.children) {
69+
text += extractText(node.children)
70+
}
71+
}
72+
return text
73+
}
7774
const code = extractText(codeNode.children || [])
7875

7976
return { language, title, code }
8077
}
8178

82-
/**
83-
* Extract framework-specific content for framework component.
84-
* Groups all content (code blocks and general content) by framework headings.
85-
*/
8679
function extractFrameworkData(node: HastNode): FrameworkExtraction | null {
8780
const children = node.children ?? []
8881
const codeBlocksByFramework: Record<string, FrameworkCodeBlock[]> = {}
8982
const contentByFramework: Record<string, HastNode[]> = {}
9083

91-
let currentFramework: string | null = null
84+
// First pass: find the first H1 to determine the first framework
85+
let firstFramework: string | null = null
86+
for (const child of children) {
87+
if (child.type === 'element' && child.tagName === 'h1') {
88+
firstFramework = toString(child as any)
89+
.trim()
90+
.toLowerCase()
91+
break
92+
}
93+
}
94+
95+
// If no H1 found at all, return null
96+
if (!firstFramework) {
97+
return null
98+
}
99+
100+
// Second pass: collect content
101+
let currentFramework: string | null = firstFramework // Start with first framework for content before first H1
102+
103+
// Initialize the first framework
104+
contentByFramework[firstFramework] = []
105+
codeBlocksByFramework[firstFramework] = []
92106

93107
for (const child of children) {
94-
if (isHeading(child)) {
108+
// Check if this is an H1 heading (framework divider)
109+
if (child.type === 'element' && child.tagName === 'h1') {
110+
// Extract framework name from H1 text
95111
currentFramework = toString(child as any)
96112
.trim()
97113
.toLowerCase()
114+
98115
// Initialize arrays for this framework
99116
if (currentFramework && !contentByFramework[currentFramework]) {
100117
contentByFramework[currentFramework] = []
101118
codeBlocksByFramework[currentFramework] = []
102119
}
120+
// Don't include the H1 itself in content - it's just a divider
103121
continue
104122
}
105123

106-
// Skip if no framework heading found yet
107124
if (!currentFramework) continue
108125

109-
// Add all content to contentByFramework
110-
contentByFramework[currentFramework].push(child)
126+
// Create a shallow copy of the node
127+
const contentNode = Object.assign({}, child) as HastNode
128+
129+
// Mark all headings (h2-h6) with framework attribute so they appear in TOC only for this framework
130+
if (
131+
contentNode.type === 'element' &&
132+
contentNode.tagName &&
133+
/^h[2-6]$/.test(contentNode.tagName)
134+
) {
135+
contentNode.properties = (contentNode.properties || {}) as Record<
136+
string,
137+
unknown
138+
>
139+
contentNode.properties['data-framework'] = currentFramework
140+
}
111141

112-
// Look for <pre> elements (code blocks) under current framework
113-
if ((child as any).type === 'element' && (child as any).tagName === 'pre') {
114-
const codeBlockData = extractCodeBlockData(child)
115-
if (!codeBlockData) continue
142+
contentByFramework[currentFramework].push(contentNode)
116143

117-
codeBlocksByFramework[currentFramework].push({
118-
title: codeBlockData.title || 'Untitled',
119-
code: codeBlockData.code,
120-
language: codeBlockData.language,
121-
preNode: child,
122-
})
144+
// Extract code blocks for this framework
145+
if (contentNode.type === 'element' && contentNode.tagName === 'pre') {
146+
const codeBlockData = extractCodeBlockData(contentNode)
147+
if (codeBlockData) {
148+
codeBlocksByFramework[currentFramework].push(codeBlockData)
149+
}
123150
}
124151
}
125152

126-
// Return null only if no frameworks found at all
153+
// Return null if no frameworks found
127154
if (Object.keys(contentByFramework).length === 0) {
128155
return null
129156
}

src/utils/markdown/processor.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,11 @@ import {
1313
rehypeParseCommentComponents,
1414
rehypeTransformCommentComponents,
1515
rehypeTransformFrameworkComponents,
16+
type MarkdownHeading,
1617
} from '~/utils/markdown/plugins'
1718
import { extractCodeMeta } from '~/utils/markdown/plugins/extractCodeMeta'
1819

19-
export type MarkdownHeading = {
20-
id: string
21-
text: string
22-
level: number
23-
}
20+
export type { MarkdownHeading } from '~/utils/markdown/plugins'
2421

2522
export type MarkdownRenderResult = {
2623
markup: string

0 commit comments

Comments
 (0)