400 lines
17 KiB
JavaScript
400 lines
17 KiB
JavaScript
/**
|
|
* @fileoverview Rule to prevent invalid properties in CSS.
|
|
* @author Nicholas C. Zakas
|
|
*/
|
|
//-----------------------------------------------------------------------------
|
|
// Imports
|
|
//-----------------------------------------------------------------------------
|
|
import { isSyntaxMatchError, isSyntaxReferenceError } from "../util.js";
|
|
//-----------------------------------------------------------------------------
|
|
// Type Definitions
|
|
//-----------------------------------------------------------------------------
|
|
/**
|
|
* @import { CSSRuleDefinition } from "../types.js"
|
|
* @import { ValuePlain, FunctionNodePlain, CssLocationRange } from "@eslint/css-tree";
|
|
* @typedef {"invalidPropertyValue" | "unknownProperty" | "unknownVar"} NoInvalidPropertiesMessageIds
|
|
* @typedef {[{allowUnknownVariables?: boolean}]} NoInvalidPropertiesOptions
|
|
* @typedef {CSSRuleDefinition<{ RuleOptions: NoInvalidPropertiesOptions, MessageIds: NoInvalidPropertiesMessageIds }>} NoInvalidPropertiesRuleDefinition
|
|
*/
|
|
//-----------------------------------------------------------------------------
|
|
// Helpers
|
|
//-----------------------------------------------------------------------------
|
|
/**
|
|
* Regex to match var() functional notation with optional fallback.
|
|
*/
|
|
const varFunctionPattern = /var\(\s*(--[^,\s)]+)\s*(?:,([\s\S]+))?\)/iu;
|
|
/**
|
|
* Parses a var() function text and extracts the custom property name and fallback.
|
|
* @param {string} text The text containing a var() function.
|
|
* @returns {{ name: string, fallbackText: string | null } | null} The parsed variable name and optional fallback, or null if not a var().
|
|
*/
|
|
function parseVarFunction(text) {
|
|
const match = text.match(varFunctionPattern);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
return {
|
|
name: match[1].trim(),
|
|
fallbackText: match[2]?.trim(),
|
|
};
|
|
}
|
|
/**
|
|
* Extracts the list of fallback value or variable name used in a `var()` that is used as fallback function.
|
|
* For example, for `var(--my-color, var(--fallback-color, red));` it will return `["--fallback-color", "red"]`.
|
|
* @param {string} value The fallback value that is used in `var()`.
|
|
* @returns {Array<string>} The list of variable names of fallback value.
|
|
*/
|
|
function getVarFallbackList(value) {
|
|
const list = [];
|
|
let currentValue = value;
|
|
while (true) {
|
|
const parsed = parseVarFunction(currentValue);
|
|
if (!parsed) {
|
|
break;
|
|
}
|
|
list.push(parsed.name);
|
|
if (!parsed.fallbackText) {
|
|
break;
|
|
}
|
|
// If fallback is not another var(), we're done
|
|
if (!parsed.fallbackText.toLowerCase().includes("var(")) {
|
|
list.push(parsed.fallbackText);
|
|
break;
|
|
}
|
|
// Continue parsing from fallback
|
|
currentValue = parsed.fallbackText;
|
|
}
|
|
return list;
|
|
}
|
|
//-----------------------------------------------------------------------------
|
|
// Rule Definition
|
|
//-----------------------------------------------------------------------------
|
|
export default /** @satisfies {NoInvalidPropertiesRuleDefinition} */ ({
|
|
meta: {
|
|
type: "problem",
|
|
docs: {
|
|
description: "Disallow invalid properties",
|
|
recommended: true,
|
|
url: "https://github.com/eslint/css/blob/main/docs/rules/no-invalid-properties.md",
|
|
},
|
|
schema: [
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
allowUnknownVariables: {
|
|
type: "boolean",
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
],
|
|
defaultOptions: [
|
|
{
|
|
allowUnknownVariables: false,
|
|
},
|
|
],
|
|
messages: {
|
|
invalidPropertyValue: "Invalid value '{{value}}' for property '{{property}}'. Expected {{expected}}.",
|
|
unknownProperty: "Unknown property '{{property}}' found.",
|
|
unknownVar: "Can't validate with unknown variable '{{var}}'.",
|
|
},
|
|
},
|
|
create(context) {
|
|
const sourceCode = context.sourceCode;
|
|
const lexer = sourceCode.lexer;
|
|
/** @type {Map<string,ValuePlain>} */
|
|
const vars = new Map();
|
|
/**
|
|
* @type {Array<{
|
|
* valueParts: string[],
|
|
* functionPartsStack: string[][],
|
|
* valueSegmentLocs: Map<string,CssLocationRange>,
|
|
* skipValidation: boolean,
|
|
* hadVarSubstitution: boolean,
|
|
* resolvedCache: Map<string,string>
|
|
* }>}
|
|
*/
|
|
const declStack = [];
|
|
const [{ allowUnknownVariables }] = context.options;
|
|
/**
|
|
* Iteratively resolves CSS variable references until a value is found.
|
|
* @param {string} variableName The variable name to resolve
|
|
* @param {Map<string, string>} cache Cache for memoization within a single resolution scope
|
|
* @param {Set<string>} [seen] Set of already seen variables to detect cycles
|
|
* @returns {string|null} The resolved value or null if not found
|
|
*/
|
|
function resolveVariable(variableName, cache, seen = new Set()) {
|
|
/** @type {Array<string>} */
|
|
const fallbackStack = [];
|
|
let currentVarName = variableName;
|
|
/*
|
|
* Resolves a CSS variable by following its reference chain.
|
|
*
|
|
* Phase 1: Follow var() references
|
|
* - Use `seen` to detect cycles
|
|
* - Use `cache` for memoization
|
|
* - If value is concrete: cache and return
|
|
* - If value is another var(--next, <fallback>):
|
|
* push fallback to stack and continue with --next
|
|
* - If variable unknown: proceed to Phase 2
|
|
*
|
|
* Phase 2: Try fallback values (if Phase 1 failed)
|
|
* - Process fallbacks in reverse order (LIFO)
|
|
* - Resolve each via resolveFallback()
|
|
* - Return first successful resolution
|
|
*/
|
|
while (true) {
|
|
if (seen.has(currentVarName)) {
|
|
break;
|
|
}
|
|
seen.add(currentVarName);
|
|
if (cache.has(currentVarName)) {
|
|
return cache.get(currentVarName);
|
|
}
|
|
const valueNode = vars.get(currentVarName);
|
|
if (!valueNode) {
|
|
break;
|
|
}
|
|
const valueText = sourceCode.getText(valueNode).trim();
|
|
const parsed = parseVarFunction(valueText);
|
|
if (!parsed) {
|
|
cache.set(currentVarName, valueText);
|
|
return valueText;
|
|
}
|
|
if (parsed.fallbackText) {
|
|
fallbackStack.push(parsed.fallbackText);
|
|
}
|
|
currentVarName = parsed.name;
|
|
}
|
|
while (fallbackStack.length > 0) {
|
|
const fallbackText = fallbackStack.pop();
|
|
// eslint-disable-next-line no-use-before-define -- resolveFallback and resolveVariable are mutually recursive
|
|
const resolvedFallback = resolveFallback(fallbackText, cache, seen);
|
|
if (resolvedFallback !== null) {
|
|
return resolvedFallback;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
/**
|
|
* Resolves a fallback text which can contain nested var() calls.
|
|
* Returns the first resolvable value or null if none resolve.
|
|
* @param {string} rawFallbackText The raw fallback text to resolve.
|
|
* @param {Map<string, string>} cache Cache for memoization within a single resolution scope.
|
|
* @param {Set<string>} [seen] Set of already seen variables to detect cycles.
|
|
* @returns {string | null} The resolved fallback value, or null if none can be resolved.
|
|
*/
|
|
function resolveFallback(rawFallbackText, cache, seen = new Set()) {
|
|
const fallbackVarList = getVarFallbackList(rawFallbackText);
|
|
if (fallbackVarList.length === 0) {
|
|
return rawFallbackText;
|
|
}
|
|
for (const fallbackCandidate of fallbackVarList) {
|
|
if (fallbackCandidate.startsWith("--")) {
|
|
const resolved = resolveVariable(fallbackCandidate, cache, seen);
|
|
if (resolved !== null) {
|
|
return resolved;
|
|
}
|
|
continue;
|
|
}
|
|
return fallbackCandidate.trim();
|
|
}
|
|
return null;
|
|
}
|
|
/**
|
|
* Process a var function node and add its resolved value to the value list
|
|
* @param {Object} varNode The var() function node
|
|
* @param {string[]} valueList Array to collect processed values
|
|
* @param {Map<string,CssLocationRange>} valueSegmentLocs Map of rebuilt value segments to their locations
|
|
* @param {Map<string, string>} resolvedCache Cache for resolved variable values to prevent redundant lookups
|
|
* @returns {boolean} Whether processing was successful
|
|
*/
|
|
function processVarFunction(varNode, valueList, valueSegmentLocs, resolvedCache) {
|
|
const varValue = vars.get(varNode.children[0].name);
|
|
if (varValue) {
|
|
const resolvedValue = resolveVariable(varNode.children[0].name, resolvedCache);
|
|
if (resolvedValue) {
|
|
valueList.push(resolvedValue);
|
|
valueSegmentLocs.set(resolvedValue, varNode.loc);
|
|
return true;
|
|
}
|
|
}
|
|
// If the variable is not found and doesn't have a fallback value, report it
|
|
if (varNode.children.length === 1) {
|
|
if (!allowUnknownVariables) {
|
|
context.report({
|
|
loc: varNode.children[0].loc,
|
|
messageId: "unknownVar",
|
|
data: { var: varNode.children[0].name },
|
|
});
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
// Handle fallback values
|
|
if (varNode.children[2].type !== "Raw") {
|
|
return true;
|
|
}
|
|
const fallbackValue = varNode.children[2].value.trim();
|
|
const resolvedFallbackValue = resolveFallback(fallbackValue, resolvedCache);
|
|
if (resolvedFallbackValue) {
|
|
valueList.push(resolvedFallbackValue);
|
|
valueSegmentLocs.set(resolvedFallbackValue, varNode.loc);
|
|
return true;
|
|
}
|
|
// No valid fallback found
|
|
if (!allowUnknownVariables) {
|
|
context.report({
|
|
loc: varNode.children[0].loc,
|
|
messageId: "unknownVar",
|
|
data: { var: varNode.children[0].name },
|
|
});
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
return {
|
|
"Rule > Block Declaration"() {
|
|
declStack.push({
|
|
valueParts: [],
|
|
functionPartsStack: [],
|
|
valueSegmentLocs: new Map(),
|
|
skipValidation: false,
|
|
hadVarSubstitution: false,
|
|
/**
|
|
* Cache for resolved variable values within this single declaration.
|
|
* Prevents re-resolving the same variable and re-walking long `var()` chains.
|
|
*/
|
|
resolvedCache: new Map(),
|
|
});
|
|
},
|
|
"Rule > Block Declaration > Value > *:not(Function)"(node) {
|
|
const state = declStack.at(-1);
|
|
const text = sourceCode.getText(node).trim();
|
|
state.valueParts.push(text);
|
|
state.valueSegmentLocs.set(text, node.loc);
|
|
},
|
|
Function() {
|
|
const state = declStack.at(-1);
|
|
if (!state) {
|
|
return;
|
|
}
|
|
state.functionPartsStack.push([]);
|
|
},
|
|
"Function > *:not(Function)"(node) {
|
|
const state = declStack.at(-1);
|
|
if (!state) {
|
|
return;
|
|
}
|
|
const parts = state.functionPartsStack.at(-1);
|
|
const text = sourceCode.getText(node).trim();
|
|
parts.push(text);
|
|
state.valueSegmentLocs.set(text, node.loc);
|
|
},
|
|
"Function:exit"(node) {
|
|
const state = declStack.at(-1);
|
|
if (!state || state.skipValidation) {
|
|
return;
|
|
}
|
|
const parts = state.functionPartsStack.pop();
|
|
let result;
|
|
if (node.name.toLowerCase() === "var") {
|
|
const resolvedParts = [];
|
|
const success = processVarFunction(node, resolvedParts, state.valueSegmentLocs, state.resolvedCache);
|
|
if (!success) {
|
|
state.skipValidation = true;
|
|
return;
|
|
}
|
|
if (resolvedParts.length === 0) {
|
|
return;
|
|
}
|
|
state.hadVarSubstitution = true;
|
|
result = resolvedParts[0];
|
|
}
|
|
else {
|
|
result = `${node.name}(${parts.join(" ")})`;
|
|
}
|
|
const parentParts = state.functionPartsStack.at(-1);
|
|
if (parentParts) {
|
|
parentParts.push(result);
|
|
}
|
|
else {
|
|
state.valueParts.push(result);
|
|
}
|
|
},
|
|
"Rule > Block Declaration:exit"(node) {
|
|
const state = declStack.pop();
|
|
if (node.property.startsWith("--")) {
|
|
// store the custom property name and value to validate later
|
|
vars.set(node.property, node.value);
|
|
// don't validate custom properties
|
|
return;
|
|
}
|
|
if (state.skipValidation) {
|
|
return;
|
|
}
|
|
let value = node.value;
|
|
if (state.hadVarSubstitution) {
|
|
const valueList = state.valueParts;
|
|
value =
|
|
valueList.length > 0
|
|
? valueList.join(" ")
|
|
: sourceCode.getText(node.value);
|
|
}
|
|
const { error } = lexer.matchProperty(node.property, value);
|
|
if (error) {
|
|
// validation failure
|
|
if (isSyntaxMatchError(error)) {
|
|
const errorValue = state.hadVarSubstitution &&
|
|
value.slice(error.mismatchOffset, error.mismatchOffset + error.mismatchLength);
|
|
context.report({
|
|
/*
|
|
* When using variables, check to see if the error
|
|
* occurred at a location where a variable was replaced.
|
|
* If so, use that location; otherwise, use the error's
|
|
* reported location.
|
|
*/
|
|
loc: state.hadVarSubstitution
|
|
? (state.valueSegmentLocs.get(errorValue) ??
|
|
node.value.loc)
|
|
: error.loc,
|
|
messageId: "invalidPropertyValue",
|
|
data: {
|
|
property: node.property,
|
|
/*
|
|
* When using variables, slice the value to
|
|
* only include the part that caused the error.
|
|
* Otherwise, use the full value from the error.
|
|
*/
|
|
value: state.hadVarSubstitution
|
|
? errorValue
|
|
: error.css,
|
|
expected: error.syntax,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (!allowUnknownVariables ||
|
|
isSyntaxReferenceError(error)) {
|
|
// unknown property
|
|
context.report({
|
|
loc: {
|
|
start: node.loc.start,
|
|
end: {
|
|
line: node.loc.start.line,
|
|
column: node.loc.start.column +
|
|
node.property.length,
|
|
},
|
|
},
|
|
messageId: "unknownProperty",
|
|
data: {
|
|
property: node.property,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
},
|
|
};
|
|
},
|
|
});
|