Skip to content

chore(markdown): upgrade comrak 0.18 → 0.54 - #426

Merged
PathGao merged 1 commit into
masterfrom
chore/upgrade-comrak
Aug 3, 2026
Merged

chore(markdown): upgrade comrak 0.18 → 0.54#426
PathGao merged 1 commit into
masterfrom
chore/upgrade-comrak

Conversation

@PathGao

@PathGao PathGao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

src-tauri/Cargo.toml pinned comrak = "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.toml returns 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

0.18 0.54
options type ComrakOptions / ComrakExtensionOptions Options (the aliases were deleted in 0.54 with every other deprecation)
heading ids header_ids: Option<String> header_id_prefix: Option<String> (0.52 — the value was always a prefix)
raw HTML render.unsafe_ render.r#unsafe
anchorizer anchorize(String) anchorize(&str) (0.42)

The one that matters is the task-list checkbox. comrak 0.18 emitted disabled="" checked=""; 0.54 emits checked="" disabled="" — and TASK_ITEM_RE spelled the 0.18 order out literally:

<input type="checkbox" disabled=""(?: checked="")? />

Under 0.54 that stops matching completed tasks and only completed tasks. Every - [x] item silently loses data-task-checkbox and 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_RE now matches the boolean attributes as an unordered set, and annotate_task_checkboxes inserts the marker after the tag name instead of after disabled="", 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 with scripts/mathDelimiterCorpus.json on 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⏎b$              → <span data-math-style="inline">a b</span>      (soft break crossed)
$a␣␣⏎b$            → <span data-math-style="inline">a   b</span>    (hard break crossed)
a $b `c$d` e$f     → <span data-math-style="inline">b `c</span>d`   (code span swallowed)

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 $10 on 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_spans stays.

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]], which process_wikilinks documents as deliberately not claimed. Measured against the exact cases that comment names:

input comrak 0.54 what Markpad needs
[[Notes#Setup]] href="Notes#Setup" Notes.md#setup — anchorized, extension resolved
[[1]] becomes a link left alone (citation numbering)
[[1#x]](https://example.com) strands the (url) left alone
[[foo]] + [foo]: url pre-empts the reference link left alone

It 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 that lib.rs records 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 and every_convert_markdown_preprocessing_step_is_registered did not have to be argued with.

Rendering differences

Diffed convert_markdown output, old build vs new, over 82 documents: every .md in 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:

change classification
heading anchor: id moves from the empty inner <a> onto the heading; anchor moves to the end; aria-hiddenaria-label + data-heading-content intended (0.54 accessibility change). Anchor id values are byte-identical on every case tested, so #anchor links and [[Notes#Setup]] still resolve
block-end sourcepos stops overshooting onto the next line — <ul data-sourcepos="5:1-6:0">5:1-5:17, same for <li>, <ol>, <hr> benign fix; start lines never move
inline <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.18 benign fix
autolinks now carry data-sourcepos at all benign
footnote ids derived from the label — fn-1fn-standard, plus data-footnote-backref-idx and a more specific aria-label benign; every frontend selector for these is prefix-based (a[href^="#fnref-"], #fn-), so they still match. Non-ASCII labels percent-encode consistently on both the id and the href
Term\n: Definition without a blank line is now a <dl>; in 0.18 it was a paragraph intended, newly reachable. description_lists has been enabled since 2023 and the blank-line form already produced <dl>; only the tight form changed. dl/dt/dd are already styled in styles.css

No regressions found.

The line-number contract (#389, #352)

sourcepos describes 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:

  • 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 anywhere, except the <br> hard-break correction above.

annotate_task_checkboxes still 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.json is the hand-authored reference. Its markdown and math fields are unchanged — the backend recognises exactly the same spans it did before. Only two captured html strings moved, both by a single <code> sourcepos column.

scripts/renderProtocolFixtures.ts is regenerated from convert_markdown itself. Worth flagging separately: seven of its math* 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 one derived its expectation by reading the id off the inner a.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

  • The frontend's GitHub-alerts SVG injection in src/lib/utils/markdown.ts. comrak's alerts extension could replace it and 0.54 adds semantic-HTML alert output, but that is a separate change and markdown.ts is being edited elsewhere.
  • math_latex, above. Turning it on without the frontend half would render user-typed \(x\) as bare x — worse than today's (x).
  • processMarkdownHtml's heading-id promotion is now dead code: comrak already puts the id on the heading, so headingAnchor.id is always empty and the branch never fires. It is harmless and correctly guarded, and removing it means touching markdown.ts. Left in place deliberately.
  • process_internal_embeds and process_parenthesized_autolinks — comrak has no equivalent.
  • The corpus is 82 documents, not the CommonMark spec suite. It covers what this app renders, not everything comrak can parse.

Verification

cargo test 141 passed · npm test 546 passed · npm run check 0 errors 0 warnings · cargo clippy 2 warnings, byte-identical to the pre-change baseline on master · cargo build --release clean · cargo fmt diff count unchanged at 53 (rustfmt is not enforced in this repo).

🤖 Generated with Claude Code

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>
@PathGao

PathGao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

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 $ across a line break is what keeps adjacent-line currency from being typeset:

Costs $5
Sale $10

That is not so. Running comrak 0.54's native math_dollars against it directly gives zero math nodes, as does $5 and $10 on a single line — comrak declines those on its own grounds. The divergence the section is about is real and reproduces:

$a⏎b$              → <span data-math-style="inline">a b</span>      (soft break crossed)
$a␣␣⏎b$            → <span data-math-style="inline">a   b</span>    (hard break crossed)
a $b `c$d` e$f     → <span data-math-style="inline">b `c</span>d`   (code span swallowed)

so the conclusion — mask_math_spans stays, the native option is not a drop-in — is unchanged. Only that one illustration was wrong. The body has been edited; flagging it here rather than editing silently.

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:

  • TASK_ITEM_RE no longer depends on attribute order. comrak never promised one; the old pattern spelled disabled="" checked="" out literally, so it was one upstream reordering away from silently breaking every completed checkbox. That hardening is correct whether or not you take the version bump.
  • Block-end sourcepos stops overshooting onto the following line, and inline <code>, hard-break <br> and description-list <dt> all start reporting their own positions. Source positions are load-bearing here — the line-number contract and scroll sync both read them.
  • scripts/renderProtocolFixtures.ts had seven math* entries that were already stale against 0.18 on master — asserting against HTML the renderer had stopped producing. Found while regenerating; fixed here.

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 TASK_ITEM_RE hardening is worth keeping on its own and I can split it out as a two-line PR against 0.18.

@PathGao
PathGao merged commit 9e1a9f4 into master Aug 3, 2026
4 checks passed
@PathGao
PathGao deleted the chore/upgrade-comrak branch August 3, 2026 07:44
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