@zappie/check 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +19 -1
- package/dist/index.d.ts +26 -2
- package/dist/index.js +931 -65
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/schema/check-result.v1.json +3 -1
package/dist/index.js
CHANGED
|
@@ -42,7 +42,7 @@ function distance(left, right) {
|
|
|
42
42
|
}
|
|
43
43
|
return row[right.length] ?? Infinity;
|
|
44
44
|
}
|
|
45
|
-
function unknownKeys(value, allowed,
|
|
45
|
+
function unknownKeys(value, allowed, path14, issue) {
|
|
46
46
|
for (const key of Object.keys(value)) {
|
|
47
47
|
if (allowed.includes(key)) {
|
|
48
48
|
continue;
|
|
@@ -50,7 +50,7 @@ function unknownKeys(value, allowed, path12, issue) {
|
|
|
50
50
|
const closest = allowed.map((candidate) => ({ candidate, score: distance(key, candidate) })).sort((left, right) => left.score - right.score)[0];
|
|
51
51
|
const hint = closest && closest.score <= 2 ? closest.candidate : void 0;
|
|
52
52
|
issue({
|
|
53
|
-
path:
|
|
53
|
+
path: path14 ? `${path14}.${key}` : key,
|
|
54
54
|
code: "unknown_key",
|
|
55
55
|
message: hint ? `Unknown field "${key}". Rename it to "${hint}".` : `Unknown field "${key}". Remove it from the configuration.`,
|
|
56
56
|
severity: "error"
|
|
@@ -76,20 +76,20 @@ function hostname(value) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
// ../sdk/src/site-config/sections.ts
|
|
79
|
-
function nested(value,
|
|
79
|
+
function nested(value, path14, keys, issue, validate) {
|
|
80
80
|
if (value === void 0) {
|
|
81
81
|
return;
|
|
82
82
|
}
|
|
83
83
|
if (!record(value)) {
|
|
84
84
|
issue({
|
|
85
|
-
path:
|
|
85
|
+
path: path14,
|
|
86
86
|
code: "invalid_type",
|
|
87
|
-
message: `Expected an object. Set ${
|
|
87
|
+
message: `Expected an object. Set ${path14} to an object or remove it.`,
|
|
88
88
|
severity: "error"
|
|
89
89
|
});
|
|
90
90
|
return;
|
|
91
91
|
}
|
|
92
|
-
unknownKeys(value, keys,
|
|
92
|
+
unknownKeys(value, keys, path14, issue);
|
|
93
93
|
validate(value);
|
|
94
94
|
}
|
|
95
95
|
function budgets(value, issue, error) {
|
|
@@ -126,18 +126,18 @@ function budgets(value, issue, error) {
|
|
|
126
126
|
`Expected a positive finite number. Set budgets.${key} to a number greater than zero.`
|
|
127
127
|
);
|
|
128
128
|
} else if (amount > DEFAULT_BUDGETS[key]) {
|
|
129
|
-
const
|
|
129
|
+
const message5 = `The ${key} budget exceeds the ${DEFAULT_BUDGETS[key]} default.`;
|
|
130
130
|
if (budget.override === void 0) {
|
|
131
131
|
error(
|
|
132
132
|
`budgets.${key}`,
|
|
133
133
|
"budget_override",
|
|
134
|
-
`${
|
|
134
|
+
`${message5} Add budgets.override.reason to explain the increase.`
|
|
135
135
|
);
|
|
136
136
|
} else {
|
|
137
137
|
issue({
|
|
138
138
|
path: `budgets.${key}`,
|
|
139
139
|
code: "budget_override",
|
|
140
|
-
message: `${
|
|
140
|
+
message: `${message5} Lower it to the default when the exception is no longer needed.`,
|
|
141
141
|
severity: "warning"
|
|
142
142
|
});
|
|
143
143
|
}
|
|
@@ -183,12 +183,13 @@ var rootKeys = [
|
|
|
183
183
|
"packages",
|
|
184
184
|
"outbound",
|
|
185
185
|
"budgets",
|
|
186
|
+
"readiness",
|
|
186
187
|
"analytics"
|
|
187
188
|
];
|
|
188
189
|
function validateConfig(input) {
|
|
189
190
|
const issues = [];
|
|
190
191
|
const issue = (value) => issues.push(value);
|
|
191
|
-
const error = (
|
|
192
|
+
const error = (path14, code, message5) => issue({ path: path14, code, message: message5, severity: "error" });
|
|
192
193
|
if (!record(input)) {
|
|
193
194
|
return {
|
|
194
195
|
valid: false,
|
|
@@ -246,18 +247,49 @@ function validateConfig(input) {
|
|
|
246
247
|
});
|
|
247
248
|
});
|
|
248
249
|
budgets(input.budgets, issue, error);
|
|
250
|
+
nested(
|
|
251
|
+
input.readiness,
|
|
252
|
+
"readiness",
|
|
253
|
+
["routeDiscovery"],
|
|
254
|
+
issue,
|
|
255
|
+
(readiness) => {
|
|
256
|
+
nested(
|
|
257
|
+
readiness.routeDiscovery,
|
|
258
|
+
"readiness.routeDiscovery",
|
|
259
|
+
["optOut"],
|
|
260
|
+
issue,
|
|
261
|
+
(routeDiscovery) => {
|
|
262
|
+
nested(
|
|
263
|
+
routeDiscovery.optOut,
|
|
264
|
+
"readiness.routeDiscovery.optOut",
|
|
265
|
+
["reason"],
|
|
266
|
+
issue,
|
|
267
|
+
(optOut) => {
|
|
268
|
+
if (typeof optOut.reason !== "string" || !optOut.reason.trim()) {
|
|
269
|
+
error(
|
|
270
|
+
"readiness.routeDiscovery.optOut.reason",
|
|
271
|
+
"invalid_format",
|
|
272
|
+
"Expected a non-blank reason. Explain why the site disables initial route discovery."
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
);
|
|
249
281
|
analytics(input.analytics, issue, error);
|
|
250
282
|
return {
|
|
251
283
|
valid: !issues.some((entry) => entry.severity === "error"),
|
|
252
284
|
issues
|
|
253
285
|
};
|
|
254
286
|
}
|
|
255
|
-
function requiredSlug(value,
|
|
287
|
+
function requiredSlug(value, path14, error) {
|
|
256
288
|
if (!nonblank(value)) {
|
|
257
289
|
error(
|
|
258
|
-
|
|
290
|
+
path14,
|
|
259
291
|
value === void 0 ? "missing_required" : "invalid_type",
|
|
260
|
-
`Expected a non-empty storefront slug. Set ${
|
|
292
|
+
`Expected a non-empty storefront slug. Set ${path14} to the slug used by createZappie().`
|
|
261
293
|
);
|
|
262
294
|
}
|
|
263
295
|
}
|
|
@@ -276,29 +308,29 @@ function theme(value, error) {
|
|
|
276
308
|
);
|
|
277
309
|
}
|
|
278
310
|
}
|
|
279
|
-
function stringList(value,
|
|
311
|
+
function stringList(value, path14, error, options = {}) {
|
|
280
312
|
if (value === void 0) {
|
|
281
313
|
return void 0;
|
|
282
314
|
}
|
|
283
315
|
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
284
316
|
error(
|
|
285
|
-
|
|
317
|
+
path14,
|
|
286
318
|
"invalid_type",
|
|
287
|
-
`Expected an array of strings. Set ${
|
|
319
|
+
`Expected an array of strings. Set ${path14} to string values.`
|
|
288
320
|
);
|
|
289
321
|
return void 0;
|
|
290
322
|
}
|
|
291
323
|
if (options.nonempty && value.length === 0) {
|
|
292
324
|
error(
|
|
293
|
-
|
|
325
|
+
path14,
|
|
294
326
|
"invalid_format",
|
|
295
|
-
`Expected at least one value. Add a value to ${
|
|
327
|
+
`Expected at least one value. Add a value to ${path14} or remove the field.`
|
|
296
328
|
);
|
|
297
329
|
}
|
|
298
330
|
value.forEach((entry, index) => {
|
|
299
331
|
if (options.slug && !nonblank(entry)) {
|
|
300
332
|
error(
|
|
301
|
-
`${
|
|
333
|
+
`${path14}[${index}]`,
|
|
302
334
|
"invalid_format",
|
|
303
335
|
"Expected a non-empty branch slug. Remove the empty value or use a branch slug."
|
|
304
336
|
);
|
|
@@ -306,17 +338,17 @@ function stringList(value, path12, error, options = {}) {
|
|
|
306
338
|
});
|
|
307
339
|
if (options.unique && new Set(value).size !== value.length) {
|
|
308
340
|
error(
|
|
309
|
-
|
|
341
|
+
path14,
|
|
310
342
|
"duplicate",
|
|
311
|
-
`Expected unique values. Remove duplicates from ${
|
|
343
|
+
`Expected unique values. Remove duplicates from ${path14}.`
|
|
312
344
|
);
|
|
313
345
|
}
|
|
314
346
|
return value;
|
|
315
347
|
}
|
|
316
348
|
function paths(value, error) {
|
|
317
349
|
const values = stringList(value, "prerender", error, { unique: true });
|
|
318
|
-
values?.forEach((
|
|
319
|
-
if (!
|
|
350
|
+
values?.forEach((path14, index) => {
|
|
351
|
+
if (!path14.startsWith("/")) {
|
|
320
352
|
error(
|
|
321
353
|
`prerender[${index}]`,
|
|
322
354
|
"invalid_format",
|
|
@@ -819,11 +851,11 @@ var record2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
|
819
851
|
var includes = (values, key) => values.includes(key);
|
|
820
852
|
function validateTheme(input) {
|
|
821
853
|
const issues = [];
|
|
822
|
-
const issue = (
|
|
823
|
-
const keys = (obj, allowed,
|
|
854
|
+
const issue = (path14, code, message5, severity = "error") => issues.push({ path: path14, code, message: message5, severity });
|
|
855
|
+
const keys = (obj, allowed, path14) => {
|
|
824
856
|
for (const key of Object.keys(obj)) {
|
|
825
857
|
if (!allowed.includes(key)) {
|
|
826
|
-
issue(
|
|
858
|
+
issue(path14 ? `${path14}.${key}` : key, "invalid_theme", "Unknown field");
|
|
827
859
|
}
|
|
828
860
|
}
|
|
829
861
|
};
|
|
@@ -884,9 +916,9 @@ function validateTheme(input) {
|
|
|
884
916
|
}
|
|
885
917
|
}
|
|
886
918
|
for (const [key, value] of Object.entries(values)) {
|
|
887
|
-
const
|
|
919
|
+
const path14 = `${group}.${key}`;
|
|
888
920
|
if (!includes(TOKEN_NAMES, key)) {
|
|
889
|
-
issue(
|
|
921
|
+
issue(path14, "unknown_token", "Unknown semantic token");
|
|
890
922
|
continue;
|
|
891
923
|
}
|
|
892
924
|
let code, valid = false;
|
|
@@ -904,7 +936,7 @@ function validateTheme(input) {
|
|
|
904
936
|
valid = typeof value === "string" && safeValue(value) && (includes(DURATION_TOKENS, key) ? isDuration(value) : isEasing(value));
|
|
905
937
|
}
|
|
906
938
|
if (!valid) {
|
|
907
|
-
issue(
|
|
939
|
+
issue(path14, code, "Invalid or unsafe token value");
|
|
908
940
|
}
|
|
909
941
|
}
|
|
910
942
|
}
|
|
@@ -914,30 +946,30 @@ function validateTheme(input) {
|
|
|
914
946
|
} else {
|
|
915
947
|
const roles = /* @__PURE__ */ new Set();
|
|
916
948
|
input.fonts.forEach((font, i) => {
|
|
917
|
-
const
|
|
949
|
+
const path14 = `fonts[${i}]`;
|
|
918
950
|
if (!record2(font)) {
|
|
919
|
-
issue(
|
|
951
|
+
issue(path14, "invalid_font", "Expected a font object");
|
|
920
952
|
return;
|
|
921
953
|
}
|
|
922
|
-
keys(font, ["family", "source", "url", "weights", "role"],
|
|
954
|
+
keys(font, ["family", "source", "url", "weights", "role"], path14);
|
|
923
955
|
if (typeof font.family !== "string" || !isFontFamily(font.family) || font.family.includes(",")) {
|
|
924
956
|
issue(
|
|
925
|
-
`${
|
|
957
|
+
`${path14}.family`,
|
|
926
958
|
"invalid_font",
|
|
927
959
|
"Expected one unquoted font family"
|
|
928
960
|
);
|
|
929
961
|
}
|
|
930
962
|
if (typeof font.source !== "string" || !["google", "self", "system"].includes(font.source)) {
|
|
931
|
-
issue(`${
|
|
963
|
+
issue(`${path14}.source`, "invalid_font", "Unknown font source");
|
|
932
964
|
}
|
|
933
965
|
if (typeof font.role !== "string" || !["display", "body", "mono"].includes(font.role) || roles.has(font.role)) {
|
|
934
|
-
issue(`${
|
|
966
|
+
issue(`${path14}.role`, "invalid_font", "Expected a unique font role");
|
|
935
967
|
}
|
|
936
968
|
roles.add(String(font.role));
|
|
937
969
|
if (font.source === "self" || font.url !== void 0) {
|
|
938
970
|
if (typeof font.url !== "string" || !isAssetUrl(font.url, false) || !new URL(font.url).pathname.endsWith(".woff2")) {
|
|
939
971
|
issue(
|
|
940
|
-
`${
|
|
972
|
+
`${path14}.url`,
|
|
941
973
|
"invalid_font",
|
|
942
974
|
"Expected an HTTPS .woff2 URL"
|
|
943
975
|
);
|
|
@@ -947,7 +979,7 @@ function validateTheme(input) {
|
|
|
947
979
|
(w) => typeof w !== "number" || !Number.isInteger(w) || w < 1 || w > 1e3
|
|
948
980
|
))) {
|
|
949
981
|
issue(
|
|
950
|
-
`${
|
|
982
|
+
`${path14}.weights`,
|
|
951
983
|
"invalid_font",
|
|
952
984
|
"Expected 1\u20139 integer weights between 1 and 1000"
|
|
953
985
|
);
|
|
@@ -1057,13 +1089,13 @@ function escape(value) {
|
|
|
1057
1089
|
}
|
|
1058
1090
|
|
|
1059
1091
|
// src/result.ts
|
|
1060
|
-
function problem(
|
|
1061
|
-
return { ...details, message:
|
|
1092
|
+
function problem(message5, fix, details = {}) {
|
|
1093
|
+
return { ...details, message: message5, fix };
|
|
1062
1094
|
}
|
|
1063
1095
|
function step(name, result, problems = []) {
|
|
1064
1096
|
return { name, result, problems };
|
|
1065
1097
|
}
|
|
1066
|
-
var skipped = (name,
|
|
1098
|
+
var skipped = (name, message5) => step(name, "skipped", [problem(message5, null)]);
|
|
1067
1099
|
|
|
1068
1100
|
// src/config.ts
|
|
1069
1101
|
function supportsTypeScriptImport(version = process.versions.node) {
|
|
@@ -1524,10 +1556,784 @@ ${stderr}`.split(/\r?\n/).map((line) => line.trimEnd()).filter(Boolean).slice(-1
|
|
|
1524
1556
|
return lines.length > 0 ? lines.join("\n") : "The build exited without output.";
|
|
1525
1557
|
}
|
|
1526
1558
|
|
|
1559
|
+
// src/css-files.ts
|
|
1560
|
+
import { lstat, readFile as readFile8, readdir as readdir2 } from "fs/promises";
|
|
1561
|
+
import path8 from "path";
|
|
1562
|
+
var FILE_SIZE_CAP = 1024 * 1024;
|
|
1563
|
+
var BUILD_FILE_SIZE_CAP = 8 * 1024 * 1024;
|
|
1564
|
+
var ignoredDirectories = /* @__PURE__ */ new Set([
|
|
1565
|
+
"node_modules",
|
|
1566
|
+
"build",
|
|
1567
|
+
"dist",
|
|
1568
|
+
"coverage",
|
|
1569
|
+
"public",
|
|
1570
|
+
"vendor",
|
|
1571
|
+
"test",
|
|
1572
|
+
"tests",
|
|
1573
|
+
"e2e",
|
|
1574
|
+
"test-results",
|
|
1575
|
+
"playwright-report"
|
|
1576
|
+
]);
|
|
1577
|
+
var codeExtension = /\.(?:[cm]?[jt]sx?|html|mdx)$/;
|
|
1578
|
+
var testFile = /\.(?:test|spec)\.[^.]+$/;
|
|
1579
|
+
async function readCssInputs(cwd) {
|
|
1580
|
+
const source = [];
|
|
1581
|
+
await collect2(cwd, source, (name) => {
|
|
1582
|
+
return !name.startsWith(".") && !ignoredDirectories.has(name);
|
|
1583
|
+
});
|
|
1584
|
+
const built = [];
|
|
1585
|
+
await collect2(path8.join(cwd, "build/client"), built, () => true);
|
|
1586
|
+
const read = (files, cap) => texts(cwd, files, cap);
|
|
1587
|
+
return {
|
|
1588
|
+
sourceCss: await read(
|
|
1589
|
+
source.filter((file) => file.endsWith(".css")),
|
|
1590
|
+
FILE_SIZE_CAP
|
|
1591
|
+
),
|
|
1592
|
+
sourceCode: await read(
|
|
1593
|
+
source.filter(
|
|
1594
|
+
(file) => codeExtension.test(file) && !testFile.test(file) && !/(?:^|[/\\])[^/\\]+\.config\.[^.]+$/.test(file)
|
|
1595
|
+
),
|
|
1596
|
+
FILE_SIZE_CAP
|
|
1597
|
+
),
|
|
1598
|
+
builtCss: await read(
|
|
1599
|
+
built.filter((file) => file.endsWith(".css")),
|
|
1600
|
+
BUILD_FILE_SIZE_CAP
|
|
1601
|
+
),
|
|
1602
|
+
builtCode: await read(
|
|
1603
|
+
built.filter((file) => file.endsWith(".js")),
|
|
1604
|
+
BUILD_FILE_SIZE_CAP
|
|
1605
|
+
)
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
async function texts(cwd, files, cap) {
|
|
1609
|
+
const result = [];
|
|
1610
|
+
for (const file of files.sort()) {
|
|
1611
|
+
const info = await lstat(file);
|
|
1612
|
+
if (!info.isFile() || info.size > cap) {
|
|
1613
|
+
continue;
|
|
1614
|
+
}
|
|
1615
|
+
result.push({
|
|
1616
|
+
file: relative(cwd, file),
|
|
1617
|
+
text: await readFile8(file, "utf8")
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
return result;
|
|
1621
|
+
}
|
|
1622
|
+
async function collect2(directory, files, enter) {
|
|
1623
|
+
let entries;
|
|
1624
|
+
try {
|
|
1625
|
+
entries = await readdir2(directory, { withFileTypes: true });
|
|
1626
|
+
} catch (error) {
|
|
1627
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
throw error;
|
|
1631
|
+
}
|
|
1632
|
+
for (const entry of entries) {
|
|
1633
|
+
if (entry.isSymbolicLink()) {
|
|
1634
|
+
continue;
|
|
1635
|
+
}
|
|
1636
|
+
const file = path8.join(directory, entry.name);
|
|
1637
|
+
if (entry.isDirectory()) {
|
|
1638
|
+
if (enter(entry.name)) {
|
|
1639
|
+
await collect2(file, files, enter);
|
|
1640
|
+
}
|
|
1641
|
+
} else if (entry.isFile()) {
|
|
1642
|
+
files.push(file);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// src/css-rules.ts
|
|
1648
|
+
import postcss from "postcss";
|
|
1649
|
+
var CSS_ERRORS = /* @__PURE__ */ new Set([
|
|
1650
|
+
"css/important",
|
|
1651
|
+
"css/class-patch"
|
|
1652
|
+
]);
|
|
1653
|
+
function analyseCss(inputs) {
|
|
1654
|
+
const source = parseAll(inputs.sourceCss);
|
|
1655
|
+
const built = parseAll(inputs.builtCss);
|
|
1656
|
+
return [
|
|
1657
|
+
...source.problems,
|
|
1658
|
+
...built.problems,
|
|
1659
|
+
...important(source.parsed, inputs.sourceCode),
|
|
1660
|
+
...duplicateKeyframes(source.parsed, built.parsed),
|
|
1661
|
+
...duplicateSelectors(source.parsed),
|
|
1662
|
+
...arbitraryTokens(source.parsed, inputs.sourceCode),
|
|
1663
|
+
...unusedCustomProperties(source.parsed, built.parsed, inputs),
|
|
1664
|
+
...reducedMotion(source.parsed),
|
|
1665
|
+
...classPatches(source.parsed),
|
|
1666
|
+
...literalTokens(source.parsed)
|
|
1667
|
+
];
|
|
1668
|
+
}
|
|
1669
|
+
function parseAll(files) {
|
|
1670
|
+
const parsed = [];
|
|
1671
|
+
const problems = [];
|
|
1672
|
+
for (const entry of files) {
|
|
1673
|
+
try {
|
|
1674
|
+
parsed.push({
|
|
1675
|
+
...entry,
|
|
1676
|
+
root: postcss.parse(entry.text, { from: entry.file }),
|
|
1677
|
+
lines: entry.text.split(/\r?\n/)
|
|
1678
|
+
});
|
|
1679
|
+
} catch (error) {
|
|
1680
|
+
const line = error && typeof error === "object" && "line" in error ? Number(error.line) : void 0;
|
|
1681
|
+
problems.push(
|
|
1682
|
+
problem(
|
|
1683
|
+
`${entry.file} could not be read as CSS: ${error instanceof Error ? error.message : String(error)}`,
|
|
1684
|
+
"Fix the CSS syntax so zappie check can read the file.",
|
|
1685
|
+
{
|
|
1686
|
+
rule: "css/unreadable",
|
|
1687
|
+
file: entry.file,
|
|
1688
|
+
...line && line > 0 ? { line } : {}
|
|
1689
|
+
}
|
|
1690
|
+
)
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
return { parsed, problems };
|
|
1695
|
+
}
|
|
1696
|
+
var allowImportant = /zappie-allow-important:\s*\S/;
|
|
1697
|
+
function important(css, code) {
|
|
1698
|
+
const problems = [];
|
|
1699
|
+
const allowed = (lines, line) => line !== void 0 && allowImportant.test(lines[line - 1] ?? "");
|
|
1700
|
+
for (const entry of css) {
|
|
1701
|
+
entry.root.walkDecls((decl) => {
|
|
1702
|
+
const line = decl.source?.start?.line;
|
|
1703
|
+
if (decl.important && !allowed(entry.lines, line) && !insideMedia(decl.parent, "reduce")) {
|
|
1704
|
+
problems.push(
|
|
1705
|
+
importantProblem(
|
|
1706
|
+
`${decl.prop} is marked !important.`,
|
|
1707
|
+
entry.file,
|
|
1708
|
+
line
|
|
1709
|
+
)
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
});
|
|
1713
|
+
entry.root.walkAtRules("apply", (rule) => {
|
|
1714
|
+
const line = rule.source?.start?.line;
|
|
1715
|
+
const flagged = rule.params.split(/\s+/).filter((token) => importantUtility(token));
|
|
1716
|
+
if ((flagged.length > 0 || /!important\b/.test(rule.params)) && !allowed(entry.lines, line)) {
|
|
1717
|
+
problems.push(
|
|
1718
|
+
importantProblem(
|
|
1719
|
+
`@apply ${rule.params} forces !important.`,
|
|
1720
|
+
entry.file,
|
|
1721
|
+
line
|
|
1722
|
+
)
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
for (const entry of code) {
|
|
1728
|
+
const lines = entry.text.split(/\r?\n/);
|
|
1729
|
+
lines.forEach((text, index) => {
|
|
1730
|
+
if (allowImportant.test(text)) {
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
for (const list of classLists(text)) {
|
|
1734
|
+
for (const token of list.split(/\s+/)) {
|
|
1735
|
+
if (importantUtility(token)) {
|
|
1736
|
+
problems.push(
|
|
1737
|
+
importantProblem(
|
|
1738
|
+
`The utility ${token} forces !important.`,
|
|
1739
|
+
entry.file,
|
|
1740
|
+
index + 1
|
|
1741
|
+
)
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
return problems;
|
|
1749
|
+
}
|
|
1750
|
+
function importantProblem(message5, file, line) {
|
|
1751
|
+
return problem(
|
|
1752
|
+
message5,
|
|
1753
|
+
"Raise the selector's specificity or reorder the rules instead. For a reviewed case, add /* zappie-allow-important: <reason> */ on the same line.",
|
|
1754
|
+
{ rule: "css/important", file, ...line ? { line } : {} }
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1757
|
+
var utilityToken = /^!?-?[a-z0-9@[\]_\-:/.%()#,'=&>*~+!]*[a-z0-9\])%]!?$/;
|
|
1758
|
+
function importantUtility(token) {
|
|
1759
|
+
const utility = withoutVariants(token).utility;
|
|
1760
|
+
if (utility.length < 3) {
|
|
1761
|
+
return false;
|
|
1762
|
+
}
|
|
1763
|
+
if (/^!-?[a-z[]/.test(utility)) {
|
|
1764
|
+
return true;
|
|
1765
|
+
}
|
|
1766
|
+
return /[a-z0-9\])%]!$/.test(utility) && /[-[]/.test(utility);
|
|
1767
|
+
}
|
|
1768
|
+
function classLists(line) {
|
|
1769
|
+
const lists = [];
|
|
1770
|
+
for (const match of line.matchAll(/(["'`])([^"'`]*?)\1/g)) {
|
|
1771
|
+
const value = match[2]?.trim() ?? "";
|
|
1772
|
+
if (!value) {
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
const tokens = value.split(/\s+/);
|
|
1776
|
+
if (tokens.every((token) => utilityToken.test(token)) && tokens.some((token) => token.includes("-"))) {
|
|
1777
|
+
lists.push(value);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
return lists;
|
|
1781
|
+
}
|
|
1782
|
+
function withoutVariants(token) {
|
|
1783
|
+
let depth = 0;
|
|
1784
|
+
let cut = 0;
|
|
1785
|
+
for (let index = 0; index < token.length; index++) {
|
|
1786
|
+
const character = token[index];
|
|
1787
|
+
if (character === "[" || character === "(") {
|
|
1788
|
+
depth++;
|
|
1789
|
+
} else if (character === "]" || character === ")") {
|
|
1790
|
+
depth--;
|
|
1791
|
+
} else if (character === ":" && depth === 0) {
|
|
1792
|
+
cut = index + 1;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
return { variants: token.slice(0, cut), utility: token.slice(cut) };
|
|
1796
|
+
}
|
|
1797
|
+
function keyframesIn(files) {
|
|
1798
|
+
const found = [];
|
|
1799
|
+
for (const entry of files) {
|
|
1800
|
+
entry.root.walkAtRules(/keyframes$/i, (rule) => {
|
|
1801
|
+
const line = rule.source?.start?.line;
|
|
1802
|
+
found.push({
|
|
1803
|
+
name: rule.params.trim(),
|
|
1804
|
+
vendor: rule.name.toLowerCase().replace(/keyframes$/, ""),
|
|
1805
|
+
body: framesBody(rule),
|
|
1806
|
+
file: entry.file,
|
|
1807
|
+
...line ? { line } : {}
|
|
1808
|
+
});
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
return found;
|
|
1812
|
+
}
|
|
1813
|
+
function framesBody(rule) {
|
|
1814
|
+
const frames = [];
|
|
1815
|
+
rule.each((node) => {
|
|
1816
|
+
if (node.type !== "rule") {
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
const declarations = [];
|
|
1820
|
+
node.walkDecls((decl) => {
|
|
1821
|
+
declarations.push(
|
|
1822
|
+
`${decl.prop.toLowerCase()}:${normaliseValue(decl.value)}${decl.important ? "!" : ""}`
|
|
1823
|
+
);
|
|
1824
|
+
});
|
|
1825
|
+
const selector = node.selectors.map(
|
|
1826
|
+
(value) => value.trim().toLowerCase().replace(/^from$/, "0%").replace(/^to$/, "100%")
|
|
1827
|
+
).join(",");
|
|
1828
|
+
frames.push(`${selector}{${declarations.join(";")}}`);
|
|
1829
|
+
});
|
|
1830
|
+
return frames.join("");
|
|
1831
|
+
}
|
|
1832
|
+
function normaliseValue(value) {
|
|
1833
|
+
return value.toLowerCase().replace(/\s+/g, "").replace(/(^|[^\d.])0+(\.\d)/g, "$1$2").replace(/(\d)\.0+(?!\d)/g, "$1");
|
|
1834
|
+
}
|
|
1835
|
+
function duplicateKeyframes(source, built) {
|
|
1836
|
+
const problems = [];
|
|
1837
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1838
|
+
const sourceFrames = keyframesIn(source);
|
|
1839
|
+
const builtFrames = keyframesIn(built);
|
|
1840
|
+
const fix = "Keep one @keyframes for each animation and refer to it by that name.";
|
|
1841
|
+
const byName = (frames) => {
|
|
1842
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1843
|
+
for (const frame of frames) {
|
|
1844
|
+
const key = `${frame.vendor}${frame.name}`;
|
|
1845
|
+
groups.set(key, [...groups.get(key) ?? [], frame]);
|
|
1846
|
+
}
|
|
1847
|
+
return groups;
|
|
1848
|
+
};
|
|
1849
|
+
for (const [key, group] of byName(sourceFrames)) {
|
|
1850
|
+
if (group.length > 1) {
|
|
1851
|
+
reported.add(key);
|
|
1852
|
+
const [first, ...rest] = group;
|
|
1853
|
+
for (const frame of rest) {
|
|
1854
|
+
problems.push(
|
|
1855
|
+
problem(
|
|
1856
|
+
`@keyframes ${frame.name} is defined again (first in ${first?.file}${first?.line ? `:${first.line}` : ""}).`,
|
|
1857
|
+
fix,
|
|
1858
|
+
location("css/duplicate-keyframes", frame)
|
|
1859
|
+
)
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
for (const [key, group] of byName(builtFrames)) {
|
|
1865
|
+
if (reported.has(key) || new Set(group.map(({ body }) => body)).size < 2) {
|
|
1866
|
+
continue;
|
|
1867
|
+
}
|
|
1868
|
+
reported.add(key);
|
|
1869
|
+
const first = group[0];
|
|
1870
|
+
problems.push(
|
|
1871
|
+
problem(
|
|
1872
|
+
`@keyframes ${first?.name} is defined more than once with different frames in the built CSS, so the last one wins.`,
|
|
1873
|
+
`${fix} Rename yours if it clashes with one from a library or Tailwind.`,
|
|
1874
|
+
location("css/duplicate-keyframes", first)
|
|
1875
|
+
)
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
const byBody = /* @__PURE__ */ new Map();
|
|
1879
|
+
for (const frame of [...sourceFrames, ...builtFrames]) {
|
|
1880
|
+
if (!frame.body) {
|
|
1881
|
+
continue;
|
|
1882
|
+
}
|
|
1883
|
+
const key = `${frame.vendor}${frame.body}`;
|
|
1884
|
+
const group = byBody.get(key) ?? [];
|
|
1885
|
+
if (!group.some(({ name }) => name === frame.name)) {
|
|
1886
|
+
group.push(frame);
|
|
1887
|
+
}
|
|
1888
|
+
byBody.set(key, group);
|
|
1889
|
+
}
|
|
1890
|
+
for (const group of byBody.values()) {
|
|
1891
|
+
if (group.length < 2) {
|
|
1892
|
+
continue;
|
|
1893
|
+
}
|
|
1894
|
+
const names = group.map(({ name }) => name);
|
|
1895
|
+
const [first, ...rest] = group;
|
|
1896
|
+
problems.push(
|
|
1897
|
+
problem(
|
|
1898
|
+
`@keyframes ${names.join(", ")} have the same frames under different names.`,
|
|
1899
|
+
`${fix} Use ${first?.name} for all of them.`,
|
|
1900
|
+
location("css/duplicate-keyframes", rest[0])
|
|
1901
|
+
)
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
return problems;
|
|
1905
|
+
}
|
|
1906
|
+
function location(rule, entry) {
|
|
1907
|
+
return {
|
|
1908
|
+
rule,
|
|
1909
|
+
...entry ? { file: entry.file } : {},
|
|
1910
|
+
...entry?.line ? { line: entry.line } : {}
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
function contextOf(node) {
|
|
1914
|
+
const parts = [];
|
|
1915
|
+
for (let parent = node; parent && parent.type !== "root"; parent = parent.parent) {
|
|
1916
|
+
if (parent.type === "atrule") {
|
|
1917
|
+
const rule = parent;
|
|
1918
|
+
parts.unshift(`@${rule.name} ${rule.params.replace(/\s+/g, " ").trim()}`);
|
|
1919
|
+
} else if (parent.type === "rule") {
|
|
1920
|
+
parts.unshift(
|
|
1921
|
+
parent.selectors.map(normaliseSelector).join(",")
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
return parts.join(" > ");
|
|
1926
|
+
}
|
|
1927
|
+
var normaliseSelector = (selector) => selector.replace(/\s+/g, " ").replace(/\s*([>+~,])\s*/g, "$1").trim();
|
|
1928
|
+
function duplicateSelectors(source) {
|
|
1929
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1930
|
+
const problems = [];
|
|
1931
|
+
for (const entry of source) {
|
|
1932
|
+
entry.root.walkRules((rule) => {
|
|
1933
|
+
if (rule.parent?.type === "atrule" && /keyframes$/i.test(rule.parent.name)) {
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
const context = contextOf(rule.parent);
|
|
1937
|
+
const line = rule.source?.start?.line;
|
|
1938
|
+
const selectors = [...new Set(rule.selectors.map(normaliseSelector))];
|
|
1939
|
+
const keys = selectors.length === 1 ? selectors : [[...selectors].sort().join(",")];
|
|
1940
|
+
for (const selector of keys) {
|
|
1941
|
+
const key = `${context}\0${selector}`;
|
|
1942
|
+
const first = seen.get(key);
|
|
1943
|
+
if (!first) {
|
|
1944
|
+
seen.set(key, { file: entry.file, ...line ? { line } : {} });
|
|
1945
|
+
continue;
|
|
1946
|
+
}
|
|
1947
|
+
problems.push(
|
|
1948
|
+
problem(
|
|
1949
|
+
`${selector}${context ? ` (inside ${context})` : ""} is already styled in ${first.file}${first.line ? `:${first.line}` : ""}.`,
|
|
1950
|
+
"Merge the declarations into one rule so the cascade order cannot change the result.",
|
|
1951
|
+
location("css/duplicate-selector", {
|
|
1952
|
+
file: entry.file,
|
|
1953
|
+
...line ? { line } : {}
|
|
1954
|
+
})
|
|
1955
|
+
)
|
|
1956
|
+
);
|
|
1957
|
+
}
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
return problems;
|
|
1961
|
+
}
|
|
1962
|
+
var namespaces = [
|
|
1963
|
+
["font-weight", ["font"]],
|
|
1964
|
+
["inset-shadow", ["inset-shadow"]],
|
|
1965
|
+
["drop-shadow", ["drop-shadow"]],
|
|
1966
|
+
["text-shadow", ["text-shadow"]],
|
|
1967
|
+
[
|
|
1968
|
+
"color",
|
|
1969
|
+
[
|
|
1970
|
+
"text",
|
|
1971
|
+
"bg",
|
|
1972
|
+
"border",
|
|
1973
|
+
"border-x",
|
|
1974
|
+
"border-y",
|
|
1975
|
+
"border-t",
|
|
1976
|
+
"border-r",
|
|
1977
|
+
"border-b",
|
|
1978
|
+
"border-l",
|
|
1979
|
+
"border-s",
|
|
1980
|
+
"border-e",
|
|
1981
|
+
"ring",
|
|
1982
|
+
"ring-offset",
|
|
1983
|
+
"outline",
|
|
1984
|
+
"fill",
|
|
1985
|
+
"stroke",
|
|
1986
|
+
"decoration",
|
|
1987
|
+
"accent",
|
|
1988
|
+
"caret",
|
|
1989
|
+
"divide",
|
|
1990
|
+
"placeholder",
|
|
1991
|
+
"from",
|
|
1992
|
+
"via",
|
|
1993
|
+
"to",
|
|
1994
|
+
"shadow",
|
|
1995
|
+
"inset-shadow",
|
|
1996
|
+
"inset-ring"
|
|
1997
|
+
]
|
|
1998
|
+
],
|
|
1999
|
+
[
|
|
2000
|
+
"radius",
|
|
2001
|
+
[
|
|
2002
|
+
"rounded",
|
|
2003
|
+
"rounded-t",
|
|
2004
|
+
"rounded-r",
|
|
2005
|
+
"rounded-b",
|
|
2006
|
+
"rounded-l",
|
|
2007
|
+
"rounded-tl",
|
|
2008
|
+
"rounded-tr",
|
|
2009
|
+
"rounded-br",
|
|
2010
|
+
"rounded-bl",
|
|
2011
|
+
"rounded-s",
|
|
2012
|
+
"rounded-e",
|
|
2013
|
+
"rounded-ss",
|
|
2014
|
+
"rounded-se",
|
|
2015
|
+
"rounded-es",
|
|
2016
|
+
"rounded-ee"
|
|
2017
|
+
]
|
|
2018
|
+
],
|
|
2019
|
+
["font", ["font"]],
|
|
2020
|
+
["text", ["text"]],
|
|
2021
|
+
["tracking", ["tracking"]],
|
|
2022
|
+
["leading", ["leading"]],
|
|
2023
|
+
["shadow", ["shadow"]],
|
|
2024
|
+
["blur", ["blur"]],
|
|
2025
|
+
["ease", ["ease"]],
|
|
2026
|
+
["animate", ["animate"]],
|
|
2027
|
+
["container", ["max-w", "min-w", "w"]]
|
|
2028
|
+
];
|
|
2029
|
+
function themeTokens(source) {
|
|
2030
|
+
const tokens = [];
|
|
2031
|
+
for (const entry of source) {
|
|
2032
|
+
entry.root.walkAtRules("theme", (rule) => {
|
|
2033
|
+
rule.walkDecls(/^--/, (decl) => {
|
|
2034
|
+
for (const [namespace, utilities] of namespaces) {
|
|
2035
|
+
const prefix = `--${namespace}-`;
|
|
2036
|
+
if (decl.prop.startsWith(prefix) && decl.prop.length > prefix.length) {
|
|
2037
|
+
tokens.push({
|
|
2038
|
+
variable: decl.prop,
|
|
2039
|
+
value: compact(decl.value),
|
|
2040
|
+
utilities,
|
|
2041
|
+
name: decl.prop.slice(prefix.length)
|
|
2042
|
+
});
|
|
2043
|
+
break;
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
});
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
return tokens;
|
|
2050
|
+
}
|
|
2051
|
+
var compact = (value) => value.toLowerCase().replace(/_/g, " ").replace(/\s+/g, "");
|
|
2052
|
+
var arbitraryUtility = /(?<![\w-])((?:[^\s"'`<>{}]*:)?)(!?)(-?)([a-z][a-z-]*?)-(\[([^\]\s"'`]+)\]|\((--[a-z0-9_-]+)\))((?:\/[\w.%]+)?!?)(?![\w-])/g;
|
|
2053
|
+
function arbitraryTokens(source, code) {
|
|
2054
|
+
const tokens = themeTokens(source);
|
|
2055
|
+
if (tokens.length === 0) {
|
|
2056
|
+
return [];
|
|
2057
|
+
}
|
|
2058
|
+
const problems = [];
|
|
2059
|
+
const scan = (text, file, lineOffset = 0) => {
|
|
2060
|
+
text.split(/\r?\n/).forEach((line, index) => {
|
|
2061
|
+
for (const match of line.matchAll(arbitraryUtility)) {
|
|
2062
|
+
const [
|
|
2063
|
+
whole,
|
|
2064
|
+
variants = "",
|
|
2065
|
+
bang = "",
|
|
2066
|
+
negative = "",
|
|
2067
|
+
utility = "",
|
|
2068
|
+
,
|
|
2069
|
+
bracket,
|
|
2070
|
+
parenthesis,
|
|
2071
|
+
modifier = ""
|
|
2072
|
+
] = match;
|
|
2073
|
+
const raw = bracket ?? `var(${parenthesis})`;
|
|
2074
|
+
const value = compact(raw.replace(/^[a-z-]+:(?!\/)/, ""));
|
|
2075
|
+
const token = tokens.find(
|
|
2076
|
+
(candidate) => candidate.utilities.includes(utility) && (value === candidate.value || value === `var(${candidate.variable})`)
|
|
2077
|
+
);
|
|
2078
|
+
if (!token) {
|
|
2079
|
+
continue;
|
|
2080
|
+
}
|
|
2081
|
+
const replacement = `${variants}${bang}${negative}${utility}-${token.name}${modifier}`;
|
|
2082
|
+
problems.push(
|
|
2083
|
+
problem(
|
|
2084
|
+
`${whole} restates the theme token ${token.variable}.`,
|
|
2085
|
+
`Use ${replacement}.`,
|
|
2086
|
+
{ rule: "css/arbitrary-token", file, line: lineOffset + index + 1 }
|
|
2087
|
+
)
|
|
2088
|
+
);
|
|
2089
|
+
}
|
|
2090
|
+
});
|
|
2091
|
+
};
|
|
2092
|
+
for (const entry of code) {
|
|
2093
|
+
scan(entry.text, entry.file);
|
|
2094
|
+
}
|
|
2095
|
+
for (const entry of source) {
|
|
2096
|
+
entry.root.walkAtRules("apply", (rule) => {
|
|
2097
|
+
scan(rule.params, entry.file, (rule.source?.start?.line ?? 1) - 1);
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
return problems;
|
|
2101
|
+
}
|
|
2102
|
+
function unusedCustomProperties(source, built, inputs) {
|
|
2103
|
+
const definitions = /* @__PURE__ */ new Map();
|
|
2104
|
+
for (const entry of source) {
|
|
2105
|
+
entry.root.walkDecls(/^--/, (decl) => {
|
|
2106
|
+
if (insideTheme(decl.parent) || definitions.has(decl.prop)) {
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
const line = decl.source?.start?.line;
|
|
2110
|
+
definitions.set(decl.prop, {
|
|
2111
|
+
file: entry.file,
|
|
2112
|
+
...line ? { line } : {}
|
|
2113
|
+
});
|
|
2114
|
+
});
|
|
2115
|
+
}
|
|
2116
|
+
if (definitions.size === 0) {
|
|
2117
|
+
return [];
|
|
2118
|
+
}
|
|
2119
|
+
const read = /* @__PURE__ */ new Set();
|
|
2120
|
+
const cssReads = /var\(\s*(--[\w-]+)/g;
|
|
2121
|
+
for (const entry of [...source, ...built]) {
|
|
2122
|
+
for (const match of entry.text.matchAll(cssReads)) {
|
|
2123
|
+
read.add(match[1] ?? "");
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
const scriptMentions = /(?<![\w-])(--[a-zA-Z][\w-]*)/g;
|
|
2127
|
+
for (const entry of [...inputs.sourceCode, ...inputs.builtCode]) {
|
|
2128
|
+
for (const match of entry.text.matchAll(scriptMentions)) {
|
|
2129
|
+
read.add(match[1] ?? "");
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
const problems = [];
|
|
2133
|
+
for (const [name, where] of definitions) {
|
|
2134
|
+
if (!read.has(name)) {
|
|
2135
|
+
problems.push(
|
|
2136
|
+
problem(
|
|
2137
|
+
`${name} is defined but nothing reads it.`,
|
|
2138
|
+
"Remove the custom property, or read it with var().",
|
|
2139
|
+
location("css/unused-custom-property", where)
|
|
2140
|
+
)
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
return problems;
|
|
2145
|
+
}
|
|
2146
|
+
function insideTheme(node) {
|
|
2147
|
+
for (let parent = node; parent; parent = parent.parent) {
|
|
2148
|
+
if (parent.type === "atrule" && parent.name === "theme") {
|
|
2149
|
+
return true;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
return false;
|
|
2153
|
+
}
|
|
2154
|
+
var motionProperties = /* @__PURE__ */ new Set([
|
|
2155
|
+
"animation",
|
|
2156
|
+
"animation-name",
|
|
2157
|
+
"transition",
|
|
2158
|
+
"transition-property"
|
|
2159
|
+
]);
|
|
2160
|
+
var stillValue = /^(?:none|0m?s|initial|unset|inherit)$/i;
|
|
2161
|
+
var reducedMotionQuery = (rule, preference) => rule.name === "media" && new RegExp(`prefers-reduced-motion\\s*:\\s*${preference}`, "i").test(
|
|
2162
|
+
rule.params
|
|
2163
|
+
);
|
|
2164
|
+
function insideMedia(node, preference) {
|
|
2165
|
+
for (let parent = node; parent; parent = parent.parent) {
|
|
2166
|
+
if (parent.type === "atrule" && reducedMotionQuery(parent, preference)) {
|
|
2167
|
+
return true;
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
return false;
|
|
2171
|
+
}
|
|
2172
|
+
var globalSelector = /^(?:\*|html|:root|body|\*::?before|\*::?after|::?before|::?after)$/;
|
|
2173
|
+
function reducedMotion(source) {
|
|
2174
|
+
let global = false;
|
|
2175
|
+
const stilled = /* @__PURE__ */ new Set();
|
|
2176
|
+
for (const entry of source) {
|
|
2177
|
+
entry.root.walkAtRules("media", (media) => {
|
|
2178
|
+
if (!reducedMotionQuery(media, "reduce")) {
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
media.walkRules((rule) => {
|
|
2182
|
+
let touchesMotion = false;
|
|
2183
|
+
rule.walkDecls((decl) => {
|
|
2184
|
+
if (/^(?:animation|transition)/.test(decl.prop)) {
|
|
2185
|
+
touchesMotion = true;
|
|
2186
|
+
}
|
|
2187
|
+
});
|
|
2188
|
+
if (!touchesMotion) {
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
for (const selector of rule.selectors.map(normaliseSelector)) {
|
|
2192
|
+
stilled.add(selector);
|
|
2193
|
+
if (globalSelector.test(selector)) {
|
|
2194
|
+
global = true;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
});
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
if (global) {
|
|
2201
|
+
return [];
|
|
2202
|
+
}
|
|
2203
|
+
const problems = [];
|
|
2204
|
+
for (const entry of source) {
|
|
2205
|
+
entry.root.walkDecls((decl) => {
|
|
2206
|
+
if (!motionProperties.has(decl.prop) || stillValue.test(decl.value.trim())) {
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
const parent = decl.parent;
|
|
2210
|
+
if (insideMedia(parent, "reduce") || insideMedia(parent, "no-preference") || insideTheme(parent) || parent?.type !== "rule") {
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
const selectors = parent.selectors.map(
|
|
2214
|
+
normaliseSelector
|
|
2215
|
+
);
|
|
2216
|
+
if (selectors.every((selector) => stilled.has(selector))) {
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
const line = decl.source?.start?.line;
|
|
2220
|
+
problems.push(
|
|
2221
|
+
problem(
|
|
2222
|
+
`${selectors.join(", ")} sets ${decl.prop} with no prefers-reduced-motion path.`,
|
|
2223
|
+
"Wrap the motion in @media (prefers-reduced-motion: no-preference), or still it in a @media (prefers-reduced-motion: reduce) block.",
|
|
2224
|
+
location("css/reduced-motion", {
|
|
2225
|
+
file: entry.file,
|
|
2226
|
+
...line ? { line } : {}
|
|
2227
|
+
})
|
|
2228
|
+
)
|
|
2229
|
+
);
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
return problems;
|
|
2233
|
+
}
|
|
2234
|
+
var classAttribute = /\[\s*class\s*[*^$~|]?=\s*(?:"[^"]*"|'[^']*'|[^\]\s]+)\s*(?:[is]\s*)?\]/gi;
|
|
2235
|
+
function classPatches(source) {
|
|
2236
|
+
const problems = [];
|
|
2237
|
+
for (const entry of source) {
|
|
2238
|
+
entry.root.walkRules((rule) => {
|
|
2239
|
+
for (const match of rule.selector.matchAll(classAttribute)) {
|
|
2240
|
+
const line = rule.source?.start?.line;
|
|
2241
|
+
problems.push(
|
|
2242
|
+
problem(
|
|
2243
|
+
`${match[0]} patches elements by their class attribute.`,
|
|
2244
|
+
"Style the element through a theme token or its own class. Dark mode goes through tokens, not by matching utility classes.",
|
|
2245
|
+
location("css/class-patch", {
|
|
2246
|
+
file: entry.file,
|
|
2247
|
+
...line ? { line } : {}
|
|
2248
|
+
})
|
|
2249
|
+
)
|
|
2250
|
+
);
|
|
2251
|
+
}
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
return problems;
|
|
2255
|
+
}
|
|
2256
|
+
var colourLiteral = /#[0-9a-f]{3,8}\b|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^()]*\)/gi;
|
|
2257
|
+
var trivialValue = /^(?:0|none|inherit|initial|unset|auto|transparent|currentcolor|0px)$/;
|
|
2258
|
+
function literalTokens(source) {
|
|
2259
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
2260
|
+
for (const entry of source) {
|
|
2261
|
+
entry.root.walkDecls(/^--/, (decl) => {
|
|
2262
|
+
const value = compact(decl.value);
|
|
2263
|
+
if (value.includes("var(") || trivialValue.test(value) || tokens.has(value)) {
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
const parent = decl.parent;
|
|
2267
|
+
const rootLevel = parent?.type === "rule" && parent.selectors.some(
|
|
2268
|
+
(selector) => /^(?::root|html)$/.test(selector.trim())
|
|
2269
|
+
);
|
|
2270
|
+
if (insideTheme(parent) || rootLevel) {
|
|
2271
|
+
tokens.set(value, decl.prop);
|
|
2272
|
+
}
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
if (tokens.size === 0) {
|
|
2276
|
+
return [];
|
|
2277
|
+
}
|
|
2278
|
+
const problems = [];
|
|
2279
|
+
for (const entry of source) {
|
|
2280
|
+
entry.root.walkDecls((decl) => {
|
|
2281
|
+
if (decl.prop.startsWith("--") || insideTheme(decl.parent)) {
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
const prop = decl.prop.toLowerCase();
|
|
2285
|
+
const candidates = [];
|
|
2286
|
+
if (/radius$/.test(prop) || /shadow$/.test(prop)) {
|
|
2287
|
+
candidates.push(decl.value);
|
|
2288
|
+
}
|
|
2289
|
+
if (/(?:^|-)(?:color|background|border|outline|fill|stroke|shadow|decoration)/.test(
|
|
2290
|
+
prop
|
|
2291
|
+
) || prop === "color" || prop === "fill" || prop === "stroke") {
|
|
2292
|
+
candidates.push(...decl.value.match(colourLiteral) ?? []);
|
|
2293
|
+
}
|
|
2294
|
+
for (const candidate of new Set(candidates.map(compact))) {
|
|
2295
|
+
const token = tokens.get(candidate);
|
|
2296
|
+
if (!token || trivialValue.test(candidate)) {
|
|
2297
|
+
continue;
|
|
2298
|
+
}
|
|
2299
|
+
const line = decl.source?.start?.line;
|
|
2300
|
+
problems.push(
|
|
2301
|
+
problem(
|
|
2302
|
+
`${decl.prop}: ${decl.value} restates the token ${token}.`,
|
|
2303
|
+
`Use var(${token}).`,
|
|
2304
|
+
location("css/literal-token", {
|
|
2305
|
+
file: entry.file,
|
|
2306
|
+
...line ? { line } : {}
|
|
2307
|
+
})
|
|
2308
|
+
)
|
|
2309
|
+
);
|
|
2310
|
+
break;
|
|
2311
|
+
}
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
return problems;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
// src/css.ts
|
|
2318
|
+
async function checkCss({
|
|
2319
|
+
cwd,
|
|
2320
|
+
signal
|
|
2321
|
+
}) {
|
|
2322
|
+
signal?.throwIfAborted();
|
|
2323
|
+
const inputs = await readCssInputs(cwd);
|
|
2324
|
+
if (inputs.sourceCss.length === 0 && inputs.builtCss.length === 0) {
|
|
2325
|
+
return skipped("css", "No source or built CSS files were found.");
|
|
2326
|
+
}
|
|
2327
|
+
const problems = analyseCss(inputs);
|
|
2328
|
+
signal?.throwIfAborted();
|
|
2329
|
+
const failed = problems.some(({ rule }) => CSS_ERRORS.has(rule));
|
|
2330
|
+
return step("css", failed ? "failed" : "passed", problems);
|
|
2331
|
+
}
|
|
2332
|
+
|
|
1527
2333
|
// src/lint.ts
|
|
1528
2334
|
import zappie from "eslint-plugin-zappie";
|
|
1529
2335
|
import { createRequire } from "module";
|
|
1530
|
-
import
|
|
2336
|
+
import path9 from "path";
|
|
1531
2337
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
1532
2338
|
async function checkLint({
|
|
1533
2339
|
cwd,
|
|
@@ -1536,7 +2342,7 @@ async function checkLint({
|
|
|
1536
2342
|
signal?.throwIfAborted();
|
|
1537
2343
|
let ESLint;
|
|
1538
2344
|
try {
|
|
1539
|
-
const require2 = createRequire(
|
|
2345
|
+
const require2 = createRequire(path9.join(cwd, "package.json"));
|
|
1540
2346
|
const loadedEslint = await import(pathToFileURL2(require2.resolve("eslint")).href);
|
|
1541
2347
|
ESLint = loadedEslint.ESLint;
|
|
1542
2348
|
} catch (error) {
|
|
@@ -1602,7 +2408,9 @@ function recommendedRules() {
|
|
|
1602
2408
|
Object.assign(result, config.rules ?? {});
|
|
1603
2409
|
}
|
|
1604
2410
|
return Object.fromEntries(
|
|
1605
|
-
Object.entries(result).filter(
|
|
2411
|
+
Object.entries(result).filter(
|
|
2412
|
+
([name]) => name.startsWith("zappie/") || name.startsWith("jsx-a11y/")
|
|
2413
|
+
)
|
|
1606
2414
|
);
|
|
1607
2415
|
}
|
|
1608
2416
|
function level(value) {
|
|
@@ -1625,7 +2433,7 @@ async function sourceFile(cwd) {
|
|
|
1625
2433
|
for (const candidate of candidates) {
|
|
1626
2434
|
try {
|
|
1627
2435
|
await import("fs/promises").then(
|
|
1628
|
-
({ access: access3 }) => access3(
|
|
2436
|
+
({ access: access3 }) => access3(path9.join(cwd, candidate))
|
|
1629
2437
|
);
|
|
1630
2438
|
return candidate;
|
|
1631
2439
|
} catch {
|
|
@@ -1638,9 +2446,61 @@ function message2(error) {
|
|
|
1638
2446
|
return error instanceof Error ? error.message : String(error);
|
|
1639
2447
|
}
|
|
1640
2448
|
|
|
2449
|
+
// src/readiness.ts
|
|
2450
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
2451
|
+
import path10 from "path";
|
|
2452
|
+
import { pathToFileURL as pathToFileURL3 } from "url";
|
|
2453
|
+
async function checkReadiness({
|
|
2454
|
+
cwd,
|
|
2455
|
+
signal
|
|
2456
|
+
}) {
|
|
2457
|
+
signal?.throwIfAborted();
|
|
2458
|
+
const site = (await loadConfig(cwd)).value;
|
|
2459
|
+
const optOut = site.readiness?.routeDiscovery?.optOut;
|
|
2460
|
+
if (optOut) {
|
|
2461
|
+
return step("readiness", "passed", [
|
|
2462
|
+
problem(
|
|
2463
|
+
"Initial route discovery is disabled by an explained exception",
|
|
2464
|
+
`Remove readiness.routeDiscovery.optOut after you enable initial route discovery. Reason: ${optOut.reason}`,
|
|
2465
|
+
{ rule: "readiness/route-discovery-opt-out", file: "zappie.config.ts" }
|
|
2466
|
+
)
|
|
2467
|
+
]);
|
|
2468
|
+
}
|
|
2469
|
+
const file = path10.join(cwd, "react-router.config.ts");
|
|
2470
|
+
try {
|
|
2471
|
+
await readFile9(file, "utf8");
|
|
2472
|
+
const version = Date.now();
|
|
2473
|
+
const config = await import(`${pathToFileURL3(file).href}?readiness=${version}`);
|
|
2474
|
+
if (config.default?.routeDiscovery?.mode === "initial") {
|
|
2475
|
+
return step("readiness", "passed");
|
|
2476
|
+
}
|
|
2477
|
+
} catch (error) {
|
|
2478
|
+
return step("readiness", "failed", [
|
|
2479
|
+
problem(
|
|
2480
|
+
message3(error),
|
|
2481
|
+
'Create react-router.config.ts and set routeDiscovery: { mode: "initial" }.',
|
|
2482
|
+
{
|
|
2483
|
+
rule: "readiness/route-discovery",
|
|
2484
|
+
file: "react-router.config.ts"
|
|
2485
|
+
}
|
|
2486
|
+
)
|
|
2487
|
+
]);
|
|
2488
|
+
}
|
|
2489
|
+
return step("readiness", "failed", [
|
|
2490
|
+
problem(
|
|
2491
|
+
"Initial route discovery is not enabled",
|
|
2492
|
+
'Set routeDiscovery: { mode: "initial" } in react-router.config.ts so item routes are ready before a shopper taps.',
|
|
2493
|
+
{ rule: "readiness/route-discovery", file: "react-router.config.ts" }
|
|
2494
|
+
)
|
|
2495
|
+
]);
|
|
2496
|
+
}
|
|
2497
|
+
function message3(error) {
|
|
2498
|
+
return error instanceof Error ? error.message : String(error);
|
|
2499
|
+
}
|
|
2500
|
+
|
|
1641
2501
|
// src/secrets.ts
|
|
1642
|
-
import { lstat, readFile as
|
|
1643
|
-
import
|
|
2502
|
+
import { lstat as lstat2, readFile as readFile10, readdir as readdir3 } from "fs/promises";
|
|
2503
|
+
import path11 from "path";
|
|
1644
2504
|
|
|
1645
2505
|
// src/secret-patterns.ts
|
|
1646
2506
|
var FIX = "Move the value to a server environment variable and rotate it.";
|
|
@@ -1721,18 +2581,18 @@ var SECRET_FILE_SIZE_CAP = 1024 * 1024;
|
|
|
1721
2581
|
async function scanSecrets(dir) {
|
|
1722
2582
|
const findings = [];
|
|
1723
2583
|
for (const file of await filesToScan(dir)) {
|
|
1724
|
-
const info = await
|
|
2584
|
+
const info = await lstat2(file);
|
|
1725
2585
|
if (!info.isFile() || info.size > SECRET_FILE_SIZE_CAP) {
|
|
1726
2586
|
continue;
|
|
1727
2587
|
}
|
|
1728
|
-
const contents = await
|
|
2588
|
+
const contents = await readFile10(file);
|
|
1729
2589
|
if (contents.includes(0)) {
|
|
1730
2590
|
continue;
|
|
1731
2591
|
}
|
|
1732
2592
|
const source = contents.toString("utf8");
|
|
1733
2593
|
for (const match of secretMatches(
|
|
1734
2594
|
source,
|
|
1735
|
-
|
|
2595
|
+
path11.basename(file).startsWith(".env")
|
|
1736
2596
|
)) {
|
|
1737
2597
|
findings.push({
|
|
1738
2598
|
rule: match.rule,
|
|
@@ -1763,17 +2623,17 @@ async function checkSecrets({
|
|
|
1763
2623
|
async function filesToScan(cwd) {
|
|
1764
2624
|
const files = [];
|
|
1765
2625
|
for (const directory of ["app", "public"]) {
|
|
1766
|
-
await
|
|
2626
|
+
await collect3(path11.join(cwd, directory), files);
|
|
1767
2627
|
}
|
|
1768
|
-
await
|
|
1769
|
-
|
|
2628
|
+
await collect3(
|
|
2629
|
+
path11.join(cwd, "build/client"),
|
|
1770
2630
|
files,
|
|
1771
2631
|
(file) => file.endsWith(".js")
|
|
1772
2632
|
);
|
|
1773
2633
|
try {
|
|
1774
|
-
for (const entry of await
|
|
2634
|
+
for (const entry of await readdir3(cwd, { withFileTypes: true })) {
|
|
1775
2635
|
if (entry.isFile() && rootConfig(entry.name)) {
|
|
1776
|
-
files.push(
|
|
2636
|
+
files.push(path11.join(cwd, entry.name));
|
|
1777
2637
|
}
|
|
1778
2638
|
}
|
|
1779
2639
|
} catch {
|
|
@@ -1781,15 +2641,15 @@ async function filesToScan(cwd) {
|
|
|
1781
2641
|
}
|
|
1782
2642
|
return [...new Set(files)].sort();
|
|
1783
2643
|
}
|
|
1784
|
-
async function
|
|
2644
|
+
async function collect3(directory, files, accept = () => true) {
|
|
1785
2645
|
try {
|
|
1786
|
-
for (const entry of await
|
|
2646
|
+
for (const entry of await readdir3(directory, { withFileTypes: true })) {
|
|
1787
2647
|
if (entry.isSymbolicLink()) {
|
|
1788
2648
|
continue;
|
|
1789
2649
|
}
|
|
1790
|
-
const file =
|
|
2650
|
+
const file = path11.join(directory, entry.name);
|
|
1791
2651
|
if (entry.isDirectory()) {
|
|
1792
|
-
await
|
|
2652
|
+
await collect3(file, files, accept);
|
|
1793
2653
|
} else if (entry.isFile() && accept(file)) {
|
|
1794
2654
|
files.push(file);
|
|
1795
2655
|
}
|
|
@@ -1808,13 +2668,13 @@ function lineAt(source, index) {
|
|
|
1808
2668
|
}
|
|
1809
2669
|
|
|
1810
2670
|
// src/test-step.ts
|
|
1811
|
-
import
|
|
2671
|
+
import path12 from "path";
|
|
1812
2672
|
async function checkTests({
|
|
1813
2673
|
cwd,
|
|
1814
2674
|
packageManager,
|
|
1815
2675
|
signal
|
|
1816
2676
|
}) {
|
|
1817
|
-
const manifest = await json(
|
|
2677
|
+
const manifest = await json(path12.join(cwd, "package.json"));
|
|
1818
2678
|
if (!manifest.scripts?.test) {
|
|
1819
2679
|
return skipped("test", "No test script is defined in package.json.");
|
|
1820
2680
|
}
|
|
@@ -1837,11 +2697,11 @@ async function checkTests({
|
|
|
1837
2697
|
}
|
|
1838
2698
|
|
|
1839
2699
|
// src/types-step.ts
|
|
1840
|
-
import
|
|
2700
|
+
import path13 from "path";
|
|
1841
2701
|
async function checkTypes(options) {
|
|
1842
2702
|
const { cwd, packageManager, signal } = options;
|
|
1843
2703
|
signal?.throwIfAborted();
|
|
1844
|
-
const manifest = await json(
|
|
2704
|
+
const manifest = await json(path13.join(cwd, "package.json"));
|
|
1845
2705
|
if (manifest.dependencies?.["react-router"]) {
|
|
1846
2706
|
const generated = execArgs(packageManager, "react-router", ["typegen"]);
|
|
1847
2707
|
const result2 = await command(
|
|
@@ -1900,7 +2760,9 @@ var runners = [
|
|
|
1900
2760
|
{ name: "test", run: checkTests },
|
|
1901
2761
|
{ name: "build", run: checkBuild },
|
|
1902
2762
|
{ name: "secrets", run: checkSecrets },
|
|
1903
|
-
{ name: "
|
|
2763
|
+
{ name: "readiness", run: checkReadiness },
|
|
2764
|
+
{ name: "budgets", run: checkBudgets },
|
|
2765
|
+
{ name: "css", run: checkCss }
|
|
1904
2766
|
];
|
|
1905
2767
|
async function runCheck(options) {
|
|
1906
2768
|
const steps = [];
|
|
@@ -1913,7 +2775,7 @@ async function runCheck(options) {
|
|
|
1913
2775
|
options.signal?.throwIfAborted();
|
|
1914
2776
|
checked = step(runner.name, "failed", [
|
|
1915
2777
|
problem(
|
|
1916
|
-
|
|
2778
|
+
message4(error),
|
|
1917
2779
|
"Fix the step setup and run zappie check again."
|
|
1918
2780
|
)
|
|
1919
2781
|
]);
|
|
@@ -1928,17 +2790,21 @@ async function runCheck(options) {
|
|
|
1928
2790
|
steps
|
|
1929
2791
|
};
|
|
1930
2792
|
}
|
|
1931
|
-
function
|
|
2793
|
+
function message4(error) {
|
|
1932
2794
|
return error instanceof Error ? error.message : String(error);
|
|
1933
2795
|
}
|
|
1934
2796
|
export {
|
|
1935
2797
|
BUILTIN_ALLOWLIST_SOURCE,
|
|
2798
|
+
CSS_ERRORS,
|
|
1936
2799
|
SECRET_FILE_SIZE_CAP,
|
|
2800
|
+
analyseCss,
|
|
1937
2801
|
checkAllowlist,
|
|
1938
2802
|
checkBudgets,
|
|
1939
2803
|
checkBuild,
|
|
1940
2804
|
checkConfig,
|
|
2805
|
+
checkCss,
|
|
1941
2806
|
checkLint,
|
|
2807
|
+
checkReadiness,
|
|
1942
2808
|
checkSecrets,
|
|
1943
2809
|
checkTests,
|
|
1944
2810
|
checkTypes,
|