Skip to content

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

Merged
christianhg merged 2 commits into
mainfrom
markdown-json-object-fences
Sep 8, 2026
Merged

feat: round-trip unknown objects through json:object fences and code spans#3240
christianhg merged 2 commits into
mainfrom
markdown-json-object-fences

Conversation

@christianhg

@christianhg christianhg commented Sep 7, 2026

Copy link
Copy Markdown
Member

Converting Portable Text to markdown and back destroys custom objects. The serializer's unknown-type fallback emits a ```json fence, and re-parsing turns that fence into a code block, losing the _type, the fields, and the _key. Inline objects degrade worse: the fence breaks out of the text flow, so the value also loses its position and splits its host block into three. That loss is why editing Portable Text through markdown is off-limits for agent tooling: a read-modify-write loop through markdown wipes every custom object in the document.

Unknown objects now round-trip at both levels. A block-level object serializes as a fence with a json:object info string; an inline object serializes as a json:object-tagged inline code span, single-line JSON, staying inside its line. The parser reconstructs both into the objects they came from before any code-block handling runs. Reconstruction is pure transport: the fence and the span carry their own _type, neither the schema nor the type name gates it, and the payload comes back exactly as written, _key kept when present, nothing added when absent, duplicates not policed (uniqueness is sibling-scoped and owned downstream); validation stays where it lives for every other construct. Pinned by round-trip and fixpoint tests at both levels, including objects in blockquotes and in table cells (with | in a cell payload, which the cell escaping and markdown-it's cell splitting cancel out), and a pretty-printed hand-written payload; those fail on the pre-change code. The degradation tests (non-object bodies stay ordinary code) and binding negative tests (a space between tag and span prevents binding) pin boundary behavior the change preserves.

One guard bounds the carrier itself, pinned: the json:object info string is reserved, so a real code object with that literal language loses the language (not the code) instead of re-parsing as an embedded object whenever its content happens to be typed JSON.

Two edges are accepted and pinned rather than fixed: plain text ending in json:object immediately followed by a code-marked span whose content is a typed JSON object parses as an inline object (escapes resolve before the token-level binding, so the escaping engine cannot mask it), and marks around such a hand-written span are dropped. The serializer never emits either shape.

Validating payloads against a schema is deliberately out of scope: this PR is the carrier. The feat commit updates the package README's round-trip summary; a docs: commit updates the canonical round-tripping page and the rendering-page mentions, so the site describes this behavior once it releases.


Note

Medium Risk
Changes default Markdown output and parsing for unknown types and fenced json:object content, which can break consumers that assumed ```json fences or treat all fences as code blocks; edge-case inline binding around the reserved json:object prefix is also behaviorally sensitive.

Overview
Unknown Portable Text objects can survive Markdown conversion in both directions. @portabletext/markdown now emits block-level unrecognized types as ```json:object fences (replacing ```json) and inline types as json:object plus a single-line JSON code span in the same line, instead of breaking inline content out with newlines.

markdownToPortableText reconstructs those carriers into the original typed objects (including _key when present) before normal code-block handling, without requiring schema support. Invalid or untyped JSON still becomes an ordinary code block or inline code mark.

Guards and contract updates: real code blocks whose language is json:object serialize with an empty info string so typed JSON in the body cannot be mistaken for an embedded object. Docs, README, and changeset describe round-trip behavior, fixpoint exceptions (tagged-span adjacency), and the minor output change for unknown-type fallbacks.

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

@changeset-bot

changeset-bot Bot commented Sep 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 591a665

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 Minor

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 Sep 7, 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 8, 2026 6:00am UTC
portable-text-example-basic Ready Ready Preview Sep 8, 2026 6:00am UTC
portable-text-playground Ready Ready Preview Sep 8, 2026 6:00am UTC

Request Review

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Bundle Stats

✅ No significant changes.

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.3 KB None 53 ms -2 ms, -2.9%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms +0 ms, +2.0%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 6 ms +0 ms, +0.9%
⚪ @portabletext/editor / @portabletext/editor/selectors export 94.8 KB / 21.7 KB None 6 ms +0 ms, +0.8%
⚪ @portabletext/editor / @portabletext/editor/traversal export 42.8 KB / 11.2 KB None 5 ms +0 ms, +2.7%
⚪ @portabletext/editor / @portabletext/editor/utils export 34.0 KB / 9.1 KB None 5 ms -0 ms, -3.3%
⚪ @portabletext/markdown / @portabletext/markdown export 292.1 KB / 86.0 KB +496 B, +0.6% 32 ms -0 ms, -0.6%

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

@christianhg
christianhg marked this pull request as ready for review September 7, 2026 13:36
@christianhg
christianhg force-pushed the markdown-json-object-fences branch from b591717 to f4d6cae Compare September 7, 2026 13:50
@christianhg christianhg changed the title feat: round-trip unknown block objects through json:object fences feat: round-trip unknown objects through json:object fences and code spans Sep 7, 2026
@christianhg
christianhg force-pushed the markdown-json-object-fences branch from f4d6cae to 55b6295 Compare September 7, 2026 14:12
@christianhg
christianhg force-pushed the markdown-json-object-fences branch from 55b6295 to 8df4a1b Compare September 7, 2026 14:30

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

Stale Bugbot comment from a previous run.

Comment thread packages/markdown/src/from-portable-text/renderers/type.ts

@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 2 potential issues.

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 8e2aefd. Configure here.

Comment thread packages/markdown/src/to-portable-text/markdown-to-portable-text.ts
Comment thread packages/markdown/src/to-portable-text/markdown-to-portable-text.ts Outdated
…e spans

`DefaultUnknownTypeRenderer` emitted every value it received as a
```json fence. Re-parsing routed every fence through `types.code`, so a
PT → markdown → PT cycle turned an unknown block object into a `code`
block, destroying the `_type`, the fields, and the `_key`. Inline
objects degraded worse: the fence broke out of the text flow, so the
value lost its type and its position, and the host block split into
three. This is the loss that forces agent tooling to ban editing
Portable Text through markdown.

Unknown objects now round-trip at both levels. Block-level values
serialize with a `json:object` info string, and the parser
reconstructs such a fence into its object before the code path runs.
Inline values serialize as a `json:object`-tagged inline code span
(single-line JSON via `wrapInCodeSpan`, extracted from
`DefaultCodeRenderer`, so payload backticks widen the span), and the
inline child walk binds a text token ending in `json:object` to an
immediately following `code_inline` token, pushing the object as an
inline child in place. Reconstruction is pure transport: the fence and
the span carry their own `_type`, neither the schema nor the type name
gates it, and the payload comes back exactly as written, `_key` kept
when present, nothing added when absent, duplicates not policed
(uniqueness is sibling-scoped and owned by downstream normalization
and validation). A body that is not a JSON object with a non-empty
string `_type`, the minimum for the result to be representable as a
Portable Text node, falls through to the ordinary code path
(`parseJsonObjectFence` returns `undefined`) and never throws.

One guard bounds the carrier itself: the `json:object` info string is
reserved, and `normalizeLanguage` drops it from real `code` objects,
since emitting it would make a code block re-parse as an embedded
object whenever its content happens to be typed JSON.

Scoped deltas. Canonical output changes for unknown objects at both
levels and for `code` objects with the reserved language. Plain text
ending in `json:object` immediately followed by a code-marked span
whose content is a typed JSON object now parses as an inline object;
escapes resolve before the token-level binding runs, so the escaping
engine cannot mask this, and marks around such a hand-written span are
dropped. Both are pinned as known edges.

The round-trip and fixpoint suites (object identity with nested and
inline keys at both levels, in blockquotes and in table cells,
including `|` in a cell payload, which `escapeTableCell` escapes and
markdown-it's cell splitting unescapes before the JSON parse), the
keyless-transport pins, the reserved-language pin, and the degradation
and binding negative tests fail on the pre-change code where they
assert the new carrier; the degradation and negative tests pin the
preserved boundary behavior.
The canonical round-tripping page called unknown-object conversion
destructive; the fenced-JSON fallback now round-trips at both levels.
Principle 4's example flips from loss to identity, principle 5 scopes
key regeneration to text blocks, the exceptions section gains the
reserved info string and the tagged-span adjacency edge, and the
rendering pages' fallback mentions name both carrier forms.
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