Skip to content

Commit 548216e

Browse files
fix(markdown): indent nested list content to the parent marker (#8219)
* fix(markdown): indent nested list content to the parent marker Nested content under a list item was always indented by the configured indent size, two spaces by default. CommonMark only treats a block as a child of a list item when it starts at that item's content column, which for an ordered list is as wide as the marker: three columns for `1. `, four for `10. `. Exports were therefore ambiguous. Reading `1. one\n 1. inner` back with a strict parser puts the sub-list at the top level, so a Markdown round trip lost the hierarchy for anything that stores Markdown as its source of truth. Nested content is now aligned to the marker. Bullet lists are unchanged, since `- ` is already two columns wide. Fixes #8182 * fix(markdown): keep configured indentation for bullet lists Aligning every list item to its prefix meant bullet lists ignored `Markdown.indentation`, so a custom size or tab indentation came out as two spaces. Bullets never needed the alignment — `- ` is already two columns. Alignment is now limited to ordered lists, and it only widens the indent when the configured one is narrower than the marker. Also document the new option on the exported helper. * fix(markdown): measure configured indentation in columns A tab is one character but runs to the next tab stop, so comparing string length against the marker width replaced a configured tab with spaces on an ordered list even though the tab already cleared the marker. Compare in Markdown columns instead. * fix(markdown): measure the prefix width in columns too The configured indent was compared in Markdown columns while the prefix was still counted in characters. Both sides use columns now, so a prefix containing a tab is measured the same way as the indent it is compared with. The list callers only ever build `- ` or `N. `, so nothing reachable today changes. The helper is exported, so cover it directly.
1 parent 2e70483 commit 548216e

9 files changed

Lines changed: 212 additions & 10 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@tiptap/core': patch
3+
'@tiptap/extension-list': patch
4+
---
5+
6+
Nested lists exported to Markdown now keep their hierarchy when the file is read back by other Markdown tools.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { renderNestedMarkdownContent } from '@tiptap/core'
2+
import { describe, expect, it } from 'vitest'
3+
4+
const node = {
5+
type: 'listItem',
6+
content: [
7+
{ type: 'paragraph', content: [{ type: 'text', text: 'parent' }] },
8+
{ type: 'paragraph', content: [{ type: 'text', text: 'child' }] },
9+
],
10+
}
11+
12+
/** Stand-in for the renderer helpers, with a configurable indent. */
13+
const helpers = (indent: string) => ({
14+
renderChildren: (nodes: any[]) => nodes[0]?.content?.[0]?.text ?? '',
15+
renderChild: (child: any) => child?.content?.[0]?.text ?? '',
16+
indent: (text: string) => indent + text,
17+
})
18+
19+
describe('renderNestedMarkdownContent', () => {
20+
it('uses the configured indent when alignment is off', () => {
21+
const out = renderNestedMarkdownContent(node, helpers(' '), '10. ')
22+
23+
expect(out).toBe('10. parent\n\n child')
24+
})
25+
26+
it('widens a narrow indent to the prefix width', () => {
27+
const out = renderNestedMarkdownContent(node, helpers(' '), '10. ', undefined, {
28+
alignNestedToPrefix: true,
29+
})
30+
31+
expect(out).toBe('10. parent\n\n child')
32+
})
33+
34+
it('keeps a tab, which already reaches the prefix width', () => {
35+
const out = renderNestedMarkdownContent(node, helpers('\t'), '10. ', undefined, {
36+
alignNestedToPrefix: true,
37+
})
38+
39+
expect(out).toBe('10. parent\n\n\tchild')
40+
})
41+
42+
it('measures a tabbed prefix in columns rather than characters', () => {
43+
// `\t- ` is one tab plus two characters, so it spans six columns, not three.
44+
const out = renderNestedMarkdownContent(node, helpers(' '), '\t- ', undefined, {
45+
alignNestedToPrefix: true,
46+
})
47+
48+
expect(out).toBe('\t- parent\n\n child')
49+
})
50+
})

packages/core/src/utilities/markdown/renderNestedMarkdownContent.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import type { JSONContent } from '@tiptap/core'
1818
* @param h - The markdown renderer helper
1919
* @param prefixOrGenerator - Either a string prefix or a function that generates the prefix from context
2020
* @param ctx - Optional context object (used when prefixOrGenerator is a function)
21+
* @param options - Optional rendering options
22+
* @param options.alignNestedToPrefix - Indent nested content to the width of the prefix
23+
* instead of the configured indent size, when the configured one is narrower
2124
* @returns The rendered markdown string
2225
*
2326
* @example
@@ -29,6 +32,9 @@ import type { JSONContent } from '@tiptap/core'
2932
* const prefix = `- [${node.attrs?.checked ? 'x' : ' '}] `
3033
* return renderNestedMarkdownContent(node, h, prefix)
3134
*
35+
* // For an ordered list item, where the nested block has to line up with the marker
36+
* return renderNestedMarkdownContent(node, h, '10. ', ctx, { alignNestedToPrefix: true })
37+
*
3238
* // For a blockquote with static prefix
3339
* return renderNestedMarkdownContent(node, h, '> ')
3440
*
@@ -53,6 +59,19 @@ import type { JSONContent } from '@tiptap/core'
5359
* })
5460
* ```
5561
*/
62+
const TAB_STOP = 4
63+
64+
/** Width of indentation in Markdown columns, where a tab runs to the next tab stop. */
65+
function columnWidth(text: string): number {
66+
let width = 0
67+
68+
for (const character of text) {
69+
width = character === '\t' ? width + TAB_STOP - (width % TAB_STOP) : width + 1
70+
}
71+
72+
return width
73+
}
74+
5675
export function renderNestedMarkdownContent(
5776
node: JSONContent,
5877
h: {
@@ -62,6 +81,10 @@ export function renderNestedMarkdownContent(
6281
},
6382
prefixOrGenerator: string | ((ctx: any) => string),
6483
ctx?: any,
84+
options?: {
85+
/** See the `@param` note above. */
86+
alignNestedToPrefix?: boolean
87+
},
6588
): string {
6689
if (!node || !Array.isArray(node.content)) {
6790
return ''
@@ -83,9 +106,25 @@ export function renderNestedMarkdownContent(
83106
const childContent = h.renderChild?.(child, index + 1) ?? h.renderChildren([child])
84107
if (childContent !== undefined && childContent !== null) {
85108
// Split the child content by lines and indent each line
109+
const indentLine = (line: string) => {
110+
if (!options?.alignNestedToPrefix) {
111+
return h.indent(line)
112+
}
113+
114+
// Keep the configured indentation when it already reaches the
115+
// content column, so `Markdown.indentation` still applies. A tab
116+
// runs to the next tab stop, so it is wider than its one character.
117+
const configured = h.indent('')
118+
const prefixWidth = columnWidth(prefix)
119+
120+
return (
121+
(columnWidth(configured) >= prefixWidth ? configured : ' '.repeat(prefixWidth)) + line
122+
)
123+
}
124+
86125
const indentedChild = childContent
87126
.split('\n')
88-
.map(line => (line ? h.indent(line) : h.indent('')))
127+
.map(line => (line ? indentLine(line) : indentLine('')))
89128
.join('\n')
90129

91130
output += child.type === 'paragraph' ? `\n\n${indentedChild}` : `\n${indentedChild}`

packages/extension-list/src/item/list-item.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export const ListItem = Node.create<ListItemOptions>({
183183
return '- '
184184
},
185185
ctx,
186+
{ alignNestedToPrefix: ctx?.parentType === 'orderedList' },
186187
)
187188
},
188189

packages/markdown/__tests__/conversion-files/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * as mixedListTypes from './mixed-list-types.js'
1010
export * as nestedNodes from './nested-nodes.js'
1111
export * as orderedList from './ordered-list.js'
1212
export * as orderedListSeparatedByBullet from './ordered-list-separated-by-bullet.js'
13+
export * as orderedListWideMarker from './ordered-list-wide-marker.js'
1314
export * as orderedListWithBulletList from './ordered-list-with-bullet-list.js'
1415
export * as softBreakMarks from './soft-break-marks.js'
1516
export * as taskList from './task-list.js'
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
export const name = 'Ordered List with a Wide Marker'
2+
3+
// `10. ` is four characters, so the nested list has to start at column four to
4+
// stay a child of it.
5+
export const expectedInput = `
6+
10. ten
7+
1. inner
8+
11. eleven
9+
`.trim()
10+
11+
export const expectedOutput = {
12+
type: 'doc',
13+
content: [
14+
{
15+
type: 'orderedList',
16+
attrs: { start: 10 },
17+
content: [
18+
{
19+
type: 'listItem',
20+
content: [
21+
{
22+
type: 'paragraph',
23+
content: [{ type: 'text', text: 'ten' }],
24+
},
25+
{
26+
type: 'orderedList',
27+
content: [
28+
{
29+
type: 'listItem',
30+
content: [
31+
{
32+
type: 'paragraph',
33+
content: [{ type: 'text', text: 'inner' }],
34+
},
35+
],
36+
},
37+
],
38+
},
39+
],
40+
},
41+
{
42+
type: 'listItem',
43+
content: [
44+
{
45+
type: 'paragraph',
46+
content: [{ type: 'text', text: 'eleven' }],
47+
},
48+
],
49+
},
50+
],
51+
},
52+
],
53+
}

packages/markdown/__tests__/conversion-files/ordered-list-with-bullet-list.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export const name = 'Ordered List with Nested Bullet List'
22

33
export const expectedInput = `
44
1. one
5-
- inner
5+
- inner
66
2. two
77
`.trim()
88

packages/markdown/__tests__/conversion-files/ordered-list.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ export const name = 'Ordered List'
33
export const expectedInput = `
44
1. Item 1
55
2. Item 2
6-
1. Subitem 1
7-
2. Subitem 2
8-
1. Subsubitem 1
9-
2. Subsubitem 2
6+
1. Subitem 1
7+
2. Subitem 2
8+
1. Subsubitem 1
9+
2. Subsubitem 2
1010
3. Item 3
11-
1. Subitem 1
12-
2. Subitem 2
13-
1. Subsubitem 1
14-
2. Subsubitem 2
11+
1. Subitem 1
12+
2. Subitem 2
13+
1. Subsubitem 1
14+
2. Subsubitem 2
1515
`.trim()
1616

1717
export const expectedOutput = {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Document } from '@tiptap/extension-document'
2+
import { BulletList, ListItem, OrderedList } from '@tiptap/extension-list'
3+
import { Paragraph } from '@tiptap/extension-paragraph'
4+
import { Text } from '@tiptap/extension-text'
5+
import { MarkdownManager } from '@tiptap/markdown'
6+
import { describe, expect, it } from 'vitest'
7+
8+
const manager = (indentation?: { style?: 'space' | 'tab'; size?: number }) =>
9+
new MarkdownManager({
10+
extensions: [Document, Paragraph, Text, BulletList, OrderedList, ListItem],
11+
...(indentation ? { indentation } : {}),
12+
} as any)
13+
14+
const roundTrip = (mm: MarkdownManager, markdown: string) => mm.serialize(mm.parse(markdown) as any)
15+
16+
const ordered = ['1. one', ' 1. inner', '2. two'].join('\n')
17+
const orderedWide = ['10. ten', ' 1. inner', '11. eleven'].join('\n')
18+
const bullet = ['- one', ' - inner', '- two'].join('\n')
19+
20+
describe('list indentation on serialize', () => {
21+
it('indents a nested ordered list to the marker width', () => {
22+
expect(roundTrip(manager(), ordered)).toBe(ordered)
23+
})
24+
25+
it('widens the indent for a marker wider than the indent size', () => {
26+
expect(roundTrip(manager({ style: 'space', size: 2 }), orderedWide)).toBe(orderedWide)
27+
})
28+
29+
it('keeps a configured indent that is already past the marker', () => {
30+
expect(roundTrip(manager({ style: 'space', size: 4 }), ordered)).toBe(
31+
['1. one', ' 1. inner', '2. two'].join('\n'),
32+
)
33+
})
34+
35+
it('keeps tab indentation on an ordered list, since a tab clears the marker', () => {
36+
expect(roundTrip(manager({ style: 'tab', size: 1 }), orderedWide)).toBe(
37+
['10. ten', '\t1. inner', '11. eleven'].join('\n'),
38+
)
39+
})
40+
41+
it('leaves bullet lists on the configured indent size', () => {
42+
expect(roundTrip(manager({ style: 'space', size: 4 }), bullet)).toBe(
43+
['- one', ' - inner', '- two'].join('\n'),
44+
)
45+
})
46+
47+
it('leaves bullet lists on tab indentation', () => {
48+
expect(roundTrip(manager({ style: 'tab', size: 1 }), bullet)).toBe(
49+
['- one', '\t- inner', '- two'].join('\n'),
50+
)
51+
})
52+
})

0 commit comments

Comments
 (0)