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
18 changes: 18 additions & 0 deletions src/components/Doc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { renderMarkdown } from '~/utils/markdown'
import { DocBreadcrumb } from './DocBreadcrumb'
import { MarkdownContent } from '~/components/markdown'
import type { ConfigSchema } from '~/utils/config'
import { useLocalCurrentFramework } from './FrameworkSelect'
import { useParams } from '@tanstack/react-router'

type DocProps = {
title: string
Expand All @@ -28,6 +30,8 @@ type DocProps = {
config?: ConfigSchema
// Footer content rendered after markdown
footer?: React.ReactNode
// Optional framework to use (overrides URL and local storage)
framework?: string
}

export function Doc({
Expand All @@ -45,13 +49,26 @@ export function Doc({
pagePath,
config,
footer,
framework: frameworkProp,
}: DocProps) {
// Extract headings synchronously during render to avoid hydration mismatch
const { headings, markup } = React.useMemo(
() => renderMarkdown(content),
[content],
)

// Get current framework from prop, URL params, or local storage
const { framework: paramsFramework } = useParams({ strict: false })
const localCurrentFramework = useLocalCurrentFramework()
const currentFramework = React.useMemo(() => {
const fw =
frameworkProp ||
paramsFramework ||
localCurrentFramework.currentFramework ||
'react'
return typeof fw === 'string' ? fw.toLowerCase() : fw
}, [frameworkProp, paramsFramework, localCurrentFramework.currentFramework])

const isTocVisible = shouldRenderToc && headings.length > 1

const markdownContainerRef = React.useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -170,6 +187,7 @@ export function Doc({
colorFrom={colorFrom}
colorTo={colorTo}
textColor={textColor}
currentFramework={currentFramework}
/>
</div>
)}
Expand Down
24 changes: 22 additions & 2 deletions src/components/Toc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,29 @@ type TocProps = {
colorTo?: string
textColor?: string
activeHeadings: Array<string>
currentFramework?: string
}

export function Toc({ headings, textColor, activeHeadings }: TocProps) {
export function Toc({
headings,
textColor,
activeHeadings,
currentFramework,
}: TocProps) {
// Filter headings based on framework scope
const visibleHeadings = React.useMemo(() => {
return headings.filter((heading) => {
console.log(heading)
if (heading.framework) {
return (
currentFramework &&
heading.framework === currentFramework.toLowerCase()
)
}
// If no framework attribute, always show (not framework-scoped)
return true
})
}, [headings, currentFramework])
return (
<nav className="flex flex-col sticky top-[var(--navbar-height)] max-h-[calc(100dvh-var(--navbar-height))] overflow-hidden">
<div className="py-1">
Expand All @@ -33,7 +53,7 @@ export function Toc({ headings, textColor, activeHeadings }: TocProps) {
'py-1 flex flex-col overflow-y-auto text-[.6em] lg:text-[.65em] xl:text-[.7em] 2xl:text-[.75em]',
)}
>
{headings?.map((heading) => (
{visibleHeadings?.map((heading) => (
<li
key={heading.id}
className={twMerge('w-full', headingLevels[heading.level])}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const Route = createFileRoute(
function Docs() {
const { title, content, filePath } = Route.useLoaderData()
const { config } = docsRouteApi.useLoaderData()
const { version, libraryId } = Route.useParams()
const { version, libraryId, framework } = Route.useParams()
const library = getLibrary(libraryId)
const branch = getBranch(library, version)
const location = useLocation()
Expand All @@ -89,6 +89,7 @@ function Docs() {
libraryVersion={version === 'latest' ? library.latestVersion : version}
pagePath={location.pathname}
config={config}
framework={framework}
/>
</DocContainer>
)
Expand Down
5 changes: 5 additions & 0 deletions src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -981,3 +981,8 @@ mark {
.framework-code-block > .codeblock {
@apply my-4;
}

/* Tab content - add padding when it's not just a code block */
[data-tab]:not(:has(> .codeblock:only-child)) {
@apply px-4;
}
28 changes: 28 additions & 0 deletions src/utils/markdown/plugins/collectHeadings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type MarkdownHeading = {
id: string
text: string
level: number
framework?: string
}

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

const isFrameworkPanelAncestor = (ancestor: HastElement) => {
if (ancestor.type !== 'element') {
return false
}

return ancestor.tagName === 'md-framework-panel'
}

export function rehypeCollectHeadings(initialHeadings?: MarkdownHeading[]) {
const headings = initialHeadings ?? []

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

let currentFramework: string | undefined

const headingDataFramework = node.properties?.['data-framework']
if (typeof headingDataFramework === 'string') {
currentFramework = headingDataFramework
} else if (Array.isArray(ancestors)) {
const frameworkPanel = ancestors.find((ancestor) =>
isFrameworkPanelAncestor(ancestor as HastElement),
) as HastElement | undefined

if (frameworkPanel) {
const dataFramework = frameworkPanel.properties?.['data-framework']
if (typeof dataFramework === 'string') {
currentFramework = dataFramework
}
}
}

headings.push({
id,
level: Number(node.tagName.substring(1)),
text: toString(node as any).trim(),
framework: currentFramework,
})
})

Expand Down
105 changes: 66 additions & 39 deletions src/utils/markdown/plugins/transformFrameworkComponent.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,35 @@
import { toString } from 'hast-util-to-string'
import { visit } from 'unist-util-visit'

import { isHeading, normalizeComponentName } from './helpers'
import { normalizeComponentName } from './helpers'

type HastNode = {
type: string
tagName: string
tagName?: string
properties?: Record<string, unknown>
children?: HastNode[]
value?: string
}

type FrameworkCodeBlock = {
title: string
code: string
language: string
preNode: HastNode
}

type FrameworkExtraction = {
codeBlocksByFramework: Record<string, FrameworkCodeBlock[]>
contentByFramework: Record<string, HastNode[]>
}

// Helper to extract text from nodes (used for code content)
function extractText(nodes: any[]): string {
let text = ''
for (const node of nodes) {
if (node.type === 'text') {
text += node.value
} else if (node.type === 'element' && node.children) {
text += extractText(node.children)
}
}
return text
}

/**
* Extract code block data (language, title, code) from a <pre> element.
* Extracts title from data-code-title (set by rehypeCodeMeta).
*/
function extractCodeBlockData(preNode: HastNode): {
language: string
title: string
code: string
} | null {
// Find the <code> child
const codeNode = preNode.children?.find(
(c: HastNode) => c.type === 'element' && c.tagName === 'code',
)
Expand All @@ -61,6 +46,7 @@ function extractCodeBlockData(preNode: HastNode): {
}
}

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

// Extract code content
// Extract code text
const extractText = (nodes: HastNode[]): string => {
let text = ''
for (const node of nodes) {
if (node.type === 'text' && node.value) {
text += node.value
} else if (node.type === 'element' && node.children) {
text += extractText(node.children)
}
}
return text
}
const code = extractText(codeNode.children || [])

return { language, title, code }
}

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

let currentFramework: string | null = null
// First pass: find the first H1 to determine the first framework
let firstFramework: string | null = null
for (const child of children) {
if (child.type === 'element' && child.tagName === 'h1') {
firstFramework = toString(child as any)
.trim()
.toLowerCase()
break
}
}

// If no H1 found at all, return null
if (!firstFramework) {
return null
}

// Second pass: collect content
let currentFramework: string | null = firstFramework // Start with first framework for content before first H1

// Initialize the first framework
contentByFramework[firstFramework] = []
codeBlocksByFramework[firstFramework] = []

for (const child of children) {
if (isHeading(child)) {
// Check if this is an H1 heading (framework divider)
if (child.type === 'element' && child.tagName === 'h1') {
// Extract framework name from H1 text
currentFramework = toString(child as any)
.trim()
.toLowerCase()

// Initialize arrays for this framework
if (currentFramework && !contentByFramework[currentFramework]) {
contentByFramework[currentFramework] = []
codeBlocksByFramework[currentFramework] = []
}
// Don't include the H1 itself in content - it's just a divider
continue
}

// Skip if no framework heading found yet
if (!currentFramework) continue

// Add all content to contentByFramework
contentByFramework[currentFramework].push(child)
// Create a shallow copy of the node
const contentNode = Object.assign({}, child) as HastNode

// Mark all headings (h2-h6) with framework attribute so they appear in TOC only for this framework
if (
contentNode.type === 'element' &&
contentNode.tagName &&
/^h[2-6]$/.test(contentNode.tagName)
) {
contentNode.properties = (contentNode.properties || {}) as Record<
string,
unknown
>
contentNode.properties['data-framework'] = currentFramework
}

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

codeBlocksByFramework[currentFramework].push({
title: codeBlockData.title || 'Untitled',
code: codeBlockData.code,
language: codeBlockData.language,
preNode: child,
})
// Extract code blocks for this framework
if (contentNode.type === 'element' && contentNode.tagName === 'pre') {
const codeBlockData = extractCodeBlockData(contentNode)
if (codeBlockData) {
codeBlocksByFramework[currentFramework].push(codeBlockData)
}
}
}

// Return null only if no frameworks found at all
// Return null if no frameworks found
if (Object.keys(contentByFramework).length === 0) {
return null
}
Expand Down
7 changes: 2 additions & 5 deletions src/utils/markdown/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,11 @@ import {
rehypeParseCommentComponents,
rehypeTransformCommentComponents,
rehypeTransformFrameworkComponents,
type MarkdownHeading,
} from '~/utils/markdown/plugins'
import { extractCodeMeta } from '~/utils/markdown/plugins/extractCodeMeta'

export type MarkdownHeading = {
id: string
text: string
level: number
}
export type { MarkdownHeading } from '~/utils/markdown/plugins'

export type MarkdownRenderResult = {
markup: string
Expand Down
Loading