From f18d07268f55162f9914ad875b82f8c6e4c1cbc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Thu, 3 Sep 2026 01:46:45 -0300 Subject: [PATCH] Keep the form a reference definition was written in --- CHANGELOG.md | 1 + docs/specification.md | 1 + .../editor/components/milkdown-editor.css | 4 +- src/features/editor/plugins/referenceLink.ts | 29 +++- .../tests/markdownCompatibility.test.ts | 84 ++++++++++ .../editor/utils/markdownDestination.ts | 93 +++++++++-- src/features/editor/utils/markdownTitle.ts | 19 +-- .../editor/utils/referenceLinkMarkdown.ts | 154 ++++++++++++++++-- 8 files changed, 345 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c96abb..aaeb739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `¬arealentity;`, follows it, so text such as `https://example.com¬arealentity;` 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 `\` or `<https://example.com>` 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]: ` 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. diff --git a/docs/specification.md b/docs/specification.md index 27b3359..3ee5dd7 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -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. diff --git a/src/features/editor/components/milkdown-editor.css b/src/features/editor/components/milkdown-editor.css index edaf106..0f80e63 100644 --- a/src/features/editor/components/milkdown-editor.css +++ b/src/features/editor/components/milkdown-editor.css @@ -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; diff --git a/src/features/editor/plugins/referenceLink.ts b/src/features/editor/plugins/referenceLink.ts index 0fc9b79..d59ab82 100644 --- a/src/features/editor/plugins/referenceLink.ts +++ b/src/features/editor/plugins/referenceLink.ts @@ -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( @@ -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(); 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; + + 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. diff --git a/src/features/editor/tests/markdownCompatibility.test.ts b/src/features/editor/tests/markdownCompatibility.test.ts index c065a89..022d1df 100644 --- a/src/features/editor/tests/markdownCompatibility.test.ts +++ b/src/features/editor/tests/markdownCompatibility.test.ts @@ -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]: ", + "[a]: field-report.md", + // A destination the bare form cannot spell. + "[a]: ", + "[a]: <>", + // A destination the bare form could spell keeps the brackets it was authored between. + "[a]: ", + "[a]: ", + // The brackets close on the first unescaped `>`, so both of them keep their backslash. + String.raw`[a]: report.md>`, + String.raw`[a]: `, + // 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]: \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]: \n> 'T'", + "> > [a]: \n> > 'T'", + "- [a]: \n 'T'", + "- [a]:\n ", + ])("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 report.md\n"); + + expect(mounted.getMarkdown()).toBe("[a]: \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]: \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]: \n> 'T'\n", + source: "> [a]: \n'T'\n", + }, + { + name: "a blockquote marker written without its space", + saved: "> [a]: \n> 'T'\n", + source: ">[a]: \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([ "© © © Æ", diff --git a/src/features/editor/utils/markdownDestination.ts b/src/features/editor/utils/markdownDestination.ts index 50dc612..27f97d4 100644 --- a/src/features/editor/utils/markdownDestination.ts +++ b/src/features/editor/utils/markdownDestination.ts @@ -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["handlers"] @@ -160,21 +172,82 @@ export const serializeMarkdownLink: NonNullable { 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[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; + +const withConstruct = ( + state: StringifyState, + construct: Parameters[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 = ( - ...[node, parent, state, info]: Parameters + ...[node, , state]: Parameters ) => { + 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(); } }; diff --git a/src/features/editor/utils/markdownTitle.ts b/src/features/editor/utils/markdownTitle.ts index 65e2612..0ef9cca 100644 --- a/src/features/editor/utils/markdownTitle.ts +++ b/src/features/editor/utils/markdownTitle.ts @@ -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; } @@ -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; @@ -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; } diff --git a/src/features/editor/utils/referenceLinkMarkdown.ts b/src/features/editor/utils/referenceLinkMarkdown.ts index 80c1641..9036184 100644 --- a/src/features/editor/utils/referenceLinkMarkdown.ts +++ b/src/features/editor/utils/referenceLinkMarkdown.ts @@ -23,13 +23,27 @@ export const IMAGE_REFERENCE_MARKDOWN_TYPE = "imageReference"; export const REFERENCE_TYPE_ATTRIBUTE_NAME = "referenceType"; export const REFERENCE_LABEL_ATTRIBUTE_NAME = "referenceLabel"; +// A definition carries the form its destination was written in and the whitespace runs its two +// optional line endings sit in, which is everything a definition holds that its label, destination, +// title, and title marker do not already answer for. +export const DESTINATION_MARKER_ATTRIBUTE_NAME = "destinationMarker"; +export const DESTINATION_SEPARATOR_ATTRIBUTE_NAME = "destinationSeparator"; +export const TITLE_SEPARATOR_ATTRIBUTE_NAME = "titleSeparator"; + const DEFINITION_DOM_TYPE = "definition"; const LABEL_WHITESPACE_PATTERN = /[\t\n\r ]+/gu; const REFERENCE_TYPES: readonly unknown[] = ["collapsed", "full", "shortcut"]; export type ReferenceType = "collapsed" | "full" | "shortcut"; +export type DestinationMarker = "" | "<"; + +export interface AuthoredDefinitionForm { + destinationMarker: DestinationMarker; + destinationSeparator: string; + titleSeparator: string; +} -export interface DefinitionAttrs { +export interface DefinitionAttrs extends AuthoredDefinitionForm { label: string; title: string; titleMarker: TitleMarker; @@ -96,9 +110,30 @@ const omitReferenceAttributes = (attributes: Record) => { const CONTROL_CHARACTER_MAX_CODE = 0x20; const DELETE_CHARACTER_CODE = 0x7f; -// The definition handler writes an angle-bracket destination for an empty destination and for one -// holding a control character or whitespace, and a raw one otherwise, so the rendered block reads -// as the line the file is written with. +const ANGLE_DESTINATION_MARKER = "<"; +const BARE_DESTINATION_MARKER = ""; +// The form a definition is written in when it has none of its own: one the editor created, and one +// whose authored form cannot be recovered. +const DEFAULT_DEFINITION_SEPARATOR = " "; +const DEFAULT_DEFINITION_FORM: AuthoredDefinitionForm = { + destinationMarker: BARE_DESTINATION_MARKER, + destinationSeparator: DEFAULT_DEFINITION_SEPARATOR, + titleSeparator: DEFAULT_DEFINITION_SEPARATOR, +}; + +// CommonMark lets each of a definition's two whitespace runs carry up to one line ending, which is +// what a definition written across two or three lines spends. +const DEFINITION_SEPARATOR_PATTERN = /^[\t ]*\n?[\t ]*$/u; +// The slice a definition was built from opens at its label's `[`. A label ends at its first +// unescaped `]`, and a destination either stands between angle brackets or runs to the whitespace +// after it, so one pass over the head names the first run and the form the destination was written +// in, and what remains opens with the second run wherever a title follows. +const DEFINITION_HEAD_PATTERN = + /^\[(?:[^\\\]]|\\[\S\s])*\]:([\t ]*\n?[\t ]*)(<(?:[^\\<>]|\\[\S\s])*>|[^\s<]\S*)?/u; +const DEFINITION_TITLE_SEPARATOR_PATTERN = /^([\t ]*\n?[\t ]*)["'(]/u; + +// A destination the bare form cannot spell: an empty one, and one holding whitespace or a control +// character, which is the branch `mdast-util-to-markdown` takes for the same reason. const needsAngleDestination = (url: string) => { for (let index = 0; index < url.length; index += 1) { const code = url.charCodeAt(index); @@ -111,27 +146,100 @@ const needsAngleDestination = (url: string) => { return url === ""; }; +// A destination stands between angle brackets where it was authored between them, and wherever the +// bare form would not be read back as the destination the document holds. The recorded form only +// adds the brackets, so a destination that needs them keeps them however it was authored. +export const usesAngleDestination = (url: string, marker: DestinationMarker) => + marker === ANGLE_DESTINATION_MARKER || needsAngleDestination(url); + +const ANGLE_DESTINATION_ESCAPE_PATTERN = /[<>]/gu; + +export const readDestinationMarker = (source: object): DestinationMarker => + (source as { destinationMarker?: unknown }).destinationMarker === ANGLE_DESTINATION_MARKER + ? ANGLE_DESTINATION_MARKER + : BARE_DESTINATION_MARKER; + +const readSeparator = (source: object, name: string) => { + const run = (source as Record)[name]; + + return typeof run === "string" && DEFINITION_SEPARATOR_PATTERN.test(run) + ? run + : DEFAULT_DEFINITION_SEPARATOR; +}; + +export const readDestinationSeparator = (source: object) => + readSeparator(source, DESTINATION_SEPARATOR_ATTRIBUTE_NAME); + +// A title is separated from the destination by whitespace, so an empty run is one no definition was +// read with and the default stands in its place. +export const readTitleSeparator = (source: object) => + readSeparator(source, TITLE_SEPARATOR_ATTRIBUTE_NAME) || DEFAULT_DEFINITION_SEPARATOR; + +// A definition inside a container is sliced with whatever that container wrote before each of its +// continuation lines, and the serializer writes that prefix back around every line it makes, so the +// run a continuation line opens with is read past the columns the container owns rather than +// through them. A prefix the column count does not describe leaves a run the patterns reject, which +// is the default form rather than a guess. +const withoutContainerPrefix = (raw: string, width: number) => + width > 0 + ? raw + .split("\n") + .map((line, index) => (index === 0 ? line : line.slice(width))) + .join("\n") + : raw; + +export const findDefinitionForm = (raw: string, containerWidth = 0): AuthoredDefinitionForm => { + const source = withoutContainerPrefix(raw, containerWidth); + const head = DEFINITION_HEAD_PATTERN.exec(source); + + if (!head) { + return DEFAULT_DEFINITION_FORM; + } + + const [matched, destinationSeparator, destination = ""] = head; + const title = DEFINITION_TITLE_SEPARATOR_PATTERN.exec(source.slice(matched.length)); + + return { + destinationMarker: destination.startsWith(ANGLE_DESTINATION_MARKER) + ? ANGLE_DESTINATION_MARKER + : BARE_DESTINATION_MARKER, + destinationSeparator, + titleSeparator: title?.[1] ?? DEFAULT_DEFINITION_SEPARATOR, + }; +}; + +// The angle brackets close on the first unescaped `>`, so a destination spelling one of them gives +// up a backslash here as it does in the file the serializer writes. export const serializeDefinitionMarkdown = ({ + destinationMarker, + destinationSeparator, label, title, titleMarker, + titleSeparator, url, }: DefinitionAttrs) => { - const destination = needsAngleDestination(url) ? `<${url}>` : url; + const destination = usesAngleDestination(url, destinationMarker) + ? `<${url.replace(ANGLE_DESTINATION_ESCAPE_PATTERN, String.raw`\$&`)}>` + : url; + const head = `[${label}]:${destinationSeparator}${destination}`; if (!title) { - return `[${label}]: ${destination}`; + return head; } const [opening, closing] = TITLE_MARKER_PAIRS[chooseTitleMarker(title, titleMarker)]; - return `[${label}]: ${destination} ${opening}${title}${closing}`; + return `${head}${titleSeparator}${opening}${title}${closing}`; }; export const readDefinitionAttrs = (attrs: Record): DefinitionAttrs => ({ + destinationMarker: readDestinationMarker(attrs), + destinationSeparator: readDestinationSeparator(attrs), label: readString(attrs, "label"), title: readString(attrs, "title"), titleMarker: readTitleMarker({ titleMarker: attrs[TITLE_MARKER_ATTRIBUTE_NAME] }), + titleSeparator: readTitleSeparator(attrs), url: readString(attrs, "url"), }); @@ -150,6 +258,18 @@ export const definitionNodeSchema: NodeSchema = { url: { default: "", validate: "string" }, title: { default: "", validate: "string" }, [TITLE_MARKER_ATTRIBUTE_NAME]: { default: '"', validate: "string" }, + [DESTINATION_MARKER_ATTRIBUTE_NAME]: { + default: DEFAULT_DEFINITION_FORM.destinationMarker, + validate: "string", + }, + [DESTINATION_SEPARATOR_ATTRIBUTE_NAME]: { + default: DEFAULT_DEFINITION_FORM.destinationSeparator, + validate: "string", + }, + [TITLE_SEPARATOR_ATTRIBUTE_NAME]: { + default: DEFAULT_DEFINITION_FORM.titleSeparator, + validate: "string", + }, }, parseDOM: [ { @@ -157,12 +277,17 @@ export const definitionNodeSchema: NodeSchema = { getAttrs: (dom) => { const element = dom; - return { + return readDefinitionAttrs({ label: element.getAttribute("data-label") ?? "", url: element.getAttribute("data-url") ?? "", title: element.getAttribute("data-title") ?? "", - [TITLE_MARKER_ATTRIBUTE_NAME]: element.getAttribute("data-title-marker") ?? '"', - }; + [TITLE_MARKER_ATTRIBUTE_NAME]: element.getAttribute("data-title-marker"), + [DESTINATION_MARKER_ATTRIBUTE_NAME]: element.getAttribute("data-destination-marker"), + [DESTINATION_SEPARATOR_ATTRIBUTE_NAME]: element.getAttribute( + "data-destination-separator", + ), + [TITLE_SEPARATOR_ATTRIBUTE_NAME]: element.getAttribute("data-title-separator"), + }); }, }, ], @@ -178,6 +303,9 @@ export const definitionNodeSchema: NodeSchema = { "data-url": attrs.url, "data-title": attrs.title, "data-title-marker": attrs.titleMarker, + "data-destination-marker": attrs.destinationMarker, + "data-destination-separator": attrs.destinationSeparator, + "data-title-separator": attrs.titleSeparator, }, serializeDefinitionMarkdown(attrs), ]; @@ -190,6 +318,9 @@ export const definitionNodeSchema: NodeSchema = { url: readString(node, "url"), title: readString(node, "title"), [TITLE_MARKER_ATTRIBUTE_NAME]: readTitleMarker(node), + [DESTINATION_MARKER_ATTRIBUTE_NAME]: readDestinationMarker(node), + [DESTINATION_SEPARATOR_ATTRIBUTE_NAME]: readDestinationSeparator(node), + [TITLE_SEPARATOR_ATTRIBUTE_NAME]: readTitleSeparator(node), }); }, }, @@ -202,6 +333,9 @@ export const definitionNodeSchema: NodeSchema = { url: node.attrs.url, title: node.attrs.title || null, [TITLE_MARKER_ATTRIBUTE_NAME]: node.attrs[TITLE_MARKER_ATTRIBUTE_NAME], + [DESTINATION_MARKER_ATTRIBUTE_NAME]: node.attrs[DESTINATION_MARKER_ATTRIBUTE_NAME], + [DESTINATION_SEPARATOR_ATTRIBUTE_NAME]: node.attrs[DESTINATION_SEPARATOR_ATTRIBUTE_NAME], + [TITLE_SEPARATOR_ATTRIBUTE_NAME]: node.attrs[TITLE_SEPARATOR_ATTRIBUTE_NAME], }); }, },