import type { Options as ReactMarkdownOptions } from "react-markdown"; import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkFrontmatter from "remark-frontmatter"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; const markdownSanitizeSchema = { ...defaultSchema, attributes: { ...defaultSchema.attributes, code: [["className", /^language-./, "math-inline", "math-display"]], }, strip: [...(defaultSchema.strip || []), "iframe", "object", "style", "form"], }; export function normalizeDisplayMath(markdown: string): string { const lineBreak = markdown.includes("\r\n") ? "\r\n" : "\n"; const lines = markdown.split(/\r?\n/); const normalized: string[] = []; let fence: { marker: string; size: number } | null = null; let inlineCodeMarkerSize = 0; let rawCodeTag: string | null = null; const unmatchedDisplayMathUntil = new Map(); for (let index = 0; index < lines.length; index++) { const line = lines[index]; if (rawCodeTag) { normalized.push(line); if (new RegExp(``, "i").test(line)) rawCodeTag = null; continue; } const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/); if (fenceMatch) { const marker = fenceMatch[1][0]; const size = fenceMatch[1].length; if (!fence) fence = { marker, size }; else if (marker === fence.marker && size >= fence.size) fence = null; inlineCodeMarkerSize = 0; normalized.push(line); continue; } if (fence) { normalized.push(line); continue; } const rawCodeOpen = line.match(/<(code|pre|script|style)\b/i); if (rawCodeOpen) { const tag = rawCodeOpen[1].toLowerCase(); const remainder = line.slice((rawCodeOpen.index ?? 0) + rawCodeOpen[0].length); if (!new RegExp(``, "i").test(remainder)) rawCodeTag = tag; inlineCodeMarkerSize = 0; normalized.push(line); continue; } if (/^(?: {4}|\t)/.test(line) || line.trim() === "") { inlineCodeMarkerSize = 0; normalized.push(line); continue; } if (inlineCodeMarkerSize || line.includes("`")) { inlineCodeMarkerSize = updateInlineCodeMarker(line, inlineCodeMarkerSize); normalized.push(line); continue; } const bracketDisplayOneLine = line.match(/^([ ]{0,3})\\\[[ \t]*(.+?)[ \t]*\\\][ \t]*$/); if (bracketDisplayOneLine) { const math = bracketDisplayOneLine[2].trim(); if (math) { // Keep the content line indented together with the `$$` fence. When the // formula is nested inside a GFM list item (indented `$$`), a content line // at column 0 becomes a "lazy continuation" line, which makes remark-math // mis-parse the fence pair: the opening `$$` turns into an empty math node // and the closing one swallows the rest of the document as math content. normalized.push( `${bracketDisplayOneLine[1]}$$`, `${bracketDisplayOneLine[1]}${math}`, `${bracketDisplayOneLine[1]}$$`, ); continue; } } const bracketDisplayStart = line.match(/^([ ]{0,3})\\\[[ \t]*$/); if (bracketDisplayStart) { const closingIndex = findBracketDisplayClose(lines, index + 1); if (closingIndex !== -1) { // Same lazy-continuation guard as above: indent content lines that sit at // column 0 so the block stays parseable when nested inside a list item. normalized.push( `${bracketDisplayStart[1]}$$`, ...lines.slice(index + 1, closingIndex).map((mathLine) => indentDisplayMathContent(mathLine, bracketDisplayStart[1]), ), `${bracketDisplayStart[1]}$$`, ); index = closingIndex; continue; } } const displayMathMatch = line.match(/^([ \t]{0,3})\$\$(.+)\$\$[ \t]*$/); if (displayMathMatch) { const math = displayMathMatch[2].trim(); if (math) { // See the comment on bracketDisplayOneLine: without matching indentation, // a formula nested in a GFM list item is mis-parsed by remark-math and the // text after the formula renders as a garbled KaTeX error block. normalized.push( `${displayMathMatch[1]}$$`, `${displayMathMatch[1]}${math}`, `${displayMathMatch[1]}$$`, ); continue; } } // remark-math requires both `$$` delimiters to sit on their own lines, but // models also emit display math as a multi-line block where the opening `$$` // is glued to the first formula line and/or the closing `$$` is glued to the // end of the last one (`$$x = 1` + `y = 2$$`). Without normalization such a // block swallows the following text as math content and renders as garbage. const displayMathMultiLine = line.match(/^([ \t]{0,3})\$\$(.+)$/); if (displayMathMultiLine) { const indent = displayMathMultiLine[1]; const firstLine = displayMathMultiLine[2].trimEnd(); // Only treat this as a block opener if no other `$$` is embedded mid-line // (e.g. `$$x$$ and text` stays untouched and is rendered as inline math). if (firstLine && !firstLine.includes("$$")) { const closing = findDisplayMathClose( lines, index + 1, indent, unmatchedDisplayMathUntil, ); if (closing) { normalized.push(`${indent}$$`, `${indent}${firstLine}`); for (let j = index + 1; j < closing.index; j++) { normalized.push(indentDisplayMathContent(lines[j], indent)); } if (closing.content) normalized.push(`${indent}${closing.content}`); normalized.push(`${indent}$$`); index = closing.index; continue; } } } // Bare `$$` opener (possibly indented inside a GFM list item). Two problems // need fixing: (1) when the closing `$$` is glued to the last content line // (e.g. `z = w$$`) remark-math never finds a valid closing fence and swallows // the rest of the document; (2) inside a list item, content lines at column 0 // are lazy continuations that break the math flow. Both are fixed by moving // the closing `$$` to its own line and re-indenting lazy content lines. // A column-0 block with a properly detached closing `$$` is left untouched // (remark-math already parses it correctly). const displayMathBareOpen = line.match(/^([ \t]{0,3})\$\$\s*$/); if (displayMathBareOpen) { const indent = displayMathBareOpen[1]; const closing = findDisplayMathClose( lines, index + 1, indent, unmatchedDisplayMathUntil, ); if (closing && (closing.glued || indent !== "")) { normalized.push(`${indent}$$`); for (let j = index + 1; j < closing.index; j++) { normalized.push(indentDisplayMathContent(lines[j], indent)); } if (closing.content) normalized.push(`${indent}${closing.content}`); normalized.push(`${indent}$$`); index = closing.index; continue; } } normalized.push(normalizeInlineLatexMath(line)); } return normalized.join(lineBreak); } interface DisplayMathClose { index: number; content: string; glued: boolean; } function findDisplayMathClose( lines: string[], startIndex: number, indent: string, unmatchedUntil: Map, ): DisplayMathClose | null { const knownUnmatchedUntil = unmatchedUntil.get(indent); if (knownUnmatchedUntil !== undefined && startIndex < knownUnmatchedUntil) return null; for (let index = startIndex; index < lines.length; index++) { const line = lines[index]; if (isDisplayMathFence(line, indent)) return { index, content: "", glued: false }; // A new Markdown block cannot belong to the preceding formula. In particular, // do not let a later sibling list item provide a closing `$$` for this block. if (isDisplayMathBlockBoundary(line) || isDisplayMathOpeningLine(line)) { unmatchedUntil.set(indent, index); return null; } const content = getDisplayMathGluedCloseContent(line, indent); if (content !== null) return { index, content, glued: true }; } // Multiple unmatched glued openers with the same indentation previously each // scanned to EOF. Cache this range so the overall search remains linear. unmatchedUntil.set(indent, lines.length); return null; } function isDisplayMathFence(line: string, indent: string): boolean { if (indent === "") return /^ {0,3}\$\$\s*$/.test(line); return line.startsWith(indent) && /^\$\$\s*$/.test(line.slice(indent.length)); } function getDisplayMathGluedCloseContent(line: string, indent: string): string | null { if (!line.startsWith(indent)) return null; const match = line.slice(indent.length).match(/^(.+?)\$\$\s*$/); if (!match) return null; const content = match[1].trimEnd(); return content && !content.includes("$$") ? content : null; } function isDisplayMathOpeningLine(line: string): boolean { return /^ {0,3}\$\$(?:\S|[ \t]+\S)/.test(line); } function isDisplayMathBlockBoundary(line: string): boolean { return ( /^ {0,3}(`{3,}|~{3,})/.test(line) || /^[ \t]*(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)/.test(line) || /^ {0,3}#{1,6}(?:[ \t]+|$)/.test(line) || /^ {0,3}>/.test(line) || /<(code|pre|script|style)\b/i.test(line) ); } function indentDisplayMathContent(line: string, indent: string): string { if (!indent || !line || line.startsWith("\t")) return line; const leadingSpaces = line.match(/^ */)?.[0].length ?? 0; if (leadingSpaces >= indent.length) return line; return `${indent.slice(leadingSpaces)}${line}`; } function findBracketDisplayClose(lines: string[], startIndex: number): number { for (let index = startIndex; index < lines.length; index++) { const line = lines[index]; if (/^ {0,3}\\\][ \t]*$/.test(line)) return index; // Do not pair delimiters across another Markdown block boundary. if ( /^ {0,3}(`{3,}|~{3,})/.test(line) || /^ {0,3}\\\[[ \t]*$/.test(line) || /<(code|pre|script|style)\b/i.test(line) ) { return -1; } } return -1; } function updateInlineCodeMarker(line: string, initialMarkerSize: number): number { let markerSize = initialMarkerSize; for (let cursor = 0; cursor < line.length;) { if (line[cursor] !== "`") { cursor++; continue; } let end = cursor + 1; while (line[end] === "`") end++; const runSize = end - cursor; if (markerSize === 0) markerSize = runSize; else if (runSize === markerSize) markerSize = 0; cursor = end; } return markerSize; } function normalizeInlineLatexMath(line: string): string { if ( /^\s{0,3}\[[^\]]+\]:/.test(line) || /]\s*\(/.test(line) || /<(?:!--|\/?[A-Za-z][^>]*>)/.test(line) || /\b(?:https?|file|mailto):/i.test(line) || /\b[A-Za-z]:\\/.test(line) ) { return line; } return line.replace( /(? (math.trim() ? `$${math}$` : match), ); } // Parse YAML frontmatter into a `yaml` node before the math/GFM plugins run, so // the raw metadata never leaks into the rendered output (without it, the opening // `---` becomes an
and the closing `---` turns the YAML into a setext heading). // singleTilde:false requires ~~double~~ tildes for strikethrough. A single `~` // is the standard CJK numeric-range separator (e.g. "5~7U", "100~200倍"), and // GFM's default single-tilde strikethrough silently mangled such ranges (#385). const remarkGfmOptions = { singleTilde: false } as const; export const markdownRemarkPlugins: ReactMarkdownOptions["remarkPlugins"] = [ [remarkFrontmatter, ["yaml"]], [remarkGfm, remarkGfmOptions], remarkMath, ]; export const markdownPreviewRemarkPlugins: ReactMarkdownOptions["remarkPlugins"] = [ [remarkFrontmatter, ["yaml"]], [remarkGfm, remarkGfmOptions], remarkMath, ]; export const markdownRehypePlugins: ReactMarkdownOptions["rehypePlugins"] = [ rehypeRaw, [rehypeSanitize, markdownSanitizeSchema], [rehypeKatex, { throwOnError: false, strict: false }], ]; export const markdownPreviewRehypePlugins: ReactMarkdownOptions["rehypePlugins"] = [ rehypeRaw, [rehypeSanitize, markdownSanitizeSchema], [rehypeKatex, { throwOnError: false, strict: false }], ];