` (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)
+ })
+})