Skip to content

feat(markdown): support LaTeX rendering#1946

Merged
arnestrickmann merged 5 commits into
generalaction:mainfrom
janburzinski:emdash/latex-rendering-markdown-0k5l3
May 10, 2026
Merged

feat(markdown): support LaTeX rendering#1946
arnestrickmann merged 5 commits into
generalaction:mainfrom
janburzinski:emdash/latex-rendering-markdown-0k5l3

Conversation

@janburzinski
Copy link
Copy Markdown
Collaborator

summary

support latex rendering in markdown

image

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 9, 2026

Greptile Summary

This PR adds LaTeX math rendering to the markdown renderer using remark-math + rehype-katex, with a custom pre-processing step (normalizeLatexDelimiters) that converts \(...\) and \[...\] delimiters to dollar-sign syntax before the unified pipeline runs.

  • Plugin architecture: rehypeSanitize runs before rehypeKatex so user-supplied markdown is sanitized first; the sanitize schema is extended to preserve the math-inline/math-display class names that remark-math emits, allowing rehypeKatex to find them post-sanitize. Plugin arrays are defined at module scope, avoiding the reference-instability issue that would cause react-markdown to re-run the full pipeline on every render.
  • Delimiter normaliser (markdown-latex.ts): A hand-rolled scanner correctly skips fenced code blocks, but two edge cases in the forward-search logic can misidentify closing delimiters — findClosingDelimiter has no code-span awareness when scanning for $$, and the backtick-span handler uses indexOf which can match inside a longer fence sequence.
  • Global CSS: KaTeX stylesheet is imported once in main.tsx, applying to all renderer contexts.

Confidence Score: 5/5

Safe 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

Important Files Changed

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]
Loading
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

Comment on lines +296 to +300
const components = variant === 'full' ? fullComponents : compactComponents;
const rehypePlugins: PluggableList =
variant === 'full'
? [rehypeRaw, [rehypeSanitize, sanitizeSchema]]
: [[rehypeSanitize, sanitizeSchema]];
? [rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]
: [[rehypeSanitize, sanitizeSchema], rehypeKatex];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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.

Comment on lines +304 to +305
<Markdown
remarkPlugins={[remarkGfm, remarkMath]}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

@janburzinski
Copy link
Copy Markdown
Collaborator Author

@greptile

@janburzinski
Copy link
Copy Markdown
Collaborator Author

@greptile

Copy link
Copy Markdown
Contributor

@arnestrickmann arnestrickmann left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm -

for your homework 🙏🙂‍↕️🙂‍↕️

@arnestrickmann arnestrickmann merged commit 8609302 into generalaction:main May 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants