/** * @fileoverview Rule to prevent the use of unknown at-rules in CSS. * @author Nicholas C. Zakas */ //----------------------------------------------------------------------------- // Imports //----------------------------------------------------------------------------- import { isSyntaxMatchError } from "../util.js"; //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** * @import { AtrulePlain } from "@eslint/css-tree" * @import { CSSRuleDefinition } from "../types.js" * @typedef {"unknownAtRule" | "invalidPrelude" | "unknownDescriptor" | "invalidDescriptor" | "invalidExtraPrelude" | "missingPrelude" | "invalidCharsetSyntax"} NoInvalidAtRulesMessageIds * @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoInvalidAtRulesMessageIds }>} NoInvalidAtRulesRuleDefinition */ //----------------------------------------------------------------------------- // Helpers //----------------------------------------------------------------------------- /** * Set of at-rules that can be nested inside style rules. * @see https://www.w3.org/TR/css-nesting-1/#conditionals */ const nestableAtRules = new Set([ "media", "supports", "layer", "scope", "container", "starting-style", ]); /** * A valid `@charset` rule must: * - Enclose the encoding name in double quotes * - Include exactly one space character after `@charset` * - End immediately with a semicolon */ const charsetPattern = /^@charset "[^"]+";$/u; const charsetEncodingPattern = /^['"]?([^"';]+)['"]?/u; /** * Extracts metadata from an error object. * @param {SyntaxError} error The error object to extract metadata from. * @returns {Object} The metadata extracted from the error. */ function extractMetaDataFromError(error) { const message = error.message; const atRuleName = /`@(.*)`/u.exec(message)[1]; let messageId = "unknownAtRule"; if (message.endsWith("prelude")) { messageId = message.includes("should not") ? "invalidExtraPrelude" : "missingPrelude"; } return { messageId, data: { name: atRuleName, }, }; } //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- export default /** @satisfies {NoInvalidAtRulesRuleDefinition} */ ({ meta: { type: "problem", fixable: "code", docs: { description: "Disallow invalid at-rules", recommended: true, url: "https://github.com/eslint/css/blob/main/docs/rules/no-invalid-at-rules.md", }, messages: { unknownAtRule: "Unknown at-rule '@{{name}}' found.", invalidPrelude: "Invalid prelude '{{prelude}}' found for at-rule '@{{name}}'. Expected '{{expected}}'.", unknownDescriptor: "Unknown descriptor '{{descriptor}}' found for at-rule '@{{name}}'.", invalidDescriptor: "Invalid value '{{value}}' for descriptor '{{descriptor}}' found for at-rule '@{{name}}'. Expected {{expected}}.", invalidExtraPrelude: "At-rule '@{{name}}' should not contain a prelude.", missingPrelude: "At-rule '@{{name}}' should contain a prelude.", invalidCharsetSyntax: "Invalid @charset syntax. Expected '@charset \"{{encoding}}\";'.", }, }, create(context) { const { sourceCode } = context; const lexer = sourceCode.lexer; /** * Validates a `@charset` rule for correct syntax: * - Verifies the rule name is exactly "charset" (case-sensitive) * - Ensures the rule has a prelude * - Validates the prelude matches the expected pattern * @param {AtrulePlain} node The node representing the rule. * @returns {void} */ function validateCharsetRule(node) { const { name, prelude, loc } = node; const charsetNameLoc = { start: loc.start, end: { line: loc.start.line, column: loc.start.column + name.length + 1, }, }; if (name !== "charset") { context.report({ loc: charsetNameLoc, messageId: "unknownAtRule", data: { name, }, fix(fixer) { return fixer.replaceTextRange([ loc.start.offset, loc.start.offset + name.length + 1, ], "@charset"); }, }); return; } if (!prelude) { context.report({ loc: charsetNameLoc, messageId: "missingPrelude", data: { name, }, }); return; } const nodeText = sourceCode.getText(node); const preludeText = sourceCode.getText(prelude); const encoding = preludeText .match(charsetEncodingPattern)?.[1] ?.trim(); if (!encoding) { context.report({ loc: prelude.loc, messageId: "invalidCharsetSyntax", data: { encoding: "" }, }); return; } if (!charsetPattern.test(nodeText)) { context.report({ loc: prelude.loc, messageId: "invalidCharsetSyntax", data: { encoding }, fix(fixer) { return fixer.replaceText(node, `@charset "${encoding}";`); }, }); } } return { Atrule(node) { if (node.name.toLowerCase() === "charset") { validateCharsetRule(node); return; } // checks both name and prelude const { error } = lexer.matchAtrulePrelude(node.name, node.prelude); if (error) { if (isSyntaxMatchError(error)) { context.report({ loc: error.loc, messageId: "invalidPrelude", data: { name: node.name, prelude: error.css, expected: error.syntax, }, }); return; } const loc = node.loc; context.report({ loc: { start: loc.start, end: { line: loc.start.line, // add 1 to account for the @ symbol column: loc.start.column + node.name.length + 1, }, }, ...extractMetaDataFromError(error), }); } }, "AtRule > Block > Declaration"(node) { // skip custom descriptors if (node.property.startsWith("--")) { return; } // get at rule node const atRule = /** @type {AtrulePlain} */ (sourceCode.getParent(sourceCode.getParent(node))); if (nestableAtRules.has(atRule.name.toLowerCase())) { return; } const { error } = lexer.matchAtruleDescriptor(atRule.name, node.property, node.value); if (error) { if (isSyntaxMatchError(error)) { context.report({ loc: error.loc, messageId: "invalidDescriptor", data: { name: atRule.name, descriptor: node.property, value: error.css, expected: error.syntax, }, }); return; } const loc = node.loc; context.report({ loc: { start: loc.start, end: { line: loc.start.line, column: loc.start.column + node.property.length, }, }, messageId: "unknownDescriptor", data: { name: atRule.name, descriptor: node.property, }, }); } }, }; }, });