stylelint
Version:
A mighty CSS linter that helps you avoid errors and enforce conventions.
467 lines (371 loc) • 14.8 kB
JavaScript
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;