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.
592 lines
14 KiB
592 lines
14 KiB
/** |
|
* @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); |
|
} |
|
}, |
|
}; |
|
}, |
|
};
|
|
|