diff --git a/CHANGELOG.md b/CHANGELOG.md index de8b57a2a5..06199c2b15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,13 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ### Fixed * [`prop-types`], `propTypes`: add support for exported type inference ([#3163][] @vedadeepta) +* [`no-invalid-html-attribute`]: allow 'shortcut icon' on `link` ([#3174][] @Primajin) ### Changed * [readme] change [`jsx-runtime`] link from branch to sha ([#3160][] @tatsushitoji) * [Docs] HTTP => HTTPS ([#3133][] @Schweinepriester) +[#3174]: https://github.com/yannickcr/eslint-plugin-react/pull/3174 [#3163]: https://github.com/yannickcr/eslint-plugin-react/pull/3163 [#3160]: https://github.com/yannickcr/eslint-plugin-react/pull/3160 [#3133]: https://github.com/yannickcr/eslint-plugin-react/pull/3133 diff --git a/lib/rules/no-invalid-html-attribute.js b/lib/rules/no-invalid-html-attribute.js index 1a5b255f4e..e82c4c4316 100644 --- a/lib/rules/no-invalid-html-attribute.js +++ b/lib/rules/no-invalid-html-attribute.js @@ -39,10 +39,16 @@ const rel = new Map([ ['prerender', new Set(['link'])], ['prev', new Set(['link', 'area', 'a', 'form'])], ['search', new Set(['link', 'area', 'a', 'form'])], + ['shortcut', new Set(['link'])], // generally allowed but needs pair with "icon" + ['shortcut\u0020icon', new Set(['link'])], ['stylesheet', new Set(['link'])], ['tag', new Set(['area', 'a'])], ]); +const pairs = new Map([ + ['shortcut', new Set(['icon'])], +]); + /** * Map between attributes and a mapping between valid values and a set of tags they are valid on * @type {Map>>} @@ -51,6 +57,14 @@ const VALID_VALUES = new Map([ ['rel', rel], ]); +/** + * Map between attributes and a mapping between pair-values and a set of values they are valid with + * @type {Map>>} + */ +const VALID_PAIR_VALUES = new Map([ + ['rel', pairs], +]); + /** * The set of all possible HTML elements. Used for skipping custom types * @type {Set} @@ -216,6 +230,8 @@ const messages = { 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.', + notAlone: '“{{reportingValue}}” must be directly followed by “{{missingValue}}”.', + notPaired: '“{{reportingValue}}” can not be directly followed by “{{secondValue}}” without “{{missingValue}}”.', }; function splitIntoRangedParts(node, regex) { @@ -256,10 +272,10 @@ function checkLiteralValueNode(context, attributeName, node, parentNode, parentN 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; + const singleAttributeParts = splitIntoRangedParts(node, /(\S+)/g); + for (const singlePart of singleAttributeParts) { + const allowedTags = VALID_VALUES.get(attributeName).get(singlePart.value); + const reportingValue = singlePart.reportingValue; if (!allowedTags) { report(context, messages.neverValid, 'neverValid', { node, @@ -268,7 +284,7 @@ function checkLiteralValueNode(context, attributeName, node, parentNode, parentN reportingValue, }, fix(fixer) { - return fixer.removeRange(part.range); + return fixer.removeRange(singlePart.range); }, }); } else if (!allowedTags.has(parentNodeName)) { @@ -280,15 +296,41 @@ function checkLiteralValueNode(context, attributeName, node, parentNode, parentN elementName: parentNodeName, }, fix(fixer) { - return fixer.removeRange(part.range); + return fixer.removeRange(singlePart.range); }, }); } } + const allowedPairsForAttribute = VALID_PAIR_VALUES.get(attributeName); + if (allowedPairsForAttribute) { + const pairAttributeParts = splitIntoRangedParts(node, /(?=(\b\S+\s*\S+))/g); + for (const pairPart of pairAttributeParts) { + for (const [pairing, siblings] of allowedPairsForAttribute) { + const attributes = pairPart.reportingValue.split('\u0020'); + const [firstValue, secondValue] = attributes; + if (firstValue === pairing) { + const lastValue = attributes[attributes.length - 1]; // in case of multiple white spaces + if (!siblings.has(lastValue)) { + const message = secondValue ? messages.notPaired : messages.notAlone; + const messageId = secondValue ? 'notPaired' : 'notAlone'; + report(context, message, messageId, { + node, + data: { + reportingValue: firstValue, + secondValue, + missingValue: [...siblings].join(', '), + }, + }); + } + } + } + } + } + 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)) { + if (whitespacePart.range[0] === (node.range[0] + 1) || whitespacePart.range[1] === (node.range[1] - 1)) { report(context, messages.spaceDelimited, 'spaceDelimited', { node, data: { attributeName }, @@ -296,6 +338,14 @@ function checkLiteralValueNode(context, attributeName, node, parentNode, parentN return fixer.removeRange(whitespacePart.range); }, }); + } else if (whitespacePart.value !== '\u0020') { + report(context, messages.spaceDelimited, 'spaceDelimited', { + node, + data: { attributeName }, + fix(fixer) { + return fixer.replaceTextRange(whitespacePart.range, '\u0020'); + }, + }); } } } diff --git a/tests/lib/rules/no-invalid-html-attribute.js b/tests/lib/rules/no-invalid-html-attribute.js index 7c63453037..7021d92626 100644 --- a/tests/lib/rules/no-invalid-html-attribute.js +++ b/tests/lib/rules/no-invalid-html-attribute.js @@ -130,6 +130,9 @@ ruleTester.run('no-invalid-html-attribute', rule, { { code: '' }, { code: 'React.createElement("link", { rel: "icon" })' }, { code: 'React.createElement("link", { rel: ["icon"] })' }, + { code: '' }, + { code: 'React.createElement("link", { rel: "shortcut icon" })' }, + { code: 'React.createElement("link", { rel: ["shortcut icon"] })' }, { code: '' }, { code: 'React.createElement("link", { rel: "license" })' }, { code: 'React.createElement("link", { rel: ["license"] })' }, @@ -231,6 +234,83 @@ ruleTester.run('no-invalid-html-attribute', rule, { }, ], invalid: [ + { + code: '', + output: '', + errors: [ + { + messageId: 'neverValid', + }, + ], + }, + { + code: 'React.createElement("a", { rel: "alternatex" })', + output: 'React.createElement("a", { rel: "alternatex" })', + errors: [ + { + messageId: 'neverValid', + }, + ], + }, + { + code: 'React.createElement("a", { rel: ["alternatex"] })', + output: 'React.createElement("a", { rel: ["alternatex"] })', + errors: [ + { + messageId: 'neverValid', + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'neverValid', + }, + ], + }, + { + code: 'React.createElement("a", { rel: "alternatex alternate" })', + errors: [ + { + messageId: 'neverValid', + }, + ], + }, + { + code: 'React.createElement("a", { rel: ["alternatex alternate"] })', + errors: [ + { + messageId: 'neverValid', + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'neverValid', + }, + ], + }, + { + code: 'React.createElement("a", { rel: "alternate alternatex" })', + errors: [ + { + messageId: 'neverValid', + }, + ], + }, + { + code: 'React.createElement("a", { rel: ["alternate alternatex"] })', + errors: [ + { + messageId: 'neverValid', + }, + ], + }, { code: '', output: '', @@ -373,6 +453,26 @@ ruleTester.run('no-invalid-html-attribute', rule, { }, ], }, + { + code: '', + output: '', + errors: [ + { + messageId: 'spaceDelimited', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'spaceDelimited', + data: { attributeName: 'rel' }, + }, + ], + }, { code: '', output: '', @@ -537,6 +637,73 @@ ruleTester.run('no-invalid-html-attribute', rule, { }, ], }, + { + code: '', + errors: [ + { + messageId: 'notAlone', + data: { + reportingValue: 'shortcut', + missingValue: 'icon', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'neverValid', + data: { + reportingValue: 'foo', + attributeName: 'rel', + }, + }, + { + messageId: 'notPaired', + data: { + reportingValue: 'shortcut', + secondValue: 'foo', + missingValue: 'icon', + }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'spaceDelimited', + data: { attributeName: 'rel' }, + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + messageId: 'neverValid', + data: { + reportingValue: 'foo', + attributeName: 'rel', + }, + }, + { + messageId: 'notAlone', + data: { + reportingValue: 'shortcut', + missingValue: 'icon', + }, + }, + { + messageId: 'spaceDelimited', + data: { attributeName: 'rel' }, + }, + ], + }, { code: '', output: '',