diff --git a/changelog_unreleased/markdown/19350.md b/changelog_unreleased/markdown/19350.md new file mode 100644 index 000000000..74cc26390 --- /dev/null +++ b/changelog_unreleased/markdown/19350.md @@ -0,0 +1,31 @@ +#### Escape unindented indented pseudo setext headers (#19350 by @tats-u) + +Indented lines composed of only `=` or `-` were previously unindented without being escaped, which caused them to be mistakenly parsed as setext headers. With this change, Prettier now escapes such lines to prevent the misinterpretation: + +[Check parse results on CommonMark playground](https://spec.commonmark.org/dingus/?text=%3C!--%20Input%20--%3E%0ANot%20a%20header%0A%20%20%20%20%3D%3D%3D%0ANot%20a%20header%0A%20%20%20%20---%0ANot%20a%20list%20item%20or%20a%20header%0A%20%20%20%20-%0A%0A------%0A%0A%3C!--%20Prettier%20stable%20--%3E%0ANot%20a%20header%0A%3D%3D%3D%0ANot%20a%20header%0A---%0ANot%20a%20list%20item%20or%20a%20header%20-%0A%0A------%0A%0A%3C!--%20Prettier%20main%20--%3E%0ANot%20a%20header%0A%5C%3D%3D%3D%0ANot%20a%20header%0A%5C---%0ANot%20a%20list%20item%20or%20a%20header%0A%5C-) + + +```markdown + +Not a header + === +Not a header + --- +Not a list item or a header + - + + +Not a header +=== +Not a header +--- +Not a list item or a header - + + +Not a header +\=== +Not a header +\--- +Not a list item or a header +\- +``` diff --git a/src/language-markdown/print/mdast.js b/src/language-markdown/print/mdast.js index f37959f01..21205f44a 100644 --- a/src/language-markdown/print/mdast.js +++ b/src/language-markdown/print/mdast.js @@ -22,6 +22,7 @@ import { getFencedCodeBlockValue, getNthListSiblingIndex, isAutolink, + isNewLine, isPrettierIgnore, splitText, } from "../utilities.js"; @@ -65,6 +66,19 @@ function hasFakeWhitespaceAfterNextToken(path) { return afterNext?.type === "whitespace" && afterNext.value === ""; } +/** + * @param {AstPath} path + * @returns {boolean} + */ +function isNextTokenFakeSetextH2Line(path) { + if (!isNewLine(path.node) || path.next?.value !== "-") { + return false; + } + + const afterNext = path.siblings[path.index + 2]; + return !afterNext || isNewLine(afterNext); +} + function printMdast(path, options, print) { const { node } = path; @@ -116,7 +130,9 @@ function printMdast(path, options, print) { case "sentence": return printSentence(path, print); case "word": - return options.parser !== "mdx" ? printWord(path) : printWordLegacy(path); + return options.parser !== "mdx" + ? printWord(path, options) + : printWordLegacy(path); case "whitespace": { const { next } = path; @@ -125,7 +141,9 @@ function printMdast(path, options, print) { next && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/.test(next.value) && // Avoid https://github.com/prettier/prettier/issues/18861 - !hasFakeWhitespaceAfterNextToken(path) + !hasFakeWhitespaceAfterNextToken(path) && + // Next fake setext h2 `-` is going to be escaped, so no need to join adjacent words + !(options.proseWrap === "preserve" && isNextTokenFakeSetextH2Line(path)) ? "never" : options.proseWrap; diff --git a/src/language-markdown/print/word.js b/src/language-markdown/print/word.js index 2bf7f6843..0e5fbe433 100644 --- a/src/language-markdown/print/word.js +++ b/src/language-markdown/print/word.js @@ -1,5 +1,7 @@ import { PUNCTUATION_REGEXP } from "../constants.evaluate.js"; -import { isAutolink } from "../utilities.js"; +import { isAutolink, isNewLine } from "../utilities.js"; + +const fakeSetextHeaderRegex = /^(?:=+|-+)$/; /** * @import AstPath from "../../common/ast-path.js" @@ -8,25 +10,36 @@ import { isAutolink } from "../utilities.js"; /** * @param {AstPath} path + * @param {*} options * @return {Doc} */ -function printWord(path) { +function printWord(path, options) { const { node } = path; const emphasisOrStrong = path.findAncestor( (p) => p.type === "emphasis" || p.type === "strong", ); - if (!emphasisOrStrong) { - return node.value; - } - const { previous, next, grandparent } = path; let text = node.value; + if (!emphasisOrStrong) { + if ( + options.proseWrap === "preserve" && + path.parent.type === "sentence" && + fakeSetextHeaderRegex.test(text) && + isNewLine(path.previous) && + (path.isLast || isNewLine(path.next)) + ) { + // escape indented pseudo setext header, e.g. `Previous line↵␣␣␣␣===` + return `\\${text}`; + } + + return text; + } // escape leading `*` or `_` if it's the first character in an emphasis/strong if ( path.isFirst && (text.startsWith("*") || text.startsWith("_")) && path.callParent(() => path.isFirst) && - grandparent === emphasisOrStrong + path.grandparent === emphasisOrStrong ) { text = `\\${text}`; } @@ -44,9 +57,9 @@ function printWord(path) { } if ( canOpenOrCloseStrongOrEmphasis( - preceding.at(-1) || previous?.value.at(-1), + preceding.at(-1) || path.previous?.value.at(-1), delimiterRun, - following[0] || next?.value[0], + following[0] || path.next?.value[0], ) ) { return `${preceding}\\${delimiterRun}${following}`; diff --git a/src/language-markdown/utilities.js b/src/language-markdown/utilities.js index d5025e280..a11db88cf 100644 --- a/src/language-markdown/utilities.js +++ b/src/language-markdown/utilities.js @@ -321,6 +321,8 @@ function isSetextHeading(node) { return start.line !== end.line; } +const isNewLine = (node) => node?.type === "whitespace" && node.value === "\n"; + export { getFencedCodeBlockValue, getNthListSiblingIndex, @@ -330,6 +332,7 @@ export { INLINE_NODE_TYPES, INLINE_NODE_WRAPPER_TYPES, isAutolink, + isNewLine, isPrettierIgnore, isSetextHeading, KIND_CJ_LETTER, diff --git a/tests/format/markdown/heading/setext/__snapshots__/format.test.js.snap b/tests/format/markdown/heading/setext/__snapshots__/format.test.js.snap index c6a771e46..420cd2cd8 100644 --- a/tests/format/markdown/heading/setext/__snapshots__/format.test.js.snap +++ b/tests/format/markdown/heading/setext/__snapshots__/format.test.js.snap @@ -124,6 +124,55 @@ Bar ================================================================================ `; +exports[`not-header.md format 1`] = ` +====================================options===================================== +parsers: ["markdown"] + printWidth: 80 (default) | +=====================================input====================================== +\\=== + === +\\=== + --- +\\--- + - +\\- + = +\\= +\\\\--- +\\\\=== + \\- + \\= + \\\\- + \\\\= +-foo ---**bar**===あ=== + +\\- + - + +=====================================output===================================== +\\=== +\\=== +\\=== +\\--- +\\--- +\\- +\\- +\\= +\\= +\\\\--- +\\\\=== +\\- +\\= +\\\\- +\\\\= +-foo ---**bar**===あ=== + +\\- +\\- + +================================================================================ +`; + exports[`setext.md format 1`] = ` ====================================options===================================== parsers: ["markdown"] diff --git a/tests/format/markdown/heading/setext/not-header.md b/tests/format/markdown/heading/setext/not-header.md new file mode 100644 index 000000000..89adc9e41 --- /dev/null +++ b/tests/format/markdown/heading/setext/not-header.md @@ -0,0 +1,19 @@ +\=== + === +\=== + --- +\--- + - +\- + = +\= +\\--- +\\=== + \- + \= + \\- + \\= +-foo ---**bar**===あ=== + +\- + -