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 @@ -45,6 +45,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0
- 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 the form a reference definition was written in, so `[field report]: <field-report.md>` keeps the angle brackets around its destination and a definition whose title was written on the line below it keeps that line, instead of both being rewritten onto one bare line on the first save. The spaces or tabs written after the colon and before the title are kept as well. A destination that cannot be read back without angle brackets still gets them, and a definition the editor creates is written on one line.
- 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/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,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 reference definition keeps the form its destination was authored in, bare or between angle brackets, and the spaces or tabs written after its colon and before its title, including the line ending either run may spend. A definition written across two or three lines is written back across them, and is shown in the document as the lines it is written as. A destination is written between angle brackets whatever form was recorded wherever the bare form would not be read back as the same destination: an empty destination, and one holding whitespace or a control character. A definition the editor creates is written on one line, with one space after the colon and one before its title.
- A heading keeps the form it was authored in. An ATX heading keeps the closing sequence it was written with, at the length the file wrote rather than the length of its own opening sequence, and one written without a closing sequence does not gain one; the spaces or tabs between the opening sequence and the content are kept as well. A setext heading is written as a setext heading, underlined at the length the file wrote and by the character its level reads back as. Indentation before the opening sequence and whitespace closing either line are not part of the form and are not written. A heading the editor creates, and one moved to a level a setext underline cannot carry, is written as ATX with one space before its content and nothing closing it. A setext heading is written as ATX instead wherever its own form would not be read back: where it follows a paragraph in a tight list item, whose single-newline join would leave its content read as more of that paragraph and its underline covering both.
- 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 table keeps the outer pipes its rows were authored with, whether both, one, or neither. A table the editor creates is written with both. A table whose rows disagree keeps the pipe the rows that carry one were written with. A table is written with both pipes instead wherever its own form would not be read back: when the first or last cell of any row is blank, or when its first column is one character wide and carries no alignment marker. Cell padding, delimiter row width, and the padding an alignment marker redistributes are normalized rather than kept, because no part of the table owns a width computed across a column.
Expand Down
4 changes: 3 additions & 1 deletion src/features/editor/components/milkdown-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,10 @@
@apply mt-10 border-t border-border pt-4 text-sm text-muted-foreground;
}

/* The block renders the source the file is written with, whose line ending and indentation are
part of that source. */
.leafdown-definition {
@apply my-4 rounded-sm border border-border/60 bg-muted px-2 py-1 font-mono text-[0.9em] text-muted-foreground;
@apply my-4 rounded-sm border border-border/60 bg-muted px-2 py-1 font-mono text-[0.9em] whitespace-pre-wrap text-muted-foreground;

&.ProseMirror-selectednode {
@apply border-primary/40 ring-2 ring-primary/25;
Expand Down
29 changes: 20 additions & 9 deletions src/features/editor/plugins/referenceLink.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import type { MarkdownNode } from "@milkdown/kit/transformer";
import { $nodeSchema, $remark } from "@milkdown/kit/utils";

import { findTitleMarker } from "../utils/markdownTitle";
import { findTitleMarker, TITLE_MARKER_ATTRIBUTE_NAME } from "../utils/markdownTitle";
import {
DEFINITION_MARKDOWN_TYPE,
DEFINITION_NODE_NAME,
definitionNodeSchema,
DESTINATION_MARKER_ATTRIBUTE_NAME,
DESTINATION_SEPARATOR_ATTRIBUTE_NAME,
findDefinitionForm,
IMAGE_REFERENCE_MARKDOWN_TYPE,
LINK_REFERENCE_MARKDOWN_TYPE,
normalizeReferenceLabel,
TITLE_SEPARATOR_ATTRIBUTE_NAME,
} from "../utils/referenceLinkMarkdown";

export const leafdownDefinitionSchema = $nodeSchema(
Expand All @@ -33,21 +37,28 @@ const readIdentifier = (node: MarkdownNode) => {
};

// A definition ends at its title rather than at a `)`, so the marker is the last character of the
// slice it was built from. A definition whose title continues on the following line is one node
// covering both, and that slice still ends at the marker.
// slice it was built from. A definition whose destination or title continues on the following line
// is one node covering both, and that slice still ends at the marker.
const readDefinitions = (tree: MarkdownNode, source: string) => {
const definitions = new Map<string, ResolvedDefinition>();
const visit = (node: MarkdownNode) => {
if (node.type === DEFINITION_MARKDOWN_TYPE) {
const identifier = readIdentifier(node);
const start = node.position?.start.offset;
const start = node.position?.start;
const end = node.position?.end.offset;

if (node.title && start !== undefined && end !== undefined) {
(node as { titleMarker?: string }).titleMarker = findTitleMarker(
source.slice(start, end),
"",
);
if (start?.offset !== undefined && end !== undefined) {
const raw = source.slice(start.offset, end);
const form = findDefinitionForm(raw, start.column - 1);
const authored = node as Record<string, unknown>;

authored[DESTINATION_MARKER_ATTRIBUTE_NAME] = form.destinationMarker;
authored[DESTINATION_SEPARATOR_ATTRIBUTE_NAME] = form.destinationSeparator;
authored[TITLE_SEPARATOR_ATTRIBUTE_NAME] = form.titleSeparator;

if (node.title) {
authored[TITLE_MARKER_ATTRIBUTE_NAME] = findTitleMarker(raw, "");
}
}

// CommonMark resolves a label against the first definition that claims it.
Expand Down
84 changes: 84 additions & 0 deletions src/features/editor/tests/markdownCompatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1723,6 +1723,90 @@ describe("Reference link and image form", () => {
});
});

describe("Definition form", () => {
// The reopened document is asserted beside the bytes, because a destination written in a form the
// next read does not answer for costs the destination rather than the form, and a definition
// written across two lines is one construct whichever way its bytes are split.
it.each([
"[a]: <field-report.md>",
"[a]: field-report.md",
// A destination the bare form cannot spell.
"[a]: <with space.md>",
"[a]: <>",
// A destination the bare form could spell keeps the brackets it was authored between.
"[a]: <field(report).md>",
"[a]: <field*report.md>",
// The brackets close on the first unescaped `>`, so both of them keep their backslash.
String.raw`[a]: <field\>report.md>`,
String.raw`[a]: <field\<report.md>`,
// The colon is followed by whichever run the file wrote, including none at all.
"[a]:field-report.md",
"[a]: field-report.md 'T'",
"[a]: field-report.md\t'T'",
"[a]: field-report.md 'T'",
// A title on its own line, indented or not, and a destination on one of its own.
"[a]: <field-report.md>\n 'Field report title'",
'[a]: field-report.md\n"T"',
"[a]:\n /destination",
"[a]:\n /destination\n 'T'",
// A container writes its own prefix back around every line it makes, so the run a continuation
// line opens with is the one past that prefix.
"> [a]: <field-report.md>\n> 'T'",
"> > [a]: <field-report.md>\n> > 'T'",
"- [a]: <field-report.md>\n 'T'",
"- [a]:\n <field-report.md>",
])("writes the definition in %j as it was authored", async (source) => {
const mounted = await mountEditor(`${source}\n`);
const saved = mounted.getMarkdown();

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

const reopened = await mountEditor(saved);

expect(reopened.view.state.doc.toJSON()).toEqual(mounted.view.state.doc.toJSON());
});

// A destination is written between angle brackets wherever the bare form would not be read back
// as the destination the document holds, whatever form the file recorded for it. A character
// reference is decoded into the destination, so a bare one can spell a destination that needs
// them.
it("writes the brackets a destination needs over the form it was authored in", async () => {
const mounted = await mountEditor("[a]: field&#32;report.md\n");

expect(mounted.getMarkdown()).toBe("[a]: <field report.md>\n");
});

// The block renders the source the file is written with, which is the whole of it wherever that
// source spans more than one line.
it("renders a definition written across two lines as both of them", async () => {
const source = "[field report]: <field-report.md>\n 'Field report title'";
const mounted = await mountEditor(`${source}\n`);

expect(mounted.root.querySelector('[data-type="definition"]')?.textContent).toBe(source);
});

// A blockquote's own form and a lazy continuation are settled elsewhere, so the layout is read
// past whichever prefix the definition's column names and the line the container writes back is
// the one it would have written anyway.
it.each([
{
name: "a lazy continuation",
saved: "> [a]: <b.md>\n> 'T'\n",
source: "> [a]: <b.md>\n'T'\n",
},
{
name: "a blockquote marker written without its space",
saved: "> [a]: <b.md>\n> 'T'\n",
source: ">[a]: <b.md>\n> 'T'\n",
},
])("keeps the layout of a definition written with $name", async ({ saved, source }) => {
const mounted = await mountEditor(source);

expect(mounted.getMarkdown()).toBe(saved);
expect((await mountEditor(saved)).getMarkdown()).toBe(saved);
});
});

describe("Character references", () => {
it.each([
"&copy; &#169; &#xA9; &AElig;",
Expand Down
93 changes: 83 additions & 10 deletions src/features/editor/utils/markdownDestination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,19 @@ import {
findCharacterReferenceSources,
readAuthoredDescription,
} from "./characterReferenceMarkdown";
import { withAuthoredTitle } from "./markdownTitle";
import {
chooseTitleMarker,
readTitleMarker,
TITLE_MARKER_PAIRS,
type TitleMarker,
withAuthoredTitle,
} from "./markdownTitle";
import {
readDestinationMarker,
readDestinationSeparator,
readTitleSeparator,
usesAngleDestination,
} from "./referenceLinkMarkdown";

type RemarkStringifyHandlers = NonNullable<
ReturnType<typeof remarkStringifyOptionsCtx._typeInfo>["handlers"]
Expand Down Expand Up @@ -160,21 +172,82 @@ export const serializeMarkdownLink: NonNullable<RemarkStringifyHandlers["link"]>
{ peek: defaultHandlers.link.peek },
);

// A definition writes its destination outside any tail, so nothing in it needs a parenthesis
// escaped, and its title ends the line rather than sitting before a `)`.
type DefinitionNode = Parameters<typeof defaultHandlers.definition>[0];

// The construct a title is written inside decides which quote the run gives up a backslash for. A
// parenthesized title is written inside none of them, because the marker it is held between is only
// ever chosen for a title spelling no parenthesis, and every other pattern is out of scope for a
// definition already.
const TITLE_CONSTRUCTS = {
'"': "titleQuote",
"'": "titleApostrophe",
"(": null,
} as const satisfies Record<TitleMarker, "titleApostrophe" | "titleQuote" | null>;

const withConstruct = (
state: StringifyState,
construct: Parameters<StringifyState["enter"]>[0],
write: () => string,
) => {
const exit = state.enter(construct);

try {
return write();
} finally {
exit();
}
};

const writeDefinitionLabel = (node: DefinitionNode, state: StringifyState) =>
withConstruct(state, "label", () =>
state.safe(state.associationId(node), { before: "[", after: "]" }),
);

const writeDefinitionDestination = (node: DefinitionNode, state: StringifyState, after: string) => {
const url = node.url;

return usesAngleDestination(url, readDestinationMarker(node))
? withConstruct(
state,
"destinationLiteral",
() => `<${state.safe(url, { before: "<", after: ">" })}>`,
)
: withConstruct(state, "destinationRaw", () => state.safe(url, { before: " ", after }));
};

const writeDefinitionTitle = (node: DefinitionNode, state: StringifyState, title: string) => {
const marker = chooseTitleMarker(title, readTitleMarker(node));
const [opening, closing] = TITLE_MARKER_PAIRS[marker];
const construct = TITLE_CONSTRUCTS[marker];
const write = () =>
`${opening}${state.safe(title, { before: opening, after: closing })}${closing}`;

return construct === null ? write() : withConstruct(state, construct, write);
};

// The handler owns three of the four choices a definition spells — the form its destination is
// written in, the marker its title is held between, and the whitespace runs between the three — so
// it writes the line itself rather than steering the default one, which reads each of them off the
// document instead. Every run still reaches the file through `state.safe`, under the construct the
// default handler names for it, so what a destination or a title escapes is unchanged.
export const serializeMarkdownDefinition: NonNullable<RemarkStringifyHandlers["definition"]> = (
...[node, parent, state, info]: Parameters<typeof defaultHandlers.definition>
...[node, , state]: Parameters<typeof defaultHandlers.definition>
) => {
const title = node.title;
// What the destination is written against: the run before the title, or the line ending that
// closes a definition carrying none.
const trailing = title ? readTitleSeparator(node) : "\n";
const restore = scopeDestination(state, node.url, false);
const exit = state.enter("definition");

try {
return withAuthoredTitle(
node,
state.options,
() => defaultHandlers.definition(node, parent, state, info),
"",
);
const label = writeDefinitionLabel(node, state);
const destination = writeDefinitionDestination(node, state, trailing);
const head = `[${label}]:${readDestinationSeparator(node)}${destination}`;

return title ? `${head}${trailing}${writeDefinitionTitle(node, state, title)}` : head;
} finally {
exit();
restore();
}
};
Expand Down
19 changes: 9 additions & 10 deletions src/features/editor/utils/markdownTitle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,14 @@ const unescapeQuote = (value: string, quote: string) =>
// `mdast-util-to-markdown` writes a title with `options.quote`, whose `checkQuote` throws for
// anything but the two quotes, so the parenthesized form is reached by swapping the pair the
// handler wrote. The run between the markers is the text the handler escaped for a title, which a
// parenthesized title carries unchanged apart from that marker's own escape. `trailing` is what the
// handler writes after the closing marker, which locates that marker from the end.
const withParenthesizedTitle = (value: string, quote: string, trailing: string) => {
const closing = value.length - trailing.length - 1;
// parenthesized title carries unchanged apart from that marker's own escape. A link and an image
// both close with `)`, which locates that marker from the end.
const TITLE_TRAILING = ")";

if (!value.endsWith(trailing) || value[closing] !== quote) {
const withParenthesizedTitle = (value: string, quote: string) => {
const closing = value.length - TITLE_TRAILING.length - 1;

if (!value.endsWith(TITLE_TRAILING) || value[closing] !== quote) {
return value;
}

Expand All @@ -136,14 +138,11 @@ interface TitleOptions {
}

// Writes a node's title in the form it was authored in, by putting the marker the handler reads
// into its options and rewriting the pair it wrote where that marker is a parenthesis. `trailing`
// is what the handler writes after the title's closing marker: a link and an image close with `)`,
// while a definition ends at the title.
// into its options and rewriting the pair it wrote where that marker is a parenthesis.
export const withAuthoredTitle = (
node: { title?: string | null },
options: TitleOptions,
write: () => string,
trailing = ")",
) => {
const title = node.title;

Expand All @@ -160,7 +159,7 @@ export const withAuthoredTitle = (
try {
const value = write();

return marker === "(" ? withParenthesizedTitle(value, quote, trailing) : value;
return marker === "(" ? withParenthesizedTitle(value, quote) : value;
} finally {
options.quote = enclosing;
}
Expand Down
Loading