Skip to content

Commit d7c88e8

Browse files
authored
feat: add no-multiple-h1 rule (#377)
* feat: add no-multiple-h1 rule * add a test where the heading is inside of a code block * support front matter titles * review comments * review comments * review comments * review comments
1 parent 84935ef commit d7c88e8

File tree

4 files changed

+1028
-0
lines changed

4 files changed

+1028
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export default defineConfig([
8484
| [`no-invalid-label-refs`](./docs/rules/no-invalid-label-refs.md) | Disallow invalid label references | yes |
8585
| [`no-missing-atx-heading-space`](./docs/rules/no-missing-atx-heading-space.md) | Disallow headings without a space after the hash characters | yes |
8686
| [`no-missing-label-refs`](./docs/rules/no-missing-label-refs.md) | Disallow missing label references | yes |
87+
| [`no-multiple-h1`](./docs/rules/no-multiple-h1.md) | Disallow multiple H1 headings in the same document | yes |
8788
| [`require-alt-text`](./docs/rules/require-alt-text.md) | Require alternative text for images | yes |
8889
<!-- Rule Table End -->
8990

docs/rules/no-multiple-h1.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# no-multiple-h1
2+
3+
Disallow multiple H1 headings in the same document.
4+
5+
## Background
6+
7+
An H1 heading is meant to define the main heading of a page, providing important structural information for both users and assistive technologies. Using more than one H1 heading per page can cause confusion for screen readers, dilute SEO signals, and break the logical content hierarchy. While modern search engines are more forgiving, best practice is to use a single H1 heading to ensure clarity and accessibility.
8+
9+
## Rule Details
10+
11+
This rule warns when it finds more than one H1 heading in a Markdown document. It checks for:
12+
13+
- ATX-style headings (`# Heading`)
14+
- Setext-style headings (`Heading\n=========`)
15+
- Front matter title fields (YAML and TOML)
16+
- HTML h1 tags (`<h1>Heading</h1>`)
17+
18+
Examples of **incorrect** code for this rule:
19+
20+
```markdown
21+
<!-- eslint markdown/no-multiple-h1: "error" -->
22+
23+
# Heading 1
24+
25+
# Another H1 heading
26+
```
27+
28+
```markdown
29+
<!-- eslint markdown/no-multiple-h1: "error" -->
30+
31+
# Heading 1
32+
33+
Another H1 heading
34+
==================
35+
```
36+
37+
```markdown
38+
<!-- eslint markdown/no-multiple-h1: "error" -->
39+
40+
<h1>First Heading</h1>
41+
42+
<h1>Second Heading</h1>
43+
```
44+
45+
## Options
46+
47+
The following options are available on this rule:
48+
49+
* `frontmatterTitle: string` - A regex pattern to match title fields in front matter. The default pattern matches both YAML (`title:`) and TOML (`title =`) formats. Set to an empty string to disable front matter title checking.
50+
51+
Examples of **incorrect** code for this rule:
52+
53+
```markdown
54+
<!-- eslint markdown/no-multiple-h1: "error" -->
55+
56+
---
57+
title: My Title
58+
---
59+
60+
# Heading 1
61+
```
62+
63+
Examples of **incorrect** code when configured as `"no-multiple-h1": ["error", { "frontmatterTitle": "\\s*heading\\s*[:=]" }]`:
64+
65+
```markdown
66+
<!-- eslint markdown/no-multiple-h1: ["error", { "frontmatterTitle": "\\s*heading\\s*[:=]" }] -->
67+
68+
---
69+
heading: My Title
70+
---
71+
72+
# Heading 1
73+
```
74+
75+
Examples of **correct** code when configured as `"no-multiple-h1": ["error", { "frontmatterTitle": "" }]`:
76+
77+
```markdown
78+
<!-- eslint markdown/no-multiple-h1: ["error", { frontmatterTitle: "" }] -->
79+
80+
---
81+
title: My Title
82+
---
83+
84+
# Heading 1
85+
```
86+
87+
## When Not to Use It
88+
89+
If you have a specific use case that requires multiple H1 headings in a single Markdown document, you can safely disable this rule. However, this is not recommended.
90+
91+
## Prior Art
92+
93+
* [MD025 - Multiple top-level headings in the same document](https://github.com/DavidAnson/markdownlint/blob/main/doc/md025.md)

src/rules/no-multiple-h1.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* @fileoverview Rule to enforce at most one H1 heading in Markdown.
3+
* @author Pixel998
4+
*/
5+
6+
//-----------------------------------------------------------------------------
7+
// Imports
8+
//-----------------------------------------------------------------------------
9+
10+
import { findOffsets } from "../util.js";
11+
12+
//-----------------------------------------------------------------------------
13+
// Type Definitions
14+
//-----------------------------------------------------------------------------
15+
16+
/**
17+
* @typedef {import("../types.ts").MarkdownRuleDefinition<{ RuleOptions: [{ frontmatterTitle?: string; }]; }>}
18+
* NoMultipleH1RuleDefinition
19+
*/
20+
21+
//-----------------------------------------------------------------------------
22+
// Helpers
23+
//-----------------------------------------------------------------------------
24+
25+
const h1TagPattern = /(?<!<!--[\s\S]*?)<h1[^>]*>[\s\S]*?<\/h1>/giu;
26+
27+
/**
28+
* Checks if a frontmatter block contains a title matching the given pattern
29+
* @param {string} value The frontmatter content
30+
* @param {RegExp|null} pattern The pattern to match against
31+
* @returns {boolean} Whether a title was found
32+
*/
33+
function frontmatterHasTitle(value, pattern) {
34+
if (!pattern) {
35+
return false;
36+
}
37+
const lines = value.split("\n");
38+
for (const line of lines) {
39+
if (pattern.test(line)) {
40+
return true;
41+
}
42+
}
43+
return false;
44+
}
45+
46+
//-----------------------------------------------------------------------------
47+
// Rule Definition
48+
//-----------------------------------------------------------------------------
49+
50+
/** @type {NoMultipleH1RuleDefinition} */
51+
export default {
52+
meta: {
53+
type: "problem",
54+
55+
docs: {
56+
recommended: true,
57+
description: "Disallow multiple H1 headings in the same document",
58+
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-multiple-h1.md",
59+
},
60+
61+
messages: {
62+
multipleH1: "Unexpected additional H1 heading found.",
63+
},
64+
65+
schema: [
66+
{
67+
type: "object",
68+
properties: {
69+
frontmatterTitle: {
70+
type: "string",
71+
},
72+
},
73+
additionalProperties: false,
74+
},
75+
],
76+
77+
defaultOptions: [
78+
{ frontmatterTitle: "^\\s*['\"]?title['\"]?\\s*[:=]" },
79+
],
80+
},
81+
82+
create(context) {
83+
const [{ frontmatterTitle }] = context.options;
84+
const titlePattern =
85+
frontmatterTitle === "" ? null : new RegExp(frontmatterTitle, "iu");
86+
let h1Count = 0;
87+
88+
return {
89+
yaml(node) {
90+
if (frontmatterHasTitle(node.value, titlePattern)) {
91+
h1Count++;
92+
}
93+
},
94+
95+
toml(node) {
96+
if (frontmatterHasTitle(node.value, titlePattern)) {
97+
h1Count++;
98+
}
99+
},
100+
101+
html(node) {
102+
let match;
103+
while ((match = h1TagPattern.exec(node.value)) !== null) {
104+
h1Count++;
105+
if (h1Count > 1) {
106+
const {
107+
lineOffset: startLineOffset,
108+
columnOffset: startColumnOffset,
109+
} = findOffsets(node.value, match.index);
110+
111+
const {
112+
lineOffset: endLineOffset,
113+
columnOffset: endColumnOffset,
114+
} = findOffsets(
115+
node.value,
116+
match.index + match[0].length,
117+
);
118+
119+
const nodeStartLine = node.position.start.line;
120+
const nodeStartColumn = node.position.start.column;
121+
const startLine = nodeStartLine + startLineOffset;
122+
const endLine = nodeStartLine + endLineOffset;
123+
const startColumn =
124+
(startLine === nodeStartLine
125+
? nodeStartColumn
126+
: 1) + startColumnOffset;
127+
const endColumn =
128+
(endLine === nodeStartLine ? nodeStartColumn : 1) +
129+
endColumnOffset;
130+
131+
context.report({
132+
loc: {
133+
start: {
134+
line: startLine,
135+
column: startColumn,
136+
},
137+
end: {
138+
line: endLine,
139+
column: endColumn,
140+
},
141+
},
142+
messageId: "multipleH1",
143+
});
144+
}
145+
}
146+
},
147+
148+
heading(node) {
149+
if (node.depth === 1) {
150+
h1Count++;
151+
if (h1Count > 1) {
152+
context.report({
153+
loc: node.position,
154+
messageId: "multipleH1",
155+
});
156+
}
157+
}
158+
},
159+
};
160+
},
161+
};

0 commit comments

Comments
 (0)