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 @@ -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

Expand Down
108 changes: 105 additions & 3 deletions src/features/editor/tests/markdownCompatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
},
);

Expand All @@ -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);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});

Expand Down
22 changes: 19 additions & 3 deletions src/features/editor/utils/markdownText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,18 @@ export const serializeMarkdownRoot: NonNullable<RemarkStringifyHandlers["root"]>
}
};

// 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,
Expand Down Expand Up @@ -1068,14 +1080,18 @@ export const serializeMarkdownText: NonNullable<RemarkStringifyHandlers["text"]>
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(
Expand All @@ -1100,5 +1116,5 @@ export const serializeMarkdownText: NonNullable<RemarkStringifyHandlers["text"]>
relaxAutolinkLiteralEscapes(slots, info.before, after);
relaxCharacterReferenceEscapes(slots, after);

return encodeEscapes(slots) + trailingWhitespace;
return encodeEscapes(slots) + writtenWhitespace;
};