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, });