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 @@ -32,6 +32,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0
- Keep the address of a URL or email address written on its own when a `*`, `_`, or `~` follows it, so text such as `https://example.com*` keeps its link pointing where it did. The backslash the file writes to keep that marker literal was being read back as part of the address, which gained another backslash every time the document was opened and saved.
- Keep a URL or email address written on its own bare when a run shaped like a character reference but naming nothing, such as `&notarealentity;`, follows it, so text such as `https://example.com&notarealentity;` is saved as it was written instead of gaining angle brackets. Markdown leaves such a run outside the link whether or not the name exists.
- Keep a URL or email address written on its own bare when a literal `<` or `>` sits beside it, so text such as `\<test@example.com>` or `&lt;https://example.com&gt;` is saved as it was written, instead of putting angle brackets around it and saving `<<…>>`, which the next open reads as an angle-bracket URL between two literal brackets.
- Keep a horizontal rule written the way it was authored, so `---`, `_ _ _`, or any other accepted run stays as it is instead of being rewritten as `***` on the first save. A rule inserted from the editor is still written as `***`, and so is one whose own run would be read back as a heading underline or as part of its list item's bullet.
- Keep a reference link, a reference image, and the definitions they point at, instead of rewriting every reference as an inline link carrying its own copy of the destination and deleting the definition block on the first save. A definition now appears in the document as the line it is written as, and a reference shows that reference source when the caret reaches it.
- Leave a URL, email address, or `www` address held as ordinary text without a backslash before its `:`, `@`, or `.`, so a saved file no longer carries an escape such as `https\://example.com`, instead of writing one that other Markdown tools read as a stray backslash and that turned back into a link on the next open regardless.
- Copy text the editor keeps literal with the backslashes the file is saved with, so pasting it elsewhere keeps it literal instead of turning it into a heading, a link, or emphasis.
Expand Down
1 change: 1 addition & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
- The preset's single canonical autolink form is overridden. Bare GFM URL literals and angle-bracket autolinks parse into the same link, which Milkdown serializes as `<https://…>`, rewriting every bare URL in a file on its first save. Leafdown records the authored form on the link mark, decided in [issue #240](https://github.com/Azganoth/leafdown/issues/240), and writes and projects each form as authored. A bare literal falls back to the angle-bracket form when its neighbouring characters would hide it or extend its target, because a bare URL only survives where GFM reads it back. The fallback is withheld where a neighbour cannot take the literal in after all, decided in [issue #300](https://github.com/Azganoth/leafdown/issues/300), because the brackets it introduces are the characters at issue: GFM ends an email literal at a `>` its domain does not admit, and excludes a trailing `;` from the target wherever `&` and alphanumerics precede it, so a bare literal beside `\<…>` or `&lt;…&gt;` stays bare rather than being written as `<<…>>` and read back as an angle-bracket autolink between two literal brackets. That trailing rule is syntactic rather than a lookup, recorded in [issue #332](https://github.com/Azganoth/leafdown/issues/332), so a run is settled by whether the file writes it bare rather than by whether it names a character: a preserved reference and a run that names nothing are both written bare and both stand outside the target, while a run that does name one is written with a backslash the target takes in. A numeric reference carries a `#` the rule does not match, and text the run still admits after a trimmed one puts the neighbour back inside the target, so both keep the fallback. The fallback also holds wherever the file will escape the neighbour, decided in [issue #334](https://github.com/Azganoth/leafdown/issues/334), because a target takes a backslash in rather than leaving it out: a literal whose neighbour is escaped grew one backslash per save while every save still converged on its own output, which is why the neighbour is read off the sibling that holds it rather than off the serializer's own lookahead. That lookahead is the next node written in a context that is not the one it lands in, so it shows the escape for a marker `safe` settles and not for one a later pass adds, and reading the sibling asks the question once for every marker. A delimiter that closes an enclosing construct is not a sibling and is never escaped where it stands, so a literal at the end of a bold, italic, or struck-through span stays bare. Reading the sibling asks whether a marker sits there rather than whether the file will escape it, so the fallback is also taken where the escape turns out to be unnecessary, which is the form a literal gives up for a target it keeps. Narrowing when a marker is escaped, decided in [issue #336](https://github.com/Azganoth/leafdown/issues/336), narrows what the file writes without narrowing this, because the two decisions are made in an order that cannot be reversed: the literal is written before the text beside it is.
- Neither autolink form has a literal state, and this is not fixed. `mdast-util-gfm-autolink-literal` contributes an escape for the characters that would otherwise read as a protocol, a `www` lead, or an email marker, but its `fromMarkdown` side runs a `findAndReplace` over already-decoded text, after escapes are resolved, so the escape never changes what a reload produces: `https\://example.com` and `\<https\://example.com>` both reopen as a link, examined in [issue #241](https://github.com/Azganoth/leafdown/issues/241). Leafdown stops emitting an escape that cannot hold rather than pursue a parser change to give a bare URL a literal state, which is declined for the same reason the canonical form above is not. Inline code is the only durable way to show a URL or an email as text, because its content is never a `text` node the autolink transform visits.
- A character reference is decoded by `micromark` before the mdast text node exists, so `©` and `&copy;` are indistinguishable to everything downstream and a file written to stay ASCII does not stay ASCII. Leafdown records the authored form, decided in [issue #262](https://github.com/Azganoth/leafdown/issues/262), and writes it back in text and in link and image destinations alike. The run is recovered by walking each text node's value against the slice of the file it was built from, and carried on a mark whose stored source is verified against the text it covers before it is written, so an edit that invalidates it degrades to the character rather than to a stale reference. References written next to each other keep one mark each, decided in [issue #305](https://github.com/Azganoth/leafdown/issues/305), so breaking one converts only that one and leaves its neighbours preserved. ProseMirror merges neighbouring text nodes carrying an equal mark set, so a repeated reference still arrives as one node holding its characters repeated; only an equal mark merges, which makes that node whole repetitions of the one source it stores, and the verification counts them rather than reading the node as a reference the source does not spell. A preserved reference is inert for escaping: it opens no construct and closes none, and the escape passes read it as the characters it will be written as. That same verified source is what a caret reaching the reference projects, decided in [issue #298](https://github.com/Azganoth/leafdown/issues/298) on the rule [Offer the escape gesture only where the conversion exists](#offer-the-escape-gesture-only-where-the-conversion-exists) states, because breaking a valid reference commits the literal text it spells and the conversion therefore exists. This is the exception the byte-identity target in [issue #251](https://github.com/Azganoth/leafdown/issues/251) would otherwise have had to admit, and it is overridden rather than accepted, unlike the strikethrough run below, because a reference and the character it names are not interchangeable to an author who chose one.
- The preset's single thematic break spelling is overridden. Its `hr` node carries no attributes, so `***`, `---`, `_ _ _`, and every other accepted run parse into the same node and are written back as `***`, rewriting every break in a file on its first save. Leafdown records the run on the node, decided in [issue #319](https://github.com/Azganoth/leafdown/issues/319), read from the slice of the file the node was built from, which is the whole of a break because it holds no children. Indentation stands outside that slice and the whitespace closing the line is trimmed off it, so what is kept is the characters and the spacing between them, tabs included. A break the editor creates carries `***`, which is also what a recorded run gives way to where the line it lands on would be read back as something other than a break. `mdast-util-to-markdown` joins a tight list item's children with a single newline, so a run of hyphens written after a paragraph there underlines it into a setext heading; and a run sharing its item's bullet character stands on the bullet's line, where the two read as one longer break with no list around them. The serializer already moves the bullet off the rule character it was configured with, but that character cannot answer for a run the node carries, so the run is what gives way rather than the bullet.
- The preset's strikethrough delimiter run is not preserved. Its strikethrough mark carries no marker attribute, unlike emphasis and strong, so a single-tilde run parses and serializes back as a double-tilde run. This is left as a known normalization under [Preserve Markdown semantics over exact formatting](#preserve-markdown-semantics-over-exact-formatting) rather than overridden as the autolink form was, because both runs mean the same thing to a GFM reader. Preserving the authored run would require carrying the marker on the mark.
- The preset's strikethrough input rule is overridden. Its `(~{1,2})` backtracks to a one-tilde delimiter run when no two-tilde closing run exists yet, and its content group does not exclude the marker, so typing `~~text~~` created a mark over `~text` on the seventh keystroke and left a surplus tilde on each side that saved as an escaped character. Leafdown carries its own rule, decided in [issue #233](https://github.com/Azganoth/leafdown/issues/233), which excludes the marker from the content and anchors the match at the caret so a run stays literal text until the author closes it. This is the only input rule Leafdown owns; every other preset rule either anchors at the caret or excludes its own marker, and none of them can match a run this way.
- The replacement rule keeps the preset's leading word, colon, and slash guard, so a tilde run that touches one of those does not become a strikethrough as it is typed. `lead~~text~~` and `1~2~3` parse as strikethrough when a file holds them but stay literal text when typed, which is a real disagreement, examined in [issue #282](https://github.com/Azganoth/leafdown/issues/282) and left as it is. The guard does two jobs: it holds the word boundary, and it stops a one-tilde run from opening inside an unclosed two-tilde one. Removing it fixes the first case and breaks `~~a~b~~`, which types as a struck `b` between literal tildes, because an input rule reads only the text before the caret and cannot know another tilde is coming. Separating the two jobs means matching delimiter runs directly rather than through `markRule`, which reads one content group and cannot express the alternation. Leafdown prefers the conservative failure: literal text the author can see and correct, over a construct silently built around the wrong delimiters.
Expand Down
1 change: 1 addition & 0 deletions docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ For editor input and clipboard ownership, see [Architecture](./architecture.md#e
- Raw HTML is written back exactly as authored, including line-break tags.
- A link or image title keeps the quotation marks or parentheses it was authored with. A parenthesized title whose text holds a parenthesis is written with quotation marks instead, because CommonMark reads such a title between matching parentheses. A reference definition writes its own title on the same rule.
- A full, collapsed, or shortcut reference link or image is written back in the form it was authored in, with its definition, rather than as an inline copy of the destination the definition names. Each reference keeps the casing and spacing its label was written with, though references matching one definition still resolve together.
- A thematic break keeps the character run it was authored with, including the spaces or tabs written between its characters. Indentation before the run and whitespace after it are not part of it and are not written. A break the editor inserts is written as `***`. A break whose authored run would be read back as something else where it lands is written as `***` instead: a run of hyphens directly under a paragraph in a tight list item underlines it, and a run sharing its list item's bullet character joins that bullet into one longer break.
- A blank paragraph between blocks survives save and reopen.
- Save output trims trailing blank lines and writes at most one final line ending, controlled by `Insert final newline on save`. Trailing blank paragraphs go with them.

Expand Down
11 changes: 11 additions & 0 deletions src/features/editor/commands/inserting/blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ describe("editor block insertion commands", () => {
}
});

// A break read from a file writes the run it was authored with, so one the editor creates needs a
// run of its own. Three asterisks are the spelling no position reads as anything but a break.
it("writes an inserted horizontal rule with the default marker", async () => {
const mounted = await mountEditor(TWO_PARAGRAPH_MARKDOWN);

setTextSelection(mounted.view, 3);

expect(insertHorizontalRule(mounted.view)).toBe(true);
expect(mounted.getMarkdown()).toContain("\n***\n");
});

it("inserts after the selected block range without replacing selected content", async () => {
const mounted = await mountEditor(TWO_PARAGRAPH_MARKDOWN);

Expand Down
29 changes: 29 additions & 0 deletions src/features/editor/plugins/thematicBreak.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { MarkdownNode } from "@milkdown/kit/transformer";
import { $remark } from "@milkdown/kit/utils";

import {
findThematicBreakMarker,
THEMATIC_BREAK_MARKDOWN_TYPE,
THEMATIC_BREAK_MARKER_ATTRIBUTE_NAME,
} from "../utils/thematicBreakMarkdown";

// A break carries no text of its own, so the characters it was written with survive only in the
// slice of the file it was built from. A node the parser gave no position keeps the default.
const markAuthoredMarkers = (node: MarkdownNode, source: string) => {
for (const child of node.children ?? []) {
const start = child.position?.start.offset;
const end = child.position?.end.offset;

if (child.type === THEMATIC_BREAK_MARKDOWN_TYPE && start !== undefined && end !== undefined) {
(child as Record<string, unknown>)[THEMATIC_BREAK_MARKER_ATTRIBUTE_NAME] =
findThematicBreakMarker(source.slice(start, end));
}

markAuthoredMarkers(child, source);
}
};

export const createLeafdownThematicBreakPlugin = () =>
$remark("leafdownThematicBreak", () => () => (tree, file) => {
markAuthoredMarkers(tree as MarkdownNode, String(file));
});
74 changes: 71 additions & 3 deletions src/features/editor/tests/markdownCompatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ Footnote[^1]
[^1]: Footnote text`;

// Milkdown serializer defaults normalize several source markers:
// unordered/task markers become `*`, thematic breaks become `***`, and
// serialized output includes a final newline.
// unordered/task markers become `*`, and serialized output includes a
// final newline.
const supportedMarkdownExpected = `# Heading

Paragraph with *emphasis*, **strong**, \`code\`, ~~strike~~, https://example.com, and [link](docs/readme.md).
Expand All @@ -68,7 +68,7 @@ Paragraph with *emphasis*, **strong**, \`code\`, ~~strike~~, https://example.com
const value = 1;
\`\`\`

***
---

![Alt](image.png)

Expand Down Expand Up @@ -887,6 +887,74 @@ describe("Raw link destination ampersands", () => {
});
});

describe("Thematic break form", () => {
const setThematicBreakMarker = (mounted: MountedMilkdownEditor, marker: string) => {
const position = getEditorNodePosition(mounted, "hr");
const { attrs } = mounted.view.state.doc.nodeAt(position) ?? {};

mounted.view.dispatch(
mounted.view.state.tr.setNodeMarkup(position, undefined, { ...attrs, marker }),
);
};

it.each([
"***",
"---",
"___",
"* * *",
"- - -",
"_ _ _",
"----------",
// A tab separates the characters the way a space does. The run is read off the file rather
// than off the tab stops the parser expands it to, so the tabs stay where they were written.
"*\t*\t*",
])("writes the break in %j as it was authored", async (source) => {
const mounted = await mountEditor(`${source}\n`);

expect(mounted.getMarkdown()).toBe(`${source}\n`);
});

// Indentation before the run and whitespace after it stand outside the characters the break is
// spelled with, and Leafdown writes neither.
it.each([
{ saved: "---", source: " ---" },
{ saved: "---", source: "--- " },
{ saved: "_ _ _", source: " _ _ _\t" },
])("writes $source as the run it spells", async ({ saved, source }) => {
const mounted = await mountEditor(`${source}\n`);

expect(mounted.getMarkdown()).toBe(`${saved}\n`);
});

it.each([
{ name: "a blockquote", source: "> Quote\n>\n> ---" },
{ name: "a list item", source: "* Item\n\n ---" },
{ name: "a tight list item", source: "* Item\n ***" },
])("keeps the authored run inside $name", async ({ source }) => {
const mounted = await mountEditor(`${source}\n`);

expect(mounted.getMarkdown()).toBe(`${source}\n`);
});

// A bullet and a run spelled with the same character stand on one line and are read back as one
// longer break with no list around it, so the run gives way to the default.
it("writes a break opening a list item in a run its bullet cannot join", async () => {
const mounted = await mountEditor("* ---\n Paragraph\n");

expect(mounted.getMarkdown()).toBe("- ***\n Paragraph\n");
});

// A tight list item joins its children with a single newline, so a run of hyphens written after
// a paragraph there underlines it and the file is read back holding a heading.
it("writes a hyphen break following a paragraph in a tight list item as the default", async () => {
const mounted = await mountEditor("* Paragraph\n ***\n");

setThematicBreakMarker(mounted, "---");

expect(mounted.getMarkdown()).toBe("* Paragraph\n ***\n");
});
});

describe("Link and image title form", () => {
it.each([
'[Double quote](garden.md "Garden")',
Expand Down
19 changes: 19 additions & 0 deletions src/features/editor/tests/nativeClipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,25 @@ describe("native editor clipboard events", () => {
expect(pasted.getMarkdown()).toBe(`${source}\n`);
});

it.each(["---", "_ _ _"])(
"carries the thematic break %j through a copy and a paste",
async (source) => {
const copied = await mountEditor(source);
const clipboardData = createClipboardData();

setTextSelection(copied.view, 0, copied.view.state.doc.content.size);
dispatchClipboardEvent(copied.view.dom, "copy", clipboardData);

const pasted = await mountEditor("");

dispatchClipboardEvent(pasted.view.dom, "paste", {
[TEXT_HTML_MIME_TYPE]: clipboardData.getData(TEXT_HTML_MIME_TYPE),
});

expect(pasted.getMarkdown()).toBe(`${source}\n`);
},
);

it("preserves semantic HTML-only content outside source projection", async () => {
const mounted = await mountEditor("");

Expand Down
Loading