fix(markdown): keep an escaped dollar escaped, and render display math inside a paragraph - #422
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 mathWriting
\$\$x\$\$means "I want literal dollar signs." It renders as a formula.Traced end to end, running KaTeX's real
auto-render.mjswithkatex.renderswapped for a recorder: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 contractInline prose with $$x_1$$ mixed in.find_math_spans("display","x_1")convertInlineMathDelimiters\(…\), nodata-mathrenderMathInElement's own$$delimiter — a fourth channelSo 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:\$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 anhref, analt, or a heading anchor would break links and anchors.)convertInlineMathDelimiterstakes over paragraph-level$$, emitting\[…\]— symmetric with\(…\), and like it, something CommonMark cannot spell and onlymarkdown.tsmints — and un-escapes\$→$in place.$$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 exportedMATH_DELIMITERS, not grepped. TheKNOWN GAPnote in the corpus is deleted, because it is no longer a gap.Two findings that came out of it
convertInlineMathDelimiters'stext[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.\$\$x\$\$alone on its own line goes throughprocessDisplayMathBlocksand gets a realdata-math="display"— visible to the existing extractor, and red on the baseline.Misjudgement guards
All fourteen stay green, and the corpus
mathlists are unchanged. All 24 existing cases pass on both sides, including the four mixed guards (\$100next 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:
an 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[])npm test541 / 541,cargo test141 / 141Reverse-stash checks: reverting the frontend → 3 red; reverting only
lib.rs→ the live-capture test catches the HTML drift; revertinglib.rsand refreshing the captured HTML to silence it — i.e. the "just re-record the fixture" move — leaves the frontend contract red onLiteral \$\$x\$\$ here.. The case the baseline could not see is now visible.Not covered
\(…\)/\[…\]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.\](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.katex.renderinstrumented; glyph-level rendering was not inspected.scripts/renderProtocol.test.tspinned "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