custom-extensions
GitHub基于 TanStack Markdown 实现自定义扩展,支持解析块级/行内源语法、转换 AST 节点及渲染 HTML/React 组件。用于在文档处理中注入确定性定制语法和渲染行为。
Trigger Scenarios
Install
npx skills add TanStack/markdown --skill custom-extensions -g -y
SKILL.md
Frontmatter
{
"name": "custom-extensions",
"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"
],
"metadata": {
"type": "core",
"library": "@tanstack\/markdown",
"library_version": "1.0.0"
},
"requires": [
"render-markdown"
],
"description": "Implement MarkdownExtension block and inline source parsers, inline and document transforms, HTML hooks, and portable block and inline component output. Load when adding deterministic custom syntax or rendering behavior across HTML, React, and Octane.\n"
}
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:
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
Recognize inline source before formatting
Use inlineParser: { markers, parse(context) } for syntax that needs original source characters. markers is a string of literal first characters. The context supplies source, UTF-16 index, options, inLink, and parseInline(value) sharing the current depth and scan budget. Return { node, length } with one standard InlineNode and a positive in-bounds integer length, or undefined to decline. Invalid lengths throw RangeError.
Escapes and code spans take precedence. Hooks do not run in code, image alt text, or link destinations; they do run in emphasis and link labels. Respect inLink when creating automatic links. Indices are local to the current inline container, and ranges cannot cross enclosing inline or block boundaries. Hook dispatch is budgeted, but callback work is trusted and must avoid repeated suffix scans. Returned AST nodes are trusted; validate URLs and component metadata yourself.
Use transformInline for changes to already-parsed nodes. It cannot distinguish an escaped opener from an ordinary text opener or restore raw Markdown spelling. See the extension guide for a complete source-parser example.
Transform parsed inline nodes
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 both built-in inline nodes and extension nodes returned by inlineParser, and must return a deterministic replacement array.
For custom inline UI, return an InlineComponentNode with type: 'inlineComponent',
name, attributes, inline children, and optional tagName and string properties.
It uses the same emitted-tag component maps as a block ComponentNode, but defaults
to <span> instead of <md-comment-component>. Keep tag and property names under
extension control, use phrasing content, and recurse through inline children when
transforming text nested inside links or emphasis. This does not require allowHtml.
Derive document metadata after parsing
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
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
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:
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:
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:
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:
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:
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:
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 or InlineComponentNode and emitted-tag component mapping is the portable path.
Source: docs/guides/extensions.md
MEDIUM Changing extensions after parsing
Wrong:
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:
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 block or inline component nodes 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-markdownfor the AST, parser options, and standard renderers.docs-featuresfor first-party extension implementations and metadata contracts.react-renderingfor React mappings of emitted component tags.octane-renderingfor OctaneComponentBodymappings.production-pipelinesfor trust, compatibility, and bundle audits.
Version History
-
d9b5ef7
Current 2026-10-18 16:20
新增行内源码解析器以识别格式化前的原始字符,并澄清了行内转换的输入说明。
- 6936a01 2026-09-21 23:54


