UNPKG

@tanstack/markdown

Version:

A tiny, fast, deterministic Markdown renderer for blogs and documentation.

445 lines (352 loc) 12.6 kB
--- name: 'custom-extensions' description: > Implement MarkdownExtension block parsers, inline and document transforms, HTML hooks, and portable ComponentNode output. Load when adding deterministic custom syntax or rendering behavior across HTML, React, and Octane. metadata: type: core library: '@tanstack/markdown' library_version: '0.0.13' requires: - 'render-markdown' sources: - 'TanStack/markdown:docs/guides/extensions.md' - 'TanStack/markdown:docs/reference/extensions.md' - 'TanStack/markdown:src/types.ts' - 'TanStack/markdown:src/parser.ts' - 'TanStack/markdown:src/extensions/callouts.ts' - 'TanStack/markdown:src/extensions/comment-components.ts' --- This skill builds on `render-markdown`. Read it first for parser options, the document AST, and renderer behavior. # Custom Extensions ## Setup Implement a bounded block parser and return a standard `ComponentNode`: ```ts import type { MarkdownExtension } from '@tanstack/markdown' import { renderHtml } from '@tanstack/markdown/html' import { parseMarkdown } from '@tanstack/markdown/parser' function notesExtension(): MarkdownExtension { return { name: 'notes', parseBlock(context) { const first = context.lines[context.index] ?? '' const opening = first.match(/^:::note(?:\s+(.*))?$/) if (!opening) return undefined const body: string[] = [] let cursor = context.index + 1 while (cursor < context.lines.length && context.lines[cursor] !== ':::') { body.push(context.lines[cursor] ?? '') cursor++ } if (context.lines[cursor] !== ':::') return undefined const title = opening[1]?.trim() || 'Note' context.consume(cursor - context.index + 1) return { type: 'component', name: 'note', tagName: 'docs-note', attributes: { title }, properties: { 'data-title': title }, children: context.parseBlocks(body.join('\n')), } }, } } const source = `:::note Cache the AST Parse once and render many times. :::` const extensions = [notesExtension()] const document = parseMarkdown(source, { extensions }) const html = renderHtml(document, { extensions }) console.log(html) ``` `parseBlock` runs before built-in block parsing, and nested `parseBlocks` shares the parent depth budget and heading slugger. ## Core Patterns ### Transform parsed inline nodes ```ts import type { InlineNode, MarkdownExtension, StrongNode, } from '@tanstack/markdown' import { renderHtml } from '@tanstack/markdown/html' const importantExtension: MarkdownExtension = { name: 'important-inline', transformInline(nodes) { return nodes.map((node): InlineNode => { if (node.type !== 'text' || !node.value.startsWith('IMPORTANT: ')) { return node } const strong: StrongNode = { type: 'strong', children: [{ type: 'text', value: node.value }], } return strong }) }, } const html = renderHtml('IMPORTANT: Back up the database.', { extensions: [importantExtension], }) console.log(html) ``` Transforms receive built-in inline nodes and must return a deterministic replacement array. ### Derive document metadata after parsing ```ts import type { InlineNode, MarkdownExtension, MarkdownHeading, } from '@tanstack/markdown' import { parseMarkdown } from '@tanstack/markdown/parser' function inlineText(nodes: InlineNode[]): string { return nodes .map((node) => { if (node.type === 'text' || node.type === 'inlineCode') return node.value if (node.type === 'image') return node.alt if ('children' in node) return inlineText(node.children) return '' }) .join('') } const topLevelHeadings: MarkdownExtension = { name: 'top-level-headings', transformDocument(document) { const headings: MarkdownHeading[] = document.children.flatMap((node) => node.type === 'heading' && node.id ? [{ id: node.id, text: inlineText(node.children), level: node.depth, }] : [], ) return { ...document, headings } }, } const document = parseMarkdown('# Install\n\n## Configure', { extensions: [topLevelHeadings], }) console.log(document.headings) ``` Document transforms run after blocks and footnotes are complete and may return a new document or mutate the existing one. ### Use an HTML hook only for HTML output ```ts import type { MarkdownExtension } from '@tanstack/markdown' import { calloutsExtension } from '@tanstack/markdown/extensions/callouts' import { renderHtml } from '@tanstack/markdown/html' const compactCalloutHtml: MarkdownExtension = { name: 'compact-callout-html', renderHtml(node, context) { if (node.type !== 'callout') return undefined const children = node.children.map(context.renderBlock).join('\n') return `<aside class="compact-callout">${children}</aside>` }, } const extensions = [calloutsExtension(), compactCalloutHtml] const html = renderHtml('> [!NOTE]\n> Cached.', { extensions }) console.log(html) ``` The returned string is trusted and HTML-specific; nested standard nodes remain escaped because they use `context.renderBlock`. ### Emit portable custom elements ```ts import { commentComponentsExtension } from '@tanstack/markdown/extensions/comment-components' import { renderHtml } from '@tanstack/markdown/html' const panels = commentComponentsExtension({ transformComponent(node) { if (node.name !== 'panel') return node return { ...node, tagName: 'docs-panel', properties: { 'data-kind': node.attributes.kind ?? 'note', }, } }, }) const source = `<!-- ::start:panel kind="warning" --> Check the migration before deploying. <!-- ::end:panel -->` const html = renderHtml(source, { extensions: [panels] }) console.log(html) ``` React and Octane can replace the emitted `docs-panel` tag through their `components` maps. ## Common Mistakes ### HIGH Claiming a block without consuming it Wrong: ```ts import type { MarkdownExtension } from '@tanstack/markdown' import { renderHtml } from '@tanstack/markdown/html' const brokenNotes: MarkdownExtension = { name: 'broken-notes', parseBlock(context) { if (context.lines[context.index] !== ':::note') return undefined return { type: 'paragraph', children: context.parseInline(context.lines[context.index + 1] ?? ''), } }, } console.log(renderHtml(':::note\nCached.\n:::', { extensions: [brokenNotes], })) ``` Correct: ```ts import type { MarkdownExtension } from '@tanstack/markdown' import { renderHtml } from '@tanstack/markdown/html' const notes: MarkdownExtension = { name: 'notes', parseBlock(context) { if (context.lines[context.index] !== ':::note') return undefined const closing = context.lines.indexOf(':::', context.index + 1) if (closing === -1) return undefined const body = context.lines.slice(context.index + 1, closing).join('\n') context.consume(closing - context.index + 1) return { type: 'component', name: 'note', attributes: {}, children: context.parseBlocks(body), } }, } console.log(renderHtml(':::note\nCached.\n:::', { extensions: [notes], })) ``` Returning a node advances only one line unless `consume` records the complete owned block. Source: `docs/guides/extensions.md` ### HIGH Ordering a general parser first Wrong: ```ts import type { MarkdownExtension } from '@tanstack/markdown' import { calloutsExtension } from '@tanstack/markdown/extensions/callouts' import { renderHtml } from '@tanstack/markdown/html' const quotedLine: MarkdownExtension = { name: 'quoted-line', parseBlock(context) { const match = (context.lines[context.index] ?? '').match(/^>\s?(.*)$/) if (!match) return undefined context.consume(1) return { type: 'paragraph', children: context.parseInline(match[1] ?? '') } }, } console.log(renderHtml('> [!TIP]\n> Cache it.', { extensions: [quotedLine, calloutsExtension()], })) ``` Correct: ```ts import type { MarkdownExtension } from '@tanstack/markdown' import { calloutsExtension } from '@tanstack/markdown/extensions/callouts' import { renderHtml } from '@tanstack/markdown/html' const quotedLine: MarkdownExtension = { name: 'quoted-line', parseBlock(context) { const match = (context.lines[context.index] ?? '').match(/^>\s?(.*)$/) if (!match) return undefined context.consume(1) return { type: 'paragraph', children: context.parseInline(match[1] ?? '') } }, } console.log(renderHtml('> [!TIP]\n> Cache it.', { extensions: [calloutsExtension(), quotedLine], })) ``` Extensions run in array order, so the broad quote parser can hide callout syntax from the specific parser. Source: `docs/guides/extensions.md` ### HIGH Using HTML hooks for framework nodes Wrong: ```tsx import type { MarkdownExtension } from '@tanstack/markdown' import { Markdown } from '@tanstack/markdown/react' const htmlOnly: MarkdownExtension = { name: 'html-only', renderHtml(node, context) { if (node.type !== 'paragraph') return undefined return `<aside>${node.children.map(context.renderInline).join('')}</aside>` }, } export function Article() { return <Markdown extensions={[htmlOnly]}>Framework output</Markdown> } ``` Correct: ```tsx import { commentComponentsExtension } from '@tanstack/markdown/extensions/comment-components' import { Markdown } from '@tanstack/markdown/react' import type { ComponentProps } from 'react' const components = commentComponentsExtension({ transformComponent(node) { return node.name === 'panel' ? { ...node, tagName: 'docs-panel' } : node }, }) function Panel(props: ComponentProps<'aside'>) { return <aside {...props} /> } export function Article() { return ( <Markdown extensions={[components]} components={{ 'docs-panel': Panel }} > {'<!-- ::start:panel -->\nPortable output\n<!-- ::end:panel -->'} </Markdown> ) } ``` `renderHtml` hooks do not run in React or Octane; a `ComponentNode` and emitted-tag component mapping is the portable path. Source: `docs/guides/extensions.md` ### MEDIUM Changing extensions after parsing Wrong: ```ts import type { MarkdownExtension } from '@tanstack/markdown' import { renderHtml } from '@tanstack/markdown/html' import { parseMarkdown } from '@tanstack/markdown/parser' const emphasisHtml: MarkdownExtension = { name: 'emphasis-html', renderHtml(node, context) { if (node.type !== 'emphasis') return undefined return `<i class="accent">${node.children.map(context.renderInline).join('')}</i>` }, } const document = parseMarkdown('_Important_', { extensions: [emphasisHtml], }) console.log(renderHtml(document)) ``` Correct: ```ts import type { MarkdownExtension } from '@tanstack/markdown' import { renderHtml } from '@tanstack/markdown/html' import { parseMarkdown } from '@tanstack/markdown/parser' const emphasisHtml: MarkdownExtension = { name: 'emphasis-html', renderHtml(node, context) { if (node.type !== 'emphasis') return undefined return `<i class="accent">${node.children.map(context.renderInline).join('')}</i>` }, } const extensions = [emphasisHtml] const document = parseMarkdown('_Important_', { extensions }) console.log(renderHtml(document, { extensions })) ``` Document transforms persist in the AST, but HTML render hooks require the extension again at render time. Source: `docs/guides/extensions.md` ## Tensions and Boundaries ### HIGH Rich output versus untrusted-content safety Prefer `ComponentNode` plus application components for rich output. Treat `allowHtml`, extension HTML strings, and highlighter markup as explicit trusted boundaries; see `production-pipelines`. ### MEDIUM Parse-ahead performance versus option timing Apply parser options and document-transform extensions before caching a `MarkdownDocument`. Renderer-time options cannot rebuild missing parse behavior; see `render-markdown` and `production-pipelines`. ### HIGH Renderer parity versus customization Core nodes stay equivalent across HTML, React, and Octane. HTML hooks and framework component replacements intentionally leave that parity boundary; see `react-rendering` and `octane-rendering`. ## Related Skills - `render-markdown` for the AST, parser options, and standard renderers. - `docs-features` for first-party extension implementations and metadata contracts. - `react-rendering` for React mappings of emitted component tags. - `octane-rendering` for Octane `ComponentBody` mappings. - `production-pipelines` for trust, compatibility, and bundle audits.