@tanstack/markdown 0.0.6 → 0.0.8

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.
package/README.md CHANGED
@@ -1,36 +1,73 @@
1
1
  # TanStack Markdown
2
2
 
3
- A tiny deterministic TanStack-flavored Markdown renderer.
3
+ A tiny, fast, deterministic Markdown parser and renderer for blogs and documentation.
4
+
5
+ - 4.6 KB gzip parser
6
+ - 6.4 KB gzip HTML renderer
7
+ - 6.3 KB gzip React adapter
8
+ - 6.3 KB gzip Octane adapter
9
+ - zero runtime dependencies
10
+ - serializable AST
11
+ - safe defaults for raw HTML and executable URLs
12
+ - optional docs extensions and external syntax highlighting
4
13
 
5
14
  ```bash
6
15
  pnpm add @tanstack/markdown
7
16
  ```
8
17
 
9
- ## Commands
18
+ ```ts
19
+ import { renderHtml } from '@tanstack/markdown/html'
20
+
21
+ const html = renderHtml('# Fast by default')
22
+ ```
23
+
24
+ ```tsx
25
+ import { Markdown } from '@tanstack/markdown/react'
26
+
27
+ export function Article({ source }: { source: string }) {
28
+ return <Markdown>{source}</Markdown>
29
+ }
30
+ ```
31
+
32
+ ```tsrx
33
+ import { Markdown } from '@tanstack/markdown/octane'
34
+
35
+ export function Article({ source }: { source: string }) @{
36
+ <Markdown>{source}</Markdown>
37
+ }
38
+ ```
39
+
40
+ TanStack Markdown targets controlled technical content. It supports the Markdown used by blogs and docs, then spends its remaining complexity budget on deterministic output, renderer parity, malformed-input resilience, and small entry points. It is intentionally not a complete CommonMark, GFM, MDX, or general content-processing implementation.
41
+
42
+ ## Documentation
43
+
44
+ - [Overview](./docs/overview.md)
45
+ - [Installation](./docs/installation.md)
46
+ - [Quick Start](./docs/quick-start.md)
47
+ - [Comparison](./docs/comparison.md)
48
+ - [Syntax Profile](./docs/core-concepts/syntax-profile.md)
49
+ - [React Guide](./docs/guides/react.md)
50
+ - [Octane Guide](./docs/guides/octane.md)
51
+ - [Syntax Highlighting](./docs/guides/syntax-highlighting.md)
52
+ - [Extensions](./docs/guides/extensions.md)
53
+ - [API Reference](./docs/reference/index.md)
54
+ - [Architecture](./docs/project/architecture.md)
55
+
56
+ ## Verification
10
57
 
11
58
  ```bash
12
- pnpm test
13
- pnpm run typecheck
14
- pnpm run build
15
- pnpm run research
59
+ pnpm run verify
16
60
  ```
17
61
 
18
- ## Entry Points
62
+ To include downstream content in the corpus gate:
19
63
 
20
- - `src/parser.ts`: Markdown subset parser to a serializable AST
21
- - `src/html.ts`: HTML string renderer
22
- - `src/react.ts`: React adapter
23
- - `src/extensions/*`: optional docs-site extension functions
24
- - `scripts/measure-size.ts`: bundle size matrix
25
- - `scripts/bench.ts`: CPU benchmark matrix
64
+ ```bash
65
+ MARKDOWN_CORPUS_DIRS=../tanstack.com/src/blog:../tanstack.com/docs \
66
+ pnpm run test:corpus
67
+ ```
26
68
 
27
- ## Reports
69
+ Generated reports:
28
70
 
29
71
  - [Bundle sizes](./reports/sizes.md)
30
72
  - [Benchmarks](./reports/benchmarks.md)
