first commit

This commit is contained in:
2026-08-06 12:00:39 +10:00
commit 91221f5a8e
3259 changed files with 569069 additions and 0 deletions
@@ -0,0 +1,32 @@
declare namespace _default {
namespace meta {
let type: "suggestion";
namespace docs {
let description: string;
let recommended: boolean;
let url: string;
}
namespace messages {
let useFallbackFonts: string;
let useGenericFont: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: [];
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: FontFamilyFallbacksMessageIds;
}>): {
"Rule > Block > Declaration"(node: any): void;
"Rule > Block > Declaration[property='font-family'] > Value"(node: any): void;
"Rule > Block > Declaration[property='font'] > Value"(node: any): void;
};
}
export default _default;
export type FontFamilyFallbacksMessageIds = "useFallbackFonts" | "useGenericFont";
export type FontFamilyFallbacksRuleDefinition = CSSRuleDefinition<{
RuleOptions: [];
MessageIds: FontFamilyFallbacksMessageIds;
}>;
import type { CSSRuleDefinition } from "../types.js";
@@ -0,0 +1,340 @@
/**
* @fileoverview Rule to enforce the use of fallback fonts and a generic font last.
* @author Tanuj Kanti
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { CSSRuleDefinition } from "../types.js"
* @typedef {"useFallbackFonts" | "useGenericFont"} FontFamilyFallbacksMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: FontFamilyFallbacksMessageIds }>} FontFamilyFallbacksRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const genericFonts = new Set([
"serif",
"sans-serif",
"monospace",
"cursive",
"fantasy",
"system-ui",
"ui-serif",
"ui-sans-serif",
"ui-monospace",
"ui-rounded",
"emoji",
"math",
"fangsong",
]);
/**
* Check if the value is a CSS-wide keyword.
* @param {string} value The value to check.
* @param {Set<string>} cssWideKeywords The CSS-wide keywords to check against.
* @returns {boolean} True if the value is a CSS-wide keyword, false otherwise.
*/
function isCSSWideKeyword(value, cssWideKeywords) {
return cssWideKeywords.has(value.trim().toLowerCase());
}
/**
* Check if the node is an identifier with a CSS-wide keyword.
* @param {Object} node The node to check.
* @param {Set<string>} cssWideKeywords The CSS-wide keywords to check against.
* @returns {boolean} True if the node is a CSS-wide keyword identifier, false otherwise.
*/
function isCSSWideKeywordIdentifier(node, cssWideKeywords) {
return (node.type === "Identifier" &&
isCSSWideKeyword(node.name, cssWideKeywords));
}
/**
* Check if the node is a CSS variable function.
* @param {Object} node The node to check.
* @returns {boolean} True if the node is a variable function, false otherwise.
*/
function isVarFunction(node) {
return node.type === "Function" && node.name === "var";
}
/**
* Report an error if the font property values do not have fallbacks or a generic font.
* @param {string} fontPropertyValues The font property values to check.
* @param {Object} context The ESLint context object.
* @param {Object} node The CSS node being checked.
* @param {Set<string>} cssWideKeywords The CSS-wide keywords to check against.
* @returns {void}
* @private
*/
function reportFontWithoutFallbacksInFontProperty(fontPropertyValues, context, node, cssWideKeywords) {
if (isCSSWideKeyword(fontPropertyValues, cssWideKeywords)) {
return;
}
const valueList = fontPropertyValues.split(",").map(v => v.trim());
if (valueList.length === 1) {
const containsGenericFont = Array.from(genericFonts).some(font => valueList[0].includes(font));
if (!containsGenericFont) {
context.report({
loc: node.loc,
messageId: "useFallbackFonts",
});
}
}
else {
if (!genericFonts.has(valueList.at(-1))) {
context.report({
loc: node.loc,
messageId: "useGenericFont",
});
}
}
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {FontFamilyFallbacksRuleDefinition} */ ({
meta: {
type: "suggestion",
docs: {
description: "Enforce use of fallback fonts and a generic font last",
recommended: true,
url: "https://github.com/eslint/css/blob/main/docs/rules/font-family-fallbacks.md",
},
messages: {
useFallbackFonts: "Use fallback fonts and a generic font last.",
useGenericFont: "Use a generic font last.",
},
},
create(context) {
const sourceCode = context.sourceCode;
const cssWideKeywords = new Set(sourceCode.lexer.cssWideKeywords.map(keyword => keyword.toLowerCase()));
const variableMap = new Map();
return {
"Rule > Block > Declaration"(node) {
if (node.property.startsWith("--")) {
const variableName = node.property;
const variableValue = node.value.type === "Raw" && node.value.value;
variableMap.set(variableName, variableValue);
}
},
"Rule > Block > Declaration[property='font-family'] > Value"(node) {
const valueArr = node.children;
if (valueArr.length === 1) {
if (isCSSWideKeywordIdentifier(valueArr[0], cssWideKeywords)) {
return;
}
if (valueArr[0].type === "Function" &&
valueArr[0].name === "var") {
const variableName = valueArr[0].children[0].type === "Identifier" &&
valueArr[0].children[0].name;
const variableValue = variableMap.get(variableName);
if (!variableValue) {
return;
}
if (isCSSWideKeyword(variableValue, cssWideKeywords)) {
return;
}
const variableList = variableValue
.split(",")
.map(v => v.trim());
if (variableList.length === 1 &&
!genericFonts.has(variableList[0])) {
context.report({
loc: node.loc,
messageId: "useFallbackFonts",
});
}
else if (!genericFonts.has(variableList.at(-1))) {
context.report({
loc: node.loc,
messageId: "useGenericFont",
});
}
}
else {
if (valueArr[0].type === "Identifier" &&
genericFonts.has(valueArr[0].name)) {
return;
}
context.report({
loc: node.loc,
messageId: "useFallbackFonts",
});
}
}
else {
const isUsingVariable = valueArr.some(child => isVarFunction(child));
if (isUsingVariable) {
const fontsList = [];
const lastNode = valueArr.at(-1);
if (lastNode.type === "Function" &&
lastNode.name === "var") {
const variableName = lastNode.children[0].type === "Identifier" &&
lastNode.children[0].name;
const lastVariable = variableMap.get(variableName);
if (!lastVariable) {
return;
}
}
valueArr.forEach(child => {
if (child.type === "String") {
fontsList.push(child.value);
}
if (child.type === "Identifier") {
fontsList.push(child.name);
}
if (child.type === "Function" &&
child.name === "var") {
const variableName = child.children[0].type === "Identifier" &&
child.children[0].name;
const variableValue = variableMap.get(variableName);
if (variableValue) {
const variableList = variableValue
.split(",")
.map(v => v.trim());
fontsList.push(...variableList);
}
}
});
if (fontsList.length > 0 &&
!genericFonts.has(fontsList.at(-1))) {
context.report({
loc: node.loc,
messageId: "useGenericFont",
});
}
}
else {
const lastFont = valueArr.at(-1);
if (!(lastFont.type === "Identifier" &&
genericFonts.has(lastFont.name))) {
context.report({
loc: node.loc,
messageId: "useGenericFont",
});
}
}
}
},
"Rule > Block > Declaration[property='font'] > Value"(node) {
const valueArr = node.children;
if (valueArr.length === 1) {
const firstValue = valueArr[0];
// If it font is set to system font, we don't need to check for fallbacks
if (firstValue.type === "Identifier") {
return;
}
// If the value is a variable function, we need to check the variable value
if (firstValue.type === "Function" &&
firstValue.name === "var") {
// Check if the function is a variable
const variableName = firstValue.children[0].type === "Identifier" &&
firstValue.children[0].name;
const variableValue = variableMap.get(variableName);
if (!variableValue) {
return;
}
reportFontWithoutFallbacksInFontProperty(variableValue, context, node, cssWideKeywords);
}
}
else {
const isUsingVariable = valueArr.some(child => isVarFunction(child));
if (isUsingVariable) {
const beforOperator = [];
const afterOperator = [];
const operator = valueArr.find(child => child.type === "Operator" &&
child.value === ",");
const operatorOffset = operator && operator.loc.end.offset;
if (operatorOffset) {
valueArr.forEach(child => {
if (child.loc.end.offset < operatorOffset) {
beforOperator.push(sourceCode.getText(child).trim());
}
else if (child.loc.end.offset > operatorOffset) {
afterOperator.push(sourceCode.getText(child).trim());
}
});
if (afterOperator.length !== 0) {
const usingVar = afterOperator.some(value => value.startsWith("var"));
if (!usingVar) {
if (!genericFonts.has(afterOperator.at(-1))) {
context.report({
loc: node.loc,
messageId: "useGenericFont",
});
}
}
else {
if (afterOperator.at(-1).startsWith("var")) {
const lastNode = valueArr.at(-1);
const isFunctionVar = lastNode.type === "Function" &&
lastNode.name === "var";
const variableName = isFunctionVar &&
lastNode.children[0].type ===
"Identifier" &&
lastNode.children[0].name;
const variableValue = variableMap.get(variableName);
if (!variableValue) {
return;
}
const variableList = variableValue
.split(",")
.map(v => v.trim());
if (variableList.length > 0 &&
!genericFonts.has(variableList.at(-1))) {
context.report({
loc: node.loc,
messageId: "useGenericFont",
});
}
}
else {
if (!genericFonts.has(afterOperator.at(-1))) {
context.report({
loc: node.loc,
messageId: "useGenericFont",
});
}
}
}
}
}
else {
if (sourceCode
.getText(valueArr.at(-1))
.trim()
.startsWith("var")) {
const lastNode = valueArr.at(-1);
const isFunctionVar = lastNode.type === "Function" &&
lastNode.name === "var";
const variableName = isFunctionVar &&
lastNode.children[0].type ===
"Identifier" &&
lastNode.children[0].name;
const variableValue = variableMap.get(variableName);
if (!variableValue) {
return;
}
reportFontWithoutFallbacksInFontProperty(variableValue, context, node, cssWideKeywords);
}
else {
if (!genericFonts.has(sourceCode
.getText(valueArr.at(-1))
.trim())) {
context.report({
loc: node.loc,
messageId: "useFallbackFonts",
});
}
}
}
}
else {
const fontPropertyValues = sourceCode.getText(node);
if (fontPropertyValues) {
reportFontWithoutFallbacksInFontProperty(fontPropertyValues, context, node, cssWideKeywords);
}
}
}
},
};
},
});
@@ -0,0 +1,33 @@
declare namespace _default {
namespace meta {
let type: "problem";
let fixable: "code";
let hasSuggestions: true;
namespace docs {
let description: string;
let recommended: boolean;
let url: string;
}
namespace messages {
let duplicateImport: string;
let removeDuplicateImportWithModifiers: string;
let removeDuplicateImportWithoutModifiers: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: [];
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: NoDuplicateKeysMessageIds;
}>): {
"Atrule[name=/^import$/i]"(node: any): void;
};
}
export default _default;
export type NoDuplicateKeysMessageIds = "duplicateImport" | "removeDuplicateImportWithModifiers" | "removeDuplicateImportWithoutModifiers";
export type NoDuplicateImportsRuleDefinition = CSSRuleDefinition<{
RuleOptions: [];
MessageIds: NoDuplicateKeysMessageIds;
}>;
import type { CSSRuleDefinition } from "../types.js";
+152
View File
@@ -0,0 +1,152 @@
/**
* @fileoverview Rule to prevent duplicate imports in CSS.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { CSSRuleDefinition } from "../types.js"
* @typedef {"duplicateImport" | "removeDuplicateImportWithModifiers" | "removeDuplicateImportWithoutModifiers"} NoDuplicateKeysMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoDuplicateKeysMessageIds }>} NoDuplicateImportsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Get the end index of import statement including a following newline if present.
* @param {string} text The full text of the source code.
* @param {number} end The end index of the import statement.
* @returns {number} The end index of the import statement including a following newline.
*/
function getImportEnd(text, end) {
let removeEnd = end;
// Remove the node, and also remove a following newline if present
if (text[removeEnd] === "\r") {
removeEnd += text[removeEnd + 1] === "\n" ? 2 : 1;
}
else if (text[removeEnd] === "\n" || text[removeEnd] === "\f") {
removeEnd += 1;
}
return removeEnd;
}
/**
* Get the modifiers of an import statement.
* @param {Object} importNode The import node to get modifiers from.
* @param {Object} sourceCode The source code object.
* @returns {string[]} An array of modifiers for the import statement.
*/
function getImportModifiers(importNode, sourceCode) {
const importModifiers = [];
const importHasModifiers = importNode.prelude?.children.length > 1;
if (importHasModifiers) {
importNode.prelude?.children.slice(1).forEach(modifier => {
const modifierText = sourceCode.getText(modifier).trim();
importModifiers.push(modifierText);
});
}
return importModifiers;
}
/**
* Get the fix for a duplicate import statement.
* @param {Object} fixer The fixer object.
* @param {string} text The full text of the source code.
* @param {number} start The start index of the import statement to fix.
* @param {number} end The end index of the import statement to fix.
* @param {boolean} hasModifiers A boolean indicating whether the import statement has modifiers that differ from the original import.
* @returns {Object|null} A fix object if a fix is applicable, or null if no fix should be applied.
*/
function getFixForImport(fixer, text, start, end, hasModifiers) {
const removeEnd = getImportEnd(text, end);
if (hasModifiers) {
return fixer.removeRange([start, removeEnd]);
}
return null;
}
//-----------------------------------------------------------------------------
// Rule
//-----------------------------------------------------------------------------
export default /** @satisfies {NoDuplicateImportsRuleDefinition} */ ({
meta: {
type: "problem",
fixable: "code",
hasSuggestions: true,
docs: {
description: "Disallow duplicate @import rules",
recommended: true,
url: "https://github.com/eslint/css/blob/main/docs/rules/no-duplicate-imports.md",
},
messages: {
duplicateImport: "Unexpected duplicate @import rule for '{{url}}'.",
removeDuplicateImportWithModifiers: "Remove duplicate @import rule with modifier(s) - {{modifiers}}.",
removeDuplicateImportWithoutModifiers: "Remove duplicate @import rule without modifiers.",
},
},
create(context) {
const { sourceCode } = context;
const imports = [];
return {
"Atrule[name=/^import$/i]"(node) {
const url = node.prelude?.children[0].value;
const hasImport = imports.some(importNode => importNode.prelude?.children[0].value === url);
if (hasImport) {
const firstImportNode = imports.find(importNode => importNode.prelude?.children[0].value === url);
const [firstImportStart, firstImportEnd] = sourceCode.getRange(firstImportNode);
const firstImportHasModifiers = firstImportNode.prelude?.children.length > 1;
const nodeHasModifiers = node.prelude?.children.length > 1;
const [start, end] = sourceCode.getRange(node);
const text = sourceCode.text;
const firstImportModifiers = getImportModifiers(firstImportNode, sourceCode);
const duplicateImportModifiers = getImportModifiers(node, sourceCode);
const hasSameModifiers = firstImportModifiers.length ===
duplicateImportModifiers.length &&
firstImportModifiers.every((modifier, index) => modifier === duplicateImportModifiers[index]);
context.report({
loc: node.loc,
messageId: "duplicateImport",
data: { url },
fix(fixer) {
const hasModifiers = (!firstImportHasModifiers &&
!nodeHasModifiers) ||
hasSameModifiers;
return getFixForImport(fixer, text, start, end, hasModifiers);
},
suggest: [
{
messageId: firstImportHasModifiers
? "removeDuplicateImportWithModifiers"
: "removeDuplicateImportWithoutModifiers",
data: {
modifiers: firstImportModifiers.join(" "),
},
fix(fixer) {
const hasModifiers = (firstImportHasModifiers ||
nodeHasModifiers) &&
!hasSameModifiers;
return getFixForImport(fixer, text, firstImportStart, firstImportEnd, hasModifiers);
},
},
{
messageId: nodeHasModifiers
? "removeDuplicateImportWithModifiers"
: "removeDuplicateImportWithoutModifiers",
data: {
modifiers: duplicateImportModifiers.join(" "),
},
fix(fixer) {
const hasModifiers = (firstImportHasModifiers ||
nodeHasModifiers) &&
!hasSameModifiers;
return getFixForImport(fixer, text, start, end, hasModifiers);
},
},
],
});
}
else {
imports.push(node);
}
},
};
},
});
@@ -0,0 +1,31 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let recommended: boolean;
let url: string;
}
namespace messages {
let duplicateKeyframeSelector: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: [];
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: "duplicateKeyframeSelector";
}>): {
"Atrule[name=/^(-(o|moz|webkit)-)?keyframes$/i]"(): void;
"Atrule[name=/^(-(o|moz|webkit)-)?keyframes$/i]:exit"(): void;
Rule(node: import("@eslint/css-tree").RulePlain): void;
};
}
export default _default;
export type DuplicateKeyframeSelectorMessageIds = "duplicateKeyframeSelector";
export type DuplicateKeyframeSelectorRuleDefinition = CSSRuleDefinition<{
RuleOptions: [];
MessageIds: DuplicateKeyframeSelectorMessageIds;
}>;
import type { CSSRuleDefinition } from "../types.js";
@@ -0,0 +1,70 @@
/**
* @fileoverview Rule to disallow duplicate selectors within keyframe blocks.
* @author Nitin Kumar
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { CSSRuleDefinition } from "../types.js"
* @typedef {"duplicateKeyframeSelector"} DuplicateKeyframeSelectorMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: DuplicateKeyframeSelectorMessageIds }>} DuplicateKeyframeSelectorRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {DuplicateKeyframeSelectorRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
description: "Disallow duplicate selectors within keyframe blocks",
recommended: true,
url: "https://github.com/eslint/css/blob/main/docs/rules/no-duplicate-keyframe-selectors.md",
},
messages: {
duplicateKeyframeSelector: "Unexpected duplicate selector '{{selector}}' found within keyframe block.",
},
},
create(context) {
let insideKeyframes = false;
const seen = new Map();
return {
"Atrule[name=/^(-(o|moz|webkit)-)?keyframes$/i]"() {
insideKeyframes = true;
seen.clear();
},
"Atrule[name=/^(-(o|moz|webkit)-)?keyframes$/i]:exit"() {
insideKeyframes = false;
},
Rule(node) {
if (!insideKeyframes) {
return;
}
// @ts-ignore - children is a valid property for prelude
const selector = node.prelude.children[0].children[0];
let value;
if (selector.type === "Percentage") {
value = `${selector.value}%`;
}
else if (selector.type === "TypeSelector") {
value = selector.name.toLowerCase();
}
else {
value = selector.value;
}
if (seen.has(value)) {
context.report({
loc: selector.loc,
messageId: "duplicateKeyframeSelector",
data: {
selector: value,
},
});
}
else {
seen.set(value, true);
}
},
};
},
});
+29
View File
@@ -0,0 +1,29 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let recommended: boolean;
let url: string;
}
namespace messages {
let emptyBlock: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: [];
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: "emptyBlock";
}>): {
Block(node: import("@eslint/css-tree").BlockPlain): void;
};
}
export default _default;
export type NoEmptyBlocksMessageIds = "emptyBlock";
export type NoEmptyBlocksRuleDefinition = CSSRuleDefinition<{
RuleOptions: [];
MessageIds: NoEmptyBlocksMessageIds;
}>;
import type { CSSRuleDefinition } from "../types.js";
+40
View File
@@ -0,0 +1,40 @@
/**
* @fileoverview Rule to prevent empty blocks in CSS.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { CSSRuleDefinition } from "../types.js"
* @typedef {"emptyBlock"} NoEmptyBlocksMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoEmptyBlocksMessageIds }>} NoEmptyBlocksRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoEmptyBlocksRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
description: "Disallow empty blocks",
recommended: true,
url: "https://github.com/eslint/css/blob/main/docs/rules/no-empty-blocks.md",
},
messages: {
emptyBlock: "Unexpected empty block found.",
},
},
create(context) {
return {
Block(node) {
if (node.children.length === 0) {
context.report({
loc: node.loc,
messageId: "emptyBlock",
});
}
},
};
},
});
+31
View File
@@ -0,0 +1,31 @@
declare namespace _default {
namespace meta {
let type: "problem";
let hasSuggestions: true;
namespace docs {
let description: string;
let recommended: boolean;
let url: string;
}
namespace messages {
let unexpectedImportant: string;
let removeImportant: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: [];
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: NoImportantMessageIds;
}>): {
Declaration(node: import("@eslint/css-tree").DeclarationPlain): void;
};
}
export default _default;
export type NoImportantMessageIds = "unexpectedImportant" | "removeImportant";
export type NoImportantRuleDefinition = CSSRuleDefinition<{
RuleOptions: [];
MessageIds: NoImportantMessageIds;
}>;
import type { CSSRuleDefinition } from "../types.js";
+76
View File
@@ -0,0 +1,76 @@
/**
* @fileoverview Rule to disallow `!important` flags.
* @author thecalamiity
* @author Yann Bertrand
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { CSSRuleDefinition } from "../types.js"
* @typedef {"unexpectedImportant" | "removeImportant"} NoImportantMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoImportantMessageIds }>} NoImportantRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const importantPattern = /!\s*important/iu;
const commentPattern = /\/\*[\s\S]*?\*\//gu;
const trailingWhitespacePattern = /\s*$/u;
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoImportantRuleDefinition} */ ({
meta: {
type: "problem",
hasSuggestions: true,
docs: {
description: "Disallow !important flags",
recommended: true,
url: "https://github.com/eslint/css/blob/main/docs/rules/no-important.md",
},
messages: {
unexpectedImportant: "Unexpected !important flag found.",
removeImportant: "Remove !important flag.",
},
},
create(context) {
const { sourceCode } = context;
return {
Declaration(node) {
if (node.important) {
const declarationText = sourceCode.getText(node);
const textWithoutComments = declarationText.replace(commentPattern,
/* eslint-disable-next-line require-unicode-regexp -- we want to replace each code unit with a space */
match => match.replace(/[^\r\n\f]/g, " "));
const importantMatch = importantPattern.exec(textWithoutComments);
const importantStartOffset = importantMatch.index;
const importantEndOffset = importantStartOffset + importantMatch[0].length;
const nodeStartOffset = node.loc.start.offset;
context.report({
loc: {
start: sourceCode.getLocFromIndex(nodeStartOffset + importantStartOffset),
end: sourceCode.getLocFromIndex(nodeStartOffset + importantEndOffset),
},
messageId: "unexpectedImportant",
suggest: [
{
messageId: "removeImportant",
fix(fixer) {
// Find any trailing whitespace before the `!important`
const whitespaceEndOffset = declarationText
.slice(0, importantStartOffset)
.search(trailingWhitespacePattern);
return fixer.removeRange([
nodeStartOffset + whitespaceEndOffset,
nodeStartOffset + importantEndOffset,
]);
},
},
],
});
}
},
};
},
});
@@ -0,0 +1,32 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let recommended: boolean;
let url: string;
}
namespace messages {
let invalidCharsetPlacement: string;
let invalidImportPlacement: string;
let invalidNamespacePlacement: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: [];
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: NoInvalidAtRulePlacementMessageIds;
}>): {
Atrule(node: import("@eslint/css-tree").AtrulePlain): void;
Rule(): void;
};
}
export default _default;
export type NoInvalidAtRulePlacementMessageIds = "invalidCharsetPlacement" | "invalidImportPlacement" | "invalidNamespacePlacement";
export type NoInvalidAtRulePlacementRuleDefinition = CSSRuleDefinition<{
RuleOptions: [];
MessageIds: NoInvalidAtRulePlacementMessageIds;
}>;
import type { CSSRuleDefinition } from "../types.js";
@@ -0,0 +1,83 @@
/**
* @fileoverview Rule to enforce correct placement of at-rules.
* @author thecalamiity
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { CSSRuleDefinition } from "../types.js"
* @typedef {"invalidCharsetPlacement" | "invalidImportPlacement" | "invalidNamespacePlacement"} NoInvalidAtRulePlacementMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoInvalidAtRulePlacementMessageIds }>} NoInvalidAtRulePlacementRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoInvalidAtRulePlacementRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
description: "Disallow invalid placement of at-rules",
recommended: true,
url: "https://github.com/eslint/css/blob/main/docs/rules/no-invalid-at-rule-placement.md",
},
messages: {
invalidCharsetPlacement: "@charset must be placed at the very beginning of the stylesheet, before any rules, comments, or whitespace.",
invalidImportPlacement: "@import must be placed before all other rules, except @charset and @layer statements.",
invalidNamespacePlacement: "@namespace must be placed before all other rules, except @charset and @import.",
},
},
create(context) {
let hasSeenNonImportRule = false;
let hasSeenLayerBlock = false;
let hasSeenLayer = false;
let hasSeenNamespace = false;
return {
Atrule(node) {
const name = node.name.toLowerCase();
if (name === "charset") {
if (node.loc.start.line !== 1 ||
node.loc.start.column !== 1) {
context.report({
node,
messageId: "invalidCharsetPlacement",
});
}
return;
}
if (name === "layer") {
if (node.block) {
hasSeenLayerBlock = true;
}
hasSeenLayer = true;
return;
}
if (name === "namespace") {
if (hasSeenNonImportRule || hasSeenLayer) {
context.report({
node,
messageId: "invalidNamespacePlacement",
});
}
hasSeenNamespace = true;
return;
}
if (name === "import") {
if (hasSeenNonImportRule ||
hasSeenNamespace ||
hasSeenLayerBlock) {
context.report({
node,
messageId: "invalidImportPlacement",
});
}
return;
}
hasSeenNonImportRule = true;
},
Rule() {
hasSeenNonImportRule = true;
},
};
},
});
@@ -0,0 +1,38 @@
declare namespace _default {
namespace meta {
let type: "problem";
let fixable: "code";
namespace docs {
let description: string;
let recommended: boolean;
let url: string;
}
namespace messages {
let unknownAtRule: string;
let invalidPrelude: string;
let unknownDescriptor: string;
let invalidDescriptor: string;
let invalidExtraPrelude: string;
let missingPrelude: string;
let invalidCharsetSyntax: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: [];
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: NoInvalidAtRulesMessageIds;
}>): {
Atrule(node: AtrulePlain): void;
"AtRule > Block > Declaration"(node: any): void;
};
}
export default _default;
export type NoInvalidAtRulesMessageIds = "unknownAtRule" | "invalidPrelude" | "unknownDescriptor" | "invalidDescriptor" | "invalidExtraPrelude" | "missingPrelude" | "invalidCharsetSyntax";
export type NoInvalidAtRulesRuleDefinition = CSSRuleDefinition<{
RuleOptions: [];
MessageIds: NoInvalidAtRulesMessageIds;
}>;
import type { AtrulePlain } from "@eslint/css-tree";
import type { CSSRuleDefinition } from "../types.js";
+233
View File
@@ -0,0 +1,233 @@
/**
* @fileoverview Rule to prevent the use of unknown at-rules in CSS.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { isSyntaxMatchError } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { AtrulePlain } from "@eslint/css-tree"
* @import { CSSRuleDefinition } from "../types.js"
* @typedef {"unknownAtRule" | "invalidPrelude" | "unknownDescriptor" | "invalidDescriptor" | "invalidExtraPrelude" | "missingPrelude" | "invalidCharsetSyntax"} NoInvalidAtRulesMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoInvalidAtRulesMessageIds }>} NoInvalidAtRulesRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Set of at-rules that can be nested inside style rules.
* @see https://www.w3.org/TR/css-nesting-1/#conditionals
*/
const nestableAtRules = new Set([
"media",
"supports",
"layer",
"scope",
"container",
"starting-style",
]);
/**
* A valid `@charset` rule must:
* - Enclose the encoding name in double quotes
* - Include exactly one space character after `@charset`
* - End immediately with a semicolon
*/
const charsetPattern = /^@charset "[^"]+";$/u;
const charsetEncodingPattern = /^['"]?([^"';]+)['"]?/u;
/**
* Extracts metadata from an error object.
* @param {SyntaxError} error The error object to extract metadata from.
* @returns {Object} The metadata extracted from the error.
*/
function extractMetaDataFromError(error) {
const message = error.message;
const atRuleName = /`@(.*)`/u.exec(message)[1];
let messageId = "unknownAtRule";
if (message.endsWith("prelude")) {
messageId = message.includes("should not")
? "invalidExtraPrelude"
: "missingPrelude";
}
return {
messageId,
data: {
name: atRuleName,
},
};
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoInvalidAtRulesRuleDefinition} */ ({
meta: {
type: "problem",
fixable: "code",
docs: {
description: "Disallow invalid at-rules",
recommended: true,
url: "https://github.com/eslint/css/blob/main/docs/rules/no-invalid-at-rules.md",
},
messages: {
unknownAtRule: "Unknown at-rule '@{{name}}' found.",
invalidPrelude: "Invalid prelude '{{prelude}}' found for at-rule '@{{name}}'. Expected '{{expected}}'.",
unknownDescriptor: "Unknown descriptor '{{descriptor}}' found for at-rule '@{{name}}'.",
invalidDescriptor: "Invalid value '{{value}}' for descriptor '{{descriptor}}' found for at-rule '@{{name}}'. Expected {{expected}}.",
invalidExtraPrelude: "At-rule '@{{name}}' should not contain a prelude.",
missingPrelude: "At-rule '@{{name}}' should contain a prelude.",
invalidCharsetSyntax: "Invalid @charset syntax. Expected '@charset \"{{encoding}}\";'.",
},
},
create(context) {
const { sourceCode } = context;
const lexer = sourceCode.lexer;
/**
* Validates a `@charset` rule for correct syntax:
* - Verifies the rule name is exactly "charset" (case-sensitive)
* - Ensures the rule has a prelude
* - Validates the prelude matches the expected pattern
* @param {AtrulePlain} node The node representing the rule.
* @returns {void}
*/
function validateCharsetRule(node) {
const { name, prelude, loc } = node;
const charsetNameLoc = {
start: loc.start,
end: {
line: loc.start.line,
column: loc.start.column + name.length + 1,
},
};
if (name !== "charset") {
context.report({
loc: charsetNameLoc,
messageId: "unknownAtRule",
data: {
name,
},
fix(fixer) {
return fixer.replaceTextRange([
loc.start.offset,
loc.start.offset + name.length + 1,
], "@charset");
},
});
return;
}
if (!prelude) {
context.report({
loc: charsetNameLoc,
messageId: "missingPrelude",
data: {
name,
},
});
return;
}
const nodeText = sourceCode.getText(node);
const preludeText = sourceCode.getText(prelude);
const encoding = preludeText
.match(charsetEncodingPattern)?.[1]
?.trim();
if (!encoding) {
context.report({
loc: prelude.loc,
messageId: "invalidCharsetSyntax",
data: { encoding: "<charset>" },
});
return;
}
if (!charsetPattern.test(nodeText)) {
context.report({
loc: prelude.loc,
messageId: "invalidCharsetSyntax",
data: { encoding },
fix(fixer) {
return fixer.replaceText(node, `@charset "${encoding}";`);
},
});
}
}
return {
Atrule(node) {
if (node.name.toLowerCase() === "charset") {
validateCharsetRule(node);
return;
}
// checks both name and prelude
const { error } = lexer.matchAtrulePrelude(node.name, node.prelude);
if (error) {
if (isSyntaxMatchError(error)) {
context.report({
loc: error.loc,
messageId: "invalidPrelude",
data: {
name: node.name,
prelude: error.css,
expected: error.syntax,
},
});
return;
}
const loc = node.loc;
context.report({
loc: {
start: loc.start,
end: {
line: loc.start.line,
// add 1 to account for the @ symbol
column: loc.start.column + node.name.length + 1,
},
},
...extractMetaDataFromError(error),
});
}
},
"AtRule > Block > Declaration"(node) {
// skip custom descriptors
if (node.property.startsWith("--")) {
return;
}
// get at rule node
const atRule = /** @type {AtrulePlain} */ (sourceCode.getParent(sourceCode.getParent(node)));
if (nestableAtRules.has(atRule.name.toLowerCase())) {
return;
}
const { error } = lexer.matchAtruleDescriptor(atRule.name, node.property, node.value);
if (error) {
if (isSyntaxMatchError(error)) {
context.report({
loc: error.loc,
messageId: "invalidDescriptor",
data: {
name: atRule.name,
descriptor: node.property,
value: error.css,
expected: error.syntax,
},
});
return;
}
const loc = node.loc;
context.report({
loc: {
start: loc.start,
end: {
line: loc.start.line,
column: loc.start.column + node.property.length,
},
},
messageId: "unknownDescriptor",
data: {
name: atRule.name,
descriptor: node.property,
},
});
}
},
};
},
});
@@ -0,0 +1,31 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let recommended: boolean;
let url: string;
}
namespace messages {
let emptyGridArea: string;
let unevenGridArea: string;
let nonRectangularGridArea: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: [];
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: NoInvalidNamedGridAreasMessageIds;
}>): {
Declaration(node: import("@eslint/css-tree").DeclarationPlain): void;
};
}
export default _default;
export type NoInvalidNamedGridAreasMessageIds = "emptyGridArea" | "unevenGridArea" | "nonRectangularGridArea";
export type NoInvalidNamedGridAreasRuleDefinition = CSSRuleDefinition<{
RuleOptions: [];
MessageIds: NoInvalidNamedGridAreasMessageIds;
}>;
import type { CSSRuleDefinition } from "../types.js";
@@ -0,0 +1,136 @@
/**
* @fileoverview Rule to prevent invalid named grid areas in CSS grid templates.
* @author xbinaryx
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { CSSRuleDefinition } from "../types.js"
* @typedef {"emptyGridArea" | "unevenGridArea" | "nonRectangularGridArea"} NoInvalidNamedGridAreasMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoInvalidNamedGridAreasMessageIds }>} NoInvalidNamedGridAreasRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Regular expression to match null cell tokens (sequences of one or more dots)
*/
const nullCellToken = /^\.+$/u;
/**
* Finds non-rectangular grid areas in a 2D grid
* @param {string[][]} grid 2D array representing the grid areas
* @returns {Array<{name: string, row: number}>} Array of errors found
*/
function findNonRectangularAreas(grid) {
const errors = [];
const reported = new Set();
const names = [...new Set(grid.flat())].filter(name => !nullCellToken.test(name));
for (const name of names) {
const indicesByRow = grid.map(row => {
const indices = [];
let idx = row.indexOf(name);
while (idx !== -1) {
indices.push(idx);
idx = row.indexOf(name, idx + 1);
}
return indices;
});
for (let i = 0; i < indicesByRow.length; i++) {
for (let j = i + 1; j < indicesByRow.length; j++) {
const row1 = indicesByRow[i];
const row2 = indicesByRow[j];
if (row1.length === 0 || row2.length === 0) {
continue;
}
if (row1.length !== row2.length ||
!row1.every((val, idx) => val === row2[idx])) {
const key = `${name}|${j}`;
if (!reported.has(key)) {
errors.push({ name, row: j });
reported.add(key);
}
}
}
}
}
return errors;
}
const validProps = new Set(["grid-template-areas", "grid-template", "grid"]);
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoInvalidNamedGridAreasRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
description: "Disallow invalid named grid areas",
recommended: true,
url: "https://github.com/eslint/css/blob/main/docs/rules/no-invalid-named-grid-areas.md",
},
messages: {
emptyGridArea: "Grid area must contain at least one cell token.",
unevenGridArea: "Grid area strings must have the same number of cell tokens.",
nonRectangularGridArea: "Cell tokens with name '{{name}}' must form a rectangle.",
},
},
create(context) {
return {
Declaration(node) {
const propName = node.property.toLowerCase();
if (validProps.has(propName) &&
node.value.type === "Value" &&
node.value.children.length > 0) {
const stringNodes = node.value.children.filter(child => child.type === "String");
if (stringNodes.length === 0) {
return;
}
const grid = [];
const emptyNodes = [];
const unevenNodes = [];
let firstRowLen = null;
for (const stringNode of stringNodes) {
const trimmedValue = stringNode.value.trim();
if (trimmedValue === "") {
emptyNodes.push(stringNode);
continue;
}
const row = trimmedValue.split(" ").filter(Boolean);
grid.push(row);
if (firstRowLen === null) {
firstRowLen = row.length;
}
else if (row.length !== firstRowLen) {
unevenNodes.push(stringNode);
}
}
if (emptyNodes.length > 0) {
emptyNodes.forEach(emptyNode => context.report({
node: emptyNode,
messageId: "emptyGridArea",
}));
return;
}
if (unevenNodes.length > 0) {
unevenNodes.forEach(unevenNode => context.report({
node: unevenNode,
messageId: "unevenGridArea",
}));
return;
}
const nonRectErrors = findNonRectangularAreas(grid);
nonRectErrors.forEach(({ name, row }) => {
const stringNode = stringNodes[row];
context.report({
node: stringNode,
messageId: "nonRectangularGridArea",
data: {
name,
},
});
});
}
},
};
},
});
@@ -0,0 +1,52 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let recommended: boolean;
let url: string;
}
let schema: {
type: "object";
properties: {
allowUnknownVariables: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
allowUnknownVariables: false;
}];
namespace messages {
let invalidPropertyValue: string;
let unknownProperty: string;
let unknownVar: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: NoInvalidPropertiesOptions;
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: NoInvalidPropertiesMessageIds;
}>): {
"Rule > Block Declaration"(): void;
"Rule > Block Declaration > Value > *:not(Function)"(node: any): void;
Function(): void;
"Function > *:not(Function)"(node: any): void;
"Function:exit"(node: FunctionNodePlain): void;
"Rule > Block Declaration:exit"(node: any): void;
};
}
export default _default;
export type NoInvalidPropertiesMessageIds = "invalidPropertyValue" | "unknownProperty" | "unknownVar";
export type NoInvalidPropertiesOptions = [{
allowUnknownVariables?: boolean;
}];
export type NoInvalidPropertiesRuleDefinition = CSSRuleDefinition<{
RuleOptions: NoInvalidPropertiesOptions;
MessageIds: NoInvalidPropertiesMessageIds;
}>;
import type { FunctionNodePlain } from "@eslint/css-tree";
import type { CSSRuleDefinition } from "../types.js";
@@ -0,0 +1,399 @@
/**
* @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,
},
});
}
}
},
};
},
});
@@ -0,0 +1,29 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let recommended: boolean;
let url: string;
}
namespace messages {
let unmatchableSelector: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: [];
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: "unmatchableSelector";
}>): {
AnPlusB(node: import("@eslint/css-tree").AnPlusB): void;
};
}
export default _default;
export type NoUnmatchableSelectorsMessageIds = "unmatchableSelector";
export type NoUnmatchableSelectorsRuleDefinition = CSSRuleDefinition<{
RuleOptions: [];
MessageIds: NoUnmatchableSelectorsMessageIds;
}>;
import type { CSSRuleDefinition } from "../types.js";
@@ -0,0 +1,47 @@
/**
* @fileoverview Rule to disallow unmatchable selectors.
* @author TKDev7
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { CSSRuleDefinition } from "../types.js"
* @typedef {"unmatchableSelector"} NoUnmatchableSelectorsMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoUnmatchableSelectorsMessageIds }>} NoUnmatchableSelectorsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoUnmatchableSelectorsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
description: "Disallow unmatchable selectors",
recommended: true,
url: "https://github.com/eslint/css/blob/main/docs/rules/no-unmatchable-selectors.md",
},
messages: {
unmatchableSelector: "Unexpected unmatchable selector '{{selector}}'.",
},
},
create(context) {
const { sourceCode } = context;
return {
AnPlusB(node) {
// Either node.a or node.b can be null; Number(null) === 0.
// This coercion is intentional so that omitted coefficients are treated as 0.
const a = Number(node.a);
const b = Number(node.b);
if (a <= 0 && b <= 0) {
const pseudo = sourceCode.getParent(sourceCode.getParent(node));
context.report({
loc: pseudo.loc,
messageId: "unmatchableSelector",
data: { selector: sourceCode.getText(pseudo) },
});
}
},
};
},
});
@@ -0,0 +1,63 @@
declare namespace _default {
namespace meta {
let type: "problem";
let hasSuggestions: true;
namespace docs {
let description: string;
let url: string;
}
let schema: {
type: "object";
properties: {
allowProperties: {
type: "array";
items: {
enum: string[];
};
uniqueItems: true;
};
allowUnits: {
type: "array";
items: {
enum: string[];
};
uniqueItems: true;
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
allowProperties: any[];
allowUnits: any[];
}];
namespace messages {
let notLogicalProperty: string;
let notLogicalValue: string;
let notLogicalUnit: string;
let replaceWithLogicalProperty: string;
let replaceWithLogicalValue: string;
let replaceWithLogicalUnit: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: PreferLogicalPropertiesOptions;
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: PreferLogicalPropertiesMessageIds;
}>): {
Declaration(node: import("@eslint/css-tree").DeclarationPlain): void;
Dimension(node: import("@eslint/css-tree").Dimension): void;
};
}
export default _default;
export type PreferLogicalPropertiesMessageIds = "notLogicalProperty" | "notLogicalValue" | "notLogicalUnit" | "replaceWithLogicalProperty" | "replaceWithLogicalValue" | "replaceWithLogicalUnit";
export type PreferLogicalPropertiesOptions = [{
allowProperties?: string[];
allowUnits?: string[];
}];
export type PreferLogicalPropertiesRuleDefinition = CSSRuleDefinition<{
RuleOptions: PreferLogicalPropertiesOptions;
MessageIds: PreferLogicalPropertiesMessageIds;
}>;
import type { CSSRuleDefinition } from "../types.js";
@@ -0,0 +1,268 @@
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { CSSRuleDefinition } from "../types.js"
* @typedef {"notLogicalProperty" | "notLogicalValue" | "notLogicalUnit" | "replaceWithLogicalProperty" | "replaceWithLogicalValue" | "replaceWithLogicalUnit"} PreferLogicalPropertiesMessageIds
* @typedef {[{
* allowProperties?: string[],
* allowUnits?: string[]
* }]} PreferLogicalPropertiesOptions
* @typedef {CSSRuleDefinition<{ RuleOptions: PreferLogicalPropertiesOptions, MessageIds: PreferLogicalPropertiesMessageIds }>} PreferLogicalPropertiesRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const propertiesReplacements = new Map([
["bottom", "inset-block-end"],
["border-bottom", "border-block-end"],
["border-bottom-color", "border-block-end-color"],
["border-bottom-left-radius", "border-end-start-radius"],
["border-bottom-right-radius", "border-end-end-radius"],
["border-bottom-style", "border-block-end-style"],
["border-bottom-width", "border-block-end-width"],
["border-left", "border-inline-start"],
["border-left-color", "border-inline-start-color"],
["border-left-style", "border-inline-start-style"],
["border-left-width", "border-inline-start-width"],
["border-right", "border-inline-end"],
["border-right-color", "border-inline-end-color"],
["border-right-style", "border-inline-end-style"],
["border-right-width", "border-inline-end-width"],
["border-top", "border-block-start"],
["border-top-color", "border-block-start-color"],
["border-top-left-radius", "border-start-start-radius"],
["border-top-right-radius", "border-start-end-radius"],
["border-top-style", "border-block-start-style"],
["border-top-width", "border-block-start-width"],
["contain-intrinsic-height", "contain-intrinsic-block-size"],
["contain-intrinsic-width", "contain-intrinsic-inline-size"],
["height", "block-size"],
["left", "inset-inline-start"],
["margin-bottom", "margin-block-end"],
["margin-left", "margin-inline-start"],
["margin-right", "margin-inline-end"],
["margin-top", "margin-block-start"],
["max-height", "max-block-size"],
["max-width", "max-inline-size"],
["min-height", "min-block-size"],
["min-width", "min-inline-size"],
["overflow-x", "overflow-inline"],
["overflow-y", "overflow-block"],
["overscroll-behavior-x", "overscroll-behavior-inline"],
["overscroll-behavior-y", "overscroll-behavior-block"],
["padding-bottom", "padding-block-end"],
["padding-left", "padding-inline-start"],
["padding-right", "padding-inline-end"],
["padding-top", "padding-block-start"],
["right", "inset-inline-end"],
["scroll-margin-bottom", "scroll-margin-block-end"],
["scroll-margin-left", "scroll-margin-inline-start"],
["scroll-margin-right", "scroll-margin-inline-end"],
["scroll-margin-top", "scroll-margin-block-start"],
["scroll-padding-bottom", "scroll-padding-block-end"],
["scroll-padding-left", "scroll-padding-inline-start"],
["scroll-padding-right", "scroll-padding-inline-end"],
["scroll-padding-top", "scroll-padding-block-start"],
["top", "inset-block-start"],
["width", "inline-size"],
]);
const propertyValuesReplacements = new Map([
[
"text-align",
{
left: "start",
right: "end",
},
],
[
"resize",
{
horizontal: "inline",
vertical: "block",
},
],
[
"caption-side",
{
left: "inline-start",
right: "inline-end",
},
],
[
"box-orient",
{
horizontal: "inline-axis",
vertical: "block-axis",
},
],
[
"float",
{
left: "inline-start",
right: "inline-end",
},
],
[
"clear",
{
left: "inline-start",
right: "inline-end",
},
],
]);
const unitReplacements = new Map([
["cqh", "cqb"],
["cqw", "cqi"],
["dvh", "dvb"],
["dvw", "dvi"],
["lvh", "lvb"],
["lvw", "lvi"],
["svh", "svb"],
["svw", "svi"],
["vh", "vb"],
["vw", "vi"],
]);
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {PreferLogicalPropertiesRuleDefinition} */ ({
meta: {
type: "problem",
hasSuggestions: true,
docs: {
description: "Enforce the use of logical properties",
url: "https://github.com/eslint/css/blob/main/docs/rules/prefer-logical-properties.md",
},
schema: [
{
type: "object",
properties: {
allowProperties: {
type: "array",
items: {
enum: Array.from(propertiesReplacements.keys()),
},
uniqueItems: true,
},
allowUnits: {
type: "array",
items: {
enum: Array.from(unitReplacements.keys()),
},
uniqueItems: true,
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
allowProperties: [],
allowUnits: [],
},
],
messages: {
notLogicalProperty: "Expected logical property '{{replacement}}' instead of '{{property}}'.",
notLogicalValue: "Expected logical value '{{replacement}}' instead of '{{value}}'.",
notLogicalUnit: "Expected logical unit '{{replacement}}' instead of '{{unit}}'.",
replaceWithLogicalProperty: "Replace '{{property}}' with logical property '{{replacement}}'.",
replaceWithLogicalValue: "Replace '{{value}}' with logical value '{{replacement}}'.",
replaceWithLogicalUnit: "Replace '{{unit}}' with logical unit '{{replacement}}'.",
},
},
create(context) {
const [{ allowProperties, allowUnits }] = context.options;
return {
Declaration(node) {
const parent = context.sourceCode.getParent(node);
if (parent.type === "SupportsDeclaration") {
return;
}
const propertyReplacement = propertiesReplacements.get(node.property);
if (propertyReplacement &&
!allowProperties.includes(node.property)) {
context.report({
loc: node.loc,
messageId: "notLogicalProperty",
data: {
property: node.property,
replacement: propertyReplacement,
},
suggest: [
{
messageId: "replaceWithLogicalProperty",
data: {
property: node.property,
replacement: propertyReplacement,
},
fix(fixer) {
return fixer.replaceTextRange([
node.loc.start.offset,
node.loc.start.offset +
node.property.length,
], propertyReplacement);
},
},
],
});
}
const valueReplacements = propertyValuesReplacements.get(node.property);
if (valueReplacements &&
node.value.type === "Value" &&
node.value.children[0].type === "Identifier") {
const identifier = node.value.children[0];
const nodeValue = identifier.name;
const valueReplacement = valueReplacements[nodeValue];
if (valueReplacement) {
context.report({
loc: identifier.loc,
messageId: "notLogicalValue",
data: {
value: nodeValue,
replacement: valueReplacement,
},
suggest: [
{
messageId: "replaceWithLogicalValue",
data: {
value: nodeValue,
replacement: valueReplacement,
},
fix(fixer) {
return fixer.replaceText(identifier, valueReplacement);
},
},
],
});
}
}
},
Dimension(node) {
const unitReplacement = unitReplacements.get(node.unit);
if (unitReplacement && !allowUnits.includes(node.unit)) {
context.report({
loc: node.loc,
messageId: "notLogicalUnit",
data: {
unit: node.unit,
replacement: unitReplacement,
},
suggest: [
{
messageId: "replaceWithLogicalUnit",
data: {
unit: node.unit,
replacement: unitReplacement,
},
fix(fixer) {
return fixer.replaceText(node, node.value + unitReplacement);
},
},
],
});
}
},
};
},
});
@@ -0,0 +1,48 @@
declare namespace _default {
namespace meta {
let type: "suggestion";
namespace docs {
let description: string;
let recommended: boolean;
let url: string;
}
let schema: {
type: "object";
properties: {
allowUnits: {
type: "array";
items: {
enum: string[];
};
uniqueItems: true;
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
allowUnits: string[];
}];
namespace messages {
let allowedFontUnits: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: RelativeFontUnitsOptions;
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: "allowedFontUnits";
}>): {
Declaration(node: import("@eslint/css-tree").DeclarationPlain): void;
};
}
export default _default;
export type RelativeFontUnitsMessageIds = "allowedFontUnits";
export type RelativeFontUnitsOptions = [{
allowUnits?: string[];
}];
export type RelativeFontUnitsRuleDefinition = CSSRuleDefinition<{
RuleOptions: RelativeFontUnitsOptions;
MessageIds: RelativeFontUnitsMessageIds;
}>;
import type { CSSRuleDefinition } from "../types.js";
+156
View File
@@ -0,0 +1,156 @@
/**
* @fileoverview Enforce the use of relative units for font size.
* @author Tanuj Kanti
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { CSSRuleDefinition } from "../types.js"
* @typedef {"allowedFontUnits"} RelativeFontUnitsMessageIds
* @typedef {[{allowUnits?: string[]}]} RelativeFontUnitsOptions
* @typedef {CSSRuleDefinition<{ RuleOptions: RelativeFontUnitsOptions, MessageIds: RelativeFontUnitsMessageIds}>} RelativeFontUnitsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const relativeFontUnits = [
"%",
"cap",
"ch",
"em",
"ex",
"ic",
"lh",
"rcap",
"rch",
"rem",
"rex",
"ric",
"rlh",
];
const disallowedFontSizeKeywords = new Set([
"xx-small",
"x-small",
"small",
"medium",
"large",
"x-large",
"xx-large",
"xxx-large",
"math",
]);
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {RelativeFontUnitsRuleDefinition} */ ({
meta: {
type: "suggestion",
docs: {
description: "Enforce the use of relative font units",
recommended: false,
url: "https://github.com/eslint/css/blob/main/docs/rules/relative-font-units.md",
},
schema: [
{
type: "object",
properties: {
allowUnits: {
type: "array",
items: {
enum: relativeFontUnits,
},
uniqueItems: true,
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
allowUnits: ["rem"],
},
],
messages: {
allowedFontUnits: "Use only allowed relative units for 'font-size' - {{allowedFontUnits}}.",
},
},
create(context) {
const [{ allowUnits: allowedFontUnits }] = context.options;
return {
Declaration(node) {
if (node.property === "font-size") {
if (node.value.type === "Value" &&
node.value.children.length > 0) {
const value = node.value.children[0];
if ((value.type === "Dimension" &&
!allowedFontUnits.includes(value.unit.toLowerCase())) ||
(value.type === "Identifier" &&
disallowedFontSizeKeywords.has(value.name.toLowerCase())) ||
(value.type === "Percentage" &&
!allowedFontUnits.includes("%"))) {
context.report({
loc: value.loc,
messageId: "allowedFontUnits",
data: {
allowedFontUnits: allowedFontUnits.join(", "),
},
});
}
}
}
if (node.property === "font") {
if (node.value.type === "Value" &&
node.value.children.length > 0) {
const value = node.value;
const dimensionNode = value.children.find(child => child.type === "Dimension");
const identifierNode = value.children.find(child => child.type === "Identifier" &&
disallowedFontSizeKeywords.has(child.name.toLowerCase()));
const percentageNode = value.children.find((child, index) => {
const isPercentage = child.type === "Percentage";
const previousNode = value.children[index - 1];
const previousNodeIsSlashOperator = previousNode &&
previousNode.type === "Operator" &&
previousNode.value === "/";
return (isPercentage && !previousNodeIsSlashOperator);
});
let location;
let shouldReport = false;
const conditions = [
{
check: !allowedFontUnits.includes("%") &&
percentageNode,
loc: percentageNode?.loc,
},
{
check: identifierNode,
loc: identifierNode?.loc,
},
{
check: dimensionNode &&
!allowedFontUnits.includes(dimensionNode.unit.toLowerCase()),
loc: dimensionNode?.loc,
},
];
for (const condition of conditions) {
if (condition.check) {
shouldReport = true;
location = condition.loc;
break;
}
}
if (shouldReport) {
context.report({
loc: location,
messageId: "allowedFontUnits",
data: {
allowedFontUnits: allowedFontUnits.join(", "),
},
});
}
}
}
},
};
},
});
@@ -0,0 +1,133 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let recommended: boolean;
let url: string;
}
let schema: {
type: "object";
properties: {
maxIds: {
type: "integer";
minimum: number;
};
maxClasses: {
type: "integer";
minimum: number;
};
maxTypes: {
type: "integer";
minimum: number;
};
maxAttributes: {
type: "integer";
minimum: number;
};
maxPseudoClasses: {
type: "integer";
minimum: number;
};
maxUniversals: {
type: "integer";
minimum: number;
};
maxCompounds: {
type: "integer";
minimum: number;
};
maxCombinators: {
type: "integer";
minimum: number;
};
disallowCombinators: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
disallowPseudoClasses: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
disallowPseudoElements: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
disallowAttributes: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
disallowAttributeMatchers: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
maxIds: number;
maxClasses: number;
maxTypes: number;
maxAttributes: number;
maxPseudoClasses: number;
maxUniversals: number;
maxCompounds: number;
maxCombinators: number;
disallowCombinators: any[];
disallowPseudoClasses: any[];
disallowPseudoElements: any[];
disallowAttributes: any[];
disallowAttributeMatchers: any[];
}];
namespace messages {
let maxSelectors: string;
let disallowedSelectors: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: SelectorComplexityOptions;
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: SelectorComplexityMessageIds;
}>): {
Selector(node: import("@eslint/css-tree").SelectorPlain): void;
};
}
export default _default;
export type SelectorComplexityMessageIds = "maxSelectors" | "disallowedSelectors";
export type SelectorComplexityOptions = [{
maxIds?: number;
maxClasses?: number;
maxTypes?: number;
maxAttributes?: number;
maxPseudoClasses?: number;
maxUniversals?: number;
maxCompounds?: number;
maxCombinators?: number;
disallowCombinators?: string[];
disallowPseudoClasses?: string[];
disallowPseudoElements?: string[];
disallowAttributes?: string[];
disallowAttributeMatchers?: string[];
}];
export type SelectorComplexityRuleDefinition = CSSRuleDefinition<{
RuleOptions: SelectorComplexityOptions;
MessageIds: SelectorComplexityMessageIds;
}>;
import type { CSSRuleDefinition } from "../types.js";
+372
View File
@@ -0,0 +1,372 @@
/**
* @fileoverview Rule to limit and disallow CSS selectors.
* @author Tanuj Kanti
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { CSSRuleDefinition } from "../types.js"
* @typedef {"maxSelectors" | "disallowedSelectors"} SelectorComplexityMessageIds
* @typedef {[{
* maxIds?: number,
* maxClasses?: number,
* maxTypes?: number,
* maxAttributes?: number,
* maxPseudoClasses?: number,
* maxUniversals?: number,
* maxCompounds?: number,
* maxCombinators?: number,
* disallowCombinators?: string[],
* disallowPseudoClasses?: string[],
* disallowPseudoElements?: string[],
* disallowAttributes?: string[],
* disallowAttributeMatchers?: string[],
* }]} SelectorComplexityOptions
* @typedef {CSSRuleDefinition<{ RuleOptions: SelectorComplexityOptions, MessageIds: SelectorComplexityMessageIds }> } SelectorComplexityRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Get the location of a selector of a given name.
* @param {Array<Object>} allSelector All CSS selector nodes.
* @param {string} disallowedSelector The name of the disallowed selector.
* @returns {Object} The location of the disallowed selector.
*/
function getDisallowedSelectorsLocation(allSelector, disallowedSelector) {
return allSelector.find(selector => selector.name === disallowedSelector)
.loc;
}
/**
* An error for exceeding the maximum allowed selectors of a specific type.
* @param {Object} context The ESLint rule context object.
* @param {Object} selectorLoc The location of the selector.
* @param {number} maxValue The max number of selectors that are allowed.
* @param {string} selectorType The type of CSS selector.
* @returns {void}
*/
function exceedLimitError(context, selectorLoc, maxValue, selectorType) {
context.report({
loc: selectorLoc,
messageId: "maxSelectors",
data: {
selector: selectorType,
limit: maxValue,
},
});
}
/**
* Gives an array of CSS selectors of a specific type.
* @param {Array<Object>} selectors All CSS selectors nodes.
* @param {string} selectorType The type of CSS selector to filter out.
* @returns {Array<Object>} Filtered selectors.
*/
function getSelectors(selectors, selectorType) {
return selectors.filter(selector => selector.type === selectorType);
}
/**
* Get the names of all CSS selectors.
* @param {Array<Object>} selectors All CSS selector nodes.
* @returns {Array<string>} Array of selector names.
*/
function getSelectorNames(selectors) {
return selectors.map(selector => selector.name);
}
/**
* Get the location of the attribute matcher or operator in a given attribute selector.
* @param {Array<Object>} selectors All CSS selector nodes.
* @param {number} index The index of the attribute selector in the selectors array.
* @returns {{ startLoc: Object, endLoc: Object }} The start and end locations of the operator.
*/
function getOperatorLocation(selectors, index) {
const selector = selectors[index];
let startLoc;
let endLoc;
if (selector.name.type === "Identifier") {
startLoc = selector.name.loc.end;
}
if (selector.value) {
endLoc = selector.value.loc.start;
}
return { startLoc, endLoc };
}
/**
* Get the location of a given disallowed combinator.
* @param {Array<Object>} selectors All CSS selector nodes.
* @param {Array<Object>} combinatorNodes All combinator nodes.
* @param {string} combinator Name of combinator.
* @param {number} index The index of the given combinator.
* @returns {Object} The location of the disallowed combinator.
*/
function getDisallowedCombinatorsLocation(selectors, combinatorNodes, combinator, index) {
let location;
if (combinator === " ") {
const selectorsArr = [];
let selectorsGroup = [];
selectors.forEach(selector => {
if (selector.type === "Combinator") {
selectorsArr.push(selectorsGroup);
selectorsGroup = [];
}
else {
selectorsGroup.push(selector);
}
});
if (selectorsGroup.length > 0) {
selectorsArr.push(selectorsGroup);
}
location = {
start: selectorsArr[index].at(-1).loc.end,
end: selectorsArr[index + 1][0].loc.start,
};
}
else {
const currentCombinatorNode = combinatorNodes[index];
location = currentCombinatorNode.loc;
}
return location;
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {SelectorComplexityRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
description: "Disallow and limit CSS selectors",
recommended: false,
url: "https://github.com/eslint/css/blob/main/docs/rules/selector-complexity.md",
},
schema: [
{
type: "object",
properties: {
maxIds: {
type: "integer",
minimum: 0,
},
maxClasses: {
type: "integer",
minimum: 0,
},
maxTypes: {
type: "integer",
minimum: 0,
},
maxAttributes: {
type: "integer",
minimum: 0,
},
maxPseudoClasses: {
type: "integer",
minimum: 0,
},
maxUniversals: {
type: "integer",
minimum: 0,
},
maxCompounds: {
type: "integer",
minimum: 0,
},
maxCombinators: {
type: "integer",
minimum: 0,
},
disallowCombinators: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
disallowPseudoClasses: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
disallowPseudoElements: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
disallowAttributes: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
disallowAttributeMatchers: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
maxIds: Infinity,
maxClasses: Infinity,
maxTypes: Infinity,
maxAttributes: Infinity,
maxPseudoClasses: Infinity,
maxUniversals: Infinity,
maxCompounds: Infinity,
maxCombinators: Infinity,
disallowCombinators: [],
disallowPseudoClasses: [],
disallowPseudoElements: [],
disallowAttributes: [],
disallowAttributeMatchers: [],
},
],
messages: {
maxSelectors: "Exceeded maximum {{selector}} selector. Only {{limit}} allowed.",
disallowedSelectors: "'{{selectorName}}' {{selector}} is not allowed.",
},
},
create(context) {
const [{ maxIds, maxClasses, maxTypes, maxAttributes, maxPseudoClasses, maxUniversals, maxCompounds, maxCombinators, disallowCombinators, disallowPseudoClasses, disallowPseudoElements, disallowAttributes, disallowAttributeMatchers, },] = context.options;
return {
Selector(node) {
const selectors = node.children;
const selectorLoc = node.loc;
const idSelectors = getSelectors(selectors, "IdSelector");
const classSelectors = getSelectors(selectors, "ClassSelector");
const typeSelectors = selectors.filter(child => child.type === "TypeSelector" && child.name !== "*");
const attributeSelectors = getSelectors(selectors, "AttributeSelector");
const pseudoClassSelectors = getSelectors(selectors, "PseudoClassSelector");
const universalSelectors = selectors.filter(child => child.type === "TypeSelector" && child.name === "*");
const combinatorNodes = getSelectors(selectors, "Combinator");
const combinators = getSelectorNames(combinatorNodes);
const pseudoClassSelectorsNames = getSelectorNames(pseudoClassSelectors);
const pseudoElementSelectors = getSelectors(selectors, "PseudoElementSelector");
const pseudoElementNames = getSelectorNames(pseudoElementSelectors);
const attributeNames = attributeSelectors.map(s => s.name.name);
const attributeMatchers = attributeSelectors
.map(child => child.matcher)
.filter(Boolean);
if (idSelectors.length > maxIds) {
exceedLimitError(context, selectorLoc, maxIds, "id");
}
if (classSelectors.length > maxClasses) {
exceedLimitError(context, selectorLoc, maxClasses, "class");
}
if (typeSelectors.length > maxTypes) {
exceedLimitError(context, selectorLoc, maxTypes, "type");
}
if (attributeSelectors.length > maxAttributes) {
exceedLimitError(context, selectorLoc, maxAttributes, "attribute");
}
if (pseudoClassSelectors.length > maxPseudoClasses) {
exceedLimitError(context, selectorLoc, maxPseudoClasses, "pseudo-class");
}
if (universalSelectors.length > maxUniversals) {
exceedLimitError(context, selectorLoc, maxUniversals, "universal");
}
if (combinatorNodes.length > maxCombinators) {
exceedLimitError(context, selectorLoc, maxCombinators, "combinator");
}
if (combinatorNodes.length + 1 > maxCompounds) {
exceedLimitError(context, selectorLoc, maxCompounds, "compound");
}
if (disallowPseudoClasses.length > 0) {
let disallowedPseudoClassLocation;
for (const pseudoClassName of pseudoClassSelectorsNames) {
if (disallowPseudoClasses.includes(pseudoClassName)) {
disallowedPseudoClassLocation =
getDisallowedSelectorsLocation(pseudoClassSelectors, pseudoClassName);
context.report({
loc: disallowedPseudoClassLocation,
messageId: "disallowedSelectors",
data: {
selectorName: pseudoClassName,
selector: "pseudo-class",
},
});
}
}
}
if (disallowCombinators.length > 0) {
let disallowedCombinatorLocation;
for (const [index, combinator] of combinators.entries()) {
if (disallowCombinators.includes(combinator)) {
disallowedCombinatorLocation =
getDisallowedCombinatorsLocation(selectors, combinatorNodes, combinator, index);
context.report({
loc: disallowedCombinatorLocation,
messageId: "disallowedSelectors",
data: {
selectorName: combinator,
selector: "combinator",
},
});
}
}
}
if (disallowPseudoElements.length > 0) {
let disallowPseudoElementsLocation;
for (const pseudoElement of pseudoElementNames) {
if (disallowPseudoElements.includes(pseudoElement)) {
disallowPseudoElementsLocation =
getDisallowedSelectorsLocation(pseudoElementSelectors, pseudoElement);
context.report({
loc: disallowPseudoElementsLocation,
messageId: "disallowedSelectors",
data: {
selectorName: pseudoElement,
selector: "pseudo-element",
},
});
}
}
}
if (disallowAttributes.length > 0) {
let disallowAttributesLocation;
for (const attributeName of attributeNames) {
if (disallowAttributes.includes(attributeName)) {
disallowAttributesLocation =
attributeSelectors.find(selector => selector.name.name === attributeName).name.loc;
context.report({
loc: disallowAttributesLocation,
messageId: "disallowedSelectors",
data: {
selectorName: attributeName,
selector: "attribute",
},
});
}
}
}
if (disallowAttributeMatchers.length > 0) {
for (const [index, attributeMatcher,] of attributeMatchers.entries()) {
if (disallowAttributeMatchers.includes(attributeMatcher)) {
const { startLoc, endLoc } = getOperatorLocation(attributeSelectors.filter(s => s.matcher), index);
context.report({
loc: {
start: startLoc,
end: endLoc,
},
messageId: "disallowedSelectors",
data: {
selectorName: attributeMatcher,
selector: "attribute-matcher",
},
});
}
}
}
},
};
},
});
+138
View File
@@ -0,0 +1,138 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let recommended: boolean;
let url: string;
}
let schema: {
type: "object";
properties: {
available: {
anyOf: ({
enum: string[];
type?: undefined;
minimum?: undefined;
maximum?: undefined;
} | {
type: "integer";
minimum: number;
maximum: number;
enum?: undefined;
})[];
};
allowAtRules: {
type: "array";
items: {
enum: string[];
};
uniqueItems: true;
};
allowFunctions: {
type: "array";
items: {
enum: string[];
};
uniqueItems: true;
};
allowMediaConditions: {
type: "array";
items: {
enum: string[];
};
uniqueItems: true;
};
allowProperties: {
type: "array";
items: {
enum: string[];
};
uniqueItems: true;
};
allowPropertyValues: {
type: "object";
properties: {
[k: string]: {
type: "array";
items: {
enum: string[];
};
uniqueItems: true;
};
};
additionalProperties: false;
};
allowSelectors: {
type: "array";
items: {
enum: string[];
};
uniqueItems: true;
};
allowUnits: {
type: "array";
items: {
enum: string[];
};
uniqueItems: true;
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
available: "widely";
allowAtRules: any[];
allowFunctions: any[];
allowMediaConditions: any[];
allowProperties: any[];
allowPropertyValues: {};
allowSelectors: any[];
allowUnits: any[];
}];
namespace messages {
let notBaselineProperty: string;
let notBaselinePropertyValue: string;
let notBaselineAtRule: string;
let notBaselineFunction: string;
let notBaselineMediaCondition: string;
let notBaselineSelector: string;
let notBaselineUnit: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: UseBaselineOptions;
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: UseBaselineMessageIds;
}>): {
"Atrule[name=/^supports$/i]"(): void;
"Atrule[name=/^supports$/i] > AtrulePrelude > Condition"(node: any): void;
"Rule > Block > Declaration"(node: any): void;
"Atrule[name=/^supports$/i]:exit"(): void;
"Atrule[name=/^media$/i] > AtrulePrelude > MediaQueryList > MediaQuery > Condition"(node: any): void;
Atrule(node: import("@eslint/css-tree").AtrulePlain): void;
"PseudoClassSelector,PseudoElementSelector"(node: any): void;
NestingSelector(node: import("@eslint/css-tree").NestingSelector): void;
};
}
export default _default;
export type UseBaselineMessageIds = "notBaselineProperty" | "notBaselinePropertyValue" | "notBaselineAtRule" | "notBaselineFunction" | "notBaselineMediaCondition" | "notBaselineSelector" | "notBaselineUnit";
export type UseBaselineOptions = [{
available?: "widely" | "newly" | number;
allowAtRules?: string[];
allowFunctions?: string[];
allowMediaConditions?: string[];
allowProperties?: string[];
allowPropertyValues?: {
[property: string]: string[];
};
allowSelectors?: string[];
allowUnits?: string[];
}];
export type UseBaselineRuleDefinition = CSSRuleDefinition<{
RuleOptions: UseBaselineOptions;
MessageIds: UseBaselineMessageIds;
}>;
import type { CSSRuleDefinition } from "../types.js";
+868
View File
@@ -0,0 +1,868 @@
/**
* @fileoverview Rule to enforce the use of baseline features.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { BASELINE_HIGH, BASELINE_LOW, properties, propertyValues, atRules, mediaConditions, functions, units, selectors, } from "../data/baseline-data.js";
import { namedColors } from "../data/colors.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { CSSRuleDefinition } from "../types.js"
* @import { Identifier, FunctionNodePlain, Dimension } from "@eslint/css-tree"
* @typedef {"notBaselineProperty" | "notBaselinePropertyValue" | "notBaselineAtRule" | "notBaselineFunction" | "notBaselineMediaCondition" | "notBaselineSelector" | "notBaselineUnit"} UseBaselineMessageIds
* @typedef {[{
* available?: "widely" | "newly" | number,
* allowAtRules?: string[],
* allowFunctions?: string[],
* allowMediaConditions?: string[],
* allowProperties?: string[],
* allowPropertyValues?: { [property: string]: string[] },
* allowSelectors?: string[],
* allowUnits?: string[]
* }]} UseBaselineOptions
* @typedef {CSSRuleDefinition<{ RuleOptions: UseBaselineOptions, MessageIds: UseBaselineMessageIds }>} UseBaselineRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Represents a property that is supported via `@supports`.
*/
class SupportedProperty {
/**
* The name of the property.
* @type {string}
*/
name;
/**
* Supported identifier values.
* @type {Set<string>}
*/
#identifiers = new Set();
/**
* Supported units.
* @type {Set<string>}
*/
#units = new Set();
/**
* Supported function types.
* @type {Set<string>}
*/
#functions = new Set();
/**
* Creates a new instance.
* @param {string} name The name of the property.
*/
constructor(name) {
this.name = name;
}
/**
* Adds an identifier to the list of supported identifiers.
* @param {string} identifier The identifier to add.
* @returns {void}
*/
addIdentifier(identifier) {
this.#identifiers.add(identifier);
}
/**
* Determines if an identifier is supported.
* @param {string} identifier The identifier to check.
* @returns {boolean} `true` if the identifier is supported, `false` if not.
*/
hasIdentifier(identifier) {
return this.#identifiers.has(identifier);
}
/**
* Determines if any identifiers are supported.
* @returns {boolean} `true` if any identifiers are supported, `false` if not.
*/
hasIdentifiers() {
return this.#identifiers.size > 0;
}
/**
* Adds a unit to the list of supported units.
* @param {string} unit The unit to add.
* @returns {void}
*/
addUnit(unit) {
this.#units.add(unit);
}
/**
* Determines if a unit is supported.
* @param {string} unit The unit to check.
* @returns {boolean} `true` if the unit is supported, `false` if not.
*/
hasUnit(unit) {
return this.#units.has(unit);
}
/**
* Determines if any units are supported.
* @returns {boolean} `true` if any units are supported, `false` if not.
*/
hasUnits() {
return this.#units.size > 0;
}
/**
* Adds a function to the list of supported functions.
* @param {string} func The function to add.
* @returns {void}
*/
addFunction(func) {
this.#functions.add(func);
}
/**
* Determines if a function is supported.
* @param {string} func The function to check.
* @returns {boolean} `true` if the function is supported, `false` if not.
*/
hasFunction(func) {
return this.#functions.has(func);
}
/**
* Determines if any functions are supported.
* @returns {boolean} `true` if any functions are supported, `false` if not.
*/
hasFunctions() {
return this.#functions.size > 0;
}
}
/**
* Represents an `@supports` rule and everything it enables.
*/
class SupportsRule {
/**
* The properties supported by this rule.
* @type {Map<string, SupportedProperty>}
*/
#properties = new Map();
/**
* The selectors supported by this rule.
* @type {Set<string>}
*/
#selectors = new Set();
/**
* Adds a property to the rule.
* @param {string} property The name of the property.
* @returns {SupportedProperty} The supported property object.
*/
addProperty(property) {
if (this.#properties.has(property)) {
return this.#properties.get(property);
}
const supportedProperty = new SupportedProperty(property);
this.#properties.set(property, supportedProperty);
return supportedProperty;
}
/**
* Determines if the rule supports a property.
* @param {string} property The name of the property.
* @returns {boolean} `true` if the property is supported, `false` if not.
*/
hasProperty(property) {
return this.#properties.has(property);
}
/**
* Gets the supported property.
* @param {string} property The name of the property.
* @returns {SupportedProperty} The supported property.
*/
getProperty(property) {
return this.#properties.get(property);
}
/**
* Determines if the rule supports a property value.
* @param {string} property The name of the property.
* @param {string} identifier The identifier to check.
* @returns {boolean} `true` if the property value is supported, `false` if not.
*/
hasPropertyIdentifier(property, identifier) {
const supportedProperty = this.#properties.get(property);
if (!supportedProperty) {
return false;
}
return supportedProperty.hasIdentifier(identifier);
}
/**
* Determines if the rule supports any property values.
* @param {string} property The name of the property.
* @returns {boolean} `true` if any property values are supported, `false` if not.
*/
hasPropertyIdentifiers(property) {
const supportedProperty = this.#properties.get(property);
if (!supportedProperty) {
return false;
}
return supportedProperty.hasIdentifiers();
}
/**
* Determines if the rule supports a function.
* @param {string} property The name of the property.
* @param {string} func The function to check.
* @returns {boolean} `true` if the function is supported, `false` if not.
*/
hasFunction(property, func) {
const supportedProperty = this.#properties.get(property);
if (!supportedProperty) {
return false;
}
return supportedProperty.hasFunction(func);
}
/**
* Determines if the rule supports any functions.
* @param {string} property The name of the property.
* @returns {boolean} `true` if any functions are supported, `false` if not.
*/
hasFunctions(property) {
const supportedProperty = this.#properties.get(property);
if (!supportedProperty) {
return false;
}
return supportedProperty.hasFunctions();
}
/**
* Determines if the rule supports a unit.
* @param {string} property The name of the property.
* @param {string} unit The unit to check.
* @returns {boolean} `true` if the unit is supported, `false` if not.
*/
hasPropertyUnit(property, unit) {
const supportedProperty = this.#properties.get(property);
if (!supportedProperty) {
return false;
}
return supportedProperty.hasUnit(unit);
}
/**
* Determines if the rule supports any units.
* @param {string} property The name of the property.
* @returns {boolean} `true` if any units are supported, `false` if not.
*/
hasPropertyUnits(property) {
const supportedProperty = this.#properties.get(property);
if (!supportedProperty) {
return false;
}
return supportedProperty.hasUnits();
}
/**
* Adds a selector to the rule.
* @param {string} selector The name of the selector.
* @returns {void}
*/
addSelector(selector) {
this.#selectors.add(selector);
}
/**
* Determines if the rule supports a selector.
* @param {string} selector The name of the selector.
* @returns {boolean} `true` if the selector is supported, `false` if not.
*/
hasSelector(selector) {
return this.#selectors.has(selector);
}
}
/**
* Represents a collection of supports rules.
*/
class SupportsRules {
/**
* A collection of supports rules.
* @type {Array<SupportsRule>}
*/
#rules = [];
/**
* Adds a rule to the collection.
* @param {SupportsRule} rule The rule to add.
* @returns {void}
*/
push(rule) {
this.#rules.push(rule);
}
/**
* Removes the last rule from the collection.
* @returns {SupportsRule} The last rule in the collection.
*/
pop() {
return this.#rules.pop();
}
/**
* Retrieves the last rule in the collection.
* @returns {SupportsRule} The last rule in the collection.
*/
last() {
return this.#rules.at(-1);
}
/**
* Determines if any rule supports a property.
* @param {string} property The name of the property.
* @returns {boolean} `true` if any rule supports the property, `false` if not.
*/
hasProperty(property) {
return this.#rules.some(rule => rule.hasProperty(property));
}
/**
* Determines if any rule supports a property identifier.
* @param {string} property The name of the property.
* @param {string} identifier The identifier to check.
* @returns {boolean} `true` if any rule supports the property value, `false` if not.
*/
hasPropertyIdentifier(property, identifier) {
return this.#rules.some(rule => rule.hasPropertyIdentifier(property, identifier));
}
/**
* Determines if any rule supports any property identifiers.
* @param {string} property The name of the property.
* @returns {boolean} `true` if any rule supports the property values, `false` if not.
*/
hasPropertyIdentifiers(property) {
return this.#rules.some(rule => rule.hasPropertyIdentifiers(property));
}
/**
* Determines if any rule supports a function.
* @param {string} property The name of the property.
* @param {string} func The function to check.
* @returns {boolean} `true` if any rule supports the function, `false` if not.
*/
hasPropertyFunction(property, func) {
return this.#rules.some(rule => rule.hasFunction(property, func));
}
/**
* Determines if any rule supports any functions.
* @param {string} property The name of the property.
* @returns {boolean} `true` if any rule supports the functions, `false` if not.
*/
hasPropertyFunctions(property) {
return this.#rules.some(rule => rule.hasFunctions(property));
}
/**
* Determines if any rule supports a unit.
* @param {string} property The name of the property.
* @param {string} unit The unit to check.
* @returns {boolean} `true` if any rule supports the unit, `false` if not.
*/
hasPropertyUnit(property, unit) {
return this.#rules.some(rule => rule.hasPropertyUnit(property, unit));
}
/**
* Determines if any rule supports a selector.
* @param {string} selector The name of the selector.
* @returns {boolean} `true` if any rule supports the selector, `false` if not.
*/
hasSelector(selector) {
return this.#rules.some(rule => rule.hasSelector(selector));
}
}
/**
* Represents the required availability of a feature.
*/
class BaselineAvailability {
/**
* The preferred Baseline year.
* @type {number}
*/
#baselineYear = undefined;
/**
* The preferred Baseline status.
* @type {number}
*/
#baselineStatus = undefined;
/**
* @param {string | number} availability The required level of feature availability.
*/
constructor(availability) {
this.availability = availability;
if (typeof availability === "number") {
this.#baselineYear = availability;
}
else {
this.#baselineStatus =
availability === "widely" ? BASELINE_HIGH : BASELINE_LOW;
}
}
/**
* Determines whether a feature meets the required availability.
* @param {Object} encodedStatus A feature's encoded baseline status and year.
* @returns {boolean} `true` if the feature is supported, `false` if not.
*/
isSupported(encodedStatus) {
if (!encodedStatus) {
// if we don't know the status, assume it's supported
return true;
}
const parts = encodedStatus.split(":");
const status = Number(parts[0]);
const year = Number(parts[1] || NaN);
if (this.#baselineYear) {
return year <= this.#baselineYear;
}
return status >= this.#baselineStatus;
}
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {UseBaselineRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
description: "Enforce the use of baseline features",
recommended: true,
url: "https://github.com/eslint/css/blob/main/docs/rules/use-baseline.md",
},
schema: [
{
type: "object",
properties: {
available: {
anyOf: [
{
enum: ["widely", "newly"],
},
{
// baseline year
type: "integer",
minimum: 2000,
maximum: new Date().getFullYear(),
},
],
},
allowAtRules: {
type: "array",
items: {
enum: Array.from(atRules.keys()),
},
uniqueItems: true,
},
allowFunctions: {
type: "array",
items: {
enum: Array.from(functions.keys()),
},
uniqueItems: true,
},
allowMediaConditions: {
type: "array",
items: {
enum: Array.from(mediaConditions.keys()),
},
uniqueItems: true,
},
allowProperties: {
type: "array",
items: {
enum: Array.from(properties.keys()),
},
uniqueItems: true,
},
allowPropertyValues: {
type: "object",
properties: Object.fromEntries(Array.from(propertyValues.entries()).map(([prop, valuesMap]) => [
prop,
{
type: "array",
items: {
enum: Array.from(valuesMap.keys()),
},
uniqueItems: true,
},
])),
additionalProperties: false,
},
allowSelectors: {
type: "array",
items: {
enum: Array.from(selectors.keys()),
},
uniqueItems: true,
},
allowUnits: {
type: "array",
items: {
enum: Array.from(units.keys()),
},
uniqueItems: true,
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
available: "widely",
allowAtRules: [],
allowFunctions: [],
allowMediaConditions: [],
allowProperties: [],
allowPropertyValues: {},
allowSelectors: [],
allowUnits: [],
},
],
messages: {
notBaselineProperty: "Property '{{property}}' is not a {{availability}} available baseline feature.",
notBaselinePropertyValue: "Value '{{value}}' of property '{{property}}' is not a {{availability}} available baseline feature.",
notBaselineAtRule: "At-rule '@{{atRule}}' is not a {{availability}} available baseline feature.",
notBaselineFunction: "Function '{{function}}' is not a {{availability}} available baseline feature.",
notBaselineMediaCondition: "Media condition '{{condition}}' is not a {{availability}} available baseline feature.",
notBaselineSelector: "Selector '{{selector}}' is not a {{availability}} available baseline feature.",
notBaselineUnit: "Unit '{{unit}}' is not a {{availability}} available baseline feature.",
},
},
create(context) {
const baselineAvailability = new BaselineAvailability(context.options[0].available);
const supportsRules = new SupportsRules();
const allowAtRules = new Set(context.options[0].allowAtRules);
const allowProperties = new Set(context.options[0].allowProperties);
const allowSelectors = new Set(context.options[0].allowSelectors);
const allowFunctions = new Set(context.options[0].allowFunctions);
const allowMediaConditions = new Set(context.options[0].allowMediaConditions);
const allowUnits = new Set(context.options[0].allowUnits);
const allowPropertyValuesMap = new Map();
for (const [prop, values] of Object.entries(context.options[0].allowPropertyValues)) {
allowPropertyValuesMap.set(prop, new Set(values));
}
/**
* Checks a property value identifier to see if it's a baseline feature.
* @param {string} property The name of the property.
* @param {Identifier} child The node to check.
* @returns {void}
*/
function checkPropertyValueIdentifier(property, child) {
// named colors are always valid
if (namedColors.has(child.name)) {
return;
}
const allowedValues = allowPropertyValuesMap.get(property);
if (allowedValues?.has(child.name)) {
return;
}
const possiblePropertyValues = propertyValues.get(property);
// if we don't know of any possible property values, just skip it
if (!possiblePropertyValues) {
return;
}
const featureStatus = possiblePropertyValues.get(child.name);
// if we don't know of any possible property values, just skip it
if (featureStatus === undefined) {
return;
}
if (!baselineAvailability.isSupported(featureStatus)) {
context.report({
loc: child.loc,
messageId: "notBaselinePropertyValue",
data: {
property,
value: child.name,
availability: baselineAvailability.availability,
},
});
}
}
/**
* Checks a property value function to see if it's a baseline feature.
* @param {FunctionNodePlain} child The node to check.
* @returns {void}
*/
function checkPropertyValueFunction(child) {
if (allowFunctions.has(child.name)) {
return;
}
const featureStatus = functions.get(child.name);
// if we don't know of any possible property values, just skip it
if (featureStatus === undefined) {
return;
}
if (!baselineAvailability.isSupported(featureStatus)) {
context.report({
loc: child.loc,
messageId: "notBaselineFunction",
data: {
function: child.name,
availability: baselineAvailability.availability,
},
});
}
}
/**
* Checks a property value unit to see if it's a baseline feature.
* @param {string} property The name of the property.
* @param {Dimension} child The node to check.
* @returns {void}
*/
function checkPropertyValueUnit(property, child) {
if (allowUnits.has(child.unit)) {
return;
}
const featureStatus = units.get(child.unit);
// if we don't know of this unit, just skip it
if (featureStatus === undefined) {
return;
}
if (!baselineAvailability.isSupported(featureStatus)) {
context.report({
loc: child.loc,
messageId: "notBaselineUnit",
data: {
unit: child.unit,
availability: baselineAvailability.availability,
},
});
}
}
return {
"Atrule[name=/^supports$/i]"() {
supportsRules.push(new SupportsRule());
},
"Atrule[name=/^supports$/i] > AtrulePrelude > Condition"(node) {
const supportsRule = supportsRules.last();
for (let i = 0; i < node.children.length; i++) {
const conditionChild = node.children[i];
// if a SupportsDeclaration is preceded by "not" then we don't consider it
if (conditionChild.type === "Identifier" &&
conditionChild.name === "not") {
i++;
continue;
}
// save the supported properties and values for this at-rule
if (conditionChild.type === "SupportsDeclaration") {
const { declaration } = conditionChild;
const property = declaration.property;
const supportedProperty = supportsRule.addProperty(property);
declaration.value.children.forEach(child => {
if (child.type === "Identifier") {
supportedProperty.addIdentifier(child.name);
return;
}
if (child.type === "Dimension") {
supportedProperty.addUnit(child.unit);
return;
}
if (child.type === "Function") {
supportedProperty.addFunction(child.name);
}
});
continue;
}
if (conditionChild.type === "FeatureFunction" &&
conditionChild.feature === "selector") {
for (const selectorChild of conditionChild.value
.children) {
supportsRule.addSelector(selectorChild.name);
}
}
}
},
"Rule > Block > Declaration"(node) {
const property = node.property;
// ignore unknown properties - no-invalid-properties already catches this
if (!properties.has(property)) {
return;
}
if (allowProperties.has(property)) {
return;
}
/*
* Step 1: Check that the property is in the baseline.
*
* If the property has been tested in a @supports rule, we don't need to
* check it because it won't be applied if the browser doesn't support it.
*/
if (!supportsRules.hasProperty(property)) {
const featureStatus = properties.get(property);
if (!baselineAvailability.isSupported(featureStatus)) {
context.report({
loc: {
start: node.loc.start,
end: {
line: node.loc.start.line,
column: node.loc.start.column +
node.property.length,
},
},
messageId: "notBaselineProperty",
data: {
property,
availability: baselineAvailability.availability,
},
});
/*
* If the property isn't in baseline, then we don't go
* on to check the values. If the property itself isn't
* in baseline then chances are the values aren't too,
* and there's no need to report multiple errors for the
* same property.
*/
return;
}
}
/*
* With tolerant parsing, it's possible that the value is `Raw`
* and therefore doesn't have children. If that's the case then
* we just exit.
*/
if (!node.value?.children) {
return;
}
/*
* Step 2: Check that the property values are in the baseline.
*/
for (const child of node.value.children) {
if (child.type === "Identifier") {
// if the property value has been tested in a @supports rule, don't check it
if (!supportsRules.hasPropertyIdentifier(property, child.name)) {
checkPropertyValueIdentifier(property, child);
}
continue;
}
if (child.type === "Dimension") {
if (!supportsRules.hasPropertyUnit(property, child.unit)) {
checkPropertyValueUnit(property, child);
}
continue;
}
if (child.type === "Function") {
if (!supportsRules.hasPropertyFunction(property, child.name)) {
checkPropertyValueFunction(child);
}
}
}
},
"Atrule[name=/^supports$/i]:exit"() {
supportsRules.pop();
},
"Atrule[name=/^media$/i] > AtrulePrelude > MediaQueryList > MediaQuery > Condition"(node) {
for (const child of node.children) {
// ignore unknown media conditions - no-invalid-at-rules already catches this
if (!mediaConditions.has(child.name)) {
continue;
}
if (child.type !== "Feature") {
continue;
}
if (allowMediaConditions.has(child.name)) {
continue;
}
const featureStatus = mediaConditions.get(child.name);
if (!baselineAvailability.isSupported(featureStatus)) {
const loc = child.loc;
context.report({
loc: {
start: {
line: loc.start.line,
// add 1 to account for the @ symbol
column: loc.start.column + 1,
},
end: {
line: loc.start.line,
column:
// add 1 to account for the @ symbol
loc.start.column +
child.name.length +
1,
},
},
messageId: "notBaselineMediaCondition",
data: {
condition: child.name,
availability: baselineAvailability.availability,
},
});
}
}
},
Atrule(node) {
// ignore unknown at-rules - no-invalid-at-rules already catches this
const atRuleName = node.name.toLowerCase();
if (!atRules.has(atRuleName)) {
return;
}
if (allowAtRules.has(atRuleName)) {
return;
}
const featureStatus = atRules.get(atRuleName);
if (!baselineAvailability.isSupported(featureStatus)) {
const loc = node.loc;
context.report({
loc: {
start: loc.start,
end: {
line: loc.start.line,
// add 1 to account for the @ symbol
column: loc.start.column + node.name.length + 1,
},
},
messageId: "notBaselineAtRule",
data: {
atRule: node.name,
availability: baselineAvailability.availability,
},
});
}
},
"PseudoClassSelector,PseudoElementSelector"(node) {
const selector = node.name;
if (!selectors.has(selector)) {
return;
}
if (allowSelectors.has(selector)) {
return;
}
// if the selector has been tested in a @supports rule, don't check it
if (supportsRules.hasSelector(selector)) {
return;
}
const featureStatus = selectors.get(selector);
if (!baselineAvailability.isSupported(featureStatus)) {
const loc = node.loc;
// some selectors are prefixed with the : or :: symbols
let prefixSymbolLength = 0;
if (node.type.startsWith("PseudoClass")) {
prefixSymbolLength = 1;
}
else if (node.type.startsWith("PseudoElement")) {
prefixSymbolLength = 2;
}
context.report({
loc: {
start: loc.start,
end: {
line: loc.start.line,
column: loc.start.column +
selector.length +
prefixSymbolLength,
},
},
messageId: "notBaselineSelector",
data: {
selector,
availability: baselineAvailability.availability,
},
});
}
},
NestingSelector(node) {
// NestingSelector implies CSS nesting
const selector = "nesting";
if (allowSelectors.has(selector)) {
return;
}
const featureStatus = selectors.get(selector);
if (baselineAvailability.isSupported(featureStatus)) {
return;
}
context.report({
loc: node.loc,
messageId: "notBaselineSelector",
data: {
selector,
availability: baselineAvailability.availability,
},
});
},
};
},
});
+60
View File
@@ -0,0 +1,60 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let url: string;
}
let schema: {
type: "object";
properties: {
allowUnnamedLayers: {
type: "boolean";
};
requireImportLayers: {
type: "boolean";
};
layerNamePattern: {
type: "string";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
allowUnnamedLayers: false;
requireImportLayers: true;
layerNamePattern: string;
}];
namespace messages {
let missingLayer: string;
let missingLayerName: string;
let missingImportLayer: string;
let layerNameMismatch: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../index.js").CSSLanguageOptions;
Code: import("../index.js").CSSSourceCode;
RuleOptions: UseLayersOptions;
Node: import("@eslint/css-tree").CssNodePlain;
MessageIds: UseLayersMessageIds;
}>): {
"Atrule[name=/^import$/i]"(node: any): void;
Layer(node: import("@eslint/css-tree").Layer): void;
"Atrule[name=/^layer$/i]"(node: any): void;
"Atrule[name=/^layer$/i]:exit"(): void;
Rule(node: import("@eslint/css-tree").RulePlain): void;
};
}
export default _default;
export type UseLayersMessageIds = "missingLayer" | "missingLayerName" | "missingImportLayer" | "layerNameMismatch";
export type UseLayersOptions = [{
allowUnnamedLayers?: boolean;
requireImportLayers?: boolean;
layerNamePattern?: string;
}];
export type UseLayersRuleDefinition = CSSRuleDefinition<{
RuleOptions: UseLayersOptions;
MessageIds: UseLayersMessageIds;
}>;
import type { CSSRuleDefinition } from "../types.js";
+144
View File
@@ -0,0 +1,144 @@
/**
* @fileoverview Rule to require layers in CSS.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { CSSRuleDefinition } from "../types.js"
* @typedef {"missingLayer" | "missingLayerName" | "missingImportLayer" | "layerNameMismatch"} UseLayersMessageIds
* @typedef {[{
* allowUnnamedLayers?: boolean,
* requireImportLayers?: boolean,
* layerNamePattern?: string
* }]} UseLayersOptions
* @typedef {CSSRuleDefinition<{ RuleOptions: UseLayersOptions, MessageIds: UseLayersMessageIds }>} UseLayersRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {UseLayersRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
description: "Require use of layers",
url: "https://github.com/eslint/css/blob/main/docs/rules/use-layers.md",
},
schema: [
{
type: "object",
properties: {
allowUnnamedLayers: {
type: "boolean",
},
requireImportLayers: {
type: "boolean",
},
layerNamePattern: {
type: "string",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
allowUnnamedLayers: false,
requireImportLayers: true,
layerNamePattern: "",
},
],
messages: {
missingLayer: "Expected rule to be within a layer.",
missingLayerName: "Expected layer to have a name.",
missingImportLayer: "Expected import to be within a layer.",
layerNameMismatch: "Expected layer name '{{ name }}' to match pattern '{{pattern}}'.",
},
},
create(context) {
let layerDepth = 0;
const options = context.options[0];
const layerNameRegex = options.layerNamePattern
? new RegExp(options.layerNamePattern, "u")
: null;
return {
"Atrule[name=/^import$/i]"(node) {
// layer, if present, must always be the second child of the prelude
const secondChild = node.prelude?.children[1];
const layerNode = secondChild?.name === "layer" ? secondChild : null;
if (options.requireImportLayers && !layerNode) {
context.report({
loc: node.loc,
messageId: "missingImportLayer",
});
}
if (layerNode) {
const isLayerFunction = layerNode.type === "Function";
if (!options.allowUnnamedLayers && !isLayerFunction) {
context.report({
loc: layerNode.loc,
messageId: "missingLayerName",
});
}
}
},
Layer(node) {
if (!layerNameRegex) {
return;
}
const parts = node.name.split(".");
let currentPos = 0;
parts.forEach((part, index) => {
if (!layerNameRegex.test(part)) {
const startColumn = node.loc.start.column + currentPos;
const endColumn = startColumn + part.length;
context.report({
loc: {
start: {
line: node.loc.start.line,
column: startColumn,
},
end: {
line: node.loc.start.line,
column: endColumn,
},
},
messageId: "layerNameMismatch",
data: {
name: part,
pattern: options.layerNamePattern,
},
});
}
currentPos += part.length;
// add 1 to account for the . symbol
if (index < parts.length - 1) {
currentPos += 1;
}
});
},
"Atrule[name=/^layer$/i]"(node) {
layerDepth++;
if (!options.allowUnnamedLayers && !node.prelude) {
context.report({
loc: node.loc,
messageId: "missingLayerName",
});
}
},
"Atrule[name=/^layer$/i]:exit"() {
layerDepth--;
},
Rule(node) {
if (layerDepth > 0) {
return;
}
context.report({
loc: node.loc,
messageId: "missingLayer",
});
},
};
},
});