123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639 |
- /**
- * @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');
- const getMessageData = require('../util/message');
- // ------------------------------------------------------------------------------
- // 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([
- ['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.',
- suggestRemoveDefault: '"remove {{attributeName}}"',
- suggestRemoveEmpty: '"remove empty attribute {{attributeName}}"',
- suggestRemoveInvalid: '“remove invalid attribute {{reportingValue}}”',
- suggestRemoveWhitespaces: 'remove whitespaces in “{{reportingValue}}”',
- suggestRemoveNonString: 'remove non-string value in “{{reportingValue}}”',
- };
- 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 },
- suggest: [
- Object.assign(
- getMessageData('suggestRemoveNonString', messages.suggestRemoveNonString),
- { fix(fixer) { return fixer.remove(parentNode); } }
- ),
- ],
- });
- return;
- }
- if (!node.value.trim()) {
- report(context, messages.noEmpty, 'noEmpty', {
- node,
- data: { attributeName },
- suggest: [
- Object.assign(
- getMessageData('suggestRemoveEmpty', messages.suggestRemoveEmpty),
- { fix(fixer) { return fixer.remove(node.parent); } }
- ),
- ],
- });
- 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;
- const suggest = [
- Object.assign(
- getMessageData('suggestRemoveInvalid', messages.suggestRemoveInvalid),
- { fix(fixer) { return fixer.removeRange(singlePart.range); } }
- ),
- ];
- if (!allowedTags) {
- const data = {
- attributeName,
- reportingValue,
- };
- report(context, messages.neverValid, 'neverValid', {
- node,
- data,
- suggest,
- });
- } else if (!allowedTags.has(parentNodeName)) {
- report(context, messages.notValidFor, 'notValidFor', {
- node,
- data: {
- attributeName,
- reportingValue,
- elementName: parentNodeName,
- },
- suggest,
- });
- }
- }
- 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(', '),
- },
- suggest: false,
- });
- }
- }
- }
- }
- }
- 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 },
- suggest: [
- Object.assign(
- getMessageData('suggestRemoveWhitespaces', messages.suggestRemoveWhitespaces),
- { fix(fixer) { return fixer.removeRange(whitespacePart.range); } }
- ),
- ],
- });
- } else if (whitespacePart.value !== '\u0020') {
- report(context, messages.spaceDelimited, 'spaceDelimited', {
- node,
- data: { attributeName },
- suggest: [
- Object.assign(
- getMessageData('suggestRemoveWhitespaces', messages.suggestRemoveWhitespaces),
- { 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(', ');
- report(context, messages.onlyMeaningfulFor, 'onlyMeaningfulFor', {
- node: node.name,
- data: {
- attributeName: attribute,
- tagNames,
- },
- suggest: [
- Object.assign(
- getMessageData('suggestRemoveDefault', messages.suggestRemoveDefault),
- { fix(fixer) { return fixer.remove(node); } }
- ),
- ],
- });
- return;
- }
- function fix(fixer) { return fixer.remove(node); }
- if (!node.value) {
- report(context, messages.emptyIsMeaningless, 'emptyIsMeaningless', {
- node: node.name,
- data: { attributeName: attribute },
- suggest: [
- Object.assign(
- getMessageData('suggestRemoveEmpty', messages.suggestRemoveEmpty),
- { 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: node.value,
- data: { attributeName: attribute },
- suggest: [
- Object.assign(
- getMessageData('suggestRemoveDefault', messages.suggestRemoveDefault),
- { fix }
- ),
- ],
- });
- } else if (node.value.expression.type === 'Identifier' && node.value.expression.name === 'undefined') {
- report(context, messages.onlyStrings, 'onlyStrings', {
- node: node.value,
- data: { attributeName: attribute },
- suggest: [
- Object.assign(
- getMessageData('suggestRemoveDefault', messages.suggestRemoveDefault),
- { 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,
- },
- suggest: [
- Object.assign(
- getMessageData('suggestRemoveInvalid', messages.suggestRemoveInvalid),
- { 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') {
- 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: {
- 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 (!HTML_ELEMENTS.has(elemNameArg.value)) {
- return;
- }
- const attributes = new Set(context.options[0] || DEFAULT_ATTRIBUTES);
- for (const attribute of attributes) {
- checkCreateProps(context, node, attribute);
- }
- },
- };
- },
- };
|