diff --git a/CHANGELOG.md b/CHANGELOG.md index d9b9551..003d7b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Fixed +- Keep a list item that starts with a code block, table, quote, nested list, heading, or thematic break nested in the saved file, instead of writing an empty item and leaving the block outside the list the next time the document is opened. - Keep typed link and autolink source literal in the saved file when a space follows it, instead of writing it as live Markdown that turns into a link the next time the document is opened. - Open the Markdown source of a link whose label holds a footnote reference, instead of leaving it closed everywhere in the label except on the reference itself. - Keep a link label that mixes formatted text with a footnote reference as one link, instead of saving it as two links. diff --git a/src/features/editor/tests/markdownCompatibility.test.tsx b/src/features/editor/tests/markdownCompatibility.test.tsx index 5b60cd0..71b7f5c 100644 --- a/src/features/editor/tests/markdownCompatibility.test.tsx +++ b/src/features/editor/tests/markdownCompatibility.test.tsx @@ -217,6 +217,43 @@ describe("Markdown compatibility", () => { expect(mounted.getMarkdown()).toBe("tail\n"); }); + it.each([ + "* ```\n code\n ```", + "1. ```\n code\n ```", + "* | A | B |\n | - | - |\n | 1 | 2 |", + "1. | A | B |\n | - | - |\n | 1 | 2 |", + "* > quoted", + "1. > quoted", + "* * child", + "1. * child", + "* ## Title", + "1. ## Title", + "- ***", + "1. ***", + ])("keeps a non-paragraph first child inside its list item in %s", async (source) => { + const mounted = await mountEditor(source); + + expect(mounted.getMarkdown()).toBe(`${source}\n`); + }); + + it.each([ + { expected: "* ```\n code\n ```\n", source: "* code" }, + { expected: "1. ```\n code\n ```\n", source: "1. code" }, + ])( + "keeps an indented-code first child inside its list item in $source", + async ({ expected, source }) => { + const mounted = await mountEditor(source); + + expect(mounted.getMarkdown()).toBe(expected); + }, + ); + + it("keeps an empty list item empty", async () => { + const mounted = await mountEditor("* first\n*\n* third"); + + expect(mounted.getMarkdown()).toBe("* first\n\n*\n\n* third\n"); + }); + it.each([ "[plain\nlabel](docs/readme.md)", '[**bold** and\n*soft*](docs/readme.md "Title")', diff --git a/src/features/editor/utils/createMilkdownEditor.ts b/src/features/editor/utils/createMilkdownEditor.ts index 86af4db..73036cf 100644 --- a/src/features/editor/utils/createMilkdownEditor.ts +++ b/src/features/editor/utils/createMilkdownEditor.ts @@ -25,7 +25,8 @@ import { remarkPreserveEmptyLinePlugin, strongKeymap, } from "@milkdown/kit/preset/commonmark"; -import { gfm, strikethroughKeymap } from "@milkdown/kit/preset/gfm"; +import { extendListItemSchemaForTask, gfm, strikethroughKeymap } 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"; @@ -106,6 +107,27 @@ 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)); +}; + 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. @@ -209,6 +231,20 @@ export const createMilkdownEditor = async ({ ...withBareAutolinkForm(getSchema(schemaCtx)), priority: LINK_MARK_PRIORITY, })); + // `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, withoutFilledLeadingParagraph(node)), + }, + }; + }); ctx.set(defaultValueCtx, initialMarkdown); ctx.set(highlightPluginConfig.key, { parser }); ctx.update(historyKeymap.key, (keymap) => ({