feat: round-trip unknown objects through json:object fences and code spans - #3240
Conversation
🦋 Changeset detectedLatest commit: 591a665 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Bundle Stats✅ No significant changes. All scenario measurements (7)🗺️
Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time. |
b591717 to
f4d6cae
Compare
json:object fencesjson:object fences and code spans
f4d6cae to
55b6295
Compare
55b6295 to
8df4a1b
Compare
8df4a1b to
9718af3
Compare
9718af3 to
14f4b27
Compare
14f4b27 to
8e2aefd
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ 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.
8e2aefd to
865b7d7
Compare
865b7d7 to
48d9759
Compare
…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.
48d9759 to
591a665
Compare

Converting Portable Text to markdown and back destroys custom objects. The serializer's unknown-type fallback emits a
```jsonfence, and re-parsing turns that fence into acodeblock, 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:objectinfo string; an inline object serializes as ajson: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,_keykept 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:objectinfo string is reserved, so a realcodeobject 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:objectimmediately 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:objectcontent, which can break consumers that assumed```jsonfences or treat all fences as code blocks; edge-case inline binding around the reservedjson:objectprefix is also behaviorally sensitive.Overview
Unknown Portable Text objects can survive Markdown conversion in both directions.
@portabletext/markdownnow emits block-level unrecognized types as```json:objectfences (replacing```json) and inline types asjson:objectplus a single-line JSON code span in the same line, instead of breaking inline content out with newlines.markdownToPortableTextreconstructs those carriers into the original typed objects (including_keywhen present) before normal code-block handling, without requiring schema support. Invalid or untyped JSON still becomes an ordinarycodeblock or inline code mark.Guards and contract updates: real
codeblocks whoselanguageisjson:objectserialize 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.