From 9ea7449a81f02459b964aecbf9715da2d5f0141d Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 30 Nov 2020 08:20:29 -0500 Subject: [PATCH] [New] add `no-invalid-html-attribute` rule Co-authored-by: Sebastian Malton Co-authored-by: Jordan Harband --- CHANGELOG.md | 3 + README.md | 1 + docs/rules/no-invalid-html-attribute.md | 17 + index.js | 1 + lib/rules/no-invalid-html-attribute.js | 537 +++++++++ tests/lib/rules/no-invalid-html-attribute.js | 1089 ++++++++++++++++++ 6 files changed, 1648 insertions(+) create mode 100644 docs/rules/no-invalid-html-attribute.md create mode 100644 lib/rules/no-invalid-html-attribute.js create mode 100644 tests/lib/rules/no-invalid-html-attribute.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a2f2d983de..76501d002b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel * [`no-unused-class-component-methods`]: Handle unused class component methods ([#2166][] @jakeleventhal @pawelnvk) * add [`no-arrow-function-lifecycle`] ([#1980][] @ngtan) * add support for `@typescript-eslint/parser` v5 (@ljharb) +* [`no-invalid-html-attribute`]: add rule ([#2863][] @Nokel81) ### Fixed * `propTypes`: add `VoidFunctionComponent` to react generic list ([#3092][] @vedadeepta) @@ -30,6 +31,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel [#3110]: https://github.com/yannickcr/eslint-plugin-react/pull/3110 [#3102]: https://github.com/yannickcr/eslint-plugin-react/issue/3102 [#3092]: https://github.com/yannickcr/eslint-plugin-react/pull/3092 +[#2863]: https://github.com/yannickcr/eslint-plugin-react/pull/2863 [#2166]: https://github.com/yannickcr/eslint-plugin-react/pull/2166 [#1980]: https://github.com/yannickcr/eslint-plugin-react/pull/1980 @@ -3533,3 +3535,4 @@ If you're still not using React 15 you can keep the old behavior by setting the [`style-prop-object`]: docs/rules/style-prop-object.md [`void-dom-elements-no-children`]: docs/rules/void-dom-elements-no-children.md [`wrap-multilines`]: docs/rules/jsx-wrap-multilines.md +[`no-invalid-html-attribute`]: docs/rules/no-invalid-html-attribute.md diff --git a/README.md b/README.md index 1f899fe3f4..abd5ae1e6a 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ Enable the rules that you would like to use. | | | [react/no-did-update-set-state](docs/rules/no-did-update-set-state.md) | Prevent usage of setState in componentDidUpdate | | ✔ | | [react/no-direct-mutation-state](docs/rules/no-direct-mutation-state.md) | Prevent direct mutation of this.state | | ✔ | | [react/no-find-dom-node](docs/rules/no-find-dom-node.md) | Prevent usage of findDOMNode | +| | 🔧 | [react/no-invalid-html-attribute](docs/rules/no-invalid-html-attribute.md) | Forbid attribute with an invalid values` | | ✔ | | [react/no-is-mounted](docs/rules/no-is-mounted.md) | Prevent usage of isMounted | | | | [react/no-multi-comp](docs/rules/no-multi-comp.md) | Prevent multiple component definition per file | | | | [react/no-namespace](docs/rules/no-namespace.md) | Enforce that namespaces are not used in React elements | 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 72d5ac5fd1..6b8fbcb8f9 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..c49d31209e --- /dev/null +++ b/lib/rules/no-invalid-html-attribute.js @@ -0,0 +1,537 @@ +/** + * @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'); +const report = require('../util/report'); + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +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'])], +]); + +/** + * 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([ + ['rel', rel], +]); + +/** + * The set of all possible HTML elements. Used for skipping custom types + * @type {Set} + */ +const HTML_ELEMENTS = new Set([ + 'html', + 'base', + 'head', + 'link', + 'meta', + 'style', + 'title', + 'body', + 'address', + 'article', + 'aside', + 'footer', + 'header', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'main', + 'nav', + 'section', + 'blockquote', + 'dd', + 'div', + 'dl', + 'dt', + 'figcaption', + 'figure', + 'hr', + 'li', + 'ol', + 'p', + 'pre', + 'ul', + 'a', + 'abbr', + 'b', + 'bdi', + 'bdo', + 'br', + 'cite', + 'code', + 'data', + 'dfn', + 'em', + 'i', + 'kbd', + 'mark', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'small', + 'span', + 'strong', + 'sub', + 'sup', + 'time', + 'u', + 'var', + 'wbr', + 'area', + 'audio', + 'img', + 'map', + 'track', + 'video', + 'embed', + 'iframe', + 'object', + 'param', + 'picture', + 'portal', + 'source', + 'svg', + 'math', + 'canvas', + 'noscript', + 'script', + 'del', + 'ins', + 'caption', + 'col', + 'colgroup', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'tr', + 'button', + 'datalist', + 'fieldset', + 'form', + 'input', + 'label', + 'legend', + 'meter', + 'optgroup', + 'option', + 'output', + 'progress', + 'select', + 'textarea', + 'details', + 'dialog', + 'menu', + 'summary', + 'slot', + 'template', + 'acronym', + 'applet', + 'basefont', + 'bgsound', + 'big', + 'blink', + 'center', + 'content', + 'dir', + 'font', + 'frame', + 'frameset', + 'hgroup', + 'image', + 'keygen', + 'marquee', + 'menuitem', + 'nobr', + 'noembed', + 'noframes', + 'plaintext', + 'rb', + 'rtc', + 'shadow', + 'spacer', + 'strike', + 'tt', + 'xmp', +]); + +/** +* 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'])); + +const messages = { + onlyStrings: '“{{attributeName}}” attribute only supports strings.', + noEmpty: 'An empty “{{attributeName}}” attribute is meaningless.', + neverValid: '“{{reportingValue}}” is never a valid “{{attributeName}}” attribute value.', + notValidFor: '“{{reportingValue}}” is not a valid “{{attributeName}}” attribute value for <{{elementName}}>.', + spaceDelimited: '”{{attributeName}}“ attribute values should be space delimited.', + noMethod: 'The ”{{attributeName}}“ attribute cannot be a method.', + onlyMeaningfulFor: 'The ”{{attributeName}}“ attribute only has meaning on the tags: {{tagNames}}', + emptyIsMeaningless: 'An empty “{{attributeName}}” attribute is meaningless.', +}; + +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') { + report(context, messages.onlyStrings, 'onlyStrings', { + node, + data: { attributeName }, + fix(fixer) { + return fixer.remove(parentNode); + }, + }); + return; + } + + if (!node.value.trim()) { + report(context, messages.noEmpty, 'noEmpty', { + node, + data: { attributeName }, + fix(fixer) { + return fixer.remove(parentNode); + }, + }); + return; + } + + const parts = splitIntoRangedParts(node, /([^\s]+)/g); + for (const part of parts) { + const allowedTags = VALID_VALUES.get(attributeName).get(part.value); + const reportingValue = part.reportingValue; + if (!allowedTags) { + report(context, messages.neverValid, 'neverValid', { + node, + data: { + attributeName, + reportingValue, + }, + fix(fixer) { + return fixer.removeRange(part.range); + }, + }); + } else if (!allowedTags.has(parentNodeName)) { + report(context, messages.notValidFor, 'notValidFor', { + node, + data: { + attributeName, + reportingValue, + elementName: 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)) { + report(context, messages.spaceDelimited, 'spaceDelimited', { + node, + data: { attributeName }, + 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(', '); + report(context, messages.onlyMeaningfulFor, 'onlyMeaningfulFor', { + node, + data: { + attributeName: attribute, + tagNames, + }, + fix, + }); + return; + } + + if (!node.value) { + report(context, messages.emptyIsMeaningless, 'emptyIsMeaningless', { + node, + data: { attributeName: attribute }, + fix, + }); + return; + } + + 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') { + report(context, messages.onlyStrings, 'onlyStrings', { + node, + data: { attributeName: attribute }, + fix, + }); + return; + } + + if (node.value.expression.type === 'Identifier' && node.value.expression.name === 'undefined') { + report(context, messages.onlyStrings, 'onlyStrings', { + node, + data: { attributeName: attribute }, + fix, + }); + } +} + +function isValidCreateElement(node) { + return node.callee + && node.callee.type === 'MemberExpression' + && node.callee.object.name === 'React' + && node.callee.property.name === 'createElement' + && node.arguments.length > 0; +} + +function checkPropValidValue(context, node, value, attribute) { + const validTags = VALID_VALUES.get(attribute); + + if (value.type !== 'Literal') { + return; // cannot check non-literals + } + + const validTagSet = validTags.get(value.value); + if (!validTagSet) { + report(context, messages.neverValid, 'neverValid', { + node: value, + data: { + attributeName: attribute, + reportingValue: value.value, + }, + }); + return; + } + + if (!validTagSet.has(node.arguments[0].value)) { + report(context, messages.notValidFor, 'notValidFor', { + node: value, + data: { + attributeName: attribute, + reportingValue: value.raw, + elementName: node.arguments[0].value, + }, + }); + } +} + +/** + * + * @param {*} context + * @param {*} node + * @param {string} attribute + */ +function checkCreateProps(context, node, attribute) { + const propsArg = node.arguments[1]; + + if (!propsArg || propsArg.type !== 'ObjectExpression') { + return; // can't check variables, computed, or shorthands + } + + for (const prop of propsArg.properties) { + if (prop.key.type !== 'Identifier') { + // eslint-disable-next-line no-continue + continue; // cannot check computed keys + } + + if (prop.key.name !== attribute) { + // eslint-disable-next-line no-continue + continue; // ignore not this attribute + } + + if (!COMPONENT_ATTRIBUTE_MAP.get(attribute).has(node.arguments[0].value)) { + const tagNames = Array.from( + COMPONENT_ATTRIBUTE_MAP.get(attribute).values(), + (tagName) => `"<${tagName}>"` + ).join(', '); + + report(context, messages.onlyMeaningfulFor, 'onlyMeaningfulFor', { + node, + data: { + attributeName: attribute, + tagNames, + }, + }); + + // eslint-disable-next-line no-continue + continue; + } + + if (prop.method) { + report(context, messages.noMethod, 'noMethod', { + node: prop, + data: { + attributeName: attribute, + }, + }); + + // eslint-disable-next-line no-continue + continue; + } + + if (prop.shorthand || prop.computed) { + // eslint-disable-next-line no-continue + continue; // cannot check these + } + + if (prop.value.type === 'ArrayExpression') { + for (const value of prop.value.elements) { + checkPropValidValue(context, node, value, attribute); + } + + // eslint-disable-next-line no-continue + continue; + } + + checkPropValidValue(context, node, prop.value, attribute); + } +} + +module.exports = { + meta: { + fixable: 'code', + docs: { + description: 'Forbid attribute with an invalid values`', + category: 'Possible Errors', + url: docsUrl('no-invalid-html-attribute'), + }, + messages, + 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; + } + + // ignore non-HTML elements + if (!HTML_ELEMENTS.has(node.parent.name.name)) { + return; + } + + checkAttribute(context, node); + }, + + CallExpression(node) { + if (!isValidCreateElement(node)) { + return; + } + + const elemNameArg = node.arguments[0]; + + if (!elemNameArg || elemNameArg.type !== 'Literal') { + return; // can only check literals + } + + // ignore non-HTML elements + if (!HTML_ELEMENTS.has(elemNameArg.value)) { + return; + } + + const attributes = new Set(context.options[0] || DEFAULT_ATTRIBUTES); + + for (const attribute of attributes) { + checkCreateProps(context, node, attribute); + } + }, + }; + }, +}; 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..ed03b1fe92 --- /dev/null +++ b/tests/lib/rules/no-invalid-html-attribute.js @@ -0,0 +1,1089 @@ +/** + * @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: 'React.createElement("a", { rel: "alternate" })' }, + { code: 'React.createElement("a", { rel: ["alternate"] })' }, + { code: '' }, + { code: 'React.createElement("a", { rel: "author" })' }, + { code: 'React.createElement("a", { rel: ["author"] })' }, + { code: '' }, + { code: 'React.createElement("a", { rel: "bookmark" })' }, + { code: 'React.createElement("a", { rel: ["bookmark"] })' }, + { code: '' }, + { code: 'React.createElement("a", { rel: "external" })' }, + { code: 'React.createElement("a", { rel: ["external"] })' }, + { code: '' }, + { code: 'React.createElement("a", { rel: "help" })' }, + { code: 'React.createElement("a", { rel: ["help"] })' }, + { code: '' }, + { code: 'React.createElement("a", { rel: "license" })' }, + { code: 'React.createElement("a", { rel: ["license"] })' }, + { code: '' }, + { code: 'React.createElement("a", { rel: "next" })' }, + { code: 'React.createElement("a", { rel: ["next"] })' }, + { code: '' }, + { code: 'React.createElement("a", { rel: "nofollow" })' }, + { code: 'React.createElement("a", { rel: ["nofollow"] })' }, + { code: '' }, + { code: 'React.createElement("a", { rel: "noopener" })' }, + { code: 'React.createElement("a", { rel: ["noopener"] })' }, + { code: '' }, + { code: 'React.createElement("a", { rel: "noreferrer" })' }, + { code: 'React.createElement("a", { rel: ["noreferrer"] })' }, + { code: '' }, + { code: 'React.createElement("a", { rel: "opener" })' }, + { code: 'React.createElement("a", { rel: ["opener"] })' }, + { code: '' }, + { code: 'React.createElement("a", { rel: "prev" })' }, + { code: 'React.createElement("a", { rel: ["prev"] })' }, + { code: '' }, + { code: 'React.createElement("a", { rel: "search" })' }, + { code: 'React.createElement("a", { rel: ["search"] })' }, + { code: '' }, + { code: 'React.createElement("a", { rel: "tag" })' }, + { code: 'React.createElement("a", { rel: ["tag"] })' }, + { code: '' }, + { code: 'React.createElement("area", { rel: "alternate" })' }, + { code: 'React.createElement("area", { rel: ["alternate"] })' }, + { code: '' }, + { code: 'React.createElement("area", { rel: "author" })' }, + { code: 'React.createElement("area", { rel: ["author"] })' }, + { code: '' }, + { code: 'React.createElement("area", { rel: "bookmark" })' }, + { code: 'React.createElement("area", { rel: ["bookmark"] })' }, + { code: '' }, + { code: 'React.createElement("area", { rel: "external" })' }, + { code: 'React.createElement("area", { rel: ["external"] })' }, + { code: '' }, + { code: 'React.createElement("area", { rel: "help" })' }, + { code: 'React.createElement("area", { rel: ["help"] })' }, + { code: '' }, + { code: 'React.createElement("area", { rel: "license" })' }, + { code: 'React.createElement("area", { rel: ["license"] })' }, + { code: '' }, + { code: 'React.createElement("area", { rel: "next" })' }, + { code: 'React.createElement("area", { rel: ["next"] })' }, + { code: '' }, + { code: 'React.createElement("area", { rel: "nofollow" })' }, + { code: 'React.createElement("area", { rel: ["nofollow"] })' }, + { code: '' }, + { code: 'React.createElement("area", { rel: "noopener" })' }, + { code: 'React.createElement("area", { rel: ["noopener"] })' }, + { code: '' }, + { code: 'React.createElement("area", { rel: "noreferrer" })' }, + { code: 'React.createElement("area", { rel: ["noreferrer"] })' }, + { code: '' }, + { code: 'React.createElement("area", { rel: "opener" })' }, + { code: 'React.createElement("area", { rel: ["opener"] })' }, + { code: '' }, + { code: 'React.createElement("area", { rel: "prev" })' }, + { code: 'React.createElement("area", { rel: ["prev"] })' }, + { code: '' }, + { code: 'React.createElement("area", { rel: "search" })' }, + { code: 'React.createElement("area", { rel: ["search"] })' }, + { code: '' }, + { code: 'React.createElement("area", { rel: "tag" })' }, + { code: 'React.createElement("area", { rel: ["tag"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "alternate" })' }, + { code: 'React.createElement("link", { rel: ["alternate"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "author" })' }, + { code: 'React.createElement("link", { rel: ["author"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "canonical" })' }, + { code: 'React.createElement("link", { rel: ["canonical"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "dns-prefetch" })' }, + { code: 'React.createElement("link", { rel: ["dns-prefetch"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "help" })' }, + { code: 'React.createElement("link", { rel: ["help"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "icon" })' }, + { code: 'React.createElement("link", { rel: ["icon"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "license" })' }, + { code: 'React.createElement("link", { rel: ["license"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "manifest" })' }, + { code: 'React.createElement("link", { rel: ["manifest"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "modulepreload" })' }, + { code: 'React.createElement("link", { rel: ["modulepreload"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "next" })' }, + { code: 'React.createElement("link", { rel: ["next"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "pingback" })' }, + { code: 'React.createElement("link", { rel: ["pingback"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "preconnect" })' }, + { code: 'React.createElement("link", { rel: ["preconnect"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "prefetch" })' }, + { code: 'React.createElement("link", { rel: ["prefetch"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "preload" })' }, + { code: 'React.createElement("link", { rel: ["preload"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "prerender" })' }, + { code: 'React.createElement("link", { rel: ["prerender"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "prev" })' }, + { code: 'React.createElement("link", { rel: ["prev"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "search" })' }, + { code: 'React.createElement("link", { rel: ["search"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "stylesheet" })' }, + { code: 'React.createElement("link", { rel: ["stylesheet"] })' }, + { code: '
' }, + { code: 'React.createElement("form", { rel: "external" })' }, + { code: 'React.createElement("form", { rel: ["external"] })' }, + { code: '
' }, + { code: 'React.createElement("form", { rel: "help" })' }, + { code: 'React.createElement("form", { rel: ["help"] })' }, + { code: '
' }, + { code: 'React.createElement("form", { rel: "license" })' }, + { code: 'React.createElement("form", { rel: ["license"] })' }, + { code: '' }, + { code: 'React.createElement("form", { rel: "next" })' }, + { code: 'React.createElement("form", { rel: ["next"] })' }, + { code: '
' }, + { code: 'React.createElement("form", { rel: "nofollow" })' }, + { code: 'React.createElement("form", { rel: ["nofollow"] })' }, + { code: '
' }, + { code: 'React.createElement("form", { rel: "noopener" })' }, + { code: 'React.createElement("form", { rel: ["noopener"] })' }, + { code: '
' }, + { code: 'React.createElement("form", { rel: "noreferrer" })' }, + { code: 'React.createElement("form", { rel: ["noreferrer"] })' }, + { code: '
' }, + { code: 'React.createElement("form", { rel: "opener" })' }, + { code: 'React.createElement("form", { rel: ["opener"] })' }, + { code: '' }, + { code: 'React.createElement("form", { rel: "prev" })' }, + { code: 'React.createElement("form", { rel: ["prev"] })' }, + { code: '' }, + { code: 'React.createElement("form", { rel: "search" })' }, + { code: 'React.createElement("form", { rel: ["search"] })' }, + { code: '
' }, + { code: 'React.createElement("form", { rel: callFoo() })' }, + { code: 'React.createElement("form", { rel: [callFoo()] })' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: 'React.createElement("Foo", { rel: true })' }, + ], + invalid: [ + { + code: '', + output: '', + errors: [ + { + messageId: 'onlyMeaningfulFor', + data: { + attributeName: 'rel', + tagNames: '"", "", "", "
"', + }, + }, + ], + }, + { + code: 'React.createElement("html", { rel: 1 })', + errors: [ + { + messageId: 'onlyMeaningfulFor', + data: { + attributeName: 'rel', + tagNames: '"", "", "", ""', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'emptyIsMeaningless', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: 'React.createElement("a", { rel: 1 })', + errors: [ + { + messageId: 'neverValid', + data: { + attributeName: 'rel', + reportingValue: 1, + }, + }, + ], + }, + { + code: 'React.createElement("a", { rel() { return 1; } })', + errors: [ + { + messageId: 'noMethod', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'onlyMeaningfulFor', + data: { + attributeName: 'rel', + tagNames: '"", "", "", ""', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'onlyStrings', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'onlyStrings', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'onlyStrings', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'onlyStrings', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'onlyStrings', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'neverValid', + data: { + attributeName: 'rel', + reportingValue: 'foobar', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'spaceDelimited', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'neverValid', + data: { + reportingValue: 'foobar', + attributeName: 'rel', + }, + }, + ], + }, + { + code: 'React.createElement("a", { rel: ["noreferrer", "noopener", "foobar" ] })', + errors: [ + { + messageId: 'neverValid', + data: { + reportingValue: 'foobar', + attributeName: 'rel', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'neverValid', + data: { + reportingValue: 'foobar', + attributeName: 'rel', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'neverValid', + data: { + reportingValue: 'foobar', + attributeName: 'rel', + }, + }, + { + messageId: 'neverValid', + data: { + reportingValue: 'batgo', + attributeName: 'rel', + }, + }, + { + messageId: 'spaceDelimited', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'spaceDelimited', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'spaceDelimited', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'neverValid', + data: { + reportingValue: 'batgo', + attributeName: 'rel', + }, + }, + { + messageId: 'spaceDelimited', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'neverValid', + data: { + reportingValue: 'batgo', + attributeName: 'rel', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'spaceDelimited', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'canonical', + attributeName: 'rel', + elementName: 'a', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'dns-prefetch', + attributeName: 'rel', + elementName: 'a', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'icon', + attributeName: 'rel', + elementName: 'a', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'manifest', + attributeName: 'rel', + elementName: 'a', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'modulepreload', + attributeName: 'rel', + elementName: 'a', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'pingback', + attributeName: 'rel', + elementName: 'a', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'preconnect', + attributeName: 'rel', + elementName: 'a', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'prefetch', + attributeName: 'rel', + elementName: 'a', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'preload', + attributeName: 'rel', + elementName: 'a', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'prerender', + attributeName: 'rel', + elementName: 'a', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'stylesheet', + attributeName: 'rel', + elementName: 'a', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'canonical', + attributeName: 'rel', + elementName: 'area', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'dns-prefetch', + attributeName: 'rel', + elementName: 'area', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'icon', + attributeName: 'rel', + elementName: 'area', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'manifest', + attributeName: 'rel', + elementName: 'area', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'modulepreload', + attributeName: 'rel', + elementName: 'area', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'pingback', + attributeName: 'rel', + elementName: 'area', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'preconnect', + attributeName: 'rel', + elementName: 'area', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'prefetch', + attributeName: 'rel', + elementName: 'area', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'preload', + attributeName: 'rel', + elementName: 'area', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'prerender', + attributeName: 'rel', + elementName: 'area', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'stylesheet', + attributeName: 'rel', + elementName: 'area', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'bookmark', + attributeName: 'rel', + elementName: 'link', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'external', + attributeName: 'rel', + elementName: 'link', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'nofollow', + attributeName: 'rel', + elementName: 'link', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'noopener', + attributeName: 'rel', + elementName: 'link', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'noreferrer', + attributeName: 'rel', + elementName: 'link', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'opener', + attributeName: 'rel', + elementName: 'link', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'tag', + attributeName: 'rel', + elementName: 'link', + }, + }, + ], + }, + { + code: '
', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'alternate', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + { + code: '', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'author', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + { + code: '
', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'bookmark', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + { + code: '
', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'canonical', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + { + code: '
', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'dns-prefetch', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + { + code: '', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'icon', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + { + code: '
', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'manifest', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + { + code: '
', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'modulepreload', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + { + code: '
', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'pingback', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + { + code: '
', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'preconnect', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + { + code: '
', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'prefetch', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + { + code: '
', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'preload', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + { + code: '
', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'prerender', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + { + code: '
', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'stylesheet', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + { + code: '', + output: '
', + errors: [ + { + messageId: 'notValidFor', + data: { + reportingValue: 'tag', + attributeName: 'rel', + elementName: 'form', + }, + }, + ], + }, + ], +});