Skip to content

Commit d822987

Browse files
authored
feat: new rule main-require (#1608)
1 parent 74d475e commit d822987

File tree

14 files changed

+217
-7
lines changed

14 files changed

+217
-7
lines changed

dist/core/rules/index.js

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/htmlhint.js

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,58 @@
13291329
return inputRequiresLabel;
13301330
}
13311331

1332+
var mainRequire = {};
1333+
1334+
var hasRequiredMainRequire;
1335+
1336+
function requireMainRequire () {
1337+
if (hasRequiredMainRequire) return mainRequire;
1338+
hasRequiredMainRequire = 1;
1339+
Object.defineProperty(mainRequire, "__esModule", { value: true });
1340+
mainRequire.default = {
1341+
id: 'main-require',
1342+
description: '<main> must be present in <body> tag.',
1343+
init(parser, reporter) {
1344+
let bodyDepth = 0;
1345+
let hasMainInBody = false;
1346+
let bodyTagEvent = null;
1347+
const onTagStart = (event) => {
1348+
const tagName = event.tagName.toLowerCase();
1349+
if (tagName === 'body') {
1350+
bodyDepth++;
1351+
if (bodyDepth === 1) {
1352+
hasMainInBody = false;
1353+
bodyTagEvent = event;
1354+
}
1355+
}
1356+
else if (tagName === 'main' && bodyDepth > 0) {
1357+
hasMainInBody = true;
1358+
}
1359+
};
1360+
const onTagEnd = (event) => {
1361+
const tagName = event.tagName.toLowerCase();
1362+
if (tagName === 'body') {
1363+
if (bodyDepth === 1 && !hasMainInBody && bodyTagEvent) {
1364+
reporter.warn('<main> must be present in <body> tag.', bodyTagEvent.line, bodyTagEvent.col, this, bodyTagEvent.raw);
1365+
}
1366+
bodyDepth--;
1367+
if (bodyDepth < 0)
1368+
bodyDepth = 0;
1369+
}
1370+
};
1371+
parser.addListener('tagstart', onTagStart);
1372+
parser.addListener('tagend', onTagEnd);
1373+
parser.addListener('end', () => {
1374+
if (bodyDepth > 0 && !hasMainInBody && bodyTagEvent) {
1375+
reporter.warn('<main> must be present in <body> tag.', bodyTagEvent.line, bodyTagEvent.col, this, bodyTagEvent.raw);
1376+
}
1377+
});
1378+
},
1379+
};
1380+
1381+
return mainRequire;
1382+
}
1383+
13321384
var scriptDisabled = {};
13331385

13341386
var hasRequiredScriptDisabled;
@@ -1804,7 +1856,7 @@
18041856
hasRequiredRules = 1;
18051857
(function (exports) {
18061858
Object.defineProperty(exports, "__esModule", { value: true });
1807-
exports.titleRequire = exports.tagSelfClose = exports.tagsCheck = exports.tagPair = exports.tagnameSpecialChars = exports.tagnameLowercase = exports.styleDisabled = exports.srcNotEmpty = exports.specCharEscape = exports.spaceTabMixedDisabled = exports.scriptDisabled = exports.inputRequiresLabel = exports.inlineStyleDisabled = exports.inlineScriptDisabled = exports.idUnique = exports.idClassValue = exports.idClassAdDisabled = exports.htmlLangRequire = exports.hrefAbsOrRel = exports.headScriptDisabled = exports.h1Require = exports.emptyTagNotSelfClosed = exports.doctypeHTML5 = exports.doctypeFirst = exports.attrWhitespace = exports.attrValueSingleQuotes = exports.attrValueNotEmpty = exports.attrValueDoubleQuotes = exports.attrUnsafeChars = exports.attrSort = exports.attrNoUnnecessaryWhitespace = exports.attrNoDuplication = exports.attrLowercase = exports.altRequire = void 0;
1859+
exports.titleRequire = exports.tagSelfClose = exports.tagsCheck = exports.tagPair = exports.tagnameSpecialChars = exports.tagnameLowercase = exports.styleDisabled = exports.srcNotEmpty = exports.specCharEscape = exports.spaceTabMixedDisabled = exports.scriptDisabled = exports.mainRequire = exports.inputRequiresLabel = exports.inlineStyleDisabled = exports.inlineScriptDisabled = exports.idUnique = exports.idClassValue = exports.idClassAdDisabled = exports.htmlLangRequire = exports.hrefAbsOrRel = exports.headScriptDisabled = exports.h1Require = exports.emptyTagNotSelfClosed = exports.doctypeHTML5 = exports.doctypeFirst = exports.attrWhitespace = exports.attrValueSingleQuotes = exports.attrValueNotEmpty = exports.attrValueDoubleQuotes = exports.attrUnsafeChars = exports.attrSort = exports.attrNoUnnecessaryWhitespace = exports.attrNoDuplication = exports.attrLowercase = exports.altRequire = void 0;
18081860
var alt_require_1 = requireAltRequire();
18091861
Object.defineProperty(exports, "altRequire", { enumerable: true, get: function () { return alt_require_1.default; } });
18101862
var attr_lowercase_1 = requireAttrLowercase();
@@ -1851,6 +1903,8 @@
18511903
Object.defineProperty(exports, "inlineStyleDisabled", { enumerable: true, get: function () { return inline_style_disabled_1.default; } });
18521904
var input_requires_label_1 = requireInputRequiresLabel();
18531905
Object.defineProperty(exports, "inputRequiresLabel", { enumerable: true, get: function () { return input_requires_label_1.default; } });
1906+
var main_require_1 = requireMainRequire();
1907+
Object.defineProperty(exports, "mainRequire", { enumerable: true, get: function () { return main_require_1.default; } });
18541908
var script_disabled_1 = requireScriptDisabled();
18551909
Object.defineProperty(exports, "scriptDisabled", { enumerable: true, get: function () { return script_disabled_1.default; } });
18561910
var space_tab_mixed_disabled_1 = requireSpaceTabMixedDisabled();

dist/htmlhint.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/rules/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export { default as idUnique } from './id-unique'
2121
export { default as inlineScriptDisabled } from './inline-script-disabled'
2222
export { default as inlineStyleDisabled } from './inline-style-disabled'
2323
export { default as inputRequiresLabel } from './input-requires-label'
24+
export { default as mainRequire } from './main-require'
2425
export { default as scriptDisabled } from './script-disabled'
2526
export { default as spaceTabMixedDisabled } from './space-tab-mixed-disabled'
2627
export { default as specCharEscape } from './spec-char-escape'

src/core/rules/main-require.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Block, Listener } from '../htmlparser'
2+
import { Rule } from '../types'
3+
4+
export default {
5+
id: 'main-require',
6+
description: '<main> must be present in <body> tag.',
7+
init(parser, reporter) {
8+
let bodyDepth = 0
9+
let hasMainInBody = false
10+
let bodyTagEvent: Block | null = null
11+
12+
const onTagStart: Listener = (event: Block) => {
13+
const tagName = event.tagName.toLowerCase()
14+
if (tagName === 'body') {
15+
bodyDepth++
16+
if (bodyDepth === 1) {
17+
hasMainInBody = false
18+
bodyTagEvent = event
19+
}
20+
} else if (tagName === 'main' && bodyDepth > 0) {
21+
hasMainInBody = true
22+
}
23+
}
24+
25+
const onTagEnd: Listener = (event: Block) => {
26+
const tagName = event.tagName.toLowerCase()
27+
if (tagName === 'body') {
28+
if (bodyDepth === 1 && !hasMainInBody && bodyTagEvent) {
29+
reporter.warn(
30+
'<main> must be present in <body> tag.',
31+
bodyTagEvent.line,
32+
bodyTagEvent.col,
33+
this,
34+
bodyTagEvent.raw
35+
)
36+
}
37+
bodyDepth--
38+
if (bodyDepth < 0) bodyDepth = 0
39+
}
40+
}
41+
42+
parser.addListener('tagstart', onTagStart)
43+
parser.addListener('tagend', onTagEnd)
44+
parser.addListener('end', () => {
45+
// Handle case where <body> is not closed (malformed HTML)
46+
if (bodyDepth > 0 && !hasMainInBody && bodyTagEvent) {
47+
reporter.warn(
48+
'<main> must be present in <body> tag.',
49+
bodyTagEvent.line,
50+
bodyTagEvent.col,
51+
this,
52+
bodyTagEvent.raw
53+
)
54+
}
55+
})
56+
},
57+
} as Rule

src/core/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export interface Ruleset {
3333
'inline-script-disabled'?: boolean
3434
'inline-style-disabled'?: boolean
3535
'input-requires-label'?: boolean
36+
'main-require'?: boolean
3637
'script-disabled'?: boolean
3738
'space-tab-mixed-disabled'?:
3839
| boolean

test/rules/main-require.spec.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
const HTMLHint = require('../../dist/htmlhint.js').HTMLHint
2+
3+
const ruleId = 'main-require'
4+
5+
describe('Rule: main-require', () => {
6+
it('should not report an error when <main> is present in <body>', () => {
7+
const code = '<html><body><main>Content</main></body></html>'
8+
const messages = HTMLHint.verify(code, { [ruleId]: true })
9+
expect(messages.length).toBe(0)
10+
})
11+
12+
it('should report an error when <main> is missing in <body>', () => {
13+
const code = '<html><body><p>No main tag</p></body></html>'
14+
const messages = HTMLHint.verify(code, { [ruleId]: true })
15+
expect(messages.length).toBe(1)
16+
expect(messages[0].rule.id).toBe(ruleId)
17+
expect(messages[0].message).toBe('<main> must be present in <body> tag.')
18+
})
19+
20+
it('should not report an error when <main> is empty but present', () => {
21+
const code = '<html><body><main></main></body></html>'
22+
const messages = HTMLHint.verify(code, { [ruleId]: true })
23+
expect(messages.length).toBe(0)
24+
})
25+
26+
it('should accept multiple <main> tags even though it is not best practice', () => {
27+
const code =
28+
'<html><body><main>First</main><main>Second</main></body></html>'
29+
const messages = HTMLHint.verify(code, { [ruleId]: true })
30+
expect(messages.length).toBe(0)
31+
})
32+
33+
it('should report an error when <body> has no <main> tag even with other content', () => {
34+
const code =
35+
'<html><body><header>Header</header><footer>Footer</footer></body></html>'
36+
const messages = HTMLHint.verify(code, { [ruleId]: true })
37+
expect(messages.length).toBe(1)
38+
expect(messages[0].rule.id).toBe(ruleId)
39+
expect(messages[0].message).toBe('<main> must be present in <body> tag.')
40+
})
41+
42+
it('should detect <main> tag with attributes', () => {
43+
const code =
44+
'<html><body><main id="content" class="main-content">Content</main></body></html>'
45+
const messages = HTMLHint.verify(code, { [ruleId]: true })
46+
expect(messages.length).toBe(0)
47+
})
48+
})

website/src/content/docs/configuration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ An example configuration file (with all rules disabled):
7979

8080
## VS Code Configuration
8181

82-
Tip: to have your configuration file recognized by editors with JSON schema support, you can add the following to VS Code settings (`.vscode/settings.json`). This will enable autocompletion and validation for the `.htmlhintrc` file.
82+
To have your configuration file recognized by editors with JSON schema support, you can add the following to VS Code settings (`.vscode/settings.json`). This will enable autocompletion and validation for the `.htmlhintrc` file.
8383

8484
```json
8585
{
@@ -91,3 +91,5 @@ Tip: to have your configuration file recognized by editors with JSON schema supp
9191
]
9292
}
9393
```
94+
95+
Note: if you have the [VS Code extension](/vs-code-extension/) installed, it will automatically recognize the `.htmlhintrc` file without needing to add this configuration.

website/src/content/docs/list-rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ description: A complete list of all the rules for HTMLHint
3939
- [`src-not-empty`](/docs/user-guide/rules/src-not-empty): The src attribute of an img(script,link) must have a value.
4040
- [`href-abs-or-rel`](/docs/user-guide/rules/href-abs-or-rel): An href attribute must be either absolute or relative.
4141
- [`h1-require`](/docs/user-guide/rules/h1-require): A document must have at least one `<h1>` element.
42+
- [`main-require`](/docs/user-guide/rules/main-require): A document must have at least one `<main>` element in the `<body>` tag.
4243

4344
## Id
4445

website/src/content/docs/rules/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ description: A complete list of all the rules for HTMLHint
3838
- [`src-not-empty`](src-not-empty/): The src attribute of an img(script,link) must have a value.
3939
- [`href-abs-or-rel`](href-abs-or-rel/): An href attribute must be either absolute or relative.
4040
- [`h1-require`](h1-require/): A document must have at least one `<h1>` element.
41+
- [`main-require`](main-require/): A document must have at least one `<main>` element in the `<body>` tag.
4142

4243
## Id
4344

website/src/content/docs/rules/input-requires-label.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ keywords:
1010
import { Badge } from '@astrojs/starlight/components';
1111

1212

13-
All [ input ] tags must have a corresponding [ label ] tag.
13+
All `<input>` tags must have a corresponding `<label>` tag.
1414

1515
Level: <Badge text="Warning" variant="caution" />
1616

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
id: main-require
3+
title: main-require
4+
description: Ensures that an HTML document contains a `<main>` element within the `<body>` tag for proper document structure and accessibility.
5+
sidebar:
6+
hidden: true
7+
badge: New
8+
---
9+
import { Badge } from '@astrojs/starlight/components';
10+
11+
A `<main>` element is required within the `<body>` tag of HTML documents. This rule ensures that the document has a clear and accessible structure, which is important for both users and screen readers.
12+
13+
Level: <Badge text="Warning" variant="caution" />
14+
15+
## Config value
16+
17+
1. true: enable rule
18+
2. false: disable rule
19+
20+
21+
### The following patterns are **not** considered rule violations:
22+
23+
```html
24+
<html><body><main>Content</main></body></html>
25+
```
26+
27+
```html
28+
<html><body><header>Header</header><main>Content</main><footer>Footer</footer></body></html>
29+
```
30+
31+
### The following patterns are considered rule violations:
32+
33+
```html
34+
<html><body><p>No main tag</p></body></html>
35+
```
36+
37+
```html
38+
<html><body><header>Header</header><footer>Footer</footer></body></html>
39+
```

website/src/content/docs/rules/script-disabled.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ description: Disallows the use of <script> tags in HTML documents for security a
66
import { Badge } from '@astrojs/starlight/components';
77

88

9-
The script tag can not be used anywhere in the document.
9+
The `<script>` tag can not be used anywhere in the document.
1010

1111
Level: <Badge text="Warning" variant="caution" />
1212

website/src/custom.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,7 @@ h3[id^='the-following-patterns-are-not']::before {
101101
.top-level li a span.sl-badge {
102102
opacity: 0.5;
103103
}
104+
105+
img[src='/img/htmlhint-vscode-extension.png'] {
106+
background-color: #000;
107+
}

0 commit comments

Comments
 (0)