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

Expand Down
154 changes: 154 additions & 0 deletions src/features/editor/tests/markdownCompatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
45 changes: 43 additions & 2 deletions src/features/editor/utils/markdownText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1085,9 +1120,15 @@ export const serializeMarkdownText: NonNullable<RemarkStringifyHandlers["text"]>
// 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,
});
Expand Down