From 150c15b35d4e6f690829c5bd8231a421381bfa80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sat, 15 Aug 2026 13:23:04 -0300 Subject: [PATCH] Escape serialized text that ends in whitespace Milkdown's text handler returns any value ending in whitespace without escaping it, so typed link and autolink source reached the file as live syntax. Holding the trailing whitespace out of `safe` keeps it from being encoded as a character reference at a line ending. --- CHANGELOG.md | 1 + .../tests/markdownCompatibility.test.tsx | 55 +++++++++++++++++++ .../editor/utils/createMilkdownEditor.ts | 6 ++ src/features/editor/utils/markdownText.ts | 23 ++++++++ 4 files changed, 85 insertions(+) create mode 100644 src/features/editor/utils/markdownText.ts 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; +};