From 58352d4727ea4cf7049abc9583bf5f143c4788bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Tue, 1 Sep 2026 22:59:29 -0300 Subject: [PATCH] Write a list with the marker, numbers, and spacing it was authored with --- CHANGELOG.md | 1 + docs/specification.md | 1 + .../editor/plugins/commandKeymap.test.ts | 2 +- src/features/editor/plugins/listForm.ts | 88 ++++ .../editor/plugins/taskListCheckbox.test.ts | 4 +- .../editor/tests/keyboardBehavior.test.ts | 4 +- .../tests/markdownCompatibility.test.ts | 187 ++++++-- .../editor/utils/createMilkdownEditor.ts | 84 +--- src/features/editor/utils/listMarkdown.ts | 449 ++++++++++++++++++ src/features/editor/utils/markdownJoins.ts | 38 ++ .../editor/utils/thematicBreakMarkdown.ts | 31 +- 11 files changed, 756 insertions(+), 133 deletions(-) create mode 100644 src/features/editor/plugins/listForm.ts create mode 100644 src/features/editor/utils/listMarkdown.ts create mode 100644 src/features/editor/utils/markdownJoins.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d4fd482..0a11d6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Fixed +- Keep the bullet a list was written with, so a file authored with `-` no longer comes back with its lists rewritten into a mixture of `*` and `-` that follows the order the lists appear in, and a `+` list stays a `+` list. An ordered list keeps its own delimiter and the numbers its items were written with, so `3.` followed by `8.` is no longer renumbered to `3.` and `4.`, and `4)` no longer becomes `4.`. The spaces between a marker and its content are kept too, along with an item whose content was written on the line after its marker. A list made in the editor is still written with `*`, or `.` when it is ordered, and two lists that meet with the same marker are still written apart, because Markdown reads them back as one list. - Show a table written with a header row and no body rows as the table it is, instead of adding an empty row beneath it that holds no cells and takes no text. - Keep the outer pipes a table's rows were written with, so a table authored without them stays that way instead of gaining one on both sides of every row on the first save. A table inserted from the editor is still written with both, and so is one whose own form would no longer be read back as the table it is. - Keep the Markdown an image's description was written with, so `![Alt with *emphasis*](leaf.svg)` keeps its emphasis and `![Outer ![inner](inner.svg)](leaf.svg)` keeps the image inside it, instead of flattening the description to its text on open and losing the inner image's destination from the file on the first save. The image is still named by the text its description spells, and a description edited in the raw image Markdown is written as the text typed there. diff --git a/docs/specification.md b/docs/specification.md index 3289025..ef3aad5 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -258,6 +258,7 @@ For editor input and clipboard ownership, see [Architecture](./architecture.md#e - 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 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. +- A list keeps the marker it was authored with: `-`, `+`, or `*` for a bullet list, and `.` or `)` for an ordered one. An ordered list keeps the numbers its items were written with rather than renumbering them from its start, except for its first item, which is written with the list's own start because that is the number the file is read back with. Each item keeps the one to four spaces written between its marker and its content, and an item whose content was written on the line after its marker keeps that line. A list the editor creates is written with `*`, or `.` when it is ordered, and an item it creates with one space. Two adjacent lists are never written with the same marker, because CommonMark reads them back as one list: a bullet list moves to `*`, or to `-` where `*` is what collided, and an ordered list moves to the other delimiter. An item is written on its marker's line instead wherever its own form would not be read back: where its list follows a paragraph it has to interrupt, which an item opening on a blank line cannot do. - 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. diff --git a/src/features/editor/plugins/commandKeymap.test.ts b/src/features/editor/plugins/commandKeymap.test.ts index eb73a4c..8baf293 100644 --- a/src/features/editor/plugins/commandKeymap.test.ts +++ b/src/features/editor/plugins/commandKeymap.test.ts @@ -119,7 +119,7 @@ describe("Leafdown editor command keymap", () => { const event = dispatchEditorShortcut(mounted.view.dom, "Enter", { ctrl: true }); expect(event.defaultPrevented).toBe(true); - expect(mounted.getMarkdown()).toBe("* [x] Task\n"); + expect(mounted.getMarkdown()).toBe("- [x] Task\n"); }); it.each([ diff --git a/src/features/editor/plugins/listForm.ts b/src/features/editor/plugins/listForm.ts new file mode 100644 index 0000000..1876703 --- /dev/null +++ b/src/features/editor/plugins/listForm.ts @@ -0,0 +1,88 @@ +import type { MarkdownNode } from "@milkdown/kit/transformer"; +import { $remark } from "@milkdown/kit/utils"; + +import { + DEFAULT_BULLET_LIST_MARKER, + DEFAULT_ORDERED_LIST_MARKER, + findListItemForm, + LIST_ITEM_LEADING_BLANK_LINE_ATTRIBUTE_NAME, + LIST_ITEM_MARKDOWN_TYPE, + LIST_ITEM_NUMBER_ATTRIBUTE_NAME, + LIST_ITEM_PADDING_ATTRIBUTE_NAME, + LIST_MARKDOWN_TYPE, + LIST_MARKER_ATTRIBUTE_NAME, +} from "../utils/listMarkdown"; + +// Nine digits, a delimiter, and four spaces are the longest head CommonMark reads as a marker and +// the padding after it. +const LIST_ITEM_HEAD_LENGTH = 14; + +// An item's slice opens at its own marker rather than at the indentation the container gave it, so +// the head of that slice is the marker and what follows it on the same line. +const readListItemHead = (item: MarkdownNode, source: string) => { + const start = item.position?.start.offset; + const end = item.position?.end.offset; + + return start === undefined || end === undefined + ? undefined + : source.slice(start, Math.min(end, start + LIST_ITEM_HEAD_LENGTH)); +}; + +// CommonMark puts an item's content one space past its marker wherever the marker's own line +// carries nothing else, so an item whose first block opens on a later line is one that was written +// with a blank line after its marker. +const opensOnLaterLine = (item: MarkdownNode) => { + const marker = item.position?.start.line; + const content = item.children?.[0]?.position?.start.line; + + return marker !== undefined && content !== undefined && content > marker; +}; + +// CommonMark reads a change of marker as the start of another list, so every item of one list was +// authored with the same one and the first item that carries a position answers for all of them. +const markAuthoredListForm = (list: MarkdownNode, source: string) => { + const ordered = list.ordered === true; + let listMarker: string | undefined; + + for (const item of list.children ?? []) { + if (item.type !== LIST_ITEM_MARKDOWN_TYPE) { + continue; + } + + const head = readListItemHead(item, source); + const form = head === undefined ? undefined : findListItemForm(head, ordered); + + if (!form) { + continue; + } + + listMarker ??= form.marker; + + const authored = item as Record; + + authored[LIST_ITEM_PADDING_ATTRIBUTE_NAME] = form.padding; + authored[LIST_ITEM_LEADING_BLANK_LINE_ATTRIBUTE_NAME] = opensOnLaterLine(item); + + if (form.number !== undefined) { + authored[LIST_ITEM_NUMBER_ATTRIBUTE_NAME] = form.number; + } + } + + (list as Record)[LIST_MARKER_ATTRIBUTE_NAME] = + listMarker ?? (ordered ? DEFAULT_ORDERED_LIST_MARKER : DEFAULT_BULLET_LIST_MARKER); +}; + +const markAuthoredForm = (node: MarkdownNode, source: string) => { + for (const child of node.children ?? []) { + if (child.type === LIST_MARKDOWN_TYPE) { + markAuthoredListForm(child, source); + } + + markAuthoredForm(child, source); + } +}; + +export const createLeafdownListFormPlugin = () => + $remark("leafdownListForm", () => () => (tree, file) => { + markAuthoredForm(tree as MarkdownNode, String(file)); + }); diff --git a/src/features/editor/plugins/taskListCheckbox.test.ts b/src/features/editor/plugins/taskListCheckbox.test.ts index 36de4d6..e02a34a 100644 --- a/src/features/editor/plugins/taskListCheckbox.test.ts +++ b/src/features/editor/plugins/taskListCheckbox.test.ts @@ -34,7 +34,7 @@ describe("task list checkbox plugin", () => { expect(event.defaultPrevented).toBe(true); expect(mounted.view.dom.querySelector("li[data-checked='true']")).toHaveTextContent("Todo"); - expect(mounted.getMarkdown()).toContain("* [x] Todo"); + expect(mounted.getMarkdown()).toContain("- [x] Todo"); }); it("ignores task-list clicks outside the checkbox hit area", async () => { @@ -46,7 +46,7 @@ describe("task list checkbox plugin", () => { expect(event.defaultPrevented).toBe(false); expect(mounted.view.dom.querySelector("li[data-checked='false']")).toHaveTextContent("Todo"); - expect(mounted.getMarkdown()).toContain("* [ ] Todo"); + expect(mounted.getMarkdown()).toContain("- [ ] Todo"); }); it("ignores normal list items", async () => { diff --git a/src/features/editor/tests/keyboardBehavior.test.ts b/src/features/editor/tests/keyboardBehavior.test.ts index c61301a..8cf354a 100644 --- a/src/features/editor/tests/keyboardBehavior.test.ts +++ b/src/features/editor/tests/keyboardBehavior.test.ts @@ -32,10 +32,12 @@ describe("Milkdown keyboard behavior", () => { setSelectionAtElementTextEnd(mounted.view, listItems[1]); expect(runKeyDownHandlers(mounted.view, "Tab").handled).toBe(true); + // The nested list is one the editor made, so it carries the default marker rather than the + // one the list around it was authored with. expect(mounted.getMarkdown()).toContain(" * two"); expect(runKeyDownHandlers(mounted.view, "Tab", { shift: true }).handled).toBe(true); - expect(mounted.getMarkdown()).toBe("* one\n* two\n"); + expect(mounted.getMarkdown()).toBe("- one\n- two\n"); }); it("uses Milkdown defaults to insert hard breaks with Shift+Enter", async () => { diff --git a/src/features/editor/tests/markdownCompatibility.test.ts b/src/features/editor/tests/markdownCompatibility.test.ts index d132cf3..c4260dc 100644 --- a/src/features/editor/tests/markdownCompatibility.test.ts +++ b/src/features/editor/tests/markdownCompatibility.test.ts @@ -18,6 +18,8 @@ import { import { waitFor } from "@/test/utils/react"; import { mockTauriApiCommand } from "@/test/utils/tauriApi"; +import { runEditorCommand } from "../commands"; + const mountEditor = setupMilkdownEditorMount(); const supportedMarkdown = `# Heading @@ -49,37 +51,8 @@ Footnote[^1] [^1]: Footnote text`; -// Milkdown serializer defaults normalize several source markers: -// 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). - -> Quote - -1. One -2. Two - -* A -* B - -\`\`\`ts -const value = 1; -\`\`\` - ---- - -![Alt](image.png) - -${BASIC_TABLE_MARKDOWN} - -* [ ] todo -* [x] done - -Footnote[^1] - -[^1]: Footnote text +// The save writes the fixture back as it was authored, apart from the final newline. +const supportedMarkdownExpected = `${supportedMarkdown} `; const unusualMarkdownFixtures = [ @@ -930,6 +903,7 @@ describe("Thematic break form", () => { { name: "a blockquote", source: "> Quote\n>\n> ---" }, { name: "a list item", source: "* Item\n\n ---" }, { name: "a tight list item", source: "* Item\n ***" }, + { name: "a list item whose bullet it cannot join", source: "* ---\n Paragraph" }, ])("keeps the authored run inside $name", async ({ source }) => { const mounted = await mountEditor(`${source}\n`); @@ -937,12 +911,22 @@ describe("Thematic break form", () => { }); // 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"); + // longer break with no list around it. No file holds that spelling, because it opens as the + // longer break rather than as a list, so only a run edited into it can reach the collision. The + // run is what gives way, the bullet keeping the marker its list was authored with. + it.each([ + { marker: "---", saved: "- ***", source: "- ***" }, + { marker: "***", saved: "* ___", source: "* ---" }, + ])( + "writes a $marker break opening $source in a run its bullet cannot join", + async ({ marker, saved, source }) => { + const mounted = await mountEditor(`${source}\n Paragraph\n`); - expect(mounted.getMarkdown()).toBe("- ***\n Paragraph\n"); - }); + setThematicBreakMarker(mounted, marker); + + expect(mounted.getMarkdown()).toBe(`${saved}\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. @@ -1041,6 +1025,129 @@ describe("Table outer pipe form", () => { }); }); +describe("List marker form", () => { + const removeBlock = (mounted: MountedMilkdownEditor, index: number) => { + const { doc, tr } = mounted.view.state; + let start = 0; + + for (let child = 0; child < index; child += 1) { + start += doc.child(child).nodeSize; + } + + mounted.view.dispatch(tr.delete(start, start + doc.child(index).nodeSize)); + }; + + it.each([ + "- Hyphen", + "+ Plus", + "* Asterisk", + "1. Period", + "1) Parenthesis", + // CommonMark reads a change of marker as the start of another list, so adjacent lists were + // each authored with a marker of their own. + "- Hyphen\n\n+ Plus\n\n* Asterisk", + "1. Period\n\n1) Parenthesis", + // A nested list is a list of its own and carries its own marker. + "- Outer\n * Nested\n * Nested again\n- Outer again", + "1. Outer\n + Nested\n2. Outer again", + "+ Outer\n 1) Nested", + // A task marker stands inside the item rather than in place of its marker. + "- [ ] Todo\n- [x] Done", + "1) [ ] Todo\n2) [x] Done", + ])("writes the marker in %j as it was authored", async (source) => { + const mounted = await mountEditor(`${source}\n`); + + expect(mounted.getMarkdown()).toBe(`${source}\n`); + }); + + // Only an ordered list's first number sets the start it is read back with. The numbers after it + // are the author's own counting, which a renumbering from the start would rewrite. + it.each([ + "1. One\n2. Two\n3. Three", + "3. Three\n8. Eight\n8. Eight again", + "1. One\n1. One again\n1. One more", + "0. Zero\n0. Zero again", + "123456789. The longest marker CommonMark reads\n1. Short again", + ])("writes the numbers in %j as they were authored", async (source) => { + const mounted = await mountEditor(`${source}\n`); + + expect(mounted.getMarkdown()).toBe(`${source}\n`); + }); + + it.each([ + "- one space", + "- two spaces", + "- three spaces", + "- four spaces", + "10. Padding is measured from the end of the marker", + "- [x] A task marker stands after the padding", + // The padding is the column the item's own blocks are written at. + "- Paragraph\n\n Second paragraph", + "- Paragraph\n - Nested", + ])("writes the marker padding in %j as it was authored", async (source) => { + const mounted = await mountEditor(`${source}\n`); + + expect(mounted.getMarkdown()).toBe(`${source}\n`); + }); + + // A list the editor makes carries no authored marker and writes the default. + it("writes a list made in the editor with the default marker", async () => { + const mounted = await mountEditor("Paragraph\n"); + + await runEditorCommand(mounted.editor, "format.unorderedList"); + + expect(mounted.getMarkdown()).toBe("* Paragraph\n"); + }); + + // Two adjacent lists sharing a marker are read back as one list. No file holds that, because a + // repeated marker opens one list to begin with, but deleting what stood between two lists does. + it.each([ + { name: "bullet", saved: "- a\n\n* b\n", source: "- a\n\n\n\n- b\n" }, + { name: "ordered", saved: "1. a\n\n1) b\n", source: "1. a\n\n\n\n1. b\n" }, + ])( + "moves the second of two adjacent $name lists off the marker they share", + async ({ saved, source }) => { + const mounted = await mountEditor(source); + + removeBlock(mounted, 1); + + expect(mounted.getMarkdown()).toBe(saved); + + const reopened = await mountEditor(saved); + + expect(reopened.view.state.doc.childCount).toBe(2); + }, + ); + + it.each([ + "-\n Content on the line after the marker", + "-\n First\n- Second", + "1.\n Content", + "-\n [x] A task marker opens the content wherever it stands", + "-\n > A block other than a paragraph", + ])("writes the item in %j opening on the line after its marker", async (source) => { + const mounted = await mountEditor(`${source}\n`); + + expect(mounted.getMarkdown()).toBe(`${source}\n`); + }); + + // A list interrupts the paragraph above it only where its first item opens with content, so an + // item that would open with a blank line there is written on the marker's line instead. Only a + // tight item joins a paragraph to the list after it, and no file holds one: a list written there + // is read as more of the paragraph, so only an edit that tightens the item reaches this. + it("writes an item opening on the line after its marker where its list must interrupt a paragraph", async () => { + const mounted = await mountEditor("- Paragraph\n\n -\n Nested\n"); + const position = getEditorNodePosition(mounted, "list_item"); + const { attrs } = mounted.view.state.doc.nodeAt(position) ?? {}; + + mounted.view.dispatch( + mounted.view.state.tr.setNodeMarkup(position, undefined, { ...attrs, spread: false }), + ); + + expect(mounted.getMarkdown()).toBe("- Paragraph\n - Nested\n"); + }); +}); + describe("Link and image title form", () => { it.each([ '[Double quote](garden.md "Garden")', @@ -1371,7 +1478,7 @@ describe("Line-final whitespace", () => { { 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: "- 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", @@ -1465,7 +1572,7 @@ describe("Line-final whitespace", () => { name: "a heading", }, { - expected: `* item${NO_BREAK_SPACE}\n`, + expected: `- item${NO_BREAK_SPACE}\n`, initial: `- item${NO_BREAK_SPACE}`, name: "a list item", }, @@ -1593,7 +1700,7 @@ describe("Line-initial whitespace", () => { 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: "item", expected: "- item\n", initial: "- item", name: "a list item", typed: " " }, { anchor: "quote", expected: "> quote\n", @@ -1736,7 +1843,7 @@ describe("Typed inline mark source", () => { }); it.each([ - { expected: "* item ~~text~~", initial: "- item", name: "a list item" }, + { expected: "- item ~~text~~", initial: "- item", name: "a list item" }, { expected: "> quote ~~text~~", initial: "> quote", name: "a blockquote" }, ])("writes a strikethrough typed in $name", async ({ expected, initial }) => { expect(await typeInto(initial, " ~~text~~")).toBe(`${expected}\n`); diff --git a/src/features/editor/utils/createMilkdownEditor.ts b/src/features/editor/utils/createMilkdownEditor.ts index 18fb6cd..857619d 100644 --- a/src/features/editor/utils/createMilkdownEditor.ts +++ b/src/features/editor/utils/createMilkdownEditor.ts @@ -25,6 +25,7 @@ import { inlineCodeKeymap, linkSchema, orderedListKeymap, + orderedListSchema, paragraphKeymap, remarkInlineLinkPlugin, remarkPreserveEmptyLinePlugin, @@ -37,7 +38,6 @@ import { strikethroughKeymap, tableSchema, } from "@milkdown/kit/preset/gfm"; -import type { Node as ProseNode } from "@milkdown/kit/prose/model"; import type { EditorProps } from "@milkdown/kit/prose/view"; import { getMarkdown } from "@milkdown/kit/utils"; import { highlight, highlightPluginConfig } from "@milkdown/plugin-highlight"; @@ -67,6 +67,7 @@ import { createLeafdownDoubleClickSelectionPlugin } from "../plugins/doubleClick import { createLeafdownImageViewPlugin } from "../plugins/imageView"; import { createLeafdownLinkActivationPlugin } from "../plugins/linkActivation"; import { createLeafdownLinkPresentationPlugin } from "../plugins/linkPresentation"; +import { createLeafdownListFormPlugin } from "../plugins/listForm"; import { createLeafdownLogicalLinkSerializerPlugin } from "../plugins/logicalLinkSerializer"; import { createLeafdownMarkerPresentationPlugin } from "../plugins/markerPresentation"; import { createLeafdownMarkNestingPlugin } from "../plugins/markNesting"; @@ -104,6 +105,13 @@ import { createClipboardTextSerializer } from "./clipboard"; import { normalizeProseMirrorClipboardHtml } from "./clipboardHtml"; import { createLeafdownHighlightParser } from "./highlighting"; import type { MarkdownLinkContext } from "./linkActivation"; +import { + serializeList, + serializeListItem, + withBulletListMarker, + withListItemForm, + withOrderedListMarker, +} from "./listMarkdown"; import { serializeMarkdownDefinition, serializeMarkdownImage, @@ -155,39 +163,6 @@ export const composeEditorViewAttributes = ( ? (state) => ({ ...previous(state), ...added }) : { ...previous, ...added }; -// The list item schema requires a leading paragraph, so an item whose source starts with any other -// block parses with an empty one filled in ahead of it. Written out it becomes a blank line, and -// CommonMark ends the item at the second one. -const withoutFilledLeadingParagraph = (node: ProseNode) => { - const firstChild = node.firstChild; - - if ( - node.childCount < 2 || - !firstChild || - firstChild.type.name !== "paragraph" || - firstChild.content.size > 0 || - // GFM writes the checkbox into the item's first paragraph and drops it when that paragraph is - // not there to hold it. - node.attrs.checked != null - ) { - return node; - } - - return node.copy(node.content.cut(firstChild.nodeSize)); -}; - -// `parseMarkdown` builds `spread` with a template literal, so the attribute holds the string -// "false" where mdast expects a boolean. Forwarded raw, it reads as spread and writes every tight -// list loose. -const withBooleanSpread = (node: ProseNode) => - typeof node.attrs.spread === "boolean" - ? node - : node.type.create( - { ...node.attrs, spread: node.attrs.spread === "true" }, - node.content, - node.marks, - ); - const DEFAULT_OPEN_MARKDOWN_PATH: MarkdownLinkContext["onOpenMarkdownPath"] = () => false; // Marks serialize in `spec.priority` order, 50 unless declared, and inline code declares 100 to // stay innermost. @@ -237,6 +212,7 @@ export const createMilkdownEditor = async ({ .use(createLeafdownThematicBreakPlugin()) .use(createLeafdownBlockStructurePlugin()) .use(createLeafdownMarkNestingPlugin()) + .use(createLeafdownListFormPlugin()) .use(createLeafdownTableFormPlugin()) .use(createLeafdownTableShapePlugin()) .use(commonmark) @@ -298,6 +274,8 @@ export const createMilkdownEditor = async ({ image: serializeMarkdownImage, imageReference: serializeMarkdownImageReference, link: serializeMarkdownLink, + list: serializeList, + listItem: serializeListItem, root: serializeMarkdownRoot, table: serializeTable, text: serializeMarkdownText, @@ -319,17 +297,14 @@ export const createMilkdownEditor = async ({ }, }; }); - ctx.update(bulletListSchema.key, (getSchema) => (schemaCtx) => { - const schema = getSchema(schemaCtx); - - return { - ...schema, - toMarkdown: { - ...schema.toMarkdown, - runner: (state, node) => schema.toMarkdown.runner(state, withBooleanSpread(node)), - }, - }; - }); + ctx.update( + bulletListSchema.key, + (getSchema) => (schemaCtx) => withBulletListMarker(getSchema(schemaCtx)), + ); + ctx.update( + orderedListSchema.key, + (getSchema) => (schemaCtx) => withOrderedListMarker(getSchema(schemaCtx)), + ); ctx.update( imageSchema.key, (getSchema) => (schemaCtx) => @@ -355,21 +330,10 @@ export const createMilkdownEditor = async ({ })); // `extendSchema` registers a new slice, so an override on `listItemSchema` never reaches the // schema the editor holds. - ctx.update(extendListItemSchemaForTask.key, (getSchema) => (schemaCtx) => { - const schema = getSchema(schemaCtx); - - return { - ...schema, - toMarkdown: { - ...schema.toMarkdown, - runner: (state, node) => - schema.toMarkdown.runner( - state, - withBooleanSpread(withoutFilledLeadingParagraph(node)), - ), - }, - }; - }); + ctx.update( + extendListItemSchemaForTask.key, + (getSchema) => (schemaCtx) => withListItemForm(getSchema(schemaCtx)), + ); ctx.set(defaultValueCtx, initialMarkdown); ctx.set(highlightPluginConfig.key, { parser }); ctx.update(historyKeymap.key, (keymap) => ({ diff --git a/src/features/editor/utils/listMarkdown.ts b/src/features/editor/utils/listMarkdown.ts new file mode 100644 index 0000000..7702eca --- /dev/null +++ b/src/features/editor/utils/listMarkdown.ts @@ -0,0 +1,449 @@ +import type { remarkStringifyOptionsCtx } from "@milkdown/kit/core"; +import type { Node as ProseNode } from "@milkdown/kit/prose/model"; +import type { NodeSchema } from "@milkdown/kit/transformer"; +import { defaultHandlers } from "mdast-util-to-markdown"; + +import { joinsWithoutBlankLine } from "./markdownJoins"; + +type RemarkStringifyHandlers = NonNullable< + ReturnType["handlers"] +>; + +type StringifyState = Parameters>[2]; + +type StringifyParent = Parameters>[1]; + +type JoinArguments = Parameters; + +// Milkdown types a stringify handler's node as `any`, so a list and its item are named here from +// the blocks the serializer joins. +type ListNode = Extract; + +type ListItemNode = Extract; + +export const LIST_MARKDOWN_TYPE = "list"; +export const LIST_ITEM_MARKDOWN_TYPE = "listItem"; +export const LIST_MARKER_ATTRIBUTE_NAME = "marker"; +export const LIST_ITEM_NUMBER_ATTRIBUTE_NAME = "number"; +export const LIST_ITEM_PADDING_ATTRIBUTE_NAME = "padding"; +export const LIST_ITEM_LEADING_BLANK_LINE_ATTRIBUTE_NAME = "leadingBlankLine"; + +const PARAGRAPH_MARKDOWN_TYPE = "paragraph"; +const LIST_ITEM_LABEL_ATTRIBUTE_NAME = "label"; +const BULLET_LIST_ITEM_LABEL = "•"; +const BULLET_LIST_ITEM_TYPE = "bullet"; +const ORDERED_LIST_ITEM_TYPE = "ordered"; + +// CommonMark reads `-`, `+`, and `*` as the same bullet, and `.` and `)` as the same ordered +// delimiter, but reads a change of either as the start of another list. +const BULLET_LIST_MARKERS = ["-", "+", "*"] as const; +const ORDERED_LIST_MARKERS = [".", ")"] as const; + +type BulletListMarker = (typeof BULLET_LIST_MARKERS)[number]; + +type OrderedListMarker = (typeof ORDERED_LIST_MARKERS)[number]; + +// The marker a list is written with when it has none of its own: one the editor created, and one +// whose authored marker cannot be recovered. +export const DEFAULT_BULLET_LIST_MARKER: BulletListMarker = "*"; +export const DEFAULT_ORDERED_LIST_MARKER: OrderedListMarker = "."; +// The marker a list moves to when the one it would write is the one the list before it used. Two +// adjacent lists sharing a marker are read back as one list, and the default is what a list moves +// off where the default is what collided. +const ALTERNATE_BULLET_LIST_MARKER: BulletListMarker = "-"; +// A run of underscores is a thematic break and never a bullet, so an option held to it cannot +// match a marker. +const NON_BULLET_RULE_MARKER = "_"; + +// The spaces between a marker and the content it opens. CommonMark reads one to four of them and +// puts the content that many columns past the marker; a fifth space opens indented code inside the +// item and leaves the content one space past the marker, which is also where an item beginning on +// the line after its marker puts it. +export const DEFAULT_LIST_ITEM_PADDING = 1; +const MAXIMUM_LIST_ITEM_PADDING = 4; +// CommonMark reads at most nine digits as an ordered marker. +const MAXIMUM_LIST_ITEM_NUMBER = 999999999; + +const BULLET_LIST_ITEM_PATTERN = /^([-+*])/u; +const ORDERED_LIST_ITEM_PATTERN = /^(\d{1,9})([.)])/u; +// Anchored against the content so a run the parse does not read as padding is left to the default: +// five or more spaces belong to indented code, and none at all mean the content opens on a later +// line. +const LIST_ITEM_PADDING_PATTERN = /^ {1,4}(?=[^\t\n\r ])/u; + +export interface AuthoredListItemForm { + marker: BulletListMarker | OrderedListMarker; + number: number | undefined; + padding: number; +} + +const isBulletListMarker = (value: unknown): value is BulletListMarker => + BULLET_LIST_MARKERS.includes(value as BulletListMarker); + +const isOrderedListMarker = (value: unknown): value is OrderedListMarker => + ORDERED_LIST_MARKERS.includes(value as OrderedListMarker); + +const readAttribute = (source: object, name: string) => (source as Record)[name]; + +export const readBulletListMarker = (source: object): BulletListMarker => { + const marker = readAttribute(source, LIST_MARKER_ATTRIBUTE_NAME); + + return isBulletListMarker(marker) ? marker : DEFAULT_BULLET_LIST_MARKER; +}; + +export const readOrderedListMarker = (source: object): OrderedListMarker => { + const marker = readAttribute(source, LIST_MARKER_ATTRIBUTE_NAME); + + return isOrderedListMarker(marker) ? marker : DEFAULT_ORDERED_LIST_MARKER; +}; + +export const readListItemNumber = (source: object): number | undefined => { + const number = readAttribute(source, LIST_ITEM_NUMBER_ATTRIBUTE_NAME); + + return typeof number === "number" && + Number.isInteger(number) && + number >= 0 && + number <= MAXIMUM_LIST_ITEM_NUMBER + ? number + : undefined; +}; + +export const readListItemPadding = (source: object): number => { + const padding = readAttribute(source, LIST_ITEM_PADDING_ATTRIBUTE_NAME); + + return typeof padding === "number" && + Number.isInteger(padding) && + padding >= DEFAULT_LIST_ITEM_PADDING && + padding <= MAXIMUM_LIST_ITEM_PADDING + ? padding + : DEFAULT_LIST_ITEM_PADDING; +}; + +export const readListItemLeadingBlankLine = (source: object): boolean => + readAttribute(source, LIST_ITEM_LEADING_BLANK_LINE_ATTRIBUTE_NAME) === true; + +// The preset numbers an ordered list's items onto an mdast field of its own before the parse and +// keeps the pair up to date from the document afterwards, so they decorate the rendered item rather +// than answering for the marker the file is written with. The item is written from the number it +// was authored with, which the preset's own numbering would replace. +const readListItemLabel = (source: object) => { + const label = readAttribute(source, LIST_ITEM_LABEL_ATTRIBUTE_NAME); + + return typeof label === "number" || typeof label === "string" + ? { label: `${label}.`, listType: ORDERED_LIST_ITEM_TYPE } + : { label: BULLET_LIST_ITEM_LABEL, listType: BULLET_LIST_ITEM_TYPE }; +}; + +const findListItemPadding = (afterMarker: string) => + LIST_ITEM_PADDING_PATTERN.exec(afterMarker)?.[0].length ?? DEFAULT_LIST_ITEM_PADDING; + +// An item's slice opens at its marker, whatever the container indented it by, so the marker, the +// number it spells, and the spaces after it are read off the head of that slice. +export const findListItemForm = ( + head: string, + ordered: boolean, +): AuthoredListItemForm | undefined => { + const match = ordered + ? ORDERED_LIST_ITEM_PATTERN.exec(head) + : BULLET_LIST_ITEM_PATTERN.exec(head); + + if (!match) { + return undefined; + } + + const [matched, first, second] = match; + + return { + marker: (ordered ? second : first) as BulletListMarker | OrderedListMarker, + number: ordered ? Number(first) : undefined, + padding: findListItemPadding(head.slice(matched.length)), + }; +}; + +// The list item schema requires a leading paragraph, so an item whose source starts with any other +// block parses with an empty one filled in ahead of it. Written out it becomes a blank line, and +// CommonMark ends the item at the second one. +const withoutFilledLeadingParagraph = (node: ProseNode) => { + const firstChild = node.firstChild; + + if ( + node.childCount < 2 || + !firstChild || + firstChild.type.name !== PARAGRAPH_MARKDOWN_TYPE || + firstChild.content.size > 0 || + // GFM writes the checkbox into the item's first paragraph and drops it when that paragraph is + // not there to hold it. + node.attrs.checked != null + ) { + return node; + } + + return node.copy(node.content.cut(firstChild.nodeSize)); +}; + +// `parseMarkdown` builds `spread` with a template literal, so the attribute holds the string +// "false" where mdast expects a boolean. Forwarded raw, it reads as spread and writes every tight +// list loose. +const readSpread = (node: ProseNode) => + typeof node.attrs.spread === "boolean" ? node.attrs.spread : node.attrs.spread === "true"; + +const readAlternateBulletListMarker = (marker: BulletListMarker) => + marker === DEFAULT_BULLET_LIST_MARKER ? ALTERNATE_BULLET_LIST_MARKER : DEFAULT_BULLET_LIST_MARKER; + +// A list interrupts the paragraph it follows only where its first item opens with content, so an +// item that would open with a blank line there is written with its content on the marker's line +// instead. An item is written where its own list's parent is out of reach, so the list resolves the +// question and hands the item down without the form. A tight list item is the only container that +// joins a paragraph to the block after it without a blank line, and the join is read off the +// serializer rather than off the item's own `spread`, which is what decides it. +const withoutUninterruptingFirstItem = ( + node: ListNode, + parent: StringifyParent, + state: StringifyState, +) => { + const [first, ...rest] = node.children; + + if (!first || !readListItemLeadingBlankLine(first) || parent?.type !== LIST_ITEM_MARKDOWN_TYPE) { + return node; + } + + const index = parent.children.indexOf(node); + const previous = index > 0 ? parent.children[index - 1] : undefined; + + if ( + previous?.type !== PARAGRAPH_MARKDOWN_TYPE || + !joinsWithoutBlankLine(previous, node, parent, state) + ) { + return node; + } + + return { + ...node, + children: [{ ...first, [LIST_ITEM_LEADING_BLANK_LINE_ATTRIBUTE_NAME]: false }, ...rest], + }; +}; + +// `mdast-util-to-markdown` picks a list's marker from one option for the whole document and moves +// the next list off whatever the last one used, which is the alternation an authored marker +// replaces. The choice is reachable only through those options, so they carry the authored marker +// for the length of the list and the handler keeps the guards that stop two lists from being read +// back as one. +export const serializeList: NonNullable = ( + node: ListNode, + parent, + state, + info, +) => { + const list = withoutUninterruptingFirstItem(node, parent, state); + const { bullet, bulletOrdered, bulletOther, rule } = state.options; + + if (node.ordered) { + state.options.bulletOrdered = readOrderedListMarker(node); + } else { + const marker = readBulletListMarker(node); + + state.options.bullet = marker; + state.options.bulletOther = readAlternateBulletListMarker(marker); + // The handler also moves the bullet off the run a thematic break opening an item is written + // with, read from the option rather than from the break, which cannot answer for a run the + // node carries. `serializeThematicBreak` answers it from the break itself, so the option is + // held to a character no bullet can be and the authored marker stays. + state.options.rule = NON_BULLET_RULE_MARKER; + } + + try { + return defaultHandlers.list(list, parent, state, info); + } finally { + Object.assign(state.options, { bullet, bulletOrdered, bulletOther, rule }); + } +}; + +// A bullet item is written with the character the list settled on, which is where a collision with +// the list before it is already resolved. An ordered item spells its own number ahead of that +// character, except the first, which spells the list's own: that number is the start the file is +// read back with, and the list is what holds it. +const findListItemMarker = (node: ListItemNode, parent: StringifyParent, state: StringifyState) => { + const marker = state.bulletCurrent ?? DEFAULT_BULLET_LIST_MARKER; + + if (parent?.type !== LIST_MARKDOWN_TYPE || !parent.ordered) { + return marker; + } + + const start = typeof parent.start === "number" && parent.start > -1 ? parent.start : 1; + const index = parent.children.indexOf(node); + const number = index > 0 ? (readListItemNumber(node) ?? start + index) : start; + + return `${number}${marker}`; +}; + +// The handler upstream sizes every item by one space after its marker, or by the tab stop the +// document-wide option names, neither of which can answer for the spaces a single item was written +// with. Rewriting it here is also what keeps a task marker on an item the GFM handler cannot +// reach: it inserts the checkbox by matching the marker it expects, which is a `.` delimiter +// followed by at most three spaces. +export const serializeListItem: NonNullable = ( + node: ListItemNode, + parent, + state, + info, +) => { + const marker = findListItemMarker(node, parent, state); + // An item that opens on the line after its marker puts its content one space past the marker, + // whatever stands between the two, so that spacing is the padding it is written with. + const leadingBlankLine = readListItemLeadingBlankLine(node) && node.children.length > 0; + const padding = leadingBlankLine ? DEFAULT_LIST_ITEM_PADDING : readListItemPadding(node); + const size = marker.length + padding; + // GFM writes the checkbox into the item's first paragraph and reads it back from there, so an + // item opening on any other block cannot carry one. + const checkbox = + typeof node.checked === "boolean" && node.children[0]?.type === PARAGRAPH_MARKDOWN_TYPE + ? `[${node.checked ? "x" : " "}] ` + : ""; + const opening = leadingBlankLine + ? `${marker}\n${" ".repeat(size)}` + : marker + " ".repeat(padding); + const tracker = state.createTracker(info); + + tracker.move(opening + checkbox); + tracker.shift(size); + + const exit = state.enter(LIST_ITEM_MARKDOWN_TYPE); + const value = state.indentLines( + state.containerFlow(node, tracker.current()), + (line, index, blank) => { + if (index) { + return (blank ? "" : " ".repeat(size)) + line; + } + + return (blank ? marker : opening + checkbox) + line; + }, + ); + + exit(); + + return value; +}; + +// The preset's own runners open the mdast node themselves and carry only the fields they know, so +// each is replaced rather than wrapped: the authored form has to reach the node the runner opens. +export const withBulletListMarker = (schema: NodeSchema): NodeSchema => ({ + ...schema, + attrs: { + ...schema.attrs, + [LIST_MARKER_ATTRIBUTE_NAME]: { + default: DEFAULT_BULLET_LIST_MARKER, + validate: "string", + }, + }, + parseMarkdown: { + ...schema.parseMarkdown, + runner: (state, node, type) => { + state.openNode(type, { + spread: node.spread ?? false, + [LIST_MARKER_ATTRIBUTE_NAME]: readBulletListMarker(node), + }); + state.next(node.children); + state.closeNode(); + }, + }, + toMarkdown: { + ...schema.toMarkdown, + runner: (state, node) => { + state.openNode(LIST_MARKDOWN_TYPE, undefined, { + ordered: false, + spread: readSpread(node), + [LIST_MARKER_ATTRIBUTE_NAME]: readBulletListMarker(node.attrs), + }); + state.next(node.content); + state.closeNode(); + }, + }, +}); + +export const withOrderedListMarker = (schema: NodeSchema): NodeSchema => ({ + ...schema, + attrs: { + ...schema.attrs, + [LIST_MARKER_ATTRIBUTE_NAME]: { + default: DEFAULT_ORDERED_LIST_MARKER, + validate: "string", + }, + }, + parseMarkdown: { + ...schema.parseMarkdown, + runner: (state, node, type) => { + state.openNode(type, { + spread: node.spread ?? true, + order: node.start ?? 1, + [LIST_MARKER_ATTRIBUTE_NAME]: readOrderedListMarker(node), + }); + state.next(node.children); + state.closeNode(); + }, + }, + toMarkdown: { + ...schema.toMarkdown, + runner: (state, node) => { + state.openNode(LIST_MARKDOWN_TYPE, undefined, { + ordered: true, + start: node.attrs.order ?? 1, + spread: readSpread(node), + [LIST_MARKER_ATTRIBUTE_NAME]: readOrderedListMarker(node.attrs), + }); + state.next(node.content); + state.closeNode(); + }, + }, +}); + +// The task extension's runners are the ones the editor holds, and they carry the checkbox through +// the same fields the preset's do, so one replacement answers for a task item and an ordinary one. +export const withListItemForm = (schema: NodeSchema): NodeSchema => ({ + ...schema, + attrs: { + ...schema.attrs, + [LIST_ITEM_NUMBER_ATTRIBUTE_NAME]: { + default: null, + validate: "number|null", + }, + [LIST_ITEM_PADDING_ATTRIBUTE_NAME]: { + default: DEFAULT_LIST_ITEM_PADDING, + validate: "number", + }, + [LIST_ITEM_LEADING_BLANK_LINE_ATTRIBUTE_NAME]: { + default: false, + validate: "boolean", + }, + }, + parseMarkdown: { + ...schema.parseMarkdown, + runner: (state, node, type) => { + state.openNode(type, { + ...readListItemLabel(node), + spread: node.spread ?? true, + checked: node.checked == null ? null : Boolean(node.checked), + [LIST_ITEM_NUMBER_ATTRIBUTE_NAME]: readListItemNumber(node) ?? null, + [LIST_ITEM_PADDING_ATTRIBUTE_NAME]: readListItemPadding(node), + [LIST_ITEM_LEADING_BLANK_LINE_ATTRIBUTE_NAME]: readListItemLeadingBlankLine(node), + }); + state.next(node.children); + state.closeNode(); + }, + }, + toMarkdown: { + ...schema.toMarkdown, + runner: (state, node) => { + const item = withoutFilledLeadingParagraph(node); + + state.openNode(LIST_ITEM_MARKDOWN_TYPE, undefined, { + spread: readSpread(item), + checked: item.attrs.checked ?? null, + [LIST_ITEM_NUMBER_ATTRIBUTE_NAME]: readListItemNumber(item.attrs) ?? null, + [LIST_ITEM_PADDING_ATTRIBUTE_NAME]: readListItemPadding(item.attrs), + [LIST_ITEM_LEADING_BLANK_LINE_ATTRIBUTE_NAME]: readListItemLeadingBlankLine(item.attrs), + }); + state.next(item.content); + state.closeNode(); + }, + }, +}); diff --git a/src/features/editor/utils/markdownJoins.ts b/src/features/editor/utils/markdownJoins.ts new file mode 100644 index 0000000..2aaef62 --- /dev/null +++ b/src/features/editor/utils/markdownJoins.ts @@ -0,0 +1,38 @@ +import type { remarkStringifyOptionsCtx } from "@milkdown/kit/core"; + +type RemarkStringifyHandlers = NonNullable< + ReturnType["handlers"] +>; + +type StringifyState = Parameters>[2]; + +type JoinArguments = Parameters; + +// Asking `mdast-util-to-markdown` what it will write between two blocks, by the resolution it uses +// itself, so the answer cannot drift from the blank line it actually emits. +export const joinsWithoutBlankLine = ( + left: JoinArguments[0], + right: JoinArguments[1], + parent: JoinArguments[2], + state: StringifyState, +) => { + let index = state.join.length; + + while (index--) { + const result = state.join[index](left, right, parent, state); + + if (result === true || result === 1) { + break; + } + + if (typeof result === "number") { + return result === 0; + } + + if (result === false) { + return false; + } + } + + return false; +}; diff --git a/src/features/editor/utils/thematicBreakMarkdown.ts b/src/features/editor/utils/thematicBreakMarkdown.ts index a212f29..96fb795 100644 --- a/src/features/editor/utils/thematicBreakMarkdown.ts +++ b/src/features/editor/utils/thematicBreakMarkdown.ts @@ -2,6 +2,8 @@ import type { remarkStringifyOptionsCtx } from "@milkdown/kit/core"; import type { TagParseRule } from "@milkdown/kit/prose/model"; import type { NodeSchema } from "@milkdown/kit/transformer"; +import { joinsWithoutBlankLine } from "./markdownJoins"; + type RemarkStringifyHandlers = NonNullable< ReturnType["handlers"] >; @@ -58,35 +60,6 @@ export const findThematicBreakMarker = (raw: string): string => { return isThematicBreakMarker(marker) ? marker : DEFAULT_THEMATIC_BREAK_MARKER; }; -// Asking `mdast-util-to-markdown` what it will write between two blocks, by the resolution it uses -// itself, so the answer cannot drift from the blank line it actually emits. -const joinsWithoutBlankLine = ( - left: JoinArguments[0], - right: JoinArguments[1], - parent: JoinArguments[2], - state: StringifyState, -) => { - let index = state.join.length; - - while (index--) { - const result = state.join[index](left, right, parent, state); - - if (result === true || result === 1) { - break; - } - - if (typeof result === "number") { - return result === 0; - } - - if (result === false) { - return false; - } - } - - return false; -}; - // A tight list item joins its children with a single newline, so a break written there follows the // paragraph above it directly. A run of hyphens in that position underlines the paragraph, and the // file is read back holding a heading where the document held a break. A list item is the only