From 9265f53e0424e9843d46695256cf5b1548094b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Mon, 31 Aug 2026 11:07:17 -0300 Subject: [PATCH] Leave whitespace that starts a line out of the saved file Encoding it as ` ` would preserve it, and unlike the closing edge that direction was reachable here: a leading space in a heading and a leading tab in a paragraph already round-trip. It would still return the entity noise #242 removed to every file whose line happens to begin in whitespace, and a parse strips the character on read while a block renders it as nothing, so the two directions differ in what the file carries rather than in what an author can observe. Whitespace typed at one of those positions is dropped as a result, a tab and a leading space in a heading included, both of which round-tripped as a character reference before. An authored reference is dropped only where Milkdown hoists its character out of the mark that records it, which it does for the ASCII space alone; ` ` and ` ` keep their source and are written as they were. --- CHANGELOG.md | 1 + .../tests/markdownCompatibility.test.ts | 154 ++++++++++++++++++ src/features/editor/utils/markdownText.ts | 45 ++++- 3 files changed, 198 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0990e0b..2506adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - Keep character references written next to each other as they were written, so text such as `©©` no longer saves as the characters it names. Each one still opens as its own Markdown source, so breaking one leaves the others preserved. - Keep a link or image title in the quotation marks or parentheses it was written with, so a file holding `[Garden](garden.md 'Garden')` no longer comes back rewritten to double quotes. Editing an image no longer rewrites its title either. - Leave whitespace that ends a line out of the saved file, so a space typed at the end of a paragraph, heading, list item, quote, or table cell no longer writes a character that the next open discards and a second save then removes. Markdown drops such whitespace on read, so the space was already lost; the file now says so from the first save. Whitespace elsewhere on a line, a hard break, and whitespace inside fenced code are unchanged, and a space written as ` ` at one of those trimmed positions is now dropped on save for the same reason. +- Leave whitespace that starts a line out of the saved file, so a space or tab typed at the start of a paragraph, heading, list item, quote, or table cell no longer writes a character reference that survives one open and is gone after the one following it. Markdown drops such whitespace on read, so the character was already lost; the file now says so from the first save. Whitespace elsewhere on a line and inside fenced code is unchanged, as is a character reference naming something Markdown does not trim, such as ` `. A space an author wrote as ` ` at the start of a line is now dropped on save, for the reason one written at the end already is. ## [0.1.0-alpha.1] - 2026-07-10 diff --git a/src/features/editor/tests/markdownCompatibility.test.ts b/src/features/editor/tests/markdownCompatibility.test.ts index b82f70e..691c659 100644 --- a/src/features/editor/tests/markdownCompatibility.test.ts +++ b/src/features/editor/tests/markdownCompatibility.test.ts @@ -1348,6 +1348,160 @@ describe("Line-final whitespace", () => { }); }); +// A parse trims what a line opens with just as it trims what a line closes with, so writing +// that whitespace produces a file the next open reads as a different document. The corpus guard +// cannot reach this either: only an edit, or a character reference an author wrote, puts it +// there. +describe("Line-initial whitespace", () => { + const saveReloadSave = async ( + initial: string, + edit?: (mounted: MountedMilkdownEditor) => void, + ) => { + const edited = await mountEditor(initial); + + edit?.(edited); + + const firstSave = edited.getMarkdown(); + const reloaded = await mountEditor(firstSave); + + return { + firstSave, + reloadedText: reloaded.view.state.doc.textContent, + secondSave: reloaded.getMarkdown(), + }; + }; + + const typeBefore = (anchor: string, typed: string) => (mounted: MountedMilkdownEditor) => { + setTextSelection(mounted.view, getEditorTextPosition(mounted, anchor)); + typeText(mounted.view, typed); + }; + + it.each([ + { anchor: "plain", expected: "plain\n", initial: "plain", name: "a paragraph", typed: " " }, + { + anchor: "plain", + expected: "plain\n", + initial: "plain", + name: "a paragraph, typed twice", + typed: " ", + }, + { + anchor: "plain", + expected: "plain\n", + initial: "plain", + name: "a paragraph, typed as a tab", + typed: "\t", + }, + { anchor: "head", expected: "# head\n", initial: "# head", name: "a heading", typed: " " }, + { anchor: "item", expected: "* item\n", initial: "- item", name: "a list item", typed: " " }, + { + anchor: "quote", + expected: "> quote\n", + initial: "> quote", + name: "a blockquote", + typed: " ", + }, + { + anchor: "two", + expected: "one\n\ntwo\n", + initial: "one\n\ntwo", + name: "a later paragraph", + typed: " ", + }, + { + anchor: "text", + expected: "*text*\n", + initial: "*text*", + name: "emphasis opening a paragraph", + typed: " ", + }, + ])( + "converges on $name after a space is typed at its start", + async ({ anchor, expected, initial, typed }) => { + const { firstSave, secondSave } = await saveReloadSave(initial, typeBefore(anchor, typed)); + + expect(firstSave).toBe(expected); + expect(secondSave).toBe(firstSave); + }, + ); + + it("converges on a table cell after a space is typed at its start", async () => { + const { firstSave, secondSave } = await saveReloadSave( + BASIC_TABLE_MARKDOWN, + typeBefore("C", " "), + ); + + expect(firstSave).toBe(`${BASIC_TABLE_MARKDOWN}\n`); + expect(secondSave).toBe(firstSave); + }); + + it("converges where whitespace opens the line a hard break left behind", async () => { + const { firstSave, secondSave } = await saveReloadSave("a\\\nb", typeBefore("b", " ")); + + expect(firstSave).toBe("a\\\nb\n"); + expect(secondSave).toBe(firstSave); + }); + + it.each([ + { expected: "plain\n", initial: " plain", name: "a paragraph" }, + { expected: "# head\n", initial: "# head", name: "a heading" }, + ])( + "converges on $name a character reference opens with a space", + async ({ expected, initial }) => { + const { firstSave, secondSave } = await saveReloadSave(initial); + + expect(firstSave).toBe(expected); + expect(secondSave).toBe(firstSave); + }, + ); + + it("reloads the paragraph the editor showed before the space was typed", async () => { + const { reloadedText } = await saveReloadSave("plain", typeBefore("plain", " ")); + + expect(reloadedText).toBe("plain"); + }); + + it("reloads the paragraph without the space its character reference named", async () => { + const { reloadedText } = await saveReloadSave(" plain"); + + expect(reloadedText).toBe("plain"); + }); + + it.each([ + { + expected: "a b\n", + initial: "a b", + name: "a character reference away from a line edge", + }, + { expected: " plain\n", initial: " plain", name: "a no-break space" }, + { + expected: " plain\n", + initial: " plain", + name: "a tab a character reference names", + }, + { + expected: "*text* tail\n", + initial: "*text* tail", + name: "a space a construct on the same line precedes", + }, + ])("keeps $name", async ({ expected, initial }) => { + const { firstSave, secondSave } = await saveReloadSave(initial); + + expect(firstSave).toBe(expected); + expect(secondSave).toBe(firstSave); + }); + + it("keeps whitespace inside fenced code, which no parse trims", async () => { + const { firstSave, secondSave } = await saveReloadSave( + "```\ncode\n```", + typeBefore("code", " "), + ); + + expect(firstSave).toBe("```\n code\n```\n"); + expect(secondSave).toBe(firstSave); + }); +}); + describe("Typed inline mark source", () => { const typeInto = async (initial: string, typed: string) => { const mounted = await mountEditor(initial); diff --git a/src/features/editor/utils/markdownText.ts b/src/features/editor/utils/markdownText.ts index 36db875..7ee9c67 100644 --- a/src/features/editor/utils/markdownText.ts +++ b/src/features/editor/utils/markdownText.ts @@ -44,6 +44,10 @@ interface PhrasingNode { } const TRAILING_WHITESPACE_PATTERN = /\s+$/u; +// CommonMark trims a space or a tab at a line edge and nothing else, so a no-break space stays +// a character the line carries rather than whitespace the parse drops. +const LEADING_WHITESPACE_PATTERN = /^[\t ]+/u; +const LINE_ENDING_PATTERN = /[\r\n]$/u; // `state.safe` escapes ASCII punctuation and nothing else. Decoding with a wider class would read a // backslash before ordinary text as an escape. const ESCAPABLE_PATTERN = /[!-/:-@[-`{-~]/u; @@ -1051,6 +1055,37 @@ const closesTrimmedContent = ( WHOLE_LINE_PHRASING_PARENTS.has(parent.type) && index === parent.children.length - 1; +// A parse trims what a line opens with just as it trims what a line closes with, so whitespace +// opening a paragraph, a heading, or a cell is whitespace the next open drops. A line ending in +// `before` marks the line a hard break leaves behind; the block's own first line is read off the +// tree instead, because a heading hands its first child the marker as `before` and a cell hands +// its own padding. Reading the tree is also what separates a hoisted space from an ordinary one: +// Milkdown empties the character reference it lifts a space out of, and an emptied reference +// writes nothing, while any sibling that writes even one character puts the space mid-line. +const opensTrimmedContent = ( + node: PhrasingNode, + parent: { type: string; children: readonly PhrasingNode[] } | undefined, + before: string, +) => { + if (parent === undefined || !WHOLE_LINE_PHRASING_PARENTS.has(parent.type)) { + return false; + } + + if (LINE_ENDING_PATTERN.test(before)) { + return true; + } + + // `containerPhrasing` peeks the next child to learn what the current one has to be escaped + // against, and leaves `indexStack` pointing at the child being written rather than the one + // peeked, so the position has to come from the tree for a peek to agree with the write it predicts. + const index = parent.children.indexOf(node); + + return ( + index >= 0 && + parent.children.slice(0, index).every((child) => readWrittenCharacters(child) === "") + ); +}; + const readPhrasingNeighbors = ( parent: { type: string; children: readonly { type: string; value?: string }[] } | undefined, index: number, @@ -1085,9 +1120,15 @@ export const serializeMarkdownText: NonNullable // Whitespace the parse drops is left out rather than encoded, so the file the editor writes is // the file it reads back. Every other position keeps it raw, which is what `state.safe` would // write there anyway and what a typed space beside literal source needs. - const writtenWhitespace = closesTrimmedContent(parent, childIndex) ? "" : trailingWhitespace; + const droppedWhitespace = opensTrimmedContent(node as PhrasingNode, parent, info.before) + ? (LEADING_WHITESPACE_PATTERN.exec(value)?.[0] ?? "") + : ""; + // A value that is whitespace alone is both what the line opens with and what it closes with, + // so the body cannot start after it ends. + const bodyEnd = Math.max(droppedWhitespace.length, value.length - trailingWhitespace.length); + const writtenWhitespace = closesTrimmedContent(parent, childIndex) ? "" : value.slice(bodyEnd); const after = writtenWhitespace + info.after; - const escaped = state.safe(value.slice(0, value.length - trailingWhitespace.length), { + const escaped = state.safe(value.slice(droppedWhitespace.length, bodyEnd), { ...info, after, });