Skip to content

fix: escape markdown syntax in plain span text during serialization - #3183

Open
christianhg wants to merge 1 commit into
mainfrom
markdown-escape-text
Open

fix: escape markdown syntax in plain span text during serialization#3183
christianhg wants to merge 1 commit into
mainfrom
markdown-escape-text

Conversation

@christianhg

@christianhg christianhg commented Aug 26, 2026

Copy link
Copy Markdown
Member

Portable Text whose span text contains literal markdown punctuation does not survive a trip through portableTextToMarkdown and back: a span reading *bar* re-parses as emphasis, a leading # becomes a heading, and a text of [x]: y disappears 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 code decorator 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
Touches core PT→MD serialization with complex CommonMark edge cases; output bytes change for punctuation-heavy plain text, though behavior is heavily tested (directed corpus + seeded fuzz).

Overview
Portable Text → Markdown now backslash-escapes literal markdown punctuation in plain span text so PT → MD → PT preserves span text byte-for-byte (e.g. *bar* serializes as \*bar\* instead of becoming emphasis on reparse). Escaping is planned per block before mark renderers run: leaves are joined line-by-line, hazards are detected on the full line (including patterns split across spans), and results are keyed to text nodes via a WeakMap so custom renderers get pre-escaped children; the default code mark uses raw text and widened backtick fences instead.

The planner is context-aware (headings, list-item first lines, blockquotes, table cells) and uses linkify-it to skip escaping inside bare URLs/emails/www. (text stays identical; reparse may add a link mark). Table cell pipe escaping now respects backslash parity. Docs and the changeset state the updated round-trip fixpoint and documented exceptions.

Breaking-ish output change: stored markdown diffs will show more backslashes where span text contained significant punctuation; link label escaping is centralized (duplicate escaping removed from the default link renderer).

Reviewed by Cursor Bugbot for commit 2a860ef. Bugbot is set up for automated code reviews on this repo. Configure here.

@changeset-bot

changeset-bot Bot commented Aug 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2a860ef

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@portabletext/markdown Patch

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

@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
portable-text-editor-documentation Ready Ready Preview Aug 27, 2026 3:02pm
portable-text-example-basic Ready Ready Preview Aug 27, 2026 3:02pm
portable-text-playground Ready Ready Preview Aug 27, 2026 3:02pm

Request Review

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Bundle Stats

Warning

1 significant change.

@portabletext/markdown

🔴 @portabletext/markdown (export)
Gzip: 85.5 KB, up 5.9 KB (7.4%)
Raw: 290.3 KB, up 18.1 KB (6.7%)
Import: 42 ms, up 2 ms (4.4%)

All scenario measurements (7)

🗺️ @portabletext/editor / @portabletext/editor · @portabletext/editor / @portabletext/editor/behaviors · @portabletext/editor / @portabletext/editor/plugins · @portabletext/editor / @portabletext/editor/selectors · @portabletext/editor / @portabletext/editor/traversal · @portabletext/editor / @portabletext/editor/utils · @portabletext/markdown / @portabletext/markdown · Artifacts

Scenario Kind Bundle (raw / gzip) Gzip change Import time Import change
⚪ @portabletext/editor / @portabletext/editor export 1.09 MB / 253.2 KB None 68 ms -0 ms, -0.4%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms +0 ms, +1.3%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 7 ms -1 ms, -7.6%
⚪ @portabletext/editor / @portabletext/editor/selectors export 93.4 KB / 21.3 KB None 8 ms -0 ms, -2.3%
⚪ @portabletext/editor / @portabletext/editor/traversal export 41.4 KB / 10.7 KB None 6 ms +0 ms, +1.5%
⚪ @portabletext/editor / @portabletext/editor/utils export 32.8 KB / 8.6 KB None 6 ms +0 ms, +4.0%
🔴 @portabletext/markdown / @portabletext/markdown export 290.3 KB / 85.5 KB +5.9 KB, +7.4% 42 ms +2 ms, +4.4%

Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time.

Comment thread packages/markdown/src/from-portable-text/escape-plain-text.ts
Comment thread packages/markdown/src/from-portable-text/escape-plain-text.ts Outdated
Comment thread packages/markdown/src/from-portable-text/escape-plain-text.ts
Comment thread packages/markdown/src/from-portable-text/escape-plain-text.ts Outdated
`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 will claim (bare URLs, emails,
`www.`) are not escaped: text identity holds and the re-parse adds a
link mark. 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).
@christianhg
christianhg force-pushed the markdown-escape-text branch from 3941c77 to 2a860ef Compare August 27, 2026 15:00

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

const edits = [
...collectInlineEdits(text, isLinkLabelChar),
...collectLineStartEdits(text, args),
].filter((edit) => edit.bypassLinkifyMask || !isMasked(edit, linkifyMask))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Linkify mask drops pre-linkify markup

High Severity

The linkify mask suppresses emphasis and strikethrough escapes inside a claimed URL, but markdown-it runs those inline rules before linkify. Paired _, *, or ~~ in a path can be consumed as markup, so characters vanish instead of the substring keeping its text and only gaining a link mark. bypassLinkifyMask already exists for the same reason on entities and backticks, and is not set on these edits.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2a860ef. Configure here.

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