Skip to content

Commit

Permalink
[New] add react/no-invalid-html-attribute
Browse files Browse the repository at this point in the history
  • Loading branch information
Nokel81 authored and ljharb committed Nov 30, 2020
1 parent bf8dff0 commit fc57f2f
Show file tree
Hide file tree
Showing 5 changed files with 780 additions and 0 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Expand Up @@ -9,6 +9,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel
* [`jsx-no-target-blank`]: add fixer ([#2862][] @Nokel81)
* [`jsx-pascal-case`]: support minimatch `ignore` option ([#2906][] @bcherny)
* [`jsx-pascal-case`]: support `allowNamespace` option ([#2917][] @kev-y-huang)
* [`no-invalid-html-attribute`]: add rule ([#2863][] @Nokel81)

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

## [7.22.0] - 2020.12.29
Expand Down Expand Up @@ -3302,3 +3304,4 @@ If you're still not using React 15 you can keep the old behavior by setting the
[`function-component-definition`]: docs/rules/function-component-definition.md
[`jsx-newline`]: docs/rules/jsx-newline.md
[`jsx-no-constructed-context-values`]: docs/rules/jsx-no-constructed-context-values.md
[`no-invalid-html-attribute`]: docs/rules/no-invalid-html-attribute.md
17 changes: 17 additions & 0 deletions docs/rules/no-invalid-html-attribute.md
@@ -0,0 +1,17 @@
# Prevent usage of invalid attributes (react/no-invalid-html-attribute)

Some HTML elements have a specific set of valid values for some attributes.
For instance the elements: `a`, `area`, `link`, or `form` all have an attribute called `rel`.
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)).
To help with minimizing confusion while reading code, only the appropriate values should be on each attribute.

## Rule Details

This rule aims to remove invalid attribute values.

## Rule Options
The options is a list of attributes to check. Defaults to `["rel"]`.

## When Not To Use It

When you don't want to enforce attribute value correctness.
1 change: 1 addition & 0 deletions index.js
Expand Up @@ -54,6 +54,7 @@ const allRules = {
'jsx-uses-react': require('./lib/rules/jsx-uses-react'),
'jsx-uses-vars': require('./lib/rules/jsx-uses-vars'),
'jsx-wrap-multilines': require('./lib/rules/jsx-wrap-multilines'),
'no-invalid-html-attribute': require('./lib/rules/no-invalid-html-attribute'),
'no-access-state-in-setstate': require('./lib/rules/no-access-state-in-setstate'),
'no-adjacent-inline-elements': require('./lib/rules/no-adjacent-inline-elements'),
'no-array-index-key': require('./lib/rules/no-array-index-key'),
Expand Down
219 changes: 219 additions & 0 deletions lib/rules/no-invalid-html-attribute.js
@@ -0,0 +1,219 @@
/**
* @fileoverview Check if tag attributes to have non-valid value
* @author Sebastian Malton
*/

'use strict';

const matchAll = require('string.prototype.matchall');
const docsUrl = require('../util/docsUrl');

// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------

/**
* Map between attributes and a mapping between valid values and a set of tags they are valid on
* @type {Map<string, Map<string, Set<string>>>}
*/
const VALID_VALUES = new Map();

const rel = new Map([
['alternate', new Set(['link', 'area', 'a'])],
['author', new Set(['link', 'area', 'a'])],
['bookmark', new Set(['area', 'a'])],
['canonical', new Set(['link'])],
['dns-prefetch', new Set(['link'])],
['external', new Set(['area', 'a', 'form'])],
['help', new Set(['link', 'area', 'a', 'form'])],
['icon', new Set(['link'])],
['license', new Set(['link', 'area', 'a', 'form'])],
['manifest', new Set(['link'])],
['modulepreload', new Set(['link'])],
['next', new Set(['link', 'area', 'a', 'form'])],
['nofollow', new Set(['area', 'a', 'form'])],
['noopener', new Set(['area', 'a', 'form'])],
['noreferrer', new Set(['area', 'a', 'form'])],
['opener', new Set(['area', 'a', 'form'])],
['pingback', new Set(['link'])],
['preconnect', new Set(['link'])],
['prefetch', new Set(['link'])],
['preload', new Set(['link'])],
['prerender', new Set(['link'])],
['prev', new Set(['link', 'area', 'a', 'form'])],
['search', new Set(['link', 'area', 'a', 'form'])],
['stylesheet', new Set(['link'])],
['tag', new Set(['area', 'a'])]
]);
VALID_VALUES.set('rel', rel);

/**
* Map between attributes and set of tags that the attribute is valid on
* @type {Map<string, Set<string>>}
*/
const COMPONENT_ATTRIBUTE_MAP = new Map();
COMPONENT_ATTRIBUTE_MAP.set('rel', new Set(['link', 'a', 'area', 'form']));

function splitIntoRangedParts(node, regex) {
const valueRangeStart = node.range[0] + 1; // the plus one is for the initial quote

return Array.from(matchAll(node.value, regex), (match) => {
const start = match.index + valueRangeStart;
const end = start + match[0].length;

return {
reportingValue: `"${match[1]}"`,
value: match[1],
range: [start, end]
};
});
}

function checkLiteralValueNode(context, attributeName, node, parentNode, parentNodeName) {
if (typeof node.value !== 'string') {
return context.report({
node,
message: `"${attributeName}" attribute only supports strings.`,
fix(fixer) {
return fixer.remove(parentNode);
}
});
}

if (!node.value.trim()) {
return context.report({
node,
message: `An empty "${attributeName}" attribute is meaningless.`,
fix(fixer) {
return fixer.remove(parentNode);
}
});
}

const parts = splitIntoRangedParts(node, /([^\s]+)/g);
for (const part of parts) {
const allowedTags = VALID_VALUES.get(attributeName).get(part.value);
if (!allowedTags) {
context.report({
node,
message: `${part.reportingValue} is never a valid "${attributeName}" attribute value.`,
fix(fixer) {
return fixer.removeRange(part.range);
}
});
} else if (!allowedTags.has(parentNodeName)) {
context.report({
node,
message: `${part.reportingValue} is not a valid "${attributeName}" attribute value for <${parentNodeName}>.`,
fix(fixer) {
return fixer.removeRange(part.range);
}
});
}
}

const whitespaceParts = splitIntoRangedParts(node, /(\s+)/g);
for (const whitespacePart of whitespaceParts) {
if (whitespacePart.value !== ' ' || whitespacePart.range[0] === (node.range[0] + 1) || whitespacePart.range[1] === (node.range[1] - 1)) {
context.report({
node,
message: `"${attributeName}" attribute values should be space delimited.`,
fix(fixer) {
return fixer.removeRange(whitespacePart.range);
}
});
}
}
}

const DEFAULT_ATTRIBUTES = ['rel'];

function checkAttribute(context, node) {
const attribute = node.name.name;

function fix(fixer) {
return fixer.remove(node);
}

const parentNodeName = node.parent.name.name;
if (!COMPONENT_ATTRIBUTE_MAP.has(attribute) || !COMPONENT_ATTRIBUTE_MAP.get(attribute).has(parentNodeName)) {
const tagNames = Array.from(
COMPONENT_ATTRIBUTE_MAP.get(attribute).values(),
(tagName) => `"<${tagName}>"`
).join(', ');
return context.report({
node,
message: `The "${attribute}" attribute only has meaning on the tags: ${tagNames}`,
fix
});
}

if (!node.value) {
return context.report({
node,
message: `An empty "${attribute}" attribute is meaningless.`,
fix
});
}

if (node.value.type === 'Literal') {
return checkLiteralValueNode(context, attribute, node.value, node, parentNodeName);
}

if (node.value.expression.type === 'Literal') {
return checkLiteralValueNode(context, attribute, node.value.expression, node, parentNodeName);
}

if (node.value.type !== 'JSXExpressionContainer') {
return;
}

if (node.value.expression.type === 'ObjectExpression') {
return context.report({
node,
message: `"${attribute}" attribute only supports strings.`,
fix
});
}

if (node.value.expression.type === 'Identifier' && node.value.expression.name === 'undefined') {
return context.report({
node,
message: `"${attribute}" attribute only supports strings.`,
fix
});
}
}

module.exports = {
meta: {
fixable: 'code',
docs: {
description: 'Forbid attribute with an invalid values`',
category: 'Possible Errors',
url: docsUrl('no-invalid-html-attribute')
},
schema: [{
type: 'array',
uniqueItems: true,
items: {
enum: ['rel']
}
}]
},

create(context) {
return {
JSXAttribute(node) {
const attributes = new Set(context.options[0] || DEFAULT_ATTRIBUTES);

// ignore attributes that aren't configured to be checked
if (!attributes.has(node.name.name)) {
return;
}

checkAttribute(context, node);
}
};
}
};

0 comments on commit fc57f2f

Please sign in to comment.