@tanstack/markdown 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,444 @@
1
+ ---
2
+ name: 'custom-extensions'
3
+ description: >
4
+ Implement MarkdownExtension block parsers, inline and document transforms,
5
+ HTML hooks, and portable ComponentNode output. Load when adding deterministic
6
+ custom syntax or rendering behavior across HTML, React, and Octane.
7
+ metadata:
8
+ type: core
9
+ library: '@tanstack/markdown'
10
+ library_version: '0.0.10'
11
+ requires:
12
+ - 'render-markdown'
13
+ sources:
14
+ - 'TanStack/markdown:docs/guides/extensions.md'
15
+ - 'TanStack/markdown:docs/reference/extensions.md'
16
+ - 'TanStack/markdown:src/types.ts'
17
+ - 'TanStack/markdown:src/parser.ts'
18
+ - 'TanStack/markdown:src/extensions/callouts.ts'
19
+ - 'TanStack/markdown:src/extensions/comment-components.ts'
20
+ ---
21
+
22
+ This skill builds on `render-markdown`. Read it first for parser options, the document AST, and renderer behavior.
23
+
24
+ # Custom Extensions
25
+
26
+ ## Setup
27
+
28
+ Implement a bounded block parser and return a standard `ComponentNode`:
29
+
30
+ ```ts
31
+ import type { MarkdownExtension } from '@tanstack/markdown'
32
+ import { renderHtml } from '@tanstack/markdown/html'
33
+ import { parseMarkdown } from '@tanstack/markdown/parser'
34
+
35
+ function notesExtension(): MarkdownExtension {
36
+ return {
37
+ name: 'notes',
38
+ parseBlock(context) {
39
+ const first = context.lines[context.index] ?? ''
40
+ const opening = first.match(/^:::note(?:\s+(.*))?$/)
41
+ if (!opening) return undefined
42
+
43
+ const body: string[] = []
44
+ let cursor = context.index + 1
45
+ while (cursor < context.lines.length && context.lines[cursor] !== ':::') {
46
+ body.push(context.lines[cursor] ?? '')
47
+ cursor++
48
+ }
49
+ if (context.lines[cursor] !== ':::') return undefined
50
+
51
+ const title = opening[1]?.trim() || 'Note'
52
+ context.consume(cursor - context.index + 1)
53
+ return {
54
+ type: 'component',
55
+ name: 'note',
56
+ tagName: 'docs-note',
57
+ attributes: { title },
58
+ properties: { 'data-title': title },
59
+ children: context.parseBlocks(body.join('\n')),
60
+ }
61
+ },
62
+ }
63
+ }
64
+
65
+ const source = `:::note Cache the AST
66
+ Parse once and render many times.
67
+ :::`
68
+ const extensions = [notesExtension()]
69
+ const document = parseMarkdown(source, { extensions })
70
+ const html = renderHtml(document, { extensions })
71
+
72
+ console.log(html)
73
+ ```
74
+
75
+ `parseBlock` runs before built-in block parsing, and nested `parseBlocks` shares the parent depth budget and heading slugger.
76
+
77
+ ## Core Patterns
78
+
79
+ ### Transform parsed inline nodes
80
+
81
+ ```ts
82
+ import type {
83
+ InlineNode,
84
+ MarkdownExtension,
85
+ StrongNode,
86
+ } from '@tanstack/markdown'
87
+ import { renderHtml } from '@tanstack/markdown/html'
88
+
89
+ const importantExtension: MarkdownExtension = {
90
+ name: 'important-inline',
91
+ transformInline(nodes) {
92
+ return nodes.map((node): InlineNode => {
93
+ if (node.type !== 'text' || !node.value.startsWith('IMPORTANT: ')) {
94
+ return node
95
+ }
96
+ const strong: StrongNode = {
97
+ type: 'strong',
98
+ children: [{ type: 'text', value: node.value }],
99
+ }
100
+ return strong
101
+ })
102
+ },
103
+ }
104
+
105
+ const html = renderHtml('IMPORTANT: Back up the database.', {
106
+ extensions: [importantExtension],
107
+ })
108
+
109
+ console.log(html)
110
+ ```
111
+
112
+ Transforms receive built-in inline nodes and must return a deterministic replacement array.
113
+
114
+ ### Derive document metadata after parsing
115
+
116
+ ```ts
117
+ import type {
118
+ InlineNode,
119
+ MarkdownExtension,
120
+ MarkdownHeading,
121
+ } from '@tanstack/markdown'
122
+ import { parseMarkdown } from '@tanstack/markdown/parser'
123
+
124
+ function inlineText(nodes: InlineNode[]): string {
125
+ return nodes
126
+ .map((node) => {
127
+ if (node.type === 'text' || node.type === 'inlineCode') return node.value
128
+ if (node.type === 'image') return node.alt
129
+ if ('children' in node) return inlineText(node.children)
130
+ return ''
131
+ })
132
+ .join('')
133
+ }
134
+
135
+ const topLevelHeadings: MarkdownExtension = {
136
+ name: 'top-level-headings',
137
+ transformDocument(document) {
138
+ const headings: MarkdownHeading[] = document.children.flatMap((node) =>
139
+ node.type === 'heading' && node.id
140
+ ? [{
141
+ id: node.id,
142
+ text: inlineText(node.children),
143
+ level: node.depth,
144
+ }]
145
+ : [],
146
+ )
147
+ return { ...document, headings }
148
+ },
149
+ }
150
+
151
+ const document = parseMarkdown('# Install\n\n## Configure', {
152
+ extensions: [topLevelHeadings],
153
+ })
154
+
155
+ console.log(document.headings)
156
+ ```
157
+
158
+ Document transforms run after blocks and footnotes are complete and may return a new document or mutate the existing one.
159
+
160
+ ### Use an HTML hook only for HTML output
161
+
162
+ ```ts
163
+ import type { MarkdownExtension } from '@tanstack/markdown'
164
+ import { calloutsExtension } from '@tanstack/markdown/extensions/callouts'
165
+ import { renderHtml } from '@tanstack/markdown/html'
166
+
167
+ const compactCalloutHtml: MarkdownExtension = {
168
+ name: 'compact-callout-html',
169
+ renderHtml(node, context) {
170
+ if (node.type !== 'callout') return undefined
171
+ const children = node.children.map(context.renderBlock).join('\n')
172
+ return `<aside class="compact-callout">${children}</aside>`
173
+ },
174
+ }
175
+
176
+ const extensions = [calloutsExtension(), compactCalloutHtml]
177
+ const html = renderHtml('> [!NOTE]\n> Cached.', { extensions })
178
+
179
+ console.log(html)
180
+ ```
181
+
182
+ The returned string is trusted and HTML-specific; nested standard nodes remain escaped because they use `context.renderBlock`.
183
+
184
+ ### Emit portable custom elements
185
+
186
+ ```ts
187
+ import { commentComponentsExtension } from '@tanstack/markdown/extensions/comment-components'
188
+ import { renderHtml } from '@tanstack/markdown/html'
189
+
190
+ const panels = commentComponentsExtension({
191
+ transformComponent(node) {
192
+ if (node.name !== 'panel') return node
193
+ return {
194
+ ...node,
195
+ tagName: 'docs-panel',
196
+ properties: {
197
+ 'data-kind': node.attributes.kind ?? 'note',
198
+ },
199
+ }
200
+ },
201
+ })
202
+
203
+ const source = `<!-- ::start:panel kind="warning" -->
204
+ Check the migration before deploying.
205
+ <!-- ::end:panel -->`
206
+ const html = renderHtml(source, { extensions: [panels] })
207
+
208
+ console.log(html)
209
+ ```
210
+
211
+ React and Octane can replace the emitted `docs-panel` tag through their `components` maps.
212
+
213
+ ## Common Mistakes
214
+
215
+ ### HIGH Claiming a block without consuming it
216
+
217
+ Wrong:
218
+
219
+ ```ts
220
+ import type { MarkdownExtension } from '@tanstack/markdown'
221
+ import { renderHtml } from '@tanstack/markdown/html'
222
+
223
+ const brokenNotes: MarkdownExtension = {
224
+ name: 'broken-notes',
225
+ parseBlock(context) {
226
+ if (context.lines[context.index] !== ':::note') return undefined
227
+ return {
228
+ type: 'paragraph',
229
+ children: context.parseInline(context.lines[context.index + 1] ?? ''),
230
+ }
231
+ },
232
+ }
233
+
234
+ console.log(renderHtml(':::note\nCached.\n:::', {
235
+ extensions: [brokenNotes],
236
+ }))
237
+ ```
238
+
239
+ Correct:
240
+
241
+ ```ts
242
+ import type { MarkdownExtension } from '@tanstack/markdown'
243
+ import { renderHtml } from '@tanstack/markdown/html'
244
+
245
+ const notes: MarkdownExtension = {
246
+ name: 'notes',
247
+ parseBlock(context) {
248
+ if (context.lines[context.index] !== ':::note') return undefined
249
+ const closing = context.lines.indexOf(':::', context.index + 1)
250
+ if (closing === -1) return undefined
251
+ const body = context.lines.slice(context.index + 1, closing).join('\n')
252
+ context.consume(closing - context.index + 1)
253
+ return {
254
+ type: 'component',
255
+ name: 'note',
256
+ attributes: {},
257
+ children: context.parseBlocks(body),
258
+ }
259
+ },
260
+ }
261
+
262
+ console.log(renderHtml(':::note\nCached.\n:::', {
263
+ extensions: [notes],
264
+ }))
265
+ ```
266
+
267
+ Returning a node advances only one line unless `consume` records the complete owned block.
268
+
269
+ Source: `docs/guides/extensions.md`
270
+
271
+ ### HIGH Ordering a general parser first
272
+
273
+ Wrong:
274
+
275
+ ```ts
276
+ import type { MarkdownExtension } from '@tanstack/markdown'
277
+ import { calloutsExtension } from '@tanstack/markdown/extensions/callouts'
278
+ import { renderHtml } from '@tanstack/markdown/html'
279
+
280
+ const quotedLine: MarkdownExtension = {
281
+ name: 'quoted-line',
282
+ parseBlock(context) {
283
+ const match = (context.lines[context.index] ?? '').match(/^>\s?(.*)$/)
284
+ if (!match) return undefined
285
+ context.consume(1)
286
+ return { type: 'paragraph', children: context.parseInline(match[1] ?? '') }
287
+ },
288
+ }
289
+
290
+ console.log(renderHtml('> [!TIP]\n> Cache it.', {
291
+ extensions: [quotedLine, calloutsExtension()],
292
+ }))
293
+ ```
294
+
295
+ Correct:
296
+
297
+ ```ts
298
+ import type { MarkdownExtension } from '@tanstack/markdown'
299
+ import { calloutsExtension } from '@tanstack/markdown/extensions/callouts'
300
+ import { renderHtml } from '@tanstack/markdown/html'
301
+
302
+ const quotedLine: MarkdownExtension = {
303
+ name: 'quoted-line',
304
+ parseBlock(context) {
305
+ const match = (context.lines[context.index] ?? '').match(/^>\s?(.*)$/)
306
+ if (!match) return undefined
307
+ context.consume(1)
308
+ return { type: 'paragraph', children: context.parseInline(match[1] ?? '') }
309
+ },
310
+ }
311
+
312
+ console.log(renderHtml('> [!TIP]\n> Cache it.', {
313
+ extensions: [calloutsExtension(), quotedLine],
314
+ }))
315
+ ```
316
+
317
+ Extensions run in array order, so the broad quote parser can hide callout syntax from the specific parser.
318
+
319
+ Source: `docs/guides/extensions.md`
320
+
321
+ ### HIGH Using HTML hooks for framework nodes
322
+
323
+ Wrong:
324
+
325
+ ```tsx
326
+ import type { MarkdownExtension } from '@tanstack/markdown'
327
+ import { Markdown } from '@tanstack/markdown/react'
328
+
329
+ const htmlOnly: MarkdownExtension = {
330
+ name: 'html-only',
331
+ renderHtml(node, context) {
332
+ if (node.type !== 'paragraph') return undefined
333
+ return `<aside>${node.children.map(context.renderInline).join('')}</aside>`
334
+ },
335
+ }
336
+
337
+ export function Article() {
338
+ return <Markdown extensions={[htmlOnly]}>Framework output</Markdown>
339
+ }
340
+ ```
341
+
342
+ Correct:
343
+
344
+ ```tsx
345
+ import { commentComponentsExtension } from '@tanstack/markdown/extensions/comment-components'
346
+ import { Markdown } from '@tanstack/markdown/react'
347
+ import type { ComponentProps } from 'react'
348
+
349
+ const components = commentComponentsExtension({
350
+ transformComponent(node) {
351
+ return node.name === 'panel'
352
+ ? { ...node, tagName: 'docs-panel' }
353
+ : node
354
+ },
355
+ })
356
+
357
+ function Panel(props: ComponentProps<'aside'>) {
358
+ return <aside {...props} />
359
+ }
360
+
361
+ export function Article() {
362
+ return (
363
+ <Markdown
364
+ extensions={[components]}
365
+ components={{ 'docs-panel': Panel }}
366
+ >
367
+ {'<!-- ::start:panel -->\nPortable output\n<!-- ::end:panel -->'}
368
+ </Markdown>
369
+ )
370
+ }
371
+ ```
372
+
373
+ `renderHtml` hooks do not run in React or Octane; a `ComponentNode` and emitted-tag component mapping is the portable path.
374
+
375
+ Source: `docs/guides/extensions.md`
376
+
377
+ ### MEDIUM Changing extensions after parsing
378
+
379
+ Wrong:
380
+
381
+ ```ts
382
+ import type { MarkdownExtension } from '@tanstack/markdown'
383
+ import { renderHtml } from '@tanstack/markdown/html'
384
+ import { parseMarkdown } from '@tanstack/markdown/parser'
385
+
386
+ const emphasisHtml: MarkdownExtension = {
387
+ name: 'emphasis-html',
388
+ renderHtml(node, context) {
389
+ if (node.type !== 'emphasis') return undefined
390
+ return `<i class="accent">${node.children.map(context.renderInline).join('')}</i>`
391
+ },
392
+ }
393
+
394
+ const document = parseMarkdown('_Important_', {
395
+ extensions: [emphasisHtml],
396
+ })
397
+ console.log(renderHtml(document))
398
+ ```
399
+
400
+ Correct:
401
+
402
+ ```ts
403
+ import type { MarkdownExtension } from '@tanstack/markdown'
404
+ import { renderHtml } from '@tanstack/markdown/html'
405
+ import { parseMarkdown } from '@tanstack/markdown/parser'
406
+
407
+ const emphasisHtml: MarkdownExtension = {
408
+ name: 'emphasis-html',
409
+ renderHtml(node, context) {
410
+ if (node.type !== 'emphasis') return undefined
411
+ return `<i class="accent">${node.children.map(context.renderInline).join('')}</i>`
412
+ },
413
+ }
414
+
415
+ const extensions = [emphasisHtml]
416
+ const document = parseMarkdown('_Important_', { extensions })
417
+ console.log(renderHtml(document, { extensions }))
418
+ ```
419
+
420
+ Document transforms persist in the AST, but HTML render hooks require the extension again at render time.
421
+
422
+ Source: `docs/guides/extensions.md`
423
+
424
+ ## Tensions and Boundaries
425
+
426
+ ### HIGH Rich output versus untrusted-content safety
427
+
428
+ Prefer `ComponentNode` plus application components for rich output. Treat `allowHtml`, extension HTML strings, and highlighter markup as explicit trusted boundaries; see `production-pipelines`.
429
+
430
+ ### MEDIUM Parse-ahead performance versus option timing
431
+
432
+ 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`.
433
+
434
+ ### HIGH Renderer parity versus customization
435
+
436
+ 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`.
437
+
438
+ ## Related Skills
439
+
440
+ - `render-markdown` for the AST, parser options, and standard renderers.
441
+ - `docs-features` for first-party extension implementations and metadata contracts.
442
+ - `react-rendering` for React mappings of emitted component tags.
443
+ - `octane-rendering` for Octane `ComponentBody` mappings.
444
+ - `production-pipelines` for trust, compatibility, and bundle audits.