31
- - [Audit](./docs/audit.md)
32
- - [Spec and API](./docs/spec.md)
33
- - [Goal and coverage map](./docs/goal.md)
34
- - [Bundle strategy](./docs/bundle-strategy.md)
35
- - [Extensions](./docs/extensions.md)
36
- - [Recommendation](./docs/recommendation.md)
73
+ - [CommonMark compatibility accounting](./reports/conformance.md)
@@ -1 +1 @@
1
- {"version":3,"file":"html.d.ts","sourceRoot":"","sources":["../src/html.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAyD,UAAU,EAAE,gBAAgB,EAAE,aAAa,EAAE,aAAa,EAAiB,MAAM,YAAY,CAAA;AAG7K,wBAAgB,UAAU,CAAC,KAAK,EAAE,aAAa,EAAE,OAAO,GAAE,aAAkB,GAAG,MAAM,CAGpF;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,GAAE,aAAkB,GAAG,MAAM,CAwChF;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,GAAE,aAAkB,GAAG,MAAM,CA0BlF;AAED,wBAAgB,cAAc,CAAC,QAAQ,EAAE,gBAAgB,EAAE,OAAO,GAAE,aAAkB,GAAG,MAAM,CAE9F"}
1
+ {"version":3,"file":"html.d.ts","sourceRoot":"","sources":["../src/html.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAyD,UAAU,EAAE,gBAAgB,EAAE,aAAa,EAAE,aAAa,EAAiB,MAAM,YAAY,CAAA;AAG7K,wBAAgB,UAAU,CAAC,KAAK,EAAE,aAAa,EAAE,OAAO,GAAE,aAAkB,GAAG,MAAM,CAGpF;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,GAAE,aAAkB,GAAG,MAAM,CA0ChF;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,GAAE,aAAkB,GAAG,MAAM,CA0BlF;AAED,wBAAgB,cAAc,CAAC,QAAQ,EAAE,gBAAgB,EAAE,OAAO,GAAE,aAAkB,GAAG,MAAM,CAE9F"}
package/dist/html.js CHANGED
@@ -28,9 +28,11 @@ export function renderBlock(node, options = {}) {
28
28
  return `<blockquote>\n${node.children.map(child => renderBlock(child, options)).join('\n')}\n</blockquote>`;
29
29
  case 'table': {
30
30
  const header = `<thead><tr>${node.header.map((cell, index) => renderTableCell('th', cell, node.align[index], options)).join('')}</tr></thead>`;
31
- const body = `<tbody>${node.rows
32
- .map(row => `<tr>${row.map((cell, index) => renderTableCell('td', cell, node.align[index], options)).join('')}</tr>`)
33
- .join('')}</tbody>`;
31
+ const body = node.rows.length
32
+ ? `<tbody>${node.rows
33
+ .map(row => `<tr>${row.map((cell, index) => renderTableCell('td', cell, node.align[index], options)).join('')}</tr>`)
34
+ .join('')}</tbody>`
35
+ : '';
34
36
  return `<table>${header}${body}</table>`;
35
37
  }
36
38
  case 'footnotes':
package/dist/inline.js CHANGED
@@ -7,7 +7,12 @@ export function parseInline(value, options = {}) {
7
7
  }
8
8
  return result;
9
9
  }
10
- function parseInlineRaw(value, options) {
10
+ const maxInlineDepth = 32;
11
+ const scansPerCharacter = 16;
12
+ function parseInlineRaw(value, options, budget = { scans: Math.max(value.length * scansPerCharacter, 1024), depth: 0 }) {
13
+ if (budget.depth >= maxInlineDepth)
14
+ return value ? [{ type: 'text', value }] : [];
15
+ budget.depth++;
11
16
  const nodes = [];
12
17
  let index = 0;
13
18
  let text = '';
@@ -35,7 +40,7 @@ function parseInlineRaw(value, options) {
35
40
  }
36
41
  if (char === '`') {
37
42
  const tickCount = countRun(value, index, '`');
38
- const close = findClosingRun(value, index + tickCount, '`', tickCount);
43
+ const close = findClosingRun(value, index + tickCount, '`', tickCount, budget);
39
44
  if (close !== -1) {
40
45
  pushText();
41
46
  nodes.push({
@@ -45,15 +50,18 @@ function parseInlineRaw(value, options) {
45
50
  index = close + tickCount;
46
51
  continue;
47
52
  }
53
+ text += value.slice(index, index + tickCount);
54
+ index += tickCount;
55
+ continue;
48
56
  }
49
57
  if (char === '!' && next === '[') {
50
- const parsed = parseLinkish(value, index + 1, options);
58
+ const parsed = parseLinkish(value, index + 1, options, budget);
51
59
  if (parsed) {
52
60
  pushText();
53
61
  nodes.push({
54
62
  type: 'image',
55
63
  src: sanitizeUrl(parsed.href),
56
- alt: textFromMarkdown(parsed.label),
64
+ alt: textFromMarkdown(parsed.label, budget),
57
65
  ...(parsed.title ? { title: parsed.title } : {}),
58
66
  });
59
67
  index = parsed.end;
@@ -61,14 +69,14 @@ function parseInlineRaw(value, options) {
61
69
  }
62
70
  }
63
71
  if (char === '[') {
64
- const footnote = parseFootnoteReference(value, index, options);
72
+ const footnote = parseFootnoteReference(value, index, options, budget);
65
73
  if (footnote) {
66
74
  pushText();
67
75
  nodes.push(footnote.node);
68
76
  index = footnote.end;
69
77
  continue;
70
78
  }
71
- const parsed = parseLinkish(value, index, options);
79
+ const parsed = parseLinkish(value, index, options, budget);
72
80
  if (parsed) {
73
81
  const href = sanitizeUrl(parsed.href);
74
82
  pushText();
@@ -77,23 +85,23 @@ function parseInlineRaw(value, options) {
77
85
  type: 'link',
78
86
  href,
79
87
  ...(parsed.title ? { title: parsed.title } : {}),
80
- children: parseInlineRaw(parsed.label, options),
88
+ children: parseInlineRaw(parsed.label, options, budget),
81
89
  });
82
90
  }
83
91
  else {
84
- nodes.push(...parseInlineRaw(parsed.label, options));
92
+ nodes.push(...parseInlineRaw(parsed.label, options, budget));
85
93
  }
86
94
  index = parsed.end;
87
95
  continue;
88
96
  }
89
97
  }
90
98
  if ((char === '*' && next === '*') || (char === '_' && next === '_')) {
91
- const close = findDelimiter(value, index + 2, char + char);
99
+ const close = findDelimiter(value, index + 2, char + char, budget);
92
100
  if (close !== -1) {
93
101
  pushText();
94
102
  nodes.push({
95
103
  type: 'strong',
96
- children: parseInlineRaw(value.slice(index + 2, close), options),
104
+ children: parseInlineRaw(value.slice(index + 2, close), options, budget),
97
105
  });
98
106
  index = close + 2;
99
107
  continue;
@@ -102,13 +110,19 @@ function parseInlineRaw(value, options) {
102
110
  index += 2;
103
111
  continue;
104
112
  }
113
+ if (char === '~' && next === '~' && value[index + 2] === '~') {
114
+ const run = countRun(value, index, '~');
115
+ text += value.slice(index, index + run);
116
+ index += run;
117
+ continue;
118
+ }
105
119
  if (char === '~' && next === '~') {
106
- const close = findDelimiter(value, index + 2, '~~');
120
+ const close = findDelimiter(value, index + 2, '~~', budget);
107
121
  if (close !== -1) {
108
122
  pushText();
109
123
  nodes.push({
110
124
  type: 'strike',
111
- children: parseInlineRaw(value.slice(index + 2, close), options),
125
+ children: parseInlineRaw(value.slice(index + 2, close), options, budget),
112
126
  });
113
127
  index = close + 2;
114
128
  continue;
@@ -118,31 +132,31 @@ function parseInlineRaw(value, options) {
118
132
  continue;
119
133
  }
120
134
  if (char === '~') {
121
- const close = findSingleTildeDelimiter(value, index + 1);
135
+ const close = findSingleTildeDelimiter(value, index + 1, budget);
122
136
  if (close !== -1) {
123
137
  pushText();
124
138
  nodes.push({
125
139
  type: 'strike',
126
- children: parseInlineRaw(value.slice(index + 1, close), options),
140
+ children: parseInlineRaw(value.slice(index + 1, close), options, budget),
127
141
  });
128
142
  index = close + 1;
129
143
  continue;
130
144
  }
131
145
  }
132
146
  if (char === '*' || char === '_') {
133
- const close = findDelimiter(value, index + 1, char);
147
+ const close = findDelimiter(value, index + 1, char, budget);
134
148
  if (close !== -1 && !isIntrawordUnderscore(value, index, close, char)) {
135
149
  pushText();
136
150
  nodes.push({
137
151
  type: 'emphasis',
138
- children: parseInlineRaw(value.slice(index + 1, close), options),
152
+ children: parseInlineRaw(value.slice(index + 1, close), options, budget),
139
153
  });
140
154
  index = close + 1;
141
155
  continue;
142
156
  }
143
157
  }
144
158
  if (char === '<' && options.allowHtml) {
145
- const close = value.indexOf('>', index + 1);
159
+ const close = findCharacter(value, index + 1, '>', budget);
146
160
  if (close !== -1) {
147
161
  pushText();
148
162
  nodes.push({ type: 'inlineHtml', value: value.slice(index, close + 1) });
@@ -154,12 +168,13 @@ function parseInlineRaw(value, options) {
154
168
  index++;
155
169
  }
156
170
  pushText();
171
+ budget.depth--;
157
172
  return nodes;
158
173
  }
159
- function parseFootnoteReference(value, open, options) {
174
+ function parseFootnoteReference(value, open, options, budget) {
160
175
  if (value[open + 1] !== '^')
161
176
  return undefined;
162
- const close = value.indexOf(']', open + 2);
177
+ const close = findCharacter(value, open + 2, ']', budget);
163
178
  if (close === -1)
164
179
  return undefined;
165
180
  const label = value.slice(open + 2, close);
@@ -185,13 +200,13 @@ function parseFootnoteReference(value, open, options) {
185
200
  end: close + 1,
186
201
  };
187
202
  }
188
- function parseLinkish(value, open, options) {
189
- const closeBracket = findBalanced(value, open, '[', ']');
203
+ function parseLinkish(value, open, options, budget) {
204
+ const closeBracket = findBalanced(value, open, '[', ']', budget);
190
205
  if (closeBracket === -1)
191
206
  return undefined;
192
207
  const label = value.slice(open + 1, closeBracket);
193
208
  if (value[closeBracket + 1] === '(') {
194
- const closeParen = findBalanced(value, closeBracket + 1, '(', ')');
209
+ const closeParen = findBalanced(value, closeBracket + 1, '(', ')', budget);
195
210
  if (closeParen === -1)
196
211
  return undefined;
197
212
  const destination = value.slice(closeBracket + 2, closeParen).trim();
@@ -206,7 +221,7 @@ function parseLinkish(value, open, options) {
206
221
  };
207
222
  }
208
223
  if (value[closeBracket + 1] === '[') {
209
- const closeReference = findBalanced(value, closeBracket + 1, '[', ']');
224
+ const closeReference = findBalanced(value, closeBracket + 1, '[', ']', budget);
210
225
  if (closeReference === -1)
211
226
  return undefined;
212
227
  const definition = options.references?.[normalizeReferenceLabel(value.slice(closeBracket + 2, closeReference))];
@@ -230,9 +245,11 @@ function parseDestination(value) {
230
245
  ...(match[2] ? { title: match[2] } : {}),
231
246
  };
232
247
  }
233
- function findBalanced(value, openIndex, open, close) {
248
+ function findBalanced(value, openIndex, open, close, budget) {
234
249
  let depth = 0;
235
250
  for (let index = openIndex; index < value.length; index++) {
251
+ if (!takeScan(budget))
252
+ return -1;
236
253
  if (value[index - 1] === '\\')
237
254
  continue;
238
255
  if (value[index] === open)
@@ -245,38 +262,51 @@ function findBalanced(value, openIndex, open, close) {
245
262
  }
246
263
  return -1;
247
264
  }
248
- function countRun(value, index, char) {
265
+ function countRun(value, index, char, budget) {
249
266
  let count = 0;
250
- while (value[index + count] === char)
267
+ while (value[index + count] === char) {
268
+ if (budget && !takeScan(budget))
269
+ break;
251
270
  count++;
271
+ }
252
272
  return count;
253
273
  }
254
- function findClosingRun(value, start, char, count) {
274
+ function findClosingRun(value, start, char, count, budget) {
255
275
  for (let index = start; index < value.length; index++) {
276
+ if (!takeScan(budget))
277
+ return -1;
256
278
  if (value[index] !== char)
257
279
  continue;
258
- if (countRun(value, index, char) >= count)
280
+ if (countRun(value, index, char, budget) >= count)
259
281
  return index;
260
282
  }
261
283
  return -1;
262
284
  }
263
- function findDelimiter(value, start, delimiter) {
285
+ function findDelimiter(value, start, delimiter, budget) {
264
286
  for (let index = start; index < value.length; index++) {
287
+ if (!takeScan(budget))
288
+ return -1;
265
289
  if (value[index - 1] === '\\')
266
290
  continue;
291
+ if (delimiter === '~~' && (value[index - 1] === '~' || value[index + 2] === '~'))
292
+ continue;
267
293
  if (value.startsWith(delimiter, index))
268
294
  return index;
269
295
  }
270
296
  return -1;
271
297
  }
272
- function findSingleTildeDelimiter(value, start) {
298
+ function findSingleTildeDelimiter(value, start, budget) {
273
299
  if (isWhitespace(value[start] ?? ''))
274
300
  return -1;
275
301
  for (let index = start; index < value.length; index++) {
302
+ if (!takeScan(budget))
303
+ return -1;
276
304
  if (value[index - 1] === '\\')
277
305
  continue;
278
306
  if (value[index] !== '~')
279
307
  continue;
308
+ if (value[index - 1] === '~')
309
+ continue;
280
310
  if (value[index + 1] === '~')
281
311
  continue;
282
312
  if (isWhitespace(value[index - 1] ?? ''))
@@ -285,6 +315,21 @@ function findSingleTildeDelimiter(value, start) {
285
315
  }
286
316
  return -1;
287
317
  }
318
+ function findCharacter(value, start, character, budget) {
319
+ for (let index = start; index < value.length; index++) {
320
+ if (!takeScan(budget))
321
+ return -1;
322
+ if (value[index] === character)
323
+ return index;
324
+ }
325
+ return -1;
326
+ }
327
+ function takeScan(budget) {
328
+ if (budget.scans <= 0)
329
+ return false;
330
+ budget.scans--;
331
+ return true;
332
+ }
288
333
  function isIntrawordUnderscore(value, open, close, delimiter) {
289
334
  if (delimiter !== '_')
290
335
  return false;
@@ -293,8 +338,8 @@ function isIntrawordUnderscore(value, open, close, delimiter) {
293
338
  function isWhitespace(value) {
294
339
  return /\s/.test(value);
295
340
  }
296
- function textFromMarkdown(value) {
297
- return parseInlineRaw(value, {}).map(node => (node.type === 'text' || node.type === 'inlineCode' ? node.value : '')).join('');
341
+ function textFromMarkdown(value, budget) {
342
+ return parseInlineRaw(value, {}, budget).map(node => (node.type === 'text' || node.type === 'inlineCode' ? node.value : '')).join('');
298
343
  }
299
344
  function mergeText(nodes) {
300
345
  const result = [];
@@ -0,0 +1,15 @@
1
+ import type { ComponentBody, ElementDescriptor, OctaneNode } from 'octane';
2
+ import type { BlockNode, InlineNode, MarkdownInput, RenderOptions } from './types.js';
3
+ type ComponentMap = Partial<Record<string, string | ComponentBody<any>>>;
4
+ export interface MarkdownOctaneOptions extends RenderOptions {
5
+ components?: ComponentMap;
6
+ }
7
+ export interface MarkdownProps extends MarkdownOctaneOptions {
8
+ children: MarkdownInput;
9
+ }
10
+ export declare function Markdown({ children, ...options }: MarkdownProps): ElementDescriptor;
11
+ export declare function renderMarkdownOctane(input: MarkdownInput, options?: MarkdownOctaneOptions): OctaneNode[];
12
+ export declare function renderBlockOctane(node: BlockNode, options?: MarkdownOctaneOptions, key?: string): ElementDescriptor;
13
+ export declare function renderInlineOctane(node: InlineNode, options?: MarkdownOctaneOptions, key?: string): OctaneNode;
14
+ export {};
15
+ //# sourceMappingURL=octane.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"octane.d.ts","sourceRoot":"","sources":["../src/octane.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAA;AAE1E,OAAO,KAAK,EAAE,SAAS,EAAmC,UAAU,EAAE,aAAa,EAAE,aAAa,EAAiB,MAAM,YAAY,CAAA;AAErI,KAAK,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA;AAExE,MAAM,WAAW,qBAAsB,SAAQ,aAAa;IAC1D,UAAU,CAAC,EAAE,YAAY,CAAA;CAC1B;AAED,MAAM,WAAW,aAAc,SAAQ,qBAAqB;IAC1D,QAAQ,EAAE,aAAa,CAAA;CACxB;AAED,wBAAgB,QAAQ,CAAC,EAAE,QAAQ,EAAE,GAAG,OAAO,EAAE,EAAE,aAAa,GAAG,iBAAiB,CAEnF;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,aAAa,EAAE,OAAO,GAAE,qBAA0B,GAAG,UAAU,EAAE,CAG5G;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,GAAE,qBAA0B,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,iBAAiB,CA+DvH;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,GAAE,qBAA0B,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,UAAU,CAwClH"}
package/dist/octane.js ADDED
@@ -0,0 +1,189 @@
1
+ import { Fragment, createElement } from 'octane';
2
+ import { parseMarkdown } from './parser.js';
3
+ export function Markdown({ children, ...options }) {
4
+ return createElement(Fragment, null, ...renderMarkdownOctane(children, options));
5
+ }
6
+ export function renderMarkdownOctane(input, options = {}) {
7
+ const document = typeof input === 'string' ? parseMarkdown(input, options) : input;
8
+ return document.children.map((node, index) => renderBlockOctane(node, options, `b:${index}`));
9
+ }
10
+ export function renderBlockOctane(node, options = {}, key) {
11
+ switch (node.type) {
12
+ case 'heading':
13
+ return h(options, `h${node.depth}`, { key, ...(node.id ? { id: node.id } : {}), ...(node.framework ? { 'data-framework': node.framework } : {}) }, renderInlines(node.children, options), renderHeadingAnchorOctane(node.id, options));
14
+ case 'paragraph':
15
+ return h(options, 'p', { key }, renderInlines(node.children, options));
16
+ case 'code':
17
+ return renderCodeBlockOctane(node, options, key);
18
+ case 'list': {
19
+ const tag = node.ordered ? 'ol' : 'ul';
20
+ return h(options, tag, { key, ...(node.ordered && node.start && node.start !== 1 ? { start: node.start } : {}) }, node.items.map((item, index) => h(options, 'li', { key: index }, renderListItemChildrenOctane(item.children, item.checked, node.loose, options, `${index}`))));
21
+ }
22
+ case 'blockquote':
23
+ return h(options, 'blockquote', { key }, node.children.map((child, index) => renderBlockOctane(child, options, `${key}:${index}`)));
24
+ case 'table':
25
+ return h(options, 'table', { key }, h(options, 'thead', null, h(options, 'tr', null, node.header.map((cell, index) => renderTableCellOctane('th', cell, node.align[index], options, index)))), node.rows.length
26
+ ? h(options, 'tbody', null, node.rows.map((row, rowIndex) => h(options, 'tr', { key: rowIndex }, row.map((cell, index) => renderTableCellOctane('td', cell, node.align[index], options, index)))))
27
+ : null);
28
+ case 'footnotes':
29
+ return renderFootnotesOctane(node.items, options, key);
30
+ case 'thematicBreak':
31
+ return h(options, 'hr', { key });
32
+ case 'html':
33
+ return options.allowHtml
34
+ ? h(options, 'div', { key, dangerouslySetInnerHTML: { __html: node.value } })
35
+ : h(options, 'p', { key }, node.value);
36
+ case 'callout':
37
+ return h(options, 'div', { key, className: `markdown-alert markdown-alert-${node.kind.toLowerCase()}` }, h(options, 'p', { className: 'markdown-alert-title' }, node.title), h(options, 'div', { className: 'markdown-alert-content' }, node.children.map((child, index) => renderBlockOctane(child, options, `${key}:${index}`))));
38
+ case 'component':
39
+ return renderComponentOctane(node, options, key);
40
+ }
41
+ }
42
+ export function renderInlineOctane(node, options = {}, key) {
43
+ switch (node.type) {
44
+ case 'text':
45
+ return node.value;
46
+ case 'inlineCode':
47
+ return h(options, 'code', { key }, node.value);
48
+ case 'strong':
49
+ return h(options, 'strong', { key }, renderInlines(node.children, options));
50
+ case 'emphasis':
51
+ return h(options, 'em', { key }, renderInlines(node.children, options));
52
+ case 'strike':
53
+ return h(options, 'del', { key }, renderInlines(node.children, options));
54
+ case 'footnoteReference':
55
+ return h(options, 'sup', { key }, h(options, 'a', {
56
+ id: `user-content-fnref-${footnoteReferenceId(node)}`,
57
+ 'data-footnote-ref': '',
58
+ 'aria-describedby': 'footnote-label',
59
+ href: `#user-content-fn-${node.id}`,
60
+ }, node.number));
61
+ case 'link':
62
+ return h(options, 'a', { key, href: node.href, ...(node.title ? { title: node.title } : {}) }, renderInlines(node.children, options));
63
+ case 'image':
64
+ return h(options, 'img', { key, src: node.src, alt: node.alt, ...(node.title ? { title: node.title } : {}) });
65
+ case 'break':
66
+ return h(options, 'br', { key });
67
+ case 'inlineHtml':
68
+ return options.allowHtml
69
+ ? h(options, 'span', { key, dangerouslySetInnerHTML: { __html: node.value } })
70
+ : node.value;
71
+ }
72
+ }
73
+ function renderInlines(nodes, options) {
74
+ return nodes.map((node, index) => renderInlineOctane(node, options, `i:${index}`));
75
+ }
76
+ function renderCodeBlockOctane(node, options, key) {
77
+ const lang = node.lang ?? 'plaintext';
78
+ const highlighter = options.highlighter;
79
+ const content = highlighter ? undefined : node.value;
80
+ const highlighted = highlighter
81
+ ? {
82
+ dangerouslySetInnerHTML: {
83
+ __html: highlighter(node.value, lang, {
84
+ ...(node.highlightLines && { highlightLines: node.highlightLines }),
85
+ ...(options.codeLineNumbers !== undefined && { lineNumbers: options.codeLineNumbers }),
86
+ }),
87
+ },
88
+ }
89
+ : undefined;
90
+ const pre = h(options, 'pre', {
91
+ className: 'tm-code',
92
+ 'data-lang': lang,
93
+ ...(node.title ? { 'data-code-title': node.title } : {}),
94
+ ...(node.file ? { 'data-filename': node.file } : {}),
95
+ ...(node.framework ? { 'data-framework': node.framework } : {}),
96
+ }, h(options, 'code', { className: `language-${lang}`, ...highlighted }, content));
97
+ if (!node.title)
98
+ return h(options, Fragment, { key }, pre);
99
+ return h(options, 'figure', { key, className: 'tm-code-frame', 'data-lang': lang }, h(options, 'figcaption', null, node.title), pre);
100
+ }
101
+ function renderListItemChildrenOctane(children, checked, loose, options, key) {
102
+ const [first, ...rest] = children;
103
+ const task = checked === undefined
104
+ ? []
105
+ : [
106
+ h(options, 'input', {
107
+ key: `${key}:checkbox`,
108
+ type: 'checkbox',
109
+ disabled: true,
110
+ checked,
111
+ readOnly: true,
112
+ }),
113
+ ' ',
114
+ ];
115
+ if (first?.type === 'paragraph') {
116
+ const content = [...task, ...renderInlines(first.children, options)];
117
+ return [
118
+ ...(loose ? [h(options, 'p', { key: `${key}:paragraph` }, content)] : content),
119
+ ...rest.flatMap((child, childIndex) => renderListChildOctane(child, loose, options, `${key}:${childIndex + 1}`)),
120
+ ];
121
+ }
122
+ return [...task, ...children.flatMap((child, childIndex) => renderListChildOctane(child, loose, options, `${key}:${childIndex}`))];
123
+ }
124
+ function renderListChildOctane(child, loose, options, key) {
125
+ return !loose && child.type === 'paragraph' ? renderInlines(child.children, options) : [renderBlockOctane(child, options, key)];
126
+ }
127
+ function renderTableCellOctane(tag, cell, align, options, key) {
128
+ return h(options, tag, { key, ...(align ? { style: { textAlign: align } } : {}) }, renderInlines(cell.children, options));
129
+ }
130
+ function renderFootnotesOctane(items, options, key) {
131
+ return h(options, 'section', { key, 'data-footnotes': '', className: 'footnotes' }, h(options, 'h2', { id: 'footnote-label', className: 'sr-only' }, 'Footnotes', renderHeadingAnchorOctane('footnote-label', options)), h(options, 'ol', null, items.map(item => h(options, 'li', { key: item.id, id: `user-content-fn-${item.id}` }, renderFootnoteItemOctane(item, options)))));
132
+ }
133
+ function renderFootnoteItemOctane(item, options) {
134
+ const lastIndex = item.children.length - 1;
135
+ const backrefs = renderFootnoteBackrefsOctane(item, options);
136
+ if (lastIndex < 0)
137
+ return [h(options, 'p', { key: 'backref-wrapper' }, backrefs.slice(1))];
138
+ return item.children.map((child, index) => {
139
+ if (index === lastIndex && child.type === 'paragraph') {
140
+ return h(options, 'p', { key: index }, renderInlines(child.children, options), backrefs);
141
+ }
142
+ return renderBlockOctane(child, options, `${index}`);
143
+ });
144
+ }
145
+ function renderFootnoteBackrefsOctane(item, options) {
146
+ const result = [];
147
+ for (let index = 1; index <= (item.referenceCount ?? 1); index++) {
148
+ const referenceId = index === 1 ? item.id : `${item.id}-${index}`;
149
+ const label = index === 1 ? `${item.number}` : `${item.number}-${index}`;
150
+ result.push(' ', h(options, 'a', {
151
+ key: index,
152
+ 'data-footnote-backref': '',
153
+ 'aria-label': `Back to reference ${label}`,
154
+ className: 'data-footnote-backref',
155
+ href: `#user-content-fnref-${referenceId}`,
156
+ }, '\u21a9'));
157
+ }
158
+ return result;
159
+ }
160
+ function footnoteReferenceId(node) {
161
+ return node.referenceIndex && node.referenceIndex > 1 ? `${node.id}-${node.referenceIndex}` : node.id;
162
+ }
163
+ function h(options, tag, props, ...children) {
164
+ const component = typeof tag === 'string' ? options.components?.[tag] ?? tag : tag;
165
+ return createElement(component, props ?? undefined, ...children);
166
+ }
167
+ function renderComponentOctane(node, options, key) {
168
+ const tag = node.tagName ?? 'md-comment-component';
169
+ const props = {
170
+ ...(node.properties ?? {}),
171
+ };
172
+ if (!node.tagName) {
173
+ props['data-component'] = node.name;
174
+ if (!props['data-attributes'])
175
+ props['data-attributes'] = JSON.stringify(node.attributes);
176
+ }
177
+ return h(options, tag, { key, ...props }, node.children.map((child, index) => renderBlockOctane(child, options, `${key}:${index}`)));
178
+ }
179
+ function renderHeadingAnchorOctane(id, options) {
180
+ if (!id || !options.headingAnchors)
181
+ return null;
182
+ const anchorOptions = typeof options.headingAnchors === 'object' ? options.headingAnchors : {};
183
+ return h(options, 'a', {
184
+ href: `#${id}`,
185
+ 'aria-hidden': anchorOptions.ariaHidden ?? true,
186
+ className: anchorOptions.className ?? 'anchor-heading anchor-heading-link',
187
+ tabIndex: anchorOptions.tabIndex ?? -1,
188
+ }, anchorOptions.content ?? '#');
189
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAQV,gBAAgB,EAChB,YAAY,EAGb,MAAM,YAAY,CAAA;AAKnB,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,YAAiB,GAAG,gBAAgB,CAyC5F"}
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAQV,gBAAgB,EAChB,YAAY,EAGb,MAAM,YAAY,CAAA;AAWnB,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,YAAiB,GAAG,gBAAgB,CAyC5F"}
package/dist/parser.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseInline } from './inline.js';
2
2
  import { createSlugger, footnoteId, isBlank, normalizeInput, normalizeReferenceLabel, plainText, stripIndent } from './utils.js';
3
+ const maxBlockDepth = 64;
3
4
  export function parseMarkdown(markdown, options = {}) {
4
5
  const normalized = normalizeInput(markdown);
5
6
  const frontmatterEnabled = options.frontmatter !== false;
@@ -40,13 +41,21 @@ class BlockParser {
40
41
  lines;
41
42
  options;
42
43
  slugger;
44
+ budget;
43
45
  index = 0;
44
- constructor(lines, options, slugger) {
46
+ constructor(lines, options, slugger, budget = { depth: 0 }) {
45
47
  this.lines = lines;
46
48
  this.options = options;
47
49
  this.slugger = slugger;
50
+ this.budget = budget;
48
51
  }
49
52
  parse() {
53
+ if (this.budget.depth >= maxBlockDepth) {
54
+ const value = this.lines.slice(this.index).join('\n');
55
+ this.index = this.lines.length;
56
+ return value ? [{ type: 'paragraph', children: parseInline(value, this.options) }] : [];
57
+ }
58
+ this.budget.depth++;
50
59
  const nodes = [];
51
60
  while (this.index < this.lines.length) {
52
61
  if (isBlank(this.current())) {
@@ -68,6 +77,7 @@ class BlockParser {
68
77
  this.parseParagraph();
69
78
  nodes.push(node);
70
79
  }
80
+ this.budget.depth--;
71
81
  return nodes;
72
82
  }
73
83
  parseExtensionBlock() {
@@ -78,7 +88,7 @@ class BlockParser {
78
88
  index: this.index,
79
89
  options: this.options,
80
90
  parseInline: value => parseInline(value, this.options),
81
- parseBlocks: value => new BlockParser(normalizeInput(value).split('\n'), this.options, this.slugger).parse(),
91
+ parseBlocks: value => new BlockParser(normalizeInput(value).split('\n'), this.options, this.slugger, this.budget).parse(),
82
92
  consume: lines => {
83
93
  consumed = lines;
84
94
  },
@@ -151,7 +161,7 @@ class BlockParser {
151
161
  }
152
162
  return {
153
163
  type: 'blockquote',
154
- children: new BlockParser(quoted, this.options, this.slugger).parse(),
164
+ children: new BlockParser(quoted, this.options, this.slugger, this.budget).parse(),
155
165
  };
156
166
  }
157
167
  parseList() {
@@ -211,7 +221,7 @@ class BlockParser {
211
221
  itemLines.push(line.trimStart());
212
222
  this.index++;
213
223
  }
214
- const children = new BlockParser(itemLines, this.options, this.slugger).parse();
224
+ const children = new BlockParser(itemLines, this.options, this.slugger, this.budget).parse();
215
225
  const item = { type: 'listItem', children };
216
226
  if (checked !== undefined)
217
227
  item.checked = checked;
@@ -232,13 +242,15 @@ class BlockParser {
232
242
  return undefined;
233
243
  const headerCells = splitTableRow(header);
234
244
  const align = splitTableRow(delimiter).map(parseAlign);
245
+ const columns = headerCells.length;
235
246
  const rows = [];
236
247
  this.index += 2;
237
248
  while (this.index < this.lines.length) {
238
249
  const line = this.current();
239
- if (isBlank(line) || !line.includes('|'))
250
+ if (isBlank(line) || isBlockStart(line, this.next()))
240
251
  break;
241
- rows.push(splitTableRow(line).map(value => cell(value, this.options)));
252
+ const values = splitTableRow(line);
253
+ rows.push(Array.from({ length: columns }, (_, index) => cell(values[index] ?? '', this.options)));
242
254
  this.index++;
243
255
  }
244
256
  return {
@@ -434,7 +446,7 @@ function looksLikeTableHeader(header, delimiter) {
434
446
  if (!header.includes('|'))
435
447
  return false;
436
448
  const cells = splitTableRow(delimiter);
437
- return cells.length > 0 && cells.every(cell => /^:?-{2,}:?$/.test(cell.trim()));
449
+ return cells.length === splitTableRow(header).length && cells.every(cell => /^:?-+:?$/.test(cell.trim()));
438
450
  }
439
451
  function splitTableRow(value) {
440
452
  let row = value.trim();
@@ -1 +1 @@
1
- {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEnE,OAAO,KAAK,EAAE,SAAS,EAAmC,UAAU,EAAE,aAAa,EAAE,aAAa,EAAiB,MAAM,YAAY,CAAA;AAErI,KAAK,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA;AAExE,MAAM,WAAW,oBAAqB,SAAQ,aAAa;IACzD,UAAU,CAAC,EAAE,YAAY,CAAA;CAC1B;AAED,MAAM,WAAW,aAAc,SAAQ,oBAAoB;IACzD,QAAQ,EAAE,aAAa,CAAA;CACxB;AAED,wBAAgB,QAAQ,CAAC,EAAE,QAAQ,EAAE,GAAG,OAAO,EAAE,EAAE,aAAa,GAAG,YAAY,CAE9E;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,aAAa,EAAE,OAAO,GAAE,oBAAyB,GAAG,SAAS,CAGvG;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,GAAE,oBAAyB,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,YAAY,CA6DhH;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,GAAE,oBAAyB,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAwC/G"}
1
+ {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEnE,OAAO,KAAK,EAAE,SAAS,EAAmC,UAAU,EAAE,aAAa,EAAE,aAAa,EAAiB,MAAM,YAAY,CAAA;AAErI,KAAK,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA;AAExE,MAAM,WAAW,oBAAqB,SAAQ,aAAa;IACzD,UAAU,CAAC,EAAE,YAAY,CAAA;CAC1B;AAED,MAAM,WAAW,aAAc,SAAQ,oBAAoB;IACzD,QAAQ,EAAE,aAAa,CAAA;CACxB;AAED,wBAAgB,QAAQ,CAAC,EAAE,QAAQ,EAAE,GAAG,OAAO,EAAE,EAAE,aAAa,GAAG,YAAY,CAE9E;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,aAAa,EAAE,OAAO,GAAE,oBAAyB,GAAG,SAAS,CAGvG;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,GAAE,oBAAyB,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,YAAY,CA+DhH;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,GAAE,oBAAyB,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAwC/G"}
package/dist/react.js CHANGED
@@ -22,7 +22,9 @@ export function renderBlockReact(node, options = {}, key) {
22
22
  case 'blockquote':
23
23
  return h(options, 'blockquote', { key }, node.children.map((child, index) => renderBlockReact(child, options, `${key}:${index}`)));
24
24
  case 'table':
25
- return h(options, 'table', { key }, h(options, 'thead', null, h(options, 'tr', null, node.header.map((cell, index) => renderTableCellReact('th', cell, node.align[index], options, index)))), h(options, 'tbody', null, node.rows.map((row, rowIndex) => h(options, 'tr', { key: rowIndex }, row.map((cell, index) => renderTableCellReact('td', cell, node.align[index], options, index))))));
25
+ return h(options, 'table', { key }, h(options, 'thead', null, h(options, 'tr', null, node.header.map((cell, index) => renderTableCellReact('th', cell, node.align[index], options, index)))), node.rows.length
26
+ ? h(options, 'tbody', null, node.rows.map((row, rowIndex) => h(options, 'tr', { key: rowIndex }, row.map((cell, index) => renderTableCellReact('td', cell, node.align[index], options, index)))))
27
+ : null);
26
28
  case 'footnotes':
27
29
  return renderFootnotesReact(node.items, options, key);
28
30
  case 'thematicBreak':
package/package.json CHANGED
@@ -1,10 +1,9 @@
1
1
  {
2
2
  "name": "@tanstack/markdown",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
- "description": "A tiny deterministic TanStack-flavored markdown renderer.",
5
+ "description": "A tiny, fast, deterministic Markdown renderer for blogs and documentation.",
6
6
  "license": "MIT",
7
- "packageManager": "pnpm@11.4.0",
8
7
  "sideEffects": false,
9
8
  "files": [
10
9
  "dist",
@@ -28,6 +27,10 @@
28
27
  "types": "./dist/react.d.ts",
29
28
  "import": "./dist/react.js"
30
29
  },
30
+ "./octane": {
31
+ "types": "./dist/octane.d.ts",
32
+ "import": "./dist/octane.js"
33
+ },
31
34
  "./extensions/callouts": {
32
35
  "types": "./dist/extensions/callouts.d.ts",
33
36
  "import": "./dist/extensions/callouts.js"
@@ -53,21 +56,17 @@
53
56
  "import": "./dist/extensions/tabs.js"
54
57
  }
55
58
  },
56
- "scripts": {
57
- "build": "rm -rf dist && tsc -p tsconfig.build.json",
58
- "typecheck": "tsc --noEmit",
59
- "test": "vitest run",
60
- "bench": "tsx scripts/bench.ts",
61
- "size": "tsx scripts/measure-size.ts",
62
- "research": "pnpm run size && pnpm run bench"
63
- },
64
59
  "publishConfig": {
65
60
  "access": "public"
66
61
  },
67
62
  "peerDependencies": {
63
+ "octane": ">=0.1.12",
68
64
  "react": ">=18"
69
65
  },
70
66
  "peerDependenciesMeta": {
67
+ "octane": {
68
+ "optional": true
69
+ },
71
70
  "react": {
72
71
  "optional": true
73
72
  }
@@ -77,11 +76,13 @@
77
76
  "@types/react": "^19.0.0",
78
77
  "@types/react-dom": "^19.0.0",
79
78
  "commonmark": "^0.31.2",
79
+ "commonmark-spec": "0.31.2",
80
80
  "esbuild": "^0.25.0",
81
81
  "markdown-it": "^14.2.0",
82
82
  "markdown-wasm": "^1.2.0",
83
83
  "marked": "^18.0.5",
84
84
  "micromark": "^4.0.2",
85
+ "octane": "0.1.12",
85
86
  "react": "^19.0.0",
86
87
  "react-dom": "^19.0.0",
87
88
  "rehype-stringify": "^10.0.1",
@@ -91,5 +92,17 @@
91
92
  "typescript": "^5.8.0",
92
93
  "unified": "^11.0.5",
93
94
  "vitest": "^3.2.0"
95
+ },
96
+ "scripts": {
97
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
98
+ "typecheck": "tsc --noEmit",
99
+ "test": "vitest run",
100
+ "test:corpus": "vitest run tests/corpus.test.tsx",
101
+ "docs:verify": "node scripts/verify-docs.mjs",
102
+ "conformance": "tsx scripts/conformance.ts",
103
+ "bench": "tsx scripts/bench.ts",
104
+ "size": "tsx scripts/measure-size.ts",
105
+ "research": "pnpm run conformance && pnpm run size && pnpm run bench",
106
+ "verify": "pnpm test && pnpm run typecheck && pnpm run build && pnpm run docs:verify && pnpm run research && pnpm pack --dry-run"
94
107
  }
95
- }
108
+ }