UNPKG

@eslint/css

Version:

CSS linting plugin for ESLint

2,005 lines (1,740 loc) 164 kB
/** * @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 //--------------