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 @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions src/features/editor/tests/markdownCompatibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,43 @@ describe("Markdown compatibility", () => {
expect(mounted.getMarkdown()).toBe("tail<https://example.com>\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")',
Expand Down
38 changes: 37 additions & 1 deletion src/features/editor/utils/createMilkdownEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) => ({
Expand Down