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
9 changes: 9 additions & 0 deletions .changeset/skip-empty-block-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@portabletext/markdown': patch
---

fix: emit no blank lines for empty text blocks

A block that renders to the empty string (an empty or whitespace-only text block, or a custom renderer returning `''`) no longer leaves blank lines in `portableTextToMarkdown`'s output: `[h1 'foo', empty block, 'bar']` now serializes to `# foo\n\nbar` instead of `# foo\n\n\n\nbar`. A dropped block never survived reparsing anyway, and a custom `blockSpacing` callback now sees the pair of blocks that actually end up adjacent, never an invisible one. Two spacing consequences show in rendered HTML: two blockquotes separated only by an empty block now join into one quote with a paragraph break, and a list whose blank lines came only from empty blocks goes tight, since a skipped block no longer counts toward looseness.

The same filter runs inside containers: callout and structured-blockquote content joins skip empty blocks, so no more blank quote-prefixed lines. In list items, the marker line goes to the first block that renders output, with two exceptions that keep the markdown reparseable: a multi-line block (a code fence, a table) keeps its later lines indented inside the item instead of escaping the list at column 0, and a nested list or, on a task item, any non-text block stays indented below a bare marker, because after `- [x] ` (or fused with `- `) it would reparse as plain words.
Original file line number Diff line number Diff line change
Expand Up @@ -138,32 +138,30 @@ export function portableTextToMarkdown<
const {listIndexMap, listDepthMap} = buildListIndexMap(blocks)
const renderNode = createRenderNode(renderers, listIndexMap, listDepthMap)

return blocks
.map((node, index) => {
const renderedNode = renderNode({
node,
index,
isInline: false,
renderNode,
})

if (index === blocks.length - 1) {
return renderedNode
}

const nextNode = blocks.at(index + 1)

if (!nextNode) {
return renderedNode
// Blocks rendering to '' are dropped before spacing is computed, so
// `blockSpacing` only ever sees blocks that survive into the output.
const renderedBlocks = blocks
.map((node, index) => ({
node,
rendered: renderNode({node, index, isInline: false, renderNode}),
}))
.filter(({rendered}) => rendered !== '')

return renderedBlocks
.map(({node, rendered}, index) => {
const nextBlock = renderedBlocks.at(index + 1)

if (!nextBlock) {
return rendered
}

const blockSpacing =
renderBlockSpacing({
current: node,
next: nextNode,
next: nextBlock.node,
}) ?? '\n\n'

return `${renderedNode}${blockSpacing}`
return `${rendered}${blockSpacing}`
})
.join('')
}
Expand Down
110 changes: 79 additions & 31 deletions packages/markdown/src/from-portable-text/renderers/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ export const DefaultCalloutRenderer: PortableTextTypeRenderer<{
renderNode,
}),
)
.filter((rendered) => rendered !== '')
.join('\n\n')

const prefixed = renderedContent
Expand Down Expand Up @@ -364,6 +365,7 @@ export const DefaultBlockquoteObjectRenderer: PortableTextTypeRenderer<{
renderNode,
}),
)
.filter((rendered) => rendered !== '')
.join('\n\n')

return renderedContent
Expand Down Expand Up @@ -392,15 +394,45 @@ export const DefaultListRenderer: PortableTextTypeRenderer<{
content: Array<PortableTextBlock | TypedObject>
}>
}> = ({value, renderNode}) => {
const renderedItems = value.items.map((item) => {
// The marker-line mark changes how `renderBlock` plans line-start
// hazard escaping, so it must be placed before rendering, but which
// block ends up on the marker line is only knowable after (blocks
// rendering to '' are dropped at join time). So every text block gets
// the mark until something renders output; a mark on a dropped empty
// block is harmless, consumed by its own render. Whether the first
// surviving block actually takes the marker line is decided at
// assembly below.
let markerLineSettled = false

return item.content.map((block, blockIndex) => {
const isNestedList = (block as TypedObject)._type === 'list'
const isTextBlock = !isNestedList && isPortableTextBlock(block)
if (!markerLineSettled && isTextBlock) {
markListItemFirstBlock(block)
}
const text = renderNode({
node: block as TypedObject,
index: blockIndex,
isInline: false,
renderNode,
})
if (text !== '') {
markerLineSettled = true
}
return {isNestedList, isTextBlock, text}
})
})

// A list is "loose" when any item carries multiple non-list-block
// content entries (a continuation paragraph, a code block, etc).
// CommonMark uses blank lines between items in loose lists; tight lists
// pack items together with single newlines. A nested list as a second
// child of an item does NOT make the list loose, so we ignore those when
// counting.
const isLoose = value.items.some((item) => {
const nonNestedBlocks = item.content.filter(
(block) => (block as TypedObject)._type !== 'list',
// child of an item does NOT make the list loose, and neither does a
// block that rendered to nothing, so we ignore both when counting.
const isLoose = renderedItems.some((renderedBlocks) => {
const nonNestedBlocks = renderedBlocks.filter(
(rendered) => !rendered.isNestedList && rendered.text !== '',
)
return nonNestedBlocks.length > 1
})
Expand All @@ -415,38 +447,54 @@ export const DefaultListRenderer: PortableTextTypeRenderer<{
// markdown-it level, so its continuation indent stays at 2.
const indentWidth = value.kind === 'task' ? 2 : marker.length
const indent = ' '.repeat(indentWidth)

const renderedBlocks = item.content.map((block, blockIndex) => {
// Only the first block shares its first line with the marker (and,
// for a task item, its GFM checkbox); later blocks render on their
// own indented lines.
if (blockIndex === 0 && isPortableTextBlock(block)) {
markListItemFirstBlock(block)
}
return {
isNestedList: (block as TypedObject)._type === 'list',
text: renderNode({
node: block as TypedObject,
index: blockIndex,
isInline: false,
renderNode,
}),
}
})

const [first, ...rest] = renderedBlocks
Comment thread
christianhg marked this conversation as resolved.
// Trim trailing whitespace from empty items so `- ` becomes `-`.
const head = `${marker}${first?.text ?? ''}`.trimEnd()
const indentLines = (text: string) =>
text
.split('\n')
.map((line) => (line === '' ? '' : `${indent}${line}`))
.join('\n')

const nonEmptyBlocks = (renderedItems[itemIndex] ?? []).filter(
(rendered) => rendered.text !== '',
)
// A nested list never shares the marker line: fusing the markers into
// `- - sub` reparses the same for bullet and number kinds but reads as
// one doubled marker, and after a task checkbox the nested marker is
// literal text, destroying the sublist. A task item's marker line only
// takes a text block for the same reason: everything after `- [x] ` is
// inline text, so a promoted fence or table would reparse as words.
const markerLineCandidate = nonEmptyBlocks[0]
const promoted =
markerLineCandidate &&
!markerLineCandidate.isNestedList &&
(value.kind !== 'task' || markerLineCandidate.isTextBlock)
? markerLineCandidate
: undefined
const rest = promoted ? nonEmptyBlocks.slice(1) : nonEmptyBlocks
// Only the promoted block's first line shares the marker line; its
// later lines (a code fence's body, a table's rows) are ordinary
// continuation lines that must sit under the item indent, or
// CommonMark ends the list at the first column-0 line.
const [promotedFirstLine = '', ...promotedRestLines] = (
promoted?.text ?? ''
).split('\n')
// Trailing whitespace is trimmed from the joined head, reaching only
// its last line: an empty item's `- ` becomes `-`, while a hard
// break's trailing spaces on an earlier line survive.
const head = [
`${marker}${promotedFirstLine}`,
...(promotedRestLines.length > 0
? [indentLines(promotedRestLines.join('\n'))]
: []),
]
.join('\n')
.trimEnd()
if (rest.length === 0) {
return head
}

const tail = rest
.map((rendered) => {
const indented = rendered.text
.split('\n')
.map((line) => (line === '' ? '' : `${indent}${line}`))
.join('\n')
const indented = indentLines(rendered.text)
// Nested lists hug the previous block (tight list); other content
// gets a blank line separator (paragraph break).
return rendered.isNestedList ? `\n${indented}` : `\n\n${indented}`
Expand Down
Loading
Loading