fix(ui): render common TeX math delimiters

This commit is contained in:
fz
2026-08-05 14:08:58 +08:00
parent a1221f315f
commit 51494c9cbe
5 changed files with 306 additions and 1 deletions

View File

@@ -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 |

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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<Tokenizer>[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<Tokenizer>[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)
}

15
pnpm-lock.yaml generated
View File

@@ -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