From 619de8676b0ba0da2c1dcf82557e64d94ef8aaf3 Mon Sep 17 00:00:00 2001 From: Bertrand GRESSIER Date: Sun, 4 Oct 2026 19:47:42 +0200 Subject: [PATCH] feat!: adopt @tanstack/markdown 1.0 inlineParser API Replace the inline text-walker with an inlineParser ('$' marker) using pandoc-style rules, simplify the default renderer (shared with React), harden the block extension (indent limit, unclosedBlock option) and trim TeX consistently in both output modes. BREAKING CHANGE: peer dependency minimum raised to @tanstack/markdown 1.0.0. How to migrate: run `pnpm add @tanstack/markdown@^1 tanstack-markdown-math@^1` (or the npm/yarn equivalent). BREAKING CHANGE: heading slugs change when headings contain inline math. How to migrate: regenerate any stored heading anchors, table-of-contents entries and deep links for headings that contain `$...$`. BREAKING CHANGE: default error output now uses English text and neutral CSS classes (katex-error-badge / katex-error-box). How to migrate: replace styling that targeted the old Tailwind classes or French strings with CSS for `katex-error-badge` / `katex-error-box`, or pass a custom `render` option to keep your previous markup. BREAKING CHANGE: protectMath and restoreMathChars are deprecated. How to migrate: remove protectMath(content) calls before parsing; math with `_`, `*` and `\` works without it. Both functions stay exported for already-protected content. --- .github/workflows/ci.yml | 20 --- README.md | 42 +++-- package.json | 2 +- src/index.ts | 367 ++++++++++++++------------------------- src/react.tsx | 17 +- src/render.ts | 36 ++++ src/types.ts | 14 +- tests/component.test.ts | 2 +- tests/math.test.ts | 4 +- tests/v1.test.ts | 184 ++++++++++++++++++++ 10 files changed, 394 insertions(+), 294 deletions(-) create mode 100644 src/render.ts create mode 100644 tests/v1.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7841fd..8aeca34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,23 +18,3 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm run typecheck - run: pnpm test - - peer-matrix: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - # supported @tanstack/markdown versions with inlineComponent nodes (>= 0.0.15); - # 0.0.13/0.0.14 still satisfy the peer range but only support 'html' output mode - markdown: ['0.0.15', '0.0.16', '1.0.0'] - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --no-frozen-lockfile - - run: pnpm add -D @tanstack/markdown@${{ matrix.markdown }} - - run: pnpm run typecheck - - run: pnpm test diff --git a/README.md b/README.md index 7882573..f1b06a8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ pnpm add tanstack-markdown-math @tanstack/markdown katex # yarn add tanstack-markdown-math @tanstack/markdown katex ``` -`@tanstack/markdown` and `katex` are peer dependencies. A custom `render` option lets you avoid KaTeX at runtime, but it remains a peer dependency for now. `react` (>=18) is an optional peer, only needed for the `tanstack-markdown-math/react` subpath. +`@tanstack/markdown` (`>=1.0.0`) and `katex` are peer dependencies. A custom `render` option lets you avoid KaTeX at runtime, but it remains a peer dependency for now. `react` (>=18) is an optional peer, only needed for the `tanstack-markdown-math/react` subpath. ## Quick start @@ -64,12 +64,12 @@ import { - `mathExtension(opts?)` → `[mathBlockExtension(opts), mathInlineExtension(opts)]` - `mathBlockExtension(opts?)` → parses `$$...$$` blocks into `html` nodes (`component` nodes with `output: 'component'`). -- `mathInlineExtension(opts?)` → walks the AST and converts `$...$` into `inlineHtml` nodes (`inlineComponent` nodes with `output: 'component'`). +- `mathInlineExtension(opts?)` → registers a `$` inline parser (`@tanstack/markdown` 1.0 `inlineParser`) that converts `$...$` / `$$...$$` into `inlineHtml` nodes (`inlineComponent` nodes with `output: 'component'`). The extensions also export helpers used when pre-processing content through other tools: -- `protectMath(content)` → replaces `\`, `_` and `*` inside math spans with private-use characters (`MATH_BACKSLASH_SUB`, `MATH_UNDERSCORE_SUB`, `MATH_ASTERISK_SUB`) so other markdown passes cannot unescape TeX commands or turn subscripts into emphasis. -- `restoreMathChars(tex)` → inverse mapping, applied automatically before rendering. +- `protectMath(content)` → **deprecated** (not needed with `@tanstack/markdown` >= 1.0; math is parsed before emphasis/escape handling). Replaces replaces `\`, `_` and `*` inside math spans with private-use characters (`MATH_BACKSLASH_SUB`, `MATH_UNDERSCORE_SUB`, `MATH_ASTERISK_SUB`) so other markdown passes cannot unescape TeX commands or turn subscripts into emphasis. Kept for pre-protected content. +- `restoreMathChars(tex)` → **deprecated**, inverse mapping, applied automatically before rendering (no-op when content was not protected). ### `MathOptions` @@ -80,15 +80,16 @@ The extensions also export helpers used when pre-processing content through othe | `output` | `'html' \| 'component'` | `'html'` | Block and inline math. `'component'` emits `component`/`inlineComponent` nodes carrying raw TeX, removing the `allowHtml` requirement (see below). | | `tagName` | `string` | `'MathBlock'` | Block-math only. Tag name of the `component` node, to be mapped through the renderers' `components` option. | | `inlineTagName`| `string` | `'MathInline'` | Inline-math only. Tag name of the `inlineComponent` node, to be mapped through the renderers' `components` option. | +| `unclosedBlock`| `boolean` | `true` | Block-math only. `true` renders an unclosed `$$` block as partial math (streaming-friendly). `false` leaves it as plain text until the closing `$$` arrives. | | `cache` | `boolean \| number` | `true` | Render cache (LRU, 1000 entries). `false` disables it — use this with an impure custom `render` (side effects, non-deterministic output). A number sets a custom entry limit. | -`mathBlockExtension` accepts `render`, `output`, `tagName` and `cache`; `mathInlineExtension` accepts `render`, `allowSpaces`, `output`, `inlineTagName` and `cache`. +`mathBlockExtension` accepts `render`, `output`, `tagName`, `unclosedBlock` and `cache`; `mathInlineExtension` accepts `render`, `allowSpaces`, `output`, `inlineTagName` and `cache`. ## Math without `allowHtml` (component output) With `output: 'component'`, math is emitted as `component` (block) / `inlineComponent` (inline) nodes carrying the raw TeX in `properties.tex`, instead of pre-rendered `html`/`inlineHtml` nodes. The AST stays serialization-clean and no `allowHtml` is needed. -> Inline component output requires `@tanstack/markdown` >= 0.0.15 (`inlineComponent` node type, landed via [PR #17](https://github.com/TanStack/markdown/pull/17)). +> Requires `@tanstack/markdown` >= 1.0.0. **HTML renderer** — zero configuration: the extension ships a `renderHtml` hook that renders both block and inline component nodes. @@ -132,7 +133,9 @@ mathInlineExtension() - `$x$` → rendered math - `$5` or `$ 5 $` → left as literal text -- `$5 and $10` → left as literal text (no false monetary positives) +- `$5 and $10`, `$5-$10` → left as literal text (no false monetary positives: whitespace before the closing `$`, or a digit right after it, rejects the span) +- `\$5` → escaped dollar, literal text; `$a \$ b$` → valid math containing an escaped dollar +- `_`, `*` and `\` inside math need no pre-protection (`protectMath` is no longer required) ### Permissive mode @@ -178,7 +181,9 @@ $$ $$ \\frac{1}{2} $$ ``` -Unclosed `$$` blocks are consumed to the end of the document and rendered as partial math without crashing. This is important for streaming UIs. +Unclosed `$$` blocks are consumed to the end of the document and rendered as partial math without crashing. This is important for streaming UIs. Set `unclosedBlock: false` to leave unclosed blocks as plain text instead. + +The opening fence may be indented by at most 3 spaces; 4-space-indented `$$` stays an indented code block. ## Streaming @@ -189,7 +194,9 @@ Because `@tanstack/markdown` extensions run during document parsing, both block - Several inline math expressions on the same line are each rendered separately. - Math inside inline HTML (`$x$`) is rendered; structural block HTML containing `$` is left untouched. - Code fences / inline code containing `$` are preserved verbatim. -- Invalid TeX renders as a styled error badge/box instead of raw KaTeX output (default renderer only). +- Invalid TeX renders as a neutral error element instead of raw KaTeX output (default renderer only): `` inline and `
` in display mode, with English text ("KaTeX error"). Style these classes yourself; the React components use the same output. +- Heading anchors: math is now parsed as part of the heading's inline content, so generated heading slugs are derived from the heading's plain text and no longer include math fragments. +- Math is trimmed in both output modes (`$$ x $$` → `x`). - `mathBlockExtension` runs before built-in block parsers because it is registered earlier in the extension array. ## Design notes @@ -197,13 +204,20 @@ Because `@tanstack/markdown` extensions run during document parsing, both block These notes follow the upstream discussion in [TanStack/markdown#13](https://github.com/TanStack/markdown/issues/13) (consolidated into [#9](https://github.com/TanStack/markdown/issues/9)): 1. **First-class math nodes vs. html injection** - The default mode emits `html`/`inlineHtml` nodes. The `component` output mode demonstrates the richer design enabled by `ComponentNode` and `InlineComponentNode` (added in `@tanstack/markdown` 0.0.15, [PR #17](https://github.com/TanStack/markdown/pull/17)): raw TeX in the AST, rendering deferred to the renderer (`renderHtml` hook / React `components` map), no `allowHtml`. + The default mode emits `html`/`inlineHtml` nodes. The `component` output mode uses `ComponentNode` and `InlineComponentNode`: raw TeX in the AST, rendering deferred to the renderer (`renderHtml` hook / React `components` map), no `allowHtml`. + +2. **`inlineParser` instead of an AST walker** + Since `@tanstack/markdown` 1.0, extensions can register an `inlineParser` with `markers: '$'`. The parser sees the raw source before emphasis, escapes and links are resolved, so math bodies containing `_`, `*` or `\` are never corrupted and `protectMath` is unnecessary. Spans are recognised in every inline context (headings, links, lists, tables, ...) and never inside code. + +3. **Pandoc-style delimiter rules** + Single `$` spans reject whitespace after the opener and before the closer, reject a closer followed by a digit (`$5-$10`), honour `\$` escapes, and stop at blank lines. Scanning is linear. A small `transformDocument` remains only for block-level raw HTML nodes (`allowHtml`), and it builds new nodes instead of mutating the input so cached ASTs can be re-parsed safely. -2. **`transformDocument` vs `transformInline`** - `transformInline` is called once per inline container (paragraph, heading, list item, table cell) with the container's top-level nodes, during parsing. This implementation instead uses `transformDocument`, which traverses the fully parsed AST. Both work in 0.0.13; `transformInline` would avoid the full-document walk and could simplify future versions. +## Upgrading to v1 -3. **Inline parsing order** - Inline math must be processed after bold, italic, links, and code have already been parsed into typed nodes, so the walker skips typed nodes that should not contain math. A true inline transformer in the parser would achieve the same result more cleanly. +1. **Peer dependency raised to `@tanstack/markdown` >= 1.0.0.** Migrate with `pnpm add @tanstack/markdown@^1 tanstack-markdown-math@^1` (or the npm/yarn equivalent). +2. **`protectMath` / `restoreMathChars` are deprecated.** Remove your `protectMath(content)` calls before parsing: math containing `_`, `*` and `\` now works as-is. Both functions remain exported for already-protected content. +3. **Heading slugs change when headings contain inline math.** Math is now parsed inside the heading's inline content, so regenerate any stored heading anchors / table-of-contents links / deep links for headings that contain `$...$`. +4. **Default error output changed.** Invalid TeX now renders `` (inline) / `
` (display) with English text ("KaTeX error") instead of Tailwind utility classes and French strings. Add your own CSS for `katex-error-badge` / `katex-error-box`, or pass a custom `render` to keep your previous markup. ## License diff --git a/package.json b/package.json index 768d906..b8883da 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "prepublishOnly": "pnpm run build" }, "peerDependencies": { - "@tanstack/markdown": ">=0.0.13 <2.0.0", + "@tanstack/markdown": ">=1.0.0 <2.0.0", "katex": ">=0.16", "react": ">=18" }, diff --git a/src/index.ts b/src/index.ts index 254935e..1aae86b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -import katex from 'katex' import type { BlockNode, ComponentNode, @@ -11,12 +10,17 @@ import type { MathInlineOptions, MathOptions, } from './types.js' +import { defaultRenderer } from './render.js' export const MATH_UNDERSCORE_SUB = '\uE000' export const MATH_ASTERISK_SUB = '\uE001' export const MATH_BACKSLASH_SUB = '\uE002' /** + * @deprecated No longer needed with `@tanstack/markdown` >= 1.0: the inline + * math parser runs before emphasis/escape handling. Kept for content that is + * already pre-protected. + * * Protects backslashes, underscores and asterisks inside math formulas (`$...$` and `$$...$$`) * before markdown parsing. This prevents markdown parsers from unescaping TeX commands (like `\{`, `\}`, `\\`) * or misinterpreting TeX subscripts/operators like `$_2$` or `*` as markdown emphasis (`*...*` or `_..._`). @@ -36,6 +40,9 @@ export function protectMath(content: string): string { } /** + * @deprecated Only needed for pre-protected content (see `protectMath`). + * Safe no-op otherwise. + * * Restores protected characters inside extracted TeX. */ export function restoreMathChars(tex: string): string { @@ -45,45 +52,6 @@ export function restoreMathChars(tex: string): string { .replaceAll(MATH_BACKSLASH_SUB, '\\') } -function escapeHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -const DEFAULT_KATEX_OPTIONS: katex.KatexOptions = { - throwOnError: false, - strict: false, -} - -function defaultRenderer(tex: string, displayMode: boolean): string { - try { - const html = katex.renderToString(tex, { - ...DEFAULT_KATEX_OPTIONS, - displayMode, - throwOnError: false, - }) - if (html.includes('class="katex-error"') || html.includes('#cc0000')) { - const titleMatch = html.match(/title="([^"]*)"/) - const errTitle = titleMatch ? titleMatch[1] : 'Erreur syntaxe LaTeX' - if (displayMode) { - return `
⚠️ Erreur KaTeX: ${escapeHtml(errTitle)}
${escapeHtml(tex)}
` - } - return `⚠️ ${escapeHtml(tex)}` - } - return html - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err) - if (displayMode) { - return `
⚠️ Erreur KaTeX: ${escapeHtml(msg)}
${escapeHtml(tex)}
` - } - return `⚠️ ${escapeHtml(tex)}` - } -} - const RENDER_CACHE_LIMIT = 1000 /** @@ -174,17 +142,18 @@ export function mathBlockExtension( const render = getRenderer(opts) const output = opts?.output ?? 'html' const tagName = opts?.tagName ?? DEFAULT_MATH_TAG_NAME + const unclosedBlock = opts?.unclosedBlock ?? true const emit = (tex: string): BlockNode => output === 'component' - ? mathComponentBlockNode(tex, tagName) - : mathBlockNode(tex, render) + ? mathComponentBlockNode(tex.trim(), tagName) + : mathBlockNode(tex.trim(), render) return { name: 'math-block', parseBlock(context) { const line = context.lines[context.index] ?? '' - const open = line.match(/^\s*\$\$(.*)$/) + const open = line.match(/^ {0,3}\$\$(.*)$/) if (!open) return undefined const after = open[1] @@ -217,12 +186,13 @@ export function mathBlockExtension( } content.push(l) } + if (!unclosedBlock) return undefined context.consume(i - context.index) return emit(restoreMathChars(content.join('\n'))) }, renderHtml(node) { if (!isMathComponentNode(node)) return undefined - return render(restoreMathChars(node.properties?.tex ?? ''), true) + return render(restoreMathChars(node.properties?.tex ?? '').trim(), true) }, } } @@ -230,250 +200,169 @@ export function mathBlockExtension( function mathInlineNode( tex: string, render: (tex: string, displayMode: boolean) => string, - displayMode = false, + displayMode: boolean, inlineTag?: string, ): InlineNode { + const trimmed = restoreMathChars(tex).trim() if (inlineTag) { return { type: 'inlineComponent', - name: 'math', + name: MATH_COMPONENT_NAME, attributes: {}, tagName: inlineTag, - properties: { tex: tex.trim() }, + properties: { tex: trimmed }, children: [], } as InlineNode } return { type: 'inlineHtml', - value: render(tex, displayMode), + value: render(trimmed, displayMode), } as InlineNode } -function replaceMathInHtml( - html: string, - render: (tex: string, displayMode: boolean) => string, - allowSpaces?: boolean, -): string { - const re = allowSpaces - ? /\$\$([\s\S]+?)\$\$|\$((?:[^\n$]|\n(?!\s*\n))+?)\$/g - : /\$\$([\s\S]+?)\$\$|\$([^\s$](?:(?:[^\n$]|\n(?!\s*\n))*?[^\s$])?)\$/g +const WS = /\s/ +const BLANK_LINE = /\n\s*\n/y +const DIGIT = /\d/ - return html.replace(re, (_match, p1, p2) => { - const rawTex = p1 ?? p2 ?? '' - const tex = restoreMathChars(rawTex) - return render(tex, false) - }) -} - -function splitTextNode( - value: string, - render: (tex: string, displayMode: boolean) => string, - allowSpaces?: boolean, - inlineTag?: string, -): InlineNode[] { - const re = allowSpaces - ? /\$\$([\s\S]+?)\$\$|\$((?:[^\n$]|\n(?!\s*\n))+?)\$/g - : /\$\$([\s\S]+?)\$\$|\$([^\s$](?:(?:[^\n$]|\n(?!\s*\n))*?[^\s$])?)\$/g - - const out: InlineNode[] = [] - let last = 0 - for (const m of value.matchAll(re)) { - const idx = m.index ?? 0 - const rawTex = m[1] ?? m[2] ?? '' - const tex = restoreMathChars(rawTex) - if (idx > last) out.push({ type: 'text', value: value.slice(last, idx) }) - out.push(mathInlineNode(tex, render, false, inlineTag)) - last = idx + m[0].length +/** + * Finds the math span starting at `s[i]` (which must be `$`). Linear in the + * distance to the next unescaped `$`. + */ +function findClose( + s: string, + i: number, + allowSpaces: boolean, +): { tex: string; length: number; display: boolean } | undefined { + if (s.startsWith('$$', i)) { + const c = s.indexOf('$$', i + 2) + return c > i + 2 + ? { tex: s.slice(i + 2, c), length: c + 2 - i, display: true } + : undefined } - if (last < value.length) { - out.push({ type: 'text', value: value.slice(last) }) + let j = i + 1 + if (j >= s.length) return undefined + if (!allowSpaces && WS.test(s[j]!)) return undefined + for (; j < s.length; j++) { + const ch = s[j] + if (ch === '\\') { + j++ + continue + } + if (ch === '\n') { + BLANK_LINE.lastIndex = j + if (BLANK_LINE.test(s)) return undefined + continue + } + if (ch === '$') { + if (j === i + 1) return undefined + if (!allowSpaces && WS.test(s[j - 1]!)) return undefined + if (DIGIT.test(s[j + 1] ?? '')) return undefined + return { tex: s.slice(i + 1, j), length: j + 1 - i, display: false } + } } - return out + return undefined } -function mapInlines( - inlines: InlineNode[], +const INLINE_HTML_BLOCK = /^<\s*(?:u|span|em|strong|b|i|font|small|sub|sup|mark)\b/i + +/** Replaces math spans inside a raw block-html string. */ +function replaceMathInString( + html: string, render: (tex: string, displayMode: boolean) => string, - allowSpaces?: boolean, - inlineTag?: string, -): InlineNode[] { - const out: InlineNode[] = [] - for (const node of inlines) { - if ( - node?.type === 'text' && - (node.value.includes('$') || - node.value.includes(MATH_UNDERSCORE_SUB) || - node.value.includes(MATH_BACKSLASH_SUB)) - ) { - out.push(...splitTextNode(node.value, render, allowSpaces, inlineTag)) - } else if (node?.type === 'inlineHtml' && (node as { value: string }).value.includes('$')) { - ;(node as { value: string }).value = replaceMathInHtml( - (node as { value: string }).value, - render, - allowSpaces, - ) - out.push(node) + allowSpaces: boolean, +): string { + let out = '' + let last = 0 + let i = html.indexOf('$') + while (i !== -1) { + const r = findClose(html, i, allowSpaces) + if (r) { + out += html.slice(last, i) + render(restoreMathChars(r.tex).trim(), false) + last = i + r.length + i = html.indexOf('$', last) } else { - const children = (node as { children?: InlineNode[] }).children - if (Array.isArray(children)) { - ;(node as { children: InlineNode[] }).children = mapInlines( - children, - render, - allowSpaces, - inlineTag, - ) - } - out.push(node) + i = html.indexOf('$', i + (html.startsWith('$$', i) ? 2 : 1)) } } - return out + return last === 0 ? html : out + html.slice(last) } -const INLINE_CONTAINER_TYPES = new Set([ - 'paragraph', - 'heading', - 'tableCell', -]) - -function walkNode( - node: unknown, +/** + * Copy-on-change walk over block nodes. Only block-level `html` nodes that + * start with an inline-style tag are rewritten (e.g. `$V_1$ ...` with + * `allowHtml`). Input nodes are never mutated. + */ +function transformBlocks( + node: T, render: (tex: string, displayMode: boolean) => string, - allowSpaces?: boolean, - inlineTag?: string, -): void { - if (!node || typeof node !== 'object') return - const n = node as Record - const type = n.type as string - - if (INLINE_CONTAINER_TYPES.has(type)) { - if (Array.isArray(n.children)) { - n.children = mapInlines( - n.children as InlineNode[], - render, - allowSpaces, - inlineTag, - ) - } - return - } - - if ( - type === 'code' || - type === 'inlineCode' - ) { - return + allowSpaces: boolean, +): T { + if (Array.isArray(node)) { + let changed = false + const next = node.map((c) => { + const r = transformBlocks(c, render, allowSpaces) + if (r !== c) changed = true + return r + }) + return (changed ? next : node) as T } - - if (type === 'html') { - const val = (n.value as string) ?? '' - // Allow transforming math inside inline-style HTML blocks (e.g. ... at start of line) - // while leaving structural block HTML (e.g.
...
) untouched. + if (!node || typeof node !== 'object') return node + const n = node as Record + if (n.type === 'html') { + const val = typeof n.value === 'string' ? n.value : '' if ( - /^<\s*(?:u|span|em|strong|b|i|font|small|sub|sup|mark)\b/i.test(val) && - val.includes('$') + val.includes('$') && + !val.includes('class="katex') && + INLINE_HTML_BLOCK.test(val) ) { - ;(n as { value: string }).value = replaceMathInHtml( - val, - render, - allowSpaces, - ) - } - return - } - - if (type === 'inlineHtml') { - const val = (n.value as string) ?? '' - if (val.includes('$')) { - ;(n as { value: string }).value = replaceMathInHtml( - val, - render, - allowSpaces, - ) - } - return - } - - if (type === 'table') { - if (Array.isArray(n.header)) { - (n.header as unknown[]).forEach((child) => - walkNode(child, render, allowSpaces, inlineTag), - ) - } - if (Array.isArray(n.rows)) { - for (const row of n.rows as unknown[]) { - if (Array.isArray(row)) { - row.forEach((cell) => walkNode(cell, render, allowSpaces, inlineTag)) - } - } + const value = replaceMathInString(val, render, allowSpaces) + if (value !== val) return { ...n, value } as T } - return + return node } - - if (type === 'list') { - if (Array.isArray(n.items)) { - (n.items as unknown[]).forEach((item) => - walkNode(item, render, allowSpaces, inlineTag), - ) - } - return - } - - if (type === 'blockquote' || type === 'callout') { - if (Array.isArray(n.children)) { - (n.children as unknown[]).forEach((child) => - walkNode(child, render, allowSpaces, inlineTag), - ) - } - return - } - - if ( - type === 'strong' || - type === 'emphasis' || - type === 'strike' || - type === 'link' - ) { - if (Array.isArray(n.children)) { - n.children = mapInlines( - n.children as InlineNode[], - render, - allowSpaces, - inlineTag, - ) - } - return - } - - if (Array.isArray(n.children)) { - (n.children as unknown[]).forEach((child) => - walkNode(child, render, allowSpaces, inlineTag), - ) + if (n.type === 'code') return node + let copy: Record | undefined + for (const key of ['children', 'items', 'header', 'rows']) { + const v = n[key] + if (!Array.isArray(v)) continue + const r = transformBlocks(v, render, allowSpaces) + if (r !== v) (copy ??= { ...n })[key] = r } + return (copy ?? node) as T } /** - * Inline math extension. Walks the parsed document and converts every - * balanced `$...$` or `$$...$$` pair into a KaTeX-rendered `inlineHtml` node, - * or into an `inlineComponent` node when `output: 'component'` is set. + * Inline math extension. Uses the `@tanstack/markdown` 1.0 `inlineParser` + * API to turn `$...$` / `$$...$$` spans into KaTeX-rendered `inlineHtml` + * nodes, or `inlineComponent` nodes when `output: 'component'` is set. */ export function mathInlineExtension( opts?: MathInlineOptions, ): MarkdownExtension { const render = getRenderer(opts) - const allowSpaces = opts?.allowSpaces + const allowSpaces = opts?.allowSpaces ?? false const inlineTag = opts?.output === 'component' ? opts.inlineTagName ?? 'MathInline' : undefined return { name: 'math-inline', + inlineParser: { + markers: '$', + parse({ source, index }) { + const r = findClose(source, index, allowSpaces) + if (!r) return undefined + return { + node: mathInlineNode(r.tex, render, false, inlineTag), + length: r.length, + } + }, + }, transformDocument(doc: MarkdownDocument) { - ;(doc.children as unknown[]).forEach((child) => - walkNode(child, render, allowSpaces, inlineTag), - ) - return doc + const children = transformBlocks(doc.children, render, allowSpaces) + return children === doc.children ? doc : { ...doc, children } }, renderHtml(node) { if (!isMathInlineComponentNode(node)) return undefined - return render(restoreMathChars(node.properties?.tex ?? ''), false) + return render(restoreMathChars(node.properties?.tex ?? '').trim(), false) }, } } diff --git a/src/react.tsx b/src/react.tsx index 8314dee..8fd1522 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -1,11 +1,6 @@ import { createElement, memo } from 'react' -import katexRender from 'katex' - -const DEFAULT_KATEX_OPTIONS: katexRender.KatexOptions = { - throwOnError: false, - strict: false, -} +import { defaultRenderer } from './render.js' export interface MathComponentProps { /** Raw TeX payload carried by the `component` node's `properties.tex`. */ @@ -36,10 +31,7 @@ export interface MathComponentProps { * skip the KaTeX call entirely. */ export const MathBlock = memo(function MathBlock({ tex, displayMode = true }: MathComponentProps) { - const html = katexRender.renderToString(tex ?? '', { - ...DEFAULT_KATEX_OPTIONS, - displayMode, - }) + const html = defaultRenderer((tex ?? '').trim(), displayMode) return createElement('div', { className: 'math-block', dangerouslySetInnerHTML: { __html: html }, @@ -68,10 +60,7 @@ export const MathBlock = memo(function MathBlock({ tex, displayMode = true }: Ma * skip the KaTeX call entirely. */ export const MathInline = memo(function MathInline({ tex, displayMode = false }: MathComponentProps) { - const html = katexRender.renderToString(tex ?? '', { - ...DEFAULT_KATEX_OPTIONS, - displayMode, - }) + const html = defaultRenderer((tex ?? '').trim(), displayMode) return createElement('span', { className: 'math-inline', dangerouslySetInnerHTML: { __html: html }, diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..604c7b5 --- /dev/null +++ b/src/render.ts @@ -0,0 +1,36 @@ +import katex from 'katex' + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function renderError(tex: string, displayMode: boolean, err: unknown): string { + const msg = err instanceof Error ? err.message : String(err) + const title = escapeHtml(msg) + const code = escapeHtml(tex) + return displayMode + ? `
KaTeX error: ${title}
${code}
` + : `${code}` +} + +/** + * Default KaTeX renderer shared by the html output mode and the React + * components. Invalid TeX yields a neutral, class-only error element + * (`katex-error-badge` inline, `katex-error-box` in display mode). + */ +export function defaultRenderer(tex: string, displayMode: boolean): string { + try { + return katex.renderToString(tex, { + displayMode, + throwOnError: true, + strict: false, + }) + } catch (err) { + return renderError(tex, displayMode, err) + } +} diff --git a/src/types.ts b/src/types.ts index 87883b6..0787724 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,8 @@ export interface MathOptions { /** * Custom math renderer. Receives the raw TeX string and whether it should * be rendered in display mode. Defaults to `katex.renderToString` with - * `throwOnError: false` and `strict: false`. + * `throwOnError: true` (errors are caught and shown as a neutral + * `katex-error-badge` / `katex-error-box` element) and `strict: false`. */ render?: (tex: string, displayMode: boolean) => string /** @@ -47,6 +48,13 @@ export interface MathOptions { * results are cached; thrown errors propagate uncached. */ cache?: boolean | number + /** + * Whether an unclosed `$$` block (no closing fence before end of input) is + * rendered as math. `true` (default) keeps streaming-friendly behavior: + * the partial formula renders as it arrives. `false` leaves the content as + * plain text until the closing `$$` appears. + */ + unclosedBlock?: boolean } export interface MathBlockOptions { @@ -54,14 +62,14 @@ export interface MathBlockOptions { output?: MathOptions['output'] tagName?: MathOptions['tagName'] cache?: MathOptions['cache'] + unclosedBlock?: MathOptions['unclosedBlock'] } export interface MathInlineOptions { render?: MathOptions['render'] allowSpaces?: MathOptions['allowSpaces'] /** - * Output node type for inline math. Requires `@tanstack/markdown` >= 0.0.15 - * for the `inlineComponent` node type. + * Output node type for inline math. Requires `@tanstack/markdown` >= 1.0.0. * * - `'html'` (default): emits a pre-rendered `inlineHtml` node; renderers * need `allowHtml: true`. diff --git a/tests/component.test.ts b/tests/component.test.ts index 582cbb5..fa65f45 100644 --- a/tests/component.test.ts +++ b/tests/component.test.ts @@ -60,7 +60,7 @@ describe('mathBlockExtension component output — parsing', () => { const doc = parseMarkdown('$$ x = 1 $$', { extensions: [componentExt] }) const components = findComponentNodes(doc) expect(components).toHaveLength(1) - expect(components[0].properties.tex).toBe(' x = 1 ') + expect(components[0].properties.tex).toBe('x = 1') }) it('supports a custom tagName', () => { diff --git a/tests/math.test.ts b/tests/math.test.ts index 2023504..8ca1e65 100644 --- a/tests/math.test.ts +++ b/tests/math.test.ts @@ -505,8 +505,8 @@ describe('edge cases', () => { }) const html = findHtmlNodes(doc) expect(html.length).toBe(1) - expect(html[0].value).toContain('katex-error') - expect(html[0].value).toContain('⚠️') + expect(html[0].value).toContain('katex-error-badge') + expect(html[0].value).not.toContain('⚠️') }) it('closes multiline block when $$ is attached to content on the closing line', () => { diff --git a/tests/v1.test.ts b/tests/v1.test.ts new file mode 100644 index 0000000..565e142 --- /dev/null +++ b/tests/v1.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest' +import { parseMarkdown } from '@tanstack/markdown' +import { renderToStaticMarkup } from 'react-dom/server' +import { Markdown } from '@tanstack/markdown/react' +import { createElement } from 'react' +import { mathBlockExtension, mathExtension } from '../src/index.js' +import { MathBlock, MathInline } from '../src/react.js' + +const render = (tex: string, d: boolean) => `${tex}` +const ext = () => mathExtension({ render }) +const opts = (extra: object = {}) => ({ extensions: ext(), ...extra }) + +function collect(doc: unknown, types: string[]): Array> { + const out: Array> = [] + const walk = (n: any) => { + if (!n || typeof n !== 'object') return + if (Array.isArray(n)) return n.forEach(walk) + if (types.includes(n.type)) out.push(n) + for (const v of Object.values(n)) if (v && typeof v === 'object') walk(v) + } + walk(doc) + return out +} +const maths = (md: string, o: object = {}) => + collect(parseMarkdown(md, opts(o) as never), ['inlineHtml']).map((n) => n.value) + +describe('escapes and currency', () => { + it('keeps escaped dollars literal', () => { + expect(maths('\\$5 and \\$10')).toEqual([]) + expect(maths('\\$a\\$')).toEqual([]) + }) + it('allows escaped dollar inside math', () => { + expect(maths('$a \\$ b$')).toEqual(['a \\$ b']) + }) + it('does not treat currency pairs as math', () => { + expect(maths('$5-$10 range')).toEqual([]) + // whitespace before the closing $ and a digit after it both reject + expect(maths('$5 and $10')).toEqual([]) + // no digit after the closing $: still math + expect(maths('$5+x$ and $y$')).toHaveLength(2) + }) +}) + +describe('math without protectMath', () => { + it.each([ + ['$a*b*c$', 'a*b*c'], + ['$x_{i}*y_{j}$', 'x_{i}*y_{j}'], + ['$\\{a\\}\\\\ b$', '\\{a\\}\\\\ b'], + ['$_2$ and _x_', '_2'], + ])('%s', (md, tex) => { + expect(maths(md)[0]).toBe(`${tex}`) + }) +}) + +describe('structure', () => { + it('handles math in headings (slug includes math source)', () => { + const doc = parseMarkdown('# Title $x_1$ more', opts() as never) + const h = collect(doc, ['heading'])[0] + // The slug is derived from the heading's plain text; math nodes are not + // text, so the math source does not appear in it. + expect(h.id).toBe('title-more') + expect(typeof h.id).toBe('string') + expect(collect(h, ['inlineHtml'])).toHaveLength(1) + }) + it('block math in list, blockquote, callout', async () => { + const { calloutsExtension } = await import('@tanstack/markdown/extensions/callouts') + const o = { extensions: [...ext(), calloutsExtension()] } as never + const blockMath = (md: string) => + collect(parseMarkdown(md, o), ['html']).filter((n) => n.value.includes('')) + expect(blockMath('- item\n\n $$\n x\n $$\n')).toHaveLength(1) + expect(blockMath('> $$\n> y\n> $$')).toHaveLength(1) + expect(blockMath('> [!NOTE]\n> $$\n> z\n> $$')).toHaveLength(1) + }) + it('component output inside lists and tables', () => { + const e = mathExtension({ output: 'component' }) + const doc = parseMarkdown('- a $x_1$\n\n| a | b |\n|---|---|\n| $y$ | z |', { extensions: e }) + const comps = collect(doc, ['inlineComponent']) + expect(comps.map((c) => c.properties.tex)).toEqual(['x_1', 'y']) + }) + it('block html with inline tag still gets math (allowHtml), without mutation', () => { + const o = { allowHtml: true, extensions: ext() } as never + const doc = parseMarkdown('$V_1$ : text', o) + const before = JSON.stringify(doc) + expect(before).toContain('V_1') + }) + it('parsing the same document twice gives identical results', () => { + const md = '# H $a$\n\n- $b$\n\n$c$\n\n| a |\n|---|\n| $d$ |\n\n$$e$$' + const e = ext() + const a = JSON.stringify(parseMarkdown(md, { allowHtml: true, extensions: e })) + const b = JSON.stringify(parseMarkdown(md, { allowHtml: true, extensions: e })) + expect(a).toBe(b) + }) + it('transformDocument does not mutate input nodes', () => { + const inline = ext()[1] + const html = { type: 'html', value: '$a$' } + const doc = { type: 'document', children: [{ type: 'blockquote', children: [html] }] } as any + const frozen = JSON.stringify(doc) + const out = inline.transformDocument!(doc, { options: {} } as never) as any + expect(JSON.stringify(doc)).toBe(frozen) + expect(out).not.toBe(doc) + expect(out.children[0].children[0].value).toContain('a') + }) +}) + +describe('block hardening', () => { + it('does not steal 4-space indented code', () => { + const doc = parseMarkdown(' $$\n x\n $$', opts() as never) + expect(collect(doc, ['html']).filter((n) => n.value.includes('d="true"'))).toHaveLength(0) + }) + it('unclosedBlock: false leaves unclosed $$ as text', () => { + const o = { extensions: [mathBlockExtension({ render, unclosedBlock: false })] } + expect(collect(parseMarkdown('$$\nx', o), ['html'])).toHaveLength(0) + expect(collect(parseMarkdown('$$\nx\n$$', o), ['html'])).toHaveLength(1) + }) + it('unclosedBlock defaults to true', () => { + const o = { extensions: [mathBlockExtension({ render })] } + expect(collect(parseMarkdown('$$\nx', o), ['html'])).toHaveLength(1) + }) + it('trims tex in html and component block modes', () => { + const h = collect(parseMarkdown('$$ x $$', { extensions: [mathBlockExtension({ render })] }), ['html']) + expect(h[0].value).toBe('x') + const c = collect( + parseMarkdown('$$ x $$', { extensions: [mathBlockExtension({ output: 'component' })] }), + ['component'], + ) + expect(c[0].properties.tex).toBe('x') + }) +}) + +describe('default renderer errors', () => { + it('uses neutral english error output', () => { + const doc = parseMarkdown('$\\badmacro{1}$\n\n$$\\badmacro{1}$$', { + extensions: mathExtension(), + }) + const html = collect(doc, ['html', 'inlineHtml']).map((n) => n.value).join('') + expect(html).toContain('katex-error-badge') + expect(html).toContain('katex-error-box') + expect(html).toContain('KaTeX error') + }) + it('React components share the same error output', () => { + const out = renderToStaticMarkup(createElement(MathInline, { tex: '\\badmacro' })) + expect(out).toContain('katex-error-badge') + expect(renderToStaticMarkup(createElement(MathBlock, { tex: ' x ' }))).toContain('katex') + const md = renderToStaticMarkup( + createElement(Markdown, { + extensions: mathExtension({ output: 'component' }), + components: { MathBlock, MathInline }, + children: 'a $\\badmacro$', + } as never), + ) + expect(md).toContain('katex-error-badge') + }) +}) + +describe('robustness', () => { + const corpus = [ + '# Title $x_1$ more', + 'Inline $a*b*c$ and $$\\frac{a}{b}$$ and \\$5 or $5-$10.', + '- item $a_b$\n> quote $c_d$', + '| a | b |\n|---|---|\n| $x_1$ | $y*z$ |', + '$$\n\\begin{aligned}\nx &= 1\n\\end{aligned}\n$$', + '> [!TIP]\n> Use $\\LaTeX$ here.', + '$V_1$ : text and `$code$`', + ].join('\n\n') + + it('parses every prefix of a math-heavy corpus without throwing', async () => { + const { streamingMarkdownExtension } = await import('@tanstack/markdown/extensions/streaming') + const { calloutsExtension } = await import('@tanstack/markdown/extensions/callouts') + const extensions = [...mathExtension({ render }), calloutsExtension(), streamingMarkdownExtension()] + for (let i = 0; i <= corpus.length; i++) { + for (const allowHtml of [false, true]) { + expect(() => parseMarkdown(corpus.slice(0, i), { allowHtml, extensions })).not.toThrow() + } + } + }) + + it.each(['$a ', '$$a ', '$', '\\$a ', '$a\n'])('pathological input %j completes quickly', (unit) => { + const input = unit.repeat(20000) + const t = performance.now() + parseMarkdown(input, opts() as never) + parseMarkdown(input, { allowHtml: true, extensions: ext() } as never) + expect(performance.now() - t).toBeLessThan(5000) + }) +})