Skip to content

Commit

Permalink
[Fix] no-invalid-html-attribute: allow 'shortcut icon' on link
Browse files Browse the repository at this point in the history
Fixes #3172
  • Loading branch information
Primajin authored and ljharb committed Jan 11, 2022
1 parent 4c3d00f commit e0af60b
Show file tree
Hide file tree
Showing 3 changed files with 149 additions and 7 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Expand Up @@ -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
Expand Down
64 changes: 57 additions & 7 deletions lib/rules/no-invalid-html-attribute.js
Expand Up @@ -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<string, Map<string, Set<string>>>}
Expand All @@ -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<string, Map<string, Set<string>>>}
*/
const VALID_PAIR_VALUES = new Map([
['rel', pairs],
]);

/**
* The set of all possible HTML elements. Used for skipping custom types
* @type {Set<string>}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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)) {
Expand All @@ -280,22 +296,56 @@ 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 },
fix(fixer) {
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');
},
});
}
}
}
Expand Down
90 changes: 90 additions & 0 deletions tests/lib/rules/no-invalid-html-attribute.js
Expand Up @@ -130,6 +130,9 @@ ruleTester.run('no-invalid-html-attribute', rule, {
{ code: '<link rel="icon"></link>' },
{ code: 'React.createElement("link", { rel: "icon" })' },
{ code: 'React.createElement("link", { rel: ["icon"] })' },
{ code: '<link rel="shortcut icon"></link>' },
{ code: 'React.createElement("link", { rel: "shortcut icon" })' },
{ code: 'React.createElement("link", { rel: ["shortcut icon"] })' },
{ code: '<link rel="license"></link>' },
{ code: 'React.createElement("link", { rel: "license" })' },
{ code: 'React.createElement("link", { rel: ["license"] })' },
Expand Down Expand Up @@ -450,6 +453,26 @@ ruleTester.run('no-invalid-html-attribute', rule, {
},
],
},
{
code: '<a rel="noreferrer noopener"></a>',
output: '<a rel="noreferrer noopener"></a>',
errors: [
{
messageId: 'spaceDelimited',
data: { attributeName: 'rel' },
},
],
},
{
code: '<a rel="noreferrer\xa0\xa0noopener"></a>',
output: '<a rel="noreferrer noopener"></a>',
errors: [
{
messageId: 'spaceDelimited',
data: { attributeName: 'rel' },
},
],
},
{
code: '<a rel={"noreferrer noopener foobar"}></a>',
output: '<a rel={"noreferrer noopener "}></a>',
Expand Down Expand Up @@ -614,6 +637,73 @@ ruleTester.run('no-invalid-html-attribute', rule, {
},
],
},
{
code: '<link rel="shortcut"></link>',
errors: [
{
messageId: 'notAlone',
data: {
reportingValue: 'shortcut',
missingValue: 'icon',
},
},
],
},
{
code: '<link rel="shortcut foo"></link>',
output: '<link rel="shortcut "></link>',
errors: [
{
messageId: 'neverValid',
data: {
reportingValue: 'foo',
attributeName: 'rel',
},
},
{
messageId: 'notPaired',
data: {
reportingValue: 'shortcut',
secondValue: 'foo',
missingValue: 'icon',
},
},
],
},
{
code: '<link rel="shortcut icon"></link>',
output: '<link rel="shortcut icon"></link>',
errors: [
{
messageId: 'spaceDelimited',
data: { attributeName: 'rel' },
},
],
},
{
code: '<link rel="shortcut foo"></link>',
output: '<link rel="shortcut foo"></link>',
errors: [
{
messageId: 'neverValid',
data: {
reportingValue: 'foo',
attributeName: 'rel',
},
},
{
messageId: 'notAlone',
data: {
reportingValue: 'shortcut',
missingValue: 'icon',
},
},
{
messageId: 'spaceDelimited',
data: { attributeName: 'rel' },
},
],
},
{
code: '<a rel="manifest"></a>',
output: '<a rel=""></a>',
Expand Down

0 comments on commit e0af60b

Please sign in to comment.