You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

593 lines
14 KiB

2 years ago
/**
* @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'])],
['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();
COMPONENT_ATTRIBUTE_MAP.set('rel', new Set(['link', 'a', 'area', 'form']));
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.',
};
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 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,
data: {
attributeName,
reportingValue,
},
fix(fixer) {
return fixer.removeRange(singlePart.range);
},
});
} else if (!allowedTags.has(parentNodeName)) {
report(context, messages.notValidFor, 'notValidFor', {
node,
data: {
attributeName,
reportingValue,
elementName: parentNodeName,
},
fix(fixer) {
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 allowedPair of allowedPairsForAttribute) {
const pairing = allowedPair[0];
const siblings = allowedPair[1];
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(', '),
},
});
}
}
}
}
}
const whitespaceParts = splitIntoRangedParts(node, /(\s+)/g);
for (const whitespacePart of whitespaceParts) {
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');
},
});
}
}
}
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 || 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);
}
},
};
},
};