Skip to content

fix(markdown): keep an escaped dollar escaped, and render display math inside a paragraph - #422

Merged
PathGao merged 1 commit into
masterfrom
fix/inline-display-math-contract
Aug 3, 2026
Merged

fix(markdown): keep an escaped dollar escaped, and render display math inside a paragraph#422
PathGao merged 1 commit into
masterfrom
fix/inline-display-math-contract

Conversation

@PathGao

@PathGao PathGao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Two holes on the same seam — and one of them was inside the contract #402 built, which is the more interesting half.

1. \$\$x\$\$ renders as math

Writing \$\$x\$\$ means "I want literal dollar signs." It renders as a formula.

Traced end to end, running KaTeX's real auto-render.mjs with katex.render swapped for a recorder:

raw     Literal \$\$x\$\$ here.
 └ backend  find_math_spans → []          ← correct: escaped is not math
 └ comrak                   → "Literal $$x$$ here."   ← un-escapes; the evidence is gone
 └ frontend                 → cannot tell them apart
 └ KaTeX                    → ["display:x"]           ← escape defeated

The backend gets it right and then throws the answer away. Inline \$x\$ fails the same way.

2. $$x$$ inside a paragraph was outside the contract

Inline prose with $$x_1$$ mixed in.
backend find_math_spans masks it — ("display","x_1")
frontend convertInlineMathDelimiters passes it through verbatim — no \(…\), no data-math
the contract's extractor cannot see it — it only reads those two artefacts
what actually renders it renderMathInElement's own $$ delimiter — a fourth channel

So the earlier diagnosis needs one correction: P and R were in fact equal — the paragraph-level $$ really does render. What was wrong is that the contract could not see part of R. The corpus had thirteen display cases and every one was the block form, so the test was green on a path it never looked at.

Why the frontend cannot fix the escape alone

\$\$x\$\$ and $$x$$ reach the frontend as the same eight bytes. No rule reading comrak's output can separate them — that is provable, not a judgement call.

And the corpus now demands both a paragraph-$$ positive and an escaped negative. Those two requirements together force the backend to preserve what the frontend needs. The fix follows from the corpus rather than from taste:

  • Backend: the mask now covers \$ runs as well as math spans, restoring them verbatim — handing the decision back to the side the user can see, which is the direction fix(markdown): hide math delimiters from comrak instead of patching characters #402 already chose. (Moving the decision into the backend does not work: restoration is a plain string substitution, and landing inside an href, an alt, or a heading anchor would break links and anchors.)
  • Frontend: convertInlineMathDelimiters takes over paragraph-level $$, emitting \[…\] — symmetric with \(…\), and like it, something CommonMark cannot spell and only markdown.ts mints — and un-escapes \$$ in place.
  • KaTeX: $$ removed from the delimiter list, leaving \(…\) and \[…\].

The equality argument is now structural, not enumerated

Every delimiter KaTeX accepts can only be produced by markdown.ts (\(…\), \[…\], data-math), and CommonMark cannot spell any of them, so a fourth channel cannot exist. That is asserted directly against the exported MATH_DELIMITERS, not grepped. The KNOWN GAP note in the corpus is deleted, because it is no longer a gap.

Two findings that came out of it

  • A dead branch that came alive elsewhere. convertInlineMathDelimiters's text[index - 1] === "\\" check has never run — comrak ate the backslash long before. It became live when fix(markdown): hide math delimiters from comrak instead of patching characters #402 ported the same rule to Rust, where the raw markdown still has it.
  • A second escaped case the scan missed: \$\$x\$\$ alone on its own line goes through processDisplayMathBlocks and gets a real data-math="display" — visible to the existing extractor, and red on the baseline.

Misjudgement guards

All fourteen stay green, and the corpus math lists are unchanged. All 24 existing cases pass on both sides, including the four mixed guards (\$100 next to real math, spans crossing inline code, $a$ inside code, $$ in a fence not flipping parity). Observable output re-measured: It cost $100 and $200 today. unchanged · Costs \$5 only.Costs $5 only. with no stray backslash · $a \$ b$\(a \$ b\), TeX escape preserved.

