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..70db1636 --- /dev/null +++ b/docs/rules/no-heading-like-paragraph.md @@ -0,0 +1,82 @@ +# 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, 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 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, `\####### 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: + +* 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 + +Install the package first. +####### Installation + +> foo +> ####### hi +> bar +``` + +Examples of **correct** code for this rule: + +```markdown + + +###### Installation + +> ###### Usage + +- ###### Options + +#######Configuration + +\####### Not a heading + +Seven ####### characters in the middle of a paragraph. + +Install the package first. +###### Installation + +> foo +> ###### hi +> bar +``` + +## Options + +This rule has no options. + +## 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..5166d4c8 --- /dev/null +++ b/src/rules/no-heading-like-paragraph.js @@ -0,0 +1,126 @@ +/** + * @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 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 = + /(?:^|(?<=[\r\n]))(?: {0,3}>[ \t]?)* {0,3}(?#{7,})(?=[ \t\r\n]|$)/gu; + +/** The longest opening sequence an ATX heading allows. */ +const maxDepthHashes = "######"; + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +export default /** @satisfies {NoHeadingLikeParagraphRuleDefinition} */ ({ + meta: { + type: "problem", + + docs: { + 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 text = sourceCode.getText(node); + + /** @type {RegExpExecArray | null} */ + let match; + + 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, + ); + }, + }, + { + 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..1ecd61cc --- /dev/null +++ b/tests/rules/no-heading-like-paragraph.test.js @@ -0,0 +1,852 @@ +/** + * @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", + + // InlineCode + "`####### Installation`", + + // HTML + "
\n####### Installation\n
", + + // 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 + { + code: "###### Installation", + language: "markdown/gfm", + }, + { + code: "#######Installation", + language: "markdown/gfm", + }, + { + code: "| ####### Installation |\n| --- |", + language: "markdown/gfm", + }, + ], + + 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", + data: { count: "7" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "######\tInstallation", + }, + { + messageId: "escapeLeadingHash", + output: "\\#######\tInstallation", + }, + ], + }, + ], + }, + { + code: "#######", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "######", + }, + { + messageId: "escapeLeadingHash", + output: "\\#######", + }, + ], + }, + ], + }, + { + code: "####### ", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "###### ", + }, + { + messageId: "escapeLeadingHash", + output: "\\####### ", + }, + ], + }, + ], + }, + { + code: "#######\nInstallation", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "######\nInstallation", + }, + { + messageId: "escapeLeadingHash", + output: "\\#######\nInstallation", + }, + ], + }, + ], + }, + { + code: "#######\r\nInstallation", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "######\r\nInstallation", + }, + { + messageId: "escapeLeadingHash", + output: "\\#######\r\nInstallation", + }, + ], + }, + ], + }, + { + 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: "####### **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: "####### Installation\nRun the following command.", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 8, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + 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", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + 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", + data: { + hashes: "########", + maxDepthHashes: "######", + }, + output: "####### Installation\n\n###### Configuration", + }, + { + messageId: "escapeLeadingHash", + output: "####### Installation\n\n\\######## Configuration", + }, + ], + }, + ], + }, + + // Indent + { + code: " ####### Installation", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 1, + column: 4, + endLine: 1, + endColumn: 11, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: " ###### Installation", + }, + { + messageId: "escapeLeadingHash", + output: " \\####### Installation", + }, + ], + }, + ], + }, + + // 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", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 1, + column: 3, + endLine: 1, + endColumn: 10, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "> ###### Installation", + }, + { + messageId: "escapeLeadingHash", + output: "> \\####### Installation", + }, + ], + }, + ], + }, + { + code: "> > ####### Installation", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 1, + column: 5, + endLine: 1, + endColumn: 12, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "> > ###### Installation", + }, + { + messageId: "escapeLeadingHash", + output: "> > \\####### Installation", + }, + ], + }, + ], + }, + + { + 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", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 1, + column: 3, + endLine: 1, + endColumn: 10, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "- ###### Installation", + }, + { + messageId: "escapeLeadingHash", + output: "- \\####### Installation", + }, + ], + }, + ], + }, + { + code: "- Installation\n\n ####### Configuration", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 3, + column: 3, + endLine: 3, + endColumn: 10, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "- Installation\n\n ###### Configuration", + }, + { + messageId: "escapeLeadingHash", + output: "- Installation\n\n \\####### Configuration", + }, + ], + }, + ], + }, + + // GFM + { + code: "####### Installation", + language: "markdown/gfm", + 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: dedent`[^note]: ####### Installation + + Text[^note]`, + language: "markdown/gfm", + errors: [ + { + messageId: "headingLikeParagraph", + data: { count: "7" }, + line: 1, + column: 10, + endLine: 1, + endColumn: 17, + suggestions: [ + { + messageId: "useMaxDepthHashes", + data: { + hashes: "#######", + maxDepthHashes: "######", + }, + output: "[^note]: ###### Installation\n\nText[^note]", + }, + { + messageId: "escapeLeadingHash", + output: "[^note]: \\####### Installation\n\nText[^note]", + }, + ], + }, + ], + }, + ], +});