Skip to content

Commit 4d3d140

Browse files
authored
fix: enforce strict syntax for @charset in no-invalid-at-rules (eslint#192)
* fix: enforce strict syntax for `@charset` in no-invalid-at-rules * fix incorrect code example * fix CI failure * fix CI failure * add autofix for @charset formatting issues * improve @charset error messaging * avoid autofixing missing `@charset` encoding
1 parent 9765135 commit 4d3d140

File tree

3 files changed

+341
-7
lines changed

3 files changed

+341
-7
lines changed

docs/rules/no-invalid-at-rules.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,15 @@ CSS contains a number of at-rules, each beginning with a `@`, that perform vario
1313
- `@supports`
1414
- `@namespace`
1515
- `@page`
16-
- `@charset`
1716

1817
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:
1918

2019
```css
2120
/* typo */
22-
@charse "UTF-8";
21+
@impor "foo.css";
2322
```
2423

25-
Here, the `@charset` at-rule is incorrectly spelled as `@charse`, which means that it will be ignored.
24+
Here, the `@import` at-rule is incorrectly spelled as `@impor`, which means that it will be ignored.
2625

2726
Each at-rule also has a defined prelude (which may be empty) and potentially one or more descriptors. For example:
2827

@@ -73,6 +72,29 @@ Examples of **incorrect** code:
7372
}
7473
```
7574

75+
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:
76+
77+
```css
78+
@charset "<charset>";
79+
```
80+
81+
where `<charset>` is a [`<string>`](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.
82+
83+
Examples of **incorrect** code:
84+
85+
<!-- prettier-ignore -->
86+
```css
87+
@charset 'iso-8859-15'; /* Wrong quotes used */
88+
@charset "UTF-8"; /* More than one space */
89+
@charset UTF-8; /* The charset is a CSS <string> and requires double-quotes */
90+
```
91+
92+
Examples of **correct** code:
93+
94+
```css
95+
@charset "UTF-8";
96+
```
97+
7698
## When Not to Use It
7799

78100
If you are purposely using at-rules that aren't part of the CSS specification, then you can safely disable this rule.

src/rules/no-invalid-at-rules.js

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,23 @@ import { isSyntaxMatchError } from "../util.js";
1616
/**
1717
* @import { AtrulePlain } from "@eslint/css-tree"
1818
* @import { CSSRuleDefinition } from "../types.js"
19-
* @typedef {"unknownAtRule" | "invalidPrelude" | "unknownDescriptor" | "invalidDescriptor" | "invalidExtraPrelude" | "missingPrelude"} NoInvalidAtRulesMessageIds
19+
* @typedef {"unknownAtRule" | "invalidPrelude" | "unknownDescriptor" | "invalidDescriptor" | "invalidExtraPrelude" | "missingPrelude" | "invalidCharsetSyntax"} NoInvalidAtRulesMessageIds
2020
* @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoInvalidAtRulesMessageIds }>} NoInvalidAtRulesRuleDefinition
2121
*/
2222

2323
//-----------------------------------------------------------------------------
2424
// Helpers
2525
//-----------------------------------------------------------------------------
2626

27+
/**
28+
* A valid `@charset` rule must:
29+
* - Enclose the encoding name in double quotes
30+
* - Include exactly one space character after `@charset`
31+
* - End immediately with a semicolon
32+
*/
33+
const charsetPattern = /^@charset "[^"]+";$/u;
34+
const charsetEncodingPattern = /^['"]?([^"';]+)['"]?/u;
35+
2736
/**
2837
* Extracts metadata from an error object.
2938
* @param {SyntaxError} error The error object to extract metadata from.
@@ -57,6 +66,8 @@ export default {
5766
meta: {
5867
type: "problem",
5968

69+
fixable: "code",
70+
6071
docs: {
6172
description: "Disallow invalid at-rules",
6273
recommended: true,
@@ -74,15 +85,101 @@ export default {
7485
invalidExtraPrelude:
7586
"At-rule '@{{name}}' should not contain a prelude.",
7687
missingPrelude: "At-rule '@{{name}}' should contain a prelude.",
88+
invalidCharsetSyntax:
89+
"Invalid @charset syntax. Expected '@charset \"{{encoding}}\";'.",
7790
},
7891
},
7992

8093
create(context) {
8194
const { sourceCode } = context;
8295
const lexer = sourceCode.lexer;
8396

97+
/**
98+
* Validates a `@charset` rule for correct syntax:
99+
* - Verifies the rule name is exactly "charset" (case-sensitive)
100+
* - Ensures the rule has a prelude
101+
* - Validates the prelude matches the expected pattern
102+
* @param {AtrulePlain} node The node representing the rule.
103+
*/
104+
function validateCharsetRule(node) {
105+
const { name, prelude, loc } = node;
106+
107+
const charsetNameLoc = {
108+
start: loc.start,
109+
end: {
110+
line: loc.start.line,
111+
column: loc.start.column + name.length + 1,
112+
},
113+
};
114+
115+
if (name !== "charset") {
116+
context.report({
117+
loc: charsetNameLoc,
118+
messageId: "unknownAtRule",
119+
data: {
120+
name,
121+
},
122+
fix(fixer) {
123+
return fixer.replaceTextRange(
124+
[
125+
loc.start.offset,
126+
loc.start.offset + name.length + 1,
127+
],
128+
"@charset",
129+
);
130+
},
131+
});
132+
return;
133+
}
134+
135+
if (!prelude) {
136+
context.report({
137+
loc: charsetNameLoc,
138+
messageId: "missingPrelude",
139+
data: {
140+
name,
141+
},
142+
});
143+
return;
144+
}
145+
146+
const nodeText = sourceCode.getText(node);
147+
const preludeText = sourceCode.getText(prelude);
148+
const encoding = preludeText
149+
.match(charsetEncodingPattern)?.[1]
150+
?.trim();
151+
152+
if (!encoding) {
153+
context.report({
154+
loc: prelude.loc,
155+
messageId: "invalidCharsetSyntax",
156+
data: { encoding: "<charset>" },
157+
});
158+
return;
159+
}
160+
161+
if (!charsetPattern.test(nodeText)) {
162+
context.report({
163+
loc: prelude.loc,
164+
messageId: "invalidCharsetSyntax",
165+
data: { encoding },
166+
fix(fixer) {
167+
return fixer.replaceText(
168+
node,
169+
`@charset "${encoding}";`,
170+
);
171+
},
172+
});
173+
}
174+
}
175+
84176
return {
85177
Atrule(node) {
178+
if (node.name.toLowerCase() === "charset") {
179+
validateCharsetRule(node);
180+
return;
181+
}
182+
86183
// checks both name and prelude
87184
const { error } = lexer.matchAtrulePrelude(
88185
node.name,

0 commit comments

Comments
 (0)