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