Skip to content

feat: report serialize-side degradation via onDegradation in portableTextToMarkdown - #3289

Open
christianhg wants to merge 1 commit into
mainfrom
feat/markdown-serialize-degradation
Open

christianhg wants to merge 1 commit into
mainfrom
feat/markdown-serialize-degradation

Conversation

@christianhg

@christianhg christianhg commented Sep 18, 2026

Copy link
Copy Markdown
Member

portableTextToMarkdown now takes an onDegradation option for the losses its own default renderers fall back to: an annotation or decorator with no mark renderer (annotation-dropped, decorator-dropped), a block style with no renderer (style-fallback), or a list-item kind with no renderer (list-item-fallback). One callback, called at most once, after the whole document has been walked, only when at least one construct degraded, with each loss reported as a SerializeDegradation:

portableTextToMarkdown(blocks, {
  onDegradation: ({degradations, message}) => console.log(message),
})
// degradations: every SerializeDegradation, in encounter order
// message: the same degradations grouped, snippeted, and sorted by block

Providing your own renderer for the affected construct suppresses its report: onDegradation only fires for fallbacks the library's own default renderers produced, never for a consumer's unknownMark, unknownBlockStyle, or unknownListItem. list-item-fallback never fires under the default configuration, since the default listItem renderer already covers every kind: it only fires behind a consumer-supplied partial listItem map.

applyMarkdownEdit's serialize option doesn't accept onDegradation, since that serialization only ever runs internally to align storedPortableText, never on editedMarkdown itself.

Before this, the deserialize direction reported every lossy construct while the serialize direction was silent: a custom link annotation serialized to plain text and a round trip returned the block with markDefs: [], discoverable only by diffing output. The reporting boundary is deliberate: events fire exactly where the render walk resolves a library-default fallback renderer, pinned per family with suppression tests, while losses decided inside default renderers (unsafe links, table header demotion, empty tables) are a separate mechanism and out of scope here. Serialized output is byte-identical with and without the option, pinned by a test, and the payload mirrors the parse side's contract with a path anchor instead of source lines: keyed segments where nodes carry a _key, numeric indexes otherwise, down to the lossy node itself (a table cell's degraded span reports its full path through rows and cells). The path is best-effort, reconstructed by re-finding the reported node inside its top-level block, and falls back to the top-level segment alone when the node cannot be re-found, which the docs state.


Note

Low Risk
Additive minor API and observability only; markdown output is unchanged when the callback is omitted, with tests pinning byte-identical serialization.

Overview
Adds serialize-side degradation reporting to mirror the existing parse-side onDegradation flow. portableTextToMarkdown accepts an optional onDegradation callback (at most once per run, only when something lossy happened) with structured SerializeDegradation entries plus a grouped message.

Losses are recorded when library default fallbacks run: missing mark renderers (annotation-dropped / decorator-dropped), unknown block styles (style-fallback), and uncovered list-item kinds behind a partial listItem map (list-item-fallback). Custom unknownMark / unknownBlockStyle / unknownListItem handlers suppress those reports. Each event includes a best-effort path into the document (including nested table cells), optional truncated snippet, and stable type for consumers to match on.

applyMarkdownEdit explicitly omits onDegradation from serialize and strips it from internal canonicalization so alignment serialization stays silent. SerializeDegradation is exported from the package entry; README documents the PT→MD reporting contract.

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

@vercel

vercel Bot commented Sep 18, 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 18, 2026 10:27am UTC
portable-text-example-basic Ready Ready Preview Sep 18, 2026 10:27am UTC
portable-text-playground Ready Ready Preview Sep 18, 2026 10:27am UTC

Request Review

@changeset-bot

changeset-bot Bot commented Sep 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bd25150

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

@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Bundle Stats

Warning

1 significant change.

@portabletext/markdown

🔴 @portabletext/markdown (export)
Gzip: 108.9 KB, up 3.2 KB (3.0%)
Raw: 386.8 KB, up 10.4 KB (2.8%)
Import: 27 ms, down 0 ms (1.6%)

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 67 ms -0 ms, -0.3%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms -0 ms, -1.9%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 7 ms -0 ms, -0.4%
⚪ @portabletext/editor / @portabletext/editor/selectors export 94.7 KB / 21.7 KB None 8 ms -0 ms, -2.0%
⚪ @portabletext/editor / @portabletext/editor/traversal export 42.8 KB / 11.2 KB None 6 ms -0 ms, -2.7%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.8 KB / 9.1 KB None 6 ms +0 ms, +2.8%
🔴 @portabletext/markdown / @portabletext/markdown export 386.8 KB / 108.9 KB +3.2 KB, +3.0% 27 ms -0 ms, -1.6%

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

…bleTextToMarkdown`

`markdownToPortableText` reports every lossy construct through its
`onDegradation` callback, but `portableTextToMarkdown` had no
counterpart: an annotation with no mark renderer routed through
`unknownMark`, which emits children only, so the annotation and its
data vanished from the markdown with no signal, and a round trip
through `applyMarkdownEdit` returned the block with `markDefs: []`.
Consumers discovered serialize-side loss by diffing round-trip output.

The serializer now accepts its own `onDegradation`, mirroring the
parse side's contract: one callback, at most once, after the walk,
only when at least one construct degraded, with `{degradations,
message}`. Events are emitted exactly where the walk resolves a
fallback renderer and the resolved fallback is the library default
(identity check): `annotation-dropped` and `decorator-dropped` from
`unknownMark` split on markDef presence, `style-fallback` from the
default unknown-style renderer, and `list-item-fallback` from the
default unknown-list-item renderer, which is reachable only behind a
consumer-supplied partial `listItem` map. A consumer-supplied fallback
suppresses its family's reports. Losses inside nested content anchor
to the enclosing top-level block via a current-block slot, and block
`_key`s are snapshotted before `buildListIndexMap` mutates keyless
blocks so a keyless block reports as keyless. Serialized output is
byte-identical with and without the option, pinned by a test.

The serializer owns its own `SerializeDegradation` union rather than
sharing the parse side's `Degradation` (different anchor fields,
independent evolution). Each event carries a `path` addressing the
lossy node from the top-level array down (keyed segments where nodes
have a `_key`, numeric indexes otherwise), reconstructed at report
time by searching the enclosing top-level block for the reported node:
by reference for blocks and list items, and by a constrained `_key`
match for spans, since the mark tree rebuilds every marked span into a
fresh wrapper object. The span match requires span `_type` and a
`children` container so spec-legal duplicate keys at other levels
cannot capture the path, and the search falls back to the top-level
segment alone when the node cannot be re-found (a keyless nested span,
a renderer that clones nodes). The grouping and truncation helpers are
duplicated from the parse side with keep-in-sync pointers both ways,
since the originals are unexported internals. `applyMarkdownEdit`'s
`serialize` options now `Omit` `onDegradation`, and both internal
serialization sites strip a type-bypassing callback: those runs
produce alignment input, never consumer-facing output. Losses decided
inside default renderers (unsafe links, table header demotion, empty
tables) are deliberately out of scope: reporting them needs a reporter
channel in the public renderer signature or mirrored predicates, a
separate mechanism.
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