feat(markdown): support LaTeX rendering#1946
Conversation
Greptile SummaryThis PR adds LaTeX math rendering to the markdown renderer using
Confidence Score: 5/5Safe to merge; the two edge cases in the delimiter scanner only affect unusual inputs and do not affect the happy path or security posture. The core rendering pipeline is correctly ordered and the sanitize schema extension is sound. The two scanning edge cases in normalizeLatexDelimiters are narrow enough that they are unlikely to appear in real LLM-generated or user-written math content, and the worst outcome is a malformed display-math block rather than a security or data-loss issue. src/renderer/lib/ui/markdown-latex.ts — the findClosingDelimiter and indexOf-based code-span logic warrant a second look
|
| Filename | Overview |
|---|---|
| src/renderer/lib/ui/markdown-latex.ts | New utility normalizing LaTeX delimiters before remark-math; correctly skips fenced code blocks, but findClosingDelimiter lacks code-span awareness for the $$ case and indexOf can match inside longer backtick sequences |
| src/renderer/lib/ui/markdown-renderer.tsx | Adds remark-math and rehype-katex with module-level plugin constants; correct plugin order places rehypeSanitize before rehypeKatex; sanitize schema extended to preserve math class names |
| src/renderer/lib/ui/markdown-latex.test.ts | Good coverage of the five main normalisation scenarios including fenced blocks and unmatched delimiters |
| src/renderer/main.tsx | Adds global KaTeX CSS import at entry point |
| package.json | Adds katex, rehype-katex, remark-math as runtime deps and @types/katex as dev dep |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Markdown content prop] --> B[normalizeLatexDelimiters]
B --> C[remark-gfm]
C --> D[remark-math]
D --> E[remark-to-rehype]
E --> G{variant}
G -->|full| H[rehypeRaw]
G -->|compact| I[rehypeSanitize]
H --> I
I --> J[rehypeKatex]
J --> K[React DOM]
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
src/renderer/lib/ui/markdown-latex.ts:73-80
**`findClosingDelimiter` does not skip code spans when searching for `$$`**
When the outer loop reaches an opening `$$`, it delegates the forward scan entirely to `findClosingDelimiter`, which performs a plain linear search without any awareness of backtick spans. Input such as `` $$ text `inner $$` end $$ `` causes the closing position to be detected at the `$$` inside the code span, splitting the display-math block prematurely and leaving the remainder of the line as stray literal characters. The existing tests don't cover this case because they only verify that code-span content is preserved *at the loop level* — the `$$` branch is exercised independently.
### Issue 2 of 2
src/renderer/lib/ui/markdown-latex.ts:62-71
**`indexOf` on the closing backticks can match inside a longer fence sequence**
`content.indexOf(ticks, index + ticks.length)` finds the next occurrence of the exact backtick run as a substring, so a double-backtick span immediately followed by a triple-backtick fence could have its closing marker matched at the first two ticks of the fence opening, corrupting `inFence` state for the remainder of the document and causing math expressions inside the missed fence to be transformed unexpectedly.
Reviews (3): Last reviewed commit: "fix(markdown): separate adjacent latex b..." | Re-trigger Greptile
| const components = variant === 'full' ? fullComponents : compactComponents; | ||
| const rehypePlugins: PluggableList = | ||
| variant === 'full' | ||
| ? [rehypeRaw, [rehypeSanitize, sanitizeSchema]] | ||
| : [[rehypeSanitize, sanitizeSchema]]; | ||
| ? [rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex] | ||
| : [[rehypeSanitize, sanitizeSchema], rehypeKatex]; |
There was a problem hiding this comment.
The
rehypePlugins array (and its inner [rehypeSanitize, sanitizeSchema] tuple) is reconstructed on every render, giving react-markdown a new reference each time. react-markdown compares plugin arrays by reference and re-runs the full unified pipeline when they differ, which now includes KaTeX rendering. Wrapping these in useMemo (keyed on variant) eliminates unnecessary re-processing when only unrelated state changes trigger a re-render.
| const components = variant === 'full' ? fullComponents : compactComponents; | |
| const rehypePlugins: PluggableList = | |
| variant === 'full' | |
| ? [rehypeRaw, [rehypeSanitize, sanitizeSchema]] | |
| : [[rehypeSanitize, sanitizeSchema]]; | |
| ? [rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex] | |
| : [[rehypeSanitize, sanitizeSchema], rehypeKatex]; | |
| const components = variant === 'full' ? fullComponents : compactComponents; | |
| const rehypePlugins: PluggableList = useMemo( | |
| () => | |
| variant === 'full' | |
| ? [rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex] | |
| : [[rehypeSanitize, sanitizeSchema], rehypeKatex], | |
| [variant] | |
| ); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/renderer/lib/ui/markdown-renderer.tsx
Line: 296-300
Comment:
The `rehypePlugins` array (and its inner `[rehypeSanitize, sanitizeSchema]` tuple) is reconstructed on every render, giving `react-markdown` a new reference each time. `react-markdown` compares plugin arrays by reference and re-runs the full unified pipeline when they differ, which now includes KaTeX rendering. Wrapping these in `useMemo` (keyed on `variant`) eliminates unnecessary re-processing when only unrelated state changes trigger a re-render.
```suggestion
const components = variant === 'full' ? fullComponents : compactComponents;
const rehypePlugins: PluggableList = useMemo(
() =>
variant === 'full'
? [rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]
: [[rehypeSanitize, sanitizeSchema], rehypeKatex],
[variant]
);
```
How can I resolve this? If you propose a fix, please make it concise.| <Markdown | ||
| remarkPlugins={[remarkGfm, remarkMath]} |
There was a problem hiding this comment.
remarkMath applied unconditionally to both variants
remark-math is now active for every markdown render in the app, including compact contexts (file summaries, tooltips, etc.). The parser treats any balanced $...$ pair as inline math — so content like $HOME and $PATH in a sentence, or price text like costs $10 – $20, will silently render as a KaTeX error node instead of plain text. This can be particularly disruptive in terminal/shell output, file paths, or any free-form content that happens to contain two dollar signs. Consider accepting a math?: boolean prop (defaulting to false or only true for variant === 'full') to opt in where math is actually expected.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/renderer/lib/ui/markdown-renderer.tsx
Line: 304-305
Comment:
**`remarkMath` applied unconditionally to both variants**
`remark-math` is now active for every markdown render in the app, including compact contexts (file summaries, tooltips, etc.). The parser treats any balanced `$...$` pair as inline math — so content like `$HOME` and `$PATH` in a sentence, or price text like `costs $10 – $20`, will silently render as a KaTeX error node instead of plain text. This can be particularly disruptive in terminal/shell output, file paths, or any free-form content that happens to contain two dollar signs. Consider accepting a `math?: boolean` prop (defaulting to `false` or only `true` for `variant === 'full'`) to opt in where math is actually expected.
How can I resolve this? If you propose a fix, please make it concise.|
@greptile |
|
@greptile |
arnestrickmann
left a comment
There was a problem hiding this comment.
lgtm -
for your homework 🙏🙂
summary
support latex rendering in markdown