fix: escape markdown syntax in plain span text during serialization - #3183
Conversation
🦋 Changeset detectedLatest commit: 2da6dc1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Bundle StatsWarning 1 significant change. @portabletext/markdown🔴 All scenario measurements (7)🗺️
Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time. |
66ce7ec to
c4de779
Compare
c4de779 to
3941c77
Compare
3941c77 to
2a860ef
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2a860ef. Configure here.
2a860ef to
2caad8e
Compare
2caad8e to
ee543b0
Compare
ee543b0 to
af1b4eb
Compare
`renderText` returned span text unchanged, so Portable Text whose text contains literal markdown punctuation re-parsed as markup: a span reading `*bar*` came back as an `em` span, a leading `#` became a heading, and a text of ` [x]: y` vanished entirely as a link reference definition. Escaping happens at the text leaf, before mark renderers wrap their delimiters, planned per block by `planLeafEscaping`: leaves are grouped into lines at hard breaks (only when the hard-break renderer's output carries a newline; otherwise the break is an inline opaque segment), each line is joined into one string with a per-character map back to its owning leaf (custom- rendered segments become opaque sentinels), all hazard detection runs once against that joined line, and the resulting edits map back into per-leaf escaped strings keyed by leaf identity in a `WeakMap`, so a custom renderer rendering a synthetic text node cannot shift sibling escapes. Detection on the joined line makes leaf boundaries irrelevant by construction: constructs spliced across leaves through unrendered marks (an ordered-list marker, a reference-definition label, an entity, an emphasis run) are seen whole. Inline hazards escape everywhere in non-code text; line-leading hazards (headings, blockquotes, list markers, GFM task checkboxes, setext underlines, thematic breaks with interior spaces, indented code, reference definitions, alerts) escape only at real line starts with CommonMark's 0-3 space tolerance. Ranges that markdown-it's linkifier claims with an explicit scheme (`http:`, `mailto:`, ...) are not escaped: text identity holds and the re-parse adds a link mark. Fuzzy claims (`www.`, bare domains) take normal escaping when they contain markdown-significant punctuation, because the parser's emphasis pass beats fuzzy linkification on re-parse and would consume the characters. Entity-reference and backtick escapes bypass that mask, since both alter what the parser sees before linkify runs, and a claim interrupted by a rendered mark boundary is not honored. The `code` decorator receives raw text and widens its backtick delimiters past internal runs, padding when stripping would occur; the link renderer's own bracket escaping is gone (leaf escaping covers it, existing link expectations unchanged); table pipe escaping counts consecutive backslashes. The round-trip contract this pins: span text survives PT->MD->PT byte-for-byte, except linkified substrings (text kept, mark added) and markdown-inherent whitespace trimming. Pinned by a directed corpus and a committed seeded fuzz test (mulberry32, 2,000 cases per run over the hazard alphabet, multi-leaf splits, whitespace-only leaves, rendered and unrendered marks). Known inherent edges, both fuzz-discovered pathological shapes: an entity or backtick inside an explicit-scheme URL path corrupts under any policy, and a `<` before an inline object whose rendered output starts with a letter can form a tag; both are on record with repros. `linkify-it` becomes a declared dependency (previously reachable only transitively).
af1b4eb to
2da6dc1
Compare

Portable Text whose span text contains literal markdown punctuation does not survive a trip through
portableTextToMarkdownand back: a span reading*bar*re-parses as emphasis, a leading#becomes a heading, and a text of[x]: ydisappears entirely, consumed as a link reference definition. For agent pipelines that read and edit Portable Text as markdown, this is silent text corruption in the middle of the edit loop, and it was the documented exception holding the round-trip contract back.Escaping happens at the text leaf, before mark renderers wrap their delimiters, so the escaper never touches generated syntax. The engine plans per block: leaves are grouped into lines at hard breaks, each line is joined into one string with a per-character map back to its owning leaf, all hazard detection runs once against that joined line, and the edits map back into per-leaf escaped strings keyed by leaf identity in a
WeakMap. Detecting on the joined line makes leaf boundaries irrelevant by construction, a hazard spliced across spans through an unrendered mark is seen whole, and a custom renderer rendering its own synthetic text node cannot shift sibling escapes. Minimal noise was a design goal, since agents read this output: mid-line#,-,>, lone brackets, and spaced asterisks all stay bare, pinned by a canonical-bytes table that catches over-escaping the round-trip tests cannot see.Ranges the linkifier will claim are deliberately not escaped (text identity holds, the re-parse adds a link mark), with two precision rules: entity and backtick escapes bypass the mask because they change what the parser sees before linkify runs, and a claim interrupted by a rendered mark boundary is not honored. The
codedecorator now receives raw text and widens its backtick delimiters past internal runs; the link renderer's own bracket escaping is gone, with the existing link test expectations byte-identical as the composition guard.The contract the docs now state without an asterisk: span text survives PT → MD → PT byte-for-byte, except linkified substrings and CommonMark-inherent whitespace trimming. Pinned by a directed corpus and a committed seeded fuzz test, the anti-regression tripwire this surface lacked. Two pathological fuzz-discovered edges have no correct markdown representation and are on record as known limits rather than fixed (entities/backticks inside an explicit-scheme URL path;
<immediately before an inline object whose rendered output starts with a letter). Markdown output for text containing significant punctuation gains backslashes; anyone diffing stored markdown output will see that shift.Note
Medium Risk
Changes core PT→MD serialization for all plain text and the custom renderer contract (
childrenvstext), so downstream markdown diffs and hand-written renderers can break despite broad tests.Overview
portableTextToMarkdownnow backslash-escapes markdown-significant punctuation in plain span text so PT → MD → re-parse keeps the same characters instead of turning them into emphasis, headings, ref-defs, etc. Escaping is planned per block line (joined leaves, including across span boundaries) via newplanLeafEscapinginescape-plain-text.ts, wired throughrender-node.tsbefore mark renderers run.Visible output change: stored or diffed markdown gains backslashes where span text looked like syntax (headings, lists, tables, blockquotes, callouts).
childrenin custom block/mark renderers is pre-escaped markdown; usetextfor raw span content. The defaultcodedecorator readstextand uses widened backtick fences (no backslash escaping inside code). Default link renderer drops redundant link-text escaping because labels are already escaped; table cell pipe escaping respects backslash parity with leaf escapes.Linkify carve-out: explicit-scheme URLs and emails stay unescaped (re-parse may add a
linkmark); fuzzywww.URLs are masked only when safe.linkify-itis a new runtime dependency. Docs/changeset describe the updated round-trip fixpoint and renderer contract. Coverage adds a large directed corpus, seeded fuzz PT→MD→PT text identity, and updated golden example outputs.Reviewed by Cursor Bugbot for commit 2da6dc1. Bugbot is set up for automated code reviews on this repo. Configure here.