Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 110 additions & 20 deletions src/rules/no-invalid-at-rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import { isSyntaxMatchError } from "../util.js";
//-----------------------------------------------------------------------------

/**
* @import { SourceLocation } from "@eslint/core"
* @import { AtrulePlain } from "@eslint/css-tree"
* @import { CSSRuleDefinition } from "../types.js"
* @import { CSSSourceCode } from "../languages/css-source-code.js"
* @typedef {"unknownAtRule" | "invalidPrelude" | "unknownDescriptor" | "invalidDescriptor" | "invalidExtraPrelude" | "missingPrelude" | "invalidCharsetSyntax"} NoInvalidAtRulesMessageIds
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoInvalidAtRulesMessageIds }>} NoInvalidAtRulesRuleDefinition
*/
Expand All @@ -37,6 +39,42 @@ const nestableAtRules = new Set([
"starting-style",
]);

/**
* Map of at-rules that are only valid directly inside of another at-rule. Each
* key is an at-rule name and each value is the name of the at-rule it must be
* nested in.
* @see https://drafts.csswg.org/css-page-3/#margin-at-rules
* @see https://drafts.csswg.org/css-fonts-4/#font-feature-values-syntax
*/
const requiredParentAtRules = new Map([
// margin at-rules
["top-left-corner", "page"],
["top-left", "page"],
["top-center", "page"],
["top-right", "page"],
["top-right-corner", "page"],
["bottom-left-corner", "page"],
["bottom-left", "page"],
["bottom-center", "page"],
["bottom-right", "page"],
["bottom-right-corner", "page"],
["left-top", "page"],
["left-middle", "page"],
["left-bottom", "page"],
["right-top", "page"],
["right-middle", "page"],
["right-bottom", "page"],

// feature value blocks
["stylistic", "font-feature-values"],
["historical-forms", "font-feature-values"],
["styleset", "font-feature-values"],
["character-variant", "font-feature-values"],
["swash", "font-feature-values"],
["ornaments", "font-feature-values"],
["annotation", "font-feature-values"],
]);

/**
* A valid `@charset` rule must:
* - Enclose the encoding name in double quotes
Expand Down Expand Up @@ -70,6 +108,45 @@ function extractMetaDataFromError(error) {
};
}

/**
* Calculates the location of an at-rule's name, including the `@` symbol.
* @param {AtrulePlain} node The at-rule to calculate the location for.
* @returns {SourceLocation} The location of the at-rule's name.
*/
function getAtRuleNameLoc(node) {
const { start } = node.loc;

return {
start,
end: {
line: start.line,
column: start.column + node.name.length + 1,
},
};
}

/**
* Determines if an at-rule that must be nested inside of another at-rule, such
* as `@top-left` inside of `@page`, is nested inside of that at-rule.
* @param {CSSSourceCode} sourceCode The source code object.
* @param {AtrulePlain} node The at-rule to check.
* @returns {boolean} `true` if the at-rule requires a parent at-rule and is
* nested inside of it, `false` otherwise.
*/
function isInRequiredParentAtRule(sourceCode, node) {
const parentName = requiredParentAtRules.get(node.name.toLowerCase());

if (!parentName) {
return false;
}

const parent = sourceCode.getParent(sourceCode.getParent(node));

return (
parent?.type === "Atrule" && parent.name.toLowerCase() === parentName
);
}

//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
Expand Down Expand Up @@ -119,13 +196,7 @@ export default /** @satisfies {NoInvalidAtRulesRuleDefinition} */ ({
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,
},
};
const charsetNameLoc = getAtRuleNameLoc(node);

