@zappie/check 0.1.3 → 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 +18 -0
- package/README.md +18 -1
- package/dist/index.d.ts +24 -2
- package/dist/index.js +841 -61
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/schema/check-result.v1.json +2 -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, path13, 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) {
|
|
@@ -189,7 +189,7 @@ var rootKeys = [
|
|
|
189
189
|
function validateConfig(input) {
|
|
190
190
|
const issues = [];
|
|
191
191
|
const issue = (value) => issues.push(value);
|
|
192
|
-
const error = (
|
|
192
|
+
const error = (path14, code, message5) => issue({ path: path14, code, message: message5, severity: "error" });
|
|
193
193
|
if (!record(input)) {
|
|
194
194
|
return {
|
|
195
195
|
valid: false,
|
|
@@ -284,12 +284,12 @@ function validateConfig(input) {
|
|
|
284
284
|
issues
|
|
285
285
|
};
|
|
286
286
|
}
|
|
287
|
-
function requiredSlug(value,
|
|
287
|
+
function requiredSlug(value, path14, error) {
|
|
288
288
|
if (!nonblank(value)) {
|
|
289
289
|
error(
|
|
290
|
-
|
|
290
|
+
path14,
|
|
291
291
|
value === void 0 ? "missing_required" : "invalid_type",
|
|
292
|
-
`Expected a non-empty storefront slug. Set ${
|
|
292
|
+
`Expected a non-empty storefront slug. Set ${path14} to the slug used by createZappie().`
|
|
293
293
|
);
|
|
294
294
|
}
|
|
295
295
|
}
|
|
@@ -308,29 +308,29 @@ function theme(value, error) {
|
|
|
308
308
|
);
|
|
309
309
|
}
|
|
310
310
|
}
|
|
311
|
-
function stringList(value,
|
|
311
|
+
function stringList(value, path14, error, options = {}) {
|
|
312
312
|
if (value === void 0) {
|
|
313
313
|
return void 0;
|
|
314
314
|
}
|
|
315
315
|
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
316
316
|
error(
|
|
317
|
-
|
|
317
|
+
path14,
|
|
318
318
|
"invalid_type",
|
|
319
|
-
`Expected an array of strings. Set ${
|
|
319
|
+
`Expected an array of strings. Set ${path14} to string values.`
|
|
320
320
|
);
|
|
321
321
|
return void 0;
|
|
322
322
|
}
|
|
323
323
|
if (options.nonempty && value.length === 0) {
|
|
324
324
|
error(
|
|
325
|
-
|
|
325
|
+
path14,
|
|
326
326
|
"invalid_format",
|
|
327
|
-
`Expected at least one value. Add a value to ${
|
|
327
|
+
`Expected at least one value. Add a value to ${path14} or remove the field.`
|
|
328
328
|
);
|
|
329
329
|
}
|
|
330
330
|
value.forEach((entry, index) => {
|
|
331
331
|
if (options.slug && !nonblank(entry)) {
|
|
332
332
|
error(
|
|
333
|
-
`${
|
|
333
|
+
`${path14}[${index}]`,
|
|
334
334
|
"invalid_format",
|
|
335
335
|
"Expected a non-empty branch slug. Remove the empty value or use a branch slug."
|
|
336
336
|
);
|
|
@@ -338,17 +338,17 @@ function stringList(value, path13, error, options = {}) {
|
|
|
338
338
|
});
|
|
339
339
|
if (options.unique && new Set(value).size !== value.length) {
|
|
340
340
|
error(
|
|
341
|
-
|
|
341
|
+
path14,
|
|
342
342
|
"duplicate",
|
|
343
|
-
`Expected unique values. Remove duplicates from ${
|
|
343
|
+
`Expected unique values. Remove duplicates from ${path14}.`
|
|
344
344
|
);
|
|
345
345
|
}
|
|
346
346
|
return value;
|
|
347
347
|
}
|
|
348
348
|
function paths(value, error) {
|
|
349
349
|
const values = stringList(value, "prerender", error, { unique: true });
|
|
350
|
-
values?.forEach((
|
|
351
|
-
if (!
|
|
350
|
+
values?.forEach((path14, index) => {
|
|
351
|
+
if (!path14.startsWith("/")) {
|
|
352
352
|
error(
|
|
353
353
|
`prerender[${index}]`,
|
|
354
354
|
"invalid_format",
|
|
@@ -851,11 +851,11 @@ var record2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
|
851
851
|
var includes = (values, key) => values.includes(key);
|
|
852
852
|
function validateTheme(input) {
|
|
853
853
|
const issues = [];
|
|
854
|
-
const issue = (
|
|
855
|
-
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) => {
|
|
856
856
|
for (const key of Object.keys(obj)) {
|
|
857
857
|
if (!allowed.includes(key)) {
|
|
858
|
-
issue(
|
|
858
|
+
issue(path14 ? `${path14}.${key}` : key, "invalid_theme", "Unknown field");
|
|
859
859
|
}
|
|
860
860
|
}
|
|
861
861
|
};
|
|
@@ -916,9 +916,9 @@ function validateTheme(input) {
|
|
|
916
916
|
}
|
|
917
917
|
}
|
|
918
918
|
for (const [key, value] of Object.entries(values)) {
|
|
919
|
-
const
|
|
919
|
+
const path14 = `${group}.${key}`;
|
|
920
920
|
if (!includes(TOKEN_NAMES, key)) {
|
|
921
|
-
issue(
|
|
921
|
+
issue(path14, "unknown_token", "Unknown semantic token");
|
|
922
922
|
continue;
|
|
923
923
|
}
|
|
924
924
|
let code, valid = false;
|
|
@@ -936,7 +936,7 @@ function validateTheme(input) {
|
|
|
936
936
|
valid = typeof value === "string" && safeValue(value) && (includes(DURATION_TOKENS, key) ? isDuration(value) : isEasing(value));
|
|
937
937
|
}
|
|
938
938
|
if (!valid) {
|
|
939
|
-
issue(
|
|
939
|
+
issue(path14, code, "Invalid or unsafe token value");
|
|
940
940
|
}
|
|
941
941
|
}
|
|
942
942
|
}
|
|
@@ -946,30 +946,30 @@ function validateTheme(input) {
|
|
|
946
946
|
} else {
|
|
947
947
|
const roles = /* @__PURE__ */ new Set();
|
|
948
948
|
input.fonts.forEach((font, i) => {
|
|
949
|
-
const
|
|
949
|
+
const path14 = `fonts[${i}]`;
|
|
950
950
|
if (!record2(font)) {
|
|
951
|
-
issue(
|
|
951
|
+
issue(path14, "invalid_font", "Expected a font object");
|
|
952
952
|
return;
|
|
953
953
|
}
|
|
954
|
-
keys(font, ["family", "source", "url", "weights", "role"],
|
|
954
|
+
keys(font, ["family", "source", "url", "weights", "role"], path14);
|
|
955
955
|
if (typeof font.family !== "string" || !isFontFamily(font.family) || font.family.includes(",")) {
|
|
956
956
|
issue(
|
|
957
|
-
`${
|
|
957
|
+
`${path14}.family`,
|
|
958
958
|
"invalid_font",
|
|
959
959
|
"Expected one unquoted font family"
|
|
960
960
|
);
|
|
961
961
|
}
|
|
962
962
|
if (typeof font.source !== "string" || !["google", "self", "system"].includes(font.source)) {
|
|
963
|
-
issue(`${
|
|
963
|
+
issue(`${path14}.source`, "invalid_font", "Unknown font source");
|
|
964
964
|
}
|
|
965
965
|
if (typeof font.role !== "string" || !["display", "body", "mono"].includes(font.role) || roles.has(font.role)) {
|
|
966
|
-
issue(`${
|
|
966
|
+
issue(`${path14}.role`, "invalid_font", "Expected a unique font role");
|
|
967
967
|
}
|
|
968
968
|
roles.add(String(font.role));
|
|
969
969
|
if (font.source === "self" || font.url !== void 0) {
|
|
970
970
|
if (typeof font.url !== "string" || !isAssetUrl(font.url, false) || !new URL(font.url).pathname.endsWith(".woff2")) {
|
|
971
971
|
issue(
|
|
972
|
-
`${
|
|
972
|
+
`${path14}.url`,
|
|
973
973
|
"invalid_font",
|
|
974
974
|
"Expected an HTTPS .woff2 URL"
|
|
975
975
|
);
|
|
@@ -979,7 +979,7 @@ function validateTheme(input) {
|
|
|
979
979
|
(w) => typeof w !== "number" || !Number.isInteger(w) || w < 1 || w > 1e3
|
|
980
980
|
))) {
|
|
981
981
|
issue(
|
|
982
|
-
`${
|
|
982
|
+
`${path14}.weights`,
|
|
983
983
|
"invalid_font",
|
|
984
984
|
"Expected 1\u20139 integer weights between 1 and 1000"
|
|
985
985
|
);
|
|
@@ -1556,10 +1556,784 @@ ${stderr}`.split(/\r?\n/).map((line) => line.trimEnd()).filter(Boolean).slice(-1
|
|
|
1556
1556
|
return lines.length > 0 ? lines.join("\n") : "The build exited without output.";
|
|
1557
1557
|
}
|
|
1558
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
|
+
|
|
1559
2333
|
// src/lint.ts
|
|
1560
2334
|
import zappie from "eslint-plugin-zappie";
|
|
1561
2335
|
import { createRequire } from "module";
|
|
1562
|
-
import
|
|
2336
|
+
import path9 from "path";
|
|
1563
2337
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
1564
2338
|
async function checkLint({
|
|
1565
2339
|
cwd,
|
|
@@ -1568,7 +2342,7 @@ async function checkLint({
|
|
|
1568
2342
|
signal?.throwIfAborted();
|
|
1569
2343
|
let ESLint;
|
|
1570
2344
|
try {
|
|
1571
|
-
const require2 = createRequire(
|
|
2345
|
+
const require2 = createRequire(path9.join(cwd, "package.json"));
|
|
1572
2346
|
const loadedEslint = await import(pathToFileURL2(require2.resolve("eslint")).href);
|
|
1573
2347
|
ESLint = loadedEslint.ESLint;
|
|
1574
2348
|
} catch (error) {
|
|
@@ -1634,7 +2408,9 @@ function recommendedRules() {
|
|
|
1634
2408
|
Object.assign(result, config.rules ?? {});
|
|
1635
2409
|
}
|
|
1636
2410
|
return Object.fromEntries(
|
|
1637
|
-
Object.entries(result).filter(
|
|
2411
|
+
Object.entries(result).filter(
|
|
2412
|
+
([name]) => name.startsWith("zappie/") || name.startsWith("jsx-a11y/")
|
|
2413
|
+
)
|
|
1638
2414
|
);
|
|
1639
2415
|
}
|
|
1640
2416
|
function level(value) {
|
|
@@ -1657,7 +2433,7 @@ async function sourceFile(cwd) {
|
|
|
1657
2433
|
for (const candidate of candidates) {
|
|
1658
2434
|
try {
|
|
1659
2435
|
await import("fs/promises").then(
|
|
1660
|
-
({ access: access3 }) => access3(
|
|
2436
|
+
({ access: access3 }) => access3(path9.join(cwd, candidate))
|
|
1661
2437
|
);
|
|
1662
2438
|
return candidate;
|
|
1663
2439
|
} catch {
|
|
@@ -1671,8 +2447,8 @@ function message2(error) {
|
|
|
1671
2447
|
}
|
|
1672
2448
|
|
|
1673
2449
|
// src/readiness.ts
|
|
1674
|
-
import { readFile as
|
|
1675
|
-
import
|
|
2450
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
2451
|
+
import path10 from "path";
|
|
1676
2452
|
import { pathToFileURL as pathToFileURL3 } from "url";
|
|
1677
2453
|
async function checkReadiness({
|
|
1678
2454
|
cwd,
|
|
@@ -1690,9 +2466,9 @@ async function checkReadiness({
|
|
|
1690
2466
|
)
|
|
1691
2467
|
]);
|
|
1692
2468
|
}
|
|
1693
|
-
const file =
|
|
2469
|
+
const file = path10.join(cwd, "react-router.config.ts");
|
|
1694
2470
|
try {
|
|
1695
|
-
await
|
|
2471
|
+
await readFile9(file, "utf8");
|
|
1696
2472
|
const version = Date.now();
|
|
1697
2473
|
const config = await import(`${pathToFileURL3(file).href}?readiness=${version}`);
|
|
1698
2474
|
if (config.default?.routeDiscovery?.mode === "initial") {
|
|
@@ -1723,8 +2499,8 @@ function message3(error) {
|
|
|
1723
2499
|
}
|
|
1724
2500
|
|
|
1725
2501
|
// src/secrets.ts
|
|
1726
|
-
import { lstat, readFile as
|
|
1727
|
-
import
|
|
2502
|
+
import { lstat as lstat2, readFile as readFile10, readdir as readdir3 } from "fs/promises";
|
|
2503
|
+
import path11 from "path";
|
|
1728
2504
|
|
|
1729
2505
|
// src/secret-patterns.ts
|
|
1730
2506
|
var FIX = "Move the value to a server environment variable and rotate it.";
|
|
@@ -1805,18 +2581,18 @@ var SECRET_FILE_SIZE_CAP = 1024 * 1024;
|
|
|
1805
2581
|
async function scanSecrets(dir) {
|
|
1806
2582
|
const findings = [];
|
|
1807
2583
|
for (const file of await filesToScan(dir)) {
|
|
1808
|
-
const info = await
|
|
2584
|
+
const info = await lstat2(file);
|
|
1809
2585
|
if (!info.isFile() || info.size > SECRET_FILE_SIZE_CAP) {
|
|
1810
2586
|
continue;
|
|
1811
2587
|
}
|
|
1812
|
-
const contents = await
|
|
2588
|
+
const contents = await readFile10(file);
|
|
1813
2589
|
if (contents.includes(0)) {
|
|
1814
2590
|
continue;
|
|
1815
2591
|
}
|
|
1816
2592
|
const source = contents.toString("utf8");
|
|
1817
2593
|
for (const match of secretMatches(
|
|
1818
2594
|
source,
|
|
1819
|
-
|
|
2595
|
+
path11.basename(file).startsWith(".env")
|
|
1820
2596
|
)) {
|
|
1821
2597
|
findings.push({
|
|
1822
2598
|
rule: match.rule,
|
|
@@ -1847,17 +2623,17 @@ async function checkSecrets({
|
|
|
1847
2623
|
async function filesToScan(cwd) {
|
|
1848
2624
|
const files = [];
|
|
1849
2625
|
for (const directory of ["app", "public"]) {
|
|
1850
|
-
await
|
|
2626
|
+
await collect3(path11.join(cwd, directory), files);
|
|
1851
2627
|
}
|
|
1852
|
-
await
|
|
1853
|
-
|
|
2628
|
+
await collect3(
|
|
2629
|
+
path11.join(cwd, "build/client"),
|
|
1854
2630
|
files,
|
|
1855
2631
|
(file) => file.endsWith(".js")
|
|
1856
2632
|
);
|
|
1857
2633
|
try {
|
|
1858
|
-
for (const entry of await
|
|
2634
|
+
for (const entry of await readdir3(cwd, { withFileTypes: true })) {
|
|
1859
2635
|
if (entry.isFile() && rootConfig(entry.name)) {
|
|
1860
|
-
files.push(
|
|
2636
|
+
files.push(path11.join(cwd, entry.name));
|
|
1861
2637
|
}
|
|
1862
2638
|
}
|
|
1863
2639
|
} catch {
|
|
@@ -1865,15 +2641,15 @@ async function filesToScan(cwd) {
|
|
|
1865
2641
|
}
|
|
1866
2642
|
return [...new Set(files)].sort();
|
|
1867
2643
|
}
|
|
1868
|
-
async function
|
|
2644
|
+
async function collect3(directory, files, accept = () => true) {
|
|
1869
2645
|
try {
|
|
1870
|
-
for (const entry of await
|
|
2646
|
+
for (const entry of await readdir3(directory, { withFileTypes: true })) {
|
|
1871
2647
|
if (entry.isSymbolicLink()) {
|
|
1872
2648
|
continue;
|
|
1873
2649
|
}
|
|
1874
|
-
const file =
|
|
2650
|
+
const file = path11.join(directory, entry.name);
|
|
1875
2651
|
if (entry.isDirectory()) {
|
|
1876
|
-
await
|
|
2652
|
+
await collect3(file, files, accept);
|
|
1877
2653
|
} else if (entry.isFile() && accept(file)) {
|
|
1878
2654
|
files.push(file);
|
|
1879
2655
|
}
|
|
@@ -1892,13 +2668,13 @@ function lineAt(source, index) {
|
|
|
1892
2668
|
}
|
|
1893
2669
|
|
|
1894
2670
|
// src/test-step.ts
|
|
1895
|
-
import
|
|
2671
|
+
import path12 from "path";
|
|
1896
2672
|
async function checkTests({
|
|
1897
2673
|
cwd,
|
|
1898
2674
|
packageManager,
|
|
1899
2675
|
signal
|
|
1900
2676
|
}) {
|
|
1901
|
-
const manifest = await json(
|
|
2677
|
+
const manifest = await json(path12.join(cwd, "package.json"));
|
|
1902
2678
|
if (!manifest.scripts?.test) {
|
|
1903
2679
|
return skipped("test", "No test script is defined in package.json.");
|
|
1904
2680
|
}
|
|
@@ -1921,11 +2697,11 @@ async function checkTests({
|
|
|
1921
2697
|
}
|
|
1922
2698
|
|
|
1923
2699
|
// src/types-step.ts
|
|
1924
|
-
import
|
|
2700
|
+
import path13 from "path";
|
|
1925
2701
|
async function checkTypes(options) {
|
|
1926
2702
|
const { cwd, packageManager, signal } = options;
|
|
1927
2703
|
signal?.throwIfAborted();
|
|
1928
|
-
const manifest = await json(
|
|
2704
|
+
const manifest = await json(path13.join(cwd, "package.json"));
|
|
1929
2705
|
if (manifest.dependencies?.["react-router"]) {
|
|
1930
2706
|
const generated = execArgs(packageManager, "react-router", ["typegen"]);
|
|
1931
2707
|
const result2 = await command(
|
|
@@ -1985,7 +2761,8 @@ var runners = [
|
|
|
1985
2761
|
{ name: "build", run: checkBuild },
|
|
1986
2762
|
{ name: "secrets", run: checkSecrets },
|
|
1987
2763
|
{ name: "readiness", run: checkReadiness },
|
|
1988
|
-
{ name: "budgets", run: checkBudgets }
|
|
2764
|
+
{ name: "budgets", run: checkBudgets },
|
|
2765
|
+
{ name: "css", run: checkCss }
|
|
1989
2766
|
];
|
|
1990
2767
|
async function runCheck(options) {
|
|
1991
2768
|
const steps = [];
|
|
@@ -2018,11 +2795,14 @@ function message4(error) {
|
|
|
2018
2795
|
}
|
|
2019
2796
|
export {
|
|
2020
2797
|
BUILTIN_ALLOWLIST_SOURCE,
|
|
2798
|
+
CSS_ERRORS,
|
|
2021
2799
|
SECRET_FILE_SIZE_CAP,
|
|
2800
|
+
analyseCss,
|
|
2022
2801
|
checkAllowlist,
|
|
2023
2802
|
checkBudgets,
|
|
2024
2803
|
checkBuild,
|
|
2025
2804
|
checkConfig,
|
|
2805
|
+
checkCss,
|
|
2026
2806
|
checkLint,
|
|
2027
2807
|
checkReadiness,
|
|
2028
2808
|
checkSecrets,
|