-
-
Notifications
You must be signed in to change notification settings - Fork 334
feat(messages): add optional LaTeX math rendering #587
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
1db15a4
d6339dd
9be38ee
2d48c9e
26ec05c
43c429c
3343a39
a930fea
eb9be12
ec7bd07
e2bb038
2eff724
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,8 @@ | ||
| import { useEffect, useRef, useState, type ReactNode, type MouseEvent } from "react"; | ||
| import ReactMarkdown, { type Components } from "react-markdown"; | ||
| import rehypeKatex from "rehype-katex"; | ||
| import remarkGfm from "remark-gfm"; | ||
| import remarkMath from "remark-math"; | ||
| import { openUrl } from "@tauri-apps/plugin-opener"; | ||
| import { | ||
| describeFileTarget, | ||
|
|
@@ -20,6 +22,7 @@ type MarkdownProps = { | |
| codeBlock?: boolean; | ||
| codeBlockStyle?: "default" | "message"; | ||
| codeBlockCopyUseModifier?: boolean; | ||
| enableMathRendering?: boolean; | ||
| showFilePath?: boolean; | ||
| workspacePath?: string | null; | ||
| onOpenFileLink?: (path: ParsedFileLocation) => void; | ||
|
|
@@ -296,6 +299,84 @@ function normalizeListIndentation(value: string) { | |
| return normalized.join("\n"); | ||
| } | ||
|
|
||
| const MARKDOWN_FENCE_PATTERN = /^\s*(```|~~~)/; | ||
| const INLINE_CODE_PLACEHOLDER_PREFIX = "\u0000CODExINLINECODE"; | ||
| const INLINE_CODE_PLACEHOLDER_SUFFIX = "\u0000"; | ||
| const INLINE_CODE_PATTERN = /(`+)([\s\S]*?)\1/g; | ||
|
|
||
| function normalizeLatexMathDelimitersInChunk(value: string) { | ||
| const inlineCodeSpans: string[] = []; | ||
| const withMaskedInlineCode = value.replace(INLINE_CODE_PATTERN, (match) => { | ||
| const index = inlineCodeSpans.length; | ||
| inlineCodeSpans.push(match); | ||
| return `${INLINE_CODE_PLACEHOLDER_PREFIX}${index}${INLINE_CODE_PLACEHOLDER_SUFFIX}`; | ||
| }); | ||
|
|
||
| const withBlockMath = withMaskedInlineCode.replace( | ||
| /\\\[([\s\S]*?)\\\]/g, | ||
| (match, body: string) => { | ||
| const trimmed = body.trim(); | ||
| if (!trimmed) { | ||
| return match; | ||
| } | ||
| return `$$\n${trimmed}\n$$`; | ||
| }, | ||
| ); | ||
| const withInlineMath = withBlockMath.replace( | ||
| /\\\(([\s\S]*?)\\\)/g, | ||
| (match, body: string) => { | ||
| const trimmed = body.trim(); | ||
| if (!trimmed) { | ||
| return match; | ||
| } | ||
| return `$${trimmed}$`; | ||
| }, | ||
| ); | ||
|
|
||
| return withInlineMath.replace( | ||
| new RegExp( | ||
| `${INLINE_CODE_PLACEHOLDER_PREFIX}(\\d+)${INLINE_CODE_PLACEHOLDER_SUFFIX}`, | ||
| "g", | ||
| ), | ||
| (_match, indexString: string) => { | ||
| const index = Number(indexString); | ||
| return inlineCodeSpans[index] ?? _match; | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| function normalizeLatexMathDelimiters(value: string) { | ||
| const lines = value.split(/\r?\n/); | ||
| const output: string[] = []; | ||
| let inFence = false; | ||
| let nonFenceChunk: string[] = []; | ||
|
|
||
| const flushNonFenceChunk = () => { | ||
| if (nonFenceChunk.length === 0) { | ||
| return; | ||
| } | ||
| output.push(normalizeLatexMathDelimitersInChunk(nonFenceChunk.join("\n"))); | ||
| nonFenceChunk = []; | ||
| }; | ||
|
|
||
| for (const line of lines) { | ||
| if (MARKDOWN_FENCE_PATTERN.test(line)) { | ||
| flushNonFenceChunk(); | ||
| inFence = !inFence; | ||
| output.push(line); | ||
|
||
| continue; | ||
| } | ||
| if (inFence) { | ||
| output.push(line); | ||
| continue; | ||
| } | ||
| nonFenceChunk.push(line); | ||
| } | ||
|
|
||
| flushNonFenceChunk(); | ||
| return output.join("\n"); | ||
| } | ||
|
|
||
| function LinkBlock({ urls }: LinkBlockProps) { | ||
| return ( | ||
| <div className="markdown-linkblock"> | ||
|
|
@@ -434,15 +515,20 @@ export function Markdown({ | |
| codeBlock, | ||
| codeBlockStyle = "default", | ||
| codeBlockCopyUseModifier = false, | ||
| enableMathRendering = false, | ||
| showFilePath = true, | ||
| workspacePath = null, | ||
| onOpenFileLink, | ||
| onOpenFileLinkMenu, | ||
| onOpenThreadLink, | ||
| }: MarkdownProps) { | ||
| const markdownValue = codeBlock ? value : normalizeListIndentation(value); | ||
| const mathNormalizedValue = !codeBlock && enableMathRendering | ||
| ? normalizeLatexMathDelimiters(markdownValue) | ||
| : markdownValue; | ||
| const normalizedValue = codeBlock | ||
| ? value | ||
| : normalizeStructuredReviewTables(normalizeListIndentation(value)); | ||
| ? mathNormalizedValue | ||
| : normalizeStructuredReviewTables(mathNormalizedValue); | ||
| const content = codeBlock | ||
| ? `\`\`\`\n${normalizedValue}\n\`\`\`` | ||
| : normalizedValue; | ||
|
|
@@ -611,7 +697,12 @@ export function Markdown({ | |
| return ( | ||
| <div className={className}> | ||
| <ReactMarkdown | ||
| remarkPlugins={[remarkGfm, remarkFileLinks]} | ||
| remarkPlugins={ | ||
| enableMathRendering | ||
| ? [remarkGfm, remarkMath, remarkFileLinks] | ||
| : [remarkGfm, remarkFileLinks] | ||
| } | ||
| rehypePlugins={enableMathRendering ? [rehypeKatex] : undefined} | ||
| urlTransform={(url) => { | ||
| const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url); | ||
| // Keep file-like hrefs intact before scheme sanitization runs, otherwise | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With math rendering enabled,
normalizeLatexMathDelimitersInChunkrewrites every\(...\)match before Markdown parsing, so it also mutates link destinations and other syntax content. For example,[wiki](https://en.wikipedia.org/wiki/Function_\(mathematics\))is transformed to a URL containing$mathematics$, which breaks the original link target. This should be limited to plain text/math contexts (or done after parsing on text nodes) instead of applying a global regex over the raw Markdown source.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks. I pushed a follow-up fix that addresses this.
What changed:
\(...\)/\[...\]conversion no longer mutates markdown link destinations.[wiki](https://en.wikipedia.org/wiki/Function_\(mathematics\))remain unchanged.
Tests added/updated:
Markdown.test.tsxnow passes with the new cases, andnpm run typecheckis clean.So this should close both concerns: