Skip to content

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

Merged
christianhg merged 1 commit into
mainfrom
markdown-escape-text
Sep 7, 2026
Merged

christianhg merged 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
Changes core PT→MD serialization for all plain text and the custom renderer contract (children vs text), so downstream markdown diffs and hand-written renderers can break despite broad tests.

Overview
portableTextToMarkdown now 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 new planLeafEscaping in escape-plain-text.ts, wired through render-node.ts before mark renderers run.

Visible output change: stored or diffed markdown gains backslashes where span text looked like syntax (headings, lists, tables, blockquotes, callouts). children in custom block/mark renderers is pre-escaped markdown; use text for raw span content. The default code decorator reads text and 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 link mark); fuzzy www. URLs are masked only when safe. linkify-it is 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.

@changeset-bot

changeset-bot Bot commented Aug 26, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2da6dc1

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
portable-text-editor-documentation Ready Ready Preview Sep 7, 2026 7:51am UTC
portable-text-example-basic Ready Ready Preview Sep 7, 2026 7:51am UTC
portable-text-playground Ready Ready Preview Sep 7, 2026 7:51am UTC

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.4 KB, up 18.1 KB (6.7%)
Import: 42 ms, up 4 ms (9.9%)

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 / 254.2 KB None 71 ms +0 ms, +0.4%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms +0 ms, +3.6%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 7 ms +0 ms, +2.4%
⚪ @portabletext/editor / @portabletext/editor/selectors export 93.7 KB / 21.3 KB None 8 ms +0 ms, +0.8%
⚪ @portabletext/editor / @portabletext/editor/traversal export 41.7 KB / 10.8 KB None 6 ms -0 ms, -2.2%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.1 KB / 8.7 KB None 6 ms +0 ms, +4.0%
🔴 @portabletext/markdown / @portabletext/markdown export 290.4 KB / 85.5 KB +5.9 KB, +7.4% 42 ms +4 ms, +9.9%

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

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

Comment thread packages/markdown/src/from-portable-text/escape-plain-text.ts
`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).

This branch was successfully deployed

3 active deployments
Preview – portable-text-playground — 2da6dc16 Deployed Sep 7, 2026 by vercel[bot]
Preview – portable-text-editor-documentation — 2da6dc16 Deployed Sep 7, 2026 by vercel[bot]
Preview – portable-text-example-basic — 2da6dc16 Deployed Sep 7, 2026 by vercel[bot]
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