From 391dbb62cd441290f73fd0e8f113ef5720e16bfe Mon Sep 17 00:00:00 2001 From: Pixel Date: Wed, 26 Aug 2026 22:50:45 +0300 Subject: [PATCH] fix: support at-rules nested in `@page` and `@font-feature-values` --- src/rules/no-invalid-at-rules.js | 130 +++++++++++++++--- tests/rules/no-invalid-at-rules.test.js | 175 ++++++++++++++++++++++++ 2 files changed, 285 insertions(+), 20 deletions(-) diff --git a/src/rules/no-invalid-at-rules.js b/src/rules/no-invalid-at-rules.js index 4015d82c..32c9b73b 100644 --- a/src/rules/no-invalid-at-rules.js +++ b/src/rules/no-invalid-at-rules.js @@ -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 */ @@ -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 @@ -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 //----------------------------------------------------------------------------- @@ -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({ @@ -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, @@ -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), }); } @@ -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; } diff --git a/tests/rules/no-invalid-at-rules.test.js b/tests/rules/no-invalid-at-rules.test.js index 2a87fbd6..7aca7ef7 100644 --- a/tests/rules/no-invalid-at-rules.test.js +++ b/tests/rules/no-invalid-at-rules.test.js @@ -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: [ { @@ -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: "", + }, + }, + }, + }, + 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, + }, + ], + }, ], });