Browse Source

fix(ui): render common TeX math delimiters

fz 1 month ago
parent
commit
51494c9cbe

+ 5 - 0
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 |

+ 5 - 0
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",

+ 6 - 1
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 {

+ 275 - 0
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<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 - 0
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