Skip to content

Commit fc57f2f

Browse files
Nokel81ljharb
authored andcommitted
[New] add react/no-invalid-html-attribute
1 parent bf8dff0 commit fc57f2f

File tree

5 files changed

+780
-0
lines changed

5 files changed

+780
-0
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel
99
* [`jsx-no-target-blank`]: add fixer ([#2862][] @Nokel81)
1010
* [`jsx-pascal-case`]: support minimatch `ignore` option ([#2906][] @bcherny)
1111
* [`jsx-pascal-case`]: support `allowNamespace` option ([#2917][] @kev-y-huang)
12+
* [`no-invalid-html-attribute`]: add rule ([#2863][] @Nokel81)
1213

1314
### Fixed
1415
* [`jsx-no-constructed-context-values`]: avoid a crash with `as X` TS code ([#2894][] @ljharb)
@@ -42,6 +43,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel
4243
[#2895]: https://github.com/yannickcr/eslint-plugin-react/issues/2895
4344
[#2894]: https://github.com/yannickcr/eslint-plugin-react/issues/2894
4445
[#2893]: https://github.com/yannickcr/eslint-plugin-react/pull/2893
46+
[#2863]: https://github.com/yannickcr/eslint-plugin-react/pull/2863
4547
[#2862]: https://github.com/yannickcr/eslint-plugin-react/pull/2862
4648

4749
## [7.22.0] - 2020.12.29
@@ -3302,3 +3304,4 @@ If you're still not using React 15 you can keep the old behavior by setting the
33023304
[`function-component-definition`]: docs/rules/function-component-definition.md
33033305
[`jsx-newline`]: docs/rules/jsx-newline.md
33043306
[`jsx-no-constructed-context-values`]: docs/rules/jsx-no-constructed-context-values.md
3307+
[`no-invalid-html-attribute`]: docs/rules/no-invalid-html-attribute.md
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Prevent usage of invalid attributes (react/no-invalid-html-attribute)
2+
3+
Some HTML elements have a specific set of valid values for some attributes.
4+
For instance the elements: `a`, `area`, `link`, or `form` all have an attribute called `rel`.
5+
There is a fixed list of values that have any meaning for this attribute on these tags (see [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel)).
6+
To help with minimizing confusion while reading code, only the appropriate values should be on each attribute.
7+
8+
## Rule Details
9+
10+
This rule aims to remove invalid attribute values.
11+
12+
## Rule Options
13+
The options is a list of attributes to check. Defaults to `["rel"]`.
14+
15+
## When Not To Use It
16+
17+
When you don't want to enforce attribute value correctness.

index.js

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const allRules = {
5454
'jsx-uses-react': require('./lib/rules/jsx-uses-react'),
5555
'jsx-uses-vars': require('./lib/rules/jsx-uses-vars'),
5656
'jsx-wrap-multilines': require('./lib/rules/jsx-wrap-multilines'),
57+
'no-invalid-html-attribute': require('./lib/rules/no-invalid-html-attribute'),
5758
'no-access-state-in-setstate': require('./lib/rules/no-access-state-in-setstate'),
5859
'no-adjacent-inline-elements': require('./lib/rules/no-adjacent-inline-elements'),
5960
'no-array-index-key': require('./lib/rules/no-array-index-key'),
+219
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/**
2+
* @fileoverview Check if tag attributes to have non-valid value
3+
* @author Sebastian Malton
4+
*/
5+
6+
'use strict';
7+
8+
const matchAll = require('string.prototype.matchall');
9+
const docsUrl = require('../util/docsUrl');
10+
11+
// ------------------------------------------------------------------------------
12+
// Rule Definition
13+
// ------------------------------------------------------------------------------
14+
15+
/**
16+
* Map between attributes and a mapping between valid values and a set of tags they are valid on
17+
* @type {Map<string, Map<string, Set<string>>>}
18+
*/
19+
const VALID_VALUES = new Map();
20+
21+
const rel = new Map([
22+
['alternate', new Set(['link', 'area', 'a'])],
23+
['author', new Set(['link', 'area', 'a'])],
24+
['bookmark', new Set(['area', 'a'])],
25+
['canonical', new Set(['link'])],
26+
['dns-prefetch', new Set(['link'])],
27+
['external', new Set(['area', 'a', 'form'])],
28+
['help', new Set(['link', 'area', 'a', 'form'])],
29+
['icon', new Set(['link'])],
30+
['license', new Set(['link', 'area', 'a', 'form'])],
31+
['manifest', new Set(['link'])],
32+
['modulepreload', new Set(['link'])],
33+
['next', new Set(['link', 'area', 'a', 'form'])],
34+
['nofollow', new Set(['area', 'a', 'form'])],
35+
['noopener', new Set(['area', 'a', 'form'])],
36+
['noreferrer', new Set(['area', 'a', 'form'])],
37+
['opener', new Set(['area', 'a', 'form'])],
38+
['pingback', new Set(['link'])],
39+
['preconnect', new Set(['link'])],
40+
['prefetch', new Set(['link'])],
41+
['preload', new Set(['link'])],
42+
['prerender', new Set(['link'])],
43+
['prev', new Set(['link', 'area', 'a', 'form'])],
44+
['search', new Set(['link', 'area', 'a', 'form'])],
45+
['stylesheet', new Set(['link'])],
46+
['tag', new Set(['area', 'a'])]
47+
]);
48+
VALID_VALUES.set('rel', rel);
49+
50+
/**
51+
* Map between attributes and set of tags that the attribute is valid on
52+
* @type {Map<string, Set<string>>}
53+
*/
54+
const COMPONENT_ATTRIBUTE_MAP = new Map();
55+
COMPONENT_ATTRIBUTE_MAP.set('rel', new Set(['link', 'a', 'area', 'form']));
56+
57+
function splitIntoRangedParts(node, regex) {
58+
const valueRangeStart = node.range[0] + 1; // the plus one is for the initial quote
59+
60+
return Array.from(matchAll(node.value, regex), (match) => {
61+
const start = match.index + valueRangeStart;
62+
const end = start + match[0].length;
63+
64+
return {
65+
reportingValue: `"${match[1]}"`,
66+
value: match[1],
67+
range: [start, end]
68+
};
69+
});
70+
}
71+
72+
function checkLiteralValueNode(context, attributeName, node, parentNode, parentNodeName) {
73+
if (typeof node.value !== 'string') {
74+
return context.report({
75+
node,
76+
message: `"${attributeName}" attribute only supports strings.`,
77+
fix(fixer) {
78+
return fixer.remove(parentNode);
79+
}
80+
});
81+
}
82+
83+
if (!node.value.trim()) {
84+
return context.report({
85+
node,
86+
message: `An empty "${attributeName}" attribute is meaningless.`,
87+
fix(fixer) {
88+
return fixer.remove(parentNode);
89+
}
90+
});
91+
}
92+
93+
const parts = splitIntoRangedParts(node, /([^\s]+)/g);
94+
for (const part of parts) {
95+
const allowedTags = VALID_VALUES.get(attributeName).get(part.value);
96+
if (!allowedTags) {
97+
context.report({
98+
node,
99+
message: `${part.reportingValue} is never a valid "${attributeName}" attribute value.`,
100+
fix(fixer) {
101+
return fixer.removeRange(part.range);
102+
}
103+
});
104+
} else if (!allowedTags.has(parentNodeName)) {
105+
context.report({
106+
node,
107+
message: `${part.reportingValue} is not a valid "${attributeName}" attribute value for <${parentNodeName}>.`,
108+
fix(fixer) {
109+
return fixer.removeRange(part.range);
110+
}
111+
});
112+
}
113+
}
114+
115+
const whitespaceParts = splitIntoRangedParts(node, /(\s+)/g);
116+
for (const whitespacePart of whitespaceParts) {
117+
if (whitespacePart.value !== ' ' || whitespacePart.range[0] === (node.range[0] + 1) || whitespacePart.range[1] === (node.range[1] - 1)) {
118+
context.report({
119+
node,
120+
message: `"${attributeName}" attribute values should be space delimited.`,
121+
fix(fixer) {
122+
return fixer.removeRange(whitespacePart.range);
123+
}
124+
});
125+
}
126+
}
127+
}
128+
129+
const DEFAULT_ATTRIBUTES = ['rel'];
130+
131+
function checkAttribute(context, node) {
132+
const attribute = node.name.name;
133+
134+
function fix(fixer) {
135+
return fixer.remove(node);
136+
}
137+
138+
const parentNodeName = node.parent.name.name;
139+
if (!COMPONENT_ATTRIBUTE_MAP.has(attribute) || !COMPONENT_ATTRIBUTE_MAP.get(attribute).has(parentNodeName)) {
140+
const tagNames = Array.from(
141+
COMPONENT_ATTRIBUTE_MAP.get(attribute).values(),
142+
(tagName) => `"<${tagName}>"`
143+
).join(', ');
144+
return context.report({
145+
node,
146+
message: `The "${attribute}" attribute only has meaning on the tags: ${tagNames}`,
147+
fix
148+
});
149+
}
150+
151+
if (!node.value) {
152+
return context.report({
153+
node,
154+
message: `An empty "${attribute}" attribute is meaningless.`,
155+
fix
156+
});
157+
}
158+
159+
if (node.value.type === 'Literal') {
160+
return checkLiteralValueNode(context, attribute, node.value, node, parentNodeName);
161+
}
162+
163+
if (node.value.expression.type === 'Literal') {
164+
return checkLiteralValueNode(context, attribute, node.value.expression, node, parentNodeName);
165+
}
166+
167+
if (node.value.type !== 'JSXExpressionContainer') {
168+
return;
169+
}
170+
171+
if (node.value.expression.type === 'ObjectExpression') {
172+
return context.report({
173+
node,
174+
message: `"${attribute}" attribute only supports strings.`,
175+
fix
176+
});
177+
}
178+
179+
if (node.value.expression.type === 'Identifier' && node.value.expression.name === 'undefined') {
180+
return context.report({
181+
node,
182+
message: `"${attribute}" attribute only supports strings.`,
183+
fix
184+
});
185+
}
186+
}
187+
188+
module.exports = {
189+
meta: {
190+
fixable: 'code',
191+
docs: {
192+
description: 'Forbid attribute with an invalid values`',
193+
category: 'Possible Errors',
194+
url: docsUrl('no-invalid-html-attribute')
195+
},
196+
schema: [{
197+
type: 'array',
198+
uniqueItems: true,
199+
items: {
200+
enum: ['rel']
201+
}
202+
}]
203+
},
204+
205+
create(context) {
206+
return {
207+
JSXAttribute(node) {
208+
const attributes = new Set(context.options[0] || DEFAULT_ATTRIBUTES);
209+
210+
// ignore attributes that aren't configured to be checked
211+
if (!attributes.has(node.name.name)) {
212+
return;
213+
}
214+
215+
checkAttribute(context, node);
216+
}
217+
};
218+
}
219+
};

0 commit comments

Comments
 (0)