返回 DeepSeek-Reasonix
check-css-syntax.mjs
根目录 / desktop / frontend / scripts / check-css-syntax.mjs
1 import fs from "node:fs";
2 import path from "node:path";
3 import { fileURLToPath, pathToFileURL } from "node:url";
4
5 const scriptDir = path.dirname(fileURLToPath(import.meta.url));
6 const frontendRoot = path.resolve(scriptDir, "..");
7
8 /** Returns one message per fault site; an empty array means the file is clean. */
9 export function checkCssFile(fullPath, label) {
10 const source = fs.readFileSync(fullPath, "utf8");
11 const result = checkCssDelimiters(source);
12 if (!result.ok) return [`${label}:${result.line}:${result.column}\n${result.message}`];
13
14 return findGluedPreludes(source).map(
15 (site) =>
16 `${label}:${site.line}:${site.column}\n` +
17 ` A selector list runs into the next rule without its own declaration block: "${site.text}"`,
18 );
19 }
20
21 function main(targets) {
22 const files = targets.length > 0 ? targets : ["src/styles.css"];
23 let failed = false;
24
25 for (const file of files) {
26 const failures = checkCssFile(path.resolve(frontendRoot, file), file);
27 if (failures.length === 0) {
28 console.log(`CSS syntax check passed: ${file}`);
29 continue;
30 }
31 failed = true;
32 for (const failure of failures) console.error(`CSS syntax check failed: ${failure}`);
33 console.error(
34 "The CSS parser drops the orphaned selectors and swallows the block that follows, so the\n" +
35 "damage is invisible in the browser. Restore the deleted block or remove the selectors.",
36 );
37 }
38
39 return failed ? 1 : 0;
40 }
41
42 if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
43 process.exitCode = main(process.argv.slice(2));
44 }
45
46 /**
47 * Finds a selector list that lost its declaration block and ran into the next
48 * rule. Delimiters stay balanced in that shape, so checkCssDelimiters passes,
49 * but the CSS parser drops the orphaned selectors and swallows the block that
50 * follows — a rule silently stops applying. Only multi-line preludes are
51 * judged: a selector list puts `,` at every line end but the last, so a line
52 * that breaks that pattern is glued text rather than a selector.
53 *
54 * A single-line prelude cannot be judged (`@media (max-width: 820px)` and
55 * `.a {` look alike), and a glued rule whose last orphan happened to end in
56 * `,` is textually a legal selector list. Neither is detectable statically;
57 * both need the rendered-DOM check.
58 */
59 function findGluedPreludes(source) {
60 const found = [];
61 let state = "normal";
62 let line = 1;
63 let column = 0;
64 let buffer = "";
65 let bufferLine = 1;
66 let bufferColumn = 1;
67
68 const judge = () => {
69 const lines = buffer.split("\n").map((entry) => entry.trim()).filter(Boolean);
70 if (lines.length < 2) return null;
71 // A declaration whose value wraps across lines (`--x: linear-gradient(`)
72 // is not a prelude. Element selectors with a pseudo-class read the same
73 // way, so they pass unchecked — their siblings still get judged.
74 if (/^[-*_a-zA-Z][-\w]*\s*:/.test(lines[0])) return null;
75 for (let i = 0; i < lines.length - 1; i += 1) {
76 if (!lines[i].endsWith(",")) return lines[i];
77 }
78 return null;
79 };
80
81 const reset = (nextLine, nextColumn) => {
82 buffer = "";
83 bufferLine = nextLine;
84 bufferColumn = nextColumn;
85 };
86
87 for (let i = 0; i < source.length; i += 1) {
88 const char = source[i];
89 const next = source[i + 1];
90
91 if (char === "\n") {
92 line += 1;
93 column = 0;
94 } else {
95 column += 1;
96 }
97
98 if (state === "comment") {
99 if (char === "*" && next === "/") {
100 i += 1;
101 column += 1;
102 state = "normal";
103 }
104 continue;
105 }
106
107 if (state === "single" || state === "double") {
108 if (char === "\\") {
109 i += 1;
110 column += 1;
111 continue;
112 }
113 if ((state === "single" && char === "'") || (state === "double" && char === '"')) {
114 state = "normal";
115 }
116 continue;
117 }
118
119 if (char === "/" && next === "*") {
120 i += 1;
121 column += 1;
122 state = "comment";
123 continue;
124 }
125
126 if (char === "'") {
127 state = "single";
128 continue;
129 }
130
131 if (char === '"') {
132 state = "double";
133 continue;
134 }
135
136 if (char === "{" || char === "}") {
137 const text = judge();
138 if (text !== null) found.push({ line: bufferLine, column: bufferColumn, text });
139 reset(line, column);
140 continue;
141 }
142
143 // A `;` only terminates declarations and at-rules, never a selector list,
144 // so the buffer it closes is not a prelude worth judging.
145 if (char === ";") {
146 reset(line, column);
147 continue;
148 }
149
150 // Keep the cursor on the first character that is not whitespace, so the
151 // reported position names the selector rather than the blank line above it.
152 if (buffer.trim() === "") {
153 bufferLine = line;
154 bufferColumn = column;
155 }
156 buffer += char;
157 }
158
159 const text = judge();
160 if (text !== null) found.push({ line: bufferLine, column: bufferColumn, text });
161 return found;
162 }
163
164 function checkCssDelimiters(source) {
165 const stack = [];
166 let state = "normal";
167 let line = 1;
168 let column = 0;
169 let tokenLine = 1;
170 let tokenColumn = 1;
171
172 for (let i = 0; i < source.length; i += 1) {
173 const char = source[i];
174 const next = source[i + 1];
175
176 if (char === "\n") {
177 line += 1;
178 column = 0;
179 } else {
180 column += 1;
181 }
182
183 if (state === "comment") {
184 if (char === "*" && next === "/") {
185 i += 1;
186 column += 1;
187 state = "normal";
188 }
189 continue;
190 }
191
192 if (state === "single" || state === "double") {
193 if (char === "\\") {
194 i += 1;
195 column += 1;
196 continue;
197 }
198 if ((state === "single" && char === "'") || (state === "double" && char === '"')) {
199 state = "normal";
200 }
201 continue;
202 }
203
204 if (char === "/" && next === "*") {
205 tokenLine = line;
206 tokenColumn = column;
207 i += 1;
208 column += 1;
209 state = "comment";
210 continue;
211 }
212
213 if (char === "'") {
214 tokenLine = line;
215 tokenColumn = column;
216 state = "single";
217 continue;
218 }
219
220 if (char === '"') {
221 tokenLine = line;
222 tokenColumn = column;
223 state = "double";
224 continue;
225 }
226
227 if (char === "{") {
228 stack.push({ line, column });
229 continue;
230 }
231
232 if (char === "}") {
233 if (stack.length === 0) {
234 return {
235 ok: false,
236 line,
237 column,
238 message: "Found a closing brace without a matching opening brace.",
239 };
240 }
241 stack.pop();
242 }
243 }
244
245 if (state === "comment") {
246 return {
247 ok: false,
248 line: tokenLine,
249 column: tokenColumn,
250 message: "Found an unterminated CSS comment.",
251 };
252 }
253
254 if (state === "single" || state === "double") {
255 return {
256 ok: false,
257 line: tokenLine,
258 column: tokenColumn,
259 message: "Found an unterminated CSS string.",
260 };
261 }
262
263 if (stack.length > 0) {
264 const opener = stack[stack.length - 1];
265 return {
266 ok: false,
267 line: opener.line,
268 column: opener.column,
269 message: "Found an opening brace without a matching closing brace.",
270 };
271 }
272
273 return { ok: true };
274 }
275
275 lines Plain Text