Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .changeset/json-object-fences.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@portabletext/markdown': minor
---

feat: round-trip unknown objects through `json:object` fences and code spans

Custom objects now survive Markdown conversion in both directions. `portableTextToMarkdown` renders a block-level object as a ` ```json:object ` fence and an inline object as a `json:object`-tagged code span, and `markdownToPortableText` turns both back into the original objects, `_key` included, no schema required. Previously these objects came back as `code` blocks, and inline objects also split the block around them, so converting to Markdown and back destroyed them.

````md
```json:object
{"_type": "product", "_key": "k1", "sku": "abc-123"}
```

AAPL is at json:object`{"_type": "stockTicker", "_key": "k2", "symbol": "AAPL"}` right now.
````

Both forms parse back to the objects in the payloads, with the surrounding text intact.

What changes in existing output and parsing:

- Markdown output changes for unknown objects: ` ```json ` fences become ` ```json:object `, and inline objects stay inside their line instead of breaking out.
- The `json:object` language is reserved. A `code` block with exactly that language keeps its code but loses the language when serialized.
- A ` ```json:object ` fence or tagged code span that doesn't contain a JSON object with a `_type` parses as ordinary code.
20 changes: 13 additions & 7 deletions apps/docs/src/content/docs/conversion/markdown-round-tripping.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -141,20 +141,22 @@ portableTextToMarkdown([
// -> '- foo\n - bar\n- baz'
```

An unknown object type renders as a fenced JSON block. That block is not a stopgap spelling of the original type: on reparse it comes back as a `code` object, not the type that went out. Round-tripping an unrecognized type through Markdown is destructive.
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.

````ts
portableTextToMarkdown([{_type: 'widget', _key: 'w1', color: 'blue'}])
// -> '```json\n{\n "_type": "widget",\n "_key": "w1",\n "color": "blue"\n}\n```'
// -> '```json:object\n{\n "_type": "widget",\n "_key": "w1",\n "color": "blue"\n}\n```'

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

### 5. Identity does not round-trip
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.

Keys are regenerated on every parse, and adjacent spans with identical marks merge into one.
### 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.

```ts
portableTextToMarkdown([
Expand Down Expand Up @@ -195,7 +197,11 @@ portableTextToMarkdown([

## Exceptions

Three named exceptions qualify the fixpoint claim in guarantee 2 above.
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).

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/src/content/docs/rendering/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ But Portable Text isn't just rich text. The same array can contain **any type of

Within text blocks, **marks** add meaning to inline text. **Decorators** (bold, italic, underline) work by default. **Annotations** (links, references, footnotes) carry structured data as mark definitions. A link annotation isn't just an `<a>` tag: it's a data object with href, target, tracking parameters, or whatever fields you define. You customize annotation rendering through the `marks` component map. Text blocks can also contain **inline objects**: structured data embedded in the text flow, like a stock ticker, a product reference, or a custom emoji. Inline objects are rendered through the same `types` component map as custom blocks.

If a serializer encounters a type it doesn't recognize, it falls back to an unknown-type handler instead of throwing; what that fallback renders varies per serializer (the [Markdown serializer](/rendering/markdown/), for example, renders unrecognized types as a fenced JSON block).
If a serializer encounters a type it doesn't recognize, it falls back to an unknown-type handler instead of throwing; what that fallback renders varies per serializer (the [Markdown serializer](/rendering/markdown/), for example, renders unrecognized types as a `json:object` fence, or a `json:object`-tagged code span inline, that parses back into the same object).

## Get started

Expand Down Expand Up @@ -328,7 +328,7 @@ All serializers use the same component map structure (except Astro, which uses s
| `listItem` | List items | Custom list item rendering |
| `hardBreak` | Line breaks within text | Custom line break handling |

Unknown types, marks, and styles go through an unknown-type fallback whose default varies per serializer (the React serializer renders a hidden warning, the Markdown serializer a fenced JSON block). You can take over with `unknownType`, `unknownMark`, `unknownBlockStyle`, `unknownList`, and `unknownListItem` components.
Unknown types, marks, and styles go through an unknown-type fallback whose default varies per serializer (the React serializer renders a hidden warning, the Markdown serializer a `json:object` fence or tagged code span that parses back into the same object). You can take over with `unknownType`, `unknownMark`, `unknownBlockStyle`, `unknownList`, and `unknownListItem` components.

## Framework guides

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/docs/rendering/markdown.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const markdown = portableTextToMarkdown(blocks)

## Custom type renderers

`callout`, `code`, `horizontal-rule`, `html`, `image`, and `table` block objects render as Markdown out of the box. `callout`, `code`, `html`, `image`, and `table` fall back to a fenced JSON block when a value doesn't match the shape its renderer expects (a consumer's own differently-shaped `code` type, say); `horizontal-rule` always renders `---`. Register a renderer for other custom types you use, or to override one of the defaults.
`callout`, `code`, `horizontal-rule`, `html`, `image`, and `table` block objects render as Markdown out of the box. `callout`, `code`, `html`, `image`, and `table` fall back to a `json:object` fence when a value doesn't match the shape its renderer expects (a consumer's own differently-shaped `code` type, say), which [round-trips back into the same object](/conversion/markdown-round-tripping/); `horizontal-rule` always renders `---`. Register a renderer for other custom types you use, or to override one of the defaults.

```ts
portableTextToMarkdown(blocks, {
Expand Down
8 changes: 4 additions & 4 deletions packages/markdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,11 @@ Converting Markdown to Portable Text and back isn't a lossless mirror:
1. Translation preserves semantics, not source spelling: the first MD→PT→MD pass normalizes Markdown to one canonical spelling (autolinks become inline links, indented code becomes fenced code, soft-wrapped lines join into one, and so on).
2. The normalized Markdown is a fixpoint for plain text and the [Supported features](#supported-features) table: parsing it and serializing again reproduces it byte-for-byte.
3. MD→PT survival is schema-driven: a construct whose type the schema doesn't declare keeps its content and drops the structure that named it.
4. PT structures with no Markdown form degrade predictably on PT→MD (extra table header rows flatten into the body, deep or level-skipping lists collapse to relative nesting, unknown types render as a fenced JSON block).
5. Identity does not round-trip: keys are regenerated on every parse, and adjacent spans with identical marks merge into one.
4. PT structures with no Markdown form degrade predictably on PT→MD (extra table header rows flatten into the body, deep or level-skipping lists collapse to relative nesting, unknown marks pass their text through unformatted). Unknown object types round-trip instead: block-level as a ` ```json:object ` fence, inline as a `json:object`-tagged code span, both carrying the value as JSON. A fence or span whose body isn't a JSON object with a `_type` is ordinary code.
5. Identity does not round-trip for text blocks: keys are regenerated on every parse, and adjacent spans with identical marks merge into one. Unknown objects keep their `_key`.
6. A hard break and a `\n` in a span's text 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.

Three exceptions to the fixpoint claim: an explicit-scheme URL or email keeps its text but gains a `link` mark on reparse, and a fuzzy `www.` form does too unless it carries markdown-significant punctuation; a hard break inside a heading splits into a second block on reparse, since an ATX heading is single-line; and leading or trailing whitespace that CommonMark's own block parsing trims isn't part of the fixpoint.
The named exceptions to the fixpoint claim: an explicit-scheme URL or email keeps its text but gains a `link` mark on reparse, and a fuzzy `www.` form does too unless it carries markdown-significant punctuation; a hard break inside a heading splits into a second block on reparse, since an ATX heading is single-line; leading or trailing whitespace that CommonMark's own block parsing trims isn't part of the fixpoint; a `code` object with the reserved language `json:object` loses that language on serialization; and span text ending in `json:object` directly before a code-marked span holding a typed JSON object binds into an inline object on reparse.

See [Markdown round-tripping](https://www.portabletext.org/conversion/markdown-round-tripping/) on the docs site for the full contract and worked examples.

Expand Down Expand Up @@ -633,7 +633,7 @@ portableTextToMarkdown(blocks, {
})
```

By default, unknown types render as JSON code blocks, and unknown marks/styles pass through their children unchanged.
By default, unknown types render as `json:object` fences or tagged code spans that round-trip (see [Round-trip behavior](#round-trip-behavior)), and unknown marks/styles pass through their children unchanged.

You can also customize hard break rendering:

Expand Down
5 changes: 4 additions & 1 deletion packages/markdown/src/from-portable-text/renderers/marks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ export const DefaultStrongRenderer: PortableTextMarkRenderer = ({children}) =>
*
* @public
*/
export const DefaultCodeRenderer: PortableTextMarkRenderer = ({text}) => {
export const DefaultCodeRenderer: PortableTextMarkRenderer = ({text}) =>
wrapInCodeSpan(text)

export function wrapInCodeSpan(text: string): string {
const fence = '`'.repeat(longestBacktickRun(text) + 1)
const touchesBacktick = text.startsWith('`') || text.endsWith('`')
const wouldBeStripped =
Expand Down
18 changes: 15 additions & 3 deletions packages/markdown/src/from-portable-text/renderers/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '../../escape'
import {markListItemFirstBlock} from '../list-item-first-block'
import type {PortableTextTypeRenderer} from '../types'
import {wrapInCodeSpan} from './marks'

/**
* @public
Expand Down Expand Up @@ -38,6 +39,13 @@ function normalizeLanguage(language: unknown): string {
if (typeof language !== 'string' || language.includes('\n')) {
return ''
}
if (language === 'json:object') {
// `json:object` is reserved as the object carrier: a code block
// emitting it as its info string would re-parse as the embedded
// object whenever its content happens to be typed JSON, destroying
// the code block. The language degrades to absent instead.
return ''
}
return language
}

Expand Down Expand Up @@ -453,7 +461,11 @@ export const DefaultUnknownTypeRenderer: PortableTextTypeRenderer = ({
value,
isInline,
}) => {
const json = `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``
// For inline unknown types, add newlines to break them out of the text flow
return isInline ? `\n${json}\n` : json
if (isInline) {
// Single-line JSON: code spans turn newlines into spaces on
// reparse, so a pretty-printed payload would still reconstruct but
// would not survive byte-identically, breaking the fixpoint.
return `json:object${wrapInCodeSpan(JSON.stringify(value))}`
Comment thread
cursor[bot] marked this conversation as resolved.
}
return `\`\`\`json:object\n${JSON.stringify(value, null, 2)}\n\`\`\``
}
Loading
Loading