chore(markdown): upgrade comrak 0.18 → 0.54 - #426
Conversation
The parser had not moved since 2023-12-21 (97b77ec), and `comrak = "0.18"` on a 0.x crate is a hard pin — `>=0.18.0, <0.19.0` — not a lag. Thirty-six minor versions later the API had moved and so had the output. API: `ComrakOptions`/`ComrakExtensionOptions` were deleted in 0.54 along with every other deprecation, `header_ids` became `header_id_prefix` in 0.52 (the value was always a prefix, never a boolean), `render.unsafe_` became `render.r#unsafe`, and `Anchorizer::anchorize` took `&str` instead of `String` from 0.42. All mechanical. The one that was not mechanical is the task-list checkbox. comrak 0.18 wrote `disabled="" checked=""` and 0.54 writes `checked="" disabled=""`, and `TASK_ITEM_RE` spelled the 0.18 order out literally. The upgrade alone made it stop matching *completed* tasks and only those: every `- [x]` item silently lost `data-task-checkbox` and became untoggleable while every `- [ ]` item kept working. Four existing tests caught it, which is the whole reason they exist. The pattern now matches the boolean attributes as an unordered set and `annotate_task_checkboxes` inserts after the tag name rather than after ` disabled=""`, so the next reordering cannot reach it either. No hand-rolled rewrite was replaced by a parser option. All three candidates were tested against this repo's own reference corpus rather than the changelog, and none of them does the same job: * `math_dollars` (0.22) pairs `$` across a hard line break and across an inline code span. scripts/mathDelimiterCorpus.json requires both to be refused — that refusal is what keeps `Costs $5` / `Sale $10` on adjacent lines from being typeset. 25 of 29 corpus cases agree, 2 disagree that way, 2 differ only by HTML entity escaping the DOM undoes. The mask stays. * wikilinks (0.24) is Obsidian's bare `[[Note]]`, which `process_wikilinks` deliberately does not claim. Measured: comrak turns `[[1]]` into a link, strands the `(url)` of `[[1#x]](https://example.com)`, pre-empts a `[foo]: url` reference link, and does no heading anchorization at all — `[[Notes#Setup]]` becomes `href="Notes#Setup"`, not `Notes.md#setup`. * `math_latex` (0.54) is real and does add `\(…\)` / `\[…\]`, the thing lib.rs records as never having worked. Enabling it emits `<span data-math-style>`, which nothing in the preview renders yet, so it needs a frontend counterpart and is left off. So `line_preserving_transforms()` is untouched: no transform disappeared, and the registry test did not have to be argued with. Rendering differences, from a diff of `convert_markdown` output over 82 documents (every .md in the repo, the math corpus, the render fixtures, and hand-written CommonMark edge cases): * heading anchors — the id moves from the empty inner `<a>` onto the heading itself, the anchor moves to the end of the heading and swaps `aria-hidden` for `aria-label`. The id *values* are byte-identical on every case tested, so existing `#anchor` links and `[[Notes#Setup]]` still resolve. * block-end source positions no longer overshoot onto the next line (`5:1-6:0` → `5:1-5:17`). Start lines never move. * inline `<code>` and hard-break `<br>` source positions are corrected; the `<dt>` of a description list used to report the definition's line. * autolinks now carry `data-sourcepos` at all. * footnote ids are derived from the label (`fn-1` → `fn-standard`) and gain `data-footnote-backref-idx`. Every frontend selector for these is prefix- based, so they still match. * `Term\n: Definition` without a blank line is now a `<dl>`. The extension has been enabled since 2023 and the blank-line form already worked; the tight form did not. `dl`/`dt`/`dd` are already styled. The line-number contract is unaffected and was checked directly rather than inferred: across the same 82 documents all 48 rendered task checkboxes map to the same source line with the same checked state as before, and no element's start line changed except the `<br>` correction above. Captured fixtures are refreshed, not hand-edited. Seven `math*` entries in renderProtocolFixtures.ts were already stale against comrak 0.18 before this change; the whole table is now taken from one `convert_markdown` call. mathDelimiterCorpus.json's `markdown` and `math` — the hand-authored reference — are untouched; only two captured `html` strings moved, both by one `<code>` sourcepos column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Two notes, one a correction to this PR's own description and one about what to do with it. Corrected a claim in the description above. It said the corpus's refusal to pair That is not so. Running comrak 0.54's native so the conclusion — On whether this is worth taking at all. It was opened on the premise that the parser options would replace three hand-rolled regex passes. That premise did not survive contact with the measurements, and the section above says so. What is left is a plain dependency upgrade, so it is fair to ask whether it earns its risk. Setting out the case both ways, since this is your call and not ours: Against — 36 minor versions of a CommonMark implementation, 36 of 82 test documents render differently, and you would be trusting our classification of each of those differences. No feature the app currently uses is gained. For — three things that stand on their own, independent of the failed premise:
And one practical point: the expensive part of this change is the behavioural diff, and it is now done. 0.18.0 was published 2023-03-31. If this is deferred, that measurement has to be redone later against a larger gap. Happy to close it if you would rather not carry the risk — in that case the |
src-tauri/Cargo.tomlpinnedcomrak = "0.18". On a 0.x crate that is>=0.18.0, <0.19.0— a hard pin, not a lag.git log -S comrak -- src-tauri/Cargo.tomlreturns exactly one commit,97b77ec, dated 2023-12-21: in 459 commits over two and a half years the Markdown parser was never upgraded. This moves it to 0.54.0, the newest release.The API changes are mechanical; one behaviour change is not
ComrakOptions/ComrakExtensionOptionsOptions(the aliases were deleted in 0.54 with every other deprecation)header_ids: Option<String>header_id_prefix: Option<String>(0.52 — the value was always a prefix)render.unsafe_render.r#unsafeanchorize(String)anchorize(&str)(0.42)The one that matters is the task-list checkbox. comrak 0.18 emitted
disabled="" checked=""; 0.54 emitschecked="" disabled=""— andTASK_ITEM_REspelled the 0.18 order out literally:Under 0.54 that stops matching completed tasks and only completed tasks. Every
- [x]item silently losesdata-task-checkboxand becomes untoggleable in reading mode; every- [ ]item keeps working, so the failure is half-invisible. Four existing tests went red on it, which is what they are for.The fix is not to respell the order.
TASK_ITEM_REnow matches the boolean attributes as an unordered set, andannotate_task_checkboxesinserts the marker after the tag name instead of afterdisabled="", so neither side depends on an ordering comrak never promised.No hand-rolled rewrite was replaced
Three parser options were proposed as replacements for regex passes. All three exist and arrived when the changelog says. None of them does the same job, measured against this repo's own reference corpus rather than the changelog:
math_dollars/math_code(0.22) — agrees withscripts/mathDelimiterCorpus.jsonon 25 of 29 cases. It disagrees on two, in the dangerous direction: it pairs$across a line break (soft and hard) and across an inline code span, both of which the corpus explicitly requires be refused. Measured against comrak 0.54 directly:A correction to an earlier draft of this description: it claimed the corpus's refusal is what keeps adjacent-line currency (
Costs $5/Sale $10) from being typeset. That is not so — comrak produces zero math nodes for that input on its own, as it does for$5 and $10on one line. The divergence above is real; that particular consequence was not. The remaining two differences are HTML entity escaping inside the span, which the DOM undoes.mask_math_spansstays.comrak does distinguish
\$\$x\$\$from$$x$$at parse time, so a future change could move #422's escape masking into the parser — but only together with the frontend half, and not at the cost of those two corpus cases.wikilinks (0.24) — comrak implements Obsidian's bare
[[Note]], whichprocess_wikilinksdocuments as deliberately not claimed. Measured against the exact cases that comment names:[[Notes#Setup]]href="Notes#Setup"Notes.md#setup— anchorized, extension resolved[[1]][[1#x]](https://example.com)(url)[[foo]]+[foo]: urlIt does no heading anchorization at all, which is the entire point of the pass.
math_latex(0.54.0, 2026-07-12) — real, and it does add the\(…\)/\[…\)support thatlib.rsrecords as having never worked. Not enabled here: it emits<span data-math-style>, which nothing in the preview renders, so it needs a frontend counterpart. See below.Because nothing was removed,
line_preserving_transforms()is untouched andevery_convert_markdown_preprocessing_step_is_registereddid not have to be argued with.Rendering differences
Diffed
convert_markdownoutput, old build vs new, over 82 documents: every.mdin the repo, all 29 math-corpus cases, all 29 render fixtures, and hand-written CommonMark edge cases for lists, links, autolinks, emphasis, escapes, block structure, headings, alerts and raw HTML. 36 documents differ. Every difference falls into these classes:<a>onto the heading; anchor moves to the end;aria-hidden→aria-label+data-heading-content#anchorlinks and[[Notes#Setup]]still resolve<ul data-sourcepos="5:1-6:0">→5:1-5:17, same for<li>,<ol>,<hr><code>sourcepos now spans its backticks; hard-break<br>reports its own line instead of the previous one; a description list's<dt>reported the definition's line in 0.18data-sourceposat allfn-1→fn-standard, plusdata-footnote-backref-idxand a more specificaria-labela[href^="#fnref-"],#fn-), so they still match. Non-ASCII labels percent-encode consistently on both the id and the hrefTerm\n: Definitionwithout a blank line is now a<dl>; in 0.18 it was a paragraphdescription_listshas been enabled since 2023 and the blank-line form already produced<dl>; only the tight form changed.dl/dt/ddare already styled instyles.cssNo regressions found.
The line-number contract (#389, #352)
sourceposdescribes the preprocessed text while the frontend edits the raw buffer by line number, so this was checked directly rather than inferred from green tests. Over the same 82 documents:<br>hard-break correction above.annotate_task_checkboxesstill reads the raw buffer. That is the fail-safe for exactly this, it is not unified with the processed text, and it was not touched.Fixtures were refreshed, not hand-edited
scripts/mathDelimiterCorpus.jsonis the hand-authored reference. Itsmarkdownandmathfields are unchanged — the backend recognises exactly the same spans it did before. Only two capturedhtmlstrings moved, both by a single<code>sourcepos column.scripts/renderProtocolFixtures.tsis regenerated fromconvert_markdownitself. Worth flagging separately: seven of itsmath*entries were already stale against comrak 0.18 before this change, so the file was asserting against HTML the renderer had stopped producing. The whole table now comes from one call, and the provenance comment says so.One test changed shape.
each heading adopts comrak's anchor id and leaves the anchor without onederived its expectation by reading the id off the innera.anchor— the element comrak 0.18 put it on. It now reads the heading's own id first and falls back to the anchor. The invariant is unchanged and still live; only the place the renderer keeps the id moved.Not covered
src/lib/utils/markdown.ts. comrak'salertsextension could replace it and 0.54 adds semantic-HTML alert output, but that is a separate change andmarkdown.tsis being edited elsewhere.math_latex, above. Turning it on without the frontend half would render user-typed\(x\)as barex— worse than today's(x).processMarkdownHtml's heading-id promotion is now dead code: comrak already puts the id on the heading, soheadingAnchor.idis always empty and the branch never fires. It is harmless and correctly guarded, and removing it means touchingmarkdown.ts. Left in place deliberately.process_internal_embedsandprocess_parenthesized_autolinks— comrak has no equivalent.Verification
cargo test141 passed ·npm test546 passed ·npm run check0 errors 0 warnings ·cargo clippy2 warnings, byte-identical to the pre-change baseline on master ·cargo build --releaseclean ·cargo fmtdiff count unchanged at 53 (rustfmt is not enforced in this repo).🤖 Generated with Claude Code