Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
"contenteditable",
"nbsb",
"endl",
"cout"
"cout",
"nodownload",
"nofullscreen",
"controlslist"
]
}
78 changes: 78 additions & 0 deletions docs/rules/no-ineffective-attrs.md
Original file line number Diff line number Diff line change
@@ -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
<!-- multiple has no effect on text inputs -->
<input type="text" multiple />

<!-- multiple has no effect on checkbox inputs -->
<input type="checkbox" multiple />

<!-- accept only works with file inputs -->
<input type="text" accept=".jpg,.png" />

<!-- readonly has no effect on checkbox inputs -->
<input type="checkbox" readonly />

<!-- readonly has no effect on file inputs -->
<input type="file" readonly />

<!-- defer has no effect on inline scripts -->
<script defer>
console.log("hello");
</script>

<!-- async has no effect on inline scripts -->
<script async>
console.log("hello");
</script>

<!-- download needs href to work -->
<a download="file.pdf">Download</a>

<!-- controlslist needs controls to work -->
<audio controlslist="nodownload"></audio>
<video controlslist="nofullscreen"></video>
```

Examples of **correct** code for this rule:

```html
<!-- multiple works with email and file inputs -->
<input type="email" multiple />
<input type="file" multiple />
<select multiple></select>

<!-- accept works with file inputs -->
<input type="file" accept=".jpg,.png" />

<!-- readonly works with text and password inputs -->
<input type="text" readonly />
<input type="password" readonly />

<!-- defer and async work with external scripts -->
<script defer src="script.js"></script>
<script async src="script.js"></script>

<!-- download works when href is present -->
<a href="file.pdf" download="file.pdf">Download</a>

<!-- controlslist works when controls is present -->
<audio controls controlslist="nodownload"></audio>
<video controls controlslist="nofullscreen"></video>
```
2 changes: 2 additions & 0 deletions packages/eslint-plugin/lib/rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
};
Expand Down
184 changes: 184 additions & 0 deletions packages/eslint-plugin/lib/rules/no-ineffective-attrs.js
Original file line number Diff line number Diff line change
@@ -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<string, AttributeChecker[]>}
*/
const checkersByTag = {
input: [
{
attr: "multiple",
when: (node) => {
const type = getAttrValue(node, "type") || "text";
Copy link

Copilot AI Aug 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic string "text" is used as a default input type. Consider extracting this as a named constant to improve maintainability and reduce duplication (it appears on lines 32, 49, and 58).

Suggested change
const type = getAttrValue(node, "type") || "text";
const type = getAttrValue(node, "type") || DEFAULT_INPUT_TYPE;

Copilot uses AI. Check for mistakes.
return [
"text",
"password",
"radio",
"checkbox",
"image",
"hidden",
"reset",
"button",
].includes(type);
Comment on lines +44 to +53
Copy link

Copilot AI Aug 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The array of input types that don't support 'multiple' should be extracted as a constant. This improves maintainability and makes it easier to update the list if HTML specifications change.

Suggested change
return [
"text",
"password",
"radio",
"checkbox",
"image",
"hidden",
"reset",
"button",
].includes(type);
return INPUT_TYPES_WITHOUT_MULTIPLE.includes(type);

Copilot uses AI. Check for mistakes.
},
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);
Copy link

Copilot AI Aug 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The array of input types that don't support 'readonly' should be extracted as a constant for better maintainability and consistency with other type checking logic.

Suggested change
return ["checkbox", "radio", "file", "range", "color"].includes(type);
return INPUT_TYPES_WITHOUT_READONLY.includes(type);

Copilot uses AI. Check for mistakes.
},
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,
},
});
}
}
}
Comment on lines +138 to +180
Copy link

Copilot AI Aug 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic in the ScriptTag visitor is nearly identical to the Tag visitor (lines 126-146). Consider extracting the common attribute checking logic into a shared helper function to reduce code duplication.

Suggested change
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,
},
});
}
}
}
checkIneffectiveAttrs(node, checkersByTag[node.name], context);
},
/**
* @param {ScriptTag} node
*/
ScriptTag(node) {
checkIneffectiveAttrs(node, checkersByTag.script, context);

Copilot uses AI. Check for mistakes.
},
});
},
};
12 changes: 12 additions & 0 deletions packages/eslint-plugin/lib/rules/utils/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -254,4 +265,5 @@ module.exports = {
isRangesOverlap,
getTemplateTokens,
hasTemplate,
hasAttr,
};
Loading