From 4a092991a55a6da0dc057c95ec76973ff583da18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Thu, 10 Sep 2026 16:00:37 +0200 Subject: [PATCH] fix: emit no blank lines for empty text blocks `portableTextToMarkdown` joined every block's rendered output with block spacing, so a block rendering to the empty string (an empty or whitespace-only text block, or a custom renderer returning `''`) left its separators behind: `[h1 'foo', empty block, 'bar']` serialized to `# foo\n\n\n\nbar`. The blank lines mean nothing anywhere downstream, rendered HTML collapses them and the parser folds any blank run back into one block separator, so they only wasted tokens and misled markdown editors into treating spacing as editable. Blocks now render first, empties are filtered by rendered output (not block shape, which is what covers the `''`-returning custom renderer), and the join runs over the survivors, with `blockSpacing` called between the neighbors that actually end up adjacent. Rendering keeps each block's original index, since `renderNode` uses it for list numbering context. The same filter runs at every content join that stacks separators: callout and structured-blockquote content (quote-prefixed blank lines) and list-item content. Two spacing consequences are visible in rendered HTML and disclosed in the changeset: adjacent surviving blockquotes join into one quote, and lists loose only through empty blocks go tight, since looseness is counted from rendered non-empty content. Inside a list item the marker line goes to the first block that renders output, with the first-block mark (line-start hazard escaping) placed on every candidate until one renders non-empty, and two exceptions that keep the markdown reparseable. A multi-line block's later lines (a code fence's body, a table's rows) are indented as continuation lines, or CommonMark ends the list at the first column-0 line (pre-existing: a multi-line block as an item's first content escaped the item the same way before this change). A nested list, or on a task item any non-text block, stays indented below a bare marker: after `- [x] ` everything is inline text, so a fence, table, or `json:object` payload promoted there reparses as words (for bullet and number items the fused `- - sub` form reparses identically to the bare marker; the bare marker is canonicalization there and a data fix only for tasks). The empty-item trim runs on the joined head so it reaches only the last line, leaving a hard break's trailing spaces on earlier lines intact. Reparse behavior for the empty-block removal itself is unchanged, pinned by round trips; the marker-line pins were each proven red on the prior join, and the task and nested-list shapes are pinned with full round trips of both markdown and reparsed Portable Text. --- .changeset/skip-empty-block-output.md | 9 + .../portable-text-to-markdown.ts | 36 +- .../src/from-portable-text/renderers/type.ts | 110 ++- .../src/portable-text-to-markdown.test.ts | 919 ++++++++++++++++++ 4 files changed, 1024 insertions(+), 50 deletions(-) create mode 100644 .changeset/skip-empty-block-output.md diff --git a/.changeset/skip-empty-block-output.md b/.changeset/skip-empty-block-output.md new file mode 100644 index 000000000..0af60dd07 --- /dev/null +++ b/.changeset/skip-empty-block-output.md @@ -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. diff --git a/packages/markdown/src/from-portable-text/portable-text-to-markdown.ts b/packages/markdown/src/from-portable-text/portable-text-to-markdown.ts index c4bc85335..e48ec2dfc 100644 --- a/packages/markdown/src/from-portable-text/portable-text-to-markdown.ts +++ b/packages/markdown/src/from-portable-text/portable-text-to-markdown.ts @@ -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('') } diff --git a/packages/markdown/src/from-portable-text/renderers/type.ts b/packages/markdown/src/from-portable-text/renderers/type.ts index 577b115f2..e3f06e9c2 100644 --- a/packages/markdown/src/from-portable-text/renderers/type.ts +++ b/packages/markdown/src/from-portable-text/renderers/type.ts @@ -318,6 +318,7 @@ export const DefaultCalloutRenderer: PortableTextTypeRenderer<{ renderNode, }), ) + .filter((rendered) => rendered !== '') .join('\n\n') const prefixed = renderedContent @@ -364,6 +365,7 @@ export const DefaultBlockquoteObjectRenderer: PortableTextTypeRenderer<{ renderNode, }), ) + .filter((rendered) => rendered !== '') .join('\n\n') return renderedContent @@ -392,15 +394,45 @@ export const DefaultListRenderer: PortableTextTypeRenderer<{ content: Array }> }> = ({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 }) @@ -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 - // 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}` diff --git a/packages/markdown/src/portable-text-to-markdown.test.ts b/packages/markdown/src/portable-text-to-markdown.test.ts index 2170154e2..35e31234f 100644 --- a/packages/markdown/src/portable-text-to-markdown.test.ts +++ b/packages/markdown/src/portable-text-to-markdown.test.ts @@ -24,6 +24,7 @@ import {portableTextToMarkdown} from './from-portable-text/portable-text-to-mark import {DefaultListItemRenderer} from './from-portable-text/renderers/list-item' import { DefaultBlockquoteObjectRenderer, + DefaultCodeBlockRenderer, DefaultListRenderer, DefaultTableRenderer, } from './from-portable-text/renderers/type' @@ -106,6 +107,924 @@ describe(portableTextToMarkdown.name, () => { }), ).toBe(['foo', '', '> bar', '', '> baz', '', 'fizz'].join('\n')) }) + + test('an empty text block emits no blank lines', () => { + const keyGenerator = createTestKeyGenerator() + const portableText = [ + ...markdownToPortableText('# foo', {keyGenerator}), + { + _key: keyGenerator(), + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + {_key: keyGenerator(), _type: 'span', text: '', marks: []}, + ], + }, + ...markdownToPortableText('bar', {keyGenerator}), + ] + + expect(portableTextToMarkdown(portableText)).toEqual('# foo\n\nbar') + }) + + test('a whitespace-only text block emits no blank lines', () => { + const keyGenerator = createTestKeyGenerator() + const portableText = [ + ...markdownToPortableText('# foo', {keyGenerator}), + { + _key: keyGenerator(), + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + {_key: keyGenerator(), _type: 'span', text: ' ', marks: []}, + ], + }, + ...markdownToPortableText('bar', {keyGenerator}), + ] + + expect(portableTextToMarkdown(portableText)).toEqual('# foo\n\nbar') + }) + + test('an empty block between blockquote-style blocks keeps them intact', () => { + const stored = [ + { + _type: 'block', + _key: 'q1', + style: 'blockquote', + markDefs: [], + children: [{_type: 'span', _key: 's1', text: 'quote one', marks: []}], + }, + { + _type: 'block', + _key: 'e1', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'es1', text: '', marks: []}], + }, + { + _type: 'block', + _key: 'q2', + style: 'blockquote', + markDefs: [], + children: [{_type: 'span', _key: 's2', text: 'quote two', marks: []}], + }, + ] + const markdown = portableTextToMarkdown(stored) + expect(markdown).toEqual('> quote one\n>\n> quote two') + const keyGenerator = createTestKeyGenerator() + expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ + { + _type: 'block', + _key: 'k0', + style: 'blockquote', + markDefs: [], + children: [{_type: 'span', _key: 'k1', text: 'quote one', marks: []}], + }, + { + _type: 'block', + _key: 'k2', + style: 'blockquote', + markDefs: [], + children: [{_type: 'span', _key: 'k3', text: 'quote two', marks: []}], + }, + ]) + }) + + test('an empty block between list items joins the list', () => { + expect( + portableTextToMarkdown([ + { + _type: 'block', + _key: 'l1', + style: 'normal', + listItem: 'bullet', + level: 1, + markDefs: [], + children: [{_type: 'span', _key: 's1', text: 'item a', marks: []}], + }, + { + _type: 'block', + _key: 'e1', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'es1', text: '', marks: []}], + }, + { + _type: 'block', + _key: 'l2', + style: 'normal', + listItem: 'bullet', + level: 1, + markDefs: [], + children: [{_type: 'span', _key: 's2', text: 'item b', marks: []}], + }, + ]), + ).toEqual('- item a\n- item b') + }) + + test('an empty list item keeps its visible marker', () => { + expect( + portableTextToMarkdown([ + { + _type: 'block', + _key: 'l1', + style: 'normal', + listItem: 'bullet', + level: 1, + markDefs: [], + children: [{_type: 'span', _key: 's1', text: 'item a', marks: []}], + }, + { + _type: 'block', + _key: 'l2', + style: 'normal', + listItem: 'bullet', + level: 1, + markDefs: [], + children: [{_type: 'span', _key: 's2', text: '', marks: []}], + }, + { + _type: 'block', + _key: 'l3', + style: 'normal', + listItem: 'bullet', + level: 1, + markDefs: [], + children: [{_type: 'span', _key: 's3', text: 'item c', marks: []}], + }, + ]), + ).toEqual('- item a\n- \n- item c') + }) + + test('blank lines inside a code block are content and survive', () => { + expect( + portableTextToMarkdown([ + { + _type: 'code', + _key: 'c1', + language: 'js', + code: 'one\n\ntwo\n\n\nthree', + }, + ]), + ).toEqual('```js\none\n\ntwo\n\n\nthree\n```') + }) + + test('an empty block next to a code fence emits no blank lines', () => { + expect( + portableTextToMarkdown([ + {_type: 'code', _key: 'c1', language: 'js', code: 'const a = 1'}, + { + _type: 'block', + _key: 'e1', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'es1', text: '', marks: []}], + }, + { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 's1', text: 'after', marks: []}], + }, + ]), + ).toEqual('```js\nconst a = 1\n```\n\nafter') + }) + + test('an empty block inside a callout emits no blank quote lines', () => { + expect( + portableTextToMarkdown([ + { + _type: 'callout', + _key: 'co1', + tone: 'note', + content: [ + { + _type: 'block', + _key: 'c1', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 's1', text: 'first', marks: []}, + ], + }, + { + _type: 'block', + _key: 'e1', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'es1', text: '', marks: []}], + }, + { + _type: 'block', + _key: 'c2', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 's2', text: 'second', marks: []}, + ], + }, + ], + }, + ]), + ).toEqual('> [!NOTE]\n> first\n>\n> second') + }) + + test('an empty block inside a structured blockquote emits no blank quote lines', () => { + expect( + portableTextToMarkdown( + [ + { + _type: 'blockquote', + _key: 'bq1', + content: [ + { + _type: 'block', + _key: 'c1', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 's1', text: 'first', marks: []}, + ], + }, + { + _type: 'block', + _key: 'e1', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'es1', text: '', marks: []}], + }, + { + _type: 'block', + _key: 'c2', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 's2', text: 'second', marks: []}, + ], + }, + ], + }, + ], + {types: {blockquote: DefaultBlockquoteObjectRenderer}}, + ), + ).toEqual('> first\n>\n> second') + }) + + test('an empty block inside a list item leaves no gap', () => { + expect( + portableTextToMarkdown( + [ + { + _type: 'list', + _key: 'l1', + kind: 'bullet', + items: [ + { + _type: 'list-item', + _key: 'li1', + content: [ + { + _type: 'block', + _key: 'c1', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 's1', text: 'item', marks: []}, + ], + }, + { + _type: 'block', + _key: 'e1', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 'es1', text: '', marks: []}, + ], + }, + { + _type: 'block', + _key: 'c2', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 's2', text: 'more', marks: []}, + ], + }, + ], + }, + ], + }, + ], + {types: {list: DefaultListRenderer}}, + ), + ).toEqual('- item\n\n more') + }) + + test('a leading empty block does not promote a nested list onto the marker line', () => { + expect( + portableTextToMarkdown( + [ + { + _type: 'list', + _key: 'l1', + kind: 'bullet', + items: [ + { + _type: 'list-item', + _key: 'li1', + content: [ + { + _type: 'block', + _key: 'e1', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 'es1', text: '', marks: []}, + ], + }, + { + _type: 'list', + _key: 'l2', + kind: 'bullet', + items: [ + { + _type: 'list-item', + _key: 'li2', + content: [ + { + _type: 'block', + _key: 'c1', + style: 'normal', + markDefs: [], + children: [ + { + _type: 'span', + _key: 's1', + text: 'sub', + marks: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + {types: {list: DefaultListRenderer}}, + ), + ).toEqual('-\n - sub') + }) + + test('a leading empty block promotes the following text block onto the marker line', () => { + expect( + portableTextToMarkdown( + [ + { + _type: 'list', + _key: 'l1', + kind: 'bullet', + items: [ + { + _type: 'list-item', + _key: 'li1', + content: [ + { + _type: 'block', + _key: 'e1', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 'es1', text: '', marks: []}, + ], + }, + { + _type: 'block', + _key: 'c1', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 's1', text: 'more', marks: []}, + ], + }, + ], + }, + ], + }, + ], + {types: {list: DefaultListRenderer}}, + ), + ).toEqual('- more') + }) + + test('a multi-line block on the marker line keeps its continuation lines inside the item', () => { + expect( + portableTextToMarkdown( + [ + { + _type: 'list', + _key: 'l1', + kind: 'bullet', + items: [ + { + _type: 'list-item', + _key: 'li1', + content: [ + {_type: 'code', _key: 'c1', code: 'foo', language: 'js'}, + ], + }, + { + _type: 'list-item', + _key: 'li2', + content: [ + { + _type: 'block', + _key: 'c2', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 's2', text: 'bar', marks: []}, + ], + }, + ], + }, + ], + }, + ], + {types: {list: DefaultListRenderer, code: DefaultCodeBlockRenderer}}, + ), + ).toEqual('- ```js\n foo\n ```\n- bar') + }) + + test('a multi-line block promoted past a leading empty block stays inside the item', () => { + const markdown = portableTextToMarkdown( + [ + { + _type: 'list', + _key: 'l1', + kind: 'bullet', + items: [ + { + _type: 'list-item', + _key: 'li1', + content: [ + { + _type: 'block', + _key: 'e1', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 'es1', text: '', marks: []}, + ], + }, + {_type: 'code', _key: 'c1', code: 'foo', language: 'js'}, + ], + }, + ], + }, + ], + {types: {list: DefaultListRenderer, code: DefaultCodeBlockRenderer}}, + ) + expect(markdown).toEqual('- ```js\n foo\n ```') + + // The flat form of "an item whose content is a code block": the + // `listItem` block carries the item, the code block follows it, the + // same shape `- intro` plus an indented fence parses to. + const keyGenerator = createTestKeyGenerator() + expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ + { + _type: 'block', + _key: 'k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'k1', text: '', marks: []}], + listItem: 'bullet', + level: 1, + }, + {_type: 'code', _key: 'k2', code: 'foo', language: 'js'}, + ]) + }) + + test('a task item never takes a non-text block onto its checkbox line', () => { + const markdown = portableTextToMarkdown( + [ + { + _type: 'list', + _key: 'l1', + kind: 'task', + items: [ + { + _type: 'list-item', + _key: 'li1', + checked: true, + content: [ + { + _type: 'block', + _key: 'e1', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 'es1', text: '', marks: []}, + ], + }, + {_type: 'code', _key: 'c1', code: 'foo', language: 'js'}, + ], + }, + ], + }, + ], + {types: {list: DefaultListRenderer, code: DefaultCodeBlockRenderer}}, + ) + expect(markdown).toEqual('- [x]\n\n ```js\n foo\n ```') + + const keyGenerator = createTestKeyGenerator() + expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ + { + _type: 'block', + _key: 'k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'k1', text: '[x]', marks: []}], + listItem: 'bullet', + level: 1, + }, + {_type: 'code', _key: 'k2', code: 'foo', language: 'js'}, + ]) + }) + + test('a task item promotes a text block onto its checkbox line', () => { + expect( + portableTextToMarkdown( + [ + { + _type: 'list', + _key: 'l1', + kind: 'task', + items: [ + { + _type: 'list-item', + _key: 'li1', + checked: false, + content: [ + { + _type: 'block', + _key: 'e1', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 'es1', text: '', marks: []}, + ], + }, + { + _type: 'block', + _key: 'c1', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 's1', text: 'do it', marks: []}, + ], + }, + ], + }, + ], + }, + ], + {types: {list: DefaultListRenderer}}, + ), + ).toEqual('- [ ] do it') + }) + + test('a nested list as the only content of a task item outlives the checkbox', () => { + const markdown = portableTextToMarkdown( + [ + { + _type: 'list', + _key: 'l1', + kind: 'task', + items: [ + { + _type: 'list-item', + _key: 'li1', + checked: true, + content: [ + { + _type: 'list', + _key: 'l2', + kind: 'bullet', + items: [ + { + _type: 'list-item', + _key: 'li2', + content: [ + { + _type: 'block', + _key: 'c1', + style: 'normal', + markDefs: [], + children: [ + { + _type: 'span', + _key: 's1', + text: 'sub', + marks: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + {types: {list: DefaultListRenderer}}, + ) + // The nested list's structure wins over the checkbox: `- [x] - sub` + // would reparse with the whole nested list flattened into the task + // item's text, while the bare checkbox merely reparses as literal + // `[x]` text on a plain bullet. + expect(markdown).toEqual('- [x]\n - sub') + + const keyGenerator = createTestKeyGenerator() + expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ + { + _type: 'block', + _key: 'k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'k1', text: '[x]', marks: []}], + listItem: 'bullet', + level: 1, + }, + { + _type: 'block', + _key: 'k2', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'k3', text: 'sub', marks: []}], + listItem: 'bullet', + level: 2, + }, + ]) + }) + + test('a nested list as the first content of an item stays below a bare marker', () => { + const markdown = portableTextToMarkdown( + [ + { + _type: 'list', + _key: 'l1', + kind: 'bullet', + items: [ + { + _type: 'list-item', + _key: 'li1', + content: [ + { + _type: 'list', + _key: 'l2', + kind: 'bullet', + items: [ + { + _type: 'list-item', + _key: 'li2', + content: [ + { + _type: 'block', + _key: 'c1', + style: 'normal', + markDefs: [], + children: [ + { + _type: 'span', + _key: 's1', + text: 'sub', + marks: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + {types: {list: DefaultListRenderer}}, + ) + expect(markdown).toEqual('-\n - sub') + + const keyGenerator = createTestKeyGenerator() + expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ + { + _type: 'block', + _key: 'k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'k1', text: '', marks: []}], + listItem: 'bullet', + level: 1, + }, + { + _type: 'block', + _key: 'k2', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'k3', text: 'sub', marks: []}], + listItem: 'bullet', + level: 2, + }, + ]) + }) + + test('a hard break in the marker-line block keeps its trailing spaces', () => { + const markdown = portableTextToMarkdown( + [ + { + _type: 'list', + _key: 'l1', + kind: 'bullet', + items: [ + { + _type: 'list-item', + _key: 'li1', + content: [ + { + _type: 'block', + _key: 'c1', + style: 'normal', + markDefs: [], + children: [ + { + _type: 'span', + _key: 's1', + text: 'one\ntwo', + marks: [], + }, + ], + }, + ], + }, + ], + }, + ], + {types: {list: DefaultListRenderer}}, + ) + expect(markdown).toEqual('- one \n two') + + const keyGenerator = createTestKeyGenerator() + expect(markdownToPortableText(markdown, {keyGenerator})).toEqual([ + { + _type: 'block', + _key: 'k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'k1', text: 'one\ntwo', marks: []}], + listItem: 'bullet', + level: 1, + }, + ]) + }) + + test('empty blocks do not make a list loose', () => { + expect( + portableTextToMarkdown( + [ + { + _type: 'list', + _key: 'l1', + kind: 'bullet', + items: [ + { + _type: 'list-item', + _key: 'li1', + content: [ + { + _type: 'block', + _key: 'c1', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 's1', text: 'foo', marks: []}, + ], + }, + { + _type: 'block', + _key: 'e1', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 'es1', text: '', marks: []}, + ], + }, + ], + }, + { + _type: 'list-item', + _key: 'li2', + content: [ + { + _type: 'block', + _key: 'c2', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 's2', text: 'bar', marks: []}, + ], + }, + { + _type: 'block', + _key: 'e2', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 'es2', text: '', marks: []}, + ], + }, + ], + }, + ], + }, + ], + {types: {list: DefaultListRenderer}}, + ), + ).toEqual('- foo\n- bar') + }) + + test('a custom renderer returning an empty string leaves no residue', () => { + const keyGenerator = createTestKeyGenerator() + const portableText = [ + ...markdownToPortableText('first', {keyGenerator}), + {_key: keyGenerator(), _type: 'x'}, + ...markdownToPortableText('second', {keyGenerator}), + ] + + expect( + portableTextToMarkdown(portableText, {types: {x: () => ''}}), + ).toEqual('first\n\nsecond') + }) + + test('the empty-block fixture reparses to just the two non-empty blocks', () => { + const keyGenerator = createTestKeyGenerator() + const portableText = [ + ...markdownToPortableText('# foo', {keyGenerator}), + { + _key: keyGenerator(), + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + {_key: keyGenerator(), _type: 'span', text: '', marks: []}, + ], + }, + ...markdownToPortableText('bar', {keyGenerator}), + ] + const markdown = portableTextToMarkdown(portableText) + + const reparseKeyGenerator = createTestKeyGenerator() + const expectedKeyGenerator = createTestKeyGenerator() + expect( + markdownToPortableText(markdown, {keyGenerator: reparseKeyGenerator}), + ).toEqual([ + { + _key: expectedKeyGenerator(), + _type: 'block', + style: 'h1', + markDefs: [], + children: [ + { + _key: expectedKeyGenerator(), + _type: 'span', + text: 'foo', + marks: [], + }, + ], + }, + { + _key: expectedKeyGenerator(), + _type: 'block', + style: 'normal', + markDefs: [], + children: [ + { + _key: expectedKeyGenerator(), + _type: 'span', + text: 'bar', + marks: [], + }, + ], + }, + ]) + }) }) describe('decorators', () => {