Frontend & UISûr100100/100
Custom Extensions (TanStack)
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.
ou envoie-le directement à ton agent.
Installer dans ton projet
$ curl -fsSL https://arboristhree.xyz/cli/install-skill.mjs -o /tmp/arboris-install-skill.mjs && node /tmp/arboris-install-skill.mjs custom-extensions__tanstack-markdown__skills-283a343b8fe2TanStack
TanStack/markdown
Contenu à copier
---
name: 'custom-extensions'
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.
metadata:
origin: TanStack
type: core
library: '@tanstack/markdown'
library_version: '1.0.0'
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
### 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
```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 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
```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` or `InlineComponentNode` 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 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.Colle ce Markdown dans ton agent ou utilise les boutons ci-dessus pour l’écrire dans ton projet.
Ce que fait Custom Extensions (TanStack)
`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.
Comment utiliser Custom Extensions (TanStack)
1
Copie le prompt
Un clic copie le prompt packagé (ou l'envoie à ton agent).
2
L'agent installe le skill
Il ajoute le SKILL.md et ses ressources à ton projet.
3
Activation automatique
Le skill s'active dès que le contexte correspond.
Déclencheurs pour Custom Extensions (TanStack)
Dis simplement à ton agent quelque chose comme :
Applique le skill custom-extensions__tanstack-markdown__skills-283a343b8fe2 à cette tâche
Utilise custom-extensions__tanstack-markdown__skills-283a343b8fe2 pour améliorer cette implémentation
Passe en revue ce sujet avec custom-extensions__tanstack-markdown__skills-283a343b8fe2
Skills liés à Custom Extensions (TanStack)
accessibility
Design, implement, and audit accessible UI to WCAG 2.2 Level AA across Web, iOS, and Android — semantic ARIA roles and labels, accessibility traits and hints, focus management, contrast, target size, and screen-reader support. Use when building or auditing UI for accessibility compliance, keyboard navigation, or screen-reader support.Accessibility Pass (Microsoft)
Use this skill whenever the user asks to check, review, or fix the accessibility of a PowerPoint deck, Word document, HTML page, or Markdown file, and before handing over any deck or document this agent just generated, so it does not ship with missing alt text, untitled slides, or unreadable colour contrast.Add Connector (Microsoft)
Use when adding a Power Platform connector to an Expo/React Native Power Apps mobile app and no dedicated mobile connector skill exists.