diff --git a/CHANGELOG.md b/CHANGELOG.md index 2506adc..4e1e37d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - 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. +- Keep a character Markdown does not trim where it ends a line, so a no-break space, an em space, or an ideographic space closing a paragraph, heading, list item, quote, or table cell is written back as it was read instead of being deleted on the first save. Such a character renders and reloads wherever it is written, so removing it lost text rather than settling the file. A space or tab at the same position is still left out, and one written as ` ` was never affected. ## [0.1.0-alpha.1] - 2026-07-10 diff --git a/corpus/commonmark/text-and-breaks.md b/corpus/commonmark/text-and-breaks.md index f7e5386..ef18e23 100644 --- a/corpus/commonmark/text-and-breaks.md +++ b/corpus/commonmark/text-and-breaks.md @@ -61,6 +61,24 @@ This line continues the same paragraph. Two spaces follow the end of this paragraph. +## Trailing whitespace a parse keeps + +A no-break space ends this paragraph.  + +An em space ends this paragraph.  + +An ideographic space ends this paragraph.  + +### A no-break space ends this heading  + +- A no-break space ends this list item.  + +> A no-break space ends this quote.  + +A no-break space follows the space that ends this paragraph.   + +A no-break space precedes the space that ends this paragraph.  + ## Hard breaks using a backslash A backslash follows this line.\ diff --git a/src/features/editor/tests/markdownCompatibility.test.ts b/src/features/editor/tests/markdownCompatibility.test.ts index 691c659..48c82c3 100644 --- a/src/features/editor/tests/markdownCompatibility.test.ts +++ b/src/features/editor/tests/markdownCompatibility.test.ts @@ -1346,6 +1346,120 @@ describe("Line-final whitespace", () => { expect(firstSave).toBe("```\ncode \n```\n"); expect(secondSave).toBe(firstSave); }); + + // A character CommonMark does not trim is content the line carries, so it reaches the file even + // where it sits against the line ending. `TRAILING_WHITESPACE_PATTERN` decides both what is left + // out and what `state.safe` never sees, which is why these are asserted through the escape it + // would otherwise have skipped rather than through the saved character alone. + const NO_BREAK_SPACE = "\u00a0"; + const EM_SPACE = "\u2003"; + const IDEOGRAPHIC_SPACE = "\u3000"; + + const openThenReload = async (initial: string) => { + const opened = await mountEditor(initial); + const firstSave = opened.getMarkdown(); + const reloaded = await mountEditor(firstSave); + + return { + firstSave, + reloadedText: reloaded.view.state.doc.textContent, + secondSave: reloaded.getMarkdown(), + }; + }; + + it.each([ + { + expected: `plain${NO_BREAK_SPACE}\n`, + initial: `plain${NO_BREAK_SPACE}`, + name: "a paragraph", + }, + { + expected: `# head${NO_BREAK_SPACE}\n`, + initial: `# head${NO_BREAK_SPACE}`, + name: "a heading", + }, + { + expected: `* item${NO_BREAK_SPACE}\n`, + initial: `- item${NO_BREAK_SPACE}`, + name: "a list item", + }, + { + expected: `> quote${NO_BREAK_SPACE}\n`, + initial: `> quote${NO_BREAK_SPACE}`, + name: "a blockquote", + }, + ])("keeps a no-break space ending $name", async ({ expected, initial }) => { + const { firstSave, secondSave } = await openThenReload(initial); + + expect(firstSave).toBe(expected); + expect(secondSave).toBe(firstSave); + }); + + it.each([ + { character: EM_SPACE, name: "an em space" }, + { character: IDEOGRAPHIC_SPACE, name: "an ideographic space" }, + ])("keeps $name ending a paragraph", async ({ character }) => { + const { firstSave, secondSave } = await openThenReload(`plain${character}`); + + expect(firstSave).toBe(`plain${character}\n`); + expect(secondSave).toBe(firstSave); + }); + + it("keeps a no-break space ending a table cell", async () => { + const { firstSave, secondSave } = await openThenReload( + `| A | B |\n| - | - |\n| C${NO_BREAK_SPACE} | D |`, + ); + + expect(firstSave).toContain(`C${NO_BREAK_SPACE}`); + expect(secondSave).toBe(firstSave); + }); + + it("reloads the paragraph holding the character the file kept", async () => { + const { reloadedText } = await openThenReload(`plain${NO_BREAK_SPACE}`); + + expect(reloadedText).toBe(`plain${NO_BREAK_SPACE}`); + }); + + // The two classes meet here: the space is what a parse trims and goes, the character beside it + // is not and stays, whichever order the line puts them in. + it.each([ + { + expected: `plain ${NO_BREAK_SPACE}\n`, + initial: `plain ${NO_BREAK_SPACE}`, + name: "a space the character follows", + }, + { + expected: `plain${NO_BREAK_SPACE}\n`, + initial: `plain${NO_BREAK_SPACE} `, + name: "a space following the character", + }, + { + expected: `plain${NO_BREAK_SPACE}\n`, + initial: `plain${NO_BREAK_SPACE}\t`, + name: "a tab following the character", + }, + ])("keeps a line ending in $name", async ({ expected, initial }) => { + const { firstSave, secondSave } = await openThenReload(initial); + + expect(firstSave).toBe(expected); + expect(secondSave).toBe(firstSave); + }); + + // `state.safe` sees the character now that it is no longer split off the value, so the escapes + // it decides read the real line rather than one ending where the character starts. + it("leaves a backslash the character separates from the line ending bare", async () => { + const { firstSave, secondSave } = await openThenReload(`a\\${NO_BREAK_SPACE}`); + + expect(firstSave).toBe(`a\\${NO_BREAK_SPACE}\n`); + expect(secondSave).toBe(firstSave); + }); + + it("keeps a bare autolink bare before the character", async () => { + const { firstSave, secondSave } = await openThenReload(`https://example.com${NO_BREAK_SPACE}`); + + expect(firstSave).toBe(`https://example.com${NO_BREAK_SPACE}\n`); + expect(secondSave).toBe(firstSave); + }); }); // A parse trims what a line opens with just as it trims what a line closes with, so writing diff --git a/src/features/editor/utils/markdownText.ts b/src/features/editor/utils/markdownText.ts index 7ee9c67..2fd082b 100644 --- a/src/features/editor/utils/markdownText.ts +++ b/src/features/editor/utils/markdownText.ts @@ -43,9 +43,9 @@ interface PhrasingNode { children?: readonly 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 TRAILING_WHITESPACE_PATTERN = /[\t ]+$/u; 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