UNPKG

stylelint

Version:

A mighty CSS linter that helps you avoid errors and enforce conventions.

371 lines (294 loc) • 9.94 kB
import { parse, walk } from 'css-tree'; import { aNPlusBOfSNotationPseudoClasses, dirIdentifiers, elementBackedPseudoElements, subPseudoElements, } from '../../reference/selectors.mjs'; import { assert } from '../../utils/validateTypes.mjs'; import getRuleSelector from '../../utils/getRuleSelector.mjs'; import isKeyframeRule from '../../utils/isKeyframeRule.mjs'; import isStandardSyntaxRule from '../../utils/isStandardSyntaxRule.mjs'; import { mayIncludeRegexes } from '../../utils/regexes.mjs'; import report from '../../utils/report.mjs'; import ruleMessages from '../../utils/ruleMessages.mjs'; import uniteSets from '../../utils/uniteSets.mjs'; import validateOptions from '../../utils/validateOptions.mjs'; const ruleName = 'selector-no-invalid'; const messages = ruleMessages(ruleName, { rejected: (selector, reason) => `Invalid selector "${selector}"${reason ? `, ${reason}` : ''}`, }); const meta = { url: 'https://stylelint.io/user-guide/rules/selector-no-invalid', }; /** @import { CssNode, PseudoClassSelector, PseudoElementSelector, Selector } from 'css-tree' */ /** * @typedef {(reason: string) => void} Complain */ /** * Pseudo-classes and pseudo-elements whose selector arguments are unforgiving, * excluding the deprecated `:host-context()`; * see https://drafts.csswg.org/selectors/#typedef-forgiving-selector-list */ const unforgivingArgumentPseudoSelectors = uniteSets(aNPlusBOfSNotationPseudoClasses, [ 'has', 'host', 'not', 'slotted', ]); /** * Pseudo-selectors whose argument must be a compound selector; * see https://drafts.csswg.org/css-shadow-1/ */ const compoundArgumentPseudoSelectors = new Set(['host', 'slotted']); const expectedDirIdentifiers = [...dirIdentifiers].map((id) => `"${id}"`).join(' or '); /** @type {import('stylelint').CoreRules[typeof ruleName]} */ const rule = (primary) => { return (root, result) => { const validOptions = validateOptions(result, ruleName, { actual: primary }); if (!validOptions) return; root.walkRules((ruleNode) => { if (!isStandardSyntaxRule(ruleNode)) return; if (isKeyframeRule(ruleNode)) return; const selector = getRuleSelector(ruleNode); /** * @param {number} index * @param {number} endIndex * @param {string} subject * @param {string} reason */ const complain = (index, endIndex, subject, reason) => { report({ message: messages.rejected, messageArgs: [subject, reason], node: ruleNode, index, endIndex, ruleName, result, }); }; let ast; try { ast = parse(selector, { context: 'selectorList', positions: true }); } catch (error) { if (!(error instanceof SyntaxError)) throw error; const offset = 'offset' in error && typeof error.offset === 'number' ? error.offset : 0; const message = error.message; complain( offset, Math.min(offset + 1, selector.length), selector, message.charAt(0).toLowerCase() + message.slice(1), ); return; } if (!mayIncludeRegexes.pseudo.test(selector)) return; assert(ast.type === 'SelectorList'); for (const selectorNode of ast.children) { assert(selectorNode.type === 'Selector'); assert(selectorNode.loc); const index = selectorNode.loc.start.offset; const endIndex = selectorNode.loc.end.offset; const selectorSource = selector.slice(index, endIndex); /** @type {Complain} */ const complainAboutSelector = (reason) => { complain(index, endIndex, selectorSource, reason); }; checkUnforgivingArguments(selectorNode, selectorSource, complainAboutSelector); checkTopLevelSelector(selectorNode, selectorSource, complainAboutSelector); checkDirPseudoClasses(selectorNode, selectorSource, complainAboutSelector); } }); }; }; /** * @param {Selector} selectorNode * @param {string} selectorSource * @param {Complain} complain * @returns {void} */ function checkUnforgivingArguments(selectorNode, selectorSource, complain) { if (!mayHaveUnforgivingArguments(selectorSource)) return; walk(selectorNode, { /** @param {CssNode} node */ enter(node) { if (node.type !== 'PseudoClassSelector' && node.type !== 'PseudoElementSelector') return; const name = node.name.toLowerCase(); if (!unforgivingArgumentPseudoSelectors.has(name)) return; const reason = checkContainerArguments(node, name); if (reason) complain(reason); }, }); } /** * @param {string} selectorSource * @returns {boolean} */ function mayHaveUnforgivingArguments(selectorSource) { return ( mayIncludeRegexes.hasPseudoClass.test(selectorSource) || mayIncludeRegexes.hostPseudoClass.test(selectorSource) || mayIncludeRegexes.slottedPseudoElement.test(selectorSource) || (mayIncludeRegexes.pseudoElement.test(selectorSource) && (mayIncludeRegexes.notPseudoClass.test(selectorSource) || mayIncludeRegexes.aNPlusBOfSNotationPseudoClass.test(selectorSource))) ); } /** * @param {PseudoClassSelector | PseudoElementSelector} container * @param {string} name The container's lowercased name * @returns {string | undefined} The reason the arguments are invalid */ function checkContainerArguments(container, name) { if (hasArgumentPseudoElement(container)) { return `pseudo-elements are invalid within "${formatPseudoName(container)}"`; } if (name === 'has' && hasNestedHas(container)) { return '":has()" is invalid within ":has()"'; } if (compoundArgumentPseudoSelectors.has(name) && hasArgumentCombinator(container)) { return `combinators are invalid within "${formatPseudoName(container)}"`; } return undefined; } /** * @param {PseudoClassSelector | PseudoElementSelector} container * @returns {boolean} */ function hasArgumentPseudoElement(container) { let found = false; walk(container, { /** @param {CssNode} node */ enter(node) { if (node === container) return; if (node.type === 'PseudoElementSelector') { found = true; return walk.break; } if (node.type === 'PseudoClassSelector' && node.children !== null) return walk.skip; }, }); return found; } /** * Whether a `:has()` contains another `:has()` * see https://drafts.csswg.org/selectors/#relational * * @param {PseudoClassSelector | PseudoElementSelector} hasNode * @returns {boolean} */ function hasNestedHas(hasNode) { let found = false; walk(hasNode, { /** @param {CssNode} node */ enter(node) { if (node === hasNode) return; if (node.type !== 'PseudoClassSelector') return; if (node.name.toLowerCase() === 'has') { found = true; return walk.break; } }, }); return found; } /** * @param {PseudoClassSelector | PseudoElementSelector} container * @returns {boolean} */ function hasArgumentCombinator(container) { const argument = container.children?.first; if (!argument || argument.type !== 'Selector') return false; return argument.children.some((node) => node.type === 'Combinator'); } /** * Combinators and further pseudo-elements after a pseudo-element are invalid * see https://drafts.csswg.org/selectors/#pseudo-element-structure and * https://drafts.csswg.org/selectors/#sub-pseudo-elements * * @param {Selector} selectorNode * @param {string} selectorSource * @param {Complain} complain * @returns {void} */ function checkTopLevelSelector(selectorNode, selectorSource, complain) { if (!mayIncludeRegexes.pseudoElement.test(selectorSource)) return; if (selectorNode.children.some((node) => node.type === 'NestingSelector')) return; /** @type {PseudoElementSelector | undefined} */ let lastPseudoElement; /** @type {CssNode | undefined} */ let previousNode; for (const node of selectorNode.children) { if (node.type === 'PseudoElementSelector') { if ( previousNode?.type === 'PseudoElementSelector' && isInvalidSubPseudoElement(previousNode, node) ) { complain( `"${formatPseudoName(node)}" is invalid after "${formatPseudoName(previousNode)}"`, ); return; } lastPseudoElement = node; } else if (node.type === 'Combinator' && lastPseudoElement) { complain(`combinators are invalid after "${formatPseudoName(lastPseudoElement)}"`); return; } previousNode = node; } } /** * Whether `second`, compounded to `first`, is not a defined sub-pseudo-element * see https://drafts.csswg.org/selectors/#sub-pseudo-elements * * @param {PseudoElementSelector} first * @param {PseudoElementSelector} second * @returns {boolean} */ function isInvalidSubPseudoElement(first, second) { if (elementBackedPseudoElements.has(first.name.toLowerCase())) return false; if (subPseudoElements.has(second.name.toLowerCase())) return false; return true; } /** * @param {Selector} selectorNode * @param {string} selectorSource * @param {Complain} complain * @returns {void} */ function checkDirPseudoClasses(selectorNode, selectorSource, complain) { if (!mayIncludeRegexes.dirPseudoClass.test(selectorSource)) return; walk(selectorNode, { /** @param {CssNode} node */ enter(node) { if (node.type !== 'PseudoClassSelector') return; if (node.name.toLowerCase() !== 'dir') return; if (isValidDirPseudoClass(node)) return; complain(`expected ${expectedDirIdentifiers} within "${formatPseudoName(node)}"`); }, }); } /** * @param {PseudoClassSelector} node * @returns {boolean} */ function isValidDirPseudoClass(node) { const { children } = node; if (children?.size !== 1) return false; const first = children.first; if (first?.type !== 'Identifier') return false; return dirIdentifiers.has(first.name.toLowerCase()); } /** * @param {PseudoClassSelector | PseudoElementSelector} node * @returns {string} */ function formatPseudoName(node) { const colons = node.type === 'PseudoElementSelector' ? '::' : ':'; return `${colons}${node.name.toLowerCase()}${node.children === null ? '' : '()'}`; } rule.ruleName = ruleName; rule.messages = messages; rule.meta = meta; export default rule;