if (name !== "charset") {
context.report({
Expand Down Expand Up @@ -190,11 +261,31 @@ export default /** @satisfies {NoInvalidAtRulesRuleDefinition} */ ({

return {
Atrule(node) {
if (node.name.toLowerCase() === "charset") {
const name = node.name.toLowerCase();

if (name === "charset") {
validateCharsetRule(node);
return;
}

if (
!lexer.getAtrule(name) &&
isInRequiredParentAtRule(sourceCode, node)
) {
// none of these at-rules accept a prelude
if (node.prelude) {
context.report({
loc: getAtRuleNameLoc(node),
messageId: "invalidExtraPrelude",
data: {
name: node.name,
},
});
}

return;
}

// checks both name and prelude
const { error } = lexer.matchAtrulePrelude(
node.name,
Expand All @@ -215,18 +306,8 @@ export default /** @satisfies {NoInvalidAtRulesRuleDefinition} */ ({
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,
},
},
loc: getAtRuleNameLoc(node),
...extractMetaDataFromError(error),
});
}
Expand All @@ -243,7 +324,16 @@ export default /** @satisfies {NoInvalidAtRulesRuleDefinition} */ ({
sourceCode.getParent(sourceCode.getParent(node))
);

if (nestableAtRules.has(atRule.name.toLowerCase())) {
const atRuleName = atRule.name.toLowerCase();

if (nestableAtRules.has(atRuleName)) {
return;
}

if (
!lexer.getAtrule(atRuleName) &&
isInRequiredParentAtRule(sourceCode, atRule)
) {
return;
}

Expand Down
175 changes: 175 additions & 0 deletions tests/rules/no-invalid-at-rules.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,41 @@ ruleTester.run("no-invalid-at-rules", rule, {
},
},
},
"@page { @top-left { content: 'x'; } }",
"@page { size: A4; @top-center { content: counter(page); } }",
"@page :first { @top-right { content: 'x'; } }",
"@media print { @page { @bottom-left { content: 'x'; } } }",
"@page { @top-left { --custom: red; } }",
"@PAGE { @TOP-LEFT { content: 'x'; } }",
`@page {
@top-left-corner { content: 'x'; }
@top-left { content: 'x'; }
@top-center { content: 'x'; }
@top-right { content: 'x'; }
@top-right-corner { content: 'x'; }
@bottom-left-corner { content: 'x'; }
@bottom-left { content: 'x'; }
@bottom-center { content: 'x'; }
@bottom-right { content: 'x'; }
@bottom-right-corner { content: 'x'; }
@left-top { content: 'x'; }
@left-middle { content: 'x'; }
@left-bottom { content: 'x'; }
@right-top { content: 'x'; }
@right-middle { content: 'x'; }
@right-bottom { content: 'x'; }
}`,
"@font-feature-values Font One { @styleset { nice-style: 12; } }",
"@font-feature-values Font One, Font Two { @character-variant { alt-a: 2 1; } }",
`@font-feature-values Font One {
@stylistic { cursive: 1; }
@historical-forms { hist: 1; }
@styleset { double-W: 14; }
@character-variant { alt-a: 2; }
@swash { swishy: 1; }
@ornaments { bullets: 1; }
@annotation { circled: 1; }
}`,
],
invalid: [
{
Expand Down Expand Up @@ -533,5 +568,145 @@ ruleTester.run("no-invalid-at-rules", rule, {
},
],
},
{
code: "@top-left { content: 'x'; }",
errors: [
{
messageId: "unknownAtRule",
data: { name: "top-left" },
line: 1,
column: 1,
endLine: 1,
endColumn: 10,
},
{
messageId: "unknownDescriptor",
data: { name: "top-left", descriptor: "content" },
line: 1,
column: 13,
endLine: 1,
endColumn: 20,
},
],
},
{
code: ".foo { @top-left { content: 'x'; } }",
errors: [
{
messageId: "unknownAtRule",
data: { name: "top-left" },
line: 1,
column: 8,
endLine: 1,
endColumn: 17,
},
{
messageId: "unknownDescriptor",
data: { name: "top-left", descriptor: "content" },
line: 1,
column: 20,
endLine: 1,
endColumn: 27,
},
],
},
{
code: "@page { @swash { swishy: 1; } }",
errors: [
{
messageId: "unknownAtRule",
data: { name: "swash" },
line: 1,
column: 9,
endLine: 1,
endColumn: 15,
},
{
messageId: "unknownDescriptor",
data: { name: "swash", descriptor: "swishy" },
line: 1,
column: 18,
endLine: 1,
endColumn: 24,
},
],
},
{
code: "@font-feature-values Font One { @top-left { content: 'x'; } }",
errors: [
{
messageId: "unknownAtRule",
data: { name: "top-left" },
line: 1,
column: 33,
endLine: 1,
endColumn: 42,
},
{
messageId: "unknownDescriptor",
data: { name: "top-left", descriptor: "content" },
line: 1,
column: 45,
endLine: 1,
endColumn: 52,
},
],
},
{
code: "@page { @top-left foo { content: 'x'; } }",
errors: [
{
messageId: "invalidExtraPrelude",
data: { name: "top-left" },
line: 1,
column: 9,
endLine: 1,
endColumn: 18,
},
],
},
{
code: "@font-feature-values Font One { @styleset foo { nice-style: 12; } }",
errors: [
{
messageId: "invalidExtraPrelude",
data: { name: "styleset" },
line: 1,
column: 33,
endLine: 1,
endColumn: 42,
},
],
},
{
code: "@page { @top-left { content: 'x'; } }",
languageOptions: {
customSyntax: {
atrules: {
"top-left": {
prelude: "<url>",
},
},
},
},
errors: [
{
messageId: "missingPrelude",
data: { name: "top-left" },
line: 1,
column: 9,
endLine: 1,
endColumn: 18,
},
{
messageId: "unknownDescriptor",
data: { name: "top-left", descriptor: "content" },
line: 1,
column: 21,
endLine: 1,
endColumn: 28,
},
],
},
],
});