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.', + }, + }, + ], + }, + ], +});