diff --git a/CHANGELOG.md b/CHANGELOG.md index 003d7b3..268863a 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 +- Write a backslash on save only where the character it precedes would otherwise be read as Markdown, so text such as `garden_sensor_name` keeps its underscores bare, instead of escaping every character that could be syntax somewhere else. - 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. diff --git a/src/features/editor/plugins/sourceProjection.test.tsx b/src/features/editor/plugins/sourceProjection.test.tsx index e8e2262..6a7d25d 100644 --- a/src/features/editor/plugins/sourceProjection.test.tsx +++ b/src/features/editor/plugins/sourceProjection.test.tsx @@ -492,14 +492,16 @@ describe("source projection", () => { it("maps a selection through escaped text in a mixed-format link label", async () => { const mounted = await mountProjectionEditor( - "[literal \\* and **bold**](https://example.com)", + "[\\*literal\\* and **bold**](https://example.com)", ); const selectionFrom = getEditorTextPosition(mounted, "bold"); setTextSelection(mounted.view, selectionFrom, selectionFrom + "bold".length); expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); - expect(getEditorTextContent(mounted)).toBe("[literal \\* and **bold**](https://example.com)"); + expect(getEditorTextContent(mounted)).toBe( + "[\\*literal\\* and **bold**](https://example.com)", + ); expect(getSelectedEditorText(mounted)).toBe("bold"); }); @@ -1030,7 +1032,7 @@ describe("source projection", () => { setSelectionAtDocumentEnd(mounted.view); expect(getEditorTextContent(mounted)).toContain("Text[^note"); - expect(mounted.getMarkdown()).toContain("Text\\[^note"); + expect(mounted.getMarkdown()).toBe("Text[^note\n\n[^note]: Detail\n"); }); it.each([ @@ -1171,7 +1173,7 @@ describe("source projection", () => { const literal = "[^note"; - expect(mounted.getMarkdown()).toContain("Text\\[^note"); + expect(mounted.getMarkdown()).toBe("Text[^note\n\n[^note]: Detail\n"); expect(hasActiveSourceProjection(mounted.view.state)).toBe(false); expect(mounted.view.state.selection.from).toBe( getEditorTextPosition(mounted, literal) + literal.length, diff --git a/src/features/editor/tests/markdownCompatibility.test.tsx b/src/features/editor/tests/markdownCompatibility.test.tsx index 71b7f5c..0884bbe 100644 --- a/src/features/editor/tests/markdownCompatibility.test.tsx +++ b/src/features/editor/tests/markdownCompatibility.test.tsx @@ -169,7 +169,7 @@ describe("Markdown compatibility", () => { "[**bold** plain](https://example.com)", "[plain *soft* and ~~strike~~](https://example.com)", "[plain `code` and **bold**](https://example.com)", - "[plain \\* literal and **bold**](https://example.com)", + "[plain \\*literal\\* and **bold**](https://example.com)", '**[plain *soft*](https://example.com "Title")**', "[plain **bold**]()", "[a](https://example.com) [b](https://example.com)", @@ -347,10 +347,68 @@ describe("Markdown compatibility", () => { }); }); +// Each fixture is the source that produces the document, so a case that must lose an escape is +// written with the one it loses. +describe("Escape precision", () => { + it.each([ + { saved: "garden_sensor_name", source: "garden\\_sensor\\_name" }, + { saved: "sensor.reading_value", source: "sensor.reading\\_value" }, + { saved: "foo__bar__baz", source: "foo\\_\\_bar\\_\\_baz" }, + { saved: "snake_case_ trailing", source: "snake\\_case\\_ trailing" }, + { saved: "*opening-only asterisk emphasis", source: "\\*opening-only asterisk emphasis" }, + { saved: "closing-only asterisk emphasis*", source: "closing-only asterisk emphasis\\*" }, + { saved: "a * b * c", source: "a \\* b \\* c" }, + { saved: "text with [ bracket", source: "text with \\[ bracket" }, + { saved: "text with ] bracket", source: "text with ] bracket" }, + { + saved: "\\[intentionally literal](garden.md)", + source: "\\[intentionally literal]\\(garden.md)", + }, + { + saved: "!\"#$%&'()*+,-./:;<=>?@\\[\\\\]^_\\`{|}\\~", + source: + "\\!\\\"\\#\\$\\%\\&\\'\\(\\)\\*\\+\\,\\-\\.\\/\\:\\;\\<\\=\\>\\?\\@\\[\\\\\\]\\^\\_\\`\\{\\|\\}\\~", + }, + ])("writes $saved without an escape it does not need", async ({ saved, source }) => { + const mounted = await mountEditor(`${source}\n`); + + expect(mounted.getMarkdown()).toBe(`${saved}\n`); + }); + + it.each([ + "\\*not emphasis\\*", + "\\*\\*not strong emphasis\\*\\*", + "\\_not emphasis\\_", + "\\_\\_not strong emphasis\\_\\_", + "\\* not a list item", + "\\*\\*\\*", + "\\_\\_\\_", + "\\# not a heading", + "\\> not a quote", + "\\- not a list item", + "\\[reference]\\[label]", + "\\[intentionally literal](garden.md)", + "!\\[intentionally literal](garden.png)", + "\\![literal bang before a live link](garden.png)", + "\\`not code\\`", + "\\~\\~not strikethrough\\~\\~", + "\\not html\\", + "| bed |\n| ----------- |\n| alpha\\|beta |", + "a \\ b", + "C:\\Users\\me", + "\\\\#", + "\\\\[", + ])("keeps the escape the document needs in %j", async (source) => { + const mounted = await mountEditor(`${source}\n`); + + expect(mounted.getMarkdown()).toBe(`${source}\n`); + }); +}); + describe("Typed link source", () => { const typedLinkSourceFixtures = [ { - expected: "\\[test link]\\(./test.html)", + expected: "\\[test link](./test.html)", name: "inline link", typed: "[test link](./test.html)", }, @@ -382,7 +440,7 @@ describe("Typed link source", () => { }, ); - it.each(["\\[test link]\\(./test.html)", "\\[test link]\\(./test.html) "])( + 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); diff --git a/src/features/editor/tests/sourceProjectionClipboard.test.tsx b/src/features/editor/tests/sourceProjectionClipboard.test.tsx index 599adf1..f28afc7 100644 --- a/src/features/editor/tests/sourceProjectionClipboard.test.tsx +++ b/src/features/editor/tests/sourceProjectionClipboard.test.tsx @@ -325,7 +325,7 @@ describe("source projection clipboard slices", () => { it.each([ { expected: "*Paste*", html: "

Paste

", label: "emphasis" }, - { expected: "a\\*b", html: "

a*b

", label: "characters that mean something in source" }, + { expected: "\\*a\\*", html: "

*a*

", label: "characters that mean something in source" }, { expected: "", html: "", label: "a list" }, { expected: "", html: "

one

two

", label: "two paragraphs" }, ])( diff --git a/src/features/editor/utils/markdownText.ts b/src/features/editor/utils/markdownText.ts index 6013868..20e8847 100644 --- a/src/features/editor/utils/markdownText.ts +++ b/src/features/editor/utils/markdownText.ts @@ -4,20 +4,263 @@ type RemarkStringifyHandlers = NonNullable< ReturnType["handlers"] >; +interface EscapeSlot { + character: string; + escaped: boolean; +} + +interface AttentionRun { + character: string; + start: number; + end: number; + canOpen: boolean; + canClose: boolean; + atLineStart: boolean; +} + +interface PhrasingNeighbors { + textOnly: boolean; + earlier: string; + later: string; + laterHasMarkup: boolean; +} + const TRAILING_WHITESPACE_PATTERN = /\s+$/u; +// `state.safe` escapes ASCII punctuation and nothing else. Decoding with a wider class would read a +// backslash before ordinary text as an escape. +const ESCAPABLE_PATTERN = /[!-/:-@[-`{-~]/u; +const UNICODE_PUNCTUATION_PATTERN = /[\p{P}\p{S}]/u; +const ATTENTION_CHARACTERS = "*_"; +const THEMATIC_BREAK_PATTERNS: Record = { "*": /^[*\t ]*$/u, _: /^[_\t ]*$/u }; +const WHOLE_LINE_PHRASING_PARENTS = new Set(["heading", "paragraph", "tableCell"]); + +const decodeEscapes = (serialized: string): EscapeSlot[] => { + const slots: EscapeSlot[] = []; + + for (let index = 0; index < serialized.length; index += 1) { + const character = serialized[index]; + const next = serialized[index + 1]; + + if (character === "\\" && next !== undefined && ESCAPABLE_PATTERN.test(next)) { + slots.push({ character: next, escaped: true }); + index += 1; + } else { + slots.push({ character, escaped: false }); + } + } + + return slots; +}; + +const encodeEscapes = (slots: readonly EscapeSlot[]) => + slots.map((slot) => (slot.escaped ? `\\${slot.character}` : slot.character)).join(""); + +type CharacterClass = "whitespace" | "punctuation" | "other"; + +const classifyCharacter = (character: string | undefined): CharacterClass => { + if (character === undefined || /\s/u.test(character)) { + return "whitespace"; + } + + return UNICODE_PUNCTUATION_PATTERN.test(character) ? "punctuation" : "other"; +}; + +const findAttentionRuns = ( + slots: readonly EscapeSlot[], + before: string | undefined, + after: string | undefined, +): AttentionRun[] => { + const characterAt = (index: number) => + index < 0 ? before : index < slots.length ? slots[index].character : after; + const runs: AttentionRun[] = []; + + for (let index = 0; index < slots.length; index += 1) { + const { character } = slots[index]; + + if (!ATTENTION_CHARACTERS.includes(character)) { + continue; + } + + let end = index + 1; + + while (end < slots.length && slots[end].character === character) { + end += 1; + } + + const previousCharacter = characterAt(index - 1); + const previous = classifyCharacter(previousCharacter); + const next = classifyCharacter(characterAt(end)); + const leftFlanking = next !== "whitespace" && (next !== "punctuation" || previous !== "other"); + const rightFlanking = + previous !== "whitespace" && (previous !== "punctuation" || next !== "other"); + + runs.push({ + character, + start: index, + end, + canOpen: + character === "*" + ? leftFlanking + : leftFlanking && (!rightFlanking || previous === "punctuation"), + canClose: + character === "*" + ? rightFlanking + : rightFlanking && (!leftFlanking || next === "punctuation"), + atLineStart: + previousCharacter === undefined || previousCharacter === "\n" || previousCharacter === "\r", + }); + index = end - 1; + } + + return runs; +}; + +const opensBlockConstruct = ( + run: AttentionRun, + slots: readonly EscapeSlot[], + after: string, +): boolean => { + if (!run.atLineStart) { + return false; + } + + let end = run.start; + + while (end < slots.length && slots[end].character !== "\n") { + end += 1; + } + + const line = + slots + .slice(run.start, end) + .map((slot) => slot.character) + .join("") + (end === slots.length ? after.split("\n")[0] : ""); + + const thematicBreak = THEMATIC_BREAK_PATTERNS[run.character].test(line); + const bulletMarker = + run.character === "*" && run.end - run.start === 1 && /^[\t ]/u.test(line.slice(1)); + + return thematicBreak || bulletMarker; +}; + +const relaxAttentionEscapes = ( + slots: EscapeSlot[], + before: string, + after: string, + neighbors: PhrasingNeighbors | undefined, +) => { + const runs = findAttentionRuns( + slots, + before.slice(-1) || undefined, + after.slice(0, 1) || undefined, + ); + + for (const run of runs) { + if (opensBlockConstruct(run, slots, after)) { + continue; + } + + const counterpart = (other: AttentionRun) => other.character === run.character; + const pairable = + !neighbors?.textOnly || + neighbors.earlier.includes(run.character) || + neighbors.later.includes(run.character) || + (run.canOpen && + runs.some((other) => counterpart(other) && other.start > run.start && other.canClose)) || + (run.canClose && + runs.some((other) => counterpart(other) && other.start < run.start && other.canOpen)); + + if ((run.canOpen || run.canClose) && pairable) { + continue; + } + + for (let index = run.start; index < run.end; index += 1) { + slots[index].escaped = false; + } + } +}; + +const relaxBracketEscapes = (slots: EscapeSlot[], neighbors: PhrasingNeighbors | undefined) => { + if (!neighbors) { + return; + } + + let closerAhead = neighbors.laterHasMarkup || neighbors.later.includes("]"); + + for (let index = slots.length - 1; index >= 0; index -= 1) { + const slot = slots[index]; + + if (slot.character === "]") { + closerAhead = true; + } else if (slot.character === "[" && slot.escaped && !closerAhead) { + slot.escaped = false; + } + } + + let openerBehind = neighbors.earlier.includes("["); + + for (let index = 0; index < slots.length; index += 1) { + const slot = slots[index]; + + if (slot.character === "[" && !slot.escaped) { + openerBehind = true; + } else if ( + slot.character === "(" && + slot.escaped && + !openerBehind && + slots[index - 1]?.character === "]" + ) { + slot.escaped = false; + } + } +}; + +const readPhrasingNeighbors = ( + parent: { type: string; children: readonly { type: string; value?: string }[] } | undefined, + index: number, +): PhrasingNeighbors | undefined => { + if (!parent || !WHOLE_LINE_PHRASING_PARENTS.has(parent.type) || index < 0) { + return undefined; + } + + const children = parent.children; + const earlier = children.slice(0, index); + const later = children.slice(index + 1); + const textValues = (nodes: readonly { type: string; value?: string }[]) => + nodes + .filter((child) => child.type === "text") + .map((child) => child.value ?? "") + .join(" "); + + return { + textOnly: children.every((child) => child.type === "text"), + earlier: textValues(earlier), + later: textValues(later), + laterHasMarkup: later.some((child) => child.type !== "text"), + }; +}; export const serializeMarkdownText: NonNullable = ( node: { value: string }, - _parent, + parent, state, info, ) => { const { value } = node; const trailingWhitespace = TRAILING_WHITESPACE_PATTERN.exec(value)?.[0] ?? ""; + const after = trailingWhitespace + info.after; const escaped = state.safe(value.slice(0, value.length - trailingWhitespace.length), { ...info, - after: trailingWhitespace + info.after, + after, }); + const slots = decodeEscapes(escaped); + const neighbors = readPhrasingNeighbors( + parent, + state.indexStack[state.indexStack.length - 1] ?? -1, + ); + + relaxAttentionEscapes(slots, info.before, after, neighbors); + relaxBracketEscapes(slots, neighbors); - return escaped + trailingWhitespace; + return encodeEscapes(slots) + trailingWhitespace; };