/** * @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>>} */ 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} */ 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>} */ 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); }); }, }; }, };