Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/serialize-degradation-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@portabletext/markdown': minor
---

feat: report serialize-side degradation via `onDegradation` in `portableTextToMarkdown`

`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`, whose `path` addresses the node the loss occurred on, from the top-level `blocks` array down (a cell's degradation carries the full path down to the offending node, not just its table's position):

```ts
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.
20 changes: 20 additions & 0 deletions packages/markdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,26 @@ portableTextToMarkdown(blocks, {
})
```

#### Reporting degradation

An annotation or decorator with no mark renderer drops to plain text, a block style with no renderer falls back to a normal paragraph, and a list-item kind with no renderer falls back to a plain bullet item: each degrades rather than throwing. `onDegradation` observes those losses, called once, after the whole document has been walked, only when at least one construct degraded, with a report object holding every `SerializeDegradation` in encounter order and a canonical grouped `message`. Left unset, the conversion stays silent and returns the lossiest representation it can build. Enforce against lossy output by throwing your own error from inside that callback. The throw propagates out of `portableTextToMarkdown`.

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

Each `SerializeDegradation` carries `type` (`annotation-dropped`, `decorator-dropped`, `style-fallback`, or `list-item-fallback`), a human-readable `message`, `path`, and `snippet` (the offending text, truncated to 40 characters) when there's a specific piece of text to quote. `path` addresses the node the loss occurred on, from the top-level `blocks` array down: an array position is `{_key: node._key}` when the node has a `_key`, the numeric index otherwise, and an object property is its field name. A degradation inside a table cell carries the full path down to the offending node, not just the table's own position. `path` is best-effort: it names the deepest node the conversion can re-find, falling back to just the top-level segment when it can't (a custom renderer cloning a node before rendering it, or a keyless nested span with nothing left to search by). Match on `type`, not `message`: the type is the stable contract, the message can change between releases, and the set of `type` values itself grows in minor releases, so compare against the values you handle rather than switching exhaustively.

Providing your own renderer for the affected construct (a custom `unknownMark`, `unknownBlockStyle`, or `unknownListItem`, or a `listItem` map covering every kind the document uses) suppresses the report for that construct: `onDegradation` only fires for fallbacks the library's own default renderers produced. `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`: it serializes `storedPortableText` only for internal alignment, never `editedMarkdown` itself, so there's nothing there for a caller to observe losses in.

### `applyMarkdownEdit`

Parsing markdown mints fresh `_key`s for text blocks (see [Round-trip behavior](#round-trip-behavior)), so converting a document to markdown, editing one word, and converting back returns what looks like a full rewrite: comment anchors detach, history churns, and granular patching is impossible. `applyMarkdownEdit` converts edited markdown back to Portable Text and restores stored `_key`s the way the same edit in an editor would have kept them:
Expand Down
35 changes: 35 additions & 0 deletions packages/markdown/src/apply-markdown-edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1696,6 +1696,41 @@ describe(applyMarkdownEdit.name, () => {
expect(onDegradation).toHaveBeenCalledTimes(1)
})

test('a sneaked-in `serialize.onDegradation` (an untyped caller) is never invoked, even when the stored value has an unknown annotation', () => {
const keyGenerator = createTestKeyGenerator()
const onDegradation = vi.fn()
const stored = [
{
_type: 'block',
_key: 'b1',
style: 'normal',
markDefs: [
{_type: 'customLink', _key: 'l1', href: 'https://example.com'},
],
children: [{_type: 'span', _key: 's1', text: 'foo', marks: ['l1']}],
},
]

applyMarkdownEdit(stored, 'bar', {
deserialize: {keyGenerator},
serialize: {onDegradation},
} as Parameters<typeof applyMarkdownEdit>[2])

expect(onDegradation).not.toHaveBeenCalled()
})

test('the `serialize` option type omits `onDegradation`', () => {
const onDegradation = vi.fn()
const stored: Array<PortableTextBlock> = []

applyMarkdownEdit(stored, 'foo', {
deserialize: {keyGenerator: createTestKeyGenerator()},
// @ts-expect-error `serialize` excludes `onDegradation` (see
// `ApplyMarkdownEditOptions`)
serialize: {onDegradation},
})
})

test('two fences carrying the same key stay unique against a constant keyGenerator', () => {
const stored: Array<PortableTextBlock> = []
const fence = (key: string, sku: string) =>
Expand Down
20 changes: 16 additions & 4 deletions packages/markdown/src/apply-markdown-edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ export type ApplyMarkdownEditOptions = {
* options that produced the markdown that was edited: key
* resolution aligns the edit against a fresh canonical serialization
* of `storedPortableText`, so the two serializations must agree for
* that alignment to be meaningful.
* that alignment to be meaningful. Excludes `onDegradation`: this
* serialization only ever runs internally, to canonicalize
* `storedPortableText` for alignment, never on `editedMarkdown` or its
* result, so there is nothing here for a caller to observe losses in.
*/
serialize?: Omit<
NonNullable<Parameters<typeof portableTextToMarkdown>[1]>,
'schema'
'schema' | 'onDegradation'
>
/**
* Reports how stored `_key`s were reconciled onto the converted
Expand Down Expand Up @@ -378,7 +381,7 @@ function canonicalizeStored(
): Array<Node> {
const storedClone = structuredClone(stored) as Array<PortableTextBlock>
const storedMarkdown = portableTextToMarkdown(storedClone, {
...options?.serialize,
...stripOnDegradation(options?.serialize),
schema: options?.schema,
})
const {onDegradation, ...canonicalDeserializeOptions} =
Expand All @@ -391,6 +394,15 @@ function canonicalizeStored(
}) as unknown as Array<Node>
}

function stripOnDegradation<T extends object>(
value: T | undefined,
): Omit<T, 'onDegradation'> {
const {onDegradation: _onDegradation, ...rest} = (value ?? {}) as T & {
onDegradation?: unknown
}
return rest
}

/**
* Positional pairing between `stored` and `canonical` is only
* trustworthy when serialization preserved the node count and the
Expand Down Expand Up @@ -1623,7 +1635,7 @@ function isEmptyTextBlock(
}
const rendered = portableTextToMarkdown(
[structuredClone(node)] as unknown as Array<PortableTextBlock>,
{...options?.serialize, schema: options?.schema},
{...stripOnDegradation(options?.serialize), schema: options?.schema},
)
if (rendered === '') {
return true
Expand Down
25 changes: 25 additions & 0 deletions packages/markdown/src/from-portable-text/degradation-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type {SerializeDegradationType} from './degradation-report'

/**
* The full catalog of `SerializeDegradation['message']` prose, one entry
* per `SerializeDegradationType`. Kept apart from the conversion's
* emission sites (`render-node.ts`) so the catalog reads as one list
* instead of being scattered across the walk that triggers it.
*
* Not exported from the package entry: `message` is unstable by contract
* (see `SerializeDegradation['message']`'s doc comment), so the catalog
* producing it stays internal too.
*/
export const serializeDegradationMessage = {
'annotation-dropped': (markType: string): string =>
`Removed the \`${markType}\` annotation, kept its text: no \`${markType}\` mark renderer`,

'decorator-dropped': (markType: string): string =>
`Removed the \`${markType}\` decorator, kept the text: no \`${markType}\` mark renderer`,

'style-fallback': (style: string): string =>
`Dropped the \`${style}\` style, kept the text: no \`${style}\` block renderer`,

'list-item-fallback': (listItem: string): string =>
`Rendered the \`${listItem}\` list item as a plain bullet, kept the text: no \`${listItem}\` list-item renderer`,
} satisfies Record<SerializeDegradationType, (...args: never[]) => string>
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {describe, expect, test} from 'vitest'
import {
buildSerializeDegradationMessage,
truncateSnippet,
} from './degradation-report'

describe(truncateSnippet.name, () => {
test('backs off from a cut landing mid-surrogate-pair, keeping the result well-formed', () => {
const text = `${'a'.repeat(39)}\u{1f600}bcdef`
const result = truncateSnippet(text)

expect(result).toEqual(`${'a'.repeat(39)}...`)
expect(result?.isWellFormed()).toEqual(true)
})
})

describe(buildSerializeDegradationMessage.name, () => {
test('a group past the per-group cap lists the first five snippets and tallies the rest', () => {
const degradations = ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map(
(snippet, index) => ({
type: 'decorator-dropped' as const,
message:
'Removed the `highlight` decorator, kept the text: no `highlight` mark renderer',
path: [{_key: `b${index}`}, 'children', {_key: `s${index}`}],
topIndex: index,
snippet,
}),
)

expect(buildSerializeDegradationMessage(degradations)).toEqual(
[
'Portable Text could not be serialized to Markdown without loss:',
'- Removed the `highlight` decorator, kept the text: no `highlight` mark renderer (7\u00d7: "a", "b", "c", "d", "e", and 2 more)',
].join('\n'),
)
})

test('a group where some entries have no snippet reports the blocks it spans instead of a snippet list', () => {
const message = buildSerializeDegradationMessage([
{
type: 'decorator-dropped',
message:
'Removed the `highlight` decorator, kept the text: no `highlight` mark renderer',
path: [{_key: 'b0'}, 'children', {_key: 's0'}],
topIndex: 0,
snippet: 'a',
},
{
type: 'decorator-dropped',
message:
'Removed the `highlight` decorator, kept the text: no `highlight` mark renderer',
path: [{_key: 'b1'}, 'children', {_key: 's1'}],
topIndex: 1,
},
])

expect(message).toEqual(
[
'Portable Text could not be serialized to Markdown without loss:',
'- Removed the `highlight` decorator, kept the text: no `highlight` mark renderer (2\u00d7: blocks 0, 1)',
].join('\n'),
)
})

test('two different groups sort by their earliest `topIndex`, even when encountered in reverse order', () => {
const message = buildSerializeDegradationMessage([
{
type: 'style-fallback',
message:
'Dropped the `fancy` style, kept the text: no `fancy` block renderer',
path: [{_key: 'b5'}],
topIndex: 5,
snippet: 'z',
},
{
type: 'decorator-dropped',
message:
'Removed the `highlight` decorator, kept the text: no `highlight` mark renderer',
path: [{_key: 'b1'}, 'children', {_key: 's1'}],
topIndex: 1,
snippet: 'a',
},
])

expect(message).toBe(
[
'Portable Text could not be serialized to Markdown without loss:',
'- block 1: Removed the `highlight` decorator, kept the text: no `highlight` mark renderer ("a")',
'- block 5: Dropped the `fancy` style, kept the text: no `fancy` block renderer ("z")',
].join('\n'),
)
})
})
Loading
Loading