import { describe, expect, it } from "vitest"; import { escapeUnsupportedAngleBrackets, unescapeAngleBracketEscapes, } from "./angle-bracket-markdown"; describe("escapeUnsupportedAngleBrackets", () => { it("leaves markdown without angle brackets untouched", () => { expect(escapeUnsupportedAngleBrackets("")).toBe(""); expect(escapeUnsupportedAngleBrackets("plain prose")).toBe("plain prose"); }); it("escapes a placeholder written in prose", () => { expect(escapeUnsupportedAngleBrackets("Rename to the real name")).toBe( "Rename \\ to the real name", ); }); it("escapes closing tags, comments and processing instructions", () => { expect(escapeUnsupportedAngleBrackets("a b")).toBe("a \\ b"); expect(escapeUnsupportedAngleBrackets("a b")).toBe("a \\ b"); expect(escapeUnsupportedAngleBrackets("a { expect(escapeUnsupportedAngleBrackets(" and ")).toBe("\\ and \\"); }); it("leaves a bracket that cannot open HTML alone", () => { expect(escapeUnsupportedAngleBrackets("Affected versions: <= v0.3.1")).toBe( "Affected versions: <= v0.3.1", ); expect(escapeUnsupportedAngleBrackets("2 < 3 and 4 > 1")).toBe("2 < 3 and 4 > 1"); expect(escapeUnsupportedAngleBrackets("a <1 b")).toBe("a <1 b"); expect(escapeUnsupportedAngleBrackets("trailing <")).toBe("trailing <"); }); it("escapes a bracket next to one that cannot open HTML", () => { expect(escapeUnsupportedAngleBrackets("use <= or ")).toBe("use <= or \\"); }); it("does not double-escape an already-escaped bracket", () => { expect(escapeUnsupportedAngleBrackets("Rename \\ here")).toBe("Rename \\ here"); }); it("escapes a bracket that follows a literal backslash", () => { // `\\` is an escaped backslash, so the `<` after it is still bare markup. expect(escapeUnsupportedAngleBrackets("a \\\\ b")).toBe("a \\\\\\ b"); }); it("skips inline code spans", () => { expect(escapeUnsupportedAngleBrackets("Use `` here")).toBe("Use `` here"); expect(escapeUnsupportedAngleBrackets("Use ``a c`` here")).toBe("Use ``a c`` here"); }); it("escapes outside a code span on the same line", () => { expect(escapeUnsupportedAngleBrackets("`` then ")).toBe("`` then \\"); }); it("treats an unmatched backtick as literal text, not a code span", () => { // CommonMark only forms a code span from a matched pair of equal-length // backtick runs, so the bracket after a lone backtick is still markup. expect(escapeUnsupportedAngleBrackets("a ` b c")).toBe("a ` b \\ c"); expect(escapeUnsupportedAngleBrackets("a ``b` c ")).toBe("a ``b` c \\"); }); it("treats a backslash-escaped backtick as literal text", () => { expect(escapeUnsupportedAngleBrackets("a \\`x` b ")).toBe("a \\`x` b \\"); }); it("skips a code span that wraps across a soft line break", () => { expect(escapeUnsupportedAngleBrackets("a `one\ntwo ` b ")).toBe( "a `one\ntwo ` b \\", ); }); it("does not carry a code span across a blank line", () => { // A blank line ends the paragraph, so the opening backtick never matches. expect(escapeUnsupportedAngleBrackets("a `one \n\ntwo` b ")).toBe( "a `one \\\n\ntwo` b \\", ); }); it("skips fenced code blocks", () => { expect(escapeUnsupportedAngleBrackets("```\n\n```\n")).toBe( "```\n\n```\n\\", ); expect(escapeUnsupportedAngleBrackets("~~~ts\n\n~~~")).toBe("~~~ts\n\n~~~"); }); it("skips a fenced code block nested in a blockquote", () => { expect(escapeUnsupportedAngleBrackets("> ```\n> \n> ```\n")).toBe( "> ```\n> \n> ```\n\\", ); }); it("skips a fenced code block nested in a list", () => { expect(escapeUnsupportedAngleBrackets("- ```\n \n ```\n")).toBe( "- ```\n \n ```\n\\", ); }); it("does not close a fence on a shorter run of the same character", () => { expect(escapeUnsupportedAngleBrackets("````\n\n```\n\n````\n")).toBe( "````\n\n```\n\n````\n\\", ); }); it("skips indented code blocks", () => { expect(escapeUnsupportedAngleBrackets("intro\n\n \n\nafter ")).toBe( "intro\n\n \n\nafter \\", ); expect(escapeUnsupportedAngleBrackets("\t")).toBe("\t"); }); it("escapes an indented lazy paragraph continuation", () => { // Indented code cannot interrupt a paragraph, so this line is prose. expect(escapeUnsupportedAngleBrackets("para\n more here")).toBe( "para\n more \\ here", ); }); it("escapes prose inside a blockquote but skips quoted indented code", () => { expect(escapeUnsupportedAngleBrackets("> Rename here")).toBe( "> Rename \\ here", ); expect(escapeUnsupportedAngleBrackets(">\n> ")).toBe(">\n> "); }); it("skips autolinks", () => { expect(escapeUnsupportedAngleBrackets("See for more")).toBe( "See for more", ); expect(escapeUnsupportedAngleBrackets("Mail now")).toBe( "Mail now", ); }); it("escapes a tag that only looks like an autolink", () => { expect(escapeUnsupportedAngleBrackets("")).toBe("\\"); }); it("skips pointed-bracket link destinations", () => { expect(escapeUnsupportedAngleBrackets("[a]()")).toBe("[a]()"); expect(escapeUnsupportedAngleBrackets("![i]()")).toBe("![i]()"); }); it("still escapes prose around a pointed link destination", () => { expect(escapeUnsupportedAngleBrackets("[a]() then ")).toBe( "[a]() then \\", ); }); it("is idempotent", () => { const inputs = [ "Rename to the real name", "Use `` and ", "a \\\\ b", "```\n\n```\n", "See and ", "[a]() then ", ]; for (const input of inputs) { const once = escapeUnsupportedAngleBrackets(input); expect(escapeUnsupportedAngleBrackets(once)).toBe(once); } }); }); describe("unescapeAngleBracketEscapes", () => { it("leaves markdown without escapes untouched", () => { expect(unescapeAngleBracketEscapes("")).toBe(""); expect(unescapeAngleBracketEscapes("Rename here")).toBe("Rename here"); }); it("drops the backslash from an escaped html-like bracket", () => { expect(unescapeAngleBracketEscapes("Rename \\ here")).toBe("Rename here"); expect(unescapeAngleBracketEscapes("a \\ b")).toBe("a b"); expect(unescapeAngleBracketEscapes("a \\ b")).toBe("a b"); expect(unescapeAngleBracketEscapes("a \\ { expect(unescapeAngleBracketEscapes("a \\<= b")).toBe("a \\<= b"); expect(unescapeAngleBracketEscapes("a \\<1 b")).toBe("a \\<1 b"); }); it("leaves other backslash escapes alone", () => { expect(unescapeAngleBracketEscapes("a \\> b and \\* c and \\")).toBe( "a \\> b and \\* c and ", ); }); it("keeps backslash parity", () => { // `\\` is a literal backslash; the `` after it is already bare. expect(unescapeAngleBracketEscapes("a \\\\ b")).toBe("a \\\\ b"); // `\\` then `\<` is a literal backslash followed by an escaped bracket. expect(unescapeAngleBracketEscapes("a \\\\\\ b")).toBe("a \\\\ b"); }); it("keeps an escape that would otherwise open an autolink", () => { // `\` renders as the literal text ``. // Dropping the backslash would publish a link the author escaped on purpose, // and the escape half never escapes an autolink, so there is nothing to undo. expect(unescapeAngleBracketEscapes("See \\ here")).toBe( "See \\ here", ); expect(unescapeAngleBracketEscapes("Mail \\ now")).toBe( "Mail \\ now", ); }); it("keeps an escape that would otherwise open a pointed link destination", () => { expect(unescapeAngleBracketEscapes("[a](\\)")).toBe("[a](\\)"); }); it("still unescapes a tag that only looks like an autolink", () => { expect(unescapeAngleBracketEscapes("\\")).toBe(""); // The scheme needs at least two characters, so this is not an autolink. expect(unescapeAngleBracketEscapes("\\")).toBe(""); }); it("skips code spans and code blocks", () => { expect(unescapeAngleBracketEscapes("Use `\\` here")).toBe("Use `\\` here"); expect(unescapeAngleBracketEscapes("```\n\\\n```\n\\")).toBe( "```\n\\\n```\n", ); expect(unescapeAngleBracketEscapes("intro\n\n \\\n\nafter \\")).toBe( "intro\n\n \\\n\nafter ", ); }); it("is idempotent", () => { const inputs = [ "Rename \\ here", "a \\\\\\ b", "Use `\\` and \\", "```\n\\\n```\n\\", ]; for (const input of inputs) { const once = unescapeAngleBracketEscapes(input); expect(unescapeAngleBracketEscapes(once)).toBe(once); } }); }); describe("angle bracket escape round trip", () => { const cases = [ "Rename to the real name", "Affected versions: <= v0.3.1", "5. python3 sync.py --input -- writes insights//*.md", "a and and ` but not ", "```\n\n```\n", "intro\n\n \n\nafter ", "See and mail , then ", "[a]() then ", "a \\\\ b", "> quoted ", "- item \n- other ", "para\n more here", "a `one\ntwo ` b ", "plain prose with no brackets", // An author's escaped autolink must survive an edit as literal text. "See \\ here", "Mail \\ now", "", ]; it("returns the original markdown after escaping and unescaping", () => { for (const input of cases) { expect(unescapeAngleBracketEscapes(escapeUnsupportedAngleBrackets(input))).toBe(input); } }); it("normalizes a pre-escaped bracket to its clean form", () => { // `\` and `` are the same literal text; the clean form is what // this product stores, and the rewrite only happens on a real edit. expect(unescapeAngleBracketEscapes(escapeUnsupportedAngleBrackets("Rename \\"))).toBe( "Rename ", ); }); });