@html-eslint/eslint-plugin 0.41.0-alpha.0 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/configs/recommended.js +1 -0
- package/lib/data/entities.json +2299 -0
- package/lib/rules/index.js +8 -0
- package/lib/rules/lowercase.js +8 -5
- package/lib/rules/max-element-depth.js +1 -1
- package/lib/rules/no-aria-hidden-on-focusable.js +118 -0
- package/lib/rules/no-duplicate-in-head.js +191 -0
- package/lib/rules/no-empty-headings.js +122 -0
- package/lib/rules/no-extra-spacing-text.js +1 -1
- package/lib/rules/no-invalid-entity.js +108 -0
- package/lib/rules/require-img-alt.js +36 -13
- package/package.json +6 -6
- package/types/configs/recommended.d.ts +1 -0
- package/types/index.d.ts +2 -0
- package/types/index.d.ts.map +1 -1
- package/types/rules/lowercase.d.ts.map +1 -1
- package/types/rules/no-aria-hidden-on-focusable.d.ts +8 -0
- package/types/rules/no-aria-hidden-on-focusable.d.ts.map +1 -0
- package/types/rules/no-duplicate-in-head.d.ts +11 -0
- package/types/rules/no-duplicate-in-head.d.ts.map +1 -0
- package/types/rules/no-empty-headings.d.ts +9 -0
- package/types/rules/no-empty-headings.d.ts.map +1 -0
- package/types/rules/no-invalid-entity.d.ts +13 -0
- package/types/rules/no-invalid-entity.d.ts.map +1 -0
- package/types/rules/require-img-alt.d.ts.map +1 -1
package/lib/rules/index.js
CHANGED
|
@@ -27,6 +27,7 @@ const noDuplicateAttrs = require("./no-duplicate-attrs");
|
|
|
27
27
|
const noAbstractRoles = require("./no-abstract-roles");
|
|
28
28
|
const requireButtonType = require("./require-button-type");
|
|
29
29
|
const noAriaHiddenBody = require("./no-aria-hidden-body");
|
|
30
|
+
const noAriaHiddenOnFocusable = require("./no-aria-hidden-on-focusable");
|
|
30
31
|
const noMultipleEmptyLines = require("./no-multiple-empty-lines");
|
|
31
32
|
const noAccesskeyAttrs = require("./no-accesskey-attrs");
|
|
32
33
|
const noRestrictedAttrs = require("./no-restricted-attrs");
|
|
@@ -47,6 +48,9 @@ const maxElementDepth = require("./max-element-depth");
|
|
|
47
48
|
const requireExplicitSize = require("./require-explicit-size");
|
|
48
49
|
const useBaseLine = require("./use-baseline");
|
|
49
50
|
const noDuplicateClass = require("./no-duplicate-class");
|
|
51
|
+
const noEmptyHeadings = require("./no-empty-headings");
|
|
52
|
+
const noInvalidEntity = require("./no-invalid-entity");
|
|
53
|
+
const noDuplicateInHead = require("./no-duplicate-in-head");
|
|
50
54
|
// import new rule here ↑
|
|
51
55
|
// DO NOT REMOVE THIS COMMENT
|
|
52
56
|
|
|
@@ -81,6 +85,7 @@ const rules = {
|
|
|
81
85
|
"no-abstract-roles": noAbstractRoles,
|
|
82
86
|
"require-button-type": requireButtonType,
|
|
83
87
|
"no-aria-hidden-body": noAriaHiddenBody,
|
|
88
|
+
"no-aria-hidden-on-focusable": noAriaHiddenOnFocusable,
|
|
84
89
|
"no-multiple-empty-lines": noMultipleEmptyLines,
|
|
85
90
|
"no-accesskey-attrs": noAccesskeyAttrs,
|
|
86
91
|
"no-restricted-attrs": noRestrictedAttrs,
|
|
@@ -100,6 +105,9 @@ const rules = {
|
|
|
100
105
|
"require-explicit-size": requireExplicitSize,
|
|
101
106
|
"use-baseline": useBaseLine,
|
|
102
107
|
"no-duplicate-class": noDuplicateClass,
|
|
108
|
+
"no-empty-headings": noEmptyHeadings,
|
|
109
|
+
"no-invalid-entity": noInvalidEntity,
|
|
110
|
+
"no-duplicate-in-head": noDuplicateInHead,
|
|
103
111
|
// export new rule here ↑
|
|
104
112
|
// DO NOT REMOVE THIS COMMENT
|
|
105
113
|
};
|
package/lib/rules/lowercase.js
CHANGED
|
@@ -5,11 +5,10 @@
|
|
|
5
5
|
* @typedef { import("../types").RuleModule<[]> } RuleModule
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const { NODE_TYPES } = require("@html-eslint/parser");
|
|
9
8
|
const { RULE_CATEGORY } = require("../constants");
|
|
10
9
|
const SVG_CAMEL_CASE_ATTRIBUTES = require("../constants/svg-camel-case-attributes");
|
|
11
10
|
const { createVisitors } = require("./utils/visitors");
|
|
12
|
-
const { hasTemplate } = require("./utils/node");
|
|
11
|
+
const { hasTemplate, isScript, isStyle } = require("./utils/node");
|
|
13
12
|
const { getRuleUrl } = require("./utils/rule");
|
|
14
13
|
|
|
15
14
|
const MESSAGE_IDS = {
|
|
@@ -67,8 +66,8 @@ module.exports = {
|
|
|
67
66
|
* @param {Tag | StyleTag | ScriptTag} node
|
|
68
67
|
*/
|
|
69
68
|
function nameOf(node) {
|
|
70
|
-
if (node
|
|
71
|
-
if (node
|
|
69
|
+
if (isScript(node)) return "script";
|
|
70
|
+
if (isStyle(node)) return "style";
|
|
72
71
|
return node.name;
|
|
73
72
|
}
|
|
74
73
|
|
|
@@ -77,7 +76,11 @@ module.exports = {
|
|
|
77
76
|
*/
|
|
78
77
|
function check(node) {
|
|
79
78
|
const raw = node.openStart.value.slice(1);
|
|
80
|
-
|
|
79
|
+
const name = nameOf(node);
|
|
80
|
+
if (
|
|
81
|
+
name !== raw &&
|
|
82
|
+
(svgStack.length === 0 || name.toLowerCase() === "svg")
|
|
83
|
+
) {
|
|
81
84
|
context.report({
|
|
82
85
|
node: node.openStart,
|
|
83
86
|
messageId: MESSAGE_IDS.UNEXPECTED,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef { import("../types").RuleModule<[]> } RuleModule
|
|
3
|
+
* @typedef { import("@html-eslint/types").Tag } Tag
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { RULE_CATEGORY } = require("../constants");
|
|
7
|
+
const { findAttr } = require("./utils/node");
|
|
8
|
+
const { createVisitors } = require("./utils/visitors");
|
|
9
|
+
const { getRuleUrl } = require("./utils/rule");
|
|
10
|
+
|
|
11
|
+
const MESSAGE_IDS = {
|
|
12
|
+
UNEXPECTED: "unexpected",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// List of elements that are inherently focusable
|
|
16
|
+
const FOCUSABLE_ELEMENTS = new Set([
|
|
17
|
+
"a", // if href is present
|
|
18
|
+
"button",
|
|
19
|
+
"input",
|
|
20
|
+
"select",
|
|
21
|
+
"textarea",
|
|
22
|
+
"video", // if controls is present
|
|
23
|
+
"audio", // if controls is present
|
|
24
|
+
"details",
|
|
25
|
+
"embed",
|
|
26
|
+
"iframe",
|
|
27
|
+
"summary",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @type {RuleModule}
|
|
32
|
+
*/
|
|
33
|
+
module.exports = {
|
|
34
|
+
meta: {
|
|
35
|
+
type: "code",
|
|
36
|
+
|
|
37
|
+
docs: {
|
|
38
|
+
description: 'Disallow aria-hidden="true" on focusable elements',
|
|
39
|
+
category: RULE_CATEGORY.ACCESSIBILITY,
|
|
40
|
+
recommended: false,
|
|
41
|
+
url: getRuleUrl("no-aria-hidden-on-focusable"),
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
fixable: null,
|
|
45
|
+
schema: [],
|
|
46
|
+
messages: {
|
|
47
|
+
[MESSAGE_IDS.UNEXPECTED]:
|
|
48
|
+
'Unexpected aria-hidden="true" on focusable element.',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
create(context) {
|
|
53
|
+
/**
|
|
54
|
+
* Checks if an element is focusable
|
|
55
|
+
* @param {Tag} node
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
function isFocusable(node) {
|
|
59
|
+
const tagName = node.name.toLowerCase();
|
|
60
|
+
|
|
61
|
+
const contentEditableAttr = findAttr(node, "contenteditable");
|
|
62
|
+
if (contentEditableAttr) {
|
|
63
|
+
const value = contentEditableAttr.value
|
|
64
|
+
? contentEditableAttr.value.value.toLowerCase()
|
|
65
|
+
: "";
|
|
66
|
+
if (value === "" || value === "true" || value === "plaintext-only") {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check for tabindex attribute
|
|
72
|
+
const tabIndexAttr = findAttr(node, "tabindex");
|
|
73
|
+
if (tabIndexAttr && tabIndexAttr.value) {
|
|
74
|
+
const tabIndexValue = tabIndexAttr.value.value;
|
|
75
|
+
// If tabindex is -1, the element is not focusable
|
|
76
|
+
if (tabIndexValue === "-1") {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
// If tabindex is present and not -1, the element is focusable
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Special cases for elements that are only focusable with certain attributes
|
|
84
|
+
if (tagName === "a") {
|
|
85
|
+
return !!findAttr(node, "href");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (tagName === "audio" || tagName === "video") {
|
|
89
|
+
return !!findAttr(node, "controls");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check if element is inherently focusable
|
|
93
|
+
return FOCUSABLE_ELEMENTS.has(tagName);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return createVisitors(context, {
|
|
97
|
+
Tag(node) {
|
|
98
|
+
const ariaHiddenAttr = findAttr(node, "aria-hidden");
|
|
99
|
+
if (!ariaHiddenAttr || !ariaHiddenAttr.value) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Only check for aria-hidden="true"
|
|
104
|
+
if (ariaHiddenAttr.value.value !== "true") {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check if the element is focusable
|
|
109
|
+
if (isFocusable(node)) {
|
|
110
|
+
context.report({
|
|
111
|
+
node: ariaHiddenAttr,
|
|
112
|
+
messageId: MESSAGE_IDS.UNEXPECTED,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
};
|
|
@@ -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,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef { import("../types").RuleModule<[]> } RuleModule
|
|
3
|
+
* @typedef { import("@html-eslint/types").Tag } Tag
|
|
4
|
+
* @typedef { import("@html-eslint/types").Text } Text
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { RULE_CATEGORY } = require("../constants");
|
|
8
|
+
const { findAttr, isTag, isText } = require("./utils/node");
|
|
9
|
+
const { createVisitors } = require("./utils/visitors");
|
|
10
|
+
const { getRuleUrl } = require("./utils/rule");
|
|
11
|
+
|
|
12
|
+
const MESSAGE_IDS = {
|
|
13
|
+
EMPTY_HEADING: "emptyHeading",
|
|
14
|
+
INACCESSIBLE_HEADING: "inaccessibleHeading",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const HEADING_NAMES = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {Tag} node
|
|
21
|
+
*/
|
|
22
|
+
function isAriaHidden(node) {
|
|
23
|
+
const ariaHiddenAttr = findAttr(node, "aria-hidden");
|
|
24
|
+
return (
|
|
25
|
+
ariaHiddenAttr &&
|
|
26
|
+
ariaHiddenAttr.value &&
|
|
27
|
+
ariaHiddenAttr.value.value === "true"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {Tag} node
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
function isRoleHeading(node) {
|
|
36
|
+
const roleAttr = findAttr(node, "role");
|
|
37
|
+
return !!roleAttr && !!roleAttr.value && roleAttr.value.value === "heading";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {Text | Tag} node
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
function getAllText(node) {
|
|
45
|
+
if (!isTag(node) || !node.children.length) return "";
|
|
46
|
+
let text = "";
|
|
47
|
+
for (const child of node.children) {
|
|
48
|
+
if (isText(child)) {
|
|
49
|
+
text += child.value.trim();
|
|
50
|
+
} else if (isTag(child)) {
|
|
51
|
+
text += getAllText(child);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {Text | Tag} node
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
function getAccessibleText(node) {
|
|
62
|
+
if (!isTag(node) || !node.children.length) return "";
|
|
63
|
+
let text = "";
|
|
64
|
+
for (const child of node.children) {
|
|
65
|
+
if (isText(child)) {
|
|
66
|
+
text += child.value.trim();
|
|
67
|
+
} else if (isTag(child) && !isAriaHidden(child)) {
|
|
68
|
+
text += getAccessibleText(child);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @type {RuleModule}
|
|
76
|
+
*/
|
|
77
|
+
module.exports = {
|
|
78
|
+
meta: {
|
|
79
|
+
type: "code",
|
|
80
|
+
docs: {
|
|
81
|
+
description: "Disallow empty or inaccessible headings.",
|
|
82
|
+
category: RULE_CATEGORY.ACCESSIBILITY,
|
|
83
|
+
recommended: false,
|
|
84
|
+
url: getRuleUrl("no-empty-headings"),
|
|
85
|
+
},
|
|
86
|
+
fixable: null,
|
|
87
|
+
schema: [],
|
|
88
|
+
messages: {
|
|
89
|
+
[MESSAGE_IDS.EMPTY_HEADING]: "Headings must not be empty.",
|
|
90
|
+
[MESSAGE_IDS.INACCESSIBLE_HEADING]:
|
|
91
|
+
"Heading text is inaccessible to assistive technology.",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
create(context) {
|
|
95
|
+
return createVisitors(context, {
|
|
96
|
+
Tag(node) {
|
|
97
|
+
const tagName = node.name.toLowerCase();
|
|
98
|
+
const isHeadingTag = HEADING_NAMES.has(tagName);
|
|
99
|
+
const isRoleHeadingEl = isRoleHeading(node);
|
|
100
|
+
if (!isHeadingTag && !isRoleHeadingEl) return;
|
|
101
|
+
|
|
102
|
+
// Gather all text (including aria-hidden)
|
|
103
|
+
const allText = getAllText(node);
|
|
104
|
+
if (!allText) {
|
|
105
|
+
context.report({
|
|
106
|
+
node,
|
|
107
|
+
messageId: MESSAGE_IDS.EMPTY_HEADING,
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Gather accessible text (not aria-hidden)
|
|
112
|
+
const accessibleText = getAccessibleText(node);
|
|
113
|
+
if (!accessibleText) {
|
|
114
|
+
context.report({
|
|
115
|
+
node,
|
|
116
|
+
messageId: MESSAGE_IDS.INACCESSIBLE_HEADING,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef { import("../types").RuleModule<[]> } RuleModule
|
|
3
|
+
* @typedef { import("../types").SuggestionReportDescriptor } SuggestionReportDescriptor
|
|
4
|
+
* @typedef { import("@html-eslint/types").Text} Text
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Define the type for entities.json
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} EntityData
|
|
10
|
+
* @property {number[]} codepoints
|
|
11
|
+
* @property {string} characters
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** @type {{ [key: string]: EntityData }} */
|
|
15
|
+
const entities = require("../data/entities.json");
|
|
16
|
+
|
|
17
|
+
const { RULE_CATEGORY } = require("../constants");
|
|
18
|
+
const { createVisitors } = require("./utils/visitors");
|
|
19
|
+
const { getRuleUrl } = require("./utils/rule");
|
|
20
|
+
|
|
21
|
+
const MESSAGE_IDS = {
|
|
22
|
+
INVALID_ENTITY: "invalidEntity",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @type {RuleModule}
|
|
27
|
+
*/
|
|
28
|
+
module.exports = {
|
|
29
|
+
meta: {
|
|
30
|
+
type: "code",
|
|
31
|
+
docs: {
|
|
32
|
+
description: "Disallows the use of invalid HTML entities",
|
|
33
|
+
category: RULE_CATEGORY.BEST_PRACTICE,
|
|
34
|
+
recommended: false,
|
|
35
|
+
url: getRuleUrl("no-invalid-entity"),
|
|
36
|
+
},
|
|
37
|
+
fixable: null,
|
|
38
|
+
hasSuggestions: false,
|
|
39
|
+
schema: [],
|
|
40
|
+
messages: {
|
|
41
|
+
[MESSAGE_IDS.INVALID_ENTITY]: "Invalid HTML entity '{{entity}}' used.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
create(context) {
|
|
46
|
+
/**
|
|
47
|
+
* @param {Text} node
|
|
48
|
+
*/
|
|
49
|
+
function check(node) {
|
|
50
|
+
const text = node.value;
|
|
51
|
+
|
|
52
|
+
// Regular expression to match named and numeric entities
|
|
53
|
+
const entityRegex = /&([a-zA-Z]+|#[0-9]+|#x[0-9a-fA-F]+|[#][^;]+);/g;
|
|
54
|
+
let match;
|
|
55
|
+
|
|
56
|
+
while ((match = entityRegex.exec(text)) !== null) {
|
|
57
|
+
const entity = match[0];
|
|
58
|
+
const entityName = match[1];
|
|
59
|
+
|
|
60
|
+
// Check named entities
|
|
61
|
+
if (!entityName.startsWith("#")) {
|
|
62
|
+
const fullEntity = `&${entityName};`;
|
|
63
|
+
if (!Object.prototype.hasOwnProperty.call(entities, fullEntity)) {
|
|
64
|
+
context.report({
|
|
65
|
+
node,
|
|
66
|
+
messageId: MESSAGE_IDS.INVALID_ENTITY,
|
|
67
|
+
data: { entity },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Check numeric entities
|
|
72
|
+
else {
|
|
73
|
+
const isHex = entityName[1] === "x";
|
|
74
|
+
const numStr = isHex ? entityName.slice(2) : entityName.slice(1);
|
|
75
|
+
const num = isHex ? parseInt(numStr, 16) : parseInt(numStr, 10);
|
|
76
|
+
|
|
77
|
+
// If the number is not a valid integer, report an error
|
|
78
|
+
if (isNaN(num)) {
|
|
79
|
+
context.report({
|
|
80
|
+
node,
|
|
81
|
+
messageId: MESSAGE_IDS.INVALID_ENTITY,
|
|
82
|
+
data: { entity },
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check if the numeric entity is valid (exists in entities.json or within valid Unicode range)
|
|
88
|
+
const entityKey = Object.keys(entities).find((key) => {
|
|
89
|
+
const codepoints = entities[key].codepoints;
|
|
90
|
+
return codepoints.length === 1 && codepoints[0] === num;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!entityKey && (num < 0 || num > 0x10ffff)) {
|
|
94
|
+
context.report({
|
|
95
|
+
node,
|
|
96
|
+
messageId: MESSAGE_IDS.INVALID_ENTITY,
|
|
97
|
+
data: { entity },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return createVisitors(context, {
|
|
105
|
+
Text: check,
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|