Red / green

Corpus added, implementation reverted:

new frontend cases 3 red / 2 greenan escaped display delimiter alone in a paragraph (got [display:x]), an escaped inline delimiter (got [inline:x]), same-line display math mixed into prose (want [display:x_1], got [])
the fourth green — and that one is the blind spot itself
final npm test 541 / 541, cargo test 141 / 141

Reverse-stash checks: reverting the frontend → 3 red; reverting only lib.rs → the live-capture test catches the HTML drift; reverting lib.rs and refreshing the captured HTML to silence it — i.e. the "just re-record the fixture" move — leaves the frontend contract red on Literal \$\$x\$\$ here.. The case the baseline could not see is now visible.

npm run check   438 files, 0 errors
npm test        541 / 541
cargo test      141 / 141
cargo clippy    3 warnings, identical to baseline

Not covered

  • Hand-written \(…\) / \[…\] in a document still do not work — comrak eats the backslash first. Listed as a separate change in fix(markdown): hide math delimiters from comrak instead of patching characters #402 and still is.
  • A paragraph-level formula containing a literal \] (or \) inline) truncates early. Same class as the old $$ delimiter meeting a $$ inside itself — not new, but the closing token changed.
  • \\$x$ is treated as not-math by both sides; strict CommonMark would disagree. Pre-existing rule, now explicit rather than accidental.
  • No browser screenshots. The final link — auto-render's delimiter decision — was verified by running the real module with katex.render instrumented; glyph-level rendering was not inspected.
  • One assertion in scripts/renderProtocol.test.ts pinned "paragraph-level $$ stays literal" — the behaviour being fixed — so it is updated ($$a_1$$\[a_1\]) with the fixture and other cases untouched.

🤖 Generated with Claude Code

…h inside a paragraph

Two holes on the same seam, one of them in the contract #402 built.

`\$\$x\$\$` renders as math. comrak un-escapes the backslashes before the
frontend sees anything, so `\$\$x\$\$` and `$$x$$` arrive as the same
eight bytes and no rule reading comrak's output can tell them apart. The
backend gets it right - `find_math_spans` returns nothing for the escaped
form - and then throws the evidence away. Confirmed end to end by running
KaTeX's real auto-render with `katex.render` instrumented: `["display:x"]`.
The same is true of inline `\$x\$`.

`$$x$$` inside a paragraph was masked by the backend but passed through
verbatim by `convertInlineMathDelimiters`, and rendered by a fourth
channel the contract cannot see - `renderMathInElement`'s own `$$`
delimiter. The corpus had thirteen display cases and every one of them
was the block form, so the test was green on a path it never looked at.

Fixing the escape in the frontend alone is not possible, and the corpus
now demands both an inline-`$$` positive and an escaped negative, which
forces the backend to preserve what the frontend needs. So the mask now
covers `\$` runs as well as math spans and restores them verbatim,
handing the decision back to the side the user can see - the same
direction #402 chose. The frontend then takes over paragraph-level `$$`,
emitting `\[…\]`, and `$$` is removed from KaTeX's delimiter list.

That makes the equality argument structural rather than enumerated: every
delimiter KaTeX now accepts - `\(…\)`, `\[…\]`, `data-math` - can only be
minted by markdown.ts, and CommonMark cannot spell any of them, so no
fourth channel exists. A test asserts the delimiter list directly.

The `text[index - 1] === "\\"` branch in `convertInlineMathDelimiters`
turns out never to have run: comrak had already eaten the backslash. It
became live when #402 ported the rule to Rust, where the raw markdown
still has it.

All fourteen misjudgement guards stay green and the corpus `math` lists
are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit 4b455da into master Aug 3, 2026
4 checks passed
@PathGao
PathGao deleted the fix/inline-display-math-contract branch August 3, 2026 07:09
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.

1 participant