diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c53e403dd..5f9b8c00b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) @@ -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 @@ -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 \ No newline at end of file diff --git a/docs/rules/no-invalid-html-attribute.md b/docs/rules/no-invalid-html-attribute.md new file mode 100644 index 0000000000..345bcd48bd --- /dev/null +++ b/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. diff --git a/index.js b/index.js index 8edb1177f1..22bb756144 100644 --- a/index.js +++ b/index.js @@ -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'), diff --git a/lib/rules/no-invalid-html-attribute.js b/lib/rules/no-invalid-html-attribute.js new file mode 100644 index 0000000000..4c1712f952 --- /dev/null +++ b/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>>} + */ +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>} +*/ +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); + } + }; + } +}; diff --git a/tests/lib/rules/no-invalid-html-attribute.js b/tests/lib/rules/no-invalid-html-attribute.js new file mode 100644 index 0000000000..a808d13ac6 --- /dev/null +++ b/tests/lib/rules/no-invalid-html-attribute.js @@ -0,0 +1,540 @@ +/** + * @fileoverview Forbid target='_blank' attribute + * @author Kevin Miller + */ + +'use strict'; + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const RuleTester = require('eslint').RuleTester; +const rule = require('../../../lib/rules/no-invalid-html-attribute'); + +const parserOptions = { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + jsx: true + } +}; + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({parserOptions}); + +ruleTester.run('no-invalid-html-attribute', rule, { + valid: [ + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: ''}, + {code: '
'}, + {code: '
'}, + {code: '
'}, + {code: ''}, + {code: '
'}, + {code: '
'}, + {code: '
'}, + {code: '
'}, + {code: ''}, + {code: ''}, + {code: '
'}, + {code: ''}, + {code: ''} + ], + invalid: [ + { + code: '', + output: '', + errors: [{ + message: 'The "rel" attribute only has meaning on the tags: "", "", "", "
"' + }] + }, + { + code: '', + output: '', + errors: [{ + message: 'The "rel" attribute only has meaning on the tags: "", "", "", ""' + }] + }, + { + code: '', + output: '', + errors: [{ + message: 'An empty "rel" attribute is meaningless.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: 'The "rel" attribute only has meaning on the tags: "", "", "", ""' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"rel" attribute only supports strings.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"rel" attribute only supports strings.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"rel" attribute only supports strings.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"rel" attribute only supports strings.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"rel" attribute only supports strings.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"foobar" is never a valid "rel" attribute value.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"rel" attribute values should be space delimited.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"foobar" is never a valid "rel" attribute value.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"foobar" is never a valid "rel" attribute value.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"foobar" is never a valid "rel" attribute value.' + }, { + message: '"batgo" is never a valid "rel" attribute value.' + }, { + message: '"rel" attribute values should be space delimited.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"rel" attribute values should be space delimited.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"rel" attribute values should be space delimited.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"batgo" is never a valid "rel" attribute value.' + }, { + message: '"rel" attribute values should be space delimited.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"batgo" is never a valid "rel" attribute value.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"rel" attribute values should be space delimited.' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"canonical" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"dns-prefetch" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"icon" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"manifest" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"modulepreload" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"pingback" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"preconnect" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"prefetch" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"preload" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"prerender" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"stylesheet" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"canonical" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"dns-prefetch" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"icon" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"manifest" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"modulepreload" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"pingback" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"preconnect" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"prefetch" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"preload" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"prerender" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"stylesheet" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"bookmark" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"external" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"nofollow" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"noopener" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"noreferrer" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"opener" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '', + errors: [{ + message: '"tag" is not a valid "rel" attribute value for .' + }] + }, + { + code: '', + output: '
', + errors: [{ + message: '"alternate" is not a valid "rel" attribute value for
.' + }] + }, + { + code: '
', + output: '
', + errors: [{ + message: '"author" is not a valid "rel" attribute value for
.' + }] + }, + { + code: '
', + output: '
', + errors: [{ + message: '"bookmark" is not a valid "rel" attribute value for
.' + }] + }, + { + code: '
', + output: '
', + errors: [{ + message: '"canonical" is not a valid "rel" attribute value for
.' + }] + }, + { + code: '
', + output: '
', + errors: [{ + message: '"dns-prefetch" is not a valid "rel" attribute value for
.' + }] + }, + { + code: '
', + output: '
', + errors: [{ + message: '"icon" is not a valid "rel" attribute value for
.' + }] + }, + { + code: '
', + output: '
', + errors: [{ + message: '"manifest" is not a valid "rel" attribute value for
.' + }] + }, + { + code: '
', + output: '
', + errors: [{ + message: '"modulepreload" is not a valid "rel" attribute value for
.' + }] + }, + { + code: '
', + output: '
', + errors: [{ + message: '"pingback" is not a valid "rel" attribute value for
.' + }] + }, + { + code: '
', + output: '
', + errors: [{ + message: '"preconnect" is not a valid "rel" attribute value for
.' + }] + }, + { + code: '
', + output: '
', + errors: [{ + message: '"prefetch" is not a valid "rel" attribute value for
.' + }] + }, + { + code: '
', + output: '
', + errors: [{ + message: '"preload" is not a valid "rel" attribute value for
.' + }] + }, + { + code: '
', + output: '
', + errors: [{ + message: '"prerender" is not a valid "rel" attribute value for
.' + }] + }, + { + code: '
', + output: '
', + errors: [{ + message: '"stylesheet" is not a valid "rel" attribute value for
.' + }] + }, + { + code: '
', + output: '
', + errors: [{ + message: '"tag" is not a valid "rel" attribute value for
.' + }] + } + ] +});