From 51494c9cbeb247664fe772ea4cb69e2d0e461664 Mon Sep 17 00:00:00 2001 From: fz Date: Wed, 5 Aug 2026 14:08:58 +0800 Subject: [PATCH] fix(ui): render common TeX math delimiters --- THIRD_PARTY_NOTICES.md | 5 + packages/client/ui-primitives/package.json | 5 + .../src/markdown/MarkdownText.tsx | 7 +- .../src/markdown/remarkMathCompatibility.ts | 275 ++++++++++++++++++ pnpm-lock.yaml | 15 + 5 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8b04f25504..cf3ca72099 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -63,6 +63,11 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | | [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT | +| [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT | +| [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT | +| [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT | +| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | +| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 49d6c2c2b6..683ca7a93b 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -27,6 +27,11 @@ "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", "micromark-extension-gfm": "^3.0.0", + "micromark-extension-math": "^3.1.0", + "micromark-factory-space": "^2.0.1", + "micromark-util-character": "^2.1.1", + "micromark-util-symbol": "^2.0.1", + "micromark-util-types": "^2.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 72e9168661..6450de55e2 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -5,11 +5,16 @@ import rehypeKatex from 'rehype-katex' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import { CodeBlock } from './CodeBlock.tsx' +import { remarkMathCompatibility } from './remarkMathCompatibility.ts' import 'katex/dist/katex.min.css' import css from './MarkdownText.module.css' const streamingRemarkPlugins = [remarkGfm] -const settledRemarkPlugins = [remarkGfm, remarkMath] +const settledRemarkPlugins = [ + remarkGfm, + remarkMathCompatibility, + remarkMath, +] const settledRehypePlugins = [rehypeKatex] function sanitizeUrl(url: string): string { diff --git a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts new file mode 100644 index 0000000000..16fefccad6 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts @@ -0,0 +1,275 @@ +import { factorySpace } from 'micromark-factory-space' +import type {} from 'micromark-extension-math' +import { markdownLineEnding } from 'micromark-util-character' +import { codes, constants, types } from 'micromark-util-symbol' +import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark-util-types' + +// oxlint-disable typescript/no-this-alias -- micromark binds tokenizer context only on the outer callback. + +interface RemarkProcessor { + data(): { micromarkExtensions?: Extension[] } +} + +const previousBackslash: Previous = function (code) { + return code !== codes.backslash || this.events.at(-1)?.[1].type === types.characterEscape +} + +const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) { + const self = this + + return start + + function start(code: number | null): State | undefined { + if (code !== codes.backslash) return nok(code) + effects.enter('mathText') + effects.enter('mathTextSequence') + effects.consume(code) + return open + } + + function open(code: number | null): State | undefined { + if (code !== codes.leftParenthesis) return nok(code) + effects.consume(code) + effects.exit('mathTextSequence') + return between + } + + function between(code: number | null): State | undefined { + if (code === codes.eof) return nok(code) + if (code === codes.backslash && self.previous !== codes.backslash) { + return effects.attempt({ partial: true, tokenize: tokenizeClose }, close, dataStart)(code) + } + if (markdownLineEnding(code)) { + effects.enter(types.lineEnding) + effects.consume(code) + effects.exit(types.lineEnding) + return between + } + return dataStart(code) + } + + function dataStart(code: number | null): State | undefined { + effects.enter('mathTextData') + effects.consume(code) + return data + } + + function data(code: number | null): State | undefined { + if (code === codes.eof || code === codes.backslash || markdownLineEnding(code)) { + effects.exit('mathTextData') + return between(code) + } + effects.consume(code) + return data + } + + function close(code: number | null): State | undefined { + effects.exit('mathText') + return ok(code) + } + + function tokenizeClose(closeEffects: Parameters[0], closeOk: State, closeNok: State): State { + return slash + + function slash(code: number | null): State | undefined { + if (code !== codes.backslash) return closeNok(code) + closeEffects.enter('mathTextSequence') + closeEffects.consume(code) + return parenthesis + } + + function parenthesis(code: number | null): State | undefined { + if (code !== codes.rightParenthesis) return closeNok(code) + closeEffects.consume(code) + closeEffects.exit('mathTextSequence') + return closeOk + } + } +} + +function createMathFlow(marker: number, openMarker: number, closeMarker: number, multiline: boolean): Construct { + const tokenize: Tokenizer = function (effects, ok, nok) { + const self = this + const tail = self.events.at(-1) + const initialSize = tail?.[1].type === types.linePrefix + ? tail[2].sliceSerialize(tail[1], true).length + : 0 + + return start + + function start(code: number | null): State | undefined { + if (code !== marker) return nok(code) + effects.enter('mathFlow') + effects.enter('mathFlowFence') + effects.enter('mathFlowFenceSequence') + effects.consume(code) + return open + } + + function open(code: number | null): State | undefined { + if (code !== openMarker) return nok(code) + effects.consume(code) + effects.exit('mathFlowFenceSequence') + effects.exit('mathFlowFence') + return marker === codes.dollarSign ? afterDollarOpen : content + } + + function afterDollarOpen(code: number | null): State | undefined { + return code === codes.dollarSign ? nok(code) : content(code) + } + + function content(code: number | null): State | undefined { + if (code === codes.eof) return nok(code) + if (code === marker && (marker !== codes.backslash || self.previous !== codes.backslash)) { + return effects.attempt({ partial: true, tokenize: tokenizeClosingFence }, closed, markerValueStart)(code) + } + if (markdownLineEnding(code)) { + return multiline + ? effects.attempt(nonLazyContinuation, afterContinuation, nok)(code) + : nok(code) + } + return valueStart(code) + } + + function afterContinuation(code: number | null): State | undefined { + return effects.attempt( + { partial: true, tokenize: tokenizeClosingFence }, + closed, + initialSize + ? factorySpace(effects, content, types.linePrefix, initialSize + 1) + : content, + )(code) + } + + function valueStart(code: number | null): State | undefined { + effects.enter('mathFlowValue') + effects.consume(code) + return value + } + + function markerValueStart(code: number | null): State | undefined { + effects.enter('mathFlowValue') + effects.consume(code) + return valueAfterMarker + } + + function valueAfterMarker(code: number | null): State | undefined { + if (code === marker) { + effects.consume(code) + return value + } + return value(code) + } + + function value(code: number | null): State | undefined { + if (code === codes.eof || code === marker || markdownLineEnding(code)) { + effects.exit('mathFlowValue') + return content(code) + } + effects.consume(code) + return value + } + + function closed(code: number | null): State | undefined { + effects.exit('mathFlow') + return ok(code) + } + + function tokenizeClosingFence( + closeEffects: Parameters[0], + closeOk: State, + closeNok: State, + ): State { + return factorySpace(closeEffects, sequenceStart, types.linePrefix, constants.tabSize) + + function sequenceStart(code: number | null): State | undefined { + if (code !== marker) return closeNok(code) + closeEffects.enter('mathFlowFence') + closeEffects.enter('mathFlowFenceSequence') + closeEffects.consume(code) + return sequenceEnd + } + + function sequenceEnd(code: number | null): State | undefined { + if (code !== closeMarker) return closeNok(code) + closeEffects.consume(code) + closeEffects.exit('mathFlowFenceSequence') + return factorySpace(closeEffects, after, types.whitespace) + } + + function after(code: number | null): State | undefined { + if (code !== codes.eof && !markdownLineEnding(code)) return closeNok(code) + closeEffects.exit('mathFlowFence') + return closeOk(code) + } + } + } + + return { + concrete: true, + name: marker === codes.dollarSign ? 'sameLineDollarMathFlow' : 'backslashMathFlow', + tokenize, + } +} + +const tokenizeNonLazyContinuation: Tokenizer = function (effects, ok, nok) { + const self = this + + return start + + function start(code: number | null): State | undefined { + if (code === codes.eof) return ok(code) + if (!markdownLineEnding(code)) return nok(code) + effects.enter(types.lineEnding) + effects.consume(code) + effects.exit(types.lineEnding) + return lineStart + } + + function lineStart(code: number | null): State | undefined { + return self.parser.lazy[self.now().line] ? nok(code) : ok(code) + } +} + +const nonLazyContinuation: Construct = { + partial: true, + tokenize: tokenizeNonLazyContinuation, +} + +const backslashMathText: Construct = { + name: 'backslashMathText', + previous: previousBackslash, + tokenize: tokenizeBackslashMathText, +} + +const backslashMathFlow = createMathFlow( + codes.backslash, + codes.leftSquareBracket, + codes.rightSquareBracket, + true, +) + +const sameLineDollarMathFlow = createMathFlow( + codes.dollarSign, + codes.dollarSign, + codes.dollarSign, + false, +) + +const backslashMath: Extension = { + flow: { + [codes.backslash]: backslashMathFlow, + [codes.dollarSign]: sameLineDollarMathFlow, + }, + text: { [codes.backslash]: backslashMathText }, +} + +/** + * Add TeX backslash delimiters and same-line display-dollar blocks to remark. + * @returns Nothing. + */ +export function remarkMathCompatibility(this: RemarkProcessor): undefined { + const data = this.data() + const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = []) + extensions.push(backslashMath) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f56f2dd154..fea066e912 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1697,6 +1697,21 @@ importers: micromark-extension-gfm: specifier: ^3.0.0 version: 3.0.0 + micromark-extension-math: + specifier: ^3.1.0 + version: 3.1.0 + micromark-factory-space: + specifier: ^2.0.1 + version: 2.0.1 + micromark-util-character: + specifier: ^2.1.1 + version: 2.1.1 + micromark-util-symbol: + specifier: ^2.0.1 + version: 2.0.1 + micromark-util-types: + specifier: ^2.0.2 + version: 2.0.2 react: specifier: ^18.2.0 version: 18.3.1