onsystem 0.6.0 → 0.7.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/README.md +28 -21
- package/dist/cli.js +238 -157
- package/dist/{config-lbfW91qB.d.ts → config-1XSTYI44.d.ts} +8 -0
- package/dist/{design-system-A3pRX3Ka.js → design-system-C4bhqLLw.js} +1582 -282
- package/dist/eslint-worker.d.ts +1 -1
- package/dist/eslint-worker.js +2 -2
- package/dist/eslint.js +66 -5
- package/dist/index.d.ts +20 -1
- package/dist/index.js +2 -2
- package/dist/{stdio-CEChNFLR.js → stdio-BojAQ6JP.js} +214 -5
- package/dist/{workspace-CHvUwgz9.js → workspace-Z0tZf_Dv.js} +1 -1
- package/package.json +9 -9
|
@@ -4,10 +4,10 @@ import { pathToFileURL } from "node:url";
|
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
import { escapePath, glob, globSync } from "tinyglobby";
|
|
6
6
|
import ts from "typescript";
|
|
7
|
+
import os from "node:os";
|
|
7
8
|
import { clampChroma, colorsNamed, converter, differenceEuclidean, formatHex, parse } from "culori";
|
|
8
9
|
import { createHash } from "node:crypto";
|
|
9
10
|
import fsp from "node:fs/promises";
|
|
10
|
-
import os from "node:os";
|
|
11
11
|
import postcss from "postcss";
|
|
12
12
|
import { LineCounter, isMap, isScalar, parseDocument, stringify } from "yaml";
|
|
13
13
|
import { stemmer } from "stemmer";
|
|
@@ -23,13 +23,36 @@ const DEFAULT_OPTIONS = {
|
|
|
23
23
|
resolveJsonModule: true
|
|
24
24
|
};
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* The configs read from a project's root without a `tsconfig` option, in order: a
|
|
27
|
+
* JavaScript app keeps its `paths` in jsconfig.json (create-next-app, shadcn/ui with
|
|
28
|
+
* `"tsx": false`), which TypeScript reads when there is no tsconfig.json.
|
|
29
|
+
*/
|
|
30
|
+
const PROJECT_CONFIG_FILES = ["tsconfig.json", "jsconfig.json"];
|
|
31
|
+
/**
|
|
32
|
+
* Build output, which a jsconfig.json's default `include` takes, as its `.js` files are
|
|
33
|
+
* sources to TypeScript: the folders libraries/load.ts skips without a config, and VS Code's
|
|
34
|
+
* jsconfig docs advise excluding.
|
|
35
|
+
*/
|
|
36
|
+
const BUILD_OUTPUT = /* @__PURE__ */ new Set([
|
|
37
|
+
"dist",
|
|
38
|
+
"build",
|
|
39
|
+
"out",
|
|
40
|
+
".next",
|
|
41
|
+
"coverage",
|
|
42
|
+
"public"
|
|
43
|
+
]);
|
|
44
|
+
/** The root's tsconfig.json, else its jsconfig.json. */
|
|
45
|
+
function defaultProjectConfig(root) {
|
|
46
|
+
return PROJECT_CONFIG_FILES.map((file) => path.join(root, file)).find((file) => fs.existsSync(file));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Reads the project's tsconfig, or its jsconfig without one (only from inside
|
|
50
|
+
* the root: walking further up would pick up unrelated configs, e.g. this
|
|
51
|
+
* repository's own when extracting test fixtures).
|
|
29
52
|
*/
|
|
30
53
|
function readProjectConfig(root, tsconfig) {
|
|
31
|
-
const configFile = tsconfig ? path.resolve(root, tsconfig) :
|
|
32
|
-
if (!fs.existsSync(configFile)) return {
|
|
54
|
+
const configFile = tsconfig ? path.resolve(root, tsconfig) : defaultProjectConfig(root);
|
|
55
|
+
if (!configFile || !fs.existsSync(configFile)) return {
|
|
33
56
|
options: { ...DEFAULT_OPTIONS },
|
|
34
57
|
pathsBase: root,
|
|
35
58
|
paths: {},
|
|
@@ -55,7 +78,10 @@ function readProjectConfig(root, tsconfig) {
|
|
|
55
78
|
return ts.sys.readFile(file);
|
|
56
79
|
}
|
|
57
80
|
};
|
|
58
|
-
const
|
|
81
|
+
const dir = path.dirname(configFile);
|
|
82
|
+
const jsconfig = path.basename(configFile) === "jsconfig.json";
|
|
83
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, host, dir, jsconfig ? { allowJs: true } : void 0);
|
|
84
|
+
const fileNames = jsconfig && parsed.raw?.exclude === void 0 ? parsed.fileNames.filter((file) => !inBuildOutput(path.relative(dir, file))) : parsed.fileNames;
|
|
59
85
|
const options = {
|
|
60
86
|
...DEFAULT_OPTIONS,
|
|
61
87
|
...parsed.options
|
|
@@ -69,13 +95,17 @@ function readProjectConfig(root, tsconfig) {
|
|
|
69
95
|
delete options.tsBuildInfoFile;
|
|
70
96
|
return {
|
|
71
97
|
options,
|
|
72
|
-
pathsBase: options.baseUrl ?? (typeof options.pathsBasePath === "string" ? options.pathsBasePath :
|
|
98
|
+
pathsBase: options.baseUrl ?? (typeof options.pathsBasePath === "string" ? options.pathsBasePath : dir),
|
|
73
99
|
paths: options.paths ?? {},
|
|
74
100
|
configFile,
|
|
75
101
|
configFiles,
|
|
76
|
-
fileNames
|
|
102
|
+
fileNames
|
|
77
103
|
};
|
|
78
104
|
}
|
|
105
|
+
/** Whether a file, by its path relative to the config, is in a build output folder. */
|
|
106
|
+
function inBuildOutput(relative) {
|
|
107
|
+
return relative.split(/[\\/]/).slice(0, -1).some((part) => BUILD_OUTPUT.has(part));
|
|
108
|
+
}
|
|
79
109
|
function createProgram(files, config, oldProgram) {
|
|
80
110
|
return ts.createProgram({
|
|
81
111
|
rootNames: files,
|
|
@@ -116,6 +146,24 @@ function isInside(root, file) {
|
|
|
116
146
|
const rel = path.relative(root, file);
|
|
117
147
|
return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
118
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Whether `dir` is the home folder, a folder that holds it, or a filesystem root, links
|
|
151
|
+
* resolved. None of them is a project's workspace or repository, though a stray
|
|
152
|
+
* `~/package.json` with `workspaces`, or a repository of dotfiles, makes one look like it, and
|
|
153
|
+
* from there a `../` path reaches `~/.aws/credentials`.
|
|
154
|
+
*/
|
|
155
|
+
function isHomeOrAbove(dir) {
|
|
156
|
+
const home = os.homedir();
|
|
157
|
+
const holdsHome = (d, h) => d === path.parse(d).root || isInside(d, h);
|
|
158
|
+
return holdsHome(path.resolve(dir), path.resolve(home)) || holdsHome(realOrSelf(dir), realOrSelf(home));
|
|
159
|
+
}
|
|
160
|
+
function realOrSelf(p) {
|
|
161
|
+
try {
|
|
162
|
+
return fs.realpathSync(p);
|
|
163
|
+
} catch {
|
|
164
|
+
return path.resolve(p);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
119
167
|
/** The static directory part of a glob (`components/ui/**\/*.tsx` → `components/ui`). */
|
|
120
168
|
function globBase(glob) {
|
|
121
169
|
const parts = toPosix(glob).split("/");
|
|
@@ -778,7 +826,7 @@ function fromComponentsJson(root, dir, json, project, packages, note = ignore) {
|
|
|
778
826
|
const pkgDir = name ? packages.find(name) : void 0;
|
|
779
827
|
const pkg = pkgDir ? readJson$1(path.join(pkgDir, "package.json")) : void 0;
|
|
780
828
|
if (!name || !pkgDir || !pkg) {
|
|
781
|
-
note(`the ui alias ${ui} resolves through neither tsconfig paths nor a workspace package`);
|
|
829
|
+
note(`the ui alias ${ui} resolves through neither ${project.configFile ? path.basename(project.configFile) : "tsconfig"} paths nor a workspace package`);
|
|
782
830
|
return;
|
|
783
831
|
}
|
|
784
832
|
imports = exportMappings(root, pkgDir, pkg);
|
|
@@ -1090,6 +1138,86 @@ function moduleResolver(root) {
|
|
|
1090
1138
|
};
|
|
1091
1139
|
}
|
|
1092
1140
|
/**
|
|
1141
|
+
* {@link moduleResolver} for a Tailwind config's presets, then a workspace package's
|
|
1142
|
+
* `exports` for what it leaves unresolved: `@repo/tailwind-config` through
|
|
1143
|
+
* `"exports": { ".": "./tailwind.config.ts" }`, as Turborepo's template has it.
|
|
1144
|
+
*/
|
|
1145
|
+
function presetResolver(root) {
|
|
1146
|
+
const resolve = moduleResolver(root);
|
|
1147
|
+
const packages = new PackageFinder(root);
|
|
1148
|
+
return (specifier, fromFile) => {
|
|
1149
|
+
const file = resolve(specifier, fromFile);
|
|
1150
|
+
if (file || specifier.startsWith(".")) return file;
|
|
1151
|
+
const name = packageName(specifier);
|
|
1152
|
+
const dir = name ? packages.find(name) : void 0;
|
|
1153
|
+
return name && dir ? exportedFile(dir, `.${specifier.slice(name.length)}`, (f) => SCRIPT_FILE.test(f)) : void 0;
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Resolves a stylesheet's `@import` to a project file: a path next to the stylesheet
|
|
1158
|
+
* (`./theme.css`, or `theme.css` as plain CSS reads it), or a stylesheet of a workspace
|
|
1159
|
+
* package (`@acme/theme/colors.css`, or `@acme/theme` through its `exports` or its `style`
|
|
1160
|
+
* field). Installed packages (`tailwindcss`) and URLs resolve to nothing.
|
|
1161
|
+
*/
|
|
1162
|
+
function stylesheetResolver(root) {
|
|
1163
|
+
const packages = new PackageFinder(root);
|
|
1164
|
+
const stylesheet = (file) => [file, `${file}.css`].find((f) => f.endsWith(".css") && !/[\\/]node_modules[\\/]/.test(f) && isFile(f));
|
|
1165
|
+
return (specifier, fromFile) => {
|
|
1166
|
+
if (/^(?:[a-z][\w+.-]*:|\/)/i.test(specifier)) return void 0;
|
|
1167
|
+
const relative = stylesheet(path.resolve(path.dirname(fromFile), specifier));
|
|
1168
|
+
if (relative || specifier.startsWith(".")) return relative;
|
|
1169
|
+
const name = packageName(specifier);
|
|
1170
|
+
const dir = name ? packages.find(name) : void 0;
|
|
1171
|
+
if (!name || !dir) return void 0;
|
|
1172
|
+
const subpath = specifier.slice(name.length + 1);
|
|
1173
|
+
const style = readJson$1(path.join(dir, "package.json"))?.style;
|
|
1174
|
+
return exportedFile(dir, subpath ? `./${subpath}` : ".", (f) => f.endsWith(".css")) ?? (subpath ? stylesheet(path.join(dir, subpath)) : stylesheet(path.resolve(dir, typeof style === "string" ? style : "index.css")));
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* The file a package's `exports` map `subpath` (`.`, `./theme`) to, through any condition
|
|
1179
|
+
* (`style`, `import`, `default`) and `*` patterns: the first target on disk, inside the
|
|
1180
|
+
* package, that `accept` takes.
|
|
1181
|
+
*/
|
|
1182
|
+
function exportedFile(dir, subpath, accept) {
|
|
1183
|
+
const exports = readJson$1(path.join(dir, "package.json"))?.exports;
|
|
1184
|
+
const entries = exports && typeof exports === "object" && !Array.isArray(exports) ? Object.keys(exports).every((k) => k.startsWith(".")) ? Object.entries(exports) : [[".", exports]] : exports === void 0 ? [] : [[".", exports]];
|
|
1185
|
+
const matches = [];
|
|
1186
|
+
for (const [key, value] of entries) {
|
|
1187
|
+
const star = key.indexOf("*");
|
|
1188
|
+
if (star === -1) {
|
|
1189
|
+
if (key === subpath) matches.push({
|
|
1190
|
+
middle: "",
|
|
1191
|
+
value,
|
|
1192
|
+
rank: Infinity
|
|
1193
|
+
});
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
const prefix = key.slice(0, star);
|
|
1197
|
+
const suffix = key.slice(star + 1);
|
|
1198
|
+
if (subpath.startsWith(prefix) && subpath.endsWith(suffix) && subpath.length >= prefix.length + suffix.length) {
|
|
1199
|
+
const middle = subpath.slice(prefix.length, subpath.length - suffix.length);
|
|
1200
|
+
matches.push({
|
|
1201
|
+
middle,
|
|
1202
|
+
value,
|
|
1203
|
+
rank: prefix.length
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
matches.sort((a, b) => b.rank - a.rank);
|
|
1208
|
+
for (const { middle, value } of matches) for (const target of exportTargets(value)) {
|
|
1209
|
+
const file = path.resolve(dir, target.replaceAll("*", middle));
|
|
1210
|
+
if (isInside(dir, file) && accept(file) && isFile(file)) return file;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
/** Every target of an export condition map, in order, but its types. */
|
|
1214
|
+
function exportTargets(value) {
|
|
1215
|
+
if (typeof value === "string") return [value];
|
|
1216
|
+
if (Array.isArray(value)) return value.flatMap(exportTargets);
|
|
1217
|
+
if (value && typeof value === "object") return Object.entries(value).flatMap(([condition, nested]) => condition === "types" ? [] : exportTargets(nested));
|
|
1218
|
+
return [];
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1093
1221
|
* Where an import from a workspace package points: its file, or `{}` for a
|
|
1094
1222
|
* subpath the package's `exports` declare but whose target is not on disk
|
|
1095
1223
|
* (built to `dist/`). Undefined when no such module exists. The linter tells
|
|
@@ -1305,24 +1433,47 @@ var PackageFinder = class {
|
|
|
1305
1433
|
}
|
|
1306
1434
|
return this.nodeModules;
|
|
1307
1435
|
}
|
|
1308
|
-
/**
|
|
1436
|
+
/**
|
|
1437
|
+
* The workspace root when `dir` is in it, else `dir` itself. Never the home folder or above:
|
|
1438
|
+
* a stray `~/package.json` with `workspaces` would let a package's config name
|
|
1439
|
+
* `../../../.aws/credentials`.
|
|
1440
|
+
*/
|
|
1309
1441
|
boundaryOf(dir) {
|
|
1310
|
-
if (this.workspace === void 0)
|
|
1442
|
+
if (this.workspace === void 0) {
|
|
1443
|
+
const workspace = workspaceRootOf(this.root);
|
|
1444
|
+
this.workspace = workspace !== void 0 && !isHomeOrAbove(workspace) ? workspace : null;
|
|
1445
|
+
}
|
|
1311
1446
|
return this.workspace !== null && isInside(this.workspace, dir) ? this.workspace : dir;
|
|
1312
1447
|
}
|
|
1313
1448
|
};
|
|
1314
1449
|
/**
|
|
1315
|
-
* A monorepo root: a pnpm-workspace.yaml
|
|
1316
|
-
* a lerna.json with `packages`, or an nx.json.
|
|
1317
|
-
* workspaces, so its roots are among these.
|
|
1450
|
+
* A monorepo root: a pnpm-workspace.yaml whose `packages` name more than the root,
|
|
1451
|
+
* `workspaces` in package.json (npm, Yarn, Bun), a lerna.json with `packages`, or an nx.json.
|
|
1452
|
+
* Turborepo runs on the package manager's workspaces, so its roots are among these. pnpm 11
|
|
1453
|
+
* keeps its settings in a pnpm-workspace.yaml in a single app too (`allowBuilds`, as
|
|
1454
|
+
* create-next-app --use-pnpm leaves it), and reads one without `packages`, or with only
|
|
1455
|
+
* `- '.'`, as the root alone: that is not a monorepo.
|
|
1318
1456
|
*/
|
|
1319
1457
|
function isWorkspaceRoot(dir) {
|
|
1320
|
-
if (
|
|
1458
|
+
if (pnpmWorkspacePackages(dir).length) return true;
|
|
1321
1459
|
if (fs.existsSync(path.join(dir, "nx.json"))) return true;
|
|
1322
1460
|
const pkg = readJson$1(path.join(dir, "package.json"));
|
|
1323
1461
|
if (pkg !== void 0 && pkg.workspaces !== void 0) return true;
|
|
1324
1462
|
return Array.isArray(readJson$1(path.join(dir, "lerna.json"))?.packages);
|
|
1325
1463
|
}
|
|
1464
|
+
/**
|
|
1465
|
+
* The `packages` of the pnpm-workspace.yaml in `dir` when they name a package other than the
|
|
1466
|
+
* root (`.`); none otherwise, and none without the file: then it holds only settings.
|
|
1467
|
+
*/
|
|
1468
|
+
function pnpmWorkspacePackages(dir) {
|
|
1469
|
+
let list;
|
|
1470
|
+
try {
|
|
1471
|
+
list = pnpmPackages(fs.readFileSync(path.join(dir, "pnpm-workspace.yaml"), "utf8"));
|
|
1472
|
+
} catch {
|
|
1473
|
+
return [];
|
|
1474
|
+
}
|
|
1475
|
+
return list.some((p) => !p.startsWith("!") && path.posix.normalize(toPosix(p).replace(/\/+$/, "")) !== ".") ? list : [];
|
|
1476
|
+
}
|
|
1326
1477
|
/** The nearest workspace root at or above `start`. */
|
|
1327
1478
|
function workspaceRootOf(start) {
|
|
1328
1479
|
for (let dir = start;; dir = path.dirname(dir)) {
|
|
@@ -1380,7 +1531,8 @@ function stampOf(files) {
|
|
|
1380
1531
|
}
|
|
1381
1532
|
/**
|
|
1382
1533
|
* `packages` from pnpm-workspace.yaml, `workspaces` from package.json (npm, Yarn, Bun), or
|
|
1383
|
-
* `packages` from lerna.json, in that order of precedence.
|
|
1534
|
+
* `packages` from lerna.json, in that order of precedence. A pnpm-workspace.yaml that names
|
|
1535
|
+
* no package beyond the root holds only settings, so the next source decides.
|
|
1384
1536
|
*/
|
|
1385
1537
|
function workspacePatterns(dir) {
|
|
1386
1538
|
const split = (list, source) => ({
|
|
@@ -1388,8 +1540,8 @@ function workspacePatterns(dir) {
|
|
|
1388
1540
|
exclude: list.filter((p) => p.startsWith("!")).map((p) => toPosix(p.slice(1))),
|
|
1389
1541
|
source
|
|
1390
1542
|
});
|
|
1391
|
-
const
|
|
1392
|
-
if (
|
|
1543
|
+
const pnpm = pnpmWorkspacePackages(dir);
|
|
1544
|
+
if (pnpm.length) return split(pnpm, "pnpm-workspace.yaml");
|
|
1393
1545
|
const workspaces = readJson$1(path.join(dir, "package.json"))?.workspaces;
|
|
1394
1546
|
const list = Array.isArray(workspaces) ? workspaces : asRecord(workspaces).packages;
|
|
1395
1547
|
if (Array.isArray(list)) return split(list.filter((p) => typeof p === "string"), "package.json workspaces");
|
|
@@ -1428,12 +1580,13 @@ function workspacePackageDirs(root, nx = true) {
|
|
|
1428
1580
|
}
|
|
1429
1581
|
/**
|
|
1430
1582
|
* The `packages` list of a pnpm-workspace.yaml, block (`- apps/*`, indented or
|
|
1431
|
-
* not) or flow (`[apps/*, packages/*]`) style, with comments
|
|
1432
|
-
* read, so this stays a few lines
|
|
1583
|
+
* not) or flow (`[apps/*, packages/*]`) style, with comments, a quoted key or a
|
|
1584
|
+
* leading byte order mark. Only that key is read, so this stays a few lines
|
|
1585
|
+
* rather than a YAML parser.
|
|
1433
1586
|
*/
|
|
1434
1587
|
function pnpmPackages(text) {
|
|
1435
|
-
const lines = text.split(/\r?\n/);
|
|
1436
|
-
const start = lines.findIndex((line) => /^packages\s*:/.test(line));
|
|
1588
|
+
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
1589
|
+
const start = lines.findIndex((line) => /^(['"]?)packages\1\s*:/.test(line));
|
|
1437
1590
|
if (start === -1) return [];
|
|
1438
1591
|
const uncomment = (s) => s.replace(/(?:^|\s)#.*$/, "").trim();
|
|
1439
1592
|
const unquote = (s) => s.trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
@@ -1506,9 +1659,21 @@ function isBareHsl(value) {
|
|
|
1506
1659
|
return BARE_HSL.test(value.trim());
|
|
1507
1660
|
}
|
|
1508
1661
|
/**
|
|
1662
|
+
* RGB channels without the function, `22 163 74` (commas and `/ alpha` allowed): how
|
|
1663
|
+
* Tailwind v3's docs store colors, to be used as `rgb(var(--success) / <alpha-value>)`.
|
|
1664
|
+
* Three numbers alone are no sure color, so they count as one only in a token already
|
|
1665
|
+
* known to be a color: one the Tailwind config reads with `rgb()`.
|
|
1666
|
+
*/
|
|
1667
|
+
const BARE_RGB = /^(\d{1,3})(?:\s*,\s*|\s+)(\d{1,3})(?:\s*,\s*|\s+)(\d{1,3})(?:\s*\/\s*(\d*\.?\d+%?))?$/;
|
|
1668
|
+
function isBareRgb(value) {
|
|
1669
|
+
const match = BARE_RGB.exec(value.trim());
|
|
1670
|
+
return match !== null && match.slice(1, 4).every((channel) => Number(channel) <= 255);
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1509
1673
|
* Parses any CSS color (hex, rgb(), hsl(), oklch(), named, ...) into OKLCH.
|
|
1510
1674
|
* With `bareHsl`, for token values, HSL channels count too: in a stylesheet
|
|
1511
1675
|
* they can only be a color, while in a class or style they are not one.
|
|
1676
|
+
* With `bareRgb`, for the values of color tokens, RGB channels count too.
|
|
1512
1677
|
*/
|
|
1513
1678
|
function parseColor(value, options = {}) {
|
|
1514
1679
|
const text = value.trim();
|
|
@@ -1519,10 +1684,27 @@ function parseColor(value, options = {}) {
|
|
|
1519
1684
|
const [, h, deg = "", s, l, alpha] = hsl;
|
|
1520
1685
|
parsed = parse(`hsl(${h}${deg} ${s}% ${l}%${alpha ? ` / ${alpha}` : ""})`);
|
|
1521
1686
|
}
|
|
1687
|
+
const rgb = !parsed && options.bareRgb && isBareRgb(text) ? BARE_RGB.exec(text) : null;
|
|
1688
|
+
if (rgb) {
|
|
1689
|
+
const [, r, g, b, alpha] = rgb;
|
|
1690
|
+
parsed = parse(`rgb(${r} ${g} ${b}${alpha ? ` / ${alpha}` : ""})`);
|
|
1691
|
+
}
|
|
1522
1692
|
return parsed ? toOklch$1(parsed) : void 0;
|
|
1523
1693
|
}
|
|
1694
|
+
/** A color's opacity, from 0 (transparent) to 1 (opaque). */
|
|
1695
|
+
function opacity(color) {
|
|
1696
|
+
return color.alpha ?? 1;
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* How much a difference in opacity weighs against ΔE, which leaves it out. Translucency is
|
|
1700
|
+
* part of what a color does (an overlay, a tint, a frosted header), so a token more than
|
|
1701
|
+
* 0.02 apart in opacity is never close (5 × 0.02 is CLOSE_COLOR): `bg-[#e7000b33]`, the red
|
|
1702
|
+
* at 20%, is not `bg-destructive`. One step of an 8-digit hex (1/255) is still the same color.
|
|
1703
|
+
*/
|
|
1704
|
+
const OPACITY_WEIGHT = 5;
|
|
1705
|
+
/** ΔE between two colors, plus their difference in opacity. */
|
|
1524
1706
|
function colorDistance(a, b) {
|
|
1525
|
-
return okDistance(a, b);
|
|
1707
|
+
return okDistance(a, b) + OPACITY_WEIGHT * Math.abs(opacity(a) - opacity(b));
|
|
1526
1708
|
}
|
|
1527
1709
|
function isNamedColor(value) {
|
|
1528
1710
|
return Object.hasOwn(colorsNamed, value.toLowerCase());
|
|
@@ -1602,6 +1784,25 @@ function sameHue(color, token, tinted = isTinted(color), tolerance = 15) {
|
|
|
1602
1784
|
function formatDeltaE(deltaE) {
|
|
1603
1785
|
return deltaE < 5e-4 ? "exact match" : `ΔE ${deltaE.toFixed(3)}`;
|
|
1604
1786
|
}
|
|
1787
|
+
/** `opaque`, `20% opaque` or `transparent`, to the nearest percent. */
|
|
1788
|
+
function formatOpacity(color) {
|
|
1789
|
+
const percent = Math.round(opacity(color) * 100);
|
|
1790
|
+
return percent === 100 ? "opaque" : percent === 0 ? "transparent" : `${String(percent)}% opaque`;
|
|
1791
|
+
}
|
|
1792
|
+
/**
|
|
1793
|
+
* When a token's value (`token`) and a color differ in opacity, a sentence that says so:
|
|
1794
|
+
* `destructive is opaque, and this color is 20% opaque.`
|
|
1795
|
+
*/
|
|
1796
|
+
function formatOpacityGap(color, token, name) {
|
|
1797
|
+
const [own, its] = [formatOpacity(color), formatOpacity(token)];
|
|
1798
|
+
return own === its ? void 0 : `${name} is ${its}, and this color is ${own}.`;
|
|
1799
|
+
}
|
|
1800
|
+
/** ΔE between a color and a token's value, which `formatOpacityGap` completes. */
|
|
1801
|
+
function formatDistance(color, token) {
|
|
1802
|
+
const deltaE = okDistance(color, token);
|
|
1803
|
+
if (deltaE < 5e-4 && formatOpacityGap(color, token, "")) return "same color";
|
|
1804
|
+
return formatDeltaE(deltaE);
|
|
1805
|
+
}
|
|
1605
1806
|
//#endregion
|
|
1606
1807
|
//#region src/libraries/mui.ts
|
|
1607
1808
|
/** The palette groups MUI's components take as a `color` value (`<Button color="primary">`). */
|
|
@@ -1945,21 +2146,57 @@ function themeTokens(options, source) {
|
|
|
1945
2146
|
const LIBRARY_IDS = ["mui"];
|
|
1946
2147
|
/** The libraries onsystem can read, by package. */
|
|
1947
2148
|
const LIBRARIES = [mui];
|
|
2149
|
+
/**
|
|
2150
|
+
* Published component libraries onsystem does not read yet, by package. A project that depends
|
|
2151
|
+
* on one is told so, since nothing in it is checked and a clean result would look like a
|
|
2152
|
+
* misconfigured one.
|
|
2153
|
+
*/
|
|
2154
|
+
const UNREAD_LIBRARIES = [
|
|
2155
|
+
{
|
|
2156
|
+
package: "@chakra-ui/react",
|
|
2157
|
+
title: "Chakra UI"
|
|
2158
|
+
},
|
|
2159
|
+
{
|
|
2160
|
+
package: "@mantine/core",
|
|
2161
|
+
title: "Mantine"
|
|
2162
|
+
},
|
|
2163
|
+
{
|
|
2164
|
+
package: "@heroui/react",
|
|
2165
|
+
title: "HeroUI"
|
|
2166
|
+
},
|
|
2167
|
+
{
|
|
2168
|
+
package: "@radix-ui/themes",
|
|
2169
|
+
title: "Radix Themes"
|
|
2170
|
+
},
|
|
2171
|
+
{
|
|
2172
|
+
package: "antd",
|
|
2173
|
+
title: "Ant Design"
|
|
2174
|
+
}
|
|
2175
|
+
];
|
|
1948
2176
|
function libraryAdapter(name) {
|
|
1949
2177
|
return LIBRARIES.find((library) => library.package === name);
|
|
1950
2178
|
}
|
|
2179
|
+
/** A library's name for messages, read or not: `MUI`, `Chakra UI`. */
|
|
2180
|
+
function libraryTitle(name) {
|
|
2181
|
+
return [...LIBRARIES, ...UNREAD_LIBRARIES].find((l) => l.package === name)?.title ?? name;
|
|
2182
|
+
}
|
|
2183
|
+
/** Why a library in `UNREAD_LIBRARIES` is not read: `a dependency onsystem does not read yet (…)`. */
|
|
2184
|
+
function notReadYet() {
|
|
2185
|
+
return `a dependency onsystem does not read yet (it reads only ${LIBRARIES.map((l) => l.title).join(", ")} so far)`;
|
|
2186
|
+
}
|
|
1951
2187
|
/**
|
|
1952
2188
|
* The libraries `root`'s package.json depends on (dependencies, devDependencies or
|
|
1953
2189
|
* peerDependencies), each found where Node.js would find it: in `node_modules` at the root or a
|
|
1954
2190
|
* folder above it, as a monorepo hoists it. Nothing in the package is run. `ids` is the config's
|
|
1955
2191
|
* `libraries`: undefined reads every library onsystem supports, false none, a list those named;
|
|
1956
|
-
* the dependencies it leaves out are `off`.
|
|
2192
|
+
* the dependencies it leaves out are `off`. Those in `UNREAD_LIBRARIES` are `unread`.
|
|
1957
2193
|
*/
|
|
1958
2194
|
function detectLibraries(root, ids) {
|
|
1959
2195
|
const pkg = readJson(path.join(root, "package.json"));
|
|
1960
2196
|
if (!pkg) return {
|
|
1961
2197
|
read: [],
|
|
1962
|
-
off: []
|
|
2198
|
+
off: [],
|
|
2199
|
+
unread: []
|
|
1963
2200
|
};
|
|
1964
2201
|
const dependencies = new Set([
|
|
1965
2202
|
"dependencies",
|
|
@@ -1969,23 +2206,24 @@ function detectLibraries(root, ids) {
|
|
|
1969
2206
|
const value = pkg[field];
|
|
1970
2207
|
return value && typeof value === "object" ? Object.keys(value) : [];
|
|
1971
2208
|
}));
|
|
1972
|
-
const
|
|
1973
|
-
const dir = installedPackage(root,
|
|
2209
|
+
const sourceOf = (name) => {
|
|
2210
|
+
const dir = installedPackage(root, name);
|
|
1974
2211
|
const version = dir ? readJson(path.join(dir, "package.json"))?.version : void 0;
|
|
1975
|
-
|
|
1976
|
-
package:
|
|
2212
|
+
return {
|
|
2213
|
+
package: name,
|
|
1977
2214
|
dir,
|
|
1978
2215
|
version: typeof version === "string" ? version : void 0
|
|
1979
2216
|
};
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
});
|
|
2217
|
+
};
|
|
2218
|
+
const found = LIBRARIES.filter((library) => dependencies.has(library.package)).map((library) => ({
|
|
2219
|
+
id: library.id,
|
|
2220
|
+
source: sourceOf(library.package)
|
|
2221
|
+
}));
|
|
1985
2222
|
const wanted = (id) => ids === void 0 || ids !== false && ids.includes(id);
|
|
1986
2223
|
return {
|
|
1987
2224
|
read: found.filter((f) => wanted(f.id)).map((f) => f.source),
|
|
1988
|
-
off: found.filter((f) => !wanted(f.id)).map((f) => f.source)
|
|
2225
|
+
off: found.filter((f) => !wanted(f.id)).map((f) => f.source),
|
|
2226
|
+
unread: UNREAD_LIBRARIES.filter((library) => dependencies.has(library.package)).map((library) => sourceOf(library.package))
|
|
1989
2227
|
};
|
|
1990
2228
|
}
|
|
1991
2229
|
/** The folder `name` is installed in for code in `root`, looking up through `node_modules` folders. */
|
|
@@ -2249,12 +2487,15 @@ function usageFor(token, tailwindNamespace) {
|
|
|
2249
2487
|
}
|
|
2250
2488
|
/**
|
|
2251
2489
|
* The CSS value that uses the token: `var(--primary)`, or `hsl(var(--primary))`
|
|
2252
|
-
* when the property holds bare HSL
|
|
2253
|
-
* on their own.
|
|
2490
|
+
* (`rgb(var(--success))`) when the property holds bare HSL (RGB) channels
|
|
2491
|
+
* (Tailwind v3), which are no color on their own.
|
|
2254
2492
|
*/
|
|
2255
2493
|
function cssReference(token) {
|
|
2256
2494
|
if (!token.cssVar) return void 0;
|
|
2257
|
-
|
|
2495
|
+
if (token.category !== "color") return `var(${token.cssVar})`;
|
|
2496
|
+
if (isBareHsl(token.value)) return `hsl(var(${token.cssVar}))`;
|
|
2497
|
+
if (isBareRgb(token.value)) return `rgb(var(${token.cssVar}))`;
|
|
2498
|
+
return `var(${token.cssVar})`;
|
|
2258
2499
|
}
|
|
2259
2500
|
function utilities(namespace, key) {
|
|
2260
2501
|
switch (namespace) {
|
|
@@ -2301,6 +2542,7 @@ const MAX_FILES = 12;
|
|
|
2301
2542
|
*/
|
|
2302
2543
|
function readTailwindColors(file, resolve) {
|
|
2303
2544
|
const colors = /* @__PURE__ */ new Map();
|
|
2545
|
+
const rgb = /* @__PURE__ */ new Set();
|
|
2304
2546
|
const files = [];
|
|
2305
2547
|
const queue = [file];
|
|
2306
2548
|
while (queue.length && files.length < MAX_FILES) {
|
|
@@ -2316,7 +2558,7 @@ function readTailwindColors(file, resolve) {
|
|
|
2316
2558
|
const source = ts.createSourceFile(current, text, ts.ScriptTarget.Latest, true, scriptKind(current));
|
|
2317
2559
|
const visit = (node) => {
|
|
2318
2560
|
if (ts.isPropertyAssignment(node) && propertyName(node.name) === "colors" && ts.isObjectLiteralExpression(node.initializer)) {
|
|
2319
|
-
collect(node.initializer, [], colors);
|
|
2561
|
+
collect(node.initializer, [], colors, rgb);
|
|
2320
2562
|
return;
|
|
2321
2563
|
}
|
|
2322
2564
|
const specifier = importedSpecifier(node);
|
|
@@ -2328,11 +2570,12 @@ function readTailwindColors(file, resolve) {
|
|
|
2328
2570
|
}
|
|
2329
2571
|
return {
|
|
2330
2572
|
colors,
|
|
2573
|
+
rgb,
|
|
2331
2574
|
files
|
|
2332
2575
|
};
|
|
2333
2576
|
}
|
|
2334
2577
|
/** `primary: { DEFAULT: "hsl(var(--primary))", foreground: … }` → `--primary` → `primary`, `--primary-foreground` → `primary-foreground`. */
|
|
2335
|
-
function collect(object, keys, colors) {
|
|
2578
|
+
function collect(object, keys, colors, rgb) {
|
|
2336
2579
|
for (const property of object.properties) {
|
|
2337
2580
|
if (!ts.isPropertyAssignment(property)) continue;
|
|
2338
2581
|
const key = propertyName(property.name);
|
|
@@ -2340,13 +2583,15 @@ function collect(object, keys, colors) {
|
|
|
2340
2583
|
const value = property.initializer;
|
|
2341
2584
|
const nested = [...keys, key];
|
|
2342
2585
|
if (ts.isObjectLiteralExpression(value)) {
|
|
2343
|
-
collect(value, nested, colors);
|
|
2586
|
+
collect(value, nested, colors, rgb);
|
|
2344
2587
|
continue;
|
|
2345
2588
|
}
|
|
2346
2589
|
if (!ts.isStringLiteralLike(value)) continue;
|
|
2347
2590
|
const variable = /var\(\s*(--[\w-]+)/.exec(value.text)?.[1];
|
|
2348
2591
|
const name = nested.filter((k) => k !== "DEFAULT").join("-");
|
|
2349
|
-
if (variable
|
|
2592
|
+
if (!variable || !name || colors.has(variable)) continue;
|
|
2593
|
+
colors.set(variable, name);
|
|
2594
|
+
if (/^\s*rgba?\(\s*var\(/.test(value.text)) rgb.add(variable);
|
|
2350
2595
|
}
|
|
2351
2596
|
}
|
|
2352
2597
|
/**
|
|
@@ -2449,9 +2694,9 @@ function applyTailwindRadii(tokens, radii, configFile = "tailwind.config") {
|
|
|
2449
2694
|
havePx.add(px);
|
|
2450
2695
|
}
|
|
2451
2696
|
}
|
|
2452
|
-
/** The specifier of `require("x")`, `import x from "x"` or `import("x")`. */
|
|
2697
|
+
/** The specifier of `require("x")`, `import x from "x"`, `export { default } from "x"` or `import("x")`. */
|
|
2453
2698
|
function importedSpecifier(node) {
|
|
2454
|
-
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) return node.moduleSpecifier.text;
|
|
2699
|
+
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) return node.moduleSpecifier.text;
|
|
2455
2700
|
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require") && node.arguments[0] && ts.isStringLiteralLike(node.arguments[0])) return node.arguments[0].text;
|
|
2456
2701
|
}
|
|
2457
2702
|
function scriptKind(file) {
|
|
@@ -2500,9 +2745,13 @@ const SHADCN_COLORS = /* @__PURE__ */ new Set([
|
|
|
2500
2745
|
* Gives color tokens their Tailwind v3 class names: the key the config maps
|
|
2501
2746
|
* the custom property to, or, when the config has no colors we can read,
|
|
2502
2747
|
* shadcn/ui's convention. Tokens that already have a key (`@theme`) keep it.
|
|
2748
|
+
* A property the config reads as RGB channels (`rgb(var(--success) / <alpha-value>)`)
|
|
2749
|
+
* is a color token when its values are such channels (`22 163 74`), which the
|
|
2750
|
+
* stylesheet alone leaves undecided.
|
|
2503
2751
|
*/
|
|
2504
|
-
function applyTailwindColors(tokens, colors) {
|
|
2752
|
+
function applyTailwindColors(tokens, colors, rgb = /* @__PURE__ */ new Set()) {
|
|
2505
2753
|
for (const token of tokens) {
|
|
2754
|
+
if (token.category === "other" && token.cssVar && rgb.has(token.cssVar) && [token.value, ...Object.values(token.modes ?? {})].every(isBareRgb)) token.category = "color";
|
|
2506
2755
|
if (token.category !== "color" || token.tailwind !== void 0 || !token.cssVar) continue;
|
|
2507
2756
|
const name = token.cssVar.slice(2);
|
|
2508
2757
|
const key = colors.size ? colors.get(token.cssVar) : SHADCN_COLORS.has(name) ? name.replace(/^sidebar-background$/, "sidebar") : void 0;
|
|
@@ -2511,6 +2760,49 @@ function applyTailwindColors(tokens, colors) {
|
|
|
2511
2760
|
token.usage = usageFor(token, "--color");
|
|
2512
2761
|
}
|
|
2513
2762
|
}
|
|
2763
|
+
/** Enough of a package's stylesheets to find its theme, without reading a whole design system. */
|
|
2764
|
+
const MAX_PRESET_STYLESHEETS = 200;
|
|
2765
|
+
/**
|
|
2766
|
+
* The stylesheets that set the custom properties a Tailwind v3 config maps to colors, when
|
|
2767
|
+
* they sit in a workspace package the config reads its presets from rather than in the
|
|
2768
|
+
* project: Dub's `@dub/tailwind-config`, whose `themes.css` sets `--content-error` for the
|
|
2769
|
+
* `content-error` its config maps. Each CSS file of such a package (outside `node_modules`
|
|
2770
|
+
* and build output) that declares one of them, in path order. The project's own folder, and
|
|
2771
|
+
* a folder that holds it (a preset at the workspace root), are not searched.
|
|
2772
|
+
*/
|
|
2773
|
+
function presetStylesheets(read, root) {
|
|
2774
|
+
if (!read.colors.size) return [];
|
|
2775
|
+
const packages = /* @__PURE__ */ new Set();
|
|
2776
|
+
for (const file of read.files) {
|
|
2777
|
+
const dir = packageDir(path.dirname(file));
|
|
2778
|
+
if (dir && !isInside(dir, root)) packages.add(dir);
|
|
2779
|
+
}
|
|
2780
|
+
const out = [];
|
|
2781
|
+
for (const dir of packages) {
|
|
2782
|
+
const sheets = globSync("**/*.css", {
|
|
2783
|
+
cwd: dir,
|
|
2784
|
+
absolute: true,
|
|
2785
|
+
ignore: ["**/node_modules/**", "**/{dist,build,out,.next,.turbo,coverage}/**"]
|
|
2786
|
+
}).sort().slice(0, MAX_PRESET_STYLESHEETS);
|
|
2787
|
+
for (const sheet of sheets) {
|
|
2788
|
+
let text;
|
|
2789
|
+
try {
|
|
2790
|
+
text = fs.readFileSync(sheet, "utf8");
|
|
2791
|
+
} catch {
|
|
2792
|
+
continue;
|
|
2793
|
+
}
|
|
2794
|
+
if ([...text.matchAll(/(--[\w-]+)\s*:/g)].some((m) => read.colors.has(m[1] ?? ""))) out.push(path.resolve(sheet));
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
return out;
|
|
2798
|
+
}
|
|
2799
|
+
/** The nearest folder at or above `dir` with a package.json. */
|
|
2800
|
+
function packageDir(dir) {
|
|
2801
|
+
for (let current = dir;; current = path.dirname(current)) {
|
|
2802
|
+
if (fs.existsSync(path.join(current, "package.json"))) return current;
|
|
2803
|
+
if (current === path.dirname(current)) return void 0;
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2514
2806
|
/** The project's Tailwind v3 config: `tailwind.config` in components.json, else `tailwind.config.*` in the root. */
|
|
2515
2807
|
function findTailwindConfig(root) {
|
|
2516
2808
|
try {
|
|
@@ -2767,6 +3059,7 @@ async function loadConfigWith(options, context = {}) {
|
|
|
2767
3059
|
...t,
|
|
2768
3060
|
path: slashGlob(t.path)
|
|
2769
3061
|
})),
|
|
3062
|
+
...!options.tokens?.length && configTokens === void 0 ? { tokensDetected: true } : {},
|
|
2770
3063
|
docs: (options.docs?.length ? options.docs : configDocs === void 0 ? [.../* @__PURE__ */ new Set([...DEFAULT_DOCS, ...detection?.docs ?? []])] : configDocs.map(fromBase)).map(slashGlob),
|
|
2771
3064
|
tsconfig,
|
|
2772
3065
|
importPath: config.importPath,
|
|
@@ -2777,6 +3070,7 @@ async function loadConfigWith(options, context = {}) {
|
|
|
2777
3070
|
libraries: libraries.read,
|
|
2778
3071
|
libraryIds: config.libraries,
|
|
2779
3072
|
librariesOff: libraries.off,
|
|
3073
|
+
librariesUnread: libraries.unread,
|
|
2780
3074
|
elements: config.elements ?? {},
|
|
2781
3075
|
includeDesignSystem: options.includeDesignSystem ?? (declared ? void 0 : config.includeDesignSystem) ?? false,
|
|
2782
3076
|
rules,
|
|
@@ -3118,8 +3412,14 @@ function buildComponent$1(context, candidate) {
|
|
|
3118
3412
|
const definitions = [...primary, ...nested];
|
|
3119
3413
|
const { props, inherits, openProps } = extractProps(context, candidate, typeNode, param);
|
|
3120
3414
|
const own = new Set(primary.flatMap((d) => d.variants.map((v) => v.name)));
|
|
3121
|
-
const
|
|
3122
|
-
|
|
3415
|
+
const adds = (name) => !own.has(name) && props.some((p) => p.name === name);
|
|
3416
|
+
const variants = mergeVariants([...primary.flatMap((d) => d.variants), ...nested.flatMap((d) => d.variants).filter((v) => adds(v.name))]);
|
|
3417
|
+
const layouts = /* @__PURE__ */ new Map();
|
|
3418
|
+
for (const definition of definitions) for (const [name, layout] of variantLayouts(definition)) {
|
|
3419
|
+
if (!primary.includes(definition) && !adds(name)) continue;
|
|
3420
|
+
layouts.set(name, [...layouts.get(name) ?? [], ...layout]);
|
|
3421
|
+
}
|
|
3422
|
+
mergeVariantProps(props, variants, layouts);
|
|
3123
3423
|
applyDestructuredDefaults(props, param);
|
|
3124
3424
|
const position = sourceFile.getLineAndCharacterOfPosition(candidate.declaration.getStart());
|
|
3125
3425
|
const info = {
|
|
@@ -3280,10 +3580,11 @@ function extractProps(context, candidate, typeNode, param) {
|
|
|
3280
3580
|
if (checker.getIndexInfosOfType(t).length) openProps = true;
|
|
3281
3581
|
for (const symbol of checker.getPropertiesOfType(t)) {
|
|
3282
3582
|
const entry = own.get(symbol.getName()) ?? {
|
|
3283
|
-
|
|
3583
|
+
symbols: [],
|
|
3284
3584
|
requiredIn: 0,
|
|
3285
3585
|
origin: originOf(symbol)
|
|
3286
3586
|
};
|
|
3587
|
+
if (!entry.symbols.length || type.isUnion() && !entry.symbols.includes(symbol)) entry.symbols.push(symbol);
|
|
3287
3588
|
if (!(symbol.flags & ts.SymbolFlags.Optional)) entry.requiredIn++;
|
|
3288
3589
|
own.set(symbol.getName(), entry);
|
|
3289
3590
|
}
|
|
@@ -3292,9 +3593,9 @@ function extractProps(context, candidate, typeNode, param) {
|
|
|
3292
3593
|
for (const [name, { origin }] of own) byOrigin.set(origin, [...byOrigin.get(origin) ?? [], name]);
|
|
3293
3594
|
const summarised = new Set([...byOrigin].filter(([origin, names]) => origin === "react" || origin !== "own" && names.length > MAX_LISTED_INHERITED$1).map(([origin]) => origin));
|
|
3294
3595
|
const props = [];
|
|
3295
|
-
for (const [name, {
|
|
3596
|
+
for (const [name, { symbols, requiredIn, origin }] of own) {
|
|
3296
3597
|
if (summarised.has(origin)) continue;
|
|
3297
|
-
props.push(describeProp$1(context, name,
|
|
3598
|
+
props.push(describeProp$1(context, name, symbols, location, requiredIn === types.length, origin));
|
|
3298
3599
|
}
|
|
3299
3600
|
const inherits = [];
|
|
3300
3601
|
for (const origin of summarised) {
|
|
@@ -3302,7 +3603,7 @@ function extractProps(context, candidate, typeNode, param) {
|
|
|
3302
3603
|
const key = registerPropSet(context.propSets, names);
|
|
3303
3604
|
const from = origin === "react" ? inheritedLabel(context, typeNode) : origin;
|
|
3304
3605
|
const deprecated = names.filter((name) => {
|
|
3305
|
-
return (own.get(name)?.
|
|
3606
|
+
return (own.get(name)?.symbols[0])?.getJsDocTags(checker).some((tag) => tag.name === "deprecated");
|
|
3306
3607
|
});
|
|
3307
3608
|
inherits.push({
|
|
3308
3609
|
from,
|
|
@@ -3374,21 +3675,35 @@ function originOf(symbol) {
|
|
|
3374
3675
|
if (pkg === "@types/react" || pkg === "typescript") return "react";
|
|
3375
3676
|
return pkg;
|
|
3376
3677
|
}
|
|
3377
|
-
|
|
3678
|
+
/**
|
|
3679
|
+
* A prop that several members of a union declare takes what any of them allows: its type is
|
|
3680
|
+
* the union of theirs, and so are its values when each declares a set of string literals.
|
|
3681
|
+
*/
|
|
3682
|
+
function describeProp$1(context, name, symbols, location, required, origin) {
|
|
3378
3683
|
const { checker } = context;
|
|
3379
|
-
const
|
|
3380
|
-
const
|
|
3684
|
+
const allows = (symbol) => !(checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(symbol, location)).flags & ts.TypeFlags.Never);
|
|
3685
|
+
const members = symbols.some(allows) ? symbols.filter(allows) : symbols;
|
|
3686
|
+
const printed = /* @__PURE__ */ new Map();
|
|
3687
|
+
let values = [];
|
|
3688
|
+
for (const symbol of members) {
|
|
3689
|
+
const declared = checker.getTypeOfSymbolAtLocation(symbol, location);
|
|
3690
|
+
const type = checker.getNonNullableType(declared);
|
|
3691
|
+
const text = unwrapParens(checker.typeToString(declared, void 0, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope).replace(/^(?:(?:undefined|null) \| )+|(?: \| (?:undefined|null))+$/g, ""));
|
|
3692
|
+
if (text) printed.set(text, !type.isUnion() && (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0));
|
|
3693
|
+
const found = literalValues$2(type);
|
|
3694
|
+
values = found && values ? [...values, ...found] : void 0;
|
|
3695
|
+
}
|
|
3381
3696
|
const prop = {
|
|
3382
3697
|
name,
|
|
3383
|
-
type: truncate(
|
|
3698
|
+
type: truncate([...printed].map(([text, fn]) => fn && printed.size > 1 ? `(${text})` : text).join(" | ") || "unknown", 200),
|
|
3384
3699
|
required,
|
|
3385
3700
|
kind: "prop"
|
|
3386
3701
|
};
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
const description = ts.displayPartsToString(
|
|
3702
|
+
if (values) prop.values = unique(values);
|
|
3703
|
+
const documented = members.find((s) => s.getDocumentationComment(checker).length) ?? members[0];
|
|
3704
|
+
const description = documented ? ts.displayPartsToString(documented.getDocumentationComment(checker)).trim() : "";
|
|
3390
3705
|
if (description) prop.description = description;
|
|
3391
|
-
for (const tag of
|
|
3706
|
+
for (const tag of documented?.getJsDocTags(checker) ?? []) {
|
|
3392
3707
|
const text = ts.displayPartsToString(tag.text).trim();
|
|
3393
3708
|
if ((tag.name === "default" || tag.name === "defaultValue") && text) prop.default = text;
|
|
3394
3709
|
if (tag.name === "deprecated") prop.deprecated = text || true;
|
|
@@ -3482,11 +3797,47 @@ function mergeVariants(variants) {
|
|
|
3482
3797
|
}
|
|
3483
3798
|
return [...merged.values()];
|
|
3484
3799
|
}
|
|
3485
|
-
/**
|
|
3486
|
-
function
|
|
3800
|
+
/** The layout of each variant a cva/tv definition declares, read as `parseDefinition` reads it. */
|
|
3801
|
+
function variantLayouts(definition) {
|
|
3802
|
+
const layouts = /* @__PURE__ */ new Map();
|
|
3803
|
+
const [first, second] = definition.node.arguments;
|
|
3804
|
+
const config = second ?? first;
|
|
3805
|
+
if (!config || !ts.isObjectLiteralExpression(config)) return layouts;
|
|
3806
|
+
const named = (name) => config.properties.find((p) => !ts.isSpreadAssignment(p) && propertyName(p.name) === name);
|
|
3807
|
+
const variants = named("variants");
|
|
3808
|
+
if (!variants || !ts.isPropertyAssignment(variants)) return layouts;
|
|
3809
|
+
if (!ts.isObjectLiteralExpression(variants.initializer)) return layouts;
|
|
3810
|
+
const extended = named("extend") !== void 0;
|
|
3811
|
+
for (const prop of variants.initializer.properties) {
|
|
3812
|
+
if (!ts.isPropertyAssignment(prop) || !ts.isObjectLiteralExpression(prop.initializer)) continue;
|
|
3813
|
+
const name = propertyName(prop.name);
|
|
3814
|
+
if (name === void 0) continue;
|
|
3815
|
+
const layout = extended ? [void 0] : [];
|
|
3816
|
+
for (const option of prop.initializer.properties) layout.push(ts.isPropertyAssignment(option) ? propertyName(option.name) : void 0);
|
|
3817
|
+
layouts.set(name, layout);
|
|
3818
|
+
}
|
|
3819
|
+
return layouts;
|
|
3820
|
+
}
|
|
3821
|
+
/** `values` in a layout's order, those it does not list where it takes them (or last). */
|
|
3822
|
+
function ordered(values, layout) {
|
|
3823
|
+
const unlisted = values.filter((v) => !layout.includes(v));
|
|
3824
|
+
const placed = layout.flatMap((key) => key === void 0 ? unlisted : values.includes(key) ? [key] : []);
|
|
3825
|
+
return unique(layout.includes(void 0) ? placed : [...placed, ...unlisted]);
|
|
3826
|
+
}
|
|
3827
|
+
/**
|
|
3828
|
+
* Adds or enriches variant props from cva/tv definitions (works without type info). A prop the
|
|
3829
|
+
* checker types as a set of strings keeps that set: the literal can take values it does not list
|
|
3830
|
+
* (`{ ...base, brand }`, tv's `extend`) and only gives their order. Without one, the literal's
|
|
3831
|
+
* keys are the values, unless it does not list them all: then the prop takes any value (or, when
|
|
3832
|
+
* the keys it lists are `true`/`false`, stays a boolean).
|
|
3833
|
+
*/
|
|
3834
|
+
function mergeVariantProps(props, variants, layouts) {
|
|
3487
3835
|
for (const variant of variants) {
|
|
3488
|
-
const
|
|
3836
|
+
const layout = layouts.get(variant.name) ?? variant.values;
|
|
3837
|
+
const open = layout.includes(void 0);
|
|
3838
|
+
const isBoolean = (variant.values.length > 0 || !open) && variant.values.every((v) => v === "true" || v === "false");
|
|
3489
3839
|
const existing = props.find((p) => p.name === variant.name);
|
|
3840
|
+
const typed = existing?.values;
|
|
3490
3841
|
const prop = existing ?? {
|
|
3491
3842
|
name: variant.name,
|
|
3492
3843
|
type: isBoolean ? "boolean" : variant.values.map((v) => JSON.stringify(v)).join(" | "),
|
|
@@ -3494,7 +3845,13 @@ function mergeVariantProps(props, variants) {
|
|
|
3494
3845
|
kind: "variant"
|
|
3495
3846
|
};
|
|
3496
3847
|
prop.kind = "variant";
|
|
3497
|
-
if (
|
|
3848
|
+
if (typed?.length) {
|
|
3849
|
+
prop.values = ordered(typed, layout);
|
|
3850
|
+
prop.type = prop.values.map((v) => JSON.stringify(v)).join(" | ");
|
|
3851
|
+
variant.values = [...prop.values];
|
|
3852
|
+
} else if (open && !isBoolean) {
|
|
3853
|
+
if (!existing) prop.type = "string";
|
|
3854
|
+
} else if (!isBoolean) {
|
|
3498
3855
|
prop.values = [...variant.values];
|
|
3499
3856
|
prop.type = variant.values.map((v) => JSON.stringify(v)).join(" | ");
|
|
3500
3857
|
}
|
|
@@ -4583,7 +4940,7 @@ function resolveElement(element, analysis, target, file) {
|
|
|
4583
4940
|
const [head = "", ...rest] = tag.split(".");
|
|
4584
4941
|
const binding = analysis.imports.get(head);
|
|
4585
4942
|
if (binding?.imported === "*" && rest.length) {
|
|
4586
|
-
if (!target.
|
|
4943
|
+
if (!target.checksImport(binding.source, file)) return { kind: "external" };
|
|
4587
4944
|
const name = rest.join(".");
|
|
4588
4945
|
const library = target.libraryOf(binding.source);
|
|
4589
4946
|
if (library) return fromLibrary(target, library, name, binding.source, rest.length > 1);
|
|
@@ -4610,7 +4967,7 @@ function resolveElement(element, analysis, target, file) {
|
|
|
4610
4967
|
if (resolved.kind !== "component") return resolved;
|
|
4611
4968
|
owner = resolved.component;
|
|
4612
4969
|
} else if (binding) {
|
|
4613
|
-
if (!target.
|
|
4970
|
+
if (!target.checksImport(binding.source, file)) return { kind: "external" };
|
|
4614
4971
|
const imported = binding.imported === "default" ? target.defaultExport(binding.source, file)?.name ?? head : binding.imported;
|
|
4615
4972
|
owner = target.componentFor(imported, binding.source, file);
|
|
4616
4973
|
if (!owner && target.components.has(imported)) return { kind: "external" };
|
|
@@ -5596,7 +5953,7 @@ function parseDtcgTokens(text, file, options = {}) {
|
|
|
5596
5953
|
const type = typeOf(t);
|
|
5597
5954
|
const format = (value) => {
|
|
5598
5955
|
const resolved = resolve(value, /* @__PURE__ */ new Set([path]));
|
|
5599
|
-
return t.own?.channels === "hsl" && hslChannels(resolved) || formatValue(resolved, type);
|
|
5956
|
+
return t.own?.channels === "hsl" && hslChannels(resolved) || t.own?.channels === "rgb" && rgbChannels(resolved) || formatValue(resolved, type);
|
|
5600
5957
|
};
|
|
5601
5958
|
const segments = [options.prefix, ...t.path.filter((p) => p !== "$root")].filter(Boolean);
|
|
5602
5959
|
const generated = segments.length ? `--${segments.join("-").replace(/[^\w-]+/g, "-")}` : void 0;
|
|
@@ -5751,7 +6108,7 @@ function onsystemExtension(extensions) {
|
|
|
5751
6108
|
if (typeof own.name === "string" && /^[^\s{}]+$/.test(own.name)) out.name = own.name;
|
|
5752
6109
|
if (typeof own.cssVar === "string" && /^--[\w-]+$/.test(own.cssVar)) out.cssVar = own.cssVar;
|
|
5753
6110
|
if (typeof own.tailwind === "string" && themeVariable(own.tailwind)) out.tailwind = own.tailwind;
|
|
5754
|
-
if (own.channels === "hsl") out.channels =
|
|
6111
|
+
if (own.channels === "hsl" || own.channels === "rgb") out.channels = own.channels;
|
|
5755
6112
|
if (isObject$2(own.modes)) out.modes = own.modes;
|
|
5756
6113
|
return out;
|
|
5757
6114
|
}
|
|
@@ -5791,6 +6148,13 @@ function hslChannels(value) {
|
|
|
5791
6148
|
if (h === void 0 || s === void 0 || l === void 0) return void 0;
|
|
5792
6149
|
return `${h} ${s}% ${l}%${typeof value.alpha === "number" && value.alpha < 1 ? ` / ${value.alpha}` : ""}`;
|
|
5793
6150
|
}
|
|
6151
|
+
/** `22 163 74`: an sRGB color as the bare channels a Tailwind v3 custom property holds. */
|
|
6152
|
+
function rgbChannels(value) {
|
|
6153
|
+
if (!isObject$2(value) || value.colorSpace !== "srgb" || !Array.isArray(value.components)) return;
|
|
6154
|
+
const [r, g, b] = value.components.map((c) => typeof c === "number" ? String(Math.round(c * 255)) : void 0);
|
|
6155
|
+
if (r === void 0 || g === void 0 || b === void 0) return void 0;
|
|
6156
|
+
return `${r} ${g} ${b}${typeof value.alpha === "number" && value.alpha < 1 ? ` / ${value.alpha}` : ""}`;
|
|
6157
|
+
}
|
|
5794
6158
|
function formatValue(value, type) {
|
|
5795
6159
|
if (typeof value === "string") return value;
|
|
5796
6160
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
@@ -5974,6 +6338,24 @@ function isStatusToken(token) {
|
|
|
5974
6338
|
function tokenKey(token) {
|
|
5975
6339
|
return (token.tailwind || token.name).toLowerCase().replace(/[.\s_]+/g, "-").replace(/^colou?rs?-/, "");
|
|
5976
6340
|
}
|
|
6341
|
+
/** Name segments of a line color: `card-border`, `input`, `ring`. */
|
|
6342
|
+
const LINE_WORDS = [
|
|
6343
|
+
"border",
|
|
6344
|
+
"input",
|
|
6345
|
+
"ring",
|
|
6346
|
+
"outline",
|
|
6347
|
+
"divider",
|
|
6348
|
+
"separator"
|
|
6349
|
+
];
|
|
6350
|
+
/** Name segments of a surface itself, as opposed to `muted`, which also describes text. */
|
|
6351
|
+
const SURFACE_NOUNS = [
|
|
6352
|
+
"background",
|
|
6353
|
+
"bg",
|
|
6354
|
+
"surface",
|
|
6355
|
+
"canvas",
|
|
6356
|
+
"card",
|
|
6357
|
+
"popover"
|
|
6358
|
+
];
|
|
5977
6359
|
/** Name segments that say what a token is for, checked in this order (`card-border` is a border). */
|
|
5978
6360
|
const KIND_WORDS = [
|
|
5979
6361
|
["foreground", [
|
|
@@ -5981,21 +6363,9 @@ const KIND_WORDS = [
|
|
|
5981
6363
|
"fg",
|
|
5982
6364
|
"text"
|
|
5983
6365
|
]],
|
|
5984
|
-
["line",
|
|
5985
|
-
"border",
|
|
5986
|
-
"input",
|
|
5987
|
-
"ring",
|
|
5988
|
-
"outline",
|
|
5989
|
-
"divider",
|
|
5990
|
-
"separator"
|
|
5991
|
-
]],
|
|
6366
|
+
["line", LINE_WORDS],
|
|
5992
6367
|
["surface", [
|
|
5993
|
-
|
|
5994
|
-
"bg",
|
|
5995
|
-
"surface",
|
|
5996
|
-
"canvas",
|
|
5997
|
-
"card",
|
|
5998
|
-
"popover",
|
|
6368
|
+
...SURFACE_NOUNS,
|
|
5999
6369
|
"muted",
|
|
6000
6370
|
"secondary",
|
|
6001
6371
|
"accent"
|
|
@@ -6014,9 +6384,17 @@ function roleKey(token) {
|
|
|
6014
6384
|
/** What a token is for, from its name: shadcn/ui's conventions and common DTCG names. */
|
|
6015
6385
|
function kindOf(key) {
|
|
6016
6386
|
const segments = key.split("-");
|
|
6387
|
+
if (segments[0] === "on") return "foreground";
|
|
6388
|
+
const namesLineOrSurface = (s) => LINE_WORDS.includes(s) || SURFACE_NOUNS.includes(s);
|
|
6389
|
+
if (segments[0] === "content" && !segments.some(namesLineOrSurface)) return "foreground";
|
|
6017
6390
|
for (const [kind, words] of KIND_WORDS) if (segments.some((s) => words.includes(s))) return kind;
|
|
6018
6391
|
return "other";
|
|
6019
6392
|
}
|
|
6393
|
+
/** The role a token is made for, from its name (`text` for `content-muted`); undefined for `brand` or `destructive`. */
|
|
6394
|
+
function tokenRole(token) {
|
|
6395
|
+
const kind = kindOf(roleKey(token));
|
|
6396
|
+
return kind === "foreground" ? "text" : kind === "other" ? void 0 : kind;
|
|
6397
|
+
}
|
|
6020
6398
|
/** Tokens made for each role, in the order to prefer them when their values tie. */
|
|
6021
6399
|
const PREFERRED = {
|
|
6022
6400
|
text: ["foreground", "muted-foreground"],
|
|
@@ -6137,7 +6515,7 @@ function toDtcg(tokens) {
|
|
|
6137
6515
|
if (token.cssVar) extension.cssVar = token.cssVar;
|
|
6138
6516
|
const tailwind = tailwindVariable(token);
|
|
6139
6517
|
if (tailwind) extension.tailwind = tailwind;
|
|
6140
|
-
if (own.channels) extension.channels =
|
|
6518
|
+
if (own.channels) extension.channels = own.channels;
|
|
6141
6519
|
if (token.modes && Object.keys(token.modes).length) extension.modes = Object.fromEntries(Object.entries(token.modes).map(([mode, value]) => [mode, typed(value, token, lookup).value]));
|
|
6142
6520
|
if (Object.keys(extension).length) node.$extensions = { [ONSYSTEM_EXTENSION]: extension };
|
|
6143
6521
|
insert(tree, path, node);
|
|
@@ -6259,14 +6637,17 @@ function typed(value, token, lookup) {
|
|
|
6259
6637
|
}
|
|
6260
6638
|
}
|
|
6261
6639
|
function colorValue(value) {
|
|
6262
|
-
if (!parseColor(value, {
|
|
6263
|
-
|
|
6264
|
-
|
|
6640
|
+
if (!parseColor(value, {
|
|
6641
|
+
bareHsl: true,
|
|
6642
|
+
bareRgb: true
|
|
6643
|
+
})) return { value };
|
|
6644
|
+
const channels = isBareHsl(value) ? "hsl" : isBareRgb(value) ? "rgb" : void 0;
|
|
6645
|
+
const color = colorObject(channels ? `${channels}(${value.replace(/,/g, " ")})` : value);
|
|
6265
6646
|
if (!color) return { value };
|
|
6266
|
-
return
|
|
6647
|
+
return channels ? {
|
|
6267
6648
|
type: "color",
|
|
6268
6649
|
value: color,
|
|
6269
|
-
channels
|
|
6650
|
+
channels
|
|
6270
6651
|
} : {
|
|
6271
6652
|
type: "color",
|
|
6272
6653
|
value: color
|
|
@@ -6705,8 +7086,11 @@ function frontMatterValue(token, key, lookup) {
|
|
|
6705
7086
|
const value = token.value.trim();
|
|
6706
7087
|
switch (key) {
|
|
6707
7088
|
case "colors":
|
|
6708
|
-
if (!parseColor(value, {
|
|
6709
|
-
|
|
7089
|
+
if (!parseColor(value, {
|
|
7090
|
+
bareHsl: true,
|
|
7091
|
+
bareRgb: true
|
|
7092
|
+
})) return void 0;
|
|
7093
|
+
return isBareHsl(value) ? `hsl(${value})` : isBareRgb(value) ? `rgb(${value})` : value;
|
|
6710
7094
|
case "rounded":
|
|
6711
7095
|
case "spacing": {
|
|
6712
7096
|
if (DESIGN_DIMENSION.test(value) || value === "0") return value;
|
|
@@ -6870,6 +7254,11 @@ const ROUNDED_FULL = {
|
|
|
6870
7254
|
line: 0
|
|
6871
7255
|
}
|
|
6872
7256
|
};
|
|
7257
|
+
/** A color token's value: bare HSL or RGB channels are its color (Tailwind v3). */
|
|
7258
|
+
const TOKEN_COLOR = {
|
|
7259
|
+
bareHsl: true,
|
|
7260
|
+
bareRgb: true
|
|
7261
|
+
};
|
|
6873
7262
|
/** Query helpers over the token list: lookups and nearest-token search. */
|
|
6874
7263
|
var TokenIndex = class {
|
|
6875
7264
|
tokens;
|
|
@@ -6899,7 +7288,7 @@ var TokenIndex = class {
|
|
|
6899
7288
|
const resolve = (name) => this.byVar.get(name)?.value;
|
|
6900
7289
|
let unit;
|
|
6901
7290
|
for (const token of tokens) if (token.category === "color") {
|
|
6902
|
-
const color = parseColor(token.value,
|
|
7291
|
+
const color = parseColor(token.value, TOKEN_COLOR);
|
|
6903
7292
|
if (color) this.colors.push({
|
|
6904
7293
|
token,
|
|
6905
7294
|
color,
|
|
@@ -6988,20 +7377,38 @@ var TokenIndex = class {
|
|
|
6988
7377
|
candidate: named.candidate,
|
|
6989
7378
|
distance: named.distance
|
|
6990
7379
|
};
|
|
6991
|
-
if (match)
|
|
6992
|
-
match:
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
7380
|
+
if (match) {
|
|
7381
|
+
const status = match.status ? void 0 : pool.filter((s) => s.status && s.looseHue && tinted && !s.foreign && s.fit.tier < 2 && s.distance < .1 && s.distance - match.distance < .03).sort(byDistance)[0];
|
|
7382
|
+
return {
|
|
7383
|
+
match: nearest,
|
|
7384
|
+
nearest,
|
|
7385
|
+
sameValue,
|
|
7386
|
+
closeStatus: status && {
|
|
7387
|
+
candidate: status.candidate,
|
|
7388
|
+
distance: status.distance
|
|
7389
|
+
}
|
|
7390
|
+
};
|
|
7391
|
+
}
|
|
6996
7392
|
if (!named.hue && named.status && named.looseHue && tinted) return {
|
|
6997
7393
|
nearest,
|
|
6998
7394
|
reason: "status",
|
|
6999
7395
|
hueOff: Math.round(hueDistance(color, valueOf(named.candidate))),
|
|
7000
7396
|
sameValue
|
|
7001
7397
|
};
|
|
7398
|
+
const other = pool.filter((s) => s.hue && !s.foreign && s.fit.tier === 2 && s.distance < .1).sort(byDistance)[0];
|
|
7399
|
+
const reason = !named.hue ? "hue" : named.distance >= .1 ? other ? "role" : "far" : named.foreign ? "scoped" : "role";
|
|
7400
|
+
if (reason === "role" && other && other !== named) return {
|
|
7401
|
+
nearest,
|
|
7402
|
+
reason,
|
|
7403
|
+
otherRole: {
|
|
7404
|
+
candidate: other.candidate,
|
|
7405
|
+
distance: other.distance
|
|
7406
|
+
},
|
|
7407
|
+
sameValue
|
|
7408
|
+
};
|
|
7002
7409
|
return {
|
|
7003
7410
|
nearest,
|
|
7004
|
-
reason
|
|
7411
|
+
reason,
|
|
7005
7412
|
sameValue
|
|
7006
7413
|
};
|
|
7007
7414
|
}
|
|
@@ -7057,7 +7464,7 @@ var TokenIndex = class {
|
|
|
7057
7464
|
function colorModes(token) {
|
|
7058
7465
|
const modes = {};
|
|
7059
7466
|
for (const [mode, value] of Object.entries(token.modes ?? {})) {
|
|
7060
|
-
const color = parseColor(value,
|
|
7467
|
+
const color = parseColor(value, TOKEN_COLOR);
|
|
7061
7468
|
if (color) modes[mode] = color;
|
|
7062
7469
|
}
|
|
7063
7470
|
return Object.keys(modes).length ? { modes } : {};
|
|
@@ -7551,6 +7958,16 @@ var LintTarget = class {
|
|
|
7551
7958
|
return this.importPrefixes.some((prefix) => specifier.startsWith(prefix) || specifier === prefix.slice(0, -1));
|
|
7552
7959
|
}
|
|
7553
7960
|
/**
|
|
7961
|
+
* Whether the component rules judge the components imported from `specifier`: it points at
|
|
7962
|
+
* the design system, and the model has components to judge them by. A model with none (an
|
|
7963
|
+
* app whose design-system package publishes only compiled `dist/`) still matches the
|
|
7964
|
+
* configured `importPath`, but the component rules do not run there, as `check` says, so
|
|
7965
|
+
* what comes from it is not called invented.
|
|
7966
|
+
*/
|
|
7967
|
+
checksImport(specifier, fromFile = "snippet.tsx") {
|
|
7968
|
+
return this.model.components.length > 0 && this.isDesignSystemImport(specifier, fromFile);
|
|
7969
|
+
}
|
|
7970
|
+
/**
|
|
7554
7971
|
* Whether `name`, imported from the design system, may be a real export the
|
|
7555
7972
|
* model left out rather than an invented one: its module exists outside the
|
|
7556
7973
|
* folders extraction read and exports `name`, or re-exports from such a
|
|
@@ -7969,7 +8386,10 @@ const noUnknownComponent = {
|
|
|
7969
8386
|
}
|
|
7970
8387
|
}
|
|
7971
8388
|
};
|
|
7972
|
-
/**
|
|
8389
|
+
/**
|
|
8390
|
+
* Prop names from other libraries → the conventional name in shadcn-style systems, which mean the
|
|
8391
|
+
* same thing there, so the rename is a fix.
|
|
8392
|
+
*/
|
|
7973
8393
|
const PROP_SYNONYMS = {
|
|
7974
8394
|
variant: [
|
|
7975
8395
|
"tone",
|
|
@@ -7999,20 +8419,20 @@ const PROP_SYNONYMS = {
|
|
|
7999
8419
|
"show",
|
|
8000
8420
|
"shown"
|
|
8001
8421
|
],
|
|
8002
|
-
onOpenChange: [
|
|
8003
|
-
"onClose",
|
|
8004
|
-
"onDismiss",
|
|
8005
|
-
"onToggle",
|
|
8006
|
-
"onOpen",
|
|
8007
|
-
"onVisibleChange"
|
|
8008
|
-
],
|
|
8009
|
-
asChild: [
|
|
8010
|
-
"as",
|
|
8011
|
-
"component",
|
|
8012
|
-
"render"
|
|
8013
|
-
]
|
|
8422
|
+
onOpenChange: ["onVisibleChange"]
|
|
8014
8423
|
};
|
|
8015
8424
|
/**
|
|
8425
|
+
* Handlers other libraries call on one side of a change, against `onOpenChange`, which runs on
|
|
8426
|
+
* open and on close with the new state: renamed, `onClose={() => setOpen(false)}` would keep a
|
|
8427
|
+
* dialog shut. The message shows the check to add instead of offering the rename.
|
|
8428
|
+
*/
|
|
8429
|
+
const OPEN_CHANGE_HANDLERS = /* @__PURE__ */ new Map([
|
|
8430
|
+
["onClose", "{ if (!open) … }"],
|
|
8431
|
+
["onDismiss", "{ if (!open) … }"],
|
|
8432
|
+
["onOpen", "{ if (open) … }"],
|
|
8433
|
+
["onToggle", "…"]
|
|
8434
|
+
]);
|
|
8435
|
+
/**
|
|
8016
8436
|
* Props that mean the same thing in different conventions: native elements and Radix on one
|
|
8017
8437
|
* side, React Aria Components on the other. A native field states `isInvalid` as `aria-invalid`. Whichever spelling an agent writes, the one the
|
|
8018
8438
|
* component takes is suggested, in either direction. Event handlers are left out: their
|
|
@@ -8036,14 +8456,96 @@ const PROP_EQUIVALENTS = [
|
|
|
8036
8456
|
["defaultChecked", "defaultSelected"],
|
|
8037
8457
|
["indeterminate", "isIndeterminate"]
|
|
8038
8458
|
];
|
|
8459
|
+
/** Props other libraries compose with, which no prop of a similar spelling stands in for. */
|
|
8460
|
+
const FOREIGN_COMPOSITION = /* @__PURE__ */ new Set([
|
|
8461
|
+
"href",
|
|
8462
|
+
"as",
|
|
8463
|
+
"component"
|
|
8464
|
+
]);
|
|
8039
8465
|
/**
|
|
8040
8466
|
* Radix composes with `asChild` and a child element, Base UI with a `render`
|
|
8041
|
-
* element; shadcn/ui ships both, and agents mix them up
|
|
8042
|
-
*
|
|
8467
|
+
* element; shadcn/ui ships both, and agents mix them up, or bring `as`, `component`
|
|
8468
|
+
* or `href` from other libraries. Renaming the prop is not enough (`asChild` is a
|
|
8469
|
+
* boolean, and a button with an `href` is still no link), so this explains the
|
|
8470
|
+
* other way instead of offering a fix.
|
|
8043
8471
|
*/
|
|
8044
8472
|
function compositionHint(name, known, tag) {
|
|
8045
|
-
|
|
8046
|
-
|
|
8473
|
+
const radix = `compose with asChild and a single child`;
|
|
8474
|
+
const radixExample = `<${tag} asChild><Link href="…">…</Link></${tag}>`;
|
|
8475
|
+
const base = `compose with render`;
|
|
8476
|
+
const baseExample = `<${tag} render={<Link href="…" />}>…</${tag}>`;
|
|
8477
|
+
if (name === "href") {
|
|
8478
|
+
if (known.has("asChild")) return {
|
|
8479
|
+
prop: "asChild",
|
|
8480
|
+
text: `For a link, Radix components ${radix}: ${radixExample}.`
|
|
8481
|
+
};
|
|
8482
|
+
if (known.has("render")) return {
|
|
8483
|
+
prop: "render",
|
|
8484
|
+
text: `For a link, Base UI components ${base}: ${baseExample}.`
|
|
8485
|
+
};
|
|
8486
|
+
return;
|
|
8487
|
+
}
|
|
8488
|
+
if ([
|
|
8489
|
+
"asChild",
|
|
8490
|
+
"as",
|
|
8491
|
+
"component"
|
|
8492
|
+
].includes(name) && known.has("render")) return {
|
|
8493
|
+
prop: "render",
|
|
8494
|
+
text: `Base UI components ${base} instead: ${baseExample}.`
|
|
8495
|
+
};
|
|
8496
|
+
if ([
|
|
8497
|
+
"render",
|
|
8498
|
+
"as",
|
|
8499
|
+
"component"
|
|
8500
|
+
].includes(name) && known.has("asChild")) return {
|
|
8501
|
+
prop: "asChild",
|
|
8502
|
+
text: `Radix components ${radix} instead: ${radixExample}.`
|
|
8503
|
+
};
|
|
8504
|
+
}
|
|
8505
|
+
/** `onClick`, `onValueChange`: a handler, whose arguments a spelling match does not tell. */
|
|
8506
|
+
const HANDLER = /^on[A-Z]/;
|
|
8507
|
+
/**
|
|
8508
|
+
* The prop an unknown one most likely means, and whether the rename is safe as a fix: an
|
|
8509
|
+
* equivalent, a synonym or the same name in other case (`onclick`, `tabindex`) means the same,
|
|
8510
|
+
* and so does an own prop one edit away (`varient`, `onPres`). A forwarded attribute of a
|
|
8511
|
+
* similar spelling is only named, since one letter can make another prop (`disable` is
|
|
8512
|
+
* `disabled`, but `href` is not `ref`). `ref`, own or forwarded, and forwarded handlers, whose
|
|
8513
|
+
* events differ (`onValueChange` is not `onVolumeChange`), are not guessed by spelling at all,
|
|
8514
|
+
* and neither is anything for `href`, `as` or `component` (`ref`, `is`), which compose.
|
|
8515
|
+
*/
|
|
8516
|
+
function guessProp(name, own, known) {
|
|
8517
|
+
if (FOREIGN_COMPOSITION.has(name)) return void 0;
|
|
8518
|
+
const equivalents = PROP_EQUIVALENTS.find((group) => group.includes(name)) ?? [];
|
|
8519
|
+
const equivalent = equivalents.find((other) => other !== name && own.includes(other)) ?? equivalents.find((other) => other !== name && known.has(other));
|
|
8520
|
+
if (equivalent) return {
|
|
8521
|
+
prop: equivalent,
|
|
8522
|
+
fix: true
|
|
8523
|
+
};
|
|
8524
|
+
const synonym = Object.entries(PROP_SYNONYMS).find(([target, aliases]) => known.has(target) && aliases.includes(name))?.[0];
|
|
8525
|
+
if (synonym) return {
|
|
8526
|
+
prop: synonym,
|
|
8527
|
+
fix: true
|
|
8528
|
+
};
|
|
8529
|
+
if (OPEN_CHANGE_HANDLERS.has(name) && known.has("onOpenChange")) return {
|
|
8530
|
+
prop: "onOpenChange",
|
|
8531
|
+
fix: false
|
|
8532
|
+
};
|
|
8533
|
+
const lower = name.toLowerCase();
|
|
8534
|
+
const recased = [...known].find((prop) => prop.toLowerCase() === lower);
|
|
8535
|
+
if (recased) return {
|
|
8536
|
+
prop: recased,
|
|
8537
|
+
fix: true
|
|
8538
|
+
};
|
|
8539
|
+
const typo = closest(name, own.filter((prop) => prop !== "ref"));
|
|
8540
|
+
if (typo) return {
|
|
8541
|
+
prop: typo,
|
|
8542
|
+
fix: editDistance(lower, typo.toLowerCase()) <= 1
|
|
8543
|
+
};
|
|
8544
|
+
const similar = closest(name, [...known].filter((prop) => prop !== "ref" && !HANDLER.test(prop)), .25);
|
|
8545
|
+
return similar ? {
|
|
8546
|
+
prop: similar,
|
|
8547
|
+
fix: false
|
|
8548
|
+
} : void 0;
|
|
8047
8549
|
}
|
|
8048
8550
|
const noUnknownProp = {
|
|
8049
8551
|
id: "no-unknown-prop",
|
|
@@ -8066,27 +8568,25 @@ const noUnknownProp = {
|
|
|
8066
8568
|
context.report({
|
|
8067
8569
|
start: attribute.name.getStart(context.sourceFile),
|
|
8068
8570
|
end: attribute.name.end,
|
|
8069
|
-
message: `<${element.tag}> has no prop "${name}". ${composition}`,
|
|
8070
|
-
suggestion:
|
|
8571
|
+
message: `<${element.tag}> has no prop "${name}". ${composition.text}`,
|
|
8572
|
+
suggestion: composition.prop
|
|
8071
8573
|
});
|
|
8072
8574
|
continue;
|
|
8073
8575
|
}
|
|
8074
|
-
const
|
|
8075
|
-
const
|
|
8076
|
-
const synonym = Object.entries(PROP_SYNONYMS).find(([target, aliases]) => known.has(target) && aliases.includes(name))?.[0];
|
|
8077
|
-
const guess = equivalent ?? synonym ?? closest(name, own) ?? closest(name, known, .25);
|
|
8078
|
-
const prop = guess ? component.props.find((p) => p.name === guess) : void 0;
|
|
8576
|
+
const guess = guessProp(name, own, known);
|
|
8577
|
+
const prop = guess ? component.props.find((p) => p.name === guess.prop) : void 0;
|
|
8079
8578
|
const values = prop?.values?.length ? ` (${prop.values.map((v) => `"${v}"`).join(" | ")})` : "";
|
|
8579
|
+
const handler = guess?.prop === "onOpenChange" ? OPEN_CHANGE_HANDLERS.get(name) : void 0;
|
|
8080
8580
|
const valueOk = !prop?.values || literalValues(attribute).every((l) => prop.values?.includes(l.text));
|
|
8081
|
-
const taken = guess !== void 0 && findAttribute(element, guess) !== void 0;
|
|
8581
|
+
const taken = guess !== void 0 && findAttribute(element, guess.prop) !== void 0;
|
|
8082
8582
|
context.report({
|
|
8083
8583
|
start: attribute.name.getStart(context.sourceFile),
|
|
8084
8584
|
end: attribute.name.end,
|
|
8085
|
-
message: `<${element.tag}> has no prop "${name}".` + (guess ? ` Did you mean "${guess}"${values}?` : ""),
|
|
8086
|
-
suggestion: guess,
|
|
8087
|
-
fix: guess && valueOk && !taken ? [{
|
|
8585
|
+
message: `<${element.tag}> has no prop "${name}".` + (guess ? ` Did you mean "${guess.prop}"${values}?` : "") + (handler ? ` It is called with the new open state, on open as well as on close: onOpenChange={(open) => ${handler}}.` : ""),
|
|
8586
|
+
suggestion: guess?.prop,
|
|
8587
|
+
fix: guess?.fix && valueOk && !taken ? [{
|
|
8088
8588
|
range: [attribute.name.getStart(context.sourceFile), attribute.name.end],
|
|
8089
|
-
text: guess
|
|
8589
|
+
text: guess.prop
|
|
8090
8590
|
}] : void 0
|
|
8091
8591
|
});
|
|
8092
8592
|
}
|
|
@@ -8177,11 +8677,16 @@ const VALUE_GROUPS = [
|
|
|
8177
8677
|
"href",
|
|
8178
8678
|
"inline"
|
|
8179
8679
|
],
|
|
8680
|
+
[
|
|
8681
|
+
"xs",
|
|
8682
|
+
"xsmall",
|
|
8683
|
+
"x-small",
|
|
8684
|
+
"extra-small"
|
|
8685
|
+
],
|
|
8180
8686
|
[
|
|
8181
8687
|
"sm",
|
|
8182
8688
|
"small",
|
|
8183
8689
|
"s",
|
|
8184
|
-
"xs",
|
|
8185
8690
|
"compact",
|
|
8186
8691
|
"tiny"
|
|
8187
8692
|
],
|
|
@@ -8200,6 +8705,13 @@ const VALUE_GROUPS = [
|
|
|
8200
8705
|
"l",
|
|
8201
8706
|
"big"
|
|
8202
8707
|
],
|
|
8708
|
+
[
|
|
8709
|
+
"xl",
|
|
8710
|
+
"xlarge",
|
|
8711
|
+
"x-large",
|
|
8712
|
+
"extra-large",
|
|
8713
|
+
"huge"
|
|
8714
|
+
],
|
|
8203
8715
|
[
|
|
8204
8716
|
"icon",
|
|
8205
8717
|
"iconOnly",
|
|
@@ -8208,14 +8720,72 @@ const VALUE_GROUPS = [
|
|
|
8208
8720
|
"icon-button"
|
|
8209
8721
|
]
|
|
8210
8722
|
];
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
|
|
8214
|
-
|
|
8215
|
-
|
|
8216
|
-
|
|
8723
|
+
/**
|
|
8724
|
+
* A value with the x's of its size step counted, so two spellings of one step compare equal:
|
|
8725
|
+
* `xxl` and `2xl` both give `2xl`, `icon-XXS` and `icon-2xs` both give `icon-2xs`.
|
|
8726
|
+
*/
|
|
8727
|
+
function stepSpelling(value) {
|
|
8728
|
+
return value.toLowerCase().replace(/(^|-)(x{2,})([sl])$/, (_, start, xs, end) => `${start}${xs.length}x${end}`);
|
|
8729
|
+
}
|
|
8730
|
+
/** {@link VALUE_GROUPS} spelled as {@link stepSpelling} spells a value, to compare the two. */
|
|
8731
|
+
const SPELLED_GROUPS = VALUE_GROUPS.map((group) => group.map(stepSpelling));
|
|
8732
|
+
/**
|
|
8733
|
+
* An allowed value that means the same: the same step spelled another way (`xxl` → `2xl`,
|
|
8734
|
+
* `XL` → `xl`), or a synonym (`danger` → `destructive`, `small` → `sm`). A group is searched
|
|
8735
|
+
* in its own order, not the scale's: TypeScript orders a union by when the program first met
|
|
8736
|
+
* each literal, so another file can reorder the scale, and `positive` must stay `success` on
|
|
8737
|
+
* a scale that also has `active`.
|
|
8738
|
+
*/
|
|
8739
|
+
function synonymValue(value, allowed) {
|
|
8740
|
+
const spelling = stepSpelling(value);
|
|
8741
|
+
const same = allowed.find((member) => member !== value && stepSpelling(member) === spelling);
|
|
8742
|
+
if (same) return same;
|
|
8743
|
+
for (const group of SPELLED_GROUPS) {
|
|
8744
|
+
if (!group.includes(spelling)) continue;
|
|
8745
|
+
for (const member of group) {
|
|
8746
|
+
if (member === spelling) continue;
|
|
8747
|
+
const match = allowed.find((candidate) => stepSpelling(candidate) === member);
|
|
8748
|
+
if (match) return match;
|
|
8749
|
+
}
|
|
8217
8750
|
}
|
|
8218
|
-
|
|
8751
|
+
}
|
|
8752
|
+
/**
|
|
8753
|
+
* The step a size one past an end of the scale stands next to, with its x's counted and its
|
|
8754
|
+
* prefix kept: `2xl` → `xl`, `3xl` → `2xl`, `2xs` → `xs`, `xs` → `sm`, `icon-2xl` → `icon-xl`.
|
|
8755
|
+
*/
|
|
8756
|
+
function stepInside(spelling) {
|
|
8757
|
+
const match = /^(.*-)?(\d*)x([sl])$/.exec(spelling);
|
|
8758
|
+
if (!match) return void 0;
|
|
8759
|
+
const prefix = match[1] ?? "";
|
|
8760
|
+
const end = match[3];
|
|
8761
|
+
if (!match[2]) return end === "s" ? `${prefix}sm` : void 0;
|
|
8762
|
+
const count = Number(match[2]);
|
|
8763
|
+
if (count < 2) return void 0;
|
|
8764
|
+
return `${prefix}${count === 2 ? "" : count - 1}x${end}`;
|
|
8765
|
+
}
|
|
8766
|
+
/**
|
|
8767
|
+
* The end of the scale a size one step past it stands next to, under whichever name the scale
|
|
8768
|
+
* gives it: `2xl` or `xxl` → `xl`, `3xl` → `2xl` or `xxl`, `xs` → `sm` or `small`, `icon-xxl`
|
|
8769
|
+
* → `icon-xl`. It renders another size than the one written, so it is named in the message
|
|
8770
|
+
* but not fixed.
|
|
8771
|
+
*/
|
|
8772
|
+
function nextStep(value, allowed) {
|
|
8773
|
+
const step = stepInside(stepSpelling(value));
|
|
8774
|
+
if (!step) return void 0;
|
|
8775
|
+
return allowed.find((member) => stepSpelling(member) === step) ?? synonymValue(step, allowed);
|
|
8776
|
+
}
|
|
8777
|
+
/** The step of a size scale a value names, alone or at its end: `xs`, `lg`, `2xl`, `icon-sm`. */
|
|
8778
|
+
const SIZE_STEP = /(?:^|-)(\d*x*[sml]|sm|md|lg)$/i;
|
|
8779
|
+
/**
|
|
8780
|
+
* An allowed value spelled close to `value`, for the message only: it may be another value
|
|
8781
|
+
* altogether. Two steps of a size scale are never guessed from each other, since one letter
|
|
8782
|
+
* there is another size (`xl` and `xs`, `icon-xl` and `icon-xs`).
|
|
8783
|
+
*/
|
|
8784
|
+
function spelledLike(value, allowed) {
|
|
8785
|
+
const guess = closest(value, allowed, .5);
|
|
8786
|
+
const step = SIZE_STEP.exec(value)?.[1]?.toLowerCase();
|
|
8787
|
+
const guessStep = guess ? SIZE_STEP.exec(guess)?.[1]?.toLowerCase() : void 0;
|
|
8788
|
+
return step && guessStep && step !== guessStep ? void 0 : guess;
|
|
8219
8789
|
}
|
|
8220
8790
|
const noUnknownVariant = {
|
|
8221
8791
|
id: "no-unknown-variant",
|
|
@@ -8230,7 +8800,8 @@ const noUnknownVariant = {
|
|
|
8230
8800
|
if (!prop?.values) continue;
|
|
8231
8801
|
for (const literal of literalValues(attribute)) {
|
|
8232
8802
|
if (prop.values.includes(literal.text)) continue;
|
|
8233
|
-
const
|
|
8803
|
+
const synonym = synonymValue(literal.text, prop.values);
|
|
8804
|
+
const guess = synonym ?? nextStep(literal.text, prop.values) ?? spelledLike(literal.text, prop.values);
|
|
8234
8805
|
const start = literal.getStart(context.sourceFile) + 1;
|
|
8235
8806
|
const end = literal.end - 1;
|
|
8236
8807
|
context.report({
|
|
@@ -8238,9 +8809,9 @@ const noUnknownVariant = {
|
|
|
8238
8809
|
end: end + 1,
|
|
8239
8810
|
message: `"${literal.text}" is not a valid ${name} for <${element.tag}>. Allowed: ${prop.values.join(", ")}.` + (guess ? ` Did you mean "${guess}"?` : ""),
|
|
8240
8811
|
suggestion: guess ? `${name}="${guess}"` : void 0,
|
|
8241
|
-
fix:
|
|
8812
|
+
fix: synonym ? [{
|
|
8242
8813
|
range: [start, end],
|
|
8243
|
-
text:
|
|
8814
|
+
text: synonym
|
|
8244
8815
|
}] : void 0
|
|
8245
8816
|
});
|
|
8246
8817
|
}
|
|
@@ -8248,27 +8819,205 @@ const noUnknownVariant = {
|
|
|
8248
8819
|
}
|
|
8249
8820
|
}
|
|
8250
8821
|
};
|
|
8822
|
+
/**
|
|
8823
|
+
* Labels guessed from an icon's name, for the message. The third pattern names the icons that mean
|
|
8824
|
+
* one thing on a button (a trash can deletes, a magnifier searches), matched against the name
|
|
8825
|
+
* without its digits or `Icon` suffix (`Trash2Icon` is `Trash`): only their label is fixed. A plus
|
|
8826
|
+
* may add or expand, a chevron page or expand, an X close, remove or clear, so those are fixed only
|
|
8827
|
+
* where the code says which (`iconLabel`), and the rest are named without a fix.
|
|
8828
|
+
*/
|
|
8251
8829
|
const ICON_LABELS = [
|
|
8252
|
-
[
|
|
8830
|
+
[
|
|
8831
|
+
/^(Trash|Delete|Bin)/,
|
|
8832
|
+
"Delete",
|
|
8833
|
+
/^(?:Trash|Delete|Bin)$/
|
|
8834
|
+
],
|
|
8253
8835
|
[/^(X|Close|Cross)(Icon)?$|^X(Circle|Square)/, "Close"],
|
|
8254
8836
|
[/^(Plus|Add)/, "Add"],
|
|
8255
|
-
[
|
|
8256
|
-
|
|
8837
|
+
[
|
|
8838
|
+
/^(Pencil|Edit|PenLine|SquarePen)/,
|
|
8839
|
+
"Edit",
|
|
8840
|
+
/^(?:Pencil|Edit|SquarePen)$/
|
|
8841
|
+
],
|
|
8842
|
+
[
|
|
8843
|
+
/^Search/,
|
|
8844
|
+
"Search",
|
|
8845
|
+
/^Search$/
|
|
8846
|
+
],
|
|
8257
8847
|
[/^(Menu|Hamburger)/, "Open menu"],
|
|
8258
|
-
[
|
|
8259
|
-
|
|
8260
|
-
|
|
8261
|
-
|
|
8848
|
+
[
|
|
8849
|
+
/^(Settings|Cog|Gear)/,
|
|
8850
|
+
"Settings",
|
|
8851
|
+
/^(?:Settings|Cog|Gear)$/
|
|
8852
|
+
],
|
|
8853
|
+
[
|
|
8854
|
+
/^(Copy|Clipboard)/,
|
|
8855
|
+
"Copy",
|
|
8856
|
+
/^(?:Copy|ClipboardCopy)$/
|
|
8857
|
+
],
|
|
8858
|
+
[
|
|
8859
|
+
/^Download/,
|
|
8860
|
+
"Download",
|
|
8861
|
+
/^Download$/
|
|
8862
|
+
],
|
|
8863
|
+
[
|
|
8864
|
+
/^Upload/,
|
|
8865
|
+
"Upload",
|
|
8866
|
+
/^Upload$/
|
|
8867
|
+
],
|
|
8262
8868
|
[/^(MoreHorizontal|MoreVertical|Ellipsis|DotsHorizontal|DotsVertical)/, "More options"],
|
|
8263
8869
|
[/^ChevronLeft|^ArrowLeft/, "Previous"],
|
|
8264
8870
|
[/^ChevronRight|^ArrowRight/, "Next"],
|
|
8265
8871
|
[/^(UserMinus|UserX)/, "Remove member"],
|
|
8266
8872
|
[/^(UserPlus)/, "Invite member"],
|
|
8267
8873
|
[/^(RefreshCw|RefreshCcw|RotateCw)/, "Refresh"],
|
|
8268
|
-
[
|
|
8269
|
-
|
|
8874
|
+
[
|
|
8875
|
+
/^(Share)/,
|
|
8876
|
+
"Share",
|
|
8877
|
+
/^Share$/
|
|
8878
|
+
],
|
|
8879
|
+
[
|
|
8880
|
+
/^(Filter)/,
|
|
8881
|
+
"Filter",
|
|
8882
|
+
/^Filter$/
|
|
8883
|
+
],
|
|
8270
8884
|
[/^(Info)/, "More information"]
|
|
8271
8885
|
];
|
|
8886
|
+
/** A part that expands and collapses: `CollapsibleTrigger`, `Accordion.Trigger`, `DisclosureButton`. */
|
|
8887
|
+
const TOGGLE_PART = /(?:Collapsible|Accordion|Disclosure)\w*\.?\w*(?:Trigger|Button)$/;
|
|
8888
|
+
/** A part that closes its dialog, sheet or popover: `DialogClose`, `Dialog.Close`. */
|
|
8889
|
+
const CLOSE_PART = /Close$/;
|
|
8890
|
+
/** A part that opens a menu: `DropdownMenuTrigger`, `Menu.Trigger`, Headless UI's `MenuButton`. */
|
|
8891
|
+
const MENU_TRIGGER = /Menu\w*\.?\w*Trigger$|^(?:\w+\.)?Menu\.?Button$/;
|
|
8892
|
+
/** Classes styled by an expanded state: `data-[state=open]:rotate-90`, `aria-expanded:bg-muted`. */
|
|
8893
|
+
const EXPANDED_CLASS = /data-\[state=(?:open|closed)\]|aria-expanded:/;
|
|
8894
|
+
/** The tags of the button and of every element around it, its `render` host included. */
|
|
8895
|
+
function enclosingTags(context, element) {
|
|
8896
|
+
const tags = [element.tag];
|
|
8897
|
+
ts.findAncestor(element.node.parent, (node) => {
|
|
8898
|
+
if (ts.isJsxElement(node)) tags.push(node.openingElement.tagName.getText(context.sourceFile));
|
|
8899
|
+
return false;
|
|
8900
|
+
});
|
|
8901
|
+
return tags;
|
|
8902
|
+
}
|
|
8903
|
+
/** The names a callee is reached by: `deleteMutation.mutate` is `deleteMutation` and `mutate`. */
|
|
8904
|
+
function calleeNames(expression) {
|
|
8905
|
+
const names = [];
|
|
8906
|
+
let current = expression;
|
|
8907
|
+
for (;;) if (ts.isNonNullExpression(current) || ts.isParenthesizedExpression(current)) current = current.expression;
|
|
8908
|
+
else if (ts.isPropertyAccessExpression(current)) {
|
|
8909
|
+
names.unshift(current.name.text);
|
|
8910
|
+
current = current.expression;
|
|
8911
|
+
} else {
|
|
8912
|
+
if (ts.isIdentifier(current)) names.unshift(current.text);
|
|
8913
|
+
return names;
|
|
8914
|
+
}
|
|
8915
|
+
}
|
|
8916
|
+
/** The words that name each action in a function's name. */
|
|
8917
|
+
const ACTION_WORDS = [
|
|
8918
|
+
["Remove", ["remove", "delete"]],
|
|
8919
|
+
["Clear", ["clear", "reset"]],
|
|
8920
|
+
["Close", [
|
|
8921
|
+
"close",
|
|
8922
|
+
"dismiss",
|
|
8923
|
+
"hide"
|
|
8924
|
+
]]
|
|
8925
|
+
];
|
|
8926
|
+
/** Calls that tidy up around the action rather than name it: `clearTimeout(timer)`. */
|
|
8927
|
+
const HOUSEKEEPING = /^(?:clearTimeout|clearInterval|clearImmediate|removeEventListener|removeListener)$/;
|
|
8928
|
+
/**
|
|
8929
|
+
* What one call or handler reference does, by its name: `removeAssertion`, `onClose`,
|
|
8930
|
+
* `setOpen(false)`. A name with words of two actions (`closeDeleteDialog`, `hideRemoveConfirm`) or
|
|
8931
|
+
* with `cancel` (`onCancelDelete`) says none.
|
|
8932
|
+
*/
|
|
8933
|
+
function actionOf(names, argument) {
|
|
8934
|
+
const last = names.at(-1) ?? "";
|
|
8935
|
+
if (HOUSEKEEPING.test(last)) return void 0;
|
|
8936
|
+
const words = names.flatMap((name) => name.split(/(?=[A-Z])|_/)).map((w) => w.toLowerCase());
|
|
8937
|
+
if (argument?.kind === ts.SyntaxKind.FalseKeyword && words.some((w) => w === "open" || w === "show" || w === "visible")) return "Close";
|
|
8938
|
+
if (/^set[A-Z]/.test(last) || words.includes("cancel")) return void 0;
|
|
8939
|
+
const actions = ACTION_WORDS.filter(([, named]) => words.some((w) => named.includes(w)));
|
|
8940
|
+
return actions.length === 1 ? actions[0]?.[0] : void 0;
|
|
8941
|
+
}
|
|
8942
|
+
/**
|
|
8943
|
+
* What a button's `onClick` (or React Aria's `onPress`) does, by the names of the functions it
|
|
8944
|
+
* calls or passes, and whether that is sure: only when every name says the same action. A close
|
|
8945
|
+
* button that also tidies up (`form.reset(); setOpen(false)`) or a button that removes a row and
|
|
8946
|
+
* closes a popover is named "Close", or "Remove" when it removes and clears, without a fix.
|
|
8947
|
+
* Callbacks inside the handler run later, on their own events (`mutate(id, { onSuccess: () =>
|
|
8948
|
+
* toast.dismiss() })`), so they do not count.
|
|
8949
|
+
*/
|
|
8950
|
+
function handlerAction(attributes) {
|
|
8951
|
+
const found = /* @__PURE__ */ new Set();
|
|
8952
|
+
for (const attribute of attributes) {
|
|
8953
|
+
if (!/^on(?:Click|Press)$/.test(attributeName(attribute))) continue;
|
|
8954
|
+
const init = attribute.initializer;
|
|
8955
|
+
const value = init && ts.isJsxExpression(init) ? init.expression : void 0;
|
|
8956
|
+
if (!value) continue;
|
|
8957
|
+
const reference = actionOf(calleeNames(value));
|
|
8958
|
+
if (reference) found.add(reference);
|
|
8959
|
+
const stack = [value];
|
|
8960
|
+
for (let node = stack.pop(); node; node = stack.pop()) {
|
|
8961
|
+
if (ts.isCallExpression(node)) {
|
|
8962
|
+
const action = actionOf(calleeNames(node.expression), node.arguments[0]);
|
|
8963
|
+
if (action) found.add(action);
|
|
8964
|
+
}
|
|
8965
|
+
ts.forEachChild(node, (child) => {
|
|
8966
|
+
if (!ts.isFunctionLike(child)) stack.push(child);
|
|
8967
|
+
});
|
|
8968
|
+
}
|
|
8969
|
+
}
|
|
8970
|
+
const [only] = found;
|
|
8971
|
+
if (!only) return void 0;
|
|
8972
|
+
if (found.size === 1) return {
|
|
8973
|
+
action: only,
|
|
8974
|
+
sure: true
|
|
8975
|
+
};
|
|
8976
|
+
return {
|
|
8977
|
+
action: found.has("Close") ? "Close" : "Remove",
|
|
8978
|
+
sure: false
|
|
8979
|
+
};
|
|
8980
|
+
}
|
|
8981
|
+
/**
|
|
8982
|
+
* The label for an icon-only button, and whether the fix may write it: only when the icon means
|
|
8983
|
+
* one thing on a button, or the code says what it does. A chevron that expands or collapses gets
|
|
8984
|
+
* no direction; an X is named by its handler (`Remove`, `Clear`, `Close`), and `Close` is fixed
|
|
8985
|
+
* in a close part; "More options" is fixed on a menu's trigger.
|
|
8986
|
+
*/
|
|
8987
|
+
function iconLabel(context, element, host, icon) {
|
|
8988
|
+
const entry = icon ? ICON_LABELS.find(([pattern]) => pattern.test(icon)) : void 0;
|
|
8989
|
+
if (!icon || !entry) return { fixed: false };
|
|
8990
|
+
const [, label, plain] = entry;
|
|
8991
|
+
const tags = enclosingTags(context, element);
|
|
8992
|
+
const owners = host ? [element, host] : [element];
|
|
8993
|
+
const attributes = owners.flatMap((owner) => owner.attributes);
|
|
8994
|
+
if (label === "Next" || label === "Previous") return {
|
|
8995
|
+
label: tags.some((tag) => TOGGLE_PART.test(tag)) || attributes.some((a) => /^(?:aria-expanded|data-state)$/.test(attributeName(a))) || context.analysis.classStrings.some((c) => c.element && owners.includes(c.element) && EXPANDED_CLASS.test(c.text)) ? "Toggle" : label,
|
|
8996
|
+
fixed: false
|
|
8997
|
+
};
|
|
8998
|
+
if (label === "Close") {
|
|
8999
|
+
if (tags.some((tag) => CLOSE_PART.test(tag))) return {
|
|
9000
|
+
label,
|
|
9001
|
+
fixed: true
|
|
9002
|
+
};
|
|
9003
|
+
const handler = handlerAction(attributes);
|
|
9004
|
+
return handler ? {
|
|
9005
|
+
label: handler.action,
|
|
9006
|
+
fixed: handler.sure
|
|
9007
|
+
} : {
|
|
9008
|
+
label,
|
|
9009
|
+
fixed: false
|
|
9010
|
+
};
|
|
9011
|
+
}
|
|
9012
|
+
if (label === "More options") return {
|
|
9013
|
+
label,
|
|
9014
|
+
fixed: tags.some((tag) => MENU_TRIGGER.test(tag))
|
|
9015
|
+
};
|
|
9016
|
+
return {
|
|
9017
|
+
label,
|
|
9018
|
+
fixed: plain?.test(icon.replace(/Icon$/, "").replace(/\d+$/, "")) ?? false
|
|
9019
|
+
};
|
|
9020
|
+
}
|
|
8272
9021
|
/** Text wins, then content the rule cannot read, then icons. */
|
|
8273
9022
|
const PRECEDENCE = [
|
|
8274
9023
|
"text",
|
|
@@ -8474,14 +9223,13 @@ const iconButtonAccessibleName = {
|
|
|
8474
9223
|
const content = host && !children.length ? "unknown" : classify(children, context, icons);
|
|
8475
9224
|
const sizeIcon = attributeLiterals(element, "size").some((v) => v.text.includes("icon"));
|
|
8476
9225
|
if (content === "text" || content === "unknown" || content === "empty" && !sizeIcon && resolution.kind !== "intrinsic") continue;
|
|
8477
|
-
const
|
|
8478
|
-
const label = icon ? ICON_LABELS.find(([pattern]) => pattern.test(icon))?.[1] : void 0;
|
|
9226
|
+
const { label, fixed } = iconLabel(context, element, host, icons[0]);
|
|
8479
9227
|
const attribute = `aria-label="${label ?? "…"}"`;
|
|
8480
9228
|
context.report({
|
|
8481
9229
|
...tagRange(context, element),
|
|
8482
9230
|
message: content === "icon" ? `Icon-only <${element.tag}> has no accessible name. Add ${attribute} describing the action, or visually hidden text.` : `<${element.tag}> has no content and no accessible name. Add ${attribute}.`,
|
|
8483
9231
|
suggestion: attribute,
|
|
8484
|
-
fix:
|
|
9232
|
+
fix: fixed ? [{
|
|
8485
9233
|
range: [element.tagName.end, element.tagName.end],
|
|
8486
9234
|
text: ` ${attribute}`
|
|
8487
9235
|
}] : void 0
|
|
@@ -8491,6 +9239,58 @@ const iconButtonAccessibleName = {
|
|
|
8491
9239
|
};
|
|
8492
9240
|
//#endregion
|
|
8493
9241
|
//#region src/lint/rules/hardcoded.ts
|
|
9242
|
+
/** Luminance values, not design colors: `white`/`black` (and hex twins). */
|
|
9243
|
+
const MASK_LUMINANCE = /* @__PURE__ */ new Set([
|
|
9244
|
+
"white",
|
|
9245
|
+
"black",
|
|
9246
|
+
"#fff",
|
|
9247
|
+
"#ffffff",
|
|
9248
|
+
"#000",
|
|
9249
|
+
"#000000"
|
|
9250
|
+
]);
|
|
9251
|
+
/**
|
|
9252
|
+
* Whether the element sits inside an SVG `<mask>`: a stop's luminance sets
|
|
9253
|
+
* opacity there, so `white`/`black` stops are not design-system colors (#59).
|
|
9254
|
+
*/
|
|
9255
|
+
function isInsideMask(element) {
|
|
9256
|
+
const sourceFile = element.node.getSourceFile();
|
|
9257
|
+
return ts.findAncestor(element.node, (node) => {
|
|
9258
|
+
if (!ts.isJsxElement(node) && !ts.isJsxSelfClosingElement(node)) return false;
|
|
9259
|
+
return (ts.isJsxElement(node) ? node.openingElement : node).tagName.getText(sourceFile) === "mask";
|
|
9260
|
+
}) !== void 0;
|
|
9261
|
+
}
|
|
9262
|
+
/** `id` of the wrapping gradient, if the stop sits directly in one. */
|
|
9263
|
+
function gradientIdOfStop(element) {
|
|
9264
|
+
const gradient = ts.findAncestor(element.node.parent, (node) => ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node));
|
|
9265
|
+
if (gradient === void 0) return void 0;
|
|
9266
|
+
const opening = ts.isJsxElement(gradient) ? gradient.openingElement : gradient;
|
|
9267
|
+
const tag = opening.tagName.getText(element.node.getSourceFile());
|
|
9268
|
+
if (tag !== "linearGradient" && tag !== "radialGradient") return;
|
|
9269
|
+
const idAttr = opening.attributes.properties.filter(ts.isJsxAttribute).find((a) => attributeName(a) === "id");
|
|
9270
|
+
return idAttr?.initializer && ts.isStringLiteralLike(idAttr.initializer) ? idAttr.initializer.text : void 0;
|
|
9271
|
+
}
|
|
9272
|
+
const URL_REF = /url\(\s*#([^)\s]+)\s*\)/g;
|
|
9273
|
+
/**
|
|
9274
|
+
* Gradient ids referenced via `fill="url(#id)"` (or `stroke`) from inside a
|
|
9275
|
+
* `<mask>` subtree: stops of those gradients paint mask luminance, not
|
|
9276
|
+
* colors (#59, cal.com gridFade/gridMask shape).
|
|
9277
|
+
*/
|
|
9278
|
+
function maskReferencedGradientIds(elements) {
|
|
9279
|
+
const ids = /* @__PURE__ */ new Set();
|
|
9280
|
+
const refsFrom = (node) => {
|
|
9281
|
+
if (ts.isJsxAttribute(node)) {
|
|
9282
|
+
const name = attributeName(node);
|
|
9283
|
+
if ((name === "fill" || name === "stroke") && node.initializer && ts.isStringLiteralLike(node.initializer)) {
|
|
9284
|
+
URL_REF.lastIndex = 0;
|
|
9285
|
+
let match;
|
|
9286
|
+
while ((match = URL_REF.exec(node.initializer.text)) !== null) if (match[1]) ids.add(match[1]);
|
|
9287
|
+
}
|
|
9288
|
+
}
|
|
9289
|
+
ts.forEachChild(node, refsFrom);
|
|
9290
|
+
};
|
|
9291
|
+
for (const element of elements) if (element.tag === "mask") refsFrom(element.node);
|
|
9292
|
+
return ids;
|
|
9293
|
+
}
|
|
8494
9294
|
const noHardcodedColor = {
|
|
8495
9295
|
id: "no-hardcoded-color",
|
|
8496
9296
|
description: "Colors must come from design tokens: no hex/rgb/oklch literals, arbitrary Tailwind colors or default-palette classes.",
|
|
@@ -8498,71 +9298,94 @@ const noHardcodedColor = {
|
|
|
8498
9298
|
const { tokens } = context.target;
|
|
8499
9299
|
if (!tokens.has("color")) return;
|
|
8500
9300
|
const allow = new Set((context.options.allow ?? []).map((v) => v.toLowerCase()));
|
|
8501
|
-
|
|
8502
|
-
|
|
8503
|
-
|
|
8504
|
-
const
|
|
8505
|
-
|
|
8506
|
-
|
|
8507
|
-
|
|
8508
|
-
const
|
|
8509
|
-
|
|
8510
|
-
|
|
8511
|
-
|
|
8512
|
-
|
|
8513
|
-
|
|
8514
|
-
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
color
|
|
8518
|
-
|
|
8519
|
-
|
|
8520
|
-
|
|
8521
|
-
|
|
8522
|
-
|
|
8523
|
-
|
|
8524
|
-
|
|
8525
|
-
|
|
8526
|
-
|
|
8527
|
-
|
|
8528
|
-
|
|
8529
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
8532
|
-
|
|
8533
|
-
|
|
8534
|
-
|
|
8535
|
-
|
|
8536
|
-
|
|
8537
|
-
|
|
8538
|
-
|
|
8539
|
-
|
|
8540
|
-
|
|
8541
|
-
|
|
8542
|
-
|
|
8543
|
-
|
|
8544
|
-
|
|
8545
|
-
|
|
8546
|
-
|
|
8547
|
-
|
|
8548
|
-
|
|
8549
|
-
|
|
8550
|
-
|
|
8551
|
-
|
|
8552
|
-
|
|
8553
|
-
|
|
8554
|
-
|
|
8555
|
-
|
|
8556
|
-
|
|
8557
|
-
|
|
8558
|
-
|
|
8559
|
-
|
|
8560
|
-
|
|
8561
|
-
|
|
8562
|
-
|
|
8563
|
-
|
|
9301
|
+
const maskGradients = maskReferencedGradientIds(context.analysis.elements);
|
|
9302
|
+
const lists = /* @__PURE__ */ new Map();
|
|
9303
|
+
for (const classString of context.analysis.classStrings) {
|
|
9304
|
+
const key = classString.element ?? classString;
|
|
9305
|
+
const list = lists.get(key) ?? [];
|
|
9306
|
+
lists.set(key, list);
|
|
9307
|
+
for (const token of splitClasses(classString.text, classString.start)) {
|
|
9308
|
+
const utility = parseUtility(token.value);
|
|
9309
|
+
const listed = {
|
|
9310
|
+
value: token.value,
|
|
9311
|
+
prefix: tokenColorPrefix(utility, tokens.colorKeys)
|
|
9312
|
+
};
|
|
9313
|
+
list.push(listed);
|
|
9314
|
+
if (allow.has(token.value.toLowerCase())) continue;
|
|
9315
|
+
const element = classString.element;
|
|
9316
|
+
if (utility.arbitrary !== void 0 && utility.prefix && COLOR_PREFIXES.includes(utility.prefix)) {
|
|
9317
|
+
const value = utility.arbitrary.replace(/^color:/, "").trim();
|
|
9318
|
+
const whole = parseColor(value);
|
|
9319
|
+
const literal = whole ? {
|
|
9320
|
+
text: value,
|
|
9321
|
+
color: whole
|
|
9322
|
+
} : findColorLiterals(value)[0];
|
|
9323
|
+
if (!literal || allow.has(literal.text.toLowerCase())) continue;
|
|
9324
|
+
listed.color = literal.color;
|
|
9325
|
+
listed.prefix = whole ? utility.prefix : void 0;
|
|
9326
|
+
listed.report = colorReport(context, {
|
|
9327
|
+
start: token.start,
|
|
9328
|
+
end: token.end,
|
|
9329
|
+
what: `Hardcoded color \`${token.value}\``,
|
|
9330
|
+
color: literal.color,
|
|
9331
|
+
prefix: listed.prefix,
|
|
9332
|
+
place: "class",
|
|
9333
|
+
utility,
|
|
9334
|
+
element,
|
|
9335
|
+
classes: classString.text
|
|
9336
|
+
});
|
|
9337
|
+
continue;
|
|
9338
|
+
}
|
|
9339
|
+
const property = utility.property ? cssColorProperty(utility.property) : void 0;
|
|
9340
|
+
if (property && utility.arbitrary !== void 0) {
|
|
9341
|
+
const value = utility.arbitrary.trim();
|
|
9342
|
+
const whole = parseColor(value);
|
|
9343
|
+
const literal = whole ? {
|
|
9344
|
+
text: value,
|
|
9345
|
+
color: whole
|
|
9346
|
+
} : findColorLiterals(value)[0];
|
|
9347
|
+
if (!literal || allow.has(literal.text.toLowerCase())) continue;
|
|
9348
|
+
listed.color = literal.color;
|
|
9349
|
+
listed.prefix = whole ? property.prefix : void 0;
|
|
9350
|
+
listed.report = colorReport(context, {
|
|
9351
|
+
start: token.start,
|
|
9352
|
+
end: token.end,
|
|
9353
|
+
what: `Hardcoded color \`${token.value}\``,
|
|
9354
|
+
color: literal.color,
|
|
9355
|
+
prefix: listed.prefix,
|
|
9356
|
+
place: "class",
|
|
9357
|
+
utility,
|
|
9358
|
+
element,
|
|
9359
|
+
classes: classString.text
|
|
9360
|
+
});
|
|
9361
|
+
continue;
|
|
9362
|
+
}
|
|
9363
|
+
const palette = paletteColor(utility.base);
|
|
9364
|
+
if (palette && !tokens.colorKeys.has(palette.key)) {
|
|
9365
|
+
const color = parseColor(palette.value);
|
|
9366
|
+
if (!color) continue;
|
|
9367
|
+
listed.color = color;
|
|
9368
|
+
listed.prefix = palette.prefix;
|
|
9369
|
+
listed.report = colorReport(context, {
|
|
9370
|
+
start: token.start,
|
|
9371
|
+
end: token.end,
|
|
9372
|
+
what: `\`${token.value}\` is Tailwind's default palette, not a design-system color`,
|
|
9373
|
+
color,
|
|
9374
|
+
tinted: !palette.gray,
|
|
9375
|
+
palette: palette.key,
|
|
9376
|
+
prefix: palette.prefix,
|
|
9377
|
+
place: "class",
|
|
9378
|
+
utility,
|
|
9379
|
+
element,
|
|
9380
|
+
classes: classString.text
|
|
9381
|
+
});
|
|
9382
|
+
}
|
|
8564
9383
|
}
|
|
8565
9384
|
}
|
|
9385
|
+
for (const list of lists.values()) {
|
|
9386
|
+
keepStatesApart(list);
|
|
9387
|
+
for (const { report } of list) if (report) context.report(report);
|
|
9388
|
+
}
|
|
8566
9389
|
for (const { object, element } of context.analysis.styles) for (const prop of object.properties) {
|
|
8567
9390
|
if (!ts.isPropertyAssignment(prop)) continue;
|
|
8568
9391
|
const name = propertyName(prop.name);
|
|
@@ -8585,13 +9408,16 @@ const noHardcodedColor = {
|
|
|
8585
9408
|
if (!isColorAttribute(name)) continue;
|
|
8586
9409
|
if (resolution.kind === "component" && resolution.component.props.some((p) => p.name === name && p.values?.length)) continue;
|
|
8587
9410
|
const prefix = name === "fill" || name === "stroke" ? name : "text";
|
|
8588
|
-
for (const literal of literalValues(attribute))
|
|
8589
|
-
|
|
8590
|
-
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
|
|
8594
|
-
|
|
9411
|
+
for (const literal of literalValues(attribute)) {
|
|
9412
|
+
if ((name === "stopColor" || name === "stop-color") && element.tag === "stop" && MASK_LUMINANCE.has(literal.text.toLowerCase()) && (isInsideMask(element) || maskGradients.has(gradientIdOfStop(element) ?? ""))) continue;
|
|
9413
|
+
reportLiteralColors(context, literal, element, {
|
|
9414
|
+
where: `${name}="…"`,
|
|
9415
|
+
prefix,
|
|
9416
|
+
place: "attribute",
|
|
9417
|
+
named: resolution.kind === "intrinsic",
|
|
9418
|
+
allow
|
|
9419
|
+
});
|
|
9420
|
+
}
|
|
8595
9421
|
}
|
|
8596
9422
|
}
|
|
8597
9423
|
}
|
|
@@ -8611,7 +9437,7 @@ function reportLiteralColors(context, literal, element, options) {
|
|
|
8611
9437
|
}
|
|
8612
9438
|
for (const match of matches) {
|
|
8613
9439
|
if (options.allow.has(match.text.toLowerCase())) continue;
|
|
8614
|
-
|
|
9440
|
+
const report = colorReport(context, {
|
|
8615
9441
|
start: start + match.index,
|
|
8616
9442
|
end: start + match.index + match.text.length,
|
|
8617
9443
|
what: `Hardcoded color \`${match.text}\` in ${options.where}`,
|
|
@@ -8620,9 +9446,11 @@ function reportLiteralColors(context, literal, element, options) {
|
|
|
8620
9446
|
place: options.place,
|
|
8621
9447
|
element
|
|
8622
9448
|
});
|
|
9449
|
+
if (report) context.report(report);
|
|
8623
9450
|
}
|
|
8624
9451
|
}
|
|
8625
|
-
|
|
9452
|
+
/** The finding for a color, or undefined when the design system has no color to name. */
|
|
9453
|
+
function colorReport(context, input) {
|
|
8626
9454
|
const role = colorRole(input.prefix ?? input.utility?.prefix);
|
|
8627
9455
|
const mode = input.utility?.variants.includes("dark") ? "dark" : void 0;
|
|
8628
9456
|
const suggestion = context.target.tokens.suggestColor(input.color, {
|
|
@@ -8631,13 +9459,53 @@ function reportColor(context, input) {
|
|
|
8631
9459
|
tinted: input.tinted,
|
|
8632
9460
|
mode
|
|
8633
9461
|
});
|
|
8634
|
-
if (!suggestion) return;
|
|
8635
|
-
const { match, nearest, reason, sameValue, hueOff } = suggestion;
|
|
9462
|
+
if (!suggestion) return void 0;
|
|
9463
|
+
const { match, nearest, reason, sameValue, hueOff, otherRole, closeStatus } = suggestion;
|
|
8636
9464
|
const token = nearest.candidate.token;
|
|
8637
|
-
const cls
|
|
8638
|
-
const cssVar = cssReference(token);
|
|
9465
|
+
const { cls, replacement, advice, edit } = writing(input, token);
|
|
8639
9466
|
const close = match !== void 0;
|
|
9467
|
+
const held = close && input.palette && nearest.distance >= .02 ? heldBack(input.palette, valueIn(nearest.candidate, mode), nearest.distance) : void 0;
|
|
9468
|
+
const status = close && closeStatus ? statusTooClose(input, mode, closeStatus, token, advice) : void 0;
|
|
8640
9469
|
const variant = cls && input.element ? variantApplying(context, input.element, cls) : void 0;
|
|
9470
|
+
const same = sameValue.length ? `, same value as ${sameValue.map((t) => t.name).join(", ")}` : "";
|
|
9471
|
+
const inMode = mode && token.modes?.[mode] !== void 0 ? ` in ${mode} mode` : "";
|
|
9472
|
+
const value = valueIn(nearest.candidate, mode);
|
|
9473
|
+
const found = `${token.name}${inMode} (${formatDistance(input.color, value)}${same})`;
|
|
9474
|
+
const opacityGap = formatOpacityGap(input.color, value, `${token.name}${inMode}`);
|
|
9475
|
+
const tinted = input.tinted ?? isTinted(input.color);
|
|
9476
|
+
const noHue = !close && reason === "hue" && tinted;
|
|
9477
|
+
let message;
|
|
9478
|
+
if (close) {
|
|
9479
|
+
message = `${input.what}. ${nearest.distance < .02 ? "Matches token" : "Nearest token"} ${found}`;
|
|
9480
|
+
if (advice) message += ` → ${advice}`;
|
|
9481
|
+
if (variant) message += `, or use ${variant}`;
|
|
9482
|
+
message += ".";
|
|
9483
|
+
if (opacityGap) message += ` ${opacityGap}`;
|
|
9484
|
+
if (status) message += ` ${status}`;
|
|
9485
|
+
else if (held) message += ` ${held}`;
|
|
9486
|
+
} else if (noHue) message = `${input.what}. ${NO_TOKEN_HAS_HUE}`;
|
|
9487
|
+
else message = `${input.what}. ${noMatch(reason, role, found, hueOff)}${meantForAnother(otherRole, input.color, mode)}${opacityGap ? ` ${opacityGap}` : ""} Pick the semantic token that fits.`;
|
|
9488
|
+
const owner = !variant && input.prefix && input.element ? variantProp(context, input.element, input.prefix) : void 0;
|
|
9489
|
+
if (owner) message += ` <${owner.component}> already sets ${input.prefix}-* through \`${owner.prop}\`; prefer a variant over overriding it.`;
|
|
9490
|
+
return {
|
|
9491
|
+
start: input.start,
|
|
9492
|
+
end: input.end,
|
|
9493
|
+
message,
|
|
9494
|
+
suggestion: close ? variant ?? replacement : void 0,
|
|
9495
|
+
fix: close && edit && !held && !status ? [{
|
|
9496
|
+
range: [input.start, input.end],
|
|
9497
|
+
text: edit
|
|
9498
|
+
}] : void 0,
|
|
9499
|
+
severity: noHue ? "warning" : void 0
|
|
9500
|
+
};
|
|
9501
|
+
}
|
|
9502
|
+
/**
|
|
9503
|
+
* How code writes `token` where the color is: the class (`bg-primary`), what to suggest, the
|
|
9504
|
+
* message's advice and the text a fix puts in place of the color.
|
|
9505
|
+
*/
|
|
9506
|
+
function writing(input, token) {
|
|
9507
|
+
const cls = input.prefix && token.tailwind ? `${input.prefix}-${token.tailwind}` : void 0;
|
|
9508
|
+
const cssVar = cssReference(token);
|
|
8641
9509
|
let replacement;
|
|
8642
9510
|
let advice;
|
|
8643
9511
|
let edit;
|
|
@@ -8654,45 +9522,237 @@ function reportColor(context, input) {
|
|
|
8654
9522
|
advice = cls ? `\`${cls}\` in className` : void 0;
|
|
8655
9523
|
}
|
|
8656
9524
|
if (!cls && !cssVar && token.usage.length) advice = token.usage.map((usage) => `\`${usage}\``).join(" or ");
|
|
8657
|
-
|
|
9525
|
+
return {
|
|
9526
|
+
cls,
|
|
9527
|
+
replacement,
|
|
9528
|
+
advice,
|
|
9529
|
+
edit
|
|
9530
|
+
};
|
|
9531
|
+
}
|
|
9532
|
+
/**
|
|
9533
|
+
* Why a close match is not applied when a status color of the color's hue is about as close:
|
|
9534
|
+
* `destructive is close too (ΔE 0.071), and it is a status color, so this is not applied
|
|
9535
|
+
* automatically: use \`bg-destructive\` if the color shows a status, otherwise \`bg-brand\`.`
|
|
9536
|
+
*/
|
|
9537
|
+
function statusTooClose(input, mode, status, found, advice) {
|
|
9538
|
+
const { token } = status.candidate;
|
|
8658
9539
|
const inMode = mode && token.modes?.[mode] !== void 0 ? ` in ${mode} mode` : "";
|
|
8659
|
-
const
|
|
8660
|
-
|
|
8661
|
-
|
|
8662
|
-
|
|
8663
|
-
|
|
8664
|
-
|
|
8665
|
-
|
|
8666
|
-
|
|
8667
|
-
|
|
8668
|
-
|
|
9540
|
+
const distance = formatDistance(input.color, valueIn(status.candidate, mode));
|
|
9541
|
+
const statusAdvice = writing(input, token).advice || token.name;
|
|
9542
|
+
return `${token.name}${inMode} is close too (${distance}), and it is a status color, so this is not applied automatically: use ${statusAdvice} if the color shows a status, otherwise ${advice || found.name}.`;
|
|
9543
|
+
}
|
|
9544
|
+
/** Color keywords that are no token but set a color (`bg-white`, `border-transparent`). */
|
|
9545
|
+
const KEYWORD_COLORS = /* @__PURE__ */ new Set([
|
|
9546
|
+
"white",
|
|
9547
|
+
"black",
|
|
9548
|
+
"transparent",
|
|
9549
|
+
"current",
|
|
9550
|
+
"inherit"
|
|
9551
|
+
]);
|
|
9552
|
+
/** The prefix of a class that sets a token's color or a keyword color: `bg` for `bg-primary`. */
|
|
9553
|
+
function tokenColorPrefix(utility, colorKeys) {
|
|
9554
|
+
const key = COLOR_UTILITY.exec(utility.base)?.[1];
|
|
9555
|
+
if (!key || !(colorKeys.has(key) || KEYWORD_COLORS.has(key))) return void 0;
|
|
9556
|
+
return utility.base.slice(0, -key.length - 1);
|
|
9557
|
+
}
|
|
9558
|
+
/**
|
|
9559
|
+
* Takes back a fix that would make a class under a condition (`hover:`, `focus-visible:`, `md:`)
|
|
9560
|
+
* the same color as the class without it: `bg-[#2563eb] hover:bg-[#1d4ed8]` would become
|
|
9561
|
+
* `bg-primary hover:bg-primary`, and the hover would change nothing. The state class gives way
|
|
9562
|
+
* first; a base class does when the state class already has the token it would get. `dark:` is
|
|
9563
|
+
* no such condition: dark classes are compared with the tokens' dark values, where the same
|
|
9564
|
+
* token is right. Two hardcoded classes of exactly the same color keep their fixes: the
|
|
9565
|
+
* condition changed nothing before either (`border-red-500 focus:border-red-500` keeps an error
|
|
9566
|
+
* border red on focus), and a hardcoded color is the same in every mode. A token class is not:
|
|
9567
|
+
* the class list's `bg-primary` is another color in dark mode than a `hover:bg-[#171717]` next
|
|
9568
|
+
* to it. A `dark:` state class starts from the list's `dark:` class, when it has one.
|
|
9569
|
+
*/
|
|
9570
|
+
function keepStatesApart(list) {
|
|
9571
|
+
const after = (listed) => listed.report?.fix?.[0]?.text ?? listed.value;
|
|
9572
|
+
for (const pass of ["state", "base"]) for (const listed of list) {
|
|
9573
|
+
const { report } = listed;
|
|
9574
|
+
if (!report?.fix?.[0]) continue;
|
|
9575
|
+
for (const other of list) {
|
|
9576
|
+
if (other === listed || oneColor(listed.color, other.color)) continue;
|
|
9577
|
+
const [stateListed, baseListed] = pass === "state" ? [listed, other] : [other, listed];
|
|
9578
|
+
const state = parseUtility(after(stateListed));
|
|
9579
|
+
const base = parseUtility(after(baseListed));
|
|
9580
|
+
const conditions = conditionsBetween(state, base);
|
|
9581
|
+
if (!conditions || hasDarkBase(list, stateListed, baseListed, state, base, after)) continue;
|
|
9582
|
+
const had = pass === "base" ? `\`${other.value}\` is in the class list too` : after(other) === other.value ? `The class list already has \`${other.value}\`` : `\`${other.value}\` becomes \`${after(other)}\` too`;
|
|
9583
|
+
const apart = state.modifier ? "such as with another opacity modifier" : `such as with \`${withBase({
|
|
9584
|
+
...state,
|
|
9585
|
+
modifier: "/90"
|
|
9586
|
+
}, state.base)}\``;
|
|
9587
|
+
report.message += ` ${had}, so with this fix \`${conditions}\` would change nothing: it is not applied automatically. Keep the two apart, ${apart}.`;
|
|
9588
|
+
report.fix = void 0;
|
|
9589
|
+
break;
|
|
9590
|
+
}
|
|
8669
9591
|
}
|
|
8670
|
-
|
|
8671
|
-
|
|
8672
|
-
|
|
8673
|
-
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
|
|
8680
|
-
|
|
9592
|
+
}
|
|
9593
|
+
/**
|
|
9594
|
+
* Below this ΔE two hardcoded colors are one color written two ways: a palette class and its
|
|
9595
|
+
* hex (`neutral-100` is 0.00015 from `#f5f5f5`), or an OKLCH value rounded to hex (red-500 is
|
|
9596
|
+
* 0.00097 from `#fb2c36`). SAME_COLOR is too loose for this: `#fafafa` is 0.015 from `#fff`,
|
|
9597
|
+
* and a hover from one to the other is a change the fix must not remove.
|
|
9598
|
+
*/
|
|
9599
|
+
const ONE_COLOR = .003;
|
|
9600
|
+
/** Whether two hardcoded colors are exactly one color, so one token for both changes nothing. */
|
|
9601
|
+
function oneColor(a, b) {
|
|
9602
|
+
return a !== void 0 && b !== void 0 && colorDistance(a, b) < ONE_COLOR;
|
|
9603
|
+
}
|
|
9604
|
+
/**
|
|
9605
|
+
* Whether the class list has the base class's dark-mode counterpart, which a `dark:` state class
|
|
9606
|
+
* starts from instead: with `bg-[#171717] dark:bg-[#0a0a0a]`, `dark:hover:` changes the dark
|
|
9607
|
+
* background, not `bg-[#171717]`. Only a class that adds `dark:` and nothing else to the base
|
|
9608
|
+
* class's variants counts: one that adds a condition (`group-hover:`) is off with it.
|
|
9609
|
+
*/
|
|
9610
|
+
function hasDarkBase(list, stateListed, baseListed, state, base, after) {
|
|
9611
|
+
if (baseListed.prefix === void 0) return false;
|
|
9612
|
+
if (base.variants.includes("dark") || !state.variants.includes("dark")) return false;
|
|
9613
|
+
return list.some((other) => {
|
|
9614
|
+
if (other === stateListed || other === baseListed || other.prefix !== baseListed.prefix) return false;
|
|
9615
|
+
const { variants } = parseUtility(after(other));
|
|
9616
|
+
return variants.includes("dark") && variants.every((variant) => variant === "dark" || base.variants.includes(variant)) && base.variants.every((variant) => variants.includes(variant));
|
|
8681
9617
|
});
|
|
8682
9618
|
}
|
|
9619
|
+
/**
|
|
9620
|
+
* The conditions (`hover:`) under which `state` applies and `base` does too, when both are the
|
|
9621
|
+
* same color: undefined when they are not, or `state` adds no condition but `dark:`, or styles
|
|
9622
|
+
* another element (`placeholder:`, `prose-a:`), whose color the base class does not set.
|
|
9623
|
+
*/
|
|
9624
|
+
function conditionsBetween(state, base) {
|
|
9625
|
+
if (state.base !== base.base || state.modifier !== base.modifier) return void 0;
|
|
9626
|
+
if (!base.variants.every((variant) => state.variants.includes(variant))) return void 0;
|
|
9627
|
+
const added = state.variants.filter((v) => v !== "dark" && !base.variants.includes(v));
|
|
9628
|
+
if (added.some(stylesAnother)) return void 0;
|
|
9629
|
+
return added.length ? `${added.join(":")}:` : void 0;
|
|
9630
|
+
}
|
|
9631
|
+
/** Variants for a pseudo-element or other elements than the one the class is on. */
|
|
9632
|
+
const OTHER_ELEMENT = /* @__PURE__ */ new Set([
|
|
9633
|
+
"*",
|
|
9634
|
+
"**",
|
|
9635
|
+
"after",
|
|
9636
|
+
"backdrop",
|
|
9637
|
+
"before",
|
|
9638
|
+
"details-content",
|
|
9639
|
+
"file",
|
|
9640
|
+
"first-letter",
|
|
9641
|
+
"first-line",
|
|
9642
|
+
"marker",
|
|
9643
|
+
"placeholder",
|
|
9644
|
+
"selection"
|
|
9645
|
+
]);
|
|
9646
|
+
/**
|
|
9647
|
+
* Whether a variant styles something else than the element: a pseudo-element (`placeholder:`),
|
|
9648
|
+
* children (`*:`), the typography plugin's elements (`prose-a:`), or an arbitrary selector that
|
|
9649
|
+
* goes on from `&` (`[&>svg]:`, `[&_a]:`, `[&::after]:`), not one that only narrows it
|
|
9650
|
+
* (`[&:hover]:`, `[.dark_&]:`).
|
|
9651
|
+
*/
|
|
9652
|
+
function stylesAnother(variant) {
|
|
9653
|
+
if (OTHER_ELEMENT.has(variant) || variant.startsWith("prose-")) return true;
|
|
9654
|
+
const selector = /^\[(.*)\]$/.exec(variant)?.[1];
|
|
9655
|
+
if (!selector?.includes("&")) return false;
|
|
9656
|
+
const after = selector.slice(selector.lastIndexOf("&") + 1).replace(/\([^()]*\)|\[[^\]]*\]/g, "");
|
|
9657
|
+
return /[_>+~]|::/.test(after);
|
|
9658
|
+
}
|
|
9659
|
+
/**
|
|
9660
|
+
* When no token has the color's hue: add one, or keep the color and say why. Between JSX
|
|
9661
|
+
* children a `//` line is text, which suppresses nothing and renders, so that place gets the
|
|
9662
|
+
* `{/* *\/}` form.
|
|
9663
|
+
*/
|
|
9664
|
+
const NO_TOKEN_HAS_HUE = "No token has this hue. Add a token for it to the theme, such as `--success` for a status color, or keep this color and give the reason on the line above: `// onsystem-disable-next-line no-hardcoded-color -- <reason>` (between JSX children, `{/* onsystem-disable-next-line no-hardcoded-color -- <reason> */}`).";
|
|
9665
|
+
/**
|
|
9666
|
+
* The most Tailwind v3 and v4 differ on one shade, in ΔE: 0.036 for fuchsia-600,
|
|
9667
|
+
* under 0.01 for the grays. A token this close to a class's shade, and nearer it
|
|
9668
|
+
* than to the family's other shades, can be a v3 project's copy of it.
|
|
9669
|
+
*/
|
|
9670
|
+
const PALETTE_DRIFT = .037;
|
|
9671
|
+
/** Tailwind's default palette as colors: family → its shades, lightest first. */
|
|
9672
|
+
const PALETTE_SHADES = /* @__PURE__ */ new Map();
|
|
9673
|
+
/** A family's shades as colors, lightest first: `neutral` → 50, 100, … 950. */
|
|
9674
|
+
function shadesOf(family) {
|
|
9675
|
+
let shades = PALETTE_SHADES.get(family);
|
|
9676
|
+
if (!shades) {
|
|
9677
|
+
shades = [];
|
|
9678
|
+
for (const [shade, value] of Object.entries(TAILWIND_PALETTE[family] ?? {})) {
|
|
9679
|
+
const parsed = parseColor(value);
|
|
9680
|
+
if (parsed) shades.push([shade, parsed]);
|
|
9681
|
+
}
|
|
9682
|
+
PALETTE_SHADES.set(family, shades);
|
|
9683
|
+
}
|
|
9684
|
+
return shades;
|
|
9685
|
+
}
|
|
9686
|
+
/** Index of the color in `colors` nearest to `color`, or -1 when there is none. */
|
|
9687
|
+
function nearestOf(colors, color) {
|
|
9688
|
+
let index = -1;
|
|
9689
|
+
let best = Infinity;
|
|
9690
|
+
colors.forEach(([, value], i) => {
|
|
9691
|
+
const distance = colorDistance(color, value);
|
|
9692
|
+
if (distance < best) [index, best] = [i, distance];
|
|
9693
|
+
});
|
|
9694
|
+
return index;
|
|
9695
|
+
}
|
|
9696
|
+
/** The palette color nearest to `color` across every family (`neutral-500`). */
|
|
9697
|
+
function nearestPaletteColor(color) {
|
|
9698
|
+
const all = Object.keys(TAILWIND_PALETTE).flatMap((family) => shadesOf(family).map(([shade, value]) => [`${family}-${shade}`, value]));
|
|
9699
|
+
return all[nearestOf(all, color)]?.[0];
|
|
9700
|
+
}
|
|
9701
|
+
/**
|
|
9702
|
+
* Why a token that is not an exact match for a palette class (`key`, `neutral-600`)
|
|
9703
|
+
* is suggested but not applied, or undefined when it may be: it is nearer another
|
|
9704
|
+
* shade of the family (Dub's `content-default`, neutral-700, for `text-neutral-600`),
|
|
9705
|
+
* or it is the class's shade but further from it than Tailwind v3 and v4 are from
|
|
9706
|
+
* each other (`content-subtle`, neutral-500, for `text-slate-500`).
|
|
9707
|
+
*/
|
|
9708
|
+
function heldBack(key, color, distance) {
|
|
9709
|
+
const [family = "", own = ""] = key.split("-");
|
|
9710
|
+
const shades = shadesOf(family);
|
|
9711
|
+
const from = shades.findIndex(([shade]) => shade === own);
|
|
9712
|
+
const to = nearestOf(shades, color);
|
|
9713
|
+
if (from === -1 || to === -1) return void 0;
|
|
9714
|
+
const steps = to - from;
|
|
9715
|
+
let what;
|
|
9716
|
+
let intended;
|
|
9717
|
+
if (steps) {
|
|
9718
|
+
const count = Math.abs(steps) === 1 ? "one shade" : `${String(Math.abs(steps))} shades`;
|
|
9719
|
+
const way = steps > 0 ? "darker" : "lighter";
|
|
9720
|
+
what = `${count} ${way} (${family}-${shades[to]?.[0] ?? ""})`;
|
|
9721
|
+
intended = `the ${way} color`;
|
|
9722
|
+
} else {
|
|
9723
|
+
if (distance < PALETTE_DRIFT) return void 0;
|
|
9724
|
+
const closest = nearestPaletteColor(color);
|
|
9725
|
+
what = closest && closest !== key ? `closest to ${closest}, not ${key}` : `a different color from ${key}`;
|
|
9726
|
+
intended = "that color";
|
|
9727
|
+
}
|
|
9728
|
+
return `That is ${what}, so it is not applied automatically: use it if ${intended} is intended, otherwise pick the semantic token that fits.`;
|
|
9729
|
+
}
|
|
9730
|
+
/** A token's value where the class applies: a `dark:` class takes its dark-mode value. */
|
|
9731
|
+
function valueIn(candidate, mode) {
|
|
9732
|
+
return mode && candidate.modes?.[mode] || candidate.color;
|
|
9733
|
+
}
|
|
9734
|
+
/** `content-subtle has this color (exact match), but it is meant for text.` */
|
|
9735
|
+
function meantForAnother(other, color, mode) {
|
|
9736
|
+
if (!other) return "";
|
|
9737
|
+
const { token } = other.candidate;
|
|
9738
|
+
const role = tokenRole(token);
|
|
9739
|
+
const how = other.distance < .02 ? "has this color" : "is close";
|
|
9740
|
+
const inMode = mode && token.modes?.[mode] !== void 0 ? ` in ${mode} mode` : "";
|
|
9741
|
+
return ` ${token.name} ${how}${inMode} (${formatDistance(color, valueIn(other.candidate, mode))}), but it is meant for ${role ? ROLE_WORDS[role] : "another role"}.`;
|
|
9742
|
+
}
|
|
8683
9743
|
const ROLE_WORDS = {
|
|
8684
9744
|
text: "text",
|
|
8685
9745
|
surface: "backgrounds",
|
|
8686
9746
|
line: "borders"
|
|
8687
9747
|
};
|
|
8688
|
-
/**
|
|
8689
|
-
|
|
9748
|
+
/**
|
|
9749
|
+
* Why no token replaces the color, naming the nearest one (`found`). A tinted color
|
|
9750
|
+
* with no token of its hue gets `NO_TOKEN_HAS_HUE` instead, which names none.
|
|
9751
|
+
*/
|
|
9752
|
+
function noMatch(reason, role, found, hueOff) {
|
|
8690
9753
|
if (reason === "status") return `Nearest is ${found}, a status color ${String(hueOff)}° away in hue; status colors are only suggested for their own hue.`;
|
|
8691
9754
|
if (reason === "scoped") return `Nearest is ${found}, a token one part of the UI uses (not this one).`;
|
|
8692
|
-
if (reason === "hue") {
|
|
8693
|
-
if (!tinted) return `No gray token is close; nearest is ${found}, which is tinted.`;
|
|
8694
|
-
return `No token has this hue; nearest is ${found}, ${isTinted(token) ? "another hue" : "a gray"}.`;
|
|
8695
|
-
}
|
|
9755
|
+
if (reason === "hue") return `No gray token is close; nearest is ${found}, which is tinted.`;
|
|
8696
9756
|
if (reason === "role" && role) return `No close token is meant for ${ROLE_WORDS[role]}; nearest is ${found}.`;
|
|
8697
9757
|
return `No close token; nearest is ${found}.`;
|
|
8698
9758
|
}
|
|
@@ -8758,6 +9818,7 @@ function lengthRule(id, description, spec) {
|
|
|
8758
9818
|
if (negative && !NEGATIVE_PREFIXES.has(utility.prefix)) continue;
|
|
8759
9819
|
const replacement = lengthReplacement(context, spec.category, px, utility.prefix);
|
|
8760
9820
|
if (!replacement || unnamed(replacement)) continue;
|
|
9821
|
+
if (spec.category === "spacing" && Math.abs(px) <= 1 && (!replacement.exact || negative)) continue;
|
|
8761
9822
|
const fixed = withBase({
|
|
8762
9823
|
...utility,
|
|
8763
9824
|
negative
|
|
@@ -8791,6 +9852,7 @@ function lengthRule(id, description, spec) {
|
|
|
8791
9852
|
if (negative && !NEGATIVE_PREFIXES.has(prefix)) continue;
|
|
8792
9853
|
const replacement = values.length === 1 ? lengthReplacement(context, spec.category, px, prefix) : void 0;
|
|
8793
9854
|
if (replacement && unnamed(replacement)) continue;
|
|
9855
|
+
if (replacement && spec.category === "spacing" && Math.abs(px) <= 1 && (!replacement.exact || negative)) continue;
|
|
8794
9856
|
const cls = replacement && `${negative ? "-" : ""}${replacement.cls}`;
|
|
8795
9857
|
const cssVar = replacement?.cssVar && (negative ? `calc(${replacement.cssVar} * -1)` : replacement.cssVar);
|
|
8796
9858
|
const shown = number !== void 0 ? `${name}: ${raw}` : `${name}: "${raw}"`;
|
|
@@ -8963,13 +10025,14 @@ function findingKey(diagnostic) {
|
|
|
8963
10025
|
const text = diagnostic.source.replace(/\s+/g, " ").trim();
|
|
8964
10026
|
return (/^(['"`])(.*)\1$/.exec(text)?.[2] ?? text).slice(0, 200);
|
|
8965
10027
|
}
|
|
8966
|
-
/** The baseline
|
|
10028
|
+
/** The baseline, or undefined when the file does not exist. */
|
|
8967
10029
|
function readBaseline(file) {
|
|
8968
10030
|
let text;
|
|
8969
10031
|
try {
|
|
8970
10032
|
text = fs.readFileSync(file, "utf8");
|
|
8971
10033
|
} catch (error) {
|
|
8972
|
-
|
|
10034
|
+
const { code } = error;
|
|
10035
|
+
if (code === "ENOENT" || code === "ENOTDIR") return void 0;
|
|
8973
10036
|
throw new ConfigError(`Could not read baseline ${file}: ${error.message}`);
|
|
8974
10037
|
}
|
|
8975
10038
|
const invalid = (why) => new ConfigError(`${file} is not a valid onsystem baseline (${why}). Fix it, or delete it and run check --update-baseline.`);
|
|
@@ -8981,6 +10044,8 @@ function readBaseline(file) {
|
|
|
8981
10044
|
}
|
|
8982
10045
|
if (!isObject(parsed) || parsed.version !== 1) throw invalid("expected \"version\": 1");
|
|
8983
10046
|
if (!isObject(parsed.files)) throw invalid("\"files\" must be an object");
|
|
10047
|
+
if (parsed.onsystem !== void 0 && typeof parsed.onsystem !== "string") throw invalid("\"onsystem\" must be a version string");
|
|
10048
|
+
if (parsed.rules !== void 0 && !(Array.isArray(parsed.rules) && parsed.rules.every((rule) => typeof rule === "string"))) throw invalid("\"rules\" must be a list of rule ids");
|
|
8984
10049
|
const entries = /* @__PURE__ */ new Map();
|
|
8985
10050
|
for (const [name, rules] of Object.entries(parsed.files)) {
|
|
8986
10051
|
if (!isObject(rules)) throw invalid(`"${name}" must map rules to findings`);
|
|
@@ -8996,7 +10061,11 @@ function readBaseline(file) {
|
|
|
8996
10061
|
}
|
|
8997
10062
|
entries.set(name, byRule);
|
|
8998
10063
|
}
|
|
8999
|
-
return
|
|
10064
|
+
return {
|
|
10065
|
+
entries,
|
|
10066
|
+
onsystem: parsed.onsystem,
|
|
10067
|
+
rules: parsed.rules ? new Set(parsed.rules) : void 0
|
|
10068
|
+
};
|
|
9000
10069
|
}
|
|
9001
10070
|
/**
|
|
9002
10071
|
* Removes the findings the baseline accepts for `file`. Within a file and
|
|
@@ -9057,6 +10126,71 @@ function updateBaseline(previous, checked, root, disabled = /* @__PURE__ */ new
|
|
|
9057
10126
|
}
|
|
9058
10127
|
return next;
|
|
9059
10128
|
}
|
|
10129
|
+
/**
|
|
10130
|
+
* The rules an update records as run: those that `ran` now, those the previous baseline lists,
|
|
10131
|
+
* and those with entries in `next`, since a rule with recorded findings ran when they were
|
|
10132
|
+
* recorded. Undefined, as in a baseline written before onsystem recorded rules, when the
|
|
10133
|
+
* previous baseline does not list them and the update may have left out files it covers: the
|
|
10134
|
+
* run checked only a folder or files below the root (`wholeRoot` false), or `next` keeps
|
|
10135
|
+
* entries of files the run did not check. Whatever ran over those files was not recorded, so a
|
|
10136
|
+
* list built from this run could say a rule did not run where it did.
|
|
10137
|
+
*/
|
|
10138
|
+
function rulesRun(previous, next, checked, ran, wholeRoot) {
|
|
10139
|
+
if (previous && !previous.rules && (!wholeRoot || [...next.keys()].some((file) => !checked.has(file)))) return;
|
|
10140
|
+
const known = new Set(RULE_IDS);
|
|
10141
|
+
const rules = /* @__PURE__ */ new Set([...previous?.rules ?? [], ...ran]);
|
|
10142
|
+
for (const byRule of next.values()) for (const rule of byRule.keys()) if (known.has(rule)) rules.add(rule);
|
|
10143
|
+
return [...rules];
|
|
10144
|
+
}
|
|
10145
|
+
/**
|
|
10146
|
+
* The baseline without the entries a run over `checked` (file → its findings) shows no longer
|
|
10147
|
+
* occur: a checked file keeps at most as many of each finding as it still has, plus its entries
|
|
10148
|
+
* for `disabled` rules, and files the run did not check keep theirs while they still exist under
|
|
10149
|
+
* `root`. Nothing is added, so a new finding stays new, unlike with `updateBaseline`.
|
|
10150
|
+
*/
|
|
10151
|
+
function pruneBaseline(previous, checked, root, disabled) {
|
|
10152
|
+
const next = /* @__PURE__ */ new Map();
|
|
10153
|
+
for (const [file, rules] of previous) {
|
|
10154
|
+
const diagnostics = checked.get(file);
|
|
10155
|
+
if (!diagnostics) {
|
|
10156
|
+
if (fs.existsSync(path.resolve(root, file))) next.set(file, rules);
|
|
10157
|
+
continue;
|
|
10158
|
+
}
|
|
10159
|
+
const found = /* @__PURE__ */ new Map();
|
|
10160
|
+
for (const diagnostic of diagnostics) {
|
|
10161
|
+
const id = `${diagnostic.ruleId}\0${findingKey(diagnostic)}`;
|
|
10162
|
+
found.set(id, (found.get(id) ?? 0) + 1);
|
|
10163
|
+
}
|
|
10164
|
+
const off = disabled(file);
|
|
10165
|
+
const kept = /* @__PURE__ */ new Map();
|
|
10166
|
+
for (const [rule, sources] of rules) {
|
|
10167
|
+
if (off.has(rule)) {
|
|
10168
|
+
kept.set(rule, sources);
|
|
10169
|
+
continue;
|
|
10170
|
+
}
|
|
10171
|
+
const left = /* @__PURE__ */ new Map();
|
|
10172
|
+
for (const [source, count] of sources) {
|
|
10173
|
+
const still = Math.min(count, found.get(`${rule}\0${source}`) ?? 0);
|
|
10174
|
+
if (still) left.set(source, still);
|
|
10175
|
+
}
|
|
10176
|
+
if (left.size) kept.set(rule, left);
|
|
10177
|
+
}
|
|
10178
|
+
if (kept.size) next.set(file, kept);
|
|
10179
|
+
}
|
|
10180
|
+
return next;
|
|
10181
|
+
}
|
|
10182
|
+
/**
|
|
10183
|
+
* New findings of rules that did not run when the baseline was recorded, counted by rule: a
|
|
10184
|
+
* rule that starts to run (an upgrade that finds the tokens it needs, or a rule turned on)
|
|
10185
|
+
* reports what was in the code all along. Empty when the baseline does not say which rules ran.
|
|
10186
|
+
*/
|
|
10187
|
+
function unrecordedRules(baseline, diagnostics) {
|
|
10188
|
+
const counts = /* @__PURE__ */ new Map();
|
|
10189
|
+
if (!baseline.rules) return counts;
|
|
10190
|
+
const rules = new Set(RULE_IDS);
|
|
10191
|
+
for (const { ruleId } of diagnostics) if (rules.has(ruleId) && !baseline.rules.has(ruleId)) counts.set(ruleId, (counts.get(ruleId) ?? 0) + 1);
|
|
10192
|
+
return counts;
|
|
10193
|
+
}
|
|
9060
10194
|
function countBaseline(entries) {
|
|
9061
10195
|
let findings = 0;
|
|
9062
10196
|
for (const rules of entries.values()) for (const sources of rules.values()) for (const count of sources.values()) findings += count;
|
|
@@ -9065,8 +10199,11 @@ function countBaseline(entries) {
|
|
|
9065
10199
|
files: entries.size
|
|
9066
10200
|
};
|
|
9067
10201
|
}
|
|
9068
|
-
/**
|
|
9069
|
-
|
|
10202
|
+
/**
|
|
10203
|
+
* Writes the baseline sorted at every level, so it diffs cleanly in review, with the onsystem
|
|
10204
|
+
* version and the rules that ran when given, and creates its folder.
|
|
10205
|
+
*/
|
|
10206
|
+
function writeBaseline(file, entries, about = {}) {
|
|
9070
10207
|
const sorted = (map) => [...map].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
9071
10208
|
const files = {};
|
|
9072
10209
|
for (const [name, rules] of sorted(entries)) {
|
|
@@ -9080,9 +10217,12 @@ function writeBaseline(file, entries) {
|
|
|
9080
10217
|
}
|
|
9081
10218
|
const baseline = {
|
|
9082
10219
|
version: 1,
|
|
10220
|
+
...about.onsystem === void 0 ? {} : { onsystem: about.onsystem },
|
|
10221
|
+
...about.rules === void 0 ? {} : { rules: [...new Set(about.rules)].sort() },
|
|
9083
10222
|
files
|
|
9084
10223
|
};
|
|
9085
10224
|
try {
|
|
10225
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
9086
10226
|
fs.writeFileSync(file, `${JSON.stringify(baseline, null, 2)}\n`);
|
|
9087
10227
|
} catch (error) {
|
|
9088
10228
|
throw new ConfigError(`Could not write baseline ${file}: ${error.message}`);
|
|
@@ -9096,7 +10236,7 @@ function isObject(value) {
|
|
|
9096
10236
|
var name = "onsystem";
|
|
9097
10237
|
//#endregion
|
|
9098
10238
|
//#region src/version.ts
|
|
9099
|
-
const VERSION = "0.
|
|
10239
|
+
const VERSION = "0.7.0";
|
|
9100
10240
|
const NAME = name;
|
|
9101
10241
|
//#endregion
|
|
9102
10242
|
//#region src/lint/sarif.ts
|
|
@@ -9260,6 +10400,7 @@ function formatDiagnostics(results, format, options = {}) {
|
|
|
9260
10400
|
if (fixedHint) lines.push(dim(fixedHint, useColor));
|
|
9261
10401
|
return lines.join("\n");
|
|
9262
10402
|
}
|
|
10403
|
+
/** Escapes a workflow command's message, so `%` and line breaks reach GitHub as written. */
|
|
9263
10404
|
function escapeData(value) {
|
|
9264
10405
|
return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
9265
10406
|
}
|
|
@@ -9763,6 +10904,55 @@ function searchComponents(index, query, limit = 5) {
|
|
|
9763
10904
|
}));
|
|
9764
10905
|
}
|
|
9765
10906
|
//#endregion
|
|
10907
|
+
//#region src/tokens/imports.ts
|
|
10908
|
+
/** Enough stylesheets for a theme split over files, without reading a whole design system. */
|
|
10909
|
+
const MAX_IMPORTED = 50;
|
|
10910
|
+
/**
|
|
10911
|
+
* The specifiers a stylesheet `@import`s: `@import "./theme.css" layer(base)`,
|
|
10912
|
+
* `@import url(theme.css)`, `@import "@acme/theme"`. Empty for CSS that does not parse.
|
|
10913
|
+
*/
|
|
10914
|
+
function cssImports(css) {
|
|
10915
|
+
let root;
|
|
10916
|
+
try {
|
|
10917
|
+
root = postcss.parse(css);
|
|
10918
|
+
} catch {
|
|
10919
|
+
return [];
|
|
10920
|
+
}
|
|
10921
|
+
const out = [];
|
|
10922
|
+
for (const node of root.nodes) {
|
|
10923
|
+
if (node.type !== "atrule" || node.name !== "import") continue;
|
|
10924
|
+
const match = /^\s*(?:url\(\s*)?(?:"([^"]+)"|'([^']+)'|([^\s"')]+)\s*\))/.exec(node.params);
|
|
10925
|
+
const specifier = match?.[1] ?? match?.[2] ?? match?.[3];
|
|
10926
|
+
if (specifier) out.push(specifier);
|
|
10927
|
+
}
|
|
10928
|
+
return out;
|
|
10929
|
+
}
|
|
10930
|
+
/**
|
|
10931
|
+
* The stylesheets `files` import, and the ones those import in turn, each once and in the
|
|
10932
|
+
* order they are imported. The files themselves are not repeated.
|
|
10933
|
+
*/
|
|
10934
|
+
function importedStylesheets(files, resolve) {
|
|
10935
|
+
const seen = new Set(files);
|
|
10936
|
+
const out = [];
|
|
10937
|
+
const visit = (file) => {
|
|
10938
|
+
let text;
|
|
10939
|
+
try {
|
|
10940
|
+
text = fs.readFileSync(file, "utf8");
|
|
10941
|
+
} catch {
|
|
10942
|
+
return;
|
|
10943
|
+
}
|
|
10944
|
+
for (const specifier of cssImports(text)) {
|
|
10945
|
+
const target = resolve(specifier, file);
|
|
10946
|
+
if (!target || seen.has(target) || out.length >= MAX_IMPORTED) continue;
|
|
10947
|
+
seen.add(target);
|
|
10948
|
+
out.push(target);
|
|
10949
|
+
visit(target);
|
|
10950
|
+
}
|
|
10951
|
+
};
|
|
10952
|
+
for (const file of files) visit(file);
|
|
10953
|
+
return out;
|
|
10954
|
+
}
|
|
10955
|
+
//#endregion
|
|
9766
10956
|
//#region src/util/log.ts
|
|
9767
10957
|
const stderrLogger = {
|
|
9768
10958
|
info: (message) => process.stderr.write(`[onsystem] ${message}\n`),
|
|
@@ -9887,11 +11077,94 @@ function matchUtility(base, tokens) {
|
|
|
9887
11077
|
return key ? tokens.find((t) => t.category === "color" && t.tailwind === key) : void 0;
|
|
9888
11078
|
}
|
|
9889
11079
|
/**
|
|
11080
|
+
* Where the design system's files may really be, links resolved: the project, the workspace
|
|
11081
|
+
* and the git repository it is in, the folder a design-system package's config stays in, and
|
|
11082
|
+
* packages reached through a `node_modules` folder in those, which pnpm can link from a store
|
|
11083
|
+
* elsewhere. A glob follows links, and a config can name `../` and absolute paths, so without
|
|
11084
|
+
* this a cloned repository could have the tools return any file on the machine
|
|
11085
|
+
* (`docs/credentials.md` linked to `~/.aws/credentials`).
|
|
11086
|
+
*/
|
|
11087
|
+
var Containment = class {
|
|
11088
|
+
/** The folders, as given. */
|
|
11089
|
+
dirs;
|
|
11090
|
+
/** The same folders, links resolved. */
|
|
11091
|
+
roots;
|
|
11092
|
+
reals = /* @__PURE__ */ new Map();
|
|
11093
|
+
/** Files left out, as they were found. */
|
|
11094
|
+
outside = /* @__PURE__ */ new Set();
|
|
11095
|
+
constructor(config) {
|
|
11096
|
+
const around = [
|
|
11097
|
+
workspaceRootOf(config.root),
|
|
11098
|
+
repositoryOf(config.root),
|
|
11099
|
+
config.base,
|
|
11100
|
+
config.designSystemPackage?.boundary
|
|
11101
|
+
].filter((dir) => dir !== void 0 && !isHomeOrAbove(dir));
|
|
11102
|
+
this.dirs = unique([config.root, ...around].map((dir) => path.resolve(dir)));
|
|
11103
|
+
this.roots = Promise.all(this.dirs.map((dir) => this.real(dir))).then((reals) => reals.filter((real) => real !== void 0));
|
|
11104
|
+
}
|
|
11105
|
+
/** Where `file` really is, links resolved; undefined when it is not there. */
|
|
11106
|
+
real(file) {
|
|
11107
|
+
let real = this.reals.get(file);
|
|
11108
|
+
if (!real) {
|
|
11109
|
+
real = fsp.realpath(file).catch(() => void 0);
|
|
11110
|
+
this.reals.set(file, real);
|
|
11111
|
+
}
|
|
11112
|
+
return real;
|
|
11113
|
+
}
|
|
11114
|
+
/**
|
|
11115
|
+
* `files` whose real path is inside, in their order, one path per real file: the shortest,
|
|
11116
|
+
* so a folder that links back to its parent does not add its files again.
|
|
11117
|
+
*/
|
|
11118
|
+
async filter(files) {
|
|
11119
|
+
const roots = await this.roots;
|
|
11120
|
+
const reals = await Promise.all(files.map((file) => this.real(file)));
|
|
11121
|
+
const kept = /* @__PURE__ */ new Map();
|
|
11122
|
+
files.forEach((file, i) => {
|
|
11123
|
+
const real = reals[i];
|
|
11124
|
+
if (real === void 0) return;
|
|
11125
|
+
if (!(roots.some((root) => isInside(root, real)) || isInstalled(real) && this.inNodeModules(file))) {
|
|
11126
|
+
this.outside.add(file);
|
|
11127
|
+
return;
|
|
11128
|
+
}
|
|
11129
|
+
const previous = kept.get(real);
|
|
11130
|
+
if (previous === void 0 || file.length < previous.length) kept.set(real, file);
|
|
11131
|
+
});
|
|
11132
|
+
return [...kept.values()];
|
|
11133
|
+
}
|
|
11134
|
+
/**
|
|
11135
|
+
* Whether `file`, as found, is in a package in a `node_modules` folder of the project, the
|
|
11136
|
+
* workspace or the repository, as one pnpm links from its store is. A link elsewhere in the
|
|
11137
|
+
* project to another project's `node_modules` is not.
|
|
11138
|
+
*/
|
|
11139
|
+
inNodeModules(file) {
|
|
11140
|
+
const found = path.resolve(file);
|
|
11141
|
+
return this.dirs.some((dir) => isInside(dir, found) && isInstalled(path.relative(dir, found)));
|
|
11142
|
+
}
|
|
11143
|
+
};
|
|
11144
|
+
/** Whether a path is a file of a package in `node_modules`, not of `.cache` or `.bin` there. */
|
|
11145
|
+
function isInstalled(file) {
|
|
11146
|
+
const parts = file.split(/[\\/]/);
|
|
11147
|
+
const at = parts.lastIndexOf("node_modules");
|
|
11148
|
+
return at !== -1 && at < parts.length - 2 && !(parts[at + 1] ?? ".").startsWith(".");
|
|
11149
|
+
}
|
|
11150
|
+
/** The nearest folder at or above `dir` with a `.git`: the repository it is in. */
|
|
11151
|
+
function repositoryOf(dir) {
|
|
11152
|
+
for (let current = path.resolve(dir);; current = path.dirname(current)) {
|
|
11153
|
+
if (fs.existsSync(path.join(current, ".git"))) return current;
|
|
11154
|
+
if (current === path.dirname(current)) return void 0;
|
|
11155
|
+
}
|
|
11156
|
+
}
|
|
11157
|
+
/** What `inspect` and the server's log say about files that lead outside the project. */
|
|
11158
|
+
function outsideWarning(root, files) {
|
|
11159
|
+
const shown = files.map((file) => relativePath(root, file)).sort();
|
|
11160
|
+
return `Not read: ${shown.length === 1 ? shown[0] ?? "" : shown.length <= 5 ? `${shown.slice(0, -1).join(", ")} and ${shown.at(-1) ?? ""}` : `${shown.slice(0, 5).join(", ")} and ${shown.length - 5} more`} ${shown.length === 1 ? "leads" : "lead"} outside the project. Links resolved, the design system's files must be in the project, the workspace or git repository it is in, a package installed in their node_modules, or a package that declares its config (run onsystem init in it).`;
|
|
11161
|
+
}
|
|
11162
|
+
/**
|
|
9890
11163
|
* Absolute paths matching `patterns` under the root. `node_modules` is skipped
|
|
9891
11164
|
* even when `exclude` replaces the defaults, except by patterns that name it
|
|
9892
|
-
* (tokens published as a package).
|
|
11165
|
+
* (tokens published as a package). Files that lead outside the project are left out.
|
|
9893
11166
|
*/
|
|
9894
|
-
async function find(config, patterns) {
|
|
11167
|
+
async function find(config, patterns, contain = new Containment(config)) {
|
|
9895
11168
|
const options = {
|
|
9896
11169
|
cwd: config.root,
|
|
9897
11170
|
absolute: true,
|
|
@@ -9901,7 +11174,7 @@ async function find(config, patterns) {
|
|
|
9901
11174
|
const named = patterns.filter((p) => p.includes("node_modules"));
|
|
9902
11175
|
const outside = patterns.filter((p) => !p.includes("node_modules") && p.startsWith("../"));
|
|
9903
11176
|
const rest = patterns.filter((p) => !p.includes("node_modules") && !p.startsWith("../"));
|
|
9904
|
-
|
|
11177
|
+
const found = await Promise.all([
|
|
9905
11178
|
rest.length ? glob(rest, {
|
|
9906
11179
|
...options,
|
|
9907
11180
|
ignore: unique(["**/node_modules/**", ...exclude])
|
|
@@ -9923,7 +11196,8 @@ async function find(config, patterns) {
|
|
|
9923
11196
|
])
|
|
9924
11197
|
});
|
|
9925
11198
|
})
|
|
9926
|
-
])
|
|
11199
|
+
]);
|
|
11200
|
+
return (await contain.filter(unique(found.flat()))).sort();
|
|
9927
11201
|
}
|
|
9928
11202
|
/** The design system's own component files, as absolute paths. */
|
|
9929
11203
|
function componentFiles(config) {
|
|
@@ -9934,28 +11208,51 @@ async function tokenFiles(config) {
|
|
|
9934
11208
|
return (await resolveFiles(config)).tokens.map((t) => t.file);
|
|
9935
11209
|
}
|
|
9936
11210
|
async function resolveFiles(config) {
|
|
9937
|
-
const
|
|
9938
|
-
const
|
|
11211
|
+
const contain = new Containment(config);
|
|
11212
|
+
const components = await find(config, config.components, contain);
|
|
11213
|
+
const docs = await find(config, config.docs, contain);
|
|
9939
11214
|
const tokens = [];
|
|
9940
11215
|
const seen = /* @__PURE__ */ new Set();
|
|
11216
|
+
const isNew = async (file) => {
|
|
11217
|
+
const real = await contain.real(file) ?? path.resolve(file);
|
|
11218
|
+
if (seen.has(real)) return false;
|
|
11219
|
+
seen.add(real);
|
|
11220
|
+
return true;
|
|
11221
|
+
};
|
|
9941
11222
|
const add = async (sources) => {
|
|
9942
|
-
for (const source of sources) for (const file of await find(config, [source.path])) {
|
|
9943
|
-
|
|
9944
|
-
|
|
9945
|
-
|
|
9946
|
-
file,
|
|
9947
|
-
prefix: source.prefix
|
|
9948
|
-
});
|
|
9949
|
-
}
|
|
11223
|
+
for (const source of sources) for (const file of await find(config, [source.path], contain)) if (await isNew(file)) tokens.push({
|
|
11224
|
+
file,
|
|
11225
|
+
prefix: source.prefix
|
|
11226
|
+
});
|
|
9950
11227
|
};
|
|
9951
11228
|
await add(config.tokens.filter((t) => !t.fallback));
|
|
11229
|
+
if (config.tokensDetected) {
|
|
11230
|
+
const found = tokens.map((t) => path.resolve(t.file));
|
|
11231
|
+
for (const file of await contain.filter(linkedStylesheets(config, found))) if (await isNew(file)) tokens.push({ file });
|
|
11232
|
+
}
|
|
9952
11233
|
if (!tokens.length) await add(config.tokens.filter((t) => t.fallback));
|
|
9953
11234
|
return {
|
|
9954
11235
|
components,
|
|
9955
11236
|
tokens,
|
|
9956
|
-
docs
|
|
11237
|
+
docs,
|
|
11238
|
+
outside: [...contain.outside]
|
|
9957
11239
|
};
|
|
9958
11240
|
}
|
|
11241
|
+
/**
|
|
11242
|
+
* The stylesheets zero config reads besides the ones it found: those they `@import` (by a
|
|
11243
|
+
* path, or from a workspace package: `@import "@acme/theme"`), then those that set the colors
|
|
11244
|
+
* a Tailwind v3 config maps, in the workspace package of a shared preset it reads (Dub's
|
|
11245
|
+
* `@dub/tailwind-config` and its `themes.css`), with what they import. May repeat a file.
|
|
11246
|
+
*/
|
|
11247
|
+
function linkedStylesheets(config, found) {
|
|
11248
|
+
const resolve = stylesheetResolver(config.root);
|
|
11249
|
+
const preset = config.tailwindConfig ? presetStylesheets(readTailwindColors(config.tailwindConfig, presetResolver(config.root)), config.root) : [];
|
|
11250
|
+
return [
|
|
11251
|
+
...importedStylesheets(found.filter((f) => f.endsWith(".css")), resolve),
|
|
11252
|
+
...preset,
|
|
11253
|
+
...importedStylesheets(preset, resolve)
|
|
11254
|
+
];
|
|
11255
|
+
}
|
|
9959
11256
|
/** Size and mtime of files, each read once per build. */
|
|
9960
11257
|
var FileStats = class {
|
|
9961
11258
|
root;
|
|
@@ -9977,11 +11274,11 @@ var FileStats = class {
|
|
|
9977
11274
|
/**
|
|
9978
11275
|
* Everything that, when changed, invalidates the cached model: the tracked
|
|
9979
11276
|
* files, `dependencies` (project files the components import and the tsconfig
|
|
9980
|
-
* chain, known after extraction), the lockfile and the
|
|
11277
|
+
* chain, known after extraction), the lockfile, the config and the files left out.
|
|
9981
11278
|
*/
|
|
9982
11279
|
async function cacheKey(config, files, dependencies, stats = new FileStats(config.root)) {
|
|
9983
11280
|
const extra = [
|
|
9984
|
-
|
|
11281
|
+
...PROJECT_CONFIG_FILES,
|
|
9985
11282
|
"package.json",
|
|
9986
11283
|
"pnpm-lock.yaml",
|
|
9987
11284
|
"package-lock.json",
|
|
@@ -9996,9 +11293,11 @@ async function cacheKey(config, files, dependencies, stats = new FileStats(confi
|
|
|
9996
11293
|
...dependencies
|
|
9997
11294
|
]);
|
|
9998
11295
|
const { rules: _rules, overrides: _overrides, ...relevant } = config;
|
|
11296
|
+
const outside = files.outside.map((file) => relativePath(config.root, file)).sort();
|
|
9999
11297
|
return createHash("sha256").update(JSON.stringify({
|
|
10000
11298
|
version: VERSION,
|
|
10001
11299
|
config: relevant,
|
|
11300
|
+
outside,
|
|
10002
11301
|
stats: await stats.of(all)
|
|
10003
11302
|
})).digest("hex");
|
|
10004
11303
|
}
|
|
@@ -10047,6 +11346,7 @@ async function buildModel(config, options = {}) {
|
|
|
10047
11346
|
});
|
|
10048
11347
|
const docs = [];
|
|
10049
11348
|
const warnings = [...extracted.warnings];
|
|
11349
|
+
if (files.outside.length) warnings.push(outsideWarning(config.root, files.outside));
|
|
10050
11350
|
for (const file of files.docs) try {
|
|
10051
11351
|
docs.push(parseDoc(await fsp.readFile(file, "utf8"), relativePath(config.root, file)));
|
|
10052
11352
|
} catch (error) {
|
|
@@ -10058,11 +11358,11 @@ async function buildModel(config, options = {}) {
|
|
|
10058
11358
|
const libraries = await loadLibraries(config);
|
|
10059
11359
|
warnings.push(...libraries.warnings);
|
|
10060
11360
|
tokens.tokens.push(...libraries.tokens);
|
|
10061
|
-
const tailwind = config.tailwindConfig ? readTailwindColors(config.tailwindConfig,
|
|
10062
|
-
if (tailwind) applyTailwindColors(tokens.tokens, tailwind.colors);
|
|
11361
|
+
const tailwind = config.tailwindConfig ? readTailwindColors(config.tailwindConfig, presetResolver(config.root)) : void 0;
|
|
11362
|
+
if (tailwind) applyTailwindColors(tokens.tokens, tailwind.colors, tailwind.rgb);
|
|
10063
11363
|
let radiiFiles = [];
|
|
10064
11364
|
if (config.tailwindConfig) {
|
|
10065
|
-
const radii = readTailwindRadii(config.tailwindConfig,
|
|
11365
|
+
const radii = readTailwindRadii(config.tailwindConfig, presetResolver(config.root));
|
|
10066
11366
|
applyTailwindRadii(tokens.tokens, radii.radii, config.tailwindConfig);
|
|
10067
11367
|
radiiFiles = radii.files;
|
|
10068
11368
|
}
|
|
@@ -10191,7 +11491,7 @@ var DesignSystemHost = class {
|
|
|
10191
11491
|
configFile,
|
|
10192
11492
|
designSystemPackage && path.join(designSystemPackage.dir, "package.json"),
|
|
10193
11493
|
...DETECTION_FILES.map((f) => path.join(root, f)),
|
|
10194
|
-
path.resolve(root, tsconfig
|
|
11494
|
+
...tsconfig ? [path.resolve(root, tsconfig)] : PROJECT_CONFIG_FILES.map((f) => path.join(root, f)),
|
|
10195
11495
|
...readProjectConfig(root, tsconfig).configFiles.filter(isProjectFile)
|
|
10196
11496
|
].filter((f) => Boolean(f)).map((f) => path.resolve(f));
|
|
10197
11497
|
for (const file of files) this.watchedFiles.add(file);
|
|
@@ -10285,4 +11585,4 @@ var DesignSystemHost = class {
|
|
|
10285
11585
|
}
|
|
10286
11586
|
};
|
|
10287
11587
|
//#endregion
|
|
10288
|
-
export {
|
|
11588
|
+
export { loadConfig as $, TokenIndex as A, parseDoc as B, pruneBaseline as C, relativePath as Ct, updateBaseline as D, unrecordedRules as E, TOKEN_CATEGORIES as F, DEFAULT_SEVERITY as G, CONFIG_FILES as H, parseDtcgTokens as I, LEGACY_CONFIG_FILES as J, DEFAULT_TEST_EXCLUDE as K, parseDesignMd as L, mergeTokens as M, toDesignMd as N, writeBaseline as O, toDtcg as P, hasOwnConfig as Q, parseCssTokens as R, countBaseline as S, matchesGlob as St, rulesRun as T, defaultProjectConfig as Tt, ConfigError as U, extractComponents as V, DEFAULT_DOCS as W, configJsonSchema as X, RULE_IDS as Y, defineConfig as Z, NAME as _, closest as _t, fingerprint as a, LIBRARY_IDS as at, LEGACY_BASELINE_FILE as b, unique as bt, silentLogger as c, PACKAGE_FIELD as ct, searchComponents as d, detectProject as dt, loadConfigWith as et, applyFixes as f, findDesignSystemPackage as ft, formatDiagnostics as g, workspacePatterns as gt, escapeData as h, workspacePackageDirs as ht, componentFiles as i, LIBRARIES as it, loadTokens as j, RULES as k, stderrLogger as l, PackageFinder as lt, uncheckedNotice as m, isWorkspaceRoot as mt, DesignSystemHost as n, formatPx as nt, loadDesignSystem as o, libraryTitle as ot, checkSource as p, isDesignSystemName as pt, DEFAULT_TEST_FOLDERS as q, buildModel as r, findVariantDefinitions as rt, tokenFiles as s, notReadYet as st, DesignSystem as t, rulesFor as tt, buildSearchIndex as u, declaredConfig as ut, VERSION as v, plural$1 as vt, readBaseline as w, toPosix as wt, applyBaseline as x, isInside as xt, BASELINE_FILE as y, truncate as yt, attachDocs as z };
|