Skip to content
Merged
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
38 changes: 38 additions & 0 deletions .changeset/report-degraded-conversions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@portabletext/markdown': minor
---

feat: report degraded constructs during markdown parsing

`markdownToPortableText` now takes an `onDegradation` option for constructs the schema can't represent (an undeclared decorator, a table with no `table` block object, a task checkbox with no `task` list, and so on). One callback, called at most once, after the whole document has been walked, only when at least one construct degraded: a `report` object holding `degradations`, every `Degradation` in encounter order (`{type: string, message: string, line?: number, snippet?: string}`, exported as `Degradation`, with `type` a literal union that grows as new degradation sites report; `snippet` is the offending construct's text, truncated to 40 characters, present whenever there's a specific piece of source text to quote), and `message`, the same degradations grouped, snippeted, and sorted by line into one human-readable string.

```ts
const blocks = markdownToPortableText('**a**', {
schema: compileSchema(defineSchema({})),
onDegradation: (report) => console.log(report),
})
// blocks: a plain span reading `a`, the `strong` formatting dropped
// report: {
// degradations: [{type: 'decorator-dropped', message: 'Removed bold formatting, kept the text: the schema has no `strong` decorator', line: 1, snippet: 'a'}],
// message: 'Markdown could not be converted without loss:\n- line 1: Removed bold formatting, kept the text: the schema has no `strong` decorator ("a")',
// }
```

Enforce against lossy output by throwing your own error from inside the callback; the throw propagates out of `markdownToPortableText`:

```ts
markdownToPortableText('**a**\n\n**b**\n\n| x |\n| - |\n| y |', {
schema: compileSchema(defineSchema({})),
onDegradation: ({message}) => {
throw new Error(message)
},
})
// throws Error:
// Markdown could not be converted without loss:
// - Removed bold formatting, kept the text: the schema has no `strong` decorator (2×: "a", "b")
// - line 5: Table became plain text blocks, rows and columns lost: the schema has no `table` block object
```

Repeated declines of the same kind collapse into one line of `message` instead of repeating the sentence once per occurrence; `degradations` stays ungrouped, one entry per occurrence. Match on `type`: `message` is human-readable and may change between releases.

With `onDegradation` unset, conversion still degrades silently: a library shouldn't log on its own initiative. This removes the previous behavior of a handful of style-fallback paths calling `console.warn` on their own; pass a function to `onDegradation` to observe those losses instead.
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ Override a matcher when your schema uses a different name for a type (say, `'hea
This package uses markdown-it as its Markdown parser. Remark and unified plugins are not compatible.
:::

## Reporting degradation

A construct the schema can't represent (an undeclared decorator, a table with no `table` block object, and so on) degrades to a lossier shape by default rather than failing, silently: nothing reaches the console. `onDegradation` covers what an import pipeline or an agent that can't afford to lose content silently needs: a function is called once, after the whole document has been walked, only when at least one construct degraded, with every `Degradation` (`type`, `message`, `line` when available, `snippet` when there's source text to quote) in encounter order and a canonical grouped `message`, to observe the losses. Enforce against them by throwing your own error from inside that callback instead of returning lossy Portable Text; the throw propagates out of `markdownToPortableText`. The [package README](https://github.com/portabletext/editor/tree/main/packages/markdown#reporting-degradation) has the full option shape, an enforce example, and the report object's fields.

## Round-trip behavior

Converting Markdown to Portable Text and back isn't a lossless mirror: translation normalizes rather than preserves, and structures either side can't express degrade predictably rather than failing. See [Markdown round-tripping](/conversion/markdown-round-tripping/) for the full contract, worked examples, and the named exceptions (linkified substrings, heading hard breaks, whitespace trimming).
Expand Down
33 changes: 33 additions & 0 deletions packages/markdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,39 @@ markdownToPortableText(markdown, {
})
```

#### Reporting degradation

Every construct the schema can't represent (a decorator not in the schema, a table with no `table` block object, a task checkbox with no `task` list, and so on) degrades to a lossier shape rather than throwing. A ` ```json:object ` fence or tagged code span that fails to reconstruct (invalid JSON, or no string `_type`) reports too, as `object-carrier-invalid`, even on a schema that would otherwise accept everything: it's the payload that's unusable, not the schema. `onDegradation` observes both, one callback covering all uses. Left unset, the conversion stays silent and returns the lossiest representation it can build, since a library shouldn't log on its own initiative. Passed a function, observe the losses: it's called once, after the whole document has been walked, only when at least one construct degraded, with a report object holding every `Degradation` in encounter order and a canonical grouped `message`. Enforce against lossy output by throwing your own error from inside that callback; the throw propagates out of `markdownToPortableText`.

```ts
markdownToPortableText(markdown, {
onDegradation: ({degradations, message}) => {
// degradations: every Degradation, in encounter order
// message: the same degradations grouped, snippeted, and sorted by line
logger.warn(message)
},
})
```

Each `Degradation` carries `type`, a human-readable `message`, `line` when a source line is available, and `snippet` (the offending construct's text, truncated to 40 characters) when there's a specific piece of source text to quote. Match on `type`, not `message`: the type is the stable contract, the message can change between releases.

Enforcing means throwing your own error from inside the callback:

```ts
markdownToPortableText('# heading\n\n**bold**', {
schema: compileSchema(defineSchema({})),
onDegradation: ({message}) => {
throw new Error(message)
},
})
// throws Error:
// Markdown could not be converted without loss:
// - line 1: `#` heading became a normal paragraph: the schema has no `h1` style ("heading")
// - line 3: Removed bold formatting, kept the text: the schema has no `strong` decorator ("bold")
```

Repeated declines of the same kind (the same missing decorator on three spans, say) collapse into one line of `message` instead of repeating the sentence: `- Removed bold formatting, kept the text: the schema has no \`strong\` decorator (3×: "a", "b", "c")`. `degradations` stays ungrouped, one entry per occurrence.

### `portableTextToMarkdown`

```ts
Expand Down
1 change: 1 addition & 0 deletions packages/markdown/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type {
PortableTextTypeRendererOptions,
} from './from-portable-text/types'
export {markdownToPortableText} from './to-portable-text/markdown-to-portable-text'
export type {Degradation} from './to-portable-text/markdown-to-portable-text'
export type {
AnnotationMatcher,
DecoratorMatcher,
Expand Down
Loading
Loading