@eslint/css
Version:
CSS linting plugin for ESLint
2,005 lines (1,740 loc) • 164 kB
JavaScript
/**
* @import { CssNode, CssNodePlain, Comment, Lexer, StyleSheetPlain, SyntaxConfig, SyntaxMatchError, SyntaxReferenceError, AtrulePlain, ValuePlain, FunctionNodePlain, CssLocationRange, Identifier } from "@eslint/css-tree"
* @import { SourceRange, SourceLocation, FileProblem, DirectiveType, RulesConfig, Language, OkParseResult, ParseResult, File, FileError } from "@eslint/core"
* @import { CSSSyntaxElement, CSSRuleDefinition } from "./types.js"
*/
// @ts-self-types="./index.d.ts"
import { tokenTypes, fork, lexer, parse, toPlainObject } from '@eslint/css-tree';
import { ConfigCommentParser, TextSourceCodeBase, Directive, VisitNodeStep } from '@eslint/plugin-kit';
/**
* @fileoverview Visitor keys for the CSS Tree AST.
* @author Nicholas C. Zakas
*/
const visitorKeys = {
AnPlusB: [],
Atrule: ["prelude", "block"],
AtrulePrelude: ["children"],
AttributeSelector: ["name", "value"],
Block: ["children"],
Brackets: ["children"],
CDC: [],
CDO: [],
ClassSelector: [],
Combinator: [],
Comment: [],
Condition: ["children"],
Declaration: ["value"],
DeclarationList: ["children"],
Dimension: [],
Feature: ["value"],
FeatureFunction: ["value"],
FeatureRange: ["left", "middle", "right"],
Function: ["children"],
GeneralEnclosed: ["children"],
Hash: [],
IdSelector: [],
Identifier: [],
Layer: [],
LayerList: ["children"],
MediaQuery: ["condition"],
MediaQueryList: ["children"],
NestingSelector: [],
Nth: ["nth", "selector"],
Number: [],
Operator: [],
Parentheses: ["children"],
Percentage: [],
PseudoClassSelector: ["children"],
PseudoElementSelector: ["children"],
Ratio: ["left", "right"],
Raw: [],
Rule: ["prelude", "block"],
Scope: ["root", "limit"],
Selector: ["children"],
SelectorList: ["children"],
String: [],
StyleSheet: ["children"],
SupportsDeclaration: ["declaration"],
TypeSelector: [],
UnicodeRange: [],
Url: [],
Value: ["children"],
WhiteSpace: [],
};
/**
* @fileoverview The CSSSourceCode class.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/**
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const commentParser = new ConfigCommentParser();
const INLINE_CONFIG =
/^\s*eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|$)/u;
/**
* A class to represent a step in the traversal process.
*/
class CSSTraversalStep extends VisitNodeStep {
/**
* The target of the step.
* @type {CssNode}
*/
target = undefined;
/**
* Creates a new instance.
* @param {Object} options The options for the step.
* @param {CssNode} options.target The target of the step.
* @param {1|2} options.phase The phase of the step.
* @param {Array<any>} options.args The arguments of the step.
*/
constructor({ target, phase, args }) {
super({ target, phase, args });
this.target = target;
}
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* CSS Source Code Object.
* @extends {TextSourceCodeBase<{LangOptions: CSSLanguageOptions, RootNode: StyleSheetPlain, SyntaxElementWithLoc: CSSSyntaxElement, ConfigNode: Comment}>}
*/
class CSSSourceCode extends TextSourceCodeBase {
/**
* Cached traversal steps.
* @type {Array<CSSTraversalStep>|undefined}
*/
#steps;
/**
* Cache of parent nodes.
* @type {WeakMap<CssNodePlain, CssNodePlain>}
*/
#parents = new WeakMap();
/**
* Collection of inline configuration comments.
* @type {Array<Comment>}
*/
#inlineConfigComments;
/**
* The AST of the source code.
* @type {StyleSheetPlain}
*/
ast = undefined;
/**
* The comment node in the source code.
* @type {Array<Comment>|undefined}
*/
comments;
/**
* The lexer for this instance.
* @type {Lexer}
*/
lexer;
/**
* Creates a new instance.
* @param {Object} options The options for the instance.
* @param {string} options.text The source code text.
* @param {StyleSheetPlain} options.ast The root AST node.
* @param {Array<Comment>} options.comments The comment nodes in the source code.
* @param {Lexer} options.lexer The lexer used to parse the source code.
*/
constructor({ text, ast, comments, lexer }) {
super({ text, ast });
this.ast = ast;
this.comments = comments;
this.lexer = lexer;
}
/**
* Returns the range of the given node.
* @param {CssNodePlain} node The node to get the range of.
* @returns {SourceRange} The range of the node.
* @override
*/
getRange(node) {
return [node.loc.start.offset, node.loc.end.offset];
}
/**
* Returns an array of all inline configuration nodes found in the
* source code.
* @returns {Array<Comment>} An array of all inline configuration nodes.
*/
getInlineConfigNodes() {
if (!this.#inlineConfigComments) {
this.#inlineConfigComments = this.comments.filter(comment =>
INLINE_CONFIG.test(comment.value),
);
}
return this.#inlineConfigComments;
}
/**
* Returns directives that enable or disable rules along with any problems
* encountered while parsing the directives.
* @returns {{problems:Array<FileProblem>,directives:Array<Directive>}} Information
* that ESLint needs to further process the directives.
*/
getDisableDirectives() {
/** @type {Array<FileProblem>} */
const problems = [];
/** @type {Array<Directive>} */
const directives = [];
this.getInlineConfigNodes().forEach(comment => {
const { label, value, justification } =
commentParser.parseDirective(comment.value);
// `eslint-disable-line` directives are not allowed to span multiple lines as it would be confusing to which lines they apply
if (
label === "eslint-disable-line" &&
comment.loc.start.line !== comment.loc.end.line
) {
const message = `${label} comment should not span multiple lines.`;
problems.push({
ruleId: null,
message,
loc: comment.loc,
});
return;
}
switch (label) {
case "eslint-disable":
case "eslint-enable":
case "eslint-disable-next-line":
case "eslint-disable-line": {
const directiveType = label.slice("eslint-".length);
directives.push(
new Directive({
type: /** @type {DirectiveType} */ (directiveType),
node: comment,
value,
justification,
}),
);
}
// no default
}
});
return { problems, directives };
}
/**
* Returns inline rule configurations along with any problems
* encountered while parsing the configurations.
* @returns {{problems:Array<FileProblem>,configs:Array<{config:{rules:RulesConfig},loc:SourceLocation}>}} Information
* that ESLint needs to further process the rule configurations.
*/
applyInlineConfig() {
/** @type {Array<FileProblem>} */
const problems = [];
/** @type {Array<{config:{rules:RulesConfig},loc:SourceLocation}>} */
const configs = [];
this.getInlineConfigNodes().forEach(comment => {
const { label, value } = commentParser.parseDirective(
comment.value,
);
if (label === "eslint") {
const parseResult = commentParser.parseJSONLikeConfig(value);
if (parseResult.ok) {
configs.push({
config: {
rules: parseResult.config,
},
loc: comment.loc,
});
} else {
problems.push({
ruleId: null,
message:
/** @type {{ok: false, error: { message: string }}} */ (
parseResult
).error.message,
loc: comment.loc,
});
}
}
});
return {
configs,
problems,
};
}
/**
* Returns the parent of the given node.
* @param {CssNodePlain} node The node to get the parent of.
* @returns {CssNodePlain|undefined} The parent of the node.
*/
getParent(node) {
return this.#parents.get(node);
}
/**
* Traverse the source code and return the steps that were taken.
* @returns {Iterable<CSSTraversalStep>} The steps that were taken while traversing the source code.
*/
traverse() {
// Because the AST doesn't mutate, we can cache the steps
if (this.#steps) {
return this.#steps.values();
}
/** @type {Array<CSSTraversalStep>} */
const steps = (this.#steps = []);
// Note: We can't use `walk` from `css-tree` because it uses `CssNode` instead of `CssNodePlain`
const visit = (node, parent) => {
// first set the parent
this.#parents.set(node, parent);
// then add the step
steps.push(
new CSSTraversalStep({
target: node,
phase: 1,
args: [node, parent],
}),
);
// then visit the children
for (const key of visitorKeys[node.type] || []) {
const child = node[key];
if (child) {
if (Array.isArray(child)) {
child.forEach(grandchild => {
visit(grandchild, node);
});
} else {
visit(child, node);
}
}
}
// then add the exit step
steps.push(
new CSSTraversalStep({
target: node,
phase: 2,
args: [node, parent],
}),
);
};
visit(this.ast);
return steps;
}
}
/**
* @fileoverview The CSSLanguage class.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/**
*/
/** @typedef {OkParseResult<StyleSheetPlain> & { comments: Comment[], lexer: Lexer }} CSSOkParseResult */
/** @typedef {ParseResult<StyleSheetPlain>} CSSParseResult */
/**
* @typedef {Object} CSSLanguageOptions
* @property {boolean} [tolerant] Whether to be tolerant of recoverable parsing errors.
* @property {SyntaxConfig} [customSyntax] Custom syntax to use for parsing.
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const blockOpenerTokenTypes = new Map([
[tokenTypes.Function, ")"],
[tokenTypes.LeftCurlyBracket, "}"],
[tokenTypes.LeftParenthesis, ")"],
[tokenTypes.LeftSquareBracket, "]"],
]);
const blockCloserTokenTypes = new Map([
[tokenTypes.RightCurlyBracket, "{"],
[tokenTypes.RightParenthesis, "("],
[tokenTypes.RightSquareBracket, "["],
]);
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* CSS Language Object
* @implements {Language<{ LangOptions: CSSLanguageOptions; Code: CSSSourceCode; RootNode: StyleSheetPlain; Node: CssNodePlain}>}
*/
class CSSLanguage {
/**
* The type of file to read.
* @type {"text"}
*/
fileType = "text";
/**
* The line number at which the parser starts counting.
* @type {0|1}
*/
lineStart = 1;
/**
* The column number at which the parser starts counting.
* @type {0|1}
*/
columnStart = 1;
/**
* The name of the key that holds the type of the node.
* @type {string}
*/
nodeTypeKey = "type";
/**
* The visitor keys for the CSSTree AST.
* @type {Record<string, string[]>}
*/
visitorKeys = visitorKeys;
/**
* The default language options.
* @type {CSSLanguageOptions}
*/
defaultLanguageOptions = {
tolerant: false,
};
/**
* Validates the language options.
* @param {CSSLanguageOptions} languageOptions The language options to validate.
* @throws {Error} When the language options are invalid.
*/
validateLanguageOptions(languageOptions) {
if (
"tolerant" in languageOptions &&
typeof languageOptions.tolerant !== "boolean"
) {
throw new TypeError(
"Expected a boolean value for 'tolerant' option.",
);
}
if ("customSyntax" in languageOptions) {
if (
typeof languageOptions.customSyntax !== "object" ||
languageOptions.customSyntax === null
) {
throw new TypeError(
"Expected an object value for 'customSyntax' option.",
);
}
}
}
/**
* Parses the given file into an AST.
* @param {File} file The virtual file to parse.
* @param {Object} [context] The parsing context.
* @param {CSSLanguageOptions} [context.languageOptions] The language options to use for parsing.
* @returns {CSSParseResult} The result of parsing.
*/
parse(file, { languageOptions = {} } = {}) {
// Note: BOM already removed
const text = /** @type {string} */ (file.body);
/** @type {Comment[]} */
const comments = [];
/** @type {FileError[]} */
const errors = [];
const { tolerant } = languageOptions;
const { parse: parse$1, lexer: lexer$1 } = languageOptions.customSyntax
? fork(languageOptions.customSyntax)
: { parse: parse, lexer: lexer };
/*
* Check for parsing errors first. If there's a parsing error, nothing
* else can happen. However, a parsing error does not throw an error
* from this method - it's just considered a fatal error message, a
* problem that ESLint identified just like any other.
*/
try {
const root = toPlainObject(
parse$1(text, {
filename: file.path,
positions: true,
onComment(value, loc) {
comments.push({
type: "Comment",
value,
loc,
});
},
onParseError(error) {
if (!tolerant) {
errors.push(error);
}
},
onToken(type, start, end, index) {
if (tolerant) {
return;
}
switch (type) {
// these already generate errors
case tokenTypes.BadString:
case tokenTypes.BadUrl:
break;
default:
/* eslint-disable new-cap -- This is a valid call */
if (this.isBlockOpenerTokenType(type)) {
if (
this.getBlockTokenPairIndex(index) ===
-1
) {
const loc = this.getRangeLocation(
start,
end,
);
errors.push(
parse$1.SyntaxError(
`Missing closing ${blockOpenerTokenTypes.get(type)}`,
text,
start,
loc.start.line,
loc.start.column,
),
);
}
} else if (this.isBlockCloserTokenType(type)) {
if (
this.getBlockTokenPairIndex(index) ===
-1
) {
const loc = this.getRangeLocation(
start,
end,
);
errors.push(
parse$1.SyntaxError(
`Missing opening ${blockCloserTokenTypes.get(type)}`,
text,
start,
loc.start.line,
loc.start.column,
),
);
}
}
/* eslint-enable new-cap -- This is a valid call */
}
},
}),
);
if (errors.length) {
return {
ok: false,
errors,
};
}
return {
ok: true,
ast: /** @type {StyleSheetPlain} */ (root),
comments,
lexer: lexer$1,
};
} catch (ex) {
return {
ok: false,
errors: [ex],
};
}
}
/**
* Creates a new `CSSSourceCode` object from the given information.
* @param {File} file The virtual file to create a `CSSSourceCode` object from.
* @param {CSSOkParseResult} parseResult The result returned from `parse()`.
* @returns {CSSSourceCode} The new `CSSSourceCode` object.
*/
createSourceCode(file, parseResult) {
return new CSSSourceCode({
text: /** @type {string} */ (file.body),
ast: parseResult.ast,
comments: parseResult.comments,
lexer: parseResult.lexer,
});
}
}
const rules$1 = /** @type {const} */ ({
"css/font-family-fallbacks": "error",
"css/no-duplicate-imports": "error",
"css/no-duplicate-keyframe-selectors": "error",
"css/no-empty-blocks": "error",
"css/no-important": "error",
"css/no-invalid-at-rule-placement": "error",
"css/no-invalid-at-rules": "error",
"css/no-invalid-named-grid-areas": "error",
"css/no-invalid-properties": "error",
"css/use-baseline": "error"
});
/**
* @fileoverview Rule to enforce the use of fallback fonts and a generic font last.
* @author Tanuj Kanti
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @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 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.
* @returns {void}
* @private
*/
function reportFontWithoutFallbacksInFontProperty(
fontPropertyValues,
context,
node,
) {
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
//-----------------------------------------------------------------------------
/**
* @type {FontFamilyFallbacksRuleDefinition}
*/
var rule0 = {
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 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 (
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;
}
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,
);
}
} 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,
);
} 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,
);
}
}
}
},
};
},
};
/**
* @fileoverview Rule to prevent duplicate imports in CSS.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @typedef {"duplicateImport"} NoDuplicateKeysMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoDuplicateKeysMessageIds }>} NoDuplicateImportsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule
//-----------------------------------------------------------------------------
/**
* @type {NoDuplicateImportsRuleDefinition}
*/
var rule1 = {
meta: {
type: "problem",
fixable: "code",
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}}.",
},
},
create(context) {
const { sourceCode } = context;
const imports = new Set();
return {
"Atrule[name=/^import$/i]"(node) {
const url = node.prelude.children[0].value;
if (imports.has(url)) {
context.report({
loc: node.loc,
messageId: "duplicateImport",
data: { url },
fix(fixer) {
const [start, end] = sourceCode.getRange(node);
// Remove the node, and also remove a following newline if present
const removeEnd =
sourceCode.text[end] === "\n" ? end + 1 : end;
return fixer.removeRange([start, removeEnd]);
},
});
} else {
imports.add(url);
}
},
};
},
};
/**
* @fileoverview Rule to disallow duplicate selectors within keyframe blocks.
* @author Nitin Kumar
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @typedef {"duplicateKeyframeSelector"} DuplicateKeyframeSelectorMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: DuplicateKeyframeSelectorMessageIds }>} DuplicateKeyframeSelectorRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
/** @type {DuplicateKeyframeSelectorRuleDefinition} */
var rule2 = {
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=/^keyframes$/i]"() {
insideKeyframes = true;
seen.clear();
},
"Atrule[name=/^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);
}
},
};
},
};
/**
* @fileoverview Rule to prevent empty blocks in CSS.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @typedef {"emptyBlock"} NoEmptyBlocksMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoEmptyBlocksMessageIds }>} NoEmptyBlocksRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
/** @type {NoEmptyBlocksRuleDefinition} */
var rule3 = {
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",
});
}
},
};
},
};
/**
* @fileoverview Utility functions for ESLint CSS plugin.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Determines if an error is a syntax match error.
* @param {Object} error The error object to check.
* @returns {error is SyntaxMatchError} True if the error is a syntax match error, false if not.
*/
function isSyntaxMatchError(error) {
return typeof error.syntax === "string";
}
/**
* Determines if an error is a syntax reference error.
* @param {Object} error The error object to check.
* @returns {error is SyntaxReferenceError} True if the error is a syntax reference error, false if not.
*/
function isSyntaxReferenceError(error) {
return typeof error.reference === "string";
}
/**
* Finds the line and column offsets for a given offset in a string.
* @param {string} text The text to search.
* @param {number} offset The offset to find.
* @returns {{lineOffset:number,columnOffset:number}} The location of the offset.
*/
function findOffsets(text, offset) {
let lineOffset = 0;
let columnOffset = 0;
for (let i = 0; i < offset; i++) {
if (text[i] === "\n") {
lineOffset++;
columnOffset = 0;
} else {
columnOffset++;
}
}
return {
lineOffset,
columnOffset,
};
}
/**
* @fileoverview Rule to disallow `!important` flags.
* @author thecalamiity
* @author Yann Bertrand
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @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
//-----------------------------------------------------------------------------
/** @type {NoImportantRuleDefinition} */
var rule4 = {
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) {
return {
Declaration(node) {
if (node.important) {
const declarationText = context.sourceCode.getText(node);
const textWithoutComments = declarationText.replace(
commentPattern,
match => match.replace(/[^\n]/gu, " "),
);
const importantMatch =
importantPattern.exec(textWithoutComments);
const {
lineOffset: startLineOffset,
columnOffset: startColumnOffset,
} = findOffsets(declarationText, importantMatch.index);
const {
lineOffset: endLineOffset,
columnOffset: endColumnOffset,
} = findOffsets(
declarationText,
importantMatch.index + importantMatch[0].length,
);
const nodeStartLine = node.loc.start.line;
const nodeStartColumn = node.loc.start.column;
const startLine = nodeStartLine + startLineOffset;
const endLine = nodeStartLine + endLineOffset;
const startColumn =
(startLine === nodeStartLine ? nodeStartColumn : 1) +
startColumnOffset;
const endColumn =
(endLine === nodeStartLine ? nodeStartColumn : 1) +
endColumnOffset;
context.report({
loc: {
start: {
line: startLine,
column: startColumn,
},
end: {
line: endLine,
column: endColumn,
},
},
messageId: "unexpectedImportant",
suggest: [
{
messageId: "removeImportant",
fix(fixer) {
const importantStart = importantMatch.index;
const importantEnd =
importantStart +
importantMatch[0].length;
// Find any trailing whitespace before the !important
const valuePart = declarationText.slice(
0,
importantStart,
);
const whitespaceEnd = valuePart.search(
trailingWhitespacePattern,
);
const start =
node.loc.start.offset + whitespaceEnd;
const end =
node.loc.start.offset + importantEnd;
return fixer.removeRange([start, end]);
},
},
],
});
}
},
};
},
};
/**
* @fileoverview Rule to enforce correct placement of at-rules.
* @author thecalamiity
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @typedef {"invalidCharsetPlacement" | "invalidImportPlacement" | "invalidNamespacePlacement"} NoInvalidAtRulePlacementMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoInvalidAtRulePlacementMessageIds }>} NoInvalidAtRulePlacementRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
/** @type {NoInvalidAtRulePlacementRuleDefinition} */
var rule5 = {
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;
},
};
},
};
/**
* @fileoverview Rule to prevent the use of unknown at-rules in CSS.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @typedef {"unknownAtRule" | "invalidPrelude" | "unknownDescriptor" | "invalidDescriptor" | "invalidExtraPrelude" | "missingPrelude" | "invalidCharsetSyntax"} NoInvalidAtRulesMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoInvalidAtRulesMessageIds }>} NoInvalidAtRulesRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* 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
//-----------------------------------------------------------------------------
/** @type {NoInvalidAtRulesRuleDefinition} */
var rule6 = {
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.
*/
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))
);
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,
},
});
}
},
};
},
};
/**
* @fileoverview Rule to prevent invalid named grid areas in CSS grid templates.
* @author xbinaryx
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @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
//-----------------------------------------------------------------------------
/** @type {NoInvalidNamedGridAreasRuleDefinition} */
var rule7 = {
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,
},
});
});
}
},
};
},
};
/**
* @fileoverview Rule to prevent invalid properties in CSS.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//--------------