diff --git a/docs/rules/no-invalid-at-rules.md b/docs/rules/no-invalid-at-rules.md index 5df1d663..f2e7fd7a 100644 --- a/docs/rules/no-invalid-at-rules.md +++ b/docs/rules/no-invalid-at-rules.md @@ -13,16 +13,15 @@ CSS contains a number of at-rules, each beginning with a `@`, that perform vario - `@supports` - `@namespace` - `@page` -- `@charset` It's important to use a known at-rule because unknown at-rules cause the browser to ignore the entire block, including any rules contained within. For example: ```css /* typo */ -@charse "UTF-8"; +@impor "foo.css"; ``` -Here, the `@charset` at-rule is incorrectly spelled as `@charse`, which means that it will be ignored. +Here, the `@import` at-rule is incorrectly spelled as `@impor`, which means that it will be ignored. Each at-rule also has a defined prelude (which may be empty) and potentially one or more descriptors. For example: @@ -73,6 +72,29 @@ Examples of **incorrect** code: } ``` +Note on `@charset`: Although it begins with an `@` symbol, it is not an at-rule. It is a specific byte sequence of the following form: + +```css +@charset ""; +``` + +where `` is a [``](https://developer.mozilla.org/en-US/docs/Web/CSS/string) denoting the character encoding to be used. It must be the name of a web-safe character encoding defined in the [IANA-registry](https://www.iana.org/assignments/character-sets/character-sets.xhtml), and must be double-quoted, following exactly one space character (U+0020) after `@charset`, and immediately terminated with a semicolon. + +Examples of **incorrect** code: + + +```css +@charset 'iso-8859-15'; /* Wrong quotes used */ +@charset "UTF-8"; /* More than one space */ +@charset UTF-8; /* The charset is a CSS and requires double-quotes */ +``` + +Examples of **correct** code: + +```css +@charset "UTF-8"; +``` + ## When Not to Use It If you are purposely using at-rules that aren't part of the CSS specification, then you can safely disable this rule. diff --git a/src/rules/no-invalid-at-rules.js b/src/rules/no-invalid-at-rules.js index adcf5142..c10843bb 100644 --- a/src/rules/no-invalid-at-rules.js +++ b/src/rules/no-invalid-at-rules.js @@ -16,7 +16,7 @@ import { isSyntaxMatchError } from "../util.js"; /** * @import { AtrulePlain } from "@eslint/css-tree" * @import { CSSRuleDefinition } from "../types.js" - * @typedef {"unknownAtRule" | "invalidPrelude" | "unknownDescriptor" | "invalidDescriptor" | "invalidExtraPrelude" | "missingPrelude"} NoInvalidAtRulesMessageIds + * @typedef {"unknownAtRule" | "invalidPrelude" | "unknownDescriptor" | "invalidDescriptor" | "invalidExtraPrelude" | "missingPrelude" | "invalidCharsetSyntax"} NoInvalidAtRulesMessageIds * @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoInvalidAtRulesMessageIds }>} NoInvalidAtRulesRuleDefinition */ @@ -24,6 +24,15 @@ import { isSyntaxMatchError } from "../util.js"; // Helpers //----------------------------------------------------------------------------- +/** + * 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. @@ -57,6 +66,8 @@ export default { meta: { type: "problem", + fixable: "code", + docs: { description: "Disallow invalid at-rules", recommended: true, @@ -74,6 +85,8 @@ export default { invalidExtraPrelude: "At-rule '@{{name}}' should not contain a prelude.", missingPrelude: "At-rule '@{{name}}' should contain a prelude.", + invalidCharsetSyntax: + "Invalid @charset syntax. Expected '@charset \"{{encoding}}\";'.", }, }, @@ -81,8 +94,92 @@ export default { 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. + */ + 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, diff --git a/tests/rules/no-invalid-at-rules.test.js b/tests/rules/no-invalid-at-rules.test.js index fd6ec434..68a2f0d5 100644 --- a/tests/rules/no-invalid-at-rules.test.js +++ b/tests/rules/no-invalid-at-rules.test.js @@ -30,6 +30,8 @@ ruleTester.run("no-invalid-at-rules", rule, { "@keyframes slidein { from { transform: translateX(0%); } to { transform: translateX(100%); } }", "@supports (display: grid) { .grid-container { display: grid; } }", "@namespace url(http://www.w3.org/1999/xhtml);", + '@charset "UTF-8";', + '@charset "UTF-8"; @import url("foo.css");', "@media screen and (max-width: 600px) { body { font-size: 12px; } }", { code: "@foobar url(foo.css) { body { font-size: 12px } }", @@ -96,15 +98,15 @@ ruleTester.run("no-invalid-at-rules", rule, { ], }, { - code: '@charse "test";', + code: '@impor "foo.css";', errors: [ { messageId: "unknownAtRule", - data: { name: "charse" }, + data: { name: "impor" }, line: 1, column: 1, endLine: 1, - endColumn: 8, + endColumn: 7, }, ], }, @@ -293,5 +295,218 @@ ruleTester.run("no-invalid-at-rules", rule, { }, ], }, + { + code: '@CHARSET "UTF-8";', + output: '@charset "UTF-8";', + errors: [ + { + messageId: "unknownAtRule", + data: { name: "CHARSET" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 9, + }, + ], + }, + { + code: '@CharSet "UTF-8";', + output: '@charset "UTF-8";', + errors: [ + { + messageId: "unknownAtRule", + data: { name: "CharSet" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 9, + }, + ], + }, + { + code: "@charset", + errors: [ + { + messageId: "missingPrelude", + data: { name: "charset" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 9, + }, + ], + }, + { + code: "@charset;", + errors: [ + { + messageId: "missingPrelude", + data: { name: "charset" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 9, + }, + ], + }, + { + code: "@charset ;", + errors: [ + { + messageId: "missingPrelude", + data: { name: "charset" }, + line: 1, + column: 1, + endLine: 1, + endColumn: 9, + }, + ], + }, + { + code: '@charset "";', + errors: [ + { + messageId: "invalidCharsetSyntax", + data: { encoding: "" }, + line: 1, + column: 10, + endLine: 1, + endColumn: 12, + }, + ], + }, + { + code: '@charset " ";', + errors: [ + { + messageId: "invalidCharsetSyntax", + data: { encoding: "" }, + line: 1, + column: 10, + endLine: 1, + endColumn: 14, + }, + ], + }, + { + code: "@charset 'UTF-8';", + output: '@charset "UTF-8";', + errors: [ + { + messageId: "invalidCharsetSyntax", + data: { + encoding: "UTF-8", + }, + line: 1, + column: 10, + endLine: 1, + endColumn: 17, + }, + ], + }, + { + code: "@charset UTF-8;", + output: '@charset "UTF-8";', + errors: [ + { + messageId: "invalidCharsetSyntax", + data: { + encoding: "UTF-8", + }, + line: 1, + column: 10, + endLine: 1, + endColumn: 15, + }, + ], + }, + { + code: '@charset"UTF-8";', + output: '@charset "UTF-8";', + errors: [ + { + messageId: "invalidCharsetSyntax", + data: { + encoding: "UTF-8", + }, + line: 1, + column: 9, + endLine: 1, + endColumn: 16, + }, + ], + }, + { + code: '@charset "UTF-8";', + output: '@charset "UTF-8";', + errors: [ + { + messageId: "invalidCharsetSyntax", + data: { + encoding: "UTF-8", + }, + line: 1, + column: 11, + endLine: 1, + endColumn: 18, + }, + ], + }, + { + code: '@charset "UTF-8"', + output: '@charset "UTF-8";', + errors: [ + { + messageId: "invalidCharsetSyntax", + data: { + encoding: "UTF-8", + }, + line: 1, + column: 10, + endLine: 1, + endColumn: 17, + }, + ], + }, + { + code: '@charset "UTF-8" ;', + output: '@charset "UTF-8";', + errors: [ + { + messageId: "invalidCharsetSyntax", + data: { + encoding: "UTF-8", + }, + line: 1, + column: 10, + endLine: 1, + endColumn: 17, + }, + ], + }, + { + code: '@charset "UTF-8";\n@impor "foo.css";', + output: '@charset "UTF-8";\n@impor "foo.css";', + errors: [ + { + messageId: "invalidCharsetSyntax", + data: { + encoding: "UTF-8", + }, + line: 1, + column: 11, + endLine: 1, + endColumn: 18, + }, + { + messageId: "unknownAtRule", + data: { name: "impor" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 7, + }, + ], + }, ], });