Skip to content

fix: keep heading styles inside callouts and structured blockquotes - #3592

Draft
christianhg wants to merge 2 commits into
mainfrom
fix/markdown-heading-in-callout-export
Draft

christianhg wants to merge 2 commits into
mainfrom
fix/markdown-heading-in-callout-export

Conversation

@christianhg

Copy link
Copy Markdown
Member

portableTextToMarkdown now renders a heading inside a callout or a types.blockquote object as a heading:

portableTextToMarkdown(markdownToPortableText('> [!NOTE]\n> # Heading'))
// Previously returned: '> [!NOTE]\n> Heading'

Other styles inside these containers now render the way they do at the root. A block renderer you pass for a custom style runs there too, and a style with no renderer goes to unknownBlockStyle, which by default reports a style-fallback to onDegradation. Content in the blockquote style, list items and whitespace-only blocks still render as plain paragraphs inside these containers.

Letting headings through in containers exposed a second bug, fixed in its own commit. Markdown has no multi-line # heading, so a heading whose rendered text holds a line break was split into a heading and a paragraph that read back as two blocks. Such a block is now written as a paragraph with its text intact, and the dropped style is reported as a style-fallback, at any depth. An h1 or h2 at the top level is still written as a setext heading where that reads back intact. A custom block renderer for the style, or a custom hardBreak that renders no line break, keeps the heading as before.

One reporting change applies to existing output. When a block is written in its setext or backslash-break form, onDegradation and the hiddenLosses of applyMarkdownEdit now report the losses of the form that is written, not the form it replaced. These differ only when a custom renderer reaches different content in each form.

Fixes #3588

`DefaultCalloutRenderer` and `DefaultBlockquoteObjectRenderer` rendered
every text block in their content with its `style` forced to `normal`.
The reset exists because the importer gives plain callout paragraphs the
`blockquote` style, and the container already adds the `> ` prefix, so
rendering that style as-is quoted the text twice. Forcing every style
also dropped headings: `> [!NOTE]\n> # foo` came back as
`> [!NOTE]\n> foo`, and a heading in a `types.blockquote` object lost
its `#` the same way, at any nesting depth.

Render container content with its own style, as at the root, except
where that would lose content. `blockquote` and a missing style still
render as `normal`, so custom renderers keep getting `normal` for
unstyled blocks. List items keep the plain rendering, since a heading
marker after a task marker reads back as text. A block holding a line
feed or carriage return anywhere, in span text, a link title or image
text, keeps it too: the setext rescue for multi-line headings only runs
on root blocks, and a `#` heading would split the block or push part of
it out of the container. Whitespace-only text keeps it so the line is
dropped instead of breaking the container.

A custom style now reaches the consumer's `block` renderer inside these
containers, and a style with no renderer reaches `unknownBlockStyle` and
is reported as a `style-fallback` degradation, as at the root.

Fixes #3588
…tead of splitting it

Markdown has no multi-line ATX heading. `renderBlock` wrote such a
block with the default heading renderer anyway, so the line after the
break read back as a paragraph of its own: a root h3-h6 with a `\n` in
its text came back as two blocks, as did any heading whose rendered
children gained a line break from a link title, image text or a custom
mark renderer, at the root and inside containers. The setext rescue
only covered a root h1/h2 whose plain text held a `\n`, and the
container renderers forced such a block to `normal` without reporting
the dropped style.

A block whose style resolves to one of the default heading renderers
now renders its children as a heading first and checks the rendered
output for `\n` or `\r`. With none, it stays an ATX heading, so a
custom `hardBreak` that renders no line break keeps it. With one, its
output and degradation reports are exactly those of the same block
stored with style `normal`, followed by a `style-fallback` with its own
message. Every consumer callback gets the block as if stored with style
`normal`: the `normal` renderer (or `unknownBlockStyle`, reported the
same way when `normal` is missing), the `listItem` renderer of a list
item, whose children keep a trailing line break as a normal item's do,
and `blockSpacing` as `current` and as `next`. Degradation paths and
owners still name the stored block. The paragraph render reuses the
heading render when both escape the text the same way. Otherwise the
children render again with paragraph escaping, and since a renderer
can visit different content for different children, each render's
degradations and fallbacks are buffered and only the written render's
take effect, at any nesting depth. A root h1/h2 still gets a setext
candidate, also when its paragraph fallback renders to '': such a
block is kept through candidate verification, which runs again
without it if its candidate fails, and empty-output filtering and
`blockSpacing` apply to the selected outputs in their written style.
Each root render, main or candidate, now collects its own
degradations and fallbacks, and for each root block only those of the
render that is written take effect: the main render's, or the
accepted setext or blank-line candidate's. This also applies to the
blank-line candidate of a normal block, which used to report the main
render's losses. Degradations are reported after candidate
verification, and `onLostContent` sees the same entries as
`onDegradation`. The container renderers no longer special-case line
breaks and go through the same rule.

A custom `block` renderer for the style, a `block` function and a
custom `unknownBlockStyle` are unaffected. `applyMarkdownEdit` now
traces a stored heading with a line break instead of resetting every
key, and restores its style from the stored value.
@vercel

vercel Bot commented Nov 5, 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 Nov 5, 2026 3:23pm UTC
portable-text-example-basic Ready Ready Preview Nov 5, 2026 3:23pm UTC
portable-text-playground Ready Ready Preview Nov 5, 2026 3:23pm UTC

Request Review

@changeset-bot

changeset-bot Bot commented Nov 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3f3b54b

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 Patch

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 Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Bundle Stats

✅ No significant changes.

All scenario measurements (37)

🗺️ @portabletext/block-tools / @portabletext/block-tools · @portabletext/block-tools / @portabletext/block-tools/rules · @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/editor / defineSchema · @portabletext/editor / useEditor · @portabletext/editor / typical · @portabletext/html / @portabletext/html · @portabletext/html / @portabletext/html/rules · @portabletext/html / htmlToPortableText · @portabletext/keyboard-shortcuts / @portabletext/keyboard-shortcuts · @portabletext/markdown / @portabletext/markdown · @portabletext/markdown / portableTextToMarkdown · @portabletext/patches / @portabletext/patches · @portabletext/repair / @portabletext/repair · @portabletext/sanity-bridge / @portabletext/sanity-bridge · @portabletext/schema / @portabletext/schema · @portabletext/plugin-character-pair-decorator / @portabletext/plugin-character-pair-decorator · @portabletext/plugin-decorations / @portabletext/plugin-decorations · @portabletext/plugin-dnd / @portabletext/plugin-dnd · @portabletext/plugin-emoji-picker / @portabletext/plugin-emoji-picker · @portabletext/plugin-input-rule / @portabletext/plugin-input-rule · @portabletext/plugin-list-index / @portabletext/plugin-list-index · @portabletext/plugin-markdown-shortcuts / @portabletext/plugin-markdown-shortcuts · @portabletext/plugin-one-line / @portabletext/plugin-one-line · @portabletext/plugin-paste-link / @portabletext/plugin-paste-link · @portabletext/plugin-sdk-value / @portabletext/plugin-sdk-value · @portabletext/plugin-typeahead-picker / @portabletext/plugin-typeahead-picker · @portabletext/plugin-typography / @portabletext/plugin-typography · @portabletext/toolbar / @portabletext/toolbar · @portabletext/toolbar / typical · @portabletext/plugin-table / @portabletext/plugin-table · @portabletext/plugin-table / @portabletext/plugin-table/ui · Artifacts

Scenario Kind Bundle (raw / gzip) Gzip change Import time Import change
⚪ @portabletext/block-tools / @portabletext/block-tools export 338.3 KB / 40.0 KB None 180 ms +1 ms, +0.7%
⚪ @portabletext/block-tools / @portabletext/block-tools/rules export 15.2 KB / 2.1 KB None 5 ms +0 ms, +5.5%
⚪ @portabletext/editor / @portabletext/editor export 1.02 MB / 130.4 KB None 73 ms +2 ms, +3.4%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.3 KB / 147 B None 2 ms -0 ms, -0.5%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 645 B None 7 ms +0 ms, +1.7%
⚪ @portabletext/editor / @portabletext/editor/selectors export 96.3 KB / 9.4 KB None 8 ms +0 ms, +3.1%
⚪ @portabletext/editor / @portabletext/editor/traversal export 46.6 KB / 4.2 KB None 6 ms +0 ms, +0.9%
⚪ @portabletext/editor / @portabletext/editor/utils export 32.8 KB / 4.1 KB None 6 ms -0 ms, -0.0%
⚪ @portabletext/editor / defineSchema consumer 658 B / 69 B None None None
⚪ @portabletext/editor / typical consumer 1.02 MB / 130.2 KB None None None
⚪ @portabletext/editor / useEditor consumer 1.6 KB / 248 B None None None
⚪ @portabletext/html / @portabletext/html export 69.3 KB / 10.1 KB None 7 ms +0 ms, +0.6%
⚪ @portabletext/html / @portabletext/html/rules export 15.2 KB / 2.1 KB None 4 ms -0 ms, -1.0%
⚪ @portabletext/html / htmlToPortableText consumer 69.3 KB / 10.1 KB None None None
⚪ @portabletext/keyboard-shortcuts / @portabletext/keyboard-shortcuts export 7.0 KB / 858 B None 2 ms -0 ms, -0.1%
⚪ @portabletext/markdown / @portabletext/markdown export 430.4 KB / 66.9 KB +767 B, +1.1% 39 ms -0 ms, -0.5%
⚪ @portabletext/markdown / portableTextToMarkdown consumer 343.8 KB / 57.1 KB +709 B, +1.2% None None
⚪ @portabletext/patches / @portabletext/patches export 50.0 KB / 7.7 KB None 4 ms -0 ms, -0.7%
⚪ @portabletext/plugin-character-pair-decorator / @portabletext/plugin-character-pair-decorator export 124.1 KB / 15.8 KB None None None
⚪ @portabletext/plugin-decorations / @portabletext/plugin-decorations export 16.4 KB / 2.3 KB None None None
⚪ @portabletext/plugin-dnd / @portabletext/plugin-dnd export 10.4 KB / 1.8 KB None None None
⚪ @portabletext/plugin-emoji-picker / @portabletext/plugin-emoji-picker export 150.1 KB / 18.6 KB None None None
⚪ @portabletext/plugin-input-rule / @portabletext/plugin-input-rule export 124.2 KB / 15.8 KB None None None
⚪ @portabletext/plugin-list-index / @portabletext/plugin-list-index export 7.4 KB / 1.1 KB None None None
⚪ @portabletext/plugin-markdown-shortcuts / @portabletext/plugin-markdown-shortcuts export 136.4 KB / 17.6 KB None None None
⚪ @portabletext/plugin-one-line / @portabletext/plugin-one-line export 2.6 KB / 608 B None None None
⚪ @portabletext/plugin-paste-link / @portabletext/plugin-paste-link export 4.3 KB / 1.0 KB None None None
⚪ @portabletext/plugin-sdk-value / @portabletext/plugin-sdk-value export 242.1 KB / 32.8 KB None None None
⚪ @portabletext/plugin-table / @portabletext/plugin-table export 61.1 KB / 7.1 KB None None None
⚪ @portabletext/plugin-table / @portabletext/plugin-table/ui export 114.6 KB / 19.5 KB None None None
⚪ @portabletext/plugin-typeahead-picker / @portabletext/plugin-typeahead-picker export 166.1 KB / 19.9 KB None None None
⚪ @portabletext/plugin-typography / @portabletext/plugin-typography export 131.1 KB / 17.0 KB None None None
⚪ @portabletext/repair / @portabletext/repair export 4.5 KB / 636 B None 3 ms -0 ms, -4.4%
⚪ @portabletext/sanity-bridge / @portabletext/sanity-bridge export 275.1 KB / 30.2 KB None 176 ms +1 ms, +0.5%
⚪ @portabletext/schema / @portabletext/schema export 9.9 KB / 1.2 KB None 2 ms -0 ms, -4.6%
⚪ @portabletext/toolbar / @portabletext/toolbar export 153.3 KB / 17.3 KB None None None
⚪ @portabletext/toolbar / typical consumer 145.6 KB / 16.9 KB None None None

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

This branch was successfully deployed

3 active deployments
Preview – portable-text-editor-documentation — 3f3b54bc Deployed Nov 5, 2026 by vercel[bot]
Preview – portable-text-playground — 3f3b54bc Deployed Nov 5, 2026 by vercel[bot]
Preview – portable-text-example-basic — 3f3b54bc Deployed Nov 5, 2026 by vercel[bot]
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.

portableTextToMarkdown drops heading styles inside callouts and structured blockquotes

1 participant