Skip to content

feat: gate the default markdown renderers on the schema in portableTextToMarkdown - #3255

Merged
christianhg merged 2 commits into
mainfrom
markdown-schema-driven-serialization
Sep 9, 2026
Merged

feat: gate the default markdown renderers on the schema in portableTextToMarkdown#3255
christianhg merged 2 commits into
mainfrom
markdown-schema-driven-serialization

Conversation

@christianhg

@christianhg christianhg commented Sep 9, 2026

Copy link
Copy Markdown
Member

The two conversion directions disagree about the schema, and the disagreement destroys content. markdownToPortableText is 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). portableTextToMarkdown was shape-keyed and schema-blind: any table-shaped block became a GFM table, any code-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 the json:object carrier, 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.

portableTextToMarkdown now takes an optional schema, the same compiled Schema the parse side takes:

const schema = compileSchema(
  defineSchema({
    blockObjects: [
      {
        name: 'code',
        fields: [
          {name: 'code', type: 'string'},
          {name: 'language', type: 'string'},
        ],
      },
    ],
  }),
)

portableTextToMarkdown(blocks, {schema})
// a `code` block renders as a fenced code block; a `table` block
// (undeclared) renders as a `json:object` fence that reparses verbatim

The gate wraps the six default type renderers at the renderer-merge point, so precedence falls out of the existing merge: explicit types entries are never gated (the types: {table: undefined} knockout keeps working), and undeclared types render through the resolved unknownType, so a custom unknownType receives the gated nodes (pinned). Membership is strict by position, blockObjects for block position and inlineObjects for inline, mirroring the parse side's buildObjectMatcher; an image declared only inline fences at block position rather than serializing into a position-shifting reparse (both directions pinned). Without schema the 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 custom unknownType output 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-level image now 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
portableTextToMarkdown gains an optional schema so built-in type renderers (callout, code, horizontal-rule, html, image, table) run only when that type is declared at the node’s position (blockObjects vs inlineObjects). Undeclared types serialize through unknownType (default json:object fence/span) so PT→MD→PT can preserve values the parse schema cannot rebuild. Explicit types overrides are never gated; omitting schema keeps prior behavior.

Table cells: objects that use the default carrier now emit the inline json:object form in GFM cells instead of a block fence that breaks on reparse. markdownToPortableText applies the same schema-aware placement for a lone object in a cell (inline-only types stay in the text block; otherwise lift to cell.value), including standalone images without block-level image (now image-block-to-inline instead 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.

@changeset-bot

changeset-bot Bot commented Sep 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2cd355f

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 9, 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 9, 2026 10:35am UTC
portable-text-example-basic Ready Ready Preview Sep 9, 2026 10:35am UTC
portable-text-playground Ready Ready Preview Sep 9, 2026 10:35am UTC

Request Review

@github-actions

github-actions Bot commented Sep 9, 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 66 ms +0 ms, +0.6%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms -0 ms, -1.2%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 7 ms +0 ms, +4.0%
⚪ @portabletext/editor / @portabletext/editor/selectors export 94.7 KB / 21.7 KB None 8 ms +0 ms, +2.5%
⚪ @portabletext/editor / @portabletext/editor/traversal export 42.8 KB / 11.2 KB None 6 ms -0 ms, -1.0%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.8 KB / 9.1 KB None 6 ms +0 ms, +0.2%
⚪ @portabletext/markdown / @portabletext/markdown export 313.4 KB / 90.9 KB +249 B, +0.3% 41 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 9, 2026 08:25
@christianhg
christianhg force-pushed the markdown-schema-driven-serialization branch from 2f22d62 to b9d6cc4 Compare September 9, 2026 09:14
@christianhg
christianhg force-pushed the markdown-schema-driven-serialization branch from b9d6cc4 to 7536868 Compare September 9, 2026 09:16
@christianhg
christianhg force-pushed the markdown-schema-driven-serialization branch from 7536868 to 1e72ff4 Compare September 9, 2026 09:25
@christianhg
christianhg force-pushed the markdown-schema-driven-serialization branch from 1e72ff4 to fb52027 Compare September 9, 2026 09:36
@christianhg
christianhg force-pushed the markdown-schema-driven-serialization branch from fb52027 to a613502 Compare September 9, 2026 09:53
@christianhg
christianhg force-pushed the markdown-schema-driven-serialization branch from a613502 to a32ff1f Compare September 9, 2026 10:01
@christianhg
christianhg force-pushed the markdown-schema-driven-serialization branch from a32ff1f to 078542b Compare September 9, 2026 10:15
…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.
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.
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