UNPKG

@eslint/css

Version:

CSS linting plugin for ESLint

1,980 lines (1,746 loc) 131 kB
/** * @import { CssNode, CssNodePlain, Comment, Lexer, StyleSheetPlain, SyntaxConfig, SyntaxMatchError, ValuePlain, FunctionNodePlain, CssLocationRange, AtrulePlain, 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() { const problems = []; 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() { const problems = []; 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; } } /** * @filedescription 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, }); } } /** * @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 noEmptyBlocks = { 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 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 noDuplicateImports = { meta: { type: "problem", 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 imports = new Set(); return { "Atrule[name=import]"(node) { const url = node.prelude.children[0].value; if (imports.has(url)) { context.report({ loc: node.loc, messageId: "duplicateImport", data: { url }, }); } else { imports.add(url); } }, }; }, }; /** * @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"; } /** * 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"} NoImportantMessageIds * @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoImportantMessageIds }>} NoImportantRuleDefinition */ //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- /** @type {NoImportantRuleDefinition} */ var noImportant = { meta: { type: "problem", 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.", }, }, create(context) { const importantPattern = /!(\s|\/\*.*?\*\/)*important/iu; return { Declaration(node) { if (node.important) { const declarationText = context.sourceCode.getText(node); const importantMatch = importantPattern.exec(declarationText); 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", }); } }, }; }, }; /** * @fileoverview Rule to prevent invalid properties in CSS. * @author Nicholas C. Zakas */ //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** * @typedef {"invalidPropertyValue" | "unknownProperty" | "unknownVar"} NoInvalidPropertiesMessageIds * @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoInvalidPropertiesMessageIds }>} NoInvalidPropertiesRuleDefinition */ //----------------------------------------------------------------------------- // Helpers //----------------------------------------------------------------------------- /** * Replaces all instances of a regex pattern with a replacement and tracks the offsets * @param {string} text The text to perform replacements on * @param {string} varName The regex pattern string to search for * @param {string} replaceValue The string to replace with * @returns {{text: string, offsets: Array<number>}} The updated text and array of offsets * where replacements occurred */ function replaceWithOffsets(text, varName, replaceValue) { const offsets = []; let result = ""; let lastIndex = 0; const regex = new RegExp(`var\\(\\s*${varName}\\s*\\)`, "gu"); let match; while ((match = regex.exec(text)) !== null) { result += text.slice(lastIndex, match.index); /* * We need the offset of the replacement after other replacements have * been made, so we push the current length of the result before appending * the replacement value. */ offsets.push(result.length); result += replaceValue; lastIndex = match.index + match[0].length; } result += text.slice(lastIndex); return { text: result, offsets }; } //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- /** @type {NoInvalidPropertiesRuleDefinition} */ var noInvalidProperties = { meta: { type: "problem", docs: { description: "Disallow invalid properties", recommended: true, url: "https://github.com/eslint/css/blob/main/docs/rules/no-invalid-properties.md", }, 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(); /** * We need to track this as a stack because we can have nested * rules that use the `var()` function, and we need to * ensure that we validate the innermost rule first. * @type {Array<Map<string,FunctionNodePlain>>} */ const replacements = []; return { "Rule > Block > Declaration"() { replacements.push(new Map()); }, "Function[name=var]"(node) { const map = replacements.at(-1); if (!map) { return; } /* * Store the custom property name and the function node * so can use these to validate the value later. */ const name = node.children[0].name; map.set(name, node); }, "Rule > Block > Declaration:exit"(node) { 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; } const varsFound = replacements.pop(); /** @type {Map<number,CssLocationRange>} */ const varsFoundLocs = new Map(); const usingVars = varsFound?.size > 0; let value = node.value; if (usingVars) { // need to use a text version of the value here value = sourceCode.getText(node.value); let offsets; // replace any custom properties with their values for (const [name, func] of varsFound) { const varValue = vars.get(name); if (varValue) { ({ text: value, offsets } = replaceWithOffsets( value, name, sourceCode.getText(varValue).trim(), )); /* * Store the offsets of the replacements so we can * report the correct location of any validation error. */ offsets.forEach(offset => { varsFoundLocs.set(offset, func.loc); }); } else { context.report({ loc: func.children[0].loc, messageId: "unknownVar", data: { var: name, }, }); return; } } } const { error } = lexer.matchProperty(node.property, value); if (error) { // validation failure if (isSyntaxMatchError(error)) { 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: usingVars ? (varsFoundLocs.get(error.mismatchOffset) ?? 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: usingVars ? value.slice( error.mismatchOffset, error.mismatchOffset + error.mismatchLength, ) : error.css, expected: error.syntax, }, }); return; } // 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, }, }); } }, }; }, }; /** * @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"} NoInvalidAtRulesMessageIds * @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoInvalidAtRulesMessageIds }>} NoInvalidAtRulesRuleDefinition */ //----------------------------------------------------------------------------- // Helpers //----------------------------------------------------------------------------- /** * 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 noInvalidAtRules = { meta: { type: "problem", 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.", }, }, create(context) { const { sourceCode } = context; const lexer = sourceCode.lexer; return { Atrule(node) { // 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, }, }); } }, }; }, }; //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** * @typedef {"notLogicalProperty" | "notLogicalValue" | "notLogicalUnit"} 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 //----------------------------------------------------------------------------- /** @type {PreferLogicalPropertiesRuleDefinition} */ var preferLogicalProperties = { meta: { type: "problem", fixable: "code", 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: { type: "string", }, }, allowUnits: { type: "array", items: { type: "string", }, }, }, 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}}'.", }, }, create(context) { return { Declaration(node) { const parent = context.sourceCode.getParent(node); if (parent.type === "SupportsDeclaration") { return; } const allowProperties = context.options[0].allowProperties; if ( propertiesReplacements.get(node.property) && !allowProperties.includes(node.property) ) { context.report({ loc: node.loc, messageId: "notLogicalProperty", data: { property: node.property, replacement: propertiesReplacements.get( node.property, ), }, }); } if ( propertyValuesReplacements.get(node.property) && node.value.type === "Value" && node.value.children[0].type === "Identifier" ) { const nodeValue = node.value.children[0].name; if ( propertyValuesReplacements.get(node.property)[nodeValue] ) { const replacement = propertyValuesReplacements.get( node.property, )[nodeValue]; if (replacement) { context.report({ loc: node.value.children[0].loc, messageId: "notLogicalValue", data: { value: nodeValue, replacement, }, }); } } } }, Dimension(node) { const allowUnits = context.options[0].allowUnits; if ( unitReplacements.get(node.unit) && !allowUnits.includes(node.unit) ) { context.report({ loc: node.loc, messageId: "notLogicalUnit", data: { unit: node.unit, replacement: unitReplacements.get(node.unit), }, }); } }, }; }, }; /** * @fileoverview Enforce the use of relative units for font size. * @author Tanuj Kanti */ //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** * @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 fontSizeIdentifiers = new Set([ "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", "xxx-large", "smaller", "larger", "math", "inherit", "initial", "revert", "revert-layer", "unset", ]); //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- /** @type {RelativeFontUnitsRuleDefinition} */ var relativeFontUnits$1 = { 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, }, }, }, }, ], 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)) || value.type === "Identifier" || (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" && fontSizeIdentifiers.has(child.name), ); const percentageNode = value.children.find( child => child.type === "Percentage", ); 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, ), 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(", "), }, }); } } } }, }; }, }; /** * @fileoverview Rule to require layers in CSS. * @author Nicholas C. Zakas */ //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** * @typedef {"missingLayer" | "missingLayerName" | "missingImportLayer" | "layerNameMismatch"} UseLayersMessageIds * @typedef {[{ * allowUnnamedLayers?: boolean, * requireImportLayers?: boolean, * layerNamePattern?: string * }]} UseLayersOptions * @typedef {CSSRuleDefinition<{ RuleOptions: UseLayersOptions, MessageIds: UseLayersMessageIds }>} UseLayersRuleDefinition */ //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- /** @type {UseLayersRuleDefinition} */ var useLayers = { 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]"(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]"(node) { layerDepth++; if (!options.allowUnnamedLayers && !node.prelude) { context.report({ loc: node.loc, messageId: "missingLayerName", }); } }, "Atrule[name=layer]:exit"() { layerDepth--; }, Rule(node) { if (layerDepth > 0) { return; } context.report({ loc: node.loc, messageId: "missingLayer", }); }, }; }, }; /** * @fileoverview CSS features extracted from the web-features package. * @author tools/generate-baseline.js * * THIS FILE IS AUTOGENERATED. DO NOT MODIFY DIRECTLY. */ const BASELINE_HIGH = 10; const BASELINE_LOW = 5; const properties = new Map([ ["accent-color", "0:"], ["alignment-baseline", "0:"], ["all", "10:2020"], ["anchor-name", "0:"], ["anchor-scope", "0:"], ["position-anchor", "0:"], ["position-area", "0:"], ["position-try", "0:"], ["position-try-fallbacks", "0:"], ["position-try-order", "0:"], ["position-visibility", "0:"], ["animation-composition", "5:2023"], ["animation", "10:2015"], ["animation-delay", "10:2015"], ["animation-direction", "10:2015"], ["animation-duration", "10:2015"], ["animation-fill-mode", "10:2015"], ["animation-iteration-count", "10:2015"], ["animation-name", "10:2015"], ["animation-play-state", "10:2015"], ["animation-timing-function", "10:2015"], ["appearance", "10:2022"], ["aspect-ratio", "10:2021"], ["backdrop-filter", "5:2024"], ["background", "10:2015"], ["background-attachment", "10:2015"], ["background-blend-mode", "10:2020"], ["background-clip", "10:2015"], ["background-color", "10:2015"], ["background-image", "10:2015"], ["background-origin", "10:2015"], ["background-position", "10:2015"], ["background-position-x", "10:2016"], ["background-position-y", "10:2016"], ["background-repeat", "10:2015"], ["background-size", "10:2015"], ["baseline-shift", "0:"], ["baseline-source", "0:"], ["border-image", "10:2015"], ["border-image-outset", "10:2015"], ["border-image-repeat", "10:2016"], ["border-image-slice", "10:2015"], ["border-image-source", "10:2015"], ["border-image-width", "10:2015"], ["border-bottom-left-radius", "10:2015"], ["border-bottom-right-radius", "10:2015"], ["border-radius", "10:2015"], ["border-top-left-radius", "10:2015"], ["border-top-right-radius", "10:2015"], ["border", "10:2015"], ["border-bottom", "10:2015"], ["border-bottom-color", "10:2015"], ["border-bottom-style", "10:2015"], ["border-bottom-width", "10:2015"], ["border-color", "10:2015"], ["border-left", "10:2015"], ["border-left-color", "10:2015"], ["border-left-style", "10:2015"], ["border-left-width", "10:2015"], ["border-right", "10:2015"], ["border-right-color", "10:2015"], ["border-right-style", "10:2015"], ["border-right-width", "10:2015"], ["border-style", "10:2015"], ["border-top", "10:2015"], ["border-top-color", "10:2015"], ["border-top-style", "10:2015"], ["border-top-width", "10:2015"], ["border-width", "10:2015"], ["box-decoration-break", "0:"], ["box-shadow", "10:2015"], ["box-sizing", "10:2015"], ["caret-color", "10:2020"], ["clip", "0:"], ["clip-path", "10:2020"], ["color", "10:2015"], ["color-adjust", "0:"], ["color-scheme", "10:2022"], ["column-fill", "10:2017"], ["column-span", "10:2020"], ["contain", "10:2022"], ["contain-intrinsic-block-size", "5:2023"], ["contain-intrinsic-height", "5:2023"], ["contain-intrinsic-inline-size", "5:2023"], ["contain-intrinsic-size", "5:2023"], ["contain-intrinsic-width", "5:2023"], ["container", "5:2023"], ["container-name", "5:2023"], ["container-type", "5:2023"], ["content", "10:2015"], ["content-visibility", "5:2024"], ["counter-set", "5:2023"], ["counter-increment", "10:2015"], ["counter-reset", "10:2015"], ["custom-property", "10:2017"], ["display", "10:2015"], ["dominant-baseline", "10:2020"], ["field-sizing", "0:"], ["filter", "10:2016"], ["align-content", "10:2015"], ["align-items", "10:2015"], ["align-self", "1