diff --git a/changelog_unreleased/markdown/19150.md b/changelog_unreleased/markdown/19150.md new file mode 100644 index 000000000..bc600e1f0 --- /dev/null +++ b/changelog_unreleased/markdown/19150.md @@ -0,0 +1,15 @@ +#### Fix URL formatting edge cases (#19150 by @andersk) + +Avoid corrupting a link, image, or link reference definition with an empty URL and a title, or with certain characters like `<`, `(`, `\`, by using `<...>` wrapping and `\` escaping as appropriate. + + +```md + +[link](<> "title"), ![image](<\<(\\>) + + +[link]( "title"), ![image](<(\) + + +[link](<> "title"), ![image](<\<(\\>) +``` diff --git a/src/language-markdown/print/mdast.js b/src/language-markdown/print/mdast.js index d1f09df33..f37959f01 100644 --- a/src/language-markdown/print/mdast.js +++ b/src/language-markdown/print/mdast.js @@ -1,5 +1,4 @@ import collapseWhiteSpace from "collapse-white-space"; -import escapeStringRegexp from "escape-string-regexp"; import { align, DOC_TYPE_STRING, @@ -206,7 +205,7 @@ function printMdast(path, options, print) { "[", printChildren(path, options, print), "](", - printUrl(node.url, ")"), + node.url === "" && node.title !== null ? "<>" : printUrl(node.url), printTitle(node.title, options), ")", ]; @@ -221,7 +220,7 @@ function printMdast(path, options, print) { "![", printImageAlt(node, options), "](", - printUrl(node.url, ")"), + node.url === "" && node.title !== null ? "<>" : printUrl(node.url), printTitle(node.title, options), ")", ]; @@ -489,30 +488,23 @@ function shouldRemainTheSameContent(path) { ); } -const encodeUrl = (url, characters) => { - for (const character of characters) { - url = url.replaceAll(character, encodeURIComponent(character)); - } - return url; -}; - /** * @param {string} url - * @param {string[] | string} [dangerousCharOrChars] * @returns {string} */ -function printUrl(url, dangerousCharOrChars = []) { - const dangerousChars = [ - " ", - ...(Array.isArray(dangerousCharOrChars) - ? dangerousCharOrChars - : [dangerousCharOrChars]), - ]; - - return new RegExp( - dangerousChars.map((x) => escapeStringRegexp(x)).join("|"), - ).test(url) - ? `<${encodeUrl(url, "<>")}>` +function printUrl(url) { + // Backslash followed by ASCII punctuation would be misinterpreted as an + // escape sequence, so must itself be escaped. + url = url.replaceAll(/\\(?![^!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g, "\\\\"); + // CommonMark forbids ASCII controls, space, unbalanced parentheses, and + // initial <, unless wrapped in <> with any inner < or > escaped. CommonMark + // only suggests implementations "should" support at least three levels of + // parenthesis nesting, so it's unclear whether we can safely rely on three + // levels as we do here, but we certainly can't expect more. + // eslint-disable-next-line no-control-regex + return /[\x00-\x1f\x7f ]|^])/g, String.raw`\$1`)}>` : url; } @@ -529,7 +521,13 @@ function printTitle(title, options, printSpace = true) { title = title.replaceAll(/\\(?=["')])/g, ""); } - if (title.includes('"') && title.includes("'") && !title.includes(")")) { + if ( + title.includes('"') && + title.includes("'") && + !title.includes("(") && + !title.includes(")") + ) { + title = title.replaceAll("\\", "\\\\"); return `(${title})`; // avoid escaped quotes } const quote = getPreferredQuote(title, options.singleQuote); diff --git a/tests/format/markdown/commonmark-test-suite-legacy/__snapshots__/format.test.js.snap b/tests/format/markdown/commonmark-test-suite-legacy/__snapshots__/format.test.js.snap index 9b6957de6..7a7225918 100644 --- a/tests/format/markdown/commonmark-test-suite-legacy/__snapshots__/format.test.js.snap +++ b/tests/format/markdown/commonmark-test-suite-legacy/__snapshots__/format.test.js.snap @@ -981,7 +981,7 @@ proseWrap: "always" [link]((foo)and(bar)) =====================================output===================================== -[link](<(foo)and(bar)>) +[link]((foo)and(bar)) ================================================================================ `; @@ -995,7 +995,7 @@ proseWrap: "always" [link](foo(and\\(bar\\))) =====================================output===================================== -[link]() +[link](foo(and(bar))) ================================================================================ `; @@ -1009,7 +1009,7 @@ proseWrap: "always" [link]() =====================================output===================================== -[link]() +[link](foo(and(bar))) ================================================================================ `; diff --git a/tests/format/markdown/commonmark-test-suite/__snapshots__/format.test.js.snap b/tests/format/markdown/commonmark-test-suite/__snapshots__/format.test.js.snap index 035ac2fd2..6c7d2afa0 100644 --- a/tests/format/markdown/commonmark-test-suite/__snapshots__/format.test.js.snap +++ b/tests/format/markdown/commonmark-test-suite/__snapshots__/format.test.js.snap @@ -8341,7 +8341,7 @@ proseWrap: "always" [link](\\(foo\\)) =====================================output===================================== -[link](<(foo)>) +[link]((foo)) ================================================================================ `; @@ -8355,7 +8355,7 @@ proseWrap: "always" [link](foo(and(bar))) =====================================output===================================== -[link]() +[link](foo(and(bar))) ================================================================================ `; diff --git a/tests/format/markdown/link/__snapshots__/format.test.js.snap b/tests/format/markdown/link/__snapshots__/format.test.js.snap index ee4b4539a..5541b91b0 100644 --- a/tests/format/markdown/link/__snapshots__/format.test.js.snap +++ b/tests/format/markdown/link/__snapshots__/format.test.js.snap @@ -57,28 +57,63 @@ singleQuote: true [link](https://www.google.fr/foo-%3Ebar) [link](https://www.google.fr/foo-) +[link](<> "title") +[link](<>) +[link]() + ![link](https://www.google.fr/()foo->bar) ![link](https://www.google.fr/foo->bar) ![link](https://www.google.fr/foo-%3Ebar) ![link](https://www.google.fr/foo-) +![link](<> "title") +![link](<>) +![link]() + [link]: https://www.google.fr/()foo->bar [link]: https://www.google.fr/foo->bar [link]: https://www.google.fr/foo-%3Ebar [link]: https://www.google.fr/foo- +[link]: <> "title" +[link]: <> + =====================================output===================================== -[link]() -[link](https://www.google.fr/foo->bar) [link](https://www.google.fr/foo-%3Ebar) -[link](https://www.google.fr/foo-) +[link](https://www.google.fr/()foo->bar) [link](https://www.google.fr/foo->bar) +[link](https://www.google.fr/foo-%3Ebar) [link](https://www.google.fr/foo-) [link](<(>) [link](\\\\) +[link](< >) [link](<> 'title') [link]() [link]() + +![link](https://www.google.fr/()foo->bar) ![link](https://www.google.fr/foo->bar) ![link](https://www.google.fr/foo-%3Ebar) ![link](https://www.google.fr/foo-bar [link]: https://www.google.fr/foo->bar [link]: -https://www.google.fr/foo-%3Ebar [link]: https://www.google.fr/foo-) ![link](<(>) ![link](\\\\) +![link](< >) ![link](<> 'title') ![link]() ![link]() + +[link]: https://www.google.fr/()foo->bar +[link]: https://www.google.fr/foo->bar +[link]: https://www.google.fr/foo-%3Ebar +[link]: https://www.google.fr/foo- +[link]: <(> +[link]: \\\\ +[link]: < > +[link]: <> 'title' +[link]: <> ================================================================================ `; @@ -94,28 +129,63 @@ proseWrap: "always" [link](https://www.google.fr/foo-%3Ebar) [link](https://www.google.fr/foo-) +[link](<> "title") +[link](<>) +[link]() + ![link](https://www.google.fr/()foo->bar) ![link](https://www.google.fr/foo->bar) ![link](https://www.google.fr/foo-%3Ebar) ![link](https://www.google.fr/foo-) +![link](<> "title") +![link](<>) +![link]() + [link]: https://www.google.fr/()foo->bar [link]: https://www.google.fr/foo->bar [link]: https://www.google.fr/foo-%3Ebar [link]: https://www.google.fr/foo- +[link]: <> "title" +[link]: <> + =====================================output===================================== -[link]() -[link](https://www.google.fr/foo->bar) [link](https://www.google.fr/foo-%3Ebar) -[link](https://www.google.fr/foo-) +[link](https://www.google.fr/()foo->bar) [link](https://www.google.fr/foo->bar) +[link](https://www.google.fr/foo-%3Ebar) [link](https://www.google.fr/foo-) [link](<(>) [link](\\\\) +[link](< >) [link](<> "title") [link]() [link]() + +![link](https://www.google.fr/()foo->bar) ![link](https://www.google.fr/foo->bar) ![link](https://www.google.fr/foo-%3Ebar) ![link](https://www.google.fr/foo-bar [link]: https://www.google.fr/foo->bar [link]: -https://www.google.fr/foo-%3Ebar [link]: https://www.google.fr/foo-) ![link](<(>) ![link](\\\\) +![link](< >) ![link](<> "title") ![link]() ![link]() + +[link]: https://www.google.fr/()foo->bar +[link]: https://www.google.fr/foo->bar +[link]: https://www.google.fr/foo-%3Ebar +[link]: https://www.google.fr/foo- +[link]: <(> +[link]: \\\\ +[link]: < > +[link]: <> "title" +[link]: <> ================================================================================ `; diff --git a/tests/format/markdown/link/encodedLink.md b/tests/format/markdown/link/encodedLink.md index 151023cad..1ca2e2391 100644 --- a/tests/format/markdown/link/encodedLink.md +++ b/tests/format/markdown/link/encodedLink.md @@ -3,13 +3,35 @@ [link](https://www.google.fr/foo-%3Ebar) [link](https://www.google.fr/foo-) +[link](<> "title") +[link](<>) +[link]() + ![link](https://www.google.fr/()foo->bar) ![link](https://www.google.fr/foo->bar) ![link](https://www.google.fr/foo-%3Ebar) ![link](https://www.google.fr/foo-) +![link](<> "title") +![link](<>) +![link]() + [link]: https://www.google.fr/()foo->bar [link]: https://www.google.fr/foo->bar [link]: https://www.google.fr/foo-%3Ebar [link]: https://www.google.fr/foo- +[link]: <> "title" +[link]: <>