diff --git a/.cspell.json b/.cspell.json
index c884c750..9c0cf4a5 100644
--- a/.cspell.json
+++ b/.cspell.json
@@ -59,6 +59,9 @@
"contenteditable",
"nbsb",
"endl",
- "cout"
+ "cout",
+ "nodownload",
+ "nofullscreen",
+ "controlslist"
]
}
diff --git a/docs/rules/no-ineffective-attrs.md b/docs/rules/no-ineffective-attrs.md
new file mode 100644
index 00000000..eda2738b
--- /dev/null
+++ b/docs/rules/no-ineffective-attrs.md
@@ -0,0 +1,78 @@
+# no-ineffective-attrs
+
+## How to use
+
+```js,.eslintrc.js
+module.exports = {
+ rules: {
+ "@html-eslint/no-ineffective-attrs": "error",
+ },
+};
+```
+
+## Rule Details
+
+This rule disallows HTML attributes that have no effect in their context. Such attributes may indicate errors or unnecessary code that should be removed.
+
+Examples of **incorrect** code for this rule:
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Download
+
+
+
+
+```
+
+Examples of **correct** code for this rule:
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Download
+
+
+
+
+```
diff --git a/packages/eslint-plugin/lib/rules/index.js b/packages/eslint-plugin/lib/rules/index.js
index 1a3113c0..7712a3c3 100644
--- a/packages/eslint-plugin/lib/rules/index.js
+++ b/packages/eslint-plugin/lib/rules/index.js
@@ -51,6 +51,7 @@ const noDuplicateClass = require("./no-duplicate-class");
const noEmptyHeadings = require("./no-empty-headings");
const noInvalidEntity = require("./no-invalid-entity");
const noDuplicateInHead = require("./no-duplicate-in-head");
+const noIneffectiveAttrs = require("./no-ineffective-attrs");
// import new rule here ↑
// DO NOT REMOVE THIS COMMENT
@@ -108,6 +109,7 @@ const rules = {
"no-empty-headings": noEmptyHeadings,
"no-invalid-entity": noInvalidEntity,
"no-duplicate-in-head": noDuplicateInHead,
+ "no-ineffective-attrs": noIneffectiveAttrs,
// export new rule here ↑
// DO NOT REMOVE THIS COMMENT
};
diff --git a/packages/eslint-plugin/lib/rules/no-ineffective-attrs.js b/packages/eslint-plugin/lib/rules/no-ineffective-attrs.js
new file mode 100644
index 00000000..a52f3115
--- /dev/null
+++ b/packages/eslint-plugin/lib/rules/no-ineffective-attrs.js
@@ -0,0 +1,184 @@
+/**
+ * @import {RuleModule} from "../types";
+ * @import {Tag, ScriptTag} from "@html-eslint/types"
+ * @typedef {{ attr: string; when: (node: Tag | ScriptTag) => boolean; message: string; }} AttributeChecker
+ */
+
+const { RULE_CATEGORY } = require("../constants");
+const { hasAttr, hasTemplate, findAttr } = require("./utils/node");
+const { createVisitors } = require("./utils/visitors");
+
+/**
+ * @param {Tag | ScriptTag} node
+ * @param {string} attrName
+ * @returns {string | undefined}
+ */
+function getAttrValue(node, attrName) {
+ const attr = node.attributes.find(
+ (a) => a.type === "Attribute" && a.key.value === attrName
+ );
+ if (!attr || !attr.value) return undefined;
+ return attr.value.value;
+}
+
+/**
+ * @param {Tag | ScriptTag} node
+ * @param {string} attrName
+ * @returns {boolean}
+ */
+function isTemplateValueAttr(node, attrName) {
+ const attr = findAttr(node, attrName);
+ if (!attr || !attr.value) return false;
+ return hasTemplate(attr.value);
+}
+
+/**
+ * @type {Record}
+ */
+const checkersByTag = {
+ input: [
+ {
+ attr: "multiple",
+ when: (node) => {
+ const type = getAttrValue(node, "type") || "text";
+ return [
+ "text",
+ "password",
+ "radio",
+ "checkbox",
+ "image",
+ "hidden",
+ "reset",
+ "button",
+ ].includes(type);
+ },
+ message: 'The "multiple" attribute has no effect on this input type.',
+ },
+ {
+ attr: "accept",
+ when: (node) => {
+ if (isTemplateValueAttr(node, "type")) {
+ return false;
+ }
+ const type = getAttrValue(node, "type") || "text";
+ return type !== "file";
+ },
+ message:
+ 'The "accept" attribute has no effect unless input type is "file".',
+ },
+ {
+ attr: "readonly",
+ when: (node) => {
+ const type = getAttrValue(node, "type") || "text";
+ return ["checkbox", "radio", "file", "range", "color"].includes(type);
+ },
+ message: 'The "readonly" attribute has no effect on this input type.',
+ },
+ ],
+ script: [
+ {
+ attr: "defer",
+ when: (node) => !hasAttr(node, "src"),
+ message: 'The "defer" attribute has no effect on inline scripts.',
+ },
+ {
+ attr: "async",
+ when: (node) => !hasAttr(node, "src"),
+ message: 'The "async" attribute has no effect on inline scripts.',
+ },
+ ],
+ a: [
+ {
+ attr: "download",
+ when: (node) => !hasAttr(node, "href"),
+ message: 'The "download" attribute has no effect without an "href".',
+ },
+ ],
+ audio: [
+ {
+ attr: "controlslist",
+ when: (node) => !hasAttr(node, "controls"),
+ message: 'The "controlslist" attribute has no effect without "controls".',
+ },
+ ],
+ video: [
+ {
+ attr: "controlslist",
+ when: (node) => !hasAttr(node, "controls"),
+ message: 'The "controlslist" attribute has no effect without "controls".',
+ },
+ ],
+};
+
+/**
+ * @type {RuleModule<[]>}
+ */
+module.exports = {
+ name: "no-ineffective-attrs",
+ meta: {
+ docs: {
+ description:
+ "Disallow HTML attributes that have no effect in their context",
+ category: RULE_CATEGORY.BEST_PRACTICE,
+ recommended: false,
+ },
+ messages: {
+ ineffective: "{{ message }}",
+ },
+ schema: [],
+ type: "problem",
+ },
+ defaultOptions: [],
+ create(context) {
+ return createVisitors(context, {
+ /**
+ * @param {Tag} node
+ */
+ Tag(node) {
+ const tagCheckers = checkersByTag[node.name];
+ if (!tagCheckers) return;
+
+ for (const check of tagCheckers) {
+ for (const attr of node.attributes) {
+ if (attr.type !== "Attribute") continue;
+ if (attr.key.value !== check.attr) continue;
+
+ if (check.when(node)) {
+ context.report({
+ loc: attr.loc,
+ messageId: "ineffective",
+ data: {
+ message: check.message,
+ },
+ });
+ }
+ }
+ }
+ },
+ /**
+ * @param {ScriptTag} node
+ */
+ ScriptTag(node) {
+ const scriptCheckers = checkersByTag.script;
+ if (!scriptCheckers) return;
+
+ for (const check of scriptCheckers) {
+ for (const attr of node.attributes) {
+ if (attr.type !== "Attribute") continue;
+ if (attr.key.value !== check.attr) continue;
+
+ if (check.when(node)) {
+ context.report({
+ loc: attr.loc,
+ messageId: "ineffective",
+ data: {
+ message: check.message,
+ },
+ });
+ }
+ }
+ }
+ },
+ });
+ },
+};
diff --git a/packages/eslint-plugin/lib/rules/utils/node.js b/packages/eslint-plugin/lib/rules/utils/node.js
index 1caa206a..f2909d24 100644
--- a/packages/eslint-plugin/lib/rules/utils/node.js
+++ b/packages/eslint-plugin/lib/rules/utils/node.js
@@ -17,6 +17,17 @@ function findAttr(node, key) {
);
}
+/**
+ * @param {Tag | ScriptTag} node
+ * @param {string} attrName
+ * @returns {boolean}
+ */
+function hasAttr(node, attrName) {
+ return node.attributes.some(
+ (a) => a.type === "Attribute" && a.key.value === attrName
+ );
+}
+
/**
* Checks whether a node's attributes is empty or not.
* @param {Tag | ScriptTag | StyleTag} node
@@ -254,4 +265,5 @@ module.exports = {
isRangesOverlap,
getTemplateTokens,
hasTemplate,
+ hasAttr,
};
diff --git a/packages/eslint-plugin/tests/rules/no-ineffective-attrs.test.js b/packages/eslint-plugin/tests/rules/no-ineffective-attrs.test.js
new file mode 100644
index 00000000..ddde93a0
--- /dev/null
+++ b/packages/eslint-plugin/tests/rules/no-ineffective-attrs.test.js
@@ -0,0 +1,459 @@
+const createRuleTester = require("../rule-tester");
+const rule = require("../../lib/rules/no-ineffective-attrs");
+
+const ruleTester = createRuleTester();
+const templateRuleTester = createRuleTester("espree");
+
+ruleTester.run("no-ineffective-attrs", rule, {
+ valid: [
+ // Valid input types for multiple attribute
+ {
+ code: '',
+ },
+ {
+ code: '',
+ },
+ {
+ code: "",
+ },
+
+ // Valid file input with accept
+ {
+ code: '',
+ },
+
+ // Valid text input with readonly
+ {
+ code: '',
+ },
+
+ // Valid password input with readonly
+ {
+ code: '',
+ },
+
+ // Valid script with src and defer
+ {
+ code: '',
+ },
+
+ // Valid script with src and async
+ {
+ code: '',
+ },
+
+ // Valid anchor with href and download
+ {
+ code: 'Download',
+ },
+
+ // Valid audio with controls and controlslist
+ {
+ code: '',
+ },
+
+ // Valid video with controls and controlslist
+ {
+ code: '',
+ },
+ ],
+ invalid: [
+ // Invalid multiple on text input
+ {
+ code: '',
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "multiple" attribute has no effect on this input type.',
+ },
+ },
+ ],
+ },
+ // Invalid multiple on checkbox
+ {
+ code: '',
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "multiple" attribute has no effect on this input type.',
+ },
+ },
+ ],
+ },
+ // Invalid accept on non-file input
+ {
+ code: '',
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "accept" attribute has no effect unless input type is "file".',
+ },
+ },
+ ],
+ },
+ // Invalid defer on inline script
+ {
+ code: "",
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message: 'The "defer" attribute has no effect on inline scripts.',
+ },
+ },
+ ],
+ },
+ // Invalid async on inline script
+ {
+ code: "",
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message: 'The "async" attribute has no effect on inline scripts.',
+ },
+ },
+ ],
+ },
+ // Invalid download without href
+ {
+ code: "Download",
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "download" attribute has no effect without an "href".',
+ },
+ },
+ ],
+ },
+ // Invalid controlslist on audio without controls
+ {
+ code: '',
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "controlslist" attribute has no effect without "controls".',
+ },
+ },
+ ],
+ },
+ // Invalid controlslist on video without controls
+ {
+ code: '',
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "controlslist" attribute has no effect without "controls".',
+ },
+ },
+ ],
+ },
+ // Invalid readonly on checkbox input
+ {
+ code: '',
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "readonly" attribute has no effect on this input type.',
+ },
+ },
+ ],
+ },
+ // Invalid readonly on radio input
+ {
+ code: '',
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "readonly" attribute has no effect on this input type.',
+ },
+ },
+ ],
+ },
+ // Invalid readonly on file input
+ {
+ code: '',
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "readonly" attribute has no effect on this input type.',
+ },
+ },
+ ],
+ },
+ // Invalid readonly on range input
+ {
+ code: '',
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "readonly" attribute has no effect on this input type.',
+ },
+ },
+ ],
+ },
+ // Invalid readonly on color input
+ {
+ code: '',
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "readonly" attribute has no effect on this input type.',
+ },
+ },
+ ],
+ },
+ ],
+});
+
+templateRuleTester.run("[template] no-ineffective-attrs", rule, {
+ valid: [
+ // Valid input types for multiple attribute
+ {
+ code: `html\`\``,
+ },
+ {
+ code: `html\`\``,
+ },
+ {
+ code: `html\`\``,
+ },
+
+ // Valid file input with accept
+ {
+ code: `html\`\``,
+ },
+ {
+ code: `html\`\``,
+ },
+
+ // Valid text input with readonly
+ {
+ code: `html\`\``,
+ },
+ {
+ code: `html\`\``,
+ },
+
+ // Valid password input with readonly
+ {
+ code: `html\`\``,
+ },
+
+ // Valid script with src and defer
+ {
+ code: `html\`\``,
+ },
+
+ // Valid script with src and async
+ {
+ code: `html\`\``,
+ },
+
+ // Valid anchor with href and download
+ {
+ code: `html\`Download\``,
+ },
+
+ // Valid audio with controls and controlslist
+ {
+ code: `html\`\``,
+ },
+
+ // Valid video with controls and controlslist
+ {
+ code: `html\`\``,
+ },
+ ],
+ invalid: [
+ // Invalid multiple on text input
+ {
+ code: 'html``',
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "multiple" attribute has no effect on this input type.',
+ },
+ },
+ ],
+ },
+ // Invalid multiple on checkbox
+ {
+ code: `html\`\``,
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "multiple" attribute has no effect on this input type.',
+ },
+ },
+ ],
+ },
+ // Invalid accept on non-file input
+ {
+ code: `html\`\``,
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "accept" attribute has no effect unless input type is "file".',
+ },
+ },
+ ],
+ },
+ // Invalid defer on inline script
+ {
+ code: `html\`\``,
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message: 'The "defer" attribute has no effect on inline scripts.',
+ },
+ },
+ ],
+ },
+ // Invalid async on inline script
+ {
+ code: `html\`\``,
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message: 'The "async" attribute has no effect on inline scripts.',
+ },
+ },
+ ],
+ },
+ // Invalid download without href
+ {
+ code: `html\`Download\``,
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "download" attribute has no effect without an "href".',
+ },
+ },
+ ],
+ },
+ // Invalid controlslist on audio without controls
+ {
+ code: `html\`\``,
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "controlslist" attribute has no effect without "controls".',
+ },
+ },
+ ],
+ },
+ // Invalid controlslist on video without controls
+ {
+ code: `html\`\``,
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "controlslist" attribute has no effect without "controls".',
+ },
+ },
+ ],
+ },
+ // Invalid readonly on checkbox input
+ {
+ code: `html\`\``,
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "readonly" attribute has no effect on this input type.',
+ },
+ },
+ ],
+ },
+ // Invalid readonly on radio input
+ {
+ code: `html\`\``,
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "readonly" attribute has no effect on this input type.',
+ },
+ },
+ ],
+ },
+ // Invalid readonly on file input
+ {
+ code: `html\`\``,
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "readonly" attribute has no effect on this input type.',
+ },
+ },
+ ],
+ },
+ // Invalid readonly on range input
+ {
+ code: `html\`\``,
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "readonly" attribute has no effect on this input type.',
+ },
+ },
+ ],
+ },
+ // Invalid readonly on color input
+ {
+ code: `html\`\``,
+ errors: [
+ {
+ messageId: "ineffective",
+ data: {
+ message:
+ 'The "readonly" attribute has no effect on this input type.',
+ },
+ },
+ ],
+ },
+ ],
+});