Skip to content

Commit b082215

Browse files
Sendi0011yeonjuanCopilot
authored
feat: add no-duplicate-in-head rule (#362)
* feat: add no-duplicate-in-head rule * Update docs/rules/no-duplicate-in-head.md Co-authored-by: YeonJuan <[email protected]> * Update packages/eslint-plugin/lib/rules/no-duplicate-in-head.js Co-authored-by: Copilot <[email protected]> * Update packages/eslint-plugin/lib/rules/no-duplicate-in-head.js Co-authored-by: YeonJuan <[email protected]> * Update packages/eslint-plugin/lib/rules/no-duplicate-in-head.js Co-authored-by: YeonJuan <[email protected]> * Update packages/eslint-plugin/tests/rules/no-duplicate-in-head.test.js Co-authored-by: YeonJuan <[email protected]> * Update no-duplicate-in-head.js * fix: address PR feedback for no-duplicate-in-head rule - Fix syntax error with misplaced report() call - Replace boolean with counter for proper head tag tracking - Add Tag:exit handler for nested head support - Remove unused templateInsideHead variables - Fix URL reference to use correct rule name - Update documentation title and examples * fix: format no-duplicate-in-head rule according to prettier standards * test: add comprehensive test cases for no-duplicate-in-head rule - Add tests for empty attributes edge cases - Add tests for basic template literals - Add tests for tags without attributes - Improve code coverage to address Codecov warnings * fix: address PR feedback for no-duplicate-in-head rule - Fix rule name references in tests - Remove unnecessary attribute length checks - Use findAttr consistently for hasCharset function - Add proper head count tracking for template literals - Remove filename properties from test cases - Add TypeScript annotations to fix type errors * fix(rule): normalize meta charset tag detection in no-duplicate-in-head. updated no-duplicate-in-head test * fix(rule): remove optional chaining to pass CI lint * Update rules.md * fix --------- Co-authored-by: YeonJuan <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent e2a488c commit b082215

File tree

6 files changed

+486
-0
lines changed

6 files changed

+486
-0
lines changed

docs/rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
| [no-duplicate-attrs](rules/no-duplicate-attrs) | Disallow to use duplicate attributes ||
1414
| [no-duplicate-class](rules/no-duplicate-class) | Disallow to use duplicate class | 🔧 |
1515
| [no-duplicate-id](rules/no-duplicate-id) | Disallow to use duplicate id ||
16+
| [no-duplicate-in-head](rules/no-duplicate-in-head) | Disallow duplicate tags in `<head>` | |
1617
| [no-inline-styles](rules/no-inline-styles) | Disallow using inline style | |
1718
| [no-invalid-entity](rules/no-invalid-entity) | Disallows the use of invalid HTML entities | |
1819
| [no-nested-interactive](rules/no-nested-interactive) | Disallows nested interactive elements | |

docs/rules/no-duplicate-in-head.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# no-duplicate-in-head
2+
3+
This rule disallows duplicate tags in the `<head>` section that should be unique.
4+
5+
## Why?
6+
7+
Certain HTML tags in the `<head>` section should appear only once per document for proper functionality and SEO optimization. Having duplicate tags can cause:
8+
9+
- Multiple page titles confusing search engines and browsers
10+
- Conflicting character encodings
11+
- Multiple viewport declarations causing layout issues
12+
- Conflicting base URLs
13+
- Multiple canonical URLs diluting SEO value
14+
15+
## How to use
16+
17+
```js,.eslintrc.js
18+
module.exports = {
19+
rules: {
20+
"@html-eslint/no-duplicate-in-head": "error",
21+
},
22+
};
23+
```
24+
25+
## Rule Details
26+
27+
This rule checks for duplicate occurrences of the following tags within `<head>`:
28+
29+
- `<title>` - Document title
30+
- `<base>` - Base URL for relative URLs
31+
- `<meta charset>` - Character encoding declaration
32+
- `<meta name="viewport">` - Viewport configuration
33+
- `<link rel="canonical">` - Canonical URL declaration
34+
35+
Examples of **incorrect** code for this rule:
36+
37+
```html,incorrect
38+
<head>
39+
<title>First Title</title>
40+
<title>Second Title</title>
41+
</head>
42+
```
43+
44+
```html,incorrect
45+
<head>
46+
<base href="/" />
47+
<base href="/home" />
48+
</head>
49+
```
50+
51+
```html,incorrect
52+
<head>
53+
<meta charset="UTF-8" />
54+
<meta charset="ISO-8859-1" />
55+
</head>
56+
```
57+
58+
```html,incorrect
59+
<head>
60+
<meta name="viewport" content="width=device-width" />
61+
<meta name="viewport" content="initial-scale=1" />
62+
</head>
63+
```
64+
65+
```html,incorrect
66+
<head>
67+
<link rel="canonical" href="https://example.com" />
68+
<link rel="canonical" href="https://example.org" />
69+
</head>
70+
```
71+
72+
Examples of **correct** code for this rule:
73+
74+
```html,correct
75+
<head>
76+
<title>Page Title</title>
77+
<base href="/" />
78+
<meta charset="UTF-8" />
79+
<meta name="viewport" content="width=device-width" />
80+
<link rel="canonical" href="https://example.com" />
81+
</head>
82+
```
83+
84+
```html,correct
85+
<head>
86+
<meta charset="UTF-8" />
87+
<meta name="description" content="Page description" />
88+
<meta name="keywords" content="html, css" />
89+
<link rel="stylesheet" href="style.css" />
90+
</head>
91+
```
92+
93+
Note that tags outside the `<head>` section are not checked by this rule, and other meta tags (like `description`, `keywords`) are allowed to have multiple instances.
94+
95+
## Further Reading
96+
97+
- [MDN: Document metadata](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/head)
98+
- [MDN: title element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title)
99+
- [MDN: base element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base)
100+
- [MDN: meta element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta)
101+
- [MDN: link element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link)

packages/eslint-plugin/lib/configs/recommended.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const recommended = {
2222
"@html-eslint/require-closing-tags": "error",
2323
"@html-eslint/no-duplicate-attrs": "error",
2424
"@html-eslint/use-baseline": "error",
25+
"@html-eslint/no-duplicate-in-head": "error",
2526
},
2627
};
2728

packages/eslint-plugin/lib/rules/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const useBaseLine = require("./use-baseline");
5050
const noDuplicateClass = require("./no-duplicate-class");
5151
const noEmptyHeadings = require("./no-empty-headings");
5252
const noInvalidEntity = require("./no-invalid-entity");
53+
const noDuplicateInHead = require("./no-duplicate-in-head");
5354
// import new rule here ↑
5455
// DO NOT REMOVE THIS COMMENT
5556

@@ -106,6 +107,7 @@ const rules = {
106107
"no-duplicate-class": noDuplicateClass,
107108
"no-empty-headings": noEmptyHeadings,
108109
"no-invalid-entity": noInvalidEntity,
110+
"no-duplicate-in-head": noDuplicateInHead,
109111
// export new rule here ↑
110112
// DO NOT REMOVE THIS COMMENT
111113
};
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* @typedef { import("@html-eslint/types").Tag } Tag
3+
* @typedef { import("@html-eslint/types").StyleTag } StyleTag
4+
* @typedef { import("@html-eslint/types").ScriptTag } ScriptTag
5+
* @typedef { import("@html-eslint/types").AttributeValue } AttributeValue
6+
* @typedef { import("../types").RuleModule<[]> } RuleModule
7+
*/
8+
9+
const { parse } = require("@html-eslint/template-parser");
10+
const { RULE_CATEGORY } = require("../constants");
11+
const { findAttr } = require("./utils/node");
12+
const {
13+
shouldCheckTaggedTemplateExpression,
14+
shouldCheckTemplateLiteral,
15+
} = require("./utils/settings");
16+
const { getSourceCode } = require("./utils/source-code");
17+
const { getRuleUrl } = require("./utils/rule");
18+
19+
const MESSAGE_IDS = {
20+
DUPLICATE_TAG: "duplicateTag",
21+
};
22+
23+
/**
24+
* Returns a formatted string representing a tag's key detail.
25+
* E.g., meta[charset=UTF-8], meta[name=viewport], link[rel=canonical]
26+
* @param {Tag} node
27+
* @returns {string | null}
28+
*/
29+
function getTrackingKey(node) {
30+
const tagName = node.name.toLowerCase();
31+
32+
if (["title", "base"].includes(tagName)) {
33+
return tagName;
34+
}
35+
36+
if (tagName === "meta") {
37+
const charsetAttr = findAttr(node, "charset");
38+
if (charsetAttr) {
39+
return "meta[charset]";
40+
}
41+
42+
const nameAttr = findAttr(node, "name");
43+
if (nameAttr && nameAttr.value && nameAttr.value.value === "viewport") {
44+
return "meta[name=viewport]";
45+
}
46+
}
47+
48+
if (tagName === "link") {
49+
const relAttr = findAttr(node, "rel");
50+
const hrefAttr = findAttr(node, "href");
51+
if (
52+
relAttr &&
53+
relAttr.value &&
54+
relAttr.value.value === "canonical" &&
55+
hrefAttr
56+
) {
57+
return "link[rel=canonical]";
58+
}
59+
}
60+
61+
return null;
62+
}
63+
64+
/**
65+
* @type {RuleModule}
66+
*/
67+
module.exports = {
68+
meta: {
69+
type: "code",
70+
docs: {
71+
description: "Disallow duplicate tags in `<head>`",
72+
category: RULE_CATEGORY.BEST_PRACTICE,
73+
recommended: false,
74+
url: getRuleUrl("no-duplicate-in-head"),
75+
},
76+
fixable: null,
77+
schema: [],
78+
messages: {
79+
[MESSAGE_IDS.DUPLICATE_TAG]: "Duplicate <{{tag}}> tag in <head>.",
80+
},
81+
},
82+
83+
create(context) {
84+
const htmlTagsMap = new Map();
85+
let headCount = 0;
86+
87+
/**
88+
* @param {Map<string, Tag[]>} map
89+
* @param {{count: number}|null} headCountRef
90+
*/
91+
function createTagVisitor(map, headCountRef = null) {
92+
return {
93+
/**
94+
* @param {Tag} node
95+
*/
96+
Tag(node) {
97+
const tagName = node.name.toLowerCase();
98+
99+
if (tagName === "head") {
100+
if (headCountRef !== null) {
101+
headCountRef.count++;
102+
} else {
103+
headCount++;
104+
}
105+
return;
106+
}
107+
108+
const currentHeadCount =
109+
headCountRef !== null ? headCountRef.count : headCount;
110+
if (currentHeadCount === 0) return;
111+
112+
const trackingKey = getTrackingKey(node);
113+
if (typeof trackingKey !== "string") return;
114+
115+
if (!map.has(trackingKey)) {
116+
map.set(trackingKey, []);
117+
}
118+
119+
const nodes = map.get(trackingKey);
120+
if (nodes) {
121+
nodes.push(node);
122+
}
123+
},
124+
125+
/**
126+
* @param {Tag} node
127+
*/
128+
"Tag:exit"(node) {
129+
const tagName = node.name.toLowerCase();
130+
if (tagName === "head") {
131+
if (headCountRef !== null) {
132+
headCountRef.count--;
133+
} else {
134+
headCount--;
135+
}
136+
}
137+
},
138+
};
139+
}
140+
141+
/**
142+
* @param {Map<string, Tag[]>} map
143+
*/
144+
function report(map) {
145+
map.forEach((tags, tagKey) => {
146+
if (Array.isArray(tags) && tags.length > 1) {
147+
tags.slice(1).forEach((tag) => {
148+
context.report({
149+
node: tag,
150+
data: { tag: tagKey },
151+
messageId: MESSAGE_IDS.DUPLICATE_TAG,
152+
});
153+
});
154+
}
155+
});
156+
}
157+
158+
const htmlVisitor = createTagVisitor(htmlTagsMap);
159+
160+
return {
161+
Tag: htmlVisitor.Tag,
162+
"Tag:exit": htmlVisitor["Tag:exit"],
163+
164+
"Document:exit"() {
165+
report(htmlTagsMap);
166+
},
167+
168+
TaggedTemplateExpression(node) {
169+
const tagsMap = new Map();
170+
const headCountRef = { count: 0 };
171+
172+
if (shouldCheckTaggedTemplateExpression(node, context)) {
173+
const visitor = createTagVisitor(tagsMap, headCountRef);
174+
parse(node.quasi, getSourceCode(context), visitor);
175+
report(tagsMap);
176+
}
177+
},
178+
179+
TemplateLiteral(node) {
180+
const tagsMap = new Map();
181+
const headCountRef = { count: 0 };
182+
183+
if (shouldCheckTemplateLiteral(node, context)) {
184+
const visitor = createTagVisitor(tagsMap, headCountRef);
185+
parse(node, getSourceCode(context), visitor);
186+
report(tagsMap);
187+
}
188+
},
189+
};
190+
},
191+
};

0 commit comments

Comments
 (0)