Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/escape-markdown-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@portabletext/markdown': patch
---

fix: escape markdown syntax in plain span text during serialization

Literal markdown punctuation in span text now survives the round trip: serialization escapes it, so it parses back as the same literal text instead of turning into markup.

```ts
// span text markdown (before) markdown (now) re-parses as
'*bar*' // *bar* \*bar\* the text `*bar*` (was: an `em` span reading `bar`)
'# heading' // # heading \# heading the text `# heading` (was: an `h1`)
'[x]: y' // [x]: y \[x]: y the text `[x]: y` (was: nothing, consumed as a link reference)
```

Escaping accounts for the block context a span renders into (a heading, a blockquote, a list item, a table cell) and for hazards that only appear once adjacent spans or marks are joined, such as a link pattern or an ordered-list marker split across spans. Text with the `code` decorator is never backslash-escaped; instead its backtick delimiters widen past any backtick run in the content (with space padding when the content starts or ends with a backtick), so the content survives verbatim:

```ts
// span text with the `code` decorator markdown (before) markdown (now)
'a`b' // `a`b` (broken) ``a`b``
'`a' // ``a` (broken) `` `a ``
```

Markdown output for text containing such punctuation gains backslash escapes it didn't have before.

Consumer mark and block renderers now receive pre-escaped `children`. A renderer that needs the original, unescaped text (the `code` decorator's own default renderer does this) reads it from the `text` argument instead.

Two exceptions. A bare URL, email, or `www.` address in plain text is never escaped: it keeps its text and typically gains only a `link` mark on the next parse, since linkifying such text is expected parser behavior, not something to suppress. Adjacent inline constructs can still claim marks of their own (an entity reference, a backtick, or a mark boundary sitting inside what would otherwise be that URL or email means the parser wouldn't have linkified it either, so normal escaping applies there instead). Leading or trailing whitespace that CommonMark's own block parsing trims is unaffected by this change, same as before it.
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ This package uses markdown-it as its Markdown parser. Remark and unified plugins
Converting Markdown to Portable Text and back isn't a lossless mirror. Five things to expect:

- Translation normalizes rather than preserves: a first Markdown → Portable Text → Markdown pass rewrites Markdown to one canonical spelling. Autolinks and reference links become inline links, indented code becomes fenced code, and `<https://portabletext.org>` comes back as `[https://portabletext.org](https://portabletext.org)`.
- The normalized form is a fixpoint for the constructs in the table above: parsing it and serializing again reproduces it byte-for-byte. The exception is plain text containing literal Markdown punctuation: serialization doesn't yet escape it, so `\*bar\*` comes back as `*bar*`, which a second parse reads as emphasis.
- The normalized form is a fixpoint for the constructs in the table above, and for plain text: parsing it and serializing again reproduces it byte-for-byte, because literal Markdown punctuation in plain text is backslash-escaped on the way out (`*bar*` comes back as `\*bar\*`, which a second parse reads as the literal text). Three exceptions: a bare URL, email, or `www.` address stays byte-identical but gains a `link` mark on the next parse; a hard break inside a heading forces a structural split into a second block on reparse, since an ATX heading is single-line (the text itself still survives, split across the two blocks); and leading or trailing whitespace that CommonMark's own block parsing trims isn't part of the fixpoint claim.
- Unrecognized constructs degrade, they don't fail. A mark, list, or task checkbox whose type isn't in the schema keeps its text and drops the formatting: an undeclared `strong` decorator turns `**bar**` into a plain span reading `bar`.
- Portable Text structures with no Markdown form degrade predictably going back out. GFM has one header row, so extra header rows flatten into the body; deep or level-skipping lists collapse to relative nesting; unknown object types render as a fenced JSON block.
- Keys and span boundaries aren't identity: every parse regenerates block and span keys, and adjacent spans with identical marks merge.
Expand Down
6 changes: 4 additions & 2 deletions packages/markdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,14 @@ const markdown = portableTextToMarkdown([
[ref link][id] -> [ref link](https://example.com "title")
```

2. The normalized Markdown is a fixpoint for the constructs in the [Supported features](#supported-features) table above: parsing it and serializing again reproduces it byte-for-byte, pinned by a full-document round-trip test. This doesn't yet extend to plain text that happens to contain literal Markdown punctuation: serialization doesn't escape it, so a second parse reads it back as markup instead of literal text.
2. The normalized Markdown is a fixpoint for the constructs in the [Supported features](#supported-features) table above, and for plain text: parsing it and serializing again reproduces it byte-for-byte, pinned by a full-document round-trip test. Literal Markdown punctuation in plain text is backslash-escaped on serialization, so a second parse reads back the same characters instead of markup.

```
\*bar\* -> *bar* (serialized unescaped; a second parse reads this as emphasis)
*bar* -> \*bar\* (escaped on serialization; a second parse reads back the literal text)
```

Three exceptions. A bare URL, email, or `www.` address is never escaped: text identity holds, but it gains a `link` mark on the next parse (autolinking is a parser feature, not a round-trip bug). A hard break inside a heading forces a structural split into a second block on reparse, since an ATX heading is single-line; the text itself still survives, split across the two blocks. And leading or trailing whitespace that CommonMark's own block parsing trims isn't part of the fixpoint claim.

3. MD→PT survival is schema-driven. Constructs whose type the schema doesn't declare degrade predictably: they keep their content and drop the structure that named them. Marks drop formatting but keep the text (`**bar**` with no `strong` decorator in the schema becomes a plain span reading `bar`); tables flatten their cell content into top-level blocks; images fall back to their Markdown source as plain text; task-list checkboxes strip to plain list items.

4. PT structures with no Markdown form degrade predictably on PT→MD. GFM tables have one header row, so header rows beyond the first flatten into the body. Deep or level-skipping lists collapse to relative nesting. A list's first item renders at the top level whatever its `level`, and each deeper jump between items indents one step, however many levels it skips. Multi-block table cells join their blocks with spaces. Unknown object types render as a fenced JSON block; unknown marks pass their text through unformatted.
Expand Down
2 changes: 2 additions & 0 deletions packages/markdown/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,15 @@
"@mdit/plugin-alert": "^0.23.2",
"@portabletext/schema": "workspace:^",
"@portabletext/toolkit": "^6.0.0",
"linkify-it": "^5.0.2",
"markdown-it": "^14.3.0"
},
"devDependencies": {
"@portabletext/test": "workspace:^",
"@portabletext/types": "^4.0.2",
"@sanity/pkg-utils": "catalog:tooling",
"@sanity/tsconfig": "catalog:tooling",
"@types/linkify-it": "^5.0.0",
"@types/markdown-it": "^14.1.2",
"typescript": "catalog:tooling",
"vite": "catalog:tooling",
Expand Down
10 changes: 10 additions & 0 deletions packages/markdown/src/escape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ describe(escapeTableCell.name, () => {
expect(escapeTableCell('a \\| b')).toBe('a \\| b')
})

test('escapes a pipe behind an even number of backslashes', () => {
// Two backslashes cancel out to a literal backslash, leaving the pipe
// live, the shape leaf escaping produces from a literal `\|` in a span.
expect(escapeTableCell('a\\\\| b')).toBe('a\\\\\\| b')
})

test('leaves a pipe behind an odd number of backslashes intact', () => {
expect(escapeTableCell('a\\\\\\| b')).toBe('a\\\\\\| b')
})

test('leaves backslashes alone', () => {
expect(escapeTableCell('a\\b')).toBe('a\\b')
})
Expand Down
23 changes: 14 additions & 9 deletions packages/markdown/src/escape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,21 @@ export function escapeImageAndLinkTitle(text: string): string {
* Escapes characters that have special meaning at the row level of a GFM
* table cell.
*
* A literal `|` ends the cell, so unescaped pipes are replaced with `\|`.
* Newlines end the row, so they are replaced with `<br>` to keep the
* visible line break inside the cell. Already-escaped pipes (`\|`) are
* left intact so that escapes introduced by mark renderers survive the
* pass.
* A literal `|` ends the cell, so a pipe preceded by an even number of
* backslashes (including zero) gets one more: paired backslashes cancel
* out to a literal backslash and leave the pipe live, so parity, not mere
* presence, decides whether it is already escaped. Newlines end the row,
* so they are replaced with `<br>` to keep the visible line break inside
* the cell.
*
* Backslashes are intentionally not escaped here so that other escapes
* already in the rendered cell (such as `\[` and `\]` in link text) are
* not double-escaped.
* Backslashes themselves are left alone here; only the parity check reads
* them, so escapes already in the rendered cell (such as `\[` and `\]` in
* link text) survive the pass untouched.
*/
export function escapeTableCell(text: string): string {
return text.replace(/(?<!\\)\|/g, '\\|').replace(/\n/g, '<br>')
return text
.replace(/(\\*)\|/g, (match, backslashes: string) =>
backslashes.length % 2 === 0 ? `${backslashes}\\|` : match,
)
.replace(/\n/g, '<br>')
}
4 changes: 2 additions & 2 deletions packages/markdown/src/example-document.advanced.out.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Here's a link to [a website](http://foo.bar), to a [local<br />
doc](local-doc.html), and to a [section heading in the current<br />
doc](#an-h2-header). Here's a footnote [^1].

[^1]: Footnote text goes here.
\[^1]: Footnote text goes here.

Tables can look like this:

Expand Down Expand Up @@ -155,4 +155,4 @@ math should get its own line and be put in in double-dollarsigns:
$$I = \int \rho R^{2} dV$$

And note that you can backslash-escape any punctuation characters<br />
which you wish to be displayed literally, ex.: `foo`, *bar*, etc.
which you wish to be displayed literally, ex.: \`foo\`, \*bar\*, etc.
2 changes: 1 addition & 1 deletion packages/markdown/src/example-document.out.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ Check out these autolinks: [https://example.com](https://example.com) and [mailt
<p>This is raw HTML that gets preserved</p>
</div>

Inline HTML like <span class="highlight">highlighted text</span> can be handled too.
Inline HTML like \<span class="highlight">highlighted text\</span> can be handled too.

### Reference Links

Expand Down
Loading
Loading