feat: gate the default markdown renderers on the schema in portableTextToMarkdown - #3255
Conversation
🦋 Changeset detectedLatest commit: 2cd355f 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. |
2f22d62 to
b9d6cc4
Compare
b9d6cc4 to
7536868
Compare
7536868 to
1e72ff4
Compare
1e72ff4 to
fb52027
Compare
fb52027 to
a613502
Compare
a613502 to
a32ff1f
Compare
a32ff1f to
078542b
Compare
…extToMarkdown`
The two conversion directions disagreed about the schema. Parsing is
schema-gated per object type (`buildObjectMatcher` returns `undefined`
for a type the schema doesn't declare, and the construct degrades:
`table-flattened`, `code-block-to-text`, `horizontal-rule-to-text`,
`html-block-to-text`, `image-to-text`, `callout-fallback`), but
serialization was shape-keyed and schema-blind: each default type
renderer fired on `_type` plus a shape check. A value holding one of
those shapes under a schema that can't rebuild it therefore serialized
to its markdown form and was destroyed by its own reparse, while the
`json:object` carrier, which reparses to the same value schema or no
schema, sat one fallback away.
`portableTextToMarkdown` now takes an optional compiled `schema`, the
same `Schema` type the parse side takes. When present, the six default
type renderers (`callout`, `code`, `horizontal-rule`, `html`, `image`,
`table`) are wrapped at the renderer-merge point: a renderer runs only
when the schema declares its type name in the collection matching the
render position (`isInline ? inlineObjects : blockObjects`, mirroring
`buildObjectMatcher`), and an undeclared type renders through the
resolved `unknownType`, so a consumer's `unknownType` override receives
the gated nodes. Explicit `types` entries spread over the gated
defaults and are never gated, which keeps the per-type knockout
(`types: {table: undefined}`) and renderer overrides working unchanged.
Without `schema` the renderer merge is untouched.
The gate checks the type name only: a name-only declaration keeps the
default renderer even though the parse side, which filters a construct
to its declared fields, cannot rebuild a fieldless value. Pinned as a
contract, and the docs tell consumers to declare fields. Field-aware
gating would duplicate each parse matcher's field requirements in a
second place and drift.
The contract landed first as 18 pins (one fence fallback per type, a
declared-positive per type, both position-strictness directions for
`image`, override precedence, custom `unknownType`, no-schema
baseline, and the fence round-trip restoring the full value with its
keys), 10 of them proven red on the pre-gate code, plus a
fieldless-declaration pin. A seeded
fuzz property serializes random documents mixing plain blocks with
gated types under schemas declaring random subsets and asserts content
identity through the round trip; without the gate it fails 467 of
1000 cases.
078542b to
0ddb36c
Compare
A GFM cell is one line, so the block fence carrier inside a cell was squashed into `<br>` soup that reparsed as gibberish text: an object the carrier existed to protect was destroyed by the cell around it. `getCellText` now re-emits the default carrier in its single-line inline form, swapped only when the rendered output exactly equals the default carrier's, so declared markdown forms and custom `unknownType` output pass through untouched. On the way back, the parse side's standalone-image lift generalizes to any sole object child of a cell's only block and consults the schema: a type declared inline-only (in `inlineObjects`, not `blockObjects`) stays wrapped as an inline child, its legal home, while declared-block and undeclared types come back at block position in the cell, matching PT's table model where `cell.value` is an array of blocks. That flips one image contract: a standalone cell image under a schema with no block-level `image` now stays inline and reports `image-block-to-inline`, consistent with the mixed-cell sibling, instead of lifting into a placement the schema forbids. Pinned: an undeclared object in a cell round-trips to block position (with and without a serialize schema), a declared inline-only object stays inline through the round trip, and the updated image pin covers the flipped contract; each proven red on the code it corrects. Markdown image syntax is untouched: an image declared nowhere still degrades to literal span text (`image-to-text`), since only carrier payloads are schema-exempt.
0ddb36c to
2cd355f
Compare
The two conversion directions disagree about the schema, and the disagreement destroys content.
markdownToPortableTextis schema-gated per object type: a construct whose type the schema doesn't declare degrades, named and reported (table-flattened,code-block-to-text, and kin).portableTextToMarkdownwas shape-keyed and schema-blind: anytable-shaped block became a GFM table, anycode-shaped block a fence. So a value holding a shape its schema can't rebuild serialized to a pretty markdown form and was destroyed by its own reparse, while thejson:objectcarrier, which reparses to the same value under any schema, sat one fallback away. For edit workflows that serialize, let something edit the markdown, and parse it back (#3242), that's silent data loss on exactly the documents that need custom types most.portableTextToMarkdownnow takes an optionalschema, the same compiledSchemathe parse side takes:The gate wraps the six default type renderers at the renderer-merge point, so precedence falls out of the existing merge: explicit
typesentries are never gated (thetypes: {table: undefined}knockout keeps working), and undeclared types render through the resolvedunknownType, so a customunknownTypereceives the gated nodes (pinned). Membership is strict by position,blockObjectsfor block position andinlineObjectsfor inline, mirroring the parse side'sbuildObjectMatcher; animagedeclared only inline fences at block position rather than serializing into a position-shifting reparse (both directions pinned). Withoutschemathe renderer merge is untouched (pinned, plus the whole existing suite).A second, separable fix ships as its own commit and changeset, active with or without
schema: a GFM cell is one line, so the block fence carrier inside a table cell used to squash into<br>soup that reparsed as gibberish. Cells now re-emit the default carrier in its single-line inline form (swapped only when the rendered output exactly equals the default carrier's, so declared markdown forms and customunknownTypeoutput pass through untouched), and an object riding the carrier inside a cell survives the round trip, the parse side's standalone-image lift generalized to any sole object child of a cell's only block and schema-gated: declared inline-only stays wrapped in the cell's text block (its legal home), declared-block and undeclared types come back at block position. Pinned gated, ungated, and inline-only, each proven red. One parse behavior flips with it: a standalone cell image under a schema without block-levelimagenow stays inline and reports, instead of lifting into a placement the schema forbids. Declared types whose markdown form is multi-line, a code block in a cell, still flatten on reparse; that limit is GFM's, not ours.One honest limit remains by choice: the gate checks the type name only, so a fieldless declaration keeps the default renderer even though the parse side cannot rebuild a fieldless value; field-aware gating would duplicate each parse matcher's field requirements in a second place, so the docs say "declare the fields" and the name-only behavior is pinned as a contract. The contract landed first as 18 pins (10 proven red pre-gate) and grew three more (fieldless declaration, the two cell-carrier pins), and a seeded fuzz property round-trips random documents under random schema subsets: without the gate it fails 467 of 1000 cases. The fuzz generates top-level block positions only; the inline and cell branches rest on the directed pins.
This merges before #3242: with one schema on both sides, its internal canonicalization stops destroying undeclared types, and its "pass the same serialize options" requirement collapses to "pass the same schema".
Note
Medium Risk
Changes serialization and table-cell reparse behavior for consumers using custom schemas or edit round-trips; default no-schema path is unchanged but inline-only image-in-cell now degrades with a report instead of silent lift.
Overview
portableTextToMarkdowngains an optionalschemaso built-in type renderers (callout,code,horizontal-rule,html,image,table) run only when that type is declared at the node’s position (blockObjectsvsinlineObjects). Undeclared types serialize throughunknownType(defaultjson:objectfence/span) so PT→MD→PT can preserve values the parse schema cannot rebuild. Explicittypesoverrides are never gated; omittingschemakeeps prior behavior.Table cells: objects that use the default carrier now emit the inline
json:objectform in GFM cells instead of a block fence that breaks on reparse.markdownToPortableTextapplies the same schema-aware placement for a lone object in a cell (inline-only types stay in the text block; otherwise lift tocell.value), including standalone images without block-levelimage(nowimage-block-to-inlineinstead of forbidden block lift).Docs/changesets and broad unit + seeded fuzz coverage document the contract.
Reviewed by Cursor Bugbot for commit 2cd355f. Bugbot is set up for automated code reviews on this repo. Configure here.