@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.
@@ -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
  };
@@ -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.type === NODE_TYPES.ScriptTag) return "script";
71
- if (node.type === NODE_TYPES.StyleTag) return "style";
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
- if (nameOf(node) !== raw) {
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,
@@ -25,7 +25,7 @@ module.exports = {
25
25
 
26
26
  docs: {
27
27
  description: "Enforce element maximum depth",
28
- category: RULE_CATEGORY.STYLE,
28
+ category: RULE_CATEGORY.BEST_PRACTICE,
29
29
  recommended: false,
30
30
  url: getRuleUrl("max-element-depth"),
31
31
  },
@@ -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
+ };
@@ -30,7 +30,7 @@ module.exports = {
30
30
 
31
31
  docs: {
32
32
  description: "Disallow unnecessary consecutive spaces",
33
- category: RULE_CATEGORY.BEST_PRACTICE,
33
+ category: RULE_CATEGORY.STYLE,
34
34
  recommended: false,
35
35
  url: getRuleUrl("no-extra-spacing-text"),
36
36
  },
@@ -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
+ };