UNPKG

stylelint

Version:

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

467 lines (371 loc) 14.8 kB
import { ParseErrorMessage, calcFromComponentValues } from '@csstools/css-calc'; import { TokenType, tokenize } from '@csstools/css-tokenizer'; import { find, parse, string } from 'css-tree'; import { isFunctionNode, parseListOfComponentValues, stringify, walk, } from '@csstools/css-parser-algorithms'; import { atRuleRegexes, mayIncludeRegexes } from '../../utils/regexes.mjs'; import { isRegExp, isString } from '../../utils/validateTypes.mjs'; import { declarationValueIndex } from '../../utils/nodeFieldIndices.mjs'; import { emitDeprecationWarning } from '../../utils/emitWarning.mjs'; import getDeclarationValue from '../../utils/getDeclarationValue.mjs'; import getLexer from '../../utils/getLexer.mjs'; import isCustomProperty from '../../utils/isCustomProperty.mjs'; import { isDeclaration } from '../../utils/typeGuards.mjs'; import isDescriptorDeclaration from '../../utils/isDescriptorDeclaration.mjs'; import isStandardSyntaxDeclaration from '../../utils/isStandardSyntaxDeclaration.mjs'; import isStandardSyntaxProperty from '../../utils/isStandardSyntaxProperty.mjs'; import isStandardSyntaxValue from '../../utils/isStandardSyntaxValue.mjs'; import optionsMatchesEntry from '../../utils/optionsMatchesEntry.mjs'; import report from '../../utils/report.mjs'; import ruleMessages from '../../utils/ruleMessages.mjs'; import validateObjectWithArrayProps from '../../utils/validateObjectWithArrayProps.mjs'; import validateObjectWithProps from '../../utils/validateObjectWithProps.mjs'; import validateOptions from '../../utils/validateOptions.mjs'; const ruleName = 'declaration-property-value-no-unknown'; const messages = ruleMessages(ruleName, { rejected: (property, value) => `Unknown value "${value}" for property "${property}"`, rejectedParseError: (property, value) => `Cannot parse property value "${value}" for property "${property}"`, rejectedMath: (property, expression) => `Invalid math expression "${expression}" for property "${property}"`, }); const meta = { url: 'https://stylelint.io/user-guide/rules/declaration-property-value-no-unknown', }; const SYNTAX_DESCRIPTOR = /^syntax$/i; const UNSUPPORTED_FUNCTIONS_IN_CSSTREE = new Set(['clamp', 'min', 'max', 'env']); /** @type {WeakMap<object, Set<string>>} */ const validMatchesCacheByLexer = new WeakMap(); const MAX_CACHE_SIZE = 10000; /** @import { CssNode } from 'css-tree' */ /** @import { ParseError } from '@csstools/css-calc' */ /** @typedef {import('stylelint').CoreRules[ruleName]} Rule */ /** @typedef {Parameters<Rule>[1]} SecondaryOptions */ /** @type {Rule} */ const rule = (primary, secondaryOptions) => { return (root, result) => { const validOptions = validateOptions( result, ruleName, { actual: primary }, { actual: secondaryOptions, possible: { ignoreProperties: [validateObjectWithArrayProps(isString, isRegExp)], propertiesSyntax: [validateObjectWithProps(isString)], typesSyntax: [validateObjectWithProps(isString)], }, optional: true, }, ); if (!validOptions) { return; } if (secondaryOptions?.propertiesSyntax) { emitDeprecationWarning( `We've deprecated the "propertiesSyntax" option of "${ruleName}".`, 'RULE_OPTION', 'Use the shared and more performant "languageOptions" configuration property instead. See https://stylelint.io/user-guide/rules/declaration-property-value-no-unknown#propertiessyntax', ); } if (secondaryOptions?.typesSyntax) { emitDeprecationWarning( `We've deprecated the "typesSyntax" option of "${ruleName}".`, 'RULE_OPTION', 'Use the shared and more performant "languageOptions" configuration property instead. See https://stylelint.io/user-guide/rules/declaration-property-value-no-unknown#typessyntax', ); } /** @type {(name: string, propValue: string) => boolean} */ const isPropIgnored = (name, value) => optionsMatchesEntry(secondaryOptions, 'ignoreProperties', name, value); const propertiesSyntax = { ...secondaryOptions?.propertiesSyntax }; const typesSyntax = { ...secondaryOptions?.typesSyntax }; /** @type {Map<string, string>} */ const typedCustomPropertyNames = new Map(); root.walkAtRules(atRuleRegexes.propertyName, (atRule) => { const propName = atRule.params.trim(); if (!propName || !atRule.nodes || !isCustomProperty(propName)) return; for (const node of atRule.nodes) { if (isDeclaration(node) && SYNTAX_DESCRIPTOR.test(node.prop)) { const value = node.value.trim(); const unquoted = string.decode(value); // Only string values are valid. // We can not check the syntax of this property. if (unquoted === value) continue; // Any value is allowed in this custom property. // We don't need to check this property. if (unquoted === '*') continue; // https://github.com/csstree/csstree/pull/256 // We can circumvent this issue by prefixing the property name, // making it a vendor-prefixed property instead of a custom property. // No one should be using `-stylelint--` as a property prefix. // // When this is resolved `typedCustomPropertyNames` can become a `Set<string>` // and the prefix can be removed. const prefixedPropName = `-stylelint${propName}`; typedCustomPropertyNames.set(propName, prefixedPropName); propertiesSyntax[prefixedPropName] = unquoted; } } }); const hasExtraSyntax = Object.keys(propertiesSyntax).length > 0 || Object.keys(typesSyntax).length > 0; const lexer = hasExtraSyntax ? getLexer(result.stylelint.config ?? {}, { properties: propertiesSyntax, types: typesSyntax, }) : /** @type {import('css-tree').Lexer} */ (result.stylelint.lexer); let validMatchesCache = validMatchesCacheByLexer.get(lexer); if (!validMatchesCache) { validMatchesCache = new Set(); validMatchesCacheByLexer.set(lexer, validMatchesCache); } root.walkDecls((decl) => { const { prop } = decl; const value = getDeclarationValue(decl); // csstree/csstree#243 // NOTE: CSSTree's `fork()` doesn't support `-moz-initial`, but it may be possible in the future. if (/^-moz-initial$/i.test(value)) return; if (!isStandardSyntaxDeclaration(decl)) return; if (isDescriptorDeclaration(decl)) return; if (!isStandardSyntaxProperty(prop)) return; if (!isStandardSyntaxValue(value)) return; if (isCustomProperty(prop) && !typedCustomPropertyNames.has(prop)) return; if (isPropIgnored(prop, value)) return; // TODO: csstree treats any value containing `var()` as valid, even if the `var()` expression itself is invalid. // csstree should be updated to mark invalidate values that contain invalid `var()` expressions. // skipping parsing by returning early until this is resolved upstream. if (/\bvar\s*\(/i.test(value)) return; const cacheKey = isCustomProperty(prop) ? null : `${prop}:${value}`; if (cacheKey && validMatchesCache.has(cacheKey)) return; // Check if value contains math functions that need validation const [mathFuncResult, mathFuncResultStartOffset, mathFuncResultEndOffset] = validateMathFunctions(value, prop, lexer, typedCustomPropertyNames); if (mathFuncResult === 'valid' || mathFuncResult === 'skip-validation') { if (cacheKey && validMatchesCache.size < MAX_CACHE_SIZE) validMatchesCache.add(cacheKey); return; } if (mathFuncResult === 'invalid') { const valueIndex = declarationValueIndex(decl); let expression = value; let index = valueIndex; let endIndex = index + expression.length; if (mathFuncResultStartOffset !== -1 && mathFuncResultEndOffset !== -1) { expression = value.slice(mathFuncResultStartOffset, mathFuncResultEndOffset); index = valueIndex + mathFuncResultStartOffset; endIndex = index + expression.length; } report({ message: messages.rejectedMath, messageArgs: [prop, expression], node: decl, index, endIndex, result, ruleName, }); return; } /** @type {CssNode} */ let cssTreeValueNode; try { cssTreeValueNode = parse(value, { context: 'value', positions: true }); if (containsFunctionsNotSupportedInCSSTree(cssTreeValueNode)) return; } catch { // Ignore parse errors for `attr()`, `if()` and custom functions // See: https://github.com/stylelint/stylelint/issues/8779 if (/(?:^|[^\w-])(?:attr|if|--[\w-]+)\(/i.test(value)) return; const index = declarationValueIndex(decl); const endIndex = index + value.length; report({ message: messages.rejectedParseError, messageArgs: [prop, value], node: decl, index, endIndex, result, ruleName, }); return; } const { error } = lexer.matchProperty( typedCustomPropertyNames.get(prop) ?? prop, cssTreeValueNode, ); if (!error) { if (cacheKey && validMatchesCache.size < MAX_CACHE_SIZE) validMatchesCache.add(cacheKey); return; } if (!('mismatchLength' in error)) return; const { name, rawMessage, loc } = error; if (name !== 'SyntaxMatchError') return; if (rawMessage !== 'Mismatch') return; const valueIndex = declarationValueIndex(decl); const mismatchValue = value.slice(loc.start.offset, loc.end.offset); const functionNode = find( cssTreeValueNode, (node) => node.type === 'Function' && node.loc !== undefined && loc.start.offset >= node.loc.start.offset && loc.end.offset <= node.loc.end.offset, ); if (functionNode?.loc) { const valueFunction = value.slice( functionNode.loc.start.offset, functionNode.loc.end.offset, ); const index = valueIndex + functionNode.loc.start.offset; const endIndex = index + valueFunction.length; report({ message: messages.rejected, messageArgs: [prop, valueFunction], node: decl, index, endIndex, result, ruleName, }); return; } report({ message: messages.rejected, messageArgs: [prop, mismatchValue], node: decl, index: valueIndex + loc.start.offset, endIndex: valueIndex + loc.end.offset, result, ruleName, }); }); }; }; /** * @see csstree/csstree#245 env * @param {CssNode} cssTreeNode * @returns {boolean} */ function containsFunctionsNotSupportedInCSSTree(cssTreeNode) { return Boolean( find( cssTreeNode, (node) => node.type === 'Function' && UNSUPPORTED_FUNCTIONS_IN_CSSTREE.has(node.name.toLowerCase()), ), ); } /** * Detect `calc-size()` with the `size` keyword in its arguments. * * @param {Array<import('@csstools/css-parser-algorithms').ComponentValue>} componentValues * @returns {boolean} */ function containsCalcSizeWithSizeKeyword(componentValues) { let contains = false; walk(componentValues, ({ node }) => { if (!isFunctionNode(node) || node.getName().toLowerCase() !== 'calc-size') return; contains = node .tokens() .some((token) => token[0] === TokenType.Ident && token[4]?.value.toLowerCase() === 'size'); if (contains) return false; // halt }); return contains; } /** * Validate math functions (calc, min, max, clamp, etc.) in a CSS value. * Uses @csstools/css-calc to solve expressions and validate the result. * * @param {string} value - The CSS property value * @param {string} prop - The property name * @param {ReturnType<import('css-tree')['fork']>['lexer']} lexer - The csstree lexer * @param {Map<string, string>} typedCustomPropertyNames - Map of typed custom property names * @returns {['undetermined' | 'valid' | 'invalid' | 'skip-validation', number, number]} - The validation result */ function validateMathFunctions(value, prop, lexer, typedCustomPropertyNames) { // If the value doesn't contain any math functions, continue with normal validation if (!mayIncludeRegexes.mathFunction.test(value)) return ['undetermined', -1, -1]; const nodes = parseListOfComponentValues(tokenize({ css: value }), {}); if (containsCalcSizeWithSizeKeyword(nodes)) { return ['skip-validation', -1, -1]; } /** @type {Array<ParseError>} */ const calcParseErrors = []; // Try to solve the math expression const solvedNodes = calcFromComponentValues([nodes], { onParseError: (err) => { calcParseErrors.push(err); }, }); // Check if any known errors were detected during parsing for (const calcParseError of calcParseErrors) { switch (calcParseError.message) { case ParseErrorMessage.UnexpectedAdditionOfDimensionOrPercentageWithNumber: case ParseErrorMessage.UnexpectedSubtractionOfDimensionOrPercentageWithNumber: return ['invalid', calcParseError.sourceStart, calcParseError.sourceEnd + 1]; default: break; } } const solvedValue = stringify(solvedNodes); // For other cases where calc can't be fully solved (like 100% - 10px), // skip validation and let csstree handle it (csstree allows these) if (mayIncludeRegexes.mathFunction.test(solvedValue)) { return ['undetermined', -1, -1]; } // If the expression was fully solved (no more math functions), // validate the result with csstree try { const solvedCssTreeNode = parse(solvedValue, { context: 'value', positions: true }); if (containsFunctionsNotSupportedInCSSTree(solvedCssTreeNode)) { return ['skip-validation', -1, -1]; } const { error } = lexer.matchProperty( typedCustomPropertyNames.get(prop) ?? prop, solvedCssTreeNode, ); // If the solved value is valid, skip further validation if (!error) return ['valid', -1, -1]; // If the solved value is invalid, it means the calc result type doesn't match the property // e.g., calc(2) for height: results in a number "2", but height expects a length if ( 'mismatchLength' in error && error.name === 'SyntaxMatchError' && error.rawMessage === 'Mismatch' ) { const startOffset = error.loc.start.offset; const endOffset = error.loc.end.offset; // Lookup the original source position of the invalid expression // by finding the tokens that correspond to the positions reported by csstree const solvedTokens = solvedNodes.flatMap((componentValues) => componentValues.flatMap((node) => node.tokens()), ); let counter = 0; let startToken; let endToken; solvedTokens.forEach((token) => { if (startOffset === counter) { startToken = token; } counter += token[1].length; if (endOffset === counter) { endToken = token; } }); if (!startToken || !endToken) return ['invalid', -1, -1]; return ['invalid', startToken[2], endToken[3] + 1]; } } catch { // If parsing fails, continue with normal validation return ['undetermined', -1, -1]; } return ['undetermined', -1, -1]; } rule.ruleName = ruleName; rule.messages = messages; rule.meta = meta; export default rule;