diff --git a/src/components/CodeBlock.tsx b/src/components/CodeBlock.tsx new file mode 100644 index 000000000..842fb9633 --- /dev/null +++ b/src/components/CodeBlock.tsx @@ -0,0 +1,230 @@ +import * as React from 'react' +import { twMerge } from 'tailwind-merge' +import { useToast } from '~/components/ToastProvider' +import { Copy } from 'lucide-react' +import type { Mermaid } from 'mermaid' +import { transformerNotationDiff } from '@shikijs/transformers' +import { createHighlighter, type HighlighterGeneric } from 'shiki' + +// Language aliases mapping +const LANG_ALIASES: Record = { + ts: 'typescript', + js: 'javascript', + sh: 'bash', + shell: 'bash', + console: 'bash', + zsh: 'bash', + md: 'markdown', + txt: 'plaintext', + text: 'plaintext', +} + +// Lazy highlighter singleton +let highlighterPromise: Promise> | null = null +let mermaidInstance: Mermaid | null = null +const genSvgMap = new Map() + +async function getHighlighter(language: string) { + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + themes: ['github-light', 'vitesse-dark'], + langs: [ + 'typescript', + 'javascript', + 'tsx', + 'jsx', + 'bash', + 'json', + 'html', + 'css', + 'markdown', + 'plaintext', + ], + }) + } + + const highlighter = await highlighterPromise + const normalizedLang = LANG_ALIASES[language] || language + const langToLoad = normalizedLang === 'mermaid' ? 'plaintext' : normalizedLang + + // Load language if not already loaded + if (!highlighter.getLoadedLanguages().includes(langToLoad as any)) { + try { + await highlighter.loadLanguage(langToLoad as any) + } catch { + console.warn(`Shiki: Language "${langToLoad}" not found, using plaintext`) + } + } + + return highlighter +} + +// Lazy load mermaid only when needed +async function getMermaid(): Promise { + if (!mermaidInstance) { + const { default: mermaid } = await import('mermaid') + mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' }) + mermaidInstance = mermaid + } + return mermaidInstance +} + +function extractPreAttributes(html: string): { + class: string | null + style: string | null +} { + const match = html.match(/]*)>/i) + if (!match) { + return { class: null, style: null } + } + + const attributes = match[1] + + const classMatch = attributes.match(/\bclass\s*=\s*["']([^"']*)["']/i) + const styleMatch = attributes.match(/\bstyle\s*=\s*["']([^"']*)["']/i) + + return { + class: classMatch ? classMatch[1] : null, + style: styleMatch ? styleMatch[1] : null, + } +} + +export function CodeBlock({ + isEmbedded, + showTypeCopyButton = true, + ...props +}: React.HTMLProps & { + isEmbedded?: boolean + showTypeCopyButton?: boolean +}) { + let lang = props?.children?.props?.className?.replace('language-', '') + + if (lang === 'diff') { + lang = 'plaintext' + } + + const children = props.children as + | undefined + | { + props: { + children: string + } + } + + const [copied, setCopied] = React.useState(false) + const ref = React.useRef(null) + const { notify } = useToast() + + const code = children?.props.children + + const [codeElement, setCodeElement] = React.useState( + <> +
+        {lang === 'mermaid' ?  : code}
+      
+
+        {lang === 'mermaid' ?  : code}
+      
+ , + ) + + React[ + typeof document !== 'undefined' ? 'useLayoutEffect' : 'useEffect' + ](() => { + ;(async () => { + const themes = ['github-light', 'vitesse-dark'] + const normalizedLang = LANG_ALIASES[lang] || lang + const effectiveLang = + normalizedLang === 'mermaid' ? 'plaintext' : normalizedLang + + const highlighter = await getHighlighter(lang) + + const htmls = await Promise.all( + themes.map(async (theme) => { + const output = highlighter.codeToHtml(code, { + lang: effectiveLang, + theme, + transformers: [transformerNotationDiff()], + }) + + if (lang === 'mermaid') { + const preAttributes = extractPreAttributes(output) + let svgHtml = genSvgMap.get(code || '') + if (!svgHtml) { + const mermaid = await getMermaid() + const { svg } = await mermaid.render('foo', code || '') + genSvgMap.set(code || '', svg) + svgHtml = svg + } + return `
${svgHtml}
` + } + + return output + }), + ) + + setCodeElement( +
pre]:h-full [&>pre]:rounded-none' : '', + )} + dangerouslySetInnerHTML={{ __html: htmls.join('') }} + ref={ref} + />, + ) + })() + }, [code, lang]) + + return ( +
+ {showTypeCopyButton ? ( +
+ {lang ?
{lang}
: null} + +
+ ) : null} + {codeElement} +
+ ) +} diff --git a/src/components/CodeExplorer.tsx b/src/components/CodeExplorer.tsx index 176fab99e..8c25781b3 100644 --- a/src/components/CodeExplorer.tsx +++ b/src/components/CodeExplorer.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { CodeBlock } from '~/components/Markdown' +import { CodeBlock } from '~/components/CodeBlock' import { FileExplorer } from './FileExplorer' import { InteractiveSandbox } from './InteractiveSandbox' import { CodeExplorerTopBar } from './CodeExplorerTopBar' diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index 4e922121f..e7ee56db3 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -1,22 +1,17 @@ import * as React from 'react' import { MarkdownLink } from '~/components/MarkdownLink' import type { HTMLProps } from 'react' -import { createHighlighter, type HighlighterGeneric } from 'shiki/bundle/web' -import { transformerNotationDiff } from '@shikijs/transformers' + import parse, { attributesToProps, domToReact, Element, HTMLReactParserOptions, } from 'html-react-parser' -import type { Mermaid } from 'mermaid' -import { useToast } from '~/components/ToastProvider' -import { twMerge } from 'tailwind-merge' import { renderMarkdown } from '~/utils/markdown' import { getNetlifyImageUrl } from '~/utils/netlifyImage' import { Tabs } from '~/components/Tabs' -import { Copy } from 'lucide-react' type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' @@ -110,230 +105,6 @@ const markdownComponents: Record = { ), } -export function extractPreAttributes(html: string): { - class: string | null - style: string | null -} { - const match = html.match(/]*)>/i) - if (!match) { - return { class: null, style: null } - } - - const attributes = match[1] - - const classMatch = attributes.match(/\bclass\s*=\s*["']([^"']*)["']/i) - const styleMatch = attributes.match(/\bstyle\s*=\s*["']([^"']*)["']/i) - - return { - class: classMatch ? classMatch[1] : null, - style: styleMatch ? styleMatch[1] : null, - } -} - -const genSvgMap = new Map() - -// Lazy load mermaid only when needed -let mermaidInstance: Mermaid | null = null -async function getMermaid(): Promise { - if (!mermaidInstance) { - const { default: mermaid } = await import('mermaid') - mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' }) - mermaidInstance = mermaid - } - return mermaidInstance -} - -export function CodeBlock({ - isEmbedded, - showTypeCopyButton = true, - ...props -}: React.HTMLProps & { - isEmbedded?: boolean - showTypeCopyButton?: boolean -}) { - let lang = props?.children?.props?.className?.replace('language-', '') - - if (lang === 'diff') { - lang = 'plaintext' - } - - const children = props.children as - | undefined - | { - props: { - children: string - } - } - - const [copied, setCopied] = React.useState(false) - const ref = React.useRef(null) - const { notify } = useToast() - - const code = children?.props.children - - const [codeElement, setCodeElement] = React.useState( - <> -
-        {lang === 'mermaid' ?  : code}
-      
-
-        {lang === 'mermaid' ?  : code}
-      
- , - ) - - React[ - typeof document !== 'undefined' ? 'useLayoutEffect' : 'useEffect' - ](() => { - ;(async () => { - const themes = ['github-light', 'vitesse-dark'] - const normalizedLang = LANG_ALIASES[lang] || lang - const effectiveLang = - normalizedLang === 'mermaid' ? 'plaintext' : normalizedLang - - const highlighter = await getHighlighter(lang) - - const htmls = await Promise.all( - themes.map(async (theme) => { - const output = highlighter.codeToHtml(code, { - lang: effectiveLang, - theme, - transformers: [transformerNotationDiff()], - }) - - if (lang === 'mermaid') { - const preAttributes = extractPreAttributes(output) - let svgHtml = genSvgMap.get(code || '') - if (!svgHtml) { - const mermaid = await getMermaid() - const { svg } = await mermaid.render('foo', code || '') - genSvgMap.set(code || '', svg) - svgHtml = svg - } - return `
${svgHtml}
` - } - - return output - }), - ) - - setCodeElement( -
pre]:h-full [&>pre]:rounded-none' : '', - )} - dangerouslySetInnerHTML={{ __html: htmls.join('') }} - ref={ref} - />, - ) - })() - }, [code, lang]) - - return ( -
- {showTypeCopyButton ? ( -
- {lang ?
{lang}
: null} - -
- ) : null} - {codeElement} -
- ) -} - -// Language aliases mapping -const LANG_ALIASES: Record = { - ts: 'typescript', - js: 'javascript', - sh: 'bash', - shell: 'bash', - console: 'bash', - zsh: 'bash', - md: 'markdown', - txt: 'plaintext', - text: 'plaintext', -} - -// Lazy highlighter singleton -let highlighterPromise: Promise> | null = null - -async function getHighlighter(language: string) { - if (!highlighterPromise) { - highlighterPromise = createHighlighter({ - themes: ['github-light', 'vitesse-dark'], - langs: [ - 'typescript', - 'javascript', - 'tsx', - 'jsx', - 'bash', - 'json', - 'html', - 'css', - 'markdown', - 'plaintext', - ], - }) - } - - const highlighter = await highlighterPromise - const normalizedLang = LANG_ALIASES[language] || language - const langToLoad = normalizedLang === 'mermaid' ? 'plaintext' : normalizedLang - - // Load language if not already loaded - if (!highlighter.getLoadedLanguages().includes(langToLoad as any)) { - try { - await highlighter.loadLanguage(langToLoad as any) - } catch { - console.warn(`Shiki: Language "${langToLoad}" not found, using plaintext`) - } - } - - return highlighter -} - const options: HTMLReactParserOptions = { replace: (domNode) => { if (domNode instanceof Element && domNode.attribs) { diff --git a/src/routes/_libraries/form.$version.index.tsx b/src/routes/_libraries/form.$version.index.tsx index 93f83dd26..b65cc2427 100644 --- a/src/routes/_libraries/form.$version.index.tsx +++ b/src/routes/_libraries/form.$version.index.tsx @@ -16,7 +16,7 @@ import LandingPageGad from '~/components/LandingPageGad' import { PartnersSection } from '~/components/PartnersSection' import OpenSourceStats from '~/components/OpenSourceStats' import { ossStatsQuery } from '~/queries/stats' -import { CodeBlock } from '~/components/Markdown' +import { CodeBlock } from '~/components/CodeBlock' import { AdGate } from '~/contexts/AdsContext' import { GamHeader } from '~/components/Gam' import { Link, createFileRoute } from '@tanstack/react-router' diff --git a/src/routes/_libraries/query.$version.index.tsx b/src/routes/_libraries/query.$version.index.tsx index b6afade35..e6858e43f 100644 --- a/src/routes/_libraries/query.$version.index.tsx +++ b/src/routes/_libraries/query.$version.index.tsx @@ -16,7 +16,7 @@ import { LibraryFeatureHighlights } from '~/components/LibraryFeatureHighlights' import LandingPageGad from '~/components/LandingPageGad' import OpenSourceStats from '~/components/OpenSourceStats' import { ossStatsQuery } from '~/queries/stats' -import { CodeBlock } from '~/components/Markdown' +import { CodeBlock } from '~/components/CodeBlock' import { AdGate } from '~/contexts/AdsContext' import { GamHeader } from '~/components/Gam' import { FrameworkIconTabs } from '~/components/FrameworkIconTabs' diff --git a/src/routes/_libraries/router.$version.index.tsx b/src/routes/_libraries/router.$version.index.tsx index 641d6877a..e9d7defd0 100644 --- a/src/routes/_libraries/router.$version.index.tsx +++ b/src/routes/_libraries/router.$version.index.tsx @@ -8,7 +8,7 @@ import { PartnersSection } from '~/components/PartnersSection' import { LazySponsorSection } from '~/components/LazySponsorSection' import { StackBlitzEmbed } from '~/components/StackBlitzEmbed' import { FrameworkIconTabs } from '~/components/FrameworkIconTabs' -import { CodeBlock } from '~/components/Markdown' +import { CodeBlock } from '~/components/CodeBlock' import { Link, createFileRoute } from '@tanstack/react-router' import { BottomCTA } from '~/components/BottomCTA' import { Framework, getBranch, getLibrary } from '~/libraries' diff --git a/src/routes/_libraries/table.$version.index.tsx b/src/routes/_libraries/table.$version.index.tsx index dd350305a..c903e4280 100644 --- a/src/routes/_libraries/table.$version.index.tsx +++ b/src/routes/_libraries/table.$version.index.tsx @@ -8,7 +8,7 @@ import { PartnersSection } from '~/components/PartnersSection' import { LazySponsorSection } from '~/components/LazySponsorSection' import { StackBlitzEmbed } from '~/components/StackBlitzEmbed' import { FrameworkIconTabs } from '~/components/FrameworkIconTabs' -import { CodeBlock } from '~/components/Markdown' +import { CodeBlock } from '~/components/CodeBlock' import { Link, createFileRoute } from '@tanstack/react-router' import { BottomCTA } from '~/components/BottomCTA' import { Framework, getBranch, getLibrary } from '~/libraries' diff --git a/src/routes/_libraries/virtual.$version.index.tsx b/src/routes/_libraries/virtual.$version.index.tsx index 242541128..51b3fbab4 100644 --- a/src/routes/_libraries/virtual.$version.index.tsx +++ b/src/routes/_libraries/virtual.$version.index.tsx @@ -18,7 +18,7 @@ import { PartnersSection } from '~/components/PartnersSection' import { LibraryTestimonials } from '~/components/LibraryTestimonials' import OpenSourceStats from '~/components/OpenSourceStats' import { ossStatsQuery } from '~/queries/stats' -import { CodeBlock } from '~/components/Markdown' +import { CodeBlock } from '~/components/CodeBlock' import { Link, createFileRoute } from '@tanstack/react-router' import { AdGate } from '~/contexts/AdsContext' import { GamHeader } from '~/components/Gam' diff --git a/src/utils/markdown/index.ts b/src/utils/markdown/index.ts index fa92cf569..0aaf957fc 100644 --- a/src/utils/markdown/index.ts +++ b/src/utils/markdown/index.ts @@ -1,2 +1 @@ export { renderMarkdown } from './processor' -export { rehypeParseCommentComponents } from './plugins' diff --git a/src/utils/markdown/pipeline.ts b/src/utils/markdown/pipeline.ts deleted file mode 100644 index 442a7e45d..000000000 --- a/src/utils/markdown/pipeline.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { unified } from 'unified' -import remarkParse from 'remark-parse' -import remarkRehype from 'remark-rehype' -import rehypeRaw from 'rehype-raw' -import rehypeSlug from 'rehype-slug' -import rehypeAutolinkHeadings from 'rehype-autolink-headings' -import rehypeStringify from 'rehype-stringify' - -export function createMarkdownPipeline() { - return unified() - .use(remarkParse) - .use(remarkRehype, { allowDangerousHtml: true }) - .use(rehypeRaw) - .use(rehypeSlug) - .use(rehypeAutolinkHeadings, { - behavior: 'wrap', - properties: { - className: ['anchor-heading'], - }, - }) - .use(rehypeStringify) -} diff --git a/src/utils/markdown/plugins.ts b/src/utils/markdown/plugins.ts deleted file mode 100644 index 7930d89bf..000000000 --- a/src/utils/markdown/plugins.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { unified } from 'unified' -import rehypeParse from 'rehype-parse' -import { SKIP, visit } from 'unist-util-visit' -import { toString } from 'hast-util-to-string' -import { isElement } from 'hast-util-is-element' -import type { Element, Root } from 'hast-util-is-element/lib' - -const COMPONENT_PREFIX = '::' -const START_PREFIX = '::start:' -const END_PREFIX = '::end:' - -const componentParser = unified().use(rehypeParse, { fragment: true }) - -const normalizeComponentName = (name: string) => name.toLowerCase() - -function parseDescriptor(descriptor: string) { - const tree = componentParser.parse(`<${descriptor} />`) - const node = tree.children[0] - if (!node || node.type !== 'element') { - return null - } - - const component = node.tagName - const attributes: Record = {} - const properties = node.properties ?? {} - for (const [key, value] of Object.entries(properties)) { - if (Array.isArray(value)) { - attributes[key] = value.join(' ') - } else if (value != null) { - attributes[key] = String(value) - } - } - - return { component, attributes } -} - -const isCommentNode = (value: unknown) => - Boolean( - value && - typeof value === 'object' && - 'type' in value && - value.type === 'comment', - ) - -const slugify = (value: string, fallback: string) => { - if (!value) { - return fallback - } - return ( - value - .trim() - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 64) || fallback - ) -} - -export const rehypeParseCommentComponents = () => { - return (tree: Root) => { - visit(tree, 'comment', (node, index, parent) => { - if (!isCommentNode(node) || parent == null || typeof index !== 'number') { - return - } - - const trimmed = node.value.trim() - if (!trimmed.startsWith(COMPONENT_PREFIX)) { - return - } - - const isBlock = trimmed.startsWith(START_PREFIX) - const descriptor = isBlock - ? trimmed.slice(START_PREFIX.length) - : trimmed.slice(COMPONENT_PREFIX.length) - - const parsed = parseDescriptor(descriptor) - if (!parsed) { - return - } - - const componentName = parsed.component - const element = { - type: 'element', - tagName: 'md-comment-component', - properties: { - 'data-component': componentName, - 'data-attributes': JSON.stringify(parsed.attributes ?? {}), - }, - children: [], - } - - if (!isBlock) { - parent.children.splice(index, 1, element) - return [SKIP, index] - } - - let endIndex = -1 - for (let cursor = index + 1; cursor < parent.children.length; cursor++) { - const candidate = parent.children[cursor] - if ( - isCommentNode(candidate) && - candidate.value.trim().toLowerCase() === - `${END_PREFIX}${normalizeComponentName(componentName)}` - ) { - endIndex = cursor - break - } - } - - if (endIndex === -1) { - parent.children.splice(index, 1, element) - return [SKIP, index] - } - - element.children = parent.children.slice(index + 1, endIndex) - parent.children.splice(index, endIndex - index + 1, element) - return [SKIP, index] - }) - } -} - -const isHeading = (node: any): node is Element => - isElement(node) && /^h[1-6]$/.test(node.tagName) - -const headingLevel = (node: Element) => Number(node.tagName.substring(1)) - -function extractSmartTabPanels(node: Element) { - const children = node.children ?? [] - const headings = children.filter(isHeading) - - let sectionStarted = false - let largestHeadingLevel = Infinity - headings.forEach((heading: Element) => { - largestHeadingLevel = Math.min(largestHeadingLevel, headingLevel(heading)) - }) - - const tabs: Array<{ - slug: string - name: string - headers: Array - }> = [] - const panels: any[] = [] - - let currentPanel: any = null - - children.forEach((child: any) => { - if (isHeading(child)) { - const level = headingLevel(child) - if (!sectionStarted) { - if (level !== largestHeadingLevel) { - return - } - sectionStarted = true - } - - if (level === largestHeadingLevel) { - if (currentPanel) { - panels.push(currentPanel) - } - const headingId = - (child.properties?.id && String(child.properties.id)) || - slugify(toString(child), `tab-${tabs.length + 1}`) - - tabs.push({ - slug: headingId, - name: toString(child), - headers: [], - }) - - currentPanel = [] - return - } - } - - if (sectionStarted) { - if (!currentPanel) { - currentPanel = [] - } - currentPanel.push(child) - } - }) - - if (currentPanel) { - panels.push(currentPanel) - } - - if (!tabs.length) { - return null - } - - panels.forEach((panelChildren, index) => { - const nestedHeadings: Array = [] - visit({ type: 'root', children: panelChildren }, 'element', (child) => { - if (isHeading(child) && typeof child.properties?.id === 'string') { - nestedHeadings.push(String(child.properties.id)) - } - }) - tabs[index]!.headers = nestedHeadings - }) - - return { tabs, panels } -} - -function transformTabsComponent(node: Element) { - const result = extractSmartTabPanels(node) - if (!result) { - return - } - - const panelElements: Array = result.panels.map( - (panelChildren, index) => ({ - type: 'element', - tagName: 'md-tab-panel', - properties: { - 'data-tab-slug': result.tabs[index]?.slug ?? `tab-${index + 1}`, - 'data-tab-index': String(index), - }, - children: panelChildren, - }), - ) - - node.properties = { - ...node.properties, - 'data-attributes': JSON.stringify({ tabs: result.tabs }), - } - node.children = panelElements -} - -export const rehypeTransformCommentComponents = () => { - return (tree) => { - visit(tree, 'element', (node) => { - if (!isElement(node) || node.tagName !== 'md-comment-component') { - return - } - - const component = String(node.properties?.['data-component'] ?? '') - switch (normalizeComponentName(component)) { - case 'tabs': - transformTabsComponent(node) - break - default: - break - } - }) - } -} diff --git a/src/utils/markdown/plugins/collectHeadings.ts b/src/utils/markdown/plugins/collectHeadings.ts new file mode 100644 index 000000000..21e020b9d --- /dev/null +++ b/src/utils/markdown/plugins/collectHeadings.ts @@ -0,0 +1,42 @@ +import { visit } from 'unist-util-visit' +import { toString } from 'hast-util-to-string' + +import { isHeading } from './helpers' + +export type MarkdownHeading = { + id: string + text: string + level: number +} + +export function rehypeCollectHeadings( + tree, + file, + initialHeadings?: MarkdownHeading[], +) { + const headings = initialHeadings ?? [] + + return function collectHeadings(tree, file: any) { + visit(tree, 'element', (node) => { + if (!isHeading(node)) { + return + } + + const id = + typeof node.properties?.id === 'string' ? node.properties.id : '' + if (!id) { + return + } + + headings.push({ + id, + level: Number(node.tagName.substring(1)), + text: toString(node).trim(), + }) + }) + + if (file) { + file.data.headings = headings + } + } +} diff --git a/src/utils/markdown/plugins/helpers.ts b/src/utils/markdown/plugins/helpers.ts new file mode 100644 index 000000000..51b73921f --- /dev/null +++ b/src/utils/markdown/plugins/helpers.ts @@ -0,0 +1,26 @@ +import { isElement } from 'hast-util-is-element' +import type { Element } from 'hast-util-is-element/lib' + +export const normalizeComponentName = (name: string) => name.toLowerCase() + +export const slugify = (value: string, fallback: string) => { + if (!value) { + return fallback + } + + return ( + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 64) || fallback + ) +} + +export const isHeading = (node: unknown): node is Element => + isElement(node) && /^h[1-6]$/.test(node.tagName) + +export const headingLevel = (node: Element) => Number(node.tagName.substring(1)) diff --git a/src/utils/markdown/plugins/index.ts b/src/utils/markdown/plugins/index.ts new file mode 100644 index 000000000..ee8ef639f --- /dev/null +++ b/src/utils/markdown/plugins/index.ts @@ -0,0 +1,4 @@ +export { rehypeParseCommentComponents } from './parseCommentComponents' +export { rehypeTransformCommentComponents } from './transformCommentComponents' +export { transformTabsComponent } from './transformTabsComponent' +export { type MarkdownHeading, rehypeCollectHeadings } from './collectHeadings' diff --git a/src/utils/markdown/plugins/parseCommentComponents.ts b/src/utils/markdown/plugins/parseCommentComponents.ts new file mode 100644 index 000000000..62d0154e9 --- /dev/null +++ b/src/utils/markdown/plugins/parseCommentComponents.ts @@ -0,0 +1,103 @@ +import { unified } from 'unified' +import rehypeParse from 'rehype-parse' +import { visit } from 'unist-util-visit' + +const COMPONENT_PREFIX = '::' +const START_PREFIX = '::start:' +const END_PREFIX = '::end:' + +const componentParser = unified().use(rehypeParse, { fragment: true }) + +const normalizeComponentName = (name: string) => name.toLowerCase() + +function parseDescriptor(descriptor: string) { + const tree = componentParser.parse(`<${descriptor} />`) + const node = tree.children[0] + if (!node || node.type !== 'element') { + return null + } + + const component = node.tagName + const attributes: Record = {} + const properties = node.properties ?? {} + for (const [key, value] of Object.entries(properties)) { + if (Array.isArray(value)) { + attributes[key] = value.join(' ') + } else if (value != null) { + attributes[key] = String(value) + } + } + + return { component, attributes } +} + +const isCommentNode = (value: unknown) => + Boolean( + value && + typeof value === 'object' && + 'type' in value && + value.type === 'comment', + ) + +export const rehypeParseCommentComponents = () => { + return (tree) => { + visit(tree, 'comment', (node, index, parent) => { + if (!isCommentNode(node) || parent == null || typeof index !== 'number') { + return + } + + const trimmed = node.value.trim() + if (!trimmed.startsWith(COMPONENT_PREFIX)) { + return + } + + const isBlock = trimmed.startsWith(START_PREFIX) + const descriptor = isBlock + ? trimmed.slice(START_PREFIX.length) + : trimmed.slice(COMPONENT_PREFIX.length) + + const parsed = parseDescriptor(descriptor) + if (!parsed) { + return + } + + const componentName = parsed.component + const element = { + type: 'element', + tagName: 'md-comment-component', + properties: { + 'data-component': componentName, + 'data-attributes': JSON.stringify(parsed.attributes ?? {}), + }, + children: [], + } + + if (!isBlock) { + parent.children.splice(index, 1, element) + return [SKIP, index] + } + + let endIndex = -1 + for (let cursor = index + 1; cursor < parent.children.length; cursor++) { + const candidate = parent.children[cursor] + if ( + isCommentNode(candidate) && + candidate.value.trim().toLowerCase() === + `${END_PREFIX}${normalizeComponentName(componentName)}` + ) { + endIndex = cursor + break + } + } + + if (endIndex === -1) { + parent.children.splice(index, 1, element) + return [SKIP, index] + } + + element.children = parent.children.slice(index + 1, endIndex) + parent.children.splice(index, endIndex - index + 1, element) + return [SKIP, index] + }) + } +} diff --git a/src/utils/markdown/plugins/transformCommentComponents.ts b/src/utils/markdown/plugins/transformCommentComponents.ts new file mode 100644 index 000000000..66b592d86 --- /dev/null +++ b/src/utils/markdown/plugins/transformCommentComponents.ts @@ -0,0 +1,23 @@ +import { visit } from 'unist-util-visit' + +import { normalizeComponentName } from './helpers' +import { transformTabsComponent } from './transformTabsComponent' + +export const rehypeTransformCommentComponents = () => { + return (tree) => { + visit(tree, 'element', (node) => { + if (node.tagName !== 'md-comment-component') { + return + } + + const component = String(node.properties?.['data-component'] ?? '') + switch (normalizeComponentName(component)) { + case 'tabs': + transformTabsComponent(node) + break + default: + break + } + }) + } +} diff --git a/src/utils/markdown/plugins/transformTabsComponent.ts b/src/utils/markdown/plugins/transformTabsComponent.ts new file mode 100644 index 000000000..d1ec14c1e --- /dev/null +++ b/src/utils/markdown/plugins/transformTabsComponent.ts @@ -0,0 +1,111 @@ +import { visit } from 'unist-util-visit' +import { toString } from 'hast-util-to-string' + +import { headingLevel, isHeading, slugify } from './helpers' + +type TabDescriptor = { + slug: string + name: string + headers: string[] +} + +type TabExtraction = { + tabs: TabDescriptor[] + panels: Element[][] +} + +function extractTabPanels(node): TabExtraction | null { + const children = node.children ?? [] + const headings = children.filter(isHeading) + + let sectionStarted = false + let largestHeadingLevel = Infinity + headings.forEach((heading) => { + largestHeadingLevel = Math.min(largestHeadingLevel, headingLevel(heading)) + }) + + const tabs: TabDescriptor[] = [] + const panels = [] + let currentPanel = null + + children.forEach((child: any) => { + if (isHeading(child)) { + const level = headingLevel(child) + if (!sectionStarted) { + if (level !== largestHeadingLevel) { + return + } + sectionStarted = true + } + + if (level === largestHeadingLevel) { + if (currentPanel) { + panels.push(currentPanel) + } + + const headingId = + (child.properties?.id && String(child.properties.id)) || + slugify(toString(child), `tab-${tabs.length + 1}`) + + tabs.push({ + slug: headingId, + name: toString(child), + headers: [], + }) + + currentPanel = [] + return + } + } + + if (sectionStarted) { + if (!currentPanel) { + currentPanel = [] + } + currentPanel.push(child) + } + }) + + if (currentPanel) { + panels.push(currentPanel) + } + + if (!tabs.length) { + return null + } + + panels.forEach((panelChildren, index) => { + const nestedHeadings: string[] = [] + visit({ type: 'root', children: panelChildren }, 'element', (child) => { + if (isHeading(child) && typeof child.properties?.id === 'string') { + nestedHeadings.push(String(child.properties.id)) + } + }) + tabs[index]!.headers = nestedHeadings + }) + + return { tabs, panels } +} + +export function transformTabsComponent(node) { + const result = extractTabPanels(node) + if (!result) { + return + } + + const panelElements = result.panels.map((panelChildren, index) => ({ + type: 'element', + tagName: 'md-tab-panel', + properties: { + 'data-tab-slug': result.tabs[index]?.slug ?? `tab-${index + 1}`, + 'data-tab-index': String(index), + }, + children: panelChildren, + })) + + node.properties = { + ...node.properties, + 'data-attributes': JSON.stringify({ tabs: result.tabs }), + } + node.children = panelElements +} diff --git a/src/utils/markdown/processor.ts b/src/utils/markdown/processor.ts index 19a4bb055..7caa3489f 100644 --- a/src/utils/markdown/processor.ts +++ b/src/utils/markdown/processor.ts @@ -9,11 +9,11 @@ import rehypeAutolinkHeadings from 'rehype-autolink-headings' import rehypeStringify from 'rehype-stringify' import { visit } from 'unist-util-visit' import { toString } from 'hast-util-to-string' - import { + rehypeCollectHeadings, rehypeParseCommentComponents, rehypeTransformCommentComponents, -} from './plugins' +} from '~/utils/markdown/plugins' export type MarkdownHeading = { id: string @@ -74,29 +74,7 @@ export function renderMarkdown(content): MarkdownRenderResult { className: ['anchor-heading'], }, }) - .use(() => (tree, file) => { - visit(tree, 'element', (node) => { - if (!('tagName' in node)) return - if (!/^h[1-6]$/.test(String(node.tagName))) { - return - } - - const tagName = String(node.tagName) - const id = - typeof node.properties?.id === 'string' ? node.properties.id : '' - if (!id) { - return - } - - headings.push({ - id, - level: Number(tagName.substring(1)), - text: toString(node).trim(), - }) - }) - - file.data.headings = headings - }) + .use((tree, file) => rehypeCollectHeadings(tree, file, headings)) const file = processor.use(rehypeStringify).processSync(content)