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)", + "\\", + "\\`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: "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