diff --git a/CHANGELOG.md b/CHANGELOG.md index b402075..0990e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - Show the backslashes that keep text literal when the Markdown source of bold, italic, or strikethrough is opened, as a link label already did, so text such as `**a \[b](c) d**` no longer reads as though it held a live link and an escaped `©` can be told apart from one the file preserves. - 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. ## [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 15ab403..b82f70e 100644 --- a/src/features/editor/tests/markdownCompatibility.test.ts +++ b/src/features/editor/tests/markdownCompatibility.test.ts @@ -1222,7 +1222,7 @@ describe("Typed link source", () => { setSelectionAtDocumentEnd(mounted.view); typeText(mounted.view, `${typed} `); - expect(mounted.getMarkdown()).toBe(`${typed} \n`); + expect(mounted.getMarkdown()).toBe(`${typed}\n`); }, ); @@ -1236,13 +1236,115 @@ describe("Typed link source", () => { }, ); - it("writes an ordinary trailing space as itself", async () => { + it("leaves the space that follows typed source out of the line it ends", async () => { const mounted = await mountEditor(""); setSelectionAtDocumentEnd(mounted.view); typeText(mounted.view, "plain tail "); - expect(mounted.getMarkdown()).toBe("plain tail \n"); + expect(mounted.getMarkdown()).toBe("plain tail\n"); + }); +}); + +// A parse drops whitespace closing a line or a cell, so writing it produces a file that reloads as +// a different document. The corpus guard cannot reach this: a file it has opened once no longer +// holds such whitespace, so only an edit puts it there. +describe("Line-final whitespace", () => { + const editThenReload = 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 typeAtEnd = (typed: string) => (mounted: MountedMilkdownEditor) => { + setSelectionAtDocumentEnd(mounted.view); + typeText(mounted.view, typed); + }; + + it.each([ + { expected: "plain\n", initial: "plain", name: "a paragraph", typed: " " }, + { + expected: "plain tail\n", + initial: "plain tail", + name: "a paragraph holding a space", + typed: " ", + }, + { expected: "*text*\n", initial: "*text*", name: "emphasis closing a paragraph", typed: " " }, + { expected: "plain\n", initial: "plain", name: "a paragraph, typed twice", typed: " " }, + { expected: "plain\n", initial: "plain", name: "a paragraph, typed as a tab", typed: "\t" }, + { expected: "# head\n", initial: "# head", name: "a heading", typed: " " }, + { expected: "* item\n", initial: "- item", name: "a list item", typed: " " }, + { expected: "> quote\n", initial: "> quote", name: "a blockquote", typed: " " }, + ])( + "converges on $name after a space is typed at its end", + async ({ expected, initial, typed }) => { + const { firstSave, secondSave } = await editThenReload(initial, typeAtEnd(typed)); + + expect(firstSave).toBe(expected); + expect(secondSave).toBe(firstSave); + }, + ); + + it("converges on a table cell after a space is typed at its end", async () => { + const { firstSave, secondSave } = await editThenReload(BASIC_TABLE_MARKDOWN, typeAtEnd(" ")); + + expect(secondSave).toBe(firstSave); + }); + + it("reloads the paragraph the editor showed before the space was typed", async () => { + const { reloadedText } = await editThenReload("plain", typeAtEnd(" ")); + + expect(reloadedText).toBe("plain"); + }); + + it("converges where whitespace ends a line through a deletion rather than a keystroke", async () => { + const { firstSave, secondSave } = await editThenReload("plain x", (mounted) => { + const end = mounted.view.state.doc.content.size - 1; + + mounted.view.dispatch(mounted.view.state.tr.delete(end - 1, end)); + }); + + expect(firstSave).toBe("plain\n"); + expect(secondSave).toBe(firstSave); + }); + + it("keeps a space that later text on the same line follows", async () => { + const { firstSave, secondSave } = await editThenReload("plain", (mounted) => { + setTextSelection(mounted.view, 6); + typeText(mounted.view, " tail"); + }); + + expect(firstSave).toBe("plain tail\n"); + expect(secondSave).toBe(firstSave); + }); + + it("keeps a space a hard break follows", async () => { + const { firstSave, secondSave } = await editThenReload("a\\\nb\n", (mounted) => { + setTextSelection(mounted.view, 2); + typeText(mounted.view, " "); + }); + + expect(firstSave).toBe("a \\\nb\n"); + expect(secondSave).toBe(firstSave); + }); + + it("keeps whitespace inside fenced code, which no parse trims", async () => { + const { firstSave, secondSave } = await editThenReload("```\ncode\n```", typeAtEnd(" ")); + + expect(firstSave).toBe("```\ncode \n```\n"); + expect(secondSave).toBe(firstSave); }); }); diff --git a/src/features/editor/tests/sourceProjectionTypedLink.test.ts b/src/features/editor/tests/sourceProjectionTypedLink.test.ts index a403738..63db4cb 100644 --- a/src/features/editor/tests/sourceProjectionTypedLink.test.ts +++ b/src/features/editor/tests/sourceProjectionTypedLink.test.ts @@ -234,7 +234,7 @@ describe("typed link source", () => { expect(getLinkTargets(mounted)).toEqual([]); expect(mounted.getMarkdown()).toBe( - moved ? "\\[test taillink](./test.html) \n" : "\\[test taillink](./test.html) tail\n", + moved ? "\\[test taillink](./test.html)\n" : "\\[test taillink](./test.html) tail\n", ); }); diff --git a/src/features/editor/utils/markdownText.ts b/src/features/editor/utils/markdownText.ts index f71406c..36db875 100644 --- a/src/features/editor/utils/markdownText.ts +++ b/src/features/editor/utils/markdownText.ts @@ -1039,6 +1039,18 @@ export const serializeMarkdownRoot: NonNullable } }; +// A paragraph and a heading end their line where their last child ends, and a cell is read back +// trimmed to its content, so whitespace closing any of them is whitespace the next parse drops. +// Only the last child can hold it: `state.safe` encodes whitespace a line ending follows, and +// Milkdown hoists whitespace out of a mark before the mark is written. +const closesTrimmedContent = ( + parent: { type: string; children: readonly unknown[] } | undefined, + index: number, +) => + parent !== undefined && + WHOLE_LINE_PHRASING_PARENTS.has(parent.type) && + index === parent.children.length - 1; + const readPhrasingNeighbors = ( parent: { type: string; children: readonly { type: string; value?: string }[] } | undefined, index: number, @@ -1068,14 +1080,18 @@ export const serializeMarkdownText: NonNullable info, ) => { const { value } = node; + const childIndex = state.indexStack[state.indexStack.length - 1] ?? -1; const trailingWhitespace = TRAILING_WHITESPACE_PATTERN.exec(value)?.[0] ?? ""; - const after = trailingWhitespace + info.after; + // 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 after = writtenWhitespace + info.after; const escaped = state.safe(value.slice(0, value.length - trailingWhitespace.length), { ...info, after, }); const slots = decodeEscapes(escaped); - const childIndex = state.indexStack[state.indexStack.length - 1] ?? -1; const neighbors = readPhrasingNeighbors(parent, childIndex); relaxAttentionEscapes( @@ -1100,5 +1116,5 @@ export const serializeMarkdownText: NonNullable relaxAutolinkLiteralEscapes(slots, info.before, after); relaxCharacterReferenceEscapes(slots, after); - return encodeEscapes(slots) + trailingWhitespace; + return encodeEscapes(slots) + writtenWhitespace; };