Agent SkillsTanStack/markdown › custom-extensions

custom-extensions

GitHub

实现 Markdown 自定义扩展,支持块级解析、内联转换及 HTML/React 组件输出。用于添加确定性语法或渲染行为,构建可移植的文档处理逻辑。

skills/custom-extensions/SKILL.md TanStack/markdown

Trigger Scenarios

需要解析自定义 Markdown 语法 实现 Markdown 到 HTML 或 React 组件的转换 修改 Markdown AST 节点结构

Install

npx skills add TanStack/markdown --skill custom-extensions -g -y
More Options

Use without installing

npx skills use TanStack/markdown@custom-extensions

指定 Agent (Claude Code)

npx skills add TanStack/markdown --skill custom-extensions -a claude-code -g -y

安装 repo 全部 skill

npx skills add TanStack/markdown --all -g -y

预览 repo 内 skill

npx skills add TanStack/markdown --list

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": "0.0.15"
    },
    "requires": [
        "render-markdown"
    ],
    "description": "Implement MarkdownExtension block 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

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 built-in inline nodes 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-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.

Version History

  • 6936a01 Current 2026-09-21 23:54

Same Skill Collection

skills/docs-features/SKILL.md
skills/octane-rendering/SKILL.md
skills/production-pipelines/SKILL.md
skills/react-rendering/SKILL.md
skills/render-markdown/SKILL.md

Metadata

Files
0
Version
6936a01
Hash
33e29db1
Indexed
2026-09-21 23:54

Accueil - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-09-22 00:29
浙ICP备14020137号-1