From 8d97505ac59067d6c05a5feaeef0b3b6431afa5b Mon Sep 17 00:00:00 2001 From: Minsu <52266597+Gaic4o@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:38:06 +0900 Subject: [PATCH 1/3] feat: add no-heading-like-paragraph rule --- README.md | 1 + docs/rules/no-heading-like-paragraph.md | 73 +++ src/rules/no-heading-like-paragraph.js | 117 ++++ tests/rules/no-heading-like-paragraph.test.js | 522 ++++++++++++++++++ 4 files changed, 713 insertions(+) create mode 100644 docs/rules/no-heading-like-paragraph.md create mode 100644 src/rules/no-heading-like-paragraph.js create mode 100644 tests/rules/no-heading-like-paragraph.test.js diff --git a/README.md b/README.md index f527a680..97d1d238 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ export default defineConfig([ | [`no-empty-definitions`](./docs/rules/no-empty-definitions.md) | Disallow empty definitions | yes | | [`no-empty-images`](./docs/rules/no-empty-images.md) | Disallow empty images | yes | | [`no-empty-links`](./docs/rules/no-empty-links.md) | Disallow empty links | yes | +| [`no-heading-like-paragraph`](./docs/rules/no-heading-like-paragraph.md) | Disallow paragraphs that look like ATX headings | no | | [`no-html`](./docs/rules/no-html.md) | Disallow HTML tags | no | | [`no-invalid-label-refs`](./docs/rules/no-invalid-label-refs.md) | Disallow invalid label references | yes | | [`no-missing-atx-heading-space`](./docs/rules/no-missing-atx-heading-space.md) | Disallow headings without a space after the hash characters | yes | diff --git a/docs/rules/no-heading-like-paragraph.md b/docs/rules/no-heading-like-paragraph.md new file mode 100644 index 00000000..c0b77cef --- /dev/null +++ b/docs/rules/no-heading-like-paragraph.md @@ -0,0 +1,73 @@ +# no-heading-like-paragraph + +Disallow paragraphs that look like ATX headings. + +## Background + +In Markdown, an ATX heading opens with one to six hash (`#`) characters followed by a space or a tab, so `###### Installation` is a level 6 heading. A seventh hash character exceeds the maximum heading depth, and `####### Installation` doesn't create a heading at all. Markdown renders it as a paragraph whose text starts with seven literal hash characters. + +This is almost always a typo, and it's easy to miss in review because the source still reads like a heading. + +## Rule Details + +This rule flags a paragraph that begins with seven or more hash characters followed by a space, a tab, a line ending, or the end of the paragraph. + +This rule ignores anything that can't open an ATX heading. `#######Installation` has no whitespace to delimit the hash characters, and `\####### Installation` and `####### Installation` escape their leading hash character on purpose. + +This rule provides suggestions rather than an automatic fix, because the number of hash characters alone doesn't reveal which correction the author intended: + +* Replace the leading hash characters with `######`, which makes the paragraph a level 6 heading. `####### Installation` becomes `###### Installation`. +* Escape the leading hash character, which leaves the rendered output unchanged. `####### Installation` becomes `\####### Installation`. + +Examples of **incorrect** code for this rule: + +```markdown + + +####### Installation + +######## Configuration + +> ####### Usage + +- ####### Options +``` + +Examples of **correct** code for this rule: + +```markdown + + +###### Installation + +> ###### Usage + +- ###### Options + +#######Configuration + +\####### Not a heading + +Seven ####### characters in the middle of a paragraph. +``` + +This rule only checks the beginning of a paragraph, so it ignores hash characters on a continuation line: + +```markdown +Install the package first. +####### Installation +``` + +Because `####### Installation` can't start a heading, Markdown folds it into the preceding paragraph as a lazy continuation line. The same text with six or fewer hash characters would interrupt the paragraph and become a real heading. + +## When Not to Use It + +If you intentionally write paragraphs that begin with seven or more hash characters, you can safely disable this rule. + +## Prior Art + +* [remark-lint-no-heading-like-paragraph](https://github.com/remarkjs/remark-lint/tree/main/packages/remark-lint-no-heading-like-paragraph) + +## Further Reading + +* [CommonMark Spec: ATX Headings](https://spec.commonmark.org/0.31.2/#atx-headings) diff --git a/src/rules/no-heading-like-paragraph.js b/src/rules/no-heading-like-paragraph.js new file mode 100644 index 00000000..f80197be --- /dev/null +++ b/src/rules/no-heading-like-paragraph.js @@ -0,0 +1,117 @@ +/** + * @fileoverview Rule to disallow paragraphs that look like ATX headings in Markdown. + * @author Gaic4o + */ + +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** + * @import { MarkdownRuleDefinition } from "../types.js"; + * @typedef {"headingLikeParagraph" | "useMaxDepthHashes" | "escapeLeadingHash"} NoHeadingLikeParagraphMessageIds + * @typedef {[]} NoHeadingLikeParagraphOptions + * @typedef {MarkdownRuleDefinition<{ RuleOptions: NoHeadingLikeParagraphOptions, MessageIds: NoHeadingLikeParagraphMessageIds }>} NoHeadingLikeParagraphRuleDefinition + */ + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +/** + * Matches seven or more hash characters at the start of a paragraph, followed by a + * space, a tab, a line ending, or the end of the paragraph. This mirrors the way + * CommonMark delimits the opening sequence of an ATX heading, so a no-break space + * doesn't count as a delimiter. + */ +const headingLikeParagraphPattern = /^#{7,}(?=[ \t\r\n]|$)/u; + +/** The longest opening sequence an ATX heading allows. */ +const maxDepthHashes = "######"; + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +export default /** @satisfies {NoHeadingLikeParagraphRuleDefinition} */ ({ + meta: { + type: "problem", + + docs: { + recommended: false, + description: "Disallow paragraphs that look like ATX headings", + url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-heading-like-paragraph.md", + }, + + hasSuggestions: true, + + messages: { + headingLikeParagraph: + "Unexpected paragraph starting with {{count}} hash characters. ATX headings support at most 6.", + useMaxDepthHashes: 'Replace "{{hashes}}" with "{{maxDepthHashes}}".', + escapeLeadingHash: "Escape the leading hash character.", + }, + }, + + create(context) { + const { sourceCode } = context; + + return { + paragraph(node) { + /* + * Read the raw source text instead of the `value` of the first `text` + * child, because `value` already resolves character escapes and character + * references. Both `\####### Foo` and `####### Foo` render as a + * paragraph whose text starts with seven hash characters, but in each case + * the author escaped the leading hash on purpose. + */ + const match = headingLikeParagraphPattern.exec( + sourceCode.getText(node), + ); + + if (match === null) { + return; + } + + const [hashes] = match; + const startOffset = node.position.start.offset; + const endOffset = startOffset + hashes.length; + + context.report({ + loc: { + start: sourceCode.getLocFromIndex(startOffset), + end: sourceCode.getLocFromIndex(endOffset), + }, + messageId: "headingLikeParagraph", + data: { count: hashes.length }, + + /* + * There's no autofix, because the number of hash characters alone + * doesn't reveal which of the two corrections the author intended. + */ + suggest: [ + { + messageId: "useMaxDepthHashes", + data: { hashes, maxDepthHashes }, + fix(fixer) { + return fixer.replaceTextRange( + [startOffset, endOffset], + maxDepthHashes, + ); + }, + }, + { + messageId: "escapeLeadingHash", + fix(fixer) { + return fixer.insertTextBeforeRange( + [startOffset, startOffset + 1], + "\\", + ); + }, + }, + ], + }); + }, + }; + }, +}); diff --git a/tests/rules/no-heading-like-paragraph.test.js b/tests/rules/no-heading-like-paragraph.test.js new file mode 100644 index 00000000..9e8bc884 --- /dev/null +++ b/tests/rules/no-heading-like-paragraph.test.js @@ -0,0 +1,522 @@ +/** + * @fileoverview Tests for no-heading-like-paragraph rule. + * @author Gaic4o + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/no-heading-like-paragraph.js"; +import markdown from "../../src/index.js"; +import { RuleTester } from "eslint"; +import dedent from "dedent"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + markdown, + }, + language: "markdown/commonmark", +}); + +ruleTester.run("no-heading-like-paragraph", rule, { + valid: [ + // Basic + "", + " ", + "Installation", + "###### Installation", + "# One\n\n## Two\n\n### Three\n\n#### Four\n\n##### Five\n\n###### Six", + + // Setext heading + "####### Installation\n===", + + // Not an opening sequence + "#######Installation", + "Installation ####### Configuration", + "###### ####### Installation", + "#######\u00A0Installation", // a no-break space doesn't delimit an opening sequence + "#######*Installation*", + + // Escapes and character references + "\\####### Installation", + "####### Installation", + + // Code + "```md\n####### Installation\n```", + " ####### Installation", + "`####### Installation`", + + // HTML + "
\n####### Installation\n
", + + // Paragraph continuation line + "Installation\n####### Configuration", + ], + + invalid: [ + // Basic + { + code: "####### Installation", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "###### Installation", + }, + { + messageId: "escapeLeadingHash", + output: "\\####### Installation", + }, + ], + }, + ], + }, + { + code: "######## Configuration", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "8" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 9, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "########", + maxDepthHashes: "######", + }, + output: "###### Configuration", + }, + { + messageId: "escapeLeadingHash", + output: "\\######## Configuration", + }, + ], + }, + ], + }, + { + code: "#######\tInstallation", + errors: [ + { + messageId: "headingLikeParagraph", + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "######\tInstallation", + }, + { + messageId: "escapeLeadingHash", + output: "\\#######\tInstallation", + }, + ], + }, + ], + }, + { + code: "#######", + errors: [ + { + messageId: "headingLikeParagraph", + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "######", + }, + { + messageId: "escapeLeadingHash", + output: "\\#######", + }, + ], + }, + ], + }, + { + code: "####### ", + errors: [ + { + messageId: "headingLikeParagraph", + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "###### ", + }, + { + messageId: "escapeLeadingHash", + output: "\\####### ", + }, + ], + }, + ], + }, + { + code: "#######\nInstallation", + errors: [ + { + messageId: "headingLikeParagraph", + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "######\nInstallation", + }, + { + messageId: "escapeLeadingHash", + output: "\\#######\nInstallation", + }, + ], + }, + ], + }, + { + code: "#######\r\nInstallation", + errors: [ + { + messageId: "headingLikeParagraph", + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "######\r\nInstallation", + }, + { + messageId: "escapeLeadingHash", + output: "\\#######\r\nInstallation", + }, + ], + }, + ], + }, + { + code: "####### Installation #######", + errors: [ + { + messageId: "headingLikeParagraph", + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "###### Installation #######", + }, + { + messageId: "escapeLeadingHash", + output: "\\####### Installation #######", + }, + ], + }, + ], + }, + { + code: "####### **Installation**", + errors: [ + { + messageId: "headingLikeParagraph", + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "###### **Installation**", + }, + { + messageId: "escapeLeadingHash", + output: "\\####### **Installation**", + }, + ], + }, + ], + }, + { + code: "####### Installation\nRun the following command.", + errors: [ + { + messageId: "headingLikeParagraph", + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "###### Installation\nRun the following command.", + }, + { + messageId: "escapeLeadingHash", + output: "\\####### Installation\nRun the following command.", + }, + ], + }, + ], + }, + { + code: dedent`####### Installation + + ######## Configuration`, + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "###### Installation\n\n######## Configuration", + }, + { + messageId: "escapeLeadingHash", + output: "\\####### Installation\n\n######## Configuration", + }, + ], + }, + { + messageId: "headingLikeParagraph", + data: { count: "8" }, + line: 3, + column: 1, + endLine: 3, + endColumn: 9, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "####### Installation\n\n###### Configuration", + }, + { + messageId: "escapeLeadingHash", + output: "####### Installation\n\n\\######## Configuration", + }, + ], + }, + ], + }, + + // Indent + { + code: " ####### Installation", + errors: [ + { + messageId: "headingLikeParagraph", + line: 1, + column: 4, + endLine: 1, + endColumn: 11, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: " ###### Installation", + }, + { + messageId: "escapeLeadingHash", + output: " \\####### Installation", + }, + ], + }, + ], + }, + + // Blockquote + { + code: "> ####### Installation", + errors: [ + { + messageId: "headingLikeParagraph", + line: 1, + column: 3, + endLine: 1, + endColumn: 10, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "> ###### Installation", + }, + { + messageId: "escapeLeadingHash", + output: "> \\####### Installation", + }, + ], + }, + ], + }, + { + code: "> > ####### Installation", + errors: [ + { + messageId: "headingLikeParagraph", + line: 1, + column: 5, + endLine: 1, + endColumn: 12, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "> > ###### Installation", + }, + { + messageId: "escapeLeadingHash", + output: "> > \\####### Installation", + }, + ], + }, + ], + }, + + // List item + { + code: "- ####### Installation", + errors: [ + { + messageId: "headingLikeParagraph", + line: 1, + column: 3, + endLine: 1, + endColumn: 10, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "- ###### Installation", + }, + { + messageId: "escapeLeadingHash", + output: "- \\####### Installation", + }, + ], + }, + ], + }, + { + code: "- Installation\n\n ####### Configuration", + errors: [ + { + messageId: "headingLikeParagraph", + line: 3, + column: 3, + endLine: 3, + endColumn: 10, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "- Installation\n\n ###### Configuration", + }, + { + messageId: "escapeLeadingHash", + output: "- Installation\n\n \\####### Configuration", + }, + ], + }, + ], + }, + ], +}); + +//------------------------------------------------------------------------------ +// GFM Tests +//------------------------------------------------------------------------------ + +const gfmRuleTester = new RuleTester({ + plugins: { + markdown, + }, + language: "markdown/gfm", +}); + +gfmRuleTester.run("no-heading-like-paragraph", rule, { + valid: [ + "###### Installation", + "#######Installation", + "| ####### Installation |\n| --- |", + ], + + invalid: [ + { + code: "####### Installation", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "###### Installation", + }, + { + messageId: "escapeLeadingHash", + output: "\\####### Installation", + }, + ], + }, + ], + }, + { + code: dedent`[^note]: ####### Installation + + Text[^note]`, + errors: [ + { + messageId: "headingLikeParagraph", + line: 1, + column: 10, + endLine: 1, + endColumn: 17, + suggestions: [ + { + messageId: "useMaxDepthHashes", + output: "[^note]: ###### Installation\n\nText[^note]", + }, + { + messageId: "escapeLeadingHash", + output: "[^note]: \\####### Installation\n\nText[^note]", + }, + ], + }, + ], + }, + ], +}); From bfd826fe2459983ad2974802edeba92019ae58a1 Mon Sep 17 00:00:00 2001 From: Minsu <52266597+Gaic4o@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:29:51 +0900 Subject: [PATCH 2/3] fix: apply review feedback --- src/rules/no-heading-like-paragraph.js | 9 +- tests/rules/no-heading-like-paragraph.test.js | 120 +++++++++++++++--- 2 files changed, 101 insertions(+), 28 deletions(-) diff --git a/src/rules/no-heading-like-paragraph.js b/src/rules/no-heading-like-paragraph.js index f80197be..87122e94 100644 --- a/src/rules/no-heading-like-paragraph.js +++ b/src/rules/no-heading-like-paragraph.js @@ -38,7 +38,6 @@ export default /** @satisfies {NoHeadingLikeParagraphRuleDefinition} */ ({ type: "problem", docs: { - recommended: false, description: "Disallow paragraphs that look like ATX headings", url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-heading-like-paragraph.md", }, @@ -48,7 +47,8 @@ export default /** @satisfies {NoHeadingLikeParagraphRuleDefinition} */ ({ messages: { headingLikeParagraph: "Unexpected paragraph starting with {{count}} hash characters. ATX headings support at most 6.", - useMaxDepthHashes: 'Replace "{{hashes}}" with "{{maxDepthHashes}}".', + useMaxDepthHashes: + 'Replace "{{hashes}}" with "{{maxDepthHashes}}".', escapeLeadingHash: "Escape the leading hash character.", }, }, @@ -84,11 +84,6 @@ export default /** @satisfies {NoHeadingLikeParagraphRuleDefinition} */ ({ }, messageId: "headingLikeParagraph", data: { count: hashes.length }, - - /* - * There's no autofix, because the number of hash characters alone - * doesn't reveal which of the two corrections the author intended. - */ suggest: [ { messageId: "useMaxDepthHashes", diff --git a/tests/rules/no-heading-like-paragraph.test.js b/tests/rules/no-heading-like-paragraph.test.js index 9e8bc884..110d3615 100644 --- a/tests/rules/no-heading-like-paragraph.test.js +++ b/tests/rules/no-heading-like-paragraph.test.js @@ -56,6 +56,20 @@ ruleTester.run("no-heading-like-paragraph", rule, { // Paragraph continuation line "Installation\n####### Configuration", + + // GFM + { + code: "###### Installation", + language: "markdown/gfm", + }, + { + code: "#######Installation", + language: "markdown/gfm", + }, + { + code: "| ####### Installation |\n| --- |", + language: "markdown/gfm", + }, ], invalid: [ @@ -119,6 +133,7 @@ ruleTester.run("no-heading-like-paragraph", rule, { errors: [ { messageId: "headingLikeParagraph", + data: { count: "7" }, line: 1, column: 1, endLine: 1, @@ -126,6 +141,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "######\tInstallation", }, { @@ -141,6 +160,7 @@ ruleTester.run("no-heading-like-paragraph", rule, { errors: [ { messageId: "headingLikeParagraph", + data: { count: "7" }, line: 1, column: 1, endLine: 1, @@ -148,6 +168,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "######", }, { @@ -163,6 +187,7 @@ ruleTester.run("no-heading-like-paragraph", rule, { errors: [ { messageId: "headingLikeParagraph", + data: { count: "7" }, line: 1, column: 1, endLine: 1, @@ -170,6 +195,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "###### ", }, { @@ -185,6 +214,7 @@ ruleTester.run("no-heading-like-paragraph", rule, { errors: [ { messageId: "headingLikeParagraph", + data: { count: "7" }, line: 1, column: 1, endLine: 1, @@ -192,6 +222,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "######\nInstallation", }, { @@ -207,6 +241,7 @@ ruleTester.run("no-heading-like-paragraph", rule, { errors: [ { messageId: "headingLikeParagraph", + data: { count: "7" }, line: 1, column: 1, endLine: 1, @@ -214,6 +249,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "######\r\nInstallation", }, { @@ -229,6 +268,7 @@ ruleTester.run("no-heading-like-paragraph", rule, { errors: [ { messageId: "headingLikeParagraph", + data: { count: "7" }, line: 1, column: 1, endLine: 1, @@ -236,6 +276,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "###### Installation #######", }, { @@ -251,6 +295,7 @@ ruleTester.run("no-heading-like-paragraph", rule, { errors: [ { messageId: "headingLikeParagraph", + data: { count: "7" }, line: 1, column: 1, endLine: 1, @@ -258,6 +303,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "###### **Installation**", }, { @@ -273,6 +322,7 @@ ruleTester.run("no-heading-like-paragraph", rule, { errors: [ { messageId: "headingLikeParagraph", + data: { count: "7" }, line: 1, column: 1, endLine: 1, @@ -280,6 +330,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "###### Installation\nRun the following command.", }, { @@ -305,6 +359,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "###### Installation\n\n######## Configuration", }, { @@ -323,6 +381,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "########", + maxDepthHashes: "######", + }, output: "####### Installation\n\n###### Configuration", }, { @@ -340,6 +402,7 @@ ruleTester.run("no-heading-like-paragraph", rule, { errors: [ { messageId: "headingLikeParagraph", + data: { count: "7" }, line: 1, column: 4, endLine: 1, @@ -347,6 +410,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: " ###### Installation", }, { @@ -364,6 +431,7 @@ ruleTester.run("no-heading-like-paragraph", rule, { errors: [ { messageId: "headingLikeParagraph", + data: { count: "7" }, line: 1, column: 3, endLine: 1, @@ -371,6 +439,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "> ###### Installation", }, { @@ -386,6 +458,7 @@ ruleTester.run("no-heading-like-paragraph", rule, { errors: [ { messageId: "headingLikeParagraph", + data: { count: "7" }, line: 1, column: 5, endLine: 1, @@ -393,6 +466,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "> > ###### Installation", }, { @@ -410,6 +487,7 @@ ruleTester.run("no-heading-like-paragraph", rule, { errors: [ { messageId: "headingLikeParagraph", + data: { count: "7" }, line: 1, column: 3, endLine: 1, @@ -417,6 +495,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "- ###### Installation", }, { @@ -432,6 +514,7 @@ ruleTester.run("no-heading-like-paragraph", rule, { errors: [ { messageId: "headingLikeParagraph", + data: { count: "7" }, line: 3, column: 3, endLine: 3, @@ -439,6 +522,10 @@ ruleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "- Installation\n\n ###### Configuration", }, { @@ -449,30 +536,11 @@ ruleTester.run("no-heading-like-paragraph", rule, { }, ], }, - ], -}); - -//------------------------------------------------------------------------------ -// GFM Tests -//------------------------------------------------------------------------------ - -const gfmRuleTester = new RuleTester({ - plugins: { - markdown, - }, - language: "markdown/gfm", -}); - -gfmRuleTester.run("no-heading-like-paragraph", rule, { - valid: [ - "###### Installation", - "#######Installation", - "| ####### Installation |\n| --- |", - ], - invalid: [ + // GFM { code: "####### Installation", + language: "markdown/gfm", errors: [ { messageId: "headingLikeParagraph", @@ -484,6 +552,10 @@ gfmRuleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "###### Installation", }, { @@ -498,9 +570,11 @@ gfmRuleTester.run("no-heading-like-paragraph", rule, { code: dedent`[^note]: ####### Installation Text[^note]`, + language: "markdown/gfm", errors: [ { messageId: "headingLikeParagraph", + data: { count: "7" }, line: 1, column: 10, endLine: 1, @@ -508,6 +582,10 @@ gfmRuleTester.run("no-heading-like-paragraph", rule, { suggestions: [ { messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, output: "[^note]: ###### Installation\n\nText[^note]", }, { From d5381ee3d6e739469711e6eb1fef25c87b7ff488 Mon Sep 17 00:00:00 2001 From: Minsu <52266597+Gaic4o@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:40:22 +0900 Subject: [PATCH 3/3] fix: handle multiline heading-like paragraphs --- docs/rules/no-heading-like-paragraph.md | 27 +- src/rules/no-heading-like-paragraph.js | 96 ++++--- tests/rules/no-heading-like-paragraph.test.js | 256 +++++++++++++++++- 3 files changed, 327 insertions(+), 52 deletions(-) diff --git a/docs/rules/no-heading-like-paragraph.md b/docs/rules/no-heading-like-paragraph.md index c0b77cef..70db1636 100644 --- a/docs/rules/no-heading-like-paragraph.md +++ b/docs/rules/no-heading-like-paragraph.md @@ -4,15 +4,15 @@ Disallow paragraphs that look like ATX headings. ## Background -In Markdown, an ATX heading opens with one to six hash (`#`) characters followed by a space or a tab, so `###### Installation` is a level 6 heading. A seventh hash character exceeds the maximum heading depth, and `####### Installation` doesn't create a heading at all. Markdown renders it as a paragraph whose text starts with seven literal hash characters. +In Markdown, an ATX heading opens with one to six hash (`#`) characters followed by a space, a tab, or a line ending, so `###### Installation` is a level 6 heading. Seven or more hash characters aren't heading syntax at all, so Markdown reads `####### Installation` as paragraph text that begins with seven literal hash characters. This is almost always a typo, and it's easy to miss in review because the source still reads like a heading. ## Rule Details -This rule flags a paragraph that begins with seven or more hash characters followed by a space, a tab, a line ending, or the end of the paragraph. +This rule flags a line of a paragraph that begins with seven or more hash characters followed by a space, a tab, a line ending, or the end of the paragraph. It checks continuation lines as well as the first line, because six or fewer hash characters in the same position would open a real heading. Block quote markers and up to three spaces of indentation may precede the hash characters, the same positions where an ATX heading is allowed to start. -This rule ignores anything that can't open an ATX heading. `#######Installation` has no whitespace to delimit the hash characters, and `\####### Installation` and `####### Installation` escape their leading hash character on purpose. +This rule ignores anything that can't open an ATX heading. `#######Installation` has no whitespace to delimit the hash characters, `\####### Installation` and `####### Installation` escape their leading hash character on purpose, and four or more spaces of indentation are too many for a heading. This rule provides suggestions rather than an automatic fix, because the number of hash characters alone doesn't reveal which correction the author intended: @@ -31,6 +31,13 @@ Examples of **incorrect** code for this rule: > ####### Usage - ####### Options + +Install the package first. +####### Installation + +> foo +> ####### hi +> bar ``` Examples of **correct** code for this rule: @@ -49,16 +56,18 @@ Examples of **correct** code for this rule: \####### Not a heading Seven ####### characters in the middle of a paragraph. -``` - -This rule only checks the beginning of a paragraph, so it ignores hash characters on a continuation line: -```markdown Install the package first. -####### Installation +###### Installation + +> foo +> ###### hi +> bar ``` -Because `####### Installation` can't start a heading, Markdown folds it into the preceding paragraph as a lazy continuation line. The same text with six or fewer hash characters would interrupt the paragraph and become a real heading. +## Options + +This rule has no options. ## When Not to Use It diff --git a/src/rules/no-heading-like-paragraph.js b/src/rules/no-heading-like-paragraph.js index 87122e94..5166d4c8 100644 --- a/src/rules/no-heading-like-paragraph.js +++ b/src/rules/no-heading-like-paragraph.js @@ -19,12 +19,21 @@ //----------------------------------------------------------------------------- /** - * Matches seven or more hash characters at the start of a paragraph, followed by a - * space, a tab, a line ending, or the end of the paragraph. This mirrors the way - * CommonMark delimits the opening sequence of an ATX heading, so a no-break space - * doesn't count as a delimiter. + * Matches seven or more hash characters at the start of a line within a paragraph, + * followed by a space, a tab, a line ending, or the end of the paragraph. This mirrors + * the way CommonMark delimits the opening sequence of an ATX heading, so a no-break + * space doesn't count as a delimiter. + * + * This pattern avoids the `m` flag, which would also treat U+2028 and U+2029 as line + * boundaries even though Markdown doesn't. `(?:^|(?<=[\r\n]))` starts a new line only + * after an actual carriage return or line feed. + * + * Block quote markers and up to three spaces of indentation may precede the hash + * characters, because a heading with six or fewer hash characters would still open in + * that position. */ -const headingLikeParagraphPattern = /^#{7,}(?=[ \t\r\n]|$)/u; +const headingLikeParagraphPattern = + /(?:^|(?<=[\r\n]))(?: {0,3}>[ \t]?)* {0,3}(?#{7,})(?=[ \t\r\n]|$)/gu; /** The longest opening sequence an ATX heading allows. */ const maxDepthHashes = "######"; @@ -65,47 +74,52 @@ export default /** @satisfies {NoHeadingLikeParagraphRuleDefinition} */ ({ * paragraph whose text starts with seven hash characters, but in each case * the author escaped the leading hash on purpose. */ - const match = headingLikeParagraphPattern.exec( - sourceCode.getText(node), - ); + const text = sourceCode.getText(node); - if (match === null) { - return; - } + /** @type {RegExpExecArray | null} */ + let match; - const [hashes] = match; - const startOffset = node.position.start.offset; - const endOffset = startOffset + hashes.length; + while ( + (match = headingLikeParagraphPattern.exec(text)) !== null + ) { + const { hashes } = match.groups; + const startOffset = + node.position.start.offset + + match.index + + match[0].length - + hashes.length; + const endOffset = startOffset + hashes.length; - context.report({ - loc: { - start: sourceCode.getLocFromIndex(startOffset), - end: sourceCode.getLocFromIndex(endOffset), - }, - messageId: "headingLikeParagraph", - data: { count: hashes.length }, - suggest: [ - { - messageId: "useMaxDepthHashes", - data: { hashes, maxDepthHashes }, - fix(fixer) { - return fixer.replaceTextRange( - [startOffset, endOffset], - maxDepthHashes, - ); - }, + context.report({ + loc: { + start: sourceCode.getLocFromIndex(startOffset), + end: sourceCode.getLocFromIndex(endOffset), }, - { - messageId: "escapeLeadingHash", - fix(fixer) { - return fixer.insertTextBeforeRange( - [startOffset, startOffset + 1], - "\\", - ); + messageId: "headingLikeParagraph", + data: { count: hashes.length }, + suggest: [ + { + messageId: "useMaxDepthHashes", + data: { hashes, maxDepthHashes }, + fix(fixer) { + return fixer.replaceTextRange( + [startOffset, endOffset], + maxDepthHashes, + ); + }, }, - }, - ], - }); + { + messageId: "escapeLeadingHash", + fix(fixer) { + return fixer.insertTextBeforeRange( + [startOffset, startOffset + 1], + "\\", + ); + }, + }, + ], + }); + } }, }; }, diff --git a/tests/rules/no-heading-like-paragraph.test.js b/tests/rules/no-heading-like-paragraph.test.js index 110d3615..1ecd61cc 100644 --- a/tests/rules/no-heading-like-paragraph.test.js +++ b/tests/rules/no-heading-like-paragraph.test.js @@ -49,13 +49,24 @@ ruleTester.run("no-heading-like-paragraph", rule, { // Code "```md\n####### Installation\n```", " ####### Installation", + + // InlineCode "`####### Installation`", // HTML "
\n####### Installation\n
", - // Paragraph continuation line - "Installation\n####### Configuration", + // Continuation line that can't open a heading + "foo\n ####### bar", // four spaces of indentation are too many for a heading + "> foo\n> ####### bar", // the block quote marker eats one space, leaving four + + // Line separator (U+2028) and paragraph separator (U+2029) aren't Markdown line + // endings, so the hash characters stay in the middle of a line + "Installation\u2028####### Configuration", + "Installation\u2029####### Configuration", + + // Block quote + "> foo\n> ###### hi\n> bar", // GFM { @@ -425,6 +436,165 @@ ruleTester.run("no-heading-like-paragraph", rule, { ], }, + // Continuation line + { + code: "Some paragraph text.\n####### Installation", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "Some paragraph text.\n###### Installation", + }, + { + messageId: "escapeLeadingHash", + output: "Some paragraph text.\n\\####### Installation", + }, + ], + }, + ], + }, + { + code: "Some paragraph\r\n####### Heading", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "Some paragraph\r\n###### Heading", + }, + { + messageId: "escapeLeadingHash", + output: "Some paragraph\r\n\\####### Heading", + }, + ], + }, + ], + }, + { + code: "Some paragraph\r####### Heading", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "Some paragraph\r###### Heading", + }, + { + messageId: "escapeLeadingHash", + output: "Some paragraph\r\\####### Heading", + }, + ], + }, + ], + }, + { + code: "Some paragraph text.\n ####### Installation", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 2, + column: 4, + endLine: 2, + endColumn: 11, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "Some paragraph text.\n ###### Installation", + }, + { + messageId: "escapeLeadingHash", + output: "Some paragraph text.\n \\####### Installation", + }, + ], + }, + ], + }, + { + code: "Some paragraph text.\n####### Installation\n####### Configuration", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "Some paragraph text.\n###### Installation\n####### Configuration", + }, + { + messageId: "escapeLeadingHash", + output: "Some paragraph text.\n\\####### Installation\n####### Configuration", + }, + ], + }, + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 3, + column: 1, + endLine: 3, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "Some paragraph text.\n####### Installation\n###### Configuration", + }, + { + messageId: "escapeLeadingHash", + output: "Some paragraph text.\n####### Installation\n\\####### Configuration", + }, + ], + }, + ], + }, + // Blockquote { code: "> ####### Installation", @@ -481,6 +651,88 @@ ruleTester.run("no-heading-like-paragraph", rule, { ], }, + { + code: "> foo\n> ####### hi\n> bar", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 2, + column: 3, + endLine: 2, + endColumn: 10, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "> foo\n> ###### hi\n> bar", + }, + { + messageId: "escapeLeadingHash", + output: "> foo\n> \\####### hi\n> bar", + }, + ], + }, + ], + }, + { + code: "> > foo\n> > ####### hi", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 2, + column: 5, + endLine: 2, + endColumn: 12, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "> > foo\n> > ###### hi", + }, + { + messageId: "escapeLeadingHash", + output: "> > foo\n> > \\####### hi", + }, + ], + }, + ], + }, + { + code: "> foo\n####### hi", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "> foo\n###### hi", + }, + { + messageId: "escapeLeadingHash", + output: "> foo\n\\####### hi", + }, + ], + }, + ], + }, + // List item { code: "- ####### Installation",