From 217ab3bc3b9ebbe0bae6b14a6f6f164ee16dfd0c Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 22 Jun 2026 00:22:37 +1000 Subject: [PATCH 01/14] fix(check-roundtrip): classify trailing-whitespace-only diffs as normalizing --- packages/core/src/converters/check-roundtrip.test.ts | 2 ++ packages/core/src/converters/check-roundtrip.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/core/src/converters/check-roundtrip.test.ts b/packages/core/src/converters/check-roundtrip.test.ts index 72bcbd75..381771fc 100644 --- a/packages/core/src/converters/check-roundtrip.test.ts +++ b/packages/core/src/converters/check-roundtrip.test.ts @@ -41,6 +41,8 @@ describe('checkRoundTrip', () => { it.each([ 'a\n\n\nb', // extra blank lines collapse to one '- a\n\n- b', // a loose list serializes tight + '- [ ] Asdf\n- [ ]\n- [ ] ', // a trailing space on an empty task is normalized away + 'trailing spaces ', // trailing whitespace is insignificant ])('reports normalizing for %j', (markdown) => { expect(checkRoundTrip(markdown)).toBe('normalizing') }) diff --git a/packages/core/src/converters/check-roundtrip.ts b/packages/core/src/converters/check-roundtrip.ts index 789dc6a9..6d33ee8c 100644 --- a/packages/core/src/converters/check-roundtrip.ts +++ b/packages/core/src/converters/check-roundtrip.ts @@ -4,7 +4,8 @@ import { docToMarkdown } from './pm-to-md.ts' /** * How faithfully markdown survives a parse-then-serialize round trip: * - `exact`: byte-identical (modulo the trailing newline). - * - `normalizing`: same non-blank lines, only blank-line layout differs. + * - `normalizing`: same non-blank lines ignoring trailing whitespace; only + * blank-line layout or insignificant trailing whitespace differs. * - `lossy`: a non-blank line changed, so content would be lost or altered. */ export type RoundTripFidelity = 'exact' | 'normalizing' | 'lossy' @@ -23,7 +24,13 @@ export function checkRoundTrip(markdown: string): RoundTripFidelity { if (trimTrailingNewlines(serialized) === trimTrailingNewlines(markdown)) return 'exact' const before = nonBlankLines(markdown) const after = nonBlankLines(serialized) - if (before.length === after.length && before.every((line, index) => line === after[index])) { + // Compare by trimEnd: trailing whitespace is insignificant in Markdown and the + // serializer normalizes it away, so a trailing-space-only difference is + // `normalizing`, not `lossy`. Leading indentation is structural and must match. + if ( + before.length === after.length && + before.every((line, index) => line.trimEnd() === after[index].trimEnd()) + ) { return 'normalizing' } return 'lossy' From 80aad8c5e98c6b6ccf211ad2785c15943bf40021 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 22 Jun 2026 01:13:12 +1000 Subject: [PATCH 02/14] fix(editor): preserve soft line breaks in paragraphs and headings via whitespace: pre --- .../src/converters/check-roundtrip.test.ts | 2 + packages/core/src/converters/md-to-pm.test.ts | 24 ++++++++++ packages/core/src/converters/md-to-pm.ts | 44 ++++++++++++++++-- .../core/src/converters/roundtrip.test.ts | 23 ++++++++++ packages/core/src/extensions/extension.ts | 4 +- packages/core/src/extensions/heading.test.ts | 23 ++++++++++ packages/core/src/extensions/heading.ts | 17 +++++++ .../core/src/extensions/paragraph.test.ts | 29 ++++++++++++ packages/core/src/extensions/paragraph.ts | 46 +++++++++++++++++++ packages/core/src/style.css | 9 ++++ 10 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/extensions/paragraph.test.ts create mode 100644 packages/core/src/extensions/paragraph.ts diff --git a/packages/core/src/converters/check-roundtrip.test.ts b/packages/core/src/converters/check-roundtrip.test.ts index 381771fc..13e9bdb9 100644 --- a/packages/core/src/converters/check-roundtrip.test.ts +++ b/packages/core/src/converters/check-roundtrip.test.ts @@ -34,6 +34,8 @@ describe('checkRoundTrip', () => { Hello ===== `, // setext heading keeps its text and underline length + '- x\n\n line one\n line two', // a list item's soft-wrapped paragraph keeps its indent + '- a\n - x\n\n line one\n line two', // nested list, same ])('reports exact for %j', (markdown) => { expect(checkRoundTrip(markdown)).toBe('exact') }) diff --git a/packages/core/src/converters/md-to-pm.test.ts b/packages/core/src/converters/md-to-pm.test.ts index 5343f648..6cf75f58 100644 --- a/packages/core/src/converters/md-to-pm.test.ts +++ b/packages/core/src/converters/md-to-pm.test.ts @@ -597,6 +597,30 @@ describe('markdownToDoc', () => { }) }) + it('keeps a list item soft break as a dedented single text node', () => { + expect(markdownToDoc('- x\n\n line one\n line two').toJSON()).toEqual({ + type: 'doc', + attrs: { frontmatter: null }, + content: [ + { + type: 'list', + attrs: { + kind: 'bullet', + order: null, + checked: false, + collapsed: false, + marker: '-', + taskMarker: null, + }, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'x' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'line one\nline two' }] }, + ], + }, + ], + }) + }) + it('keeps YAML frontmatter as a doc attribute', () => { // The whole input is the frontmatter block, so the only content is the // empty paragraph the schema fills in (it serializes back to nothing). diff --git a/packages/core/src/converters/md-to-pm.ts b/packages/core/src/converters/md-to-pm.ts index d4ad543e..59534f9c 100644 --- a/packages/core/src/converters/md-to-pm.ts +++ b/packages/core/src/converters/md-to-pm.ts @@ -13,6 +13,8 @@ import { CHAR_LOWERCASE_X, CHAR_PLUS, CHAR_RIGHT_PARENTHESIS, + CHAR_SPACE, + CHAR_TAB, CHAR_UPPERCASE_X, isSpaceChar, } from '../unicode.ts' @@ -204,6 +206,41 @@ function countUnderlineChars(text: string, from: number, to: number): number { return count } +/** The number of leading characters before `from` on its own line (the container's content column). */ +function lineIndentWidth(text: string, from: number): number { + return from - (text.lastIndexOf('\n', from - 1) + 1) +} + +/** Drop up to `width` leading space/tab characters from a continuation line. */ +function stripIndent(line: string, width: number): string { + let index = 0 + while (index < width && index < line.length) { + const code = line.charCodeAt(index) + if (code !== CHAR_SPACE && code !== CHAR_TAB) break + index++ + } + return index === 0 ? line : line.slice(index) +} + +/** + * Build a paragraph from raw markdown content. A soft line break stays a literal + * `\n` inside a single text node (the paragraph spec sets `whitespace: 'pre'`, so + * a DOM re-read keeps the newline instead of folding it to a space). Continuation + * lines are dedented by `indentWidth` (the container's content column) so the + * serializer's own line prefix does not double the indent. + */ +function buildParagraph( + nodes: TypedNodeBuilders, + content: string, + indentWidth: number, +): ProseMirrorNode { + if (content === '') return nodes.paragraph() + if (!content.includes('\n')) return nodes.paragraph(content) + const lines = content.split('\n') + const dedented = lines.map((line, index) => (index === 0 ? line : stripIndent(line, indentWidth))) + return nodes.paragraph(dedented.join('\n')) +} + function convertParagraph( nodes: TypedNodeBuilders, cursor: TreeCursor, @@ -211,6 +248,7 @@ function convertParagraph( ): ProseMirrorNode { const from = cursor.from const to = cursor.to + const indentWidth = lineIndentWidth(text, from) // In block-only parsing a paragraph has no inline children, with one // exception: lezer leaves the lazy-continuation `QuoteMark`s of a multi-line // blockquote (`> l1\n> l2`) embedded in the paragraph's span. @@ -226,9 +264,9 @@ function convertParagraph( } while (cursor.nextSibling()) cursor.parent() content += text.slice(pos, to) - return content === '' ? nodes.paragraph() : nodes.paragraph(content) + return buildParagraph(nodes, content, indentWidth) } - return nodes.paragraph(text.slice(from, to)) + return buildParagraph(nodes, text.slice(from, to), indentWidth) } function convertBlockquote( @@ -320,7 +358,7 @@ function convertListItem( // Skip the single separating whitespace after `[ ]` / `[x]` if (isSpaceChar(text.charCodeAt(taskStart))) taskStart += 1 const taskText = text.slice(taskStart, taskEnd) - content.push(taskText === '' ? nodes.paragraph() : nodes.paragraph(taskText)) + content.push(buildParagraph(nodes, taskText, lineIndentWidth(text, taskStart))) continue } content.push(...convertBlock(nodes, cursor, text)) diff --git a/packages/core/src/converters/roundtrip.test.ts b/packages/core/src/converters/roundtrip.test.ts index 86fc9664..424ea091 100644 --- a/packages/core/src/converters/roundtrip.test.ts +++ b/packages/core/src/converters/roundtrip.test.ts @@ -308,6 +308,20 @@ describe('bullet lists', () => { expect(roundtrip('- a\n\n para2')).toBe('- a\n\n para2\n') }) + it('keeps a soft break inside an item', () => { + expect(roundtrip('- line one\n line two')).toBe('- line one\n line two\n') + }) + + it('keeps a soft break in a second paragraph', () => { + expect(roundtrip('- x\n\n line one\n line two')).toBe('- x\n\n line one\n line two\n') + }) + + it('keeps a soft break in a nested item', () => { + expect(roundtrip('- a\n - x\n\n line one\n line two')).toBe( + '- a\n - x\n\n line one\n line two\n', + ) + }) + it('keeps a bare empty bullet', () => { expect(roundtrip('-')).toBe('-\n') }) @@ -412,6 +426,10 @@ describe('task lists', () => { expect(roundtrip('- [ ] parent\n - [x] child')).toBe('- [ ] parent\n - [x] child\n') }) + it('keeps a soft break in a task', () => { + expect(roundtrip('- [ ] line one\n line two')).toBe('- [ ] line one\n line two\n') + }) + it('keeps a tag in a task', () => { expect(roundtrip('- [ ] #todo item')).toBe('- [ ] #todo item\n') }) @@ -600,6 +618,11 @@ describe('idempotency', () => { expect(roundtrip(once)).toBe(once) }) + it('keeps a soft-wrapped item stable', () => { + const once = roundtrip('- x\n\n line one\n line two') + expect(roundtrip(once)).toBe(once) + }) + it('keeps a fenced block stable', () => { const once = roundtrip('```\ncode\n```') expect(roundtrip(once)).toBe(once) diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index 9556ab93..f3003a05 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -10,7 +10,6 @@ import { defineCodeBlock } from '@prosekit/extensions/code-block' import { defineDoc } from '@prosekit/extensions/doc' import { defineGapCursor } from '@prosekit/extensions/gap-cursor' import { defineModClickPrevention } from '@prosekit/extensions/mod-click-prevention' -import { defineParagraph } from '@prosekit/extensions/paragraph' import { defineText } from '@prosekit/extensions/text' import { defineVirtualSelection } from '@prosekit/extensions/virtual-selection' @@ -23,13 +22,14 @@ import { defineInlineMarkPlugin } from './inline-mark-plugin.ts' import { defineInlineMarks } from './inline-marks.ts' import { defineInlineToggle } from './inline-toggle-commands.ts' import { defineMeowdownList } from './list.ts' +import { defineMeowdownParagraph } from './paragraph.ts' import { defineTable } from './table.ts' import { defineWikilink } from './wikilink.ts' function defineEditorExtensionImpl() { return union( // nodes - defineParagraph(), + defineMeowdownParagraph(), defineDoc(), defineDocFrontmatterAttr(), defineText(), diff --git a/packages/core/src/extensions/heading.test.ts b/packages/core/src/extensions/heading.test.ts index 4c57d67a..509be23f 100644 --- a/packages/core/src/extensions/heading.test.ts +++ b/packages/core/src/extensions/heading.test.ts @@ -1,3 +1,4 @@ +import { DOMParser, DOMSerializer } from '@prosekit/pm/model' import { describe, expect, it } from 'vitest' import { userEvent } from 'vitest/browser' @@ -41,3 +42,25 @@ describe('keymap', () => { expect(docToMarkdown(fixture.doc)).toBe('title\n') }) }) + +describe('soft line break', () => { + it('declares whitespace: pre', () => { + using fixture = setupFixture() + expect(fixture.schema.nodes.heading.spec.whitespace).toBe('pre') + }) + + it('keeps a multi-line setext heading through a DOM round-trip', () => { + // Without whitespace: 'pre' the DOM re-read folds the `\n` to a space, + // silently merging the two lines. This pins that the break survives. + using fixture = setupFixture() + const { schema, n } = fixture + const doc = n.doc(n.heading({ level: 1 }, 'Foo bar\nbaz qux')) + + const dom = DOMSerializer.fromSchema(schema).serializeFragment(doc.content) + const container = document.createElement('div') + container.appendChild(dom) + + const parsed = DOMParser.fromSchema(schema).parse(container) + expect(parsed.child(0).textContent).toBe('Foo bar\nbaz qux') + }) +}) diff --git a/packages/core/src/extensions/heading.ts b/packages/core/src/extensions/heading.ts index d399d428..be6f6d11 100644 --- a/packages/core/src/extensions/heading.ts +++ b/packages/core/src/extensions/heading.ts @@ -1,6 +1,7 @@ import { defineKeymap, defineNodeAttr, + defineNodeSpec, isAtBlockStart, toggleNode, union, @@ -30,6 +31,21 @@ export interface MeowdownHeadingAttrs extends HeadingAttrs { setextUnderline?: number | null } +type HeadingSpecExtension = Extension<{ + Nodes: { heading: HeadingAttrs } +}> + +/** + * Merge `whitespace: 'pre'` onto the base heading spec. A multi-line setext + * heading keeps a soft line break as a literal `\n`; without `whitespace: 'pre'` + * (and `white-space: pre-wrap` in the stylesheet) a DOM re-read folds it to a + * space, dropping the break. `defineNodeSpec` merges specs of the same name, so + * this adds the single field without re-declaring the heading spec. + */ +function defineHeadingWhitespace(): HeadingSpecExtension { + return defineNodeSpec({ name: 'heading' satisfies NodeName, whitespace: 'pre' }) +} + type SetextUnderlineExtension = Extension<{ Nodes: { heading: { setextUnderline?: number | null } } }> @@ -78,6 +94,7 @@ function defineHeadingKeymap(): PlainExtension { export function defineHeading() { return union( defineHeadingSpec(), + defineHeadingWhitespace(), defineSetextUnderlineAttr(), defineHeadingInputRule(), defineHeadingCommands(), diff --git a/packages/core/src/extensions/paragraph.test.ts b/packages/core/src/extensions/paragraph.test.ts new file mode 100644 index 00000000..ac840934 --- /dev/null +++ b/packages/core/src/extensions/paragraph.test.ts @@ -0,0 +1,29 @@ +import { createEditor } from '@prosekit/core' +import { DOMParser, DOMSerializer } from '@prosekit/pm/model' +import { describe, expect, it } from 'vitest' + +import { defineEditorExtension } from './extension.ts' + +function setupEditor() { + const editor = createEditor({ extension: defineEditorExtension() }) + return { editor, schema: editor.schema, n: editor.nodes } +} + +describe('paragraph', () => { + it('declares whitespace: pre', () => { + const { schema } = setupEditor() + expect(schema.nodes.paragraph.spec.whitespace).toBe('pre') + }) + + it('keeps a soft line break through a DOM round-trip', () => { + const { schema, n } = setupEditor() + const doc = n.doc(n.list({ kind: 'bullet' }, n.paragraph('line one\nline two'))) + + const dom = DOMSerializer.fromSchema(schema).serializeFragment(doc.content) + const container = document.createElement('div') + container.appendChild(dom) + + const parsed = DOMParser.fromSchema(schema).parse(container) + expect(parsed.child(0).child(0).textContent).toBe('line one\nline two') + }) +}) diff --git a/packages/core/src/extensions/paragraph.ts b/packages/core/src/extensions/paragraph.ts new file mode 100644 index 00000000..5aff6180 --- /dev/null +++ b/packages/core/src/extensions/paragraph.ts @@ -0,0 +1,46 @@ +import { + defineNodeSpec, + Priority, + union, + withPriority, + type Extension, + type Union, +} from '@prosekit/core' +import { + defineParagraphCommands, + defineParagraphKeymap, + type ParagraphCommandsExtension, +} from '@prosekit/extensions/paragraph' +import type { Attrs } from '@prosekit/pm/model' + +import type { NodeName } from './node-names.ts' + +type ParagraphSpecExtension = Extension<{ + Nodes: { paragraph: Attrs } +}> + +function defineMeowdownParagraphSpec(): ParagraphSpecExtension { + return defineNodeSpec({ + name: 'paragraph' satisfies NodeName, + content: 'inline*', + group: 'block', + + // makes the DOM parser preserve the newline, so a multi-line paragraph survives editing + whitespace: 'pre', + + parseDOM: [{ tag: 'p' }], + toDOM() { + return ['p', 0] + }, + }) +} + +type MeowdownParagraphExtension = Union<[ParagraphSpecExtension, ParagraphCommandsExtension]> + +export function defineMeowdownParagraph(): MeowdownParagraphExtension { + return union( + withPriority(defineMeowdownParagraphSpec(), Priority.highest), + defineParagraphCommands(), + defineParagraphKeymap(), + ) +} diff --git a/packages/core/src/style.css b/packages/core/src/style.css index 1f8ccd5b..0186efcf 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -53,6 +53,12 @@ margin-top: 0.85em; } +/* Render soft line breaks (a literal `\n` kept by the paragraph's + * `whitespace: 'pre'` spec) as line breaks, while still wrapping long lines. */ +.ProseMirror p { + white-space: pre-wrap; +} + .ProseMirror h1, .ProseMirror h2, .ProseMirror h3, @@ -63,6 +69,9 @@ line-height: 1.25; letter-spacing: -0.01em; color: var(--meowdown-heading); + /* Render a multi-line setext heading's soft break (kept by the heading's + * `whitespace: 'pre'` spec) as a line break, while still wrapping long lines. */ + white-space: pre-wrap; } .ProseMirror h1 { From 641e47708c583a633c1b6dcccd5ce80d65a0a15b Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 22 Jun 2026 01:19:46 +1000 Subject: [PATCH 03/14] update comments --- packages/core/src/extensions/heading.test.ts | 2 -- packages/core/src/extensions/paragraph.test.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/extensions/heading.test.ts b/packages/core/src/extensions/heading.test.ts index 509be23f..2de18e33 100644 --- a/packages/core/src/extensions/heading.test.ts +++ b/packages/core/src/extensions/heading.test.ts @@ -50,8 +50,6 @@ describe('soft line break', () => { }) it('keeps a multi-line setext heading through a DOM round-trip', () => { - // Without whitespace: 'pre' the DOM re-read folds the `\n` to a space, - // silently merging the two lines. This pins that the break survives. using fixture = setupFixture() const { schema, n } = fixture const doc = n.doc(n.heading({ level: 1 }, 'Foo bar\nbaz qux')) diff --git a/packages/core/src/extensions/paragraph.test.ts b/packages/core/src/extensions/paragraph.test.ts index ac840934..74d6a39d 100644 --- a/packages/core/src/extensions/paragraph.test.ts +++ b/packages/core/src/extensions/paragraph.test.ts @@ -9,7 +9,7 @@ function setupEditor() { return { editor, schema: editor.schema, n: editor.nodes } } -describe('paragraph', () => { +describe('soft line break', () => { it('declares whitespace: pre', () => { const { schema } = setupEditor() expect(schema.nodes.paragraph.spec.whitespace).toBe('pre') From 4f36ad0f61350c12817ee2c4ec827c53a6fb92cc Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 22 Jun 2026 01:21:47 +1000 Subject: [PATCH 04/14] test(lezer): show list continuation indent kept in the paragraph span --- packages/core/src/lezer/parser.test.ts | 53 ++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/core/src/lezer/parser.test.ts b/packages/core/src/lezer/parser.test.ts index 8476f633..9afd9f78 100644 --- a/packages/core/src/lezer/parser.test.ts +++ b/packages/core/src/lezer/parser.test.ts @@ -423,4 +423,57 @@ describe('gfmBlockOnlyParser', () => { }" `) }) + + it('keeps a list item continuation indent inside the paragraph span', () => { + // A soft-wrapped paragraph inside a list item. lezer's Paragraph starts + // after the first line's indent, but the continuation line's 2-space indent + // stays inside the node's [from, to) span (see the `\n line two` in `text`). + // `buildParagraph` (md-to-pm.ts) dedents those continuation lines so the + // round-trip indent does not double. + const input = '- x\n\n line one\n line two\n' + expect(show(buildAst(gfmBlockOnlyParser.parse(input).cursor(), input))).toMatchInlineSnapshot(` + "{ + "name": "Document", + "from": 0, + "to": 27, + "text": "- x\\n\\n line one\\n line two\\n", + "children": [ + { + "name": "BulletList", + "from": 0, + "to": 26, + "text": "- x\\n\\n line one\\n line two", + "children": [ + { + "name": "ListItem", + "from": 0, + "to": 26, + "text": "- x\\n\\n line one\\n line two", + "children": [ + { + "name": "ListMark", + "from": 0, + "to": 1, + "text": "-" + }, + { + "name": "Paragraph", + "from": 2, + "to": 3, + "text": "x" + }, + { + "name": "Paragraph", + "from": 7, + "to": 26, + "text": "line one\\n line two" + } + ] + } + ] + } + ] + }" + `) + }) }) From d5ad8f039108b671b6d9774dedca4c29c1364aa1 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 22 Jun 2026 01:22:20 +1000 Subject: [PATCH 05/14] update test --- .../src/converters/check-roundtrip.test.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/core/src/converters/check-roundtrip.test.ts b/packages/core/src/converters/check-roundtrip.test.ts index 13e9bdb9..30984432 100644 --- a/packages/core/src/converters/check-roundtrip.test.ts +++ b/packages/core/src/converters/check-roundtrip.test.ts @@ -30,12 +30,30 @@ describe('checkRoundTrip', () => { | --- | --- | --- | | | b | | `, + + // setext heading keeps its text and underline length dedent` Hello ===== - `, // setext heading keeps its text and underline length - '- x\n\n line one\n line two', // a list item's soft-wrapped paragraph keeps its indent - '- a\n - x\n\n line one\n line two', // nested list, same + `, + + // a list item's soft-wrapped paragraph keeps its indent + dedent` + - x + + line one + line two + `, + // nested list, same + dedent` + - a + - x + + line one + line two + `, + + ])('reports exact for %j', (markdown) => { expect(checkRoundTrip(markdown)).toBe('exact') }) From 4be72b0868f17d15ba8ab0d6a46f0f7ed09c3eeb Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 22 Jun 2026 01:23:33 +1000 Subject: [PATCH 06/14] remove --- packages/core/src/lezer/parser.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/lezer/parser.test.ts b/packages/core/src/lezer/parser.test.ts index 9afd9f78..89ece98b 100644 --- a/packages/core/src/lezer/parser.test.ts +++ b/packages/core/src/lezer/parser.test.ts @@ -428,8 +428,6 @@ describe('gfmBlockOnlyParser', () => { // A soft-wrapped paragraph inside a list item. lezer's Paragraph starts // after the first line's indent, but the continuation line's 2-space indent // stays inside the node's [from, to) span (see the `\n line two` in `text`). - // `buildParagraph` (md-to-pm.ts) dedents those continuation lines so the - // round-trip indent does not double. const input = '- x\n\n line one\n line two\n' expect(show(buildAst(gfmBlockOnlyParser.parse(input).cursor(), input))).toMatchInlineSnapshot(` "{ From 437c07150b5c97a82235c4327a99b80d7834b764 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 22 Jun 2026 01:25:29 +1000 Subject: [PATCH 07/14] format --- packages/core/src/converters/check-roundtrip.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/converters/check-roundtrip.test.ts b/packages/core/src/converters/check-roundtrip.test.ts index 30984432..e03f73f9 100644 --- a/packages/core/src/converters/check-roundtrip.test.ts +++ b/packages/core/src/converters/check-roundtrip.test.ts @@ -52,8 +52,6 @@ describe('checkRoundTrip', () => { line one line two `, - - ])('reports exact for %j', (markdown) => { expect(checkRoundTrip(markdown)).toBe('exact') }) From bf1ebecdfc284a045fdb101c6b47017e940e5b05 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 22 Jun 2026 01:31:52 +1000 Subject: [PATCH 08/14] add review --- packages/core/src/converters/roundtrip.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/converters/roundtrip.test.ts b/packages/core/src/converters/roundtrip.test.ts index b04477dd..703fd78a 100644 --- a/packages/core/src/converters/roundtrip.test.ts +++ b/packages/core/src/converters/roundtrip.test.ts @@ -313,7 +313,13 @@ describe('bullet lists', () => { }) it('keeps a soft break in a second paragraph', () => { - expect(roundtrip('- x\n\n line one\n line two')).toBe('- x\n\n line one\n line two\n') + // • TODO: review, for readability considerations, if there are two or more consecutive blank lines in the input of this file, then use the following method ([].join('\n')) to construct the markdown string. Please refactor the entire file. + expect(roundtrip( + ['- x', '', ' line one', ' line two'].join('\n') + + )).toBe( + ['- x', '', ' line one', ' line two',''].join('\n') + ) }) it('keeps a soft break in a nested item', () => { From bc1c2da44464b3c67e155dec64565954ad0637d1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:38:08 +0000 Subject: [PATCH 09/14] [autofix.ci] apply automated fixes --- packages/core/src/converters/roundtrip.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/core/src/converters/roundtrip.test.ts b/packages/core/src/converters/roundtrip.test.ts index 703fd78a..020b6b39 100644 --- a/packages/core/src/converters/roundtrip.test.ts +++ b/packages/core/src/converters/roundtrip.test.ts @@ -314,11 +314,8 @@ describe('bullet lists', () => { it('keeps a soft break in a second paragraph', () => { // • TODO: review, for readability considerations, if there are two or more consecutive blank lines in the input of this file, then use the following method ([].join('\n')) to construct the markdown string. Please refactor the entire file. - expect(roundtrip( - ['- x', '', ' line one', ' line two'].join('\n') - - )).toBe( - ['- x', '', ' line one', ' line two',''].join('\n') + expect(roundtrip(['- x', '', ' line one', ' line two'].join('\n'))).toBe( + ['- x', '', ' line one', ' line two', ''].join('\n'), ) }) From f62204e460d42a52383b9b6d5b384f944e43c535 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 22 Jun 2026 01:49:49 +1000 Subject: [PATCH 10/14] refactor(converter): extract column-aware continuation dedent --- packages/core/src/converters/md-to-pm.ts | 78 ++++++++++++++++-------- 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/packages/core/src/converters/md-to-pm.ts b/packages/core/src/converters/md-to-pm.ts index b4c4d12e..ddebd6b5 100644 --- a/packages/core/src/converters/md-to-pm.ts +++ b/packages/core/src/converters/md-to-pm.ts @@ -202,7 +202,10 @@ function convertHeading( cursor.parent() } - const content = text.slice(contentStart, contentEnd).trim() + // Dedent before trimming so a multi-line setext heading inside a container + // (rare) keeps its continuation lines aligned; trim then drops the outer ends. + const raw = text.slice(contentStart, contentEnd) + const content = dedentContinuation(raw, measureContentColumn(text, contentStart)).trim() // CommonMark setext underlines may be any length; keep the source count so // the round-trip is lossless. Fall back to 1 if lezer reported no run. const setextUnderline = isSetext @@ -222,39 +225,66 @@ function countUnderlineChars(text: string, from: number, to: number): number { return count } -/** The number of leading characters before `from` on its own line (the container's content column). */ -function lineIndentWidth(text: string, from: number): number { - return from - (text.lastIndexOf('\n', from - 1) + 1) +/** + * The column at which content begins on the line containing `from` (i.e. the + * enclosing container's content column). Columns count a tab as a CommonMark + * tab stop of 4 (`4 - col % 4`), matching how lezer measures indentation. + */ +function measureContentColumn(text: string, from: number): number { + const lineStart = text.lastIndexOf('\n', from - 1) + 1 + let col = 0 + for (let index = lineStart; index < from; index++) { + col += text.charCodeAt(index) === CHAR_TAB ? 4 - (col % 4) : 1 + } + return col } -/** Drop up to `width` leading space/tab characters from a continuation line. */ -function stripIndent(line: string, width: number): string { +/** Drop a line's leading whitespace up to `column`, counting a tab as `4 - col % 4` columns. */ +function sliceColumn(line: string, column: number): string { + let col = 0 let index = 0 - while (index < width && index < line.length) { + while (index < line.length && col < column) { const code = line.charCodeAt(index) - if (code !== CHAR_SPACE && code !== CHAR_TAB) break + if (code === CHAR_SPACE) col += 1 + else if (code === CHAR_TAB) col += 4 - (col % 4) + else break index++ } - return index === 0 ? line : line.slice(index) + return line.slice(index) +} + +/** + * Strip a leaf block's structural continuation indent. + * + * lezer keeps the indent of a multi-line block's continuation lines inside the + * source span (its `scrub` pads each line's container prefix to equal-width + * whitespace to preserve positions). CommonMark and lezer require every + * continuation line to be indented to the same content `column`, which equals + * the block's first-line column. The first line is already past its indent, so + * only lines 2..n are dedented. Returns `content` untouched at column 0 (a + * top-level block) or when there is no continuation line. + */ +function dedentContinuation(content: string, column: number): string { + if (column === 0 || !content.includes('\n')) return content + return content + .split('\n') + .map((line, index) => (index === 0 ? line : sliceColumn(line, column))) + .join('\n') } /** - * Build a paragraph from raw markdown content. A soft line break stays a literal - * `\n` inside a single text node (the paragraph spec sets `whitespace: 'pre'`, so - * a DOM re-read keeps the newline instead of folding it to a space). Continuation - * lines are dedented by `indentWidth` (the container's content column) so the - * serializer's own line prefix does not double the indent. + * Build a paragraph from raw markdown content, dedenting continuation lines so + * the serializer's own line prefix does not double the indent. A soft line break + * stays a literal `\n` in a single text node; the paragraph spec's + * `whitespace: 'pre'` keeps a DOM re-read from folding it to a space. */ function buildParagraph( nodes: TypedNodeBuilders, content: string, - indentWidth: number, + column: number, ): ProseMirrorNode { - if (content === '') return nodes.paragraph() - if (!content.includes('\n')) return nodes.paragraph(content) - const lines = content.split('\n') - const dedented = lines.map((line, index) => (index === 0 ? line : stripIndent(line, indentWidth))) - return nodes.paragraph(dedented.join('\n')) + const dedented = dedentContinuation(content, column) + return dedented === '' ? nodes.paragraph() : nodes.paragraph(dedented) } function convertParagraph( @@ -264,7 +294,7 @@ function convertParagraph( ): ProseMirrorNode { const from = cursor.from const to = cursor.to - const indentWidth = lineIndentWidth(text, from) + const column = measureContentColumn(text, from) // In block-only parsing a paragraph has no inline children, with one // exception: lezer leaves the lazy-continuation `QuoteMark`s of a multi-line // blockquote (`> l1\n> l2`) embedded in the paragraph's span. @@ -280,9 +310,9 @@ function convertParagraph( } while (cursor.nextSibling()) cursor.parent() content += text.slice(pos, to) - return buildParagraph(nodes, content, indentWidth) + return buildParagraph(nodes, content, column) } - return buildParagraph(nodes, text.slice(from, to), indentWidth) + return buildParagraph(nodes, text.slice(from, to), column) } function convertBlockquote( @@ -374,7 +404,7 @@ function convertListItem( // Skip the single separating whitespace after `[ ]` / `[x]` if (isSpaceChar(text.charCodeAt(taskStart))) taskStart += 1 const taskText = text.slice(taskStart, taskEnd) - content.push(buildParagraph(nodes, taskText, lineIndentWidth(text, taskStart))) + content.push(buildParagraph(nodes, taskText, measureContentColumn(text, taskStart))) continue } content.push(...convertBlock(nodes, cursor, text)) From 349d58bb93d2a2050792d9100ab9d52216e34738 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 22 Jun 2026 01:49:49 +1000 Subject: [PATCH 11/14] test(converter): build blank-line markdown inputs with array join --- .../core/src/converters/roundtrip.test.ts | 60 ++++++++++--------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/packages/core/src/converters/roundtrip.test.ts b/packages/core/src/converters/roundtrip.test.ts index 020b6b39..2177375e 100644 --- a/packages/core/src/converters/roundtrip.test.ts +++ b/packages/core/src/converters/roundtrip.test.ts @@ -13,7 +13,8 @@ describe('text', () => { }) it('keeps a blank line between paragraphs', () => { - expect(roundtrip('line1\n\nline2')).toBe('line1\n\nline2\n') + const md = ['line1', '', 'line2'].join('\n') + expect(roundtrip(md)).toBe(md + '\n') }) it('keeps a tab', () => { @@ -255,7 +256,8 @@ describe('blockquotes', () => { }) it('keeps a quote between paragraphs', () => { - expect(roundtrip('x\n\n> q\n\ny')).toBe('x\n\n> q\n\ny\n') + const md = ['x', '', '> q', '', 'y'].join('\n') + expect(roundtrip(md)).toBe(md + '\n') }) it('keeps a tag in a quote', () => { @@ -305,7 +307,8 @@ describe('bullet lists', () => { }) it('keeps a loose two-block item', () => { - expect(roundtrip('- a\n\n para2')).toBe('- a\n\n para2\n') + const md = ['- a', '', ' para2'].join('\n') + expect(roundtrip(md)).toBe(md + '\n') }) it('keeps a soft break inside an item', () => { @@ -313,16 +316,13 @@ describe('bullet lists', () => { }) it('keeps a soft break in a second paragraph', () => { - // • TODO: review, for readability considerations, if there are two or more consecutive blank lines in the input of this file, then use the following method ([].join('\n')) to construct the markdown string. Please refactor the entire file. - expect(roundtrip(['- x', '', ' line one', ' line two'].join('\n'))).toBe( - ['- x', '', ' line one', ' line two', ''].join('\n'), - ) + const md = ['- x', '', ' line one', ' line two'].join('\n') + expect(roundtrip(md)).toBe(md + '\n') }) it('keeps a soft break in a nested item', () => { - expect(roundtrip('- a\n - x\n\n line one\n line two')).toBe( - '- a\n - x\n\n line one\n line two\n', - ) + const md = ['- a', ' - x', '', ' line one', ' line two'].join('\n') + expect(roundtrip(md)).toBe(md + '\n') }) it('keeps a bare empty bullet', () => { @@ -346,7 +346,8 @@ describe('bullet lists', () => { }) it.fails('keeps a loose list', () => { - expect(roundtrip('- a\n\n- b')).toBe('- a\n\n- b\n') + const md = ['- a', '', '- b'].join('\n') + expect(roundtrip(md)).toBe(md + '\n') }) it.fails('keeps a trailing space after the marker', () => { @@ -482,7 +483,8 @@ describe('thematic breaks', () => { }) it('keeps a rule between paragraphs', () => { - expect(roundtrip('---\n\nafter')).toBe('---\n\nafter\n') + const md = ['---', '', 'after'].join('\n') + expect(roundtrip(md)).toBe(md + '\n') }) it('keeps an asterisk rule', () => { @@ -562,7 +564,8 @@ describe('escapes and whitespace', () => { }) it.fails('keeps internal blank-line runs', () => { - expect(roundtrip('a\n\n\n\nb')).toBe('a\n\n\n\nb\n') + const md = ['a', '', '', '', 'b'].join('\n') + expect(roundtrip(md)).toBe(md + '\n') }) it('keeps YAML frontmatter', () => { @@ -570,14 +573,13 @@ describe('escapes and whitespace', () => { }) it('keeps YAML frontmatter before content', () => { - expect(roundtrip('---\ntitle: x\n---\n\n# heading', { frontmatter: true })).toBe( - '---\ntitle: x\n---\n\n# heading\n', - ) + const md = ['---', 'title: x', '---', '', '# heading'].join('\n') + expect(roundtrip(md, { frontmatter: true })).toBe(md + '\n') }) it('normalizes a missing blank line after frontmatter', () => { expect(roundtrip('---\ntitle: x\n---\n# heading', { frontmatter: true })).toBe( - '---\ntitle: x\n---\n\n# heading\n', + ['---', 'title: x', '---', '', '# heading', ''].join('\n'), ) }) @@ -588,25 +590,28 @@ describe('escapes and whitespace', () => { describe('mixed structures', () => { it('keeps a paragraph-list-paragraph sequence', () => { - expect(roundtrip('para\n\n- list\n\npara2')).toBe('para\n\n- list\n\npara2\n') + const md = ['para', '', '- list', '', 'para2'].join('\n') + expect(roundtrip(md)).toBe(md + '\n') }) it('keeps a heading-paragraph-list sequence', () => { - expect(roundtrip('# h\n\npara\n\n- list')).toBe('# h\n\npara\n\n- list\n') + const md = ['# h', '', 'para', '', '- list'].join('\n') + expect(roundtrip(md)).toBe(md + '\n') }) it('keeps a quote then a code block', () => { - expect(roundtrip('> q\n\n```\ncode\n```')).toBe('> q\n\n```\ncode\n```\n') + const md = ['> q', '', '```', 'code', '```'].join('\n') + expect(roundtrip(md)).toBe(md + '\n') }) it('keeps a code block in a list item', () => { - expect(roundtrip('- a\n\n ```\n code\n ```')).toBe('- a\n\n ```\n code\n ```\n') + const md = ['- a', '', ' ```', ' code', ' ```'].join('\n') + expect(roundtrip(md)).toBe(md + '\n') }) it('keeps a table then a list', () => { - expect(roundtrip('| a | b |\n| --- | --- |\n| 1 | 2 |\n\n- list')).toBe( - '| a | b |\n| --- | --- |\n| 1 | 2 |\n\n- list\n', - ) + const md = ['| a | b |', '| --- | --- |', '| 1 | 2 |', '', '- list'].join('\n') + expect(roundtrip(md)).toBe(md + '\n') }) it.fails('keeps stacked headings tight', () => { @@ -614,7 +619,8 @@ describe('mixed structures', () => { }) it('keeps ordered numbers before a paragraph', () => { - expect(roundtrip('1. a\n2. b\n\npara')).toBe('1. a\n2. b\n\npara\n') + const md = ['1. a', '2. b', '', 'para'].join('\n') + expect(roundtrip(md)).toBe(md + '\n') }) }) @@ -630,7 +636,7 @@ describe('idempotency', () => { }) it('keeps a soft-wrapped item stable', () => { - const once = roundtrip('- x\n\n line one\n line two') + const once = roundtrip(['- x', '', ' line one', ' line two'].join('\n')) expect(roundtrip(once)).toBe(once) }) @@ -645,7 +651,7 @@ describe('idempotency', () => { }) it('keeps a collapsed loose list stable', () => { - const once = roundtrip('- a\n\n- b') + const once = roundtrip(['- a', '', '- b'].join('\n')) expect(roundtrip(once)).toBe(once) }) From 6073353453f1df33d02ad6cd70a5ae0ca968abac Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 22 Jun 2026 01:57:54 +1000 Subject: [PATCH 12/14] refactor(converter): drop redundant empty-string paragraph guard --- packages/core/src/converters/md-to-pm.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/converters/md-to-pm.ts b/packages/core/src/converters/md-to-pm.ts index ddebd6b5..9670675c 100644 --- a/packages/core/src/converters/md-to-pm.ts +++ b/packages/core/src/converters/md-to-pm.ts @@ -283,8 +283,9 @@ function buildParagraph( content: string, column: number, ): ProseMirrorNode { - const dedented = dedentContinuation(content, column) - return dedented === '' ? nodes.paragraph() : nodes.paragraph(dedented) + // An empty string adds no child (the builder skips falsy text), so this also + // covers the empty-paragraph case. + return nodes.paragraph(dedentContinuation(content, column)) } function convertParagraph( From f073bcd55ecb540b011592933cba5e71121d1faa Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 22 Jun 2026 02:02:57 +1000 Subject: [PATCH 13/14] test(converter): cover dedent helpers and the round-trip length-mismatch path --- .../src/converters/check-roundtrip.test.ts | 4 +- packages/core/src/converters/md-to-pm.test.ts | 63 ++++++++++++++++++- packages/core/src/converters/md-to-pm.ts | 6 +- 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/packages/core/src/converters/check-roundtrip.test.ts b/packages/core/src/converters/check-roundtrip.test.ts index e03f73f9..1a3149b3 100644 --- a/packages/core/src/converters/check-roundtrip.test.ts +++ b/packages/core/src/converters/check-roundtrip.test.ts @@ -66,7 +66,9 @@ describe('checkRoundTrip', () => { }) it.each([ - '# Hello #', // a closing ATX hash sequence is dropped + '# Hello #', // a closing ATX hash sequence is dropped (same line count, content differs) + ' indented', // an indented code block becomes a fence: the non-blank line count grows + '~~~\ntilde\n~~~', // a tilde fence becomes a backtick fence: same line count, content differs ])('reports lossy for %j', (markdown) => { expect(checkRoundTrip(markdown)).toBe('lossy') }) diff --git a/packages/core/src/converters/md-to-pm.test.ts b/packages/core/src/converters/md-to-pm.test.ts index 219b55e2..173fd7f1 100644 --- a/packages/core/src/converters/md-to-pm.test.ts +++ b/packages/core/src/converters/md-to-pm.test.ts @@ -1,7 +1,7 @@ import dedent from 'dedent' import { describe, expect, it } from 'vitest' -import { markdownToDoc } from './md-to-pm.ts' +import { dedentContinuation, markdownToDoc, measureContentColumn, sliceColumn } from './md-to-pm.ts' import { sampleContent, sampleContentMarkdown } from './sample-content.ts' function tableShape(markdown: string): Array> { @@ -666,3 +666,64 @@ describe('markdownToDoc', () => { expect(markdownToDoc('---\ntitle: x\n---').attrs.frontmatter).toBe(null) }) }) + +describe('measureContentColumn', () => { + it('is 0 at the document start', () => { + expect(measureContentColumn('hello', 0)).toBe(0) + }) + + it('counts characters before the position on its line', () => { + expect(measureContentColumn('- item', 2)).toBe(2) + }) + + it('measures from the start of the current line', () => { + expect(measureContentColumn('a\n b', 4)).toBe(2) + }) + + it('counts a tab as a tab stop of 4', () => { + expect(measureContentColumn('\tx', 1)).toBe(4) + expect(measureContentColumn('ab\tx', 3)).toBe(4) + }) +}) + +describe('sliceColumn', () => { + it('drops leading spaces up to the column', () => { + expect(sliceColumn(' line', 2)).toBe('line') + }) + + it('stops at the first non-whitespace', () => { + expect(sliceColumn(' line', 8)).toBe('line') + }) + + it('keeps whitespace beyond the column', () => { + expect(sliceColumn(' deep', 2)).toBe(' deep') + }) + + it('counts a tab as a tab stop of 4', () => { + expect(sliceColumn('\tline', 2)).toBe('line') + }) + + it('returns the line unchanged at column 0', () => { + expect(sliceColumn(' line', 0)).toBe(' line') + }) +}) + +describe('dedentContinuation', () => { + it('returns single-line content unchanged', () => { + expect(dedentContinuation('hello', 2)).toBe('hello') + }) + + it('returns content unchanged at column 0', () => { + expect(dedentContinuation('a\n b', 0)).toBe('a\n b') + }) + + it('keeps the first line and dedents the rest', () => { + expect(dedentContinuation('one\n two', 2)).toBe('one\n two') + }) + + it('strips the full column from each continuation line', () => { + expect(dedentContinuation('line one\n line two\n line three', 2)).toBe( + 'line one\nline two\nline three', + ) + }) +}) diff --git a/packages/core/src/converters/md-to-pm.ts b/packages/core/src/converters/md-to-pm.ts index 9670675c..fe167268 100644 --- a/packages/core/src/converters/md-to-pm.ts +++ b/packages/core/src/converters/md-to-pm.ts @@ -230,7 +230,7 @@ function countUnderlineChars(text: string, from: number, to: number): number { * enclosing container's content column). Columns count a tab as a CommonMark * tab stop of 4 (`4 - col % 4`), matching how lezer measures indentation. */ -function measureContentColumn(text: string, from: number): number { +export function measureContentColumn(text: string, from: number): number { const lineStart = text.lastIndexOf('\n', from - 1) + 1 let col = 0 for (let index = lineStart; index < from; index++) { @@ -240,7 +240,7 @@ function measureContentColumn(text: string, from: number): number { } /** Drop a line's leading whitespace up to `column`, counting a tab as `4 - col % 4` columns. */ -function sliceColumn(line: string, column: number): string { +export function sliceColumn(line: string, column: number): string { let col = 0 let index = 0 while (index < line.length && col < column) { @@ -264,7 +264,7 @@ function sliceColumn(line: string, column: number): string { * only lines 2..n are dedented. Returns `content` untouched at column 0 (a * top-level block) or when there is no continuation line. */ -function dedentContinuation(content: string, column: number): string { +export function dedentContinuation(content: string, column: number): string { if (column === 0 || !content.includes('\n')) return content return content .split('\n') From 4726c3c72311cd36bf43747bc65662bc82393e41 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 22 Jun 2026 02:10:13 +1000 Subject: [PATCH 14/14] test(converter): clearer tab coverage for measureContentColumn and sliceColumn --- packages/core/src/converters/md-to-pm.test.ts | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/core/src/converters/md-to-pm.test.ts b/packages/core/src/converters/md-to-pm.test.ts index 173fd7f1..4a9122c3 100644 --- a/packages/core/src/converters/md-to-pm.test.ts +++ b/packages/core/src/converters/md-to-pm.test.ts @@ -672,17 +672,30 @@ describe('measureContentColumn', () => { expect(measureContentColumn('hello', 0)).toBe(0) }) - it('counts characters before the position on its line', () => { + it('is 0 at the start of a line', () => { + expect(measureContentColumn('abc\nx', 4)).toBe(0) + }) + + it('counts the characters before the position', () => { expect(measureContentColumn('- item', 2)).toBe(2) }) - it('measures from the start of the current line', () => { - expect(measureContentColumn('a\n b', 4)).toBe(2) + it('measures only the current line', () => { + expect(measureContentColumn('abc\n x', 6)).toBe(2) }) - it('counts a tab as a tab stop of 4', () => { + it('counts a tab from column 0 as 4', () => { expect(measureContentColumn('\tx', 1)).toBe(4) + }) + + it('counts a tab to the next multiple of 4', () => { + expect(measureContentColumn('a\tx', 2)).toBe(4) expect(measureContentColumn('ab\tx', 3)).toBe(4) + expect(measureContentColumn('abc\tx', 4)).toBe(4) + }) + + it('accumulates multiple tabs', () => { + expect(measureContentColumn('\t\tx', 2)).toBe(8) }) }) @@ -699,8 +712,19 @@ describe('sliceColumn', () => { expect(sliceColumn(' deep', 2)).toBe(' deep') }) - it('counts a tab as a tab stop of 4', () => { - expect(sliceColumn('\tline', 2)).toBe('line') + it('advances a tab to the next multiple of 4', () => { + expect(sliceColumn('\tx', 4)).toBe('x') + expect(sliceColumn(' \tx', 4)).toBe('x') + expect(sliceColumn(' \tx', 4)).toBe('x') + expect(sliceColumn(' \tx', 4)).toBe('x') + }) + + it('stops once a tab reaches the column', () => { + expect(sliceColumn('\t\tx', 4)).toBe('\tx') + }) + + it('consumes a whole tab when the column falls mid-tab', () => { + expect(sliceColumn('\tx', 2)).toBe('x') }) it('returns the line unchanged at column 0', () => {