Skip to content

feat: new rule main-require #1608

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions dist/core/rules/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 55 additions & 1 deletion dist/htmlhint.js
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,58 @@
return inputRequiresLabel;
}

var mainRequire = {};

var hasRequiredMainRequire;

function requireMainRequire () {
if (hasRequiredMainRequire) return mainRequire;
hasRequiredMainRequire = 1;
Object.defineProperty(mainRequire, "__esModule", { value: true });
mainRequire.default = {
id: 'main-require',
description: '<main> must be present in <body> tag.',
init(parser, reporter) {
let bodyDepth = 0;
let hasMainInBody = false;
let bodyTagEvent = null;
const onTagStart = (event) => {
const tagName = event.tagName.toLowerCase();
if (tagName === 'body') {
bodyDepth++;
if (bodyDepth === 1) {
hasMainInBody = false;
bodyTagEvent = event;
}
}
else if (tagName === 'main' && bodyDepth > 0) {
hasMainInBody = true;
}
};
const onTagEnd = (event) => {
const tagName = event.tagName.toLowerCase();
if (tagName === 'body') {
if (bodyDepth === 1 && !hasMainInBody && bodyTagEvent) {
reporter.warn('<main> must be present in <body> tag.', bodyTagEvent.line, bodyTagEvent.col, this, bodyTagEvent.raw);
}
bodyDepth--;
if (bodyDepth < 0)
bodyDepth = 0;
}
};
parser.addListener('tagstart', onTagStart);
parser.addListener('tagend', onTagEnd);
parser.addListener('end', () => {
if (bodyDepth > 0 && !hasMainInBody && bodyTagEvent) {
reporter.warn('<main> must be present in <body> tag.', bodyTagEvent.line, bodyTagEvent.col, this, bodyTagEvent.raw);
}

Check warning on line 1376 in dist/htmlhint.js

View check run for this annotation

Codecov / codecov/patch

dist/htmlhint.js#L1375-L1376

Added lines #L1375 - L1376 were not covered by tests
});
},
};

return mainRequire;
}

var scriptDisabled = {};

var hasRequiredScriptDisabled;
Expand Down Expand Up @@ -1804,7 +1856,7 @@
hasRequiredRules = 1;
(function (exports) {
Object.defineProperty(exports, "__esModule", { value: true });
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;
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;
var alt_require_1 = requireAltRequire();
Object.defineProperty(exports, "altRequire", { enumerable: true, get: function () { return alt_require_1.default; } });
var attr_lowercase_1 = requireAttrLowercase();
Expand Down Expand Up @@ -1851,6 +1903,8 @@
Object.defineProperty(exports, "inlineStyleDisabled", { enumerable: true, get: function () { return inline_style_disabled_1.default; } });
var input_requires_label_1 = requireInputRequiresLabel();
Object.defineProperty(exports, "inputRequiresLabel", { enumerable: true, get: function () { return input_requires_label_1.default; } });
var main_require_1 = requireMainRequire();
Object.defineProperty(exports, "mainRequire", { enumerable: true, get: function () { return main_require_1.default; } });
var script_disabled_1 = requireScriptDisabled();
Object.defineProperty(exports, "scriptDisabled", { enumerable: true, get: function () { return script_disabled_1.default; } });
var space_tab_mixed_disabled_1 = requireSpaceTabMixedDisabled();
Expand Down
2 changes: 1 addition & 1 deletion dist/htmlhint.min.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/core/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export { default as idUnique } from './id-unique'
export { default as inlineScriptDisabled } from './inline-script-disabled'
export { default as inlineStyleDisabled } from './inline-style-disabled'
export { default as inputRequiresLabel } from './input-requires-label'
export { default as mainRequire } from './main-require'
export { default as scriptDisabled } from './script-disabled'
export { default as spaceTabMixedDisabled } from './space-tab-mixed-disabled'
export { default as specCharEscape } from './spec-char-escape'
Expand Down
57 changes: 57 additions & 0 deletions src/core/rules/main-require.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Block, Listener } from '../htmlparser'
import { Rule } from '../types'

export default {
id: 'main-require',
description: '<main> must be present in <body> tag.',
init(parser, reporter) {
let bodyDepth = 0
let hasMainInBody = false
let bodyTagEvent: Block | null = null

const onTagStart: Listener = (event: Block) => {
const tagName = event.tagName.toLowerCase()
if (tagName === 'body') {
bodyDepth++
if (bodyDepth === 1) {
hasMainInBody = false
bodyTagEvent = event
}
} else if (tagName === 'main' && bodyDepth > 0) {
hasMainInBody = true
}
}

const onTagEnd: Listener = (event: Block) => {
const tagName = event.tagName.toLowerCase()
if (tagName === 'body') {
if (bodyDepth === 1 && !hasMainInBody && bodyTagEvent) {
reporter.warn(
'<main> must be present in <body> tag.',
bodyTagEvent.line,
bodyTagEvent.col,
this,
bodyTagEvent.raw
)
}
bodyDepth--
if (bodyDepth < 0) bodyDepth = 0
}
}

parser.addListener('tagstart', onTagStart)
parser.addListener('tagend', onTagEnd)
parser.addListener('end', () => {
// Handle case where <body> is not closed (malformed HTML)
if (bodyDepth > 0 && !hasMainInBody && bodyTagEvent) {
reporter.warn(
'<main> must be present in <body> tag.',
bodyTagEvent.line,
bodyTagEvent.col,
this,
bodyTagEvent.raw
)
}
})
},
} as Rule
1 change: 1 addition & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface Ruleset {
'inline-script-disabled'?: boolean
'inline-style-disabled'?: boolean
'input-requires-label'?: boolean
'main-require'?: boolean
'script-disabled'?: boolean
'space-tab-mixed-disabled'?:
| boolean
Expand Down
48 changes: 48 additions & 0 deletions test/rules/main-require.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const HTMLHint = require('../../dist/htmlhint.js').HTMLHint

const ruleId = 'main-require'

describe('Rule: main-require', () => {
it('should not report an error when <main> is present in <body>', () => {
const code = '<html><body><main>Content</main></body></html>'
const messages = HTMLHint.verify(code, { [ruleId]: true })
expect(messages.length).toBe(0)
})

it('should report an error when <main> is missing in <body>', () => {
const code = '<html><body><p>No main tag</p></body></html>'
const messages = HTMLHint.verify(code, { [ruleId]: true })
expect(messages.length).toBe(1)
expect(messages[0].rule.id).toBe(ruleId)
expect(messages[0].message).toBe('<main> must be present in <body> tag.')
})

it('should not report an error when <main> is empty but present', () => {
const code = '<html><body><main></main></body></html>'
const messages = HTMLHint.verify(code, { [ruleId]: true })
expect(messages.length).toBe(0)
})

it('should accept multiple <main> tags even though it is not best practice', () => {
const code =
'<html><body><main>First</main><main>Second</main></body></html>'
const messages = HTMLHint.verify(code, { [ruleId]: true })
expect(messages.length).toBe(0)
})

it('should report an error when <body> has no <main> tag even with other content', () => {
const code =
'<html><body><header>Header</header><footer>Footer</footer></body></html>'
const messages = HTMLHint.verify(code, { [ruleId]: true })
expect(messages.length).toBe(1)
expect(messages[0].rule.id).toBe(ruleId)
expect(messages[0].message).toBe('<main> must be present in <body> tag.')
})

it('should detect <main> tag with attributes', () => {
const code =
'<html><body><main id="content" class="main-content">Content</main></body></html>'
const messages = HTMLHint.verify(code, { [ruleId]: true })
expect(messages.length).toBe(0)
})
})
4 changes: 3 additions & 1 deletion website/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ An example configuration file (with all rules disabled):

## VS Code Configuration

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.
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.

```json
{
Expand All @@ -91,3 +91,5 @@ Tip: to have your configuration file recognized by editors with JSON schema supp
]
}
```

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.
1 change: 1 addition & 0 deletions website/src/content/docs/list-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ description: A complete list of all the rules for HTMLHint
- [`src-not-empty`](/docs/user-guide/rules/src-not-empty): The src attribute of an img(script,link) must have a value.
- [`href-abs-or-rel`](/docs/user-guide/rules/href-abs-or-rel): An href attribute must be either absolute or relative.
- [`h1-require`](/docs/user-guide/rules/h1-require): A document must have at least one `<h1>` element.
- [`main-require`](/docs/user-guide/rules/main-require): A document must have at least one `<main>` element in the `<body>` tag.

## Id

Expand Down
1 change: 1 addition & 0 deletions website/src/content/docs/rules/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ description: A complete list of all the rules for HTMLHint
- [`src-not-empty`](src-not-empty/): The src attribute of an img(script,link) must have a value.
- [`href-abs-or-rel`](href-abs-or-rel/): An href attribute must be either absolute or relative.
- [`h1-require`](h1-require/): A document must have at least one `<h1>` element.
- [`main-require`](main-require/): A document must have at least one `<main>` element in the `<body>` tag.

## Id

Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/rules/input-requires-label.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ keywords:
import { Badge } from '@astrojs/starlight/components';


All [ input ] tags must have a corresponding [ label ] tag.
All `<input>` tags must have a corresponding `<label>` tag.

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

Expand Down
39 changes: 39 additions & 0 deletions website/src/content/docs/rules/main-require.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
id: main-require
title: main-require
description: Ensures that an HTML document contains a `<main>` element within the `<body>` tag for proper document structure and accessibility.
sidebar:
hidden: true
badge: New
---
import { Badge } from '@astrojs/starlight/components';

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.

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

## Config value

1. true: enable rule
2. false: disable rule


### The following patterns are **not** considered rule violations:

```html
<html><body><main>Content</main></body></html>
```

```html
<html><body><header>Header</header><main>Content</main><footer>Footer</footer></body></html>
```

### The following patterns are considered rule violations:

```html
<html><body><p>No main tag</p></body></html>
```

```html
<html><body><header>Header</header><footer>Footer</footer></body></html>
```
2 changes: 1 addition & 1 deletion website/src/content/docs/rules/script-disabled.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: Disallows the use of <script> tags in HTML documents for security a
import { Badge } from '@astrojs/starlight/components';


The script tag can not be used anywhere in the document.
The `<script>` tag can not be used anywhere in the document.

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

Expand Down
4 changes: 4 additions & 0 deletions website/src/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,7 @@ h3[id^='the-following-patterns-are-not']::before {
.top-level li a span.sl-badge {
opacity: 0.5;
}

img[src='/img/htmlhint-vscode-extension.png'] {
background-color: #000;
}