Skip to content

fix(parser): preserve named and numeric character references - #1018

Merged
eszlamczyk merged 6 commits into
fix/md4c-symbol-isolationfrom
fix/text-character-references
Oct 16, 2026
Merged

eszlamczyk merged 6 commits into
fix/md4c-symbol-isolationfrom
fix/text-character-references

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

What/Why?

The shared parser drops valid character references because MD_TEXT_ENTITY is ignored, so Tom & Jerry loses its ampersand. Named and numeric references are now decoded after md4c's syntax classification, so encoded punctuation stays literal text and cannot become Markdown syntax. The named lookup is md4c's own HTML5 entity table (upstream it belongs to md4c-html, not the parser), taken from our fork as enrmrkd-entity.{c,h} so it exports enrmrkd_entity_lookup rather than a bare entity_lookup, and kept in sync by scripts/fetch-md4c.sh.

Stacked on #922, which moves the parser to the ENRMRKD_-prefixed fork; review and merge that first.

This carries @Yash-Singh1's #994 (eb70edc) unchanged as the first commit plus one follow-up. #994 comes from an organization-owned fork, which GitHub does not let maintainers push to, so the follow-up lands here. #994 stays open for the discussion there and closes when this merges; both commits keep their authorship.

Closes #993.

Follow-up commits on top of #994

  • appendCodepoint clamps zero, surrogates and anything above U+10FFFF to U+FFFD itself, so the named table and the numeric path share one guard and the UTF-8 encoder can never emit a sequence for an out-of-range value. The numeric path only validates the digits.
  • The committed md4c.js is rebuilt with the entity table linked in. Web kept dropping references until this artifact caught up with the sources.
  • Numeric references are parsed with a plain digit loop instead of std::from_chars. Package.swift and the Android CMake targets set no C++ standard, and the standalone iOS package failed to compile on <charconv>. md4c caps numeric references at 7 decimal or 6 hex digits, so the loop cannot overflow.
  • Link and image attributes decode too. md4c hands attributes as typed substrings and getAttributeText copied the raw bytes, so [q](/u?a=1&amp;b=2) decoded the text but kept &amp; in the href. That was already the case on main; entity segments now go through the same decoder as text.
  • <cstdlib> is included before the extern "C" block so entity.h's stdlib include is a no-op there.
  • The Swift package's SourceOffsetAnnotator and its tests assumed entities are dropped from rendered text, so CI's ios-unit-tests failed once they decode. The matcher now bridges the one or two scalars an entity decodes to, and the tests assert the decoded content with its range spanning the source reference.
  • The entity table comes from the fork as enrmrkd-entity.{c,h} (enrmrkd_entity_lookup, ENRMRKD_ENTITY). Upstream's entity.c exports a bare entity_lookup, which would reintroduce Isolate embedded MD4C symbols and parser headers for coexistence #846's duplicate-symbol failure for any app that also links md4c-html. scripts/test-core-parser.sh builds the table, runs the enrmrkd_ prefix check over it, and links upstream md4c-html's entity.c into the coexistence binary. The golden dump is re-recorded; its only change is the decoded & in commonmark.md's Term &amp; entity.
  • A jest suite, src/web/__tests__/parseMarkdown.test.ts, runs the committed web parser through named, numeric, two-codepoint, literal-preserving, U+FFFD, no-syntax-leak and attribute cases, so a stale md4c.js fails CI.

Size

md4c's table is the cost of spec-compliant entity handling: about 54 KB per native arch at -Os, and the web parser goes from 147 KB to 197 KB raw, 57 KB to 77 KB gzipped. A build flag was considered and rejected: entity references are CommonMark behavior, so the default would have to stay on, and the web build is a single committed artifact. If the web size matters later, the table representation can be compacted in a separate change.

Testing

  • Rebuilt the WASM parser and ran it through node: Tom &amp; Jerry, &copy; &#169; &#xA9; and the two-codepoint &NotNestedGreaterGreater; decode; &foo;, &amp without a semicolon, `&amp;` and \&amp; stay literal; &#0; &#xD800; &#x110000; become U+FFFD.
  • Standalone iOS package ParserTests on iPhone 17 / iOS 26.5: 18 of 18 pass. Jest web parser suite: 6 of 6 pass.
  • Pre-commit clang-format, lint and typecheck pass. The Swift and Android parser tests from fix(parser): preserve named and numeric character references #994 are unchanged.

PR Checklist

  • Code compiles and runs on iOS, via the shared parser sources
  • Code compiles and runs on Android, via the shared parser sources
  • Updated documentation/README if applicable, no public API change
  • Ran example app to verify changes
  • E2E tests are passing
  • Required E2E tests have been added, parser tests cover it instead

🤖 Generated with Claude Code

@eszlamczyk
eszlamczyk force-pushed the fix/text-character-references branch from 5e11543 to 8f557c5 Compare October 16, 2026 20:08
@eszlamczyk
eszlamczyk changed the base branch from main to fix/md4c-symbol-isolation October 16, 2026 20:08
@eszlamczyk
eszlamczyk added this pull request to stack #1020 October 16, 2026 20:28
Yash-Singh1 and others added 6 commits October 16, 2026 22:31
…b parser

appendCodepoint now maps zero, surrogates and anything above U+10FFFF to
U+FFFD itself, so the named table and the numeric path share one guard and
the UTF-8 encoder can never emit a sequence for an out-of-range value. The
numeric path only validates the digits.

The committed md4c.js is rebuilt with entity.c linked in; web dropped
character references until this artifact caught up with the sources.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Swift package and the Android CMake targets set no C++ standard, so
<charconv> is not guaranteed; the standalone iOS package failed to compile.
md4c already caps numeric references at 7 decimal or 6 hex digits, so a
plain digit loop cannot overflow and needs no library support.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
md4c hands attributes as typed substrings; getAttributeText copied the raw
bytes, so `[q](/u?a=1&amp;b=2)` decoded the text but kept `&amp;` in the
href. Entity segments now go through the same decoder as text. Include
<cstdlib> before the extern "C" block so entity.h's stdlib include is a
no-op there, and add a jest test that runs the committed web parser so a
stale md4c.js fails CI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Swift package's annotator and its tests assumed the shared core drops
entity references, so a text node that now starts with a decoded entity
failed to anchor and the slicer returned nil. The matcher already bridged
one decoded scalar; it now bridges two for the handful of entities that
decode to a pair, and the tests assert the decoded content with its source
range spanning the reference. The entity branch moves into its own method
to keep matchRemainder under the complexity limit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The vendored entity.c exported a bare entity_lookup, the same duplicate-symbol
class #846 fixed for the parser: an app that also links md4c-html would fail
to link. software-mansion-labs/md4c already ships the table as
enrmrkd-entity.{c,h} with enrmrkd_entity_lookup, so fetch-md4c.sh syncs that
instead and MD4CParser uses the prefixed names. The table itself is unchanged.

test-core-parser.sh builds the table into the AST dumper, runs the prefix check
over it, and links upstream md4c-html's entity.c into the coexistence binary.
The golden dump is re-recorded: the only change is the decoded "&" in
commonmark.md's "Term &amp; entity" across its five variants.
@eszlamczyk
eszlamczyk force-pushed the fix/text-character-references branch from 8f557c5 to 0e30b3c Compare October 16, 2026 20:33
@eszlamczyk
eszlamczyk merged commit 131d261 into main Oct 16, 2026
17 of 18 checks passed
@eszlamczyk
eszlamczyk deleted the fix/text-character-references branch October 16, 2026 21:15
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.

Shared parser drops named and numeric character references

3 participants