Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions corpus/commonmark/text-and-breaks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.\
Expand Down
114 changes: 114 additions & 0 deletions src/features/editor/tests/markdownCompatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/features/editor/utils/markdownText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down