Skip to content
This page is available as Markdown at /conversion/markdown-round-tripping.md. For the full documentation index, see /llms.txt, or the complete corpus at /llms-full.txt.

Markdown round-tripping

Converting Markdown to Portable Text and back with markdownToPortableText and portableTextToMarkdown isn’t a lossless mirror. It’s a contract with six guarantees and a short list of named exceptions. This page is for anyone evaluating a Markdown-based pipeline, an AI agent that reads or writes Markdown, an import/export tool, a migration from a Markdown-first CMS, who needs to know exactly what a round trip preserves, normalizes, or degrades.

1. Translation preserves semantics, not source spelling

Section titled “1. Translation preserves semantics, not source spelling”

The first Markdown → Portable Text → Markdown pass normalizes Markdown to one canonical spelling: autolinks and reference links become inline links, indented code becomes fenced code, soft-wrapped lines join into one, and emphasis, headings, lists, and tables each get one canonical form.

<https://portabletext.org>

parses and serializes back as:

[https://portabletext.org](https://portabletext.org)

The normalized Markdown is a fixpoint for plain text and for every construct in the Supported features table: parsing it and serializing again reproduces it byte-for-byte. Literal Markdown punctuation in a span’s text is backslash-escaped when it serializes, so a second parse reads back the same characters instead of new markup.

import {
markdownToPortableText,
portableTextToMarkdown,
} from '@portabletext/markdown'
portableTextToMarkdown([
{
_type: 'block',
style: 'normal',
children: [{_type: 'span', text: '*bar*', marks: []}],
markDefs: [],
},
])
// -> '\*bar\*'
markdownToPortableText('\\*bar\\*')
// -> span text back to '*bar*'

Constructs whose type the schema doesn’t declare degrade predictably: they keep their content and drop the structure that named them. A mark, list, or task checkbox whose type isn’t in the schema keeps its text and drops the formatting.

import {compileSchema, defineSchema} from '@portabletext/schema'
const schema = compileSchema(defineSchema({})) // no 'strong' decorator declared
markdownToPortableText('**bar**', {schema})
// -> [{..., children: [{text: 'bar', marks: []}]}]

Portable Text structures with no Markdown form degrade predictably going back out. GFM tables have one header row, so header rows beyond the first flatten into the body.

portableTextToMarkdown([
{
_type: 'table',
headerRows: 2,
rows: [
{
_type: 'row',
cells: [
{
_type: 'cell',
value: [
/* 'H1a' */
],
},
],
},
{
_type: 'row',
cells: [
{
_type: 'cell',
value: [
/* 'H1b' */
],
},
],
},
{
_type: 'row',
cells: [
{
_type: 'cell',
value: [
/* 'data' */
],
},
],
},
],
},
])
| H1a |
| ---- |
| H1b |
| data |

Deep or level-skipping list levels 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.

portableTextToMarkdown([
{
_type: 'block',
listItem: 'bullet',
level: 1,
children: [{_type: 'span', text: 'foo', marks: []}],
},
{
_type: 'block',
listItem: 'bullet',
level: 4,
children: [{_type: 'span', text: 'bar', marks: []}],
},
{
_type: 'block',
listItem: 'bullet',
level: 1,
children: [{_type: 'span', text: 'baz', marks: []}],
},
])
// -> '- foo\n - bar\n- baz'

Unknown object types are the exception: they round-trip. A block-level object renders as a fence with a json:object info string holding the value as JSON, and an inline object as a json:object-tagged inline code span. The parser turns both back into the objects they came from, whatever the schema declares.

portableTextToMarkdown([{_type: 'widget', _key: 'w1', color: 'blue'}])
// -> '```json:object\n{\n "_type": "widget",\n "_key": "w1",\n "color": "blue"\n}\n```'
markdownToPortableText(/* the fenced block above */)
// -> [{_type: 'widget', _key: 'w1', color: 'blue'}]
// (the original object, `_key` included)

A json:object fence or tagged span whose body is not a JSON object with a non-empty string _type is ordinary code: it never throws, and it degrades like any other code the schema does or does not declare.

5. Identity does not round-trip for text blocks

Section titled “5. Identity does not round-trip for text blocks”

Text block and span keys are regenerated on every parse, and adjacent spans with identical marks merge into one. Unknown objects keep their _key through the round trip, since it travels inside the JSON payload.

portableTextToMarkdown([
{
_type: 'block',
children: [
{_type: 'span', text: 'foo', marks: ['strong']},
{_type: 'span', text: 'bar', marks: ['strong']},
],
},
])
// -> '**foobar**'
markdownToPortableText('**foobar**')
// -> one span, a new key, text: 'foobar', marks: ['strong']
// (the two original spans and their keys are gone)

6. Hard breaks round-trip through a dedicated channel

Section titled “6. Hard breaks round-trip through a dedicated channel”

A \n inside a span’s text and hard-break syntax (two or more trailing spaces, or a backslash, before the newline) are exclusive counterparts in both directions: a \n always renders as hard-break syntax on the way out, and hard-break syntax always becomes \n on the way in, never the space a soft wrap joins with.

markdownToPortableText('line one\nline two')
// -> one span, text: 'line one line two' (soft wrap, joined with a space)
markdownToPortableText('line one \nline two')
// -> one span, text: 'line one\nline two' (hard break, kept as \n)
portableTextToMarkdown([
{
_type: 'block',
children: [{_type: 'span', text: 'line one\nline two', marks: []}],
},
])
// -> 'line one \nline two' (hard-break syntax)

Five named exceptions qualify the fixpoint claim in guarantee 2 above.

The reserved json:object info string. A code object whose language is literally json:object loses that language on serialization: emitting it would make the code block re-parse as an embedded object whenever its content happens to be typed JSON. The code itself survives.

Tagged-span adjacency. Span text ending in json:object directly before a code-marked span holding a JSON object with a string _type binds into an inline object on reparse. Escapes cannot prevent it: they resolve before the binding runs.

Linkified substrings. An explicit-scheme URL or an email address in a span’s text is never escaped: the text stays byte-identical, but it gains a link mark on the next parse (autolinking is a parser feature, not a round-trip bug).

portableTextToMarkdown([
{
_type: 'block',
children: [
{_type: 'span', text: 'Visit https://example.com now', marks: []},
],
},
])
// -> 'Visit https://example.com now'
markdownToPortableText('Visit https://example.com now')
// -> three spans: 'Visit ' (no marks), 'https://example.com' (marks: ['<generated link key>']),
// ' now' (no marks); a 'link' markDef with href 'https://example.com'
portableTextToMarkdown(/* the three-span result above */)
// -> 'Visit [https://example.com](https://example.com) now'

The next serialization renders the now-marked span as the explicit inline link [https://example.com](https://example.com), which is the stable form from that point on: reparsing and serializing again reproduces the same markdown byte-for-byte. The loop converges after one cycle, it never oscillates between the bare and linked spellings.

Fuzzy www. forms follow the same unescaped-and-linkified path only while they carry no markdown-significant punctuation. Once one does, the punctuation takes normal escaping instead, the same as it would anywhere else in a span’s text:

portableTextToMarkdown([
{
_type: 'block',
children: [
{_type: 'span', text: 'see www.example.com/*x* today', marks: []},
],
},
])
// -> 'see www.example.com/\*x\* today'
markdownToPortableText('see www.example.com/\\*x\\* today')
// -> text back to 'see www.example.com/*x* today' (byte-identical), split across three spans:
// 'see ' (no marks), 'www.example.com/' (marks: ['<generated link key>']), '*x* today' (no marks)

Heading hard-break structural split. 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.

portableTextToMarkdown([
{
_type: 'block',
style: 'h1',
children: [{_type: 'span', text: 'foo\nbar', marks: []}],
},
])
// -> '# foo \nbar' (hard-break syntax: two trailing spaces before the newline)
markdownToPortableText('# foo \nbar')
// -> two blocks: {style: 'h1', children: [{text: 'foo'}]}, {style: 'normal', children: [{text: 'bar'}]}
// (the span text survives, split across the two blocks; block identity doesn't)

CommonMark-inherent whitespace trimming. Leading or trailing whitespace that CommonMark’s own block parsing trims isn’t part of the fixpoint claim.

markdownToPortableText(' hello world ')
// -> span text: 'hello world' (no leading or trailing spaces)