the-forest/client/node_modules/eslint-plugin-react/lib/rules/no-invalid-html-attribute.js

655 lines
16 KiB
JavaScript
Raw Normal View History

2024-09-17 20:35:18 -04:00
/**
* @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'])],
['apple-touch-icon', new Set(['link'])],
['apple-touch-startup-image', new Set(['link'])],
['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'])],
['mask-icon', 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'])],
['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>>>}
*/
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>}
*/
const HTML_ELEMENTS = new Set([
'a',
'abbr',
'acronym',
'address',
'applet',
'area',
'article',
'aside',
'audio',
'b',
'base',
'basefont',
'bdi',
'bdo',
'bgsound',
'big',
'blink',
'blockquote',
'body',
'br',
'button',
'canvas',
'caption',
'center',
'cite',
'code',
'col',
'colgroup',
'content',
'data',
'datalist',
'dd',
'del',
'details',
'dfn',
'dialog',
'dir',
'div',
'dl',
'dt',
'em',
'embed',
'fieldset',
'figcaption',
'figure',
'font',
'footer',
'form',
'frame',
'frameset',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'head',
'header',
'hgroup',
'hr',
'html',
'i',
'iframe',
'image',
'img',
'input',
'ins',
'kbd',
'keygen',
'label',
'legend',
'li',
'link',
'main',
'map',
'mark',
'marquee',
'math',
'menu',
'menuitem',
'meta',
'meter',
'nav',
'nobr',
'noembed',
'noframes',
'noscript',
'object',
'ol',
'optgroup',
'option',
'output',
'p',
'param',
'picture',
'plaintext',
'portal',
'pre',
'progress',
'q',
'rb',
'rp',
'rt',
'rtc',
'ruby',
's',
'samp',
'script',
'section',
'select',
'shadow',
'slot',
'small',
'source',
'spacer',
'span',
'strike',
'strong',
'style',
'sub',
'summary',
'sup',
'svg',
'table',
'tbody',
'td',
'template',
'textarea',
'tfoot',
'th',
'thead',
'time',
'title',
'tr',
'track',
'tt',
'u',
'ul',
'var',
'video',
'wbr',
'xmp',
]);
/**
* Map between attributes and set of tags that the attribute is valid on
* @type {Map<string, Set<string>>}
*/
const COMPONENT_ATTRIBUTE_MAP = new Map([
['rel', new Set(['link', 'a', 'area', 'form'])],
]);
/* eslint-disable eslint-plugin/no-unused-message-ids -- false positives, these messageIds are used */
const messages = {
emptyIsMeaningless: 'An empty “{{attributeName}}” attribute is meaningless.',
neverValid: '“{{reportingValue}}” is never a valid “{{attributeName}}” attribute value.',
noEmpty: 'An empty “{{attributeName}}” attribute is meaningless.',
noMethod: 'The ”{{attributeName}}“ attribute cannot be a method.',
notAlone: '“{{reportingValue}}” must be directly followed by “{{missingValue}}”.',
notPaired: '“{{reportingValue}}” can not be directly followed by “{{secondValue}}” without “{{missingValue}}”.',
notValidFor: '“{{reportingValue}}” is not a valid “{{attributeName}}” attribute value for <{{elementName}}>.',
onlyMeaningfulFor: 'The ”{{attributeName}}“ attribute only has meaning on the tags: {{tagNames}}',
onlyStrings: '“{{attributeName}}” attribute only supports strings.',
spaceDelimited: '”{{attributeName}}“ attribute values should be space delimited.',
suggestRemoveDefault: '"remove {{attributeName}}"',
suggestRemoveEmpty: '"remove empty attribute {{attributeName}}"',
suggestRemoveInvalid: '“remove invalid attribute {{reportingValue}}”',
suggestRemoveWhitespaces: 'remove whitespaces in “{{attributeName}}”',
suggestRemoveNonString: 'remove non-string value in “{{attributeName}}”',
};
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') {
const data = { attributeName, reportingValue: node.value };
report(context, messages.onlyStrings, 'onlyStrings', {
node,
data,
suggest: [{
messageId: 'suggestRemoveNonString',
data,
fix(fixer) { return fixer.remove(parentNode); },
}],
});
return;
}
if (!node.value.trim()) {
const data = { attributeName, reportingValue: node.value };
report(context, messages.noEmpty, 'noEmpty', {
node,
data,
suggest: [{
messageId: 'suggestRemoveEmpty',
data,
fix(fixer) { return fixer.remove(node.parent); },
}],
});
return;
}
const singleAttributeParts = splitIntoRangedParts(node, /(\S+)/g);
singleAttributeParts.forEach((singlePart) => {
const allowedTags = VALID_VALUES.get(attributeName).get(singlePart.value);
const reportingValue = singlePart.reportingValue;
if (!allowedTags) {
const data = {
attributeName,
reportingValue,
};
const suggest = [{
messageId: 'suggestRemoveInvalid',
data,
fix(fixer) { return fixer.removeRange(singlePart.range); },
}];
report(context, messages.neverValid, 'neverValid', {
node,
data,
suggest,
});
} else if (!allowedTags.has(parentNodeName)) {
const data = {
attributeName,
reportingValue,
elementName: parentNodeName,
};
const suggest = [{
messageId: 'suggestRemoveInvalid',
data,
fix(fixer) { return fixer.removeRange(singlePart.range); },
}];
report(context, messages.notValidFor, 'notValidFor', {
node,
data,
suggest,
});
}
});
const allowedPairsForAttribute = VALID_PAIR_VALUES.get(attributeName);
if (allowedPairsForAttribute) {
const pairAttributeParts = splitIntoRangedParts(node, /(?=(\b\S+\s*\S+))/g);
pairAttributeParts.forEach((pairPart) => {
allowedPairsForAttribute.forEach((siblings, pairing) => {
const attributes = pairPart.reportingValue.split('\u0020');
const firstValue = attributes[0];
const secondValue = attributes[1];
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: Array.from(siblings).join(', '),
},
suggest: false,
});
}
}
});
});
}
const whitespaceParts = splitIntoRangedParts(node, /(\s+)/g);
whitespaceParts.forEach((whitespacePart) => {
const data = { attributeName };
if (whitespacePart.range[0] === (node.range[0] + 1) || whitespacePart.range[1] === (node.range[1] - 1)) {
report(context, messages.spaceDelimited, 'spaceDelimited', {
node,
data,
suggest: [{
messageId: 'suggestRemoveWhitespaces',
data,
fix(fixer) { return fixer.removeRange(whitespacePart.range); },
}],
});
} else if (whitespacePart.value !== '\u0020') {
report(context, messages.spaceDelimited, 'spaceDelimited', {
node,
data,
suggest: [{
messageId: 'suggestRemoveWhitespaces',
data,
fix(fixer) { return fixer.replaceTextRange(whitespacePart.range, '\u0020'); },
}],
});
}
});
}
const DEFAULT_ATTRIBUTES = ['rel'];
function checkAttribute(context, node) {
const attribute = node.name.name;
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(', ');
const data = {
attributeName: attribute,
tagNames,
};
report(context, messages.onlyMeaningfulFor, 'onlyMeaningfulFor', {
node: node.name,
data,
suggest: [{
messageId: 'suggestRemoveDefault',
data,
fix(fixer) { return fixer.remove(node); },
}],
});
return;
}
function fix(fixer) { return fixer.remove(node); }
if (!node.value) {
const data = { attributeName: attribute };
report(context, messages.emptyIsMeaningless, 'emptyIsMeaningless', {
node: node.name,
data,
suggest: [{
messageId: 'suggestRemoveEmpty',
data,
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') {
const data = { attributeName: attribute };
report(context, messages.onlyStrings, 'onlyStrings', {
node: node.value,
data,
suggest: [{
messageId: 'suggestRemoveDefault',
data,
fix,
}],
});
} else if (node.value.expression.type === 'Identifier' && node.value.expression.name === 'undefined') {
const data = { attributeName: attribute };
report(context, messages.onlyStrings, 'onlyStrings', {
node: node.value,
data,
suggest: [{
messageId: 'suggestRemoveDefault',
data,
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) {
const data = {
attributeName: attribute,
reportingValue: value.value,
};
report(context, messages.neverValid, 'neverValid', {
node: value,
data,
suggest: [{
messageId: 'suggestRemoveInvalid',
data,
fix(fixer) { return fixer.replaceText(value, value.raw.replace(value.value, '')); },
}],
});
} else 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,
},
suggest: false,
});
}
}
/**
*
* @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 || 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: prop.key,
data: {
attributeName: attribute,
tagNames,
},
suggest: false,
});
// eslint-disable-next-line no-continue
continue;
}
if (prop.method) {
report(context, messages.noMethod, 'noMethod', {
node: prop,
data: {
attributeName: attribute,
},
suggest: false,
});
// 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') {
prop.value.elements.forEach((value) => {
checkPropValidValue(context, node, value, attribute);
});
// eslint-disable-next-line no-continue
continue;
}
checkPropValidValue(context, node, prop.value, attribute);
}
}
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
docs: {
description: 'Disallow usage of invalid attributes',
category: 'Possible Errors',
url: docsUrl('no-invalid-html-attribute'),
},
messages,
schema: [{
type: 'array',
uniqueItems: true,
items: {
enum: ['rel'],
},
}],
type: 'suggestion',
hasSuggestions: true, // eslint-disable-line eslint-plugin/require-meta-has-suggestions
},
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 (typeof elemNameArg.value === 'string' && !HTML_ELEMENTS.has(elemNameArg.value)) {
return;
}
const attributes = new Set(context.options[0] || DEFAULT_ATTRIBUTES);
attributes.forEach((attribute) => {
checkCreateProps(context, node, attribute);
});
},
};
},
};