diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c32b2..d846f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Fixed +- 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. - Open bold, italic, or strikethrough that wraps a link as one Markdown source with the link inside it, instead of one side of the link at a time with markers that do not match the file. diff --git a/src/features/editor/tests/markdownCompatibility.test.tsx b/src/features/editor/tests/markdownCompatibility.test.tsx index abaf637..1200195 100644 --- a/src/features/editor/tests/markdownCompatibility.test.tsx +++ b/src/features/editor/tests/markdownCompatibility.test.tsx @@ -270,6 +270,61 @@ describe("Markdown compatibility", () => { }); }); +describe("Typed link source", () => { + const typedLinkSourceFixtures = [ + { + expected: "\\[test link]\\(./test.html)", + name: "inline link", + typed: "[test link](./test.html)", + }, + { expected: "https\\://example.com", name: "autolink literal", typed: "https://example.com" }, + { expected: "\\", name: "URI autolink", typed: "" }, + ]; + + it.each(typedLinkSourceFixtures)( + "keeps a typed $name literal when it ends the paragraph", + async ({ expected, typed }) => { + const mounted = await mountEditor(""); + + setSelectionAtDocumentEnd(mounted.view); + typeText(mounted.view, typed); + + expect(mounted.getMarkdown()).toBe(`${expected}\n`); + }, + ); + + it.each(typedLinkSourceFixtures)( + "keeps a typed $name literal when a space follows it", + async ({ expected, typed }) => { + const mounted = await mountEditor(""); + + setSelectionAtDocumentEnd(mounted.view); + typeText(mounted.view, `${typed} `); + + expect(mounted.getMarkdown()).toBe(`${expected} \n`); + }, + ); + + it.each(["\\[test link]\\(./test.html)", "\\[test link]\\(./test.html) "])( + "reloads a typed inline link as the text the editor presented in %j", + async (source) => { + const mounted = await mountEditor(source); + + expect(mounted.view.dom.querySelector("a")).toBeNull(); + expect(mounted.view.dom).toHaveTextContent("[test link](./test.html)"); + }, + ); + + it("writes an ordinary trailing space as itself", async () => { + const mounted = await mountEditor(""); + + setSelectionAtDocumentEnd(mounted.view); + typeText(mounted.view, "plain tail "); + + expect(mounted.getMarkdown()).toBe("plain tail \n"); + }); +}); + describe("Authored raw line breaks", () => { it.each([ "a
b", diff --git a/src/features/editor/utils/createMilkdownEditor.ts b/src/features/editor/utils/createMilkdownEditor.ts index 2a0130f..1b2316b 100644 --- a/src/features/editor/utils/createMilkdownEditor.ts +++ b/src/features/editor/utils/createMilkdownEditor.ts @@ -3,6 +3,7 @@ import { defaultValueCtx, editorViewCtx, editorViewOptionsCtx, + remarkStringifyOptionsCtx, rootAttrsCtx, rootCtx, } from "@milkdown/kit/core"; @@ -67,6 +68,7 @@ import { EMPTY_MARKDOWN_REFERENCE_CONTEXT, type MarkdownReferenceContext, } from "./markdownReferences"; +import { serializeMarkdownText } from "./markdownText"; export interface MilkdownMarkdownUpdate { markdown: string; @@ -186,6 +188,10 @@ export const createMilkdownEditor = async ({ normalizeProseMirrorClipboardHtml(previousTransformPastedHTML?.(html, view) ?? html), }; }); + ctx.update(remarkStringifyOptionsCtx, (options) => ({ + ...options, + handlers: { ...options.handlers, text: serializeMarkdownText }, + })); ctx.update(hardbreakSchema.key, (getSchema) => (schemaCtx) => ({ ...getSchema(schemaCtx), linebreakReplacement: true, diff --git a/src/features/editor/utils/markdownText.ts b/src/features/editor/utils/markdownText.ts new file mode 100644 index 0000000..6013868 --- /dev/null +++ b/src/features/editor/utils/markdownText.ts @@ -0,0 +1,23 @@ +import type { remarkStringifyOptionsCtx } from "@milkdown/kit/core"; + +type RemarkStringifyHandlers = NonNullable< + ReturnType["handlers"] +>; + +const TRAILING_WHITESPACE_PATTERN = /\s+$/u; + +export const serializeMarkdownText: NonNullable = ( + node: { value: string }, + _parent, + state, + info, +) => { + const { value } = node; + const trailingWhitespace = TRAILING_WHITESPACE_PATTERN.exec(value)?.[0] ?? ""; + const escaped = state.safe(value.slice(0, value.length - trailingWhitespace.length), { + ...info, + after: trailingWhitespace + info.after, + }); + + return escaped + trailingWhitespace; +};