onsystem 0.6.0 → 0.8.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 +29 -22
- package/dist/cli.js +629 -221
- package/dist/{config-lbfW91qB.d.ts → config-kYGiD5oW.d.ts} +15 -0
- package/dist/{design-system-A3pRX3Ka.js → design-system-Dmcv7eHR.js} +2641 -331
- package/dist/eslint-worker.d.ts +1 -1
- package/dist/eslint-worker.js +3 -5
- package/dist/eslint.js +66 -5
- package/dist/index.d.ts +35 -3
- package/dist/index.js +2 -2
- package/dist/{stdio-CEChNFLR.js → stdio-MTS6QAgo.js} +241 -9
- package/dist/{workspace-CHvUwgz9.js → workspace-CM1RyqZA.js} +1 -1
- package/package.json +9 -9
- package/schema.json +1 -1
|
@@ -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
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import { clampChroma, colorsNamed, converter, differenceEuclidean, formatHex, parse, wcagContrast } 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,
|
|
@@ -107,6 +137,12 @@ function slashGlob(pattern) {
|
|
|
107
137
|
if (!pattern.includes("/")) return pattern.replaceAll("\\", "/");
|
|
108
138
|
return pattern.replace(/\\(?![()[\]{}*?!+@])/g, "/");
|
|
109
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* The files `check` and `check_ui` read: `.tsx`, `.jsx`, `.ts`, `.js` and the `.mts`, `.cts`,
|
|
142
|
+
* `.mjs` and `.cjs` forms, in any case. CSS, Markdown or JSON parsed as TSX gives hundreds of
|
|
143
|
+
* syntax errors (209 for a shadcn/ui `app/globals.css`).
|
|
144
|
+
*/
|
|
145
|
+
const SCRIPT_FILE$1 = /\.(?:[jt]sx?|[cm][jt]s)$/i;
|
|
110
146
|
/** Path relative to `root`, always with forward slashes. */
|
|
111
147
|
function relativePath(root, file) {
|
|
112
148
|
return toPosix(path.relative(root, file));
|
|
@@ -116,6 +152,24 @@ function isInside(root, file) {
|
|
|
116
152
|
const rel = path.relative(root, file);
|
|
117
153
|
return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
118
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Whether `dir` is the home folder, a folder that holds it, or a filesystem root, links
|
|
157
|
+
* resolved. None of them is a project's workspace or repository, though a stray
|
|
158
|
+
* `~/package.json` with `workspaces`, or a repository of dotfiles, makes one look like it, and
|
|
159
|
+
* from there a `../` path reaches `~/.aws/credentials`.
|
|
160
|
+
*/
|
|
161
|
+
function isHomeOrAbove(dir) {
|
|
162
|
+
const home = os.homedir();
|
|
163
|
+
const holdsHome = (d, h) => d === path.parse(d).root || isInside(d, h);
|
|
164
|
+
return holdsHome(path.resolve(dir), path.resolve(home)) || holdsHome(realOrSelf(dir), realOrSelf(home));
|
|
165
|
+
}
|
|
166
|
+
function realOrSelf(p) {
|
|
167
|
+
try {
|
|
168
|
+
return fs.realpathSync(p);
|
|
169
|
+
} catch {
|
|
170
|
+
return path.resolve(p);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
119
173
|
/** The static directory part of a glob (`components/ui/**\/*.tsx` → `components/ui`). */
|
|
120
174
|
function globBase(glob) {
|
|
121
175
|
const parts = toPosix(glob).split("/");
|
|
@@ -143,6 +197,16 @@ function matchesGlob(file, pattern) {
|
|
|
143
197
|
return regex.test(file.replace(/^\.\//, ""));
|
|
144
198
|
}
|
|
145
199
|
const globRegExps = /* @__PURE__ */ new Map();
|
|
200
|
+
/**
|
|
201
|
+
* Whether a root-relative path is one a glob matches, or is in a folder it matches, as a
|
|
202
|
+
* glob's `ignore` leaves a matching folder out: `app/legacy`, `app/legacy/`, `./app/legacy` and
|
|
203
|
+
* `app/legacy/**` all cover `app/legacy/old.tsx`, and `**\/legacy` covers every legacy folder.
|
|
204
|
+
* How `exclude` patterns are matched, wherever they apply.
|
|
205
|
+
*/
|
|
206
|
+
function coveredByGlob(file, pattern) {
|
|
207
|
+
const glob = pattern.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
208
|
+
return matchesGlob(file, glob) || matchesGlob(file, `${glob}/**`);
|
|
209
|
+
}
|
|
146
210
|
function globSource(pattern) {
|
|
147
211
|
let out = "";
|
|
148
212
|
for (let i = 0; i < pattern.length; i++) {
|
|
@@ -534,7 +598,9 @@ const STYLESHEETS = [
|
|
|
534
598
|
* workspace sources, read the same way or through their own `components.json`,
|
|
535
599
|
* or, for a package without `exports` that apps import by path
|
|
536
600
|
* (`@acme/ui/primitives/button`), from the files the root's code imports. They
|
|
537
|
-
* are added to what `components.json` found, not skipped for it.
|
|
601
|
+
* are added to what `components.json` found, not skipped for it. One installed
|
|
602
|
+
* from a registry is read through its `exports` alone, when they point at the
|
|
603
|
+
* component sources it publishes, not from `dist/` back to `src/`.
|
|
538
604
|
* 4. A flat folder of components that wrap a primitives library, the way React
|
|
539
605
|
* Aria's Tailwind starter ships them (`src/Button.tsx`, `src/Checkbox.tsx`).
|
|
540
606
|
*
|
|
@@ -542,6 +608,10 @@ const STYLESHEETS = [
|
|
|
542
608
|
* (`designSystems`). A candidate whose globs match no file is skipped. Returns undefined
|
|
543
609
|
* when nothing applies, so the shadcn defaults stay in force.
|
|
544
610
|
*
|
|
611
|
+
* Next to the ui folder `components.json` names, the folders `npx shadcn add` copies other
|
|
612
|
+
* registries' code into (its `registries`: `@signoff-ui` → `components/signoff-ui`) are
|
|
613
|
+
* read-only design systems, after all the others ({@link fromRegistries}).
|
|
614
|
+
*
|
|
545
615
|
* `declared` is the dependency whose config the root takes on ({@link findDesignSystemPackage}):
|
|
546
616
|
* it is read first among the dependencies in step 3, whatever its name, and also when it is
|
|
547
617
|
* installed from a registry rather than a workspace package.
|
|
@@ -554,8 +624,9 @@ function detectProject(root, tsconfig, trace, declared, packages = new PackageFi
|
|
|
554
624
|
});
|
|
555
625
|
const found = [];
|
|
556
626
|
const shadcn = readJson$1(path.join(root, "components.json"));
|
|
627
|
+
const project = shadcn ? readProjectConfig(root, tsconfig) : void 0;
|
|
557
628
|
let why = "no ui or components alias";
|
|
558
|
-
const fromShadcn = shadcn ? fromComponentsJson(root, root, shadcn,
|
|
629
|
+
const fromShadcn = shadcn && project ? fromComponentsJson(root, root, shadcn, project, packages, (r) => {
|
|
559
630
|
why = r;
|
|
560
631
|
}) : void 0;
|
|
561
632
|
if (!shadcn) step("components.json", false, fs.existsSync(path.join(root, "components.json")) ? "not valid JSON" : "not found");
|
|
@@ -591,21 +662,23 @@ function detectProject(root, tsconfig, trace, declared, packages = new PackageFi
|
|
|
591
662
|
let imported;
|
|
592
663
|
for (const name of names) {
|
|
593
664
|
const own = name === declared?.name ? declared : void 0;
|
|
594
|
-
const
|
|
665
|
+
const workspaceDir = own ? void 0 : packages.find(name);
|
|
666
|
+
const installedDir = own || workspaceDir ? void 0 : packages.installed(name);
|
|
667
|
+
const kind = own?.installed ?? installedDir !== void 0 ? "installed" : "workspace";
|
|
595
668
|
const candidate = `${kind} package ${name}`;
|
|
596
669
|
if (found.some((f) => f.package === name)) {
|
|
597
670
|
step(candidate, false, "not added twice: components.json points into it");
|
|
598
671
|
continue;
|
|
599
672
|
}
|
|
600
|
-
const dir = own
|
|
673
|
+
const dir = own?.dir ?? workspaceDir ?? installedDir;
|
|
601
674
|
const pkg = dir ? readJson$1(path.join(dir, "package.json")) : void 0;
|
|
602
675
|
if (!dir || !pkg) {
|
|
603
|
-
step(candidate, false, "not a workspace package
|
|
676
|
+
step(candidate, false, "not a workspace package, and not installed");
|
|
604
677
|
continue;
|
|
605
678
|
}
|
|
606
679
|
const reasons = [];
|
|
607
680
|
const note = (r) => reasons.push(r);
|
|
608
|
-
const result = fromPackage(root, dir, pkg, note) ?? fromPackageComponentsJson(root, dir, pkg, packages, note) ?? fromImports(root, dir, name, (imported ??= importedSubpaths(root, names)).get(name), note);
|
|
681
|
+
const result = installedDir ? fromPackage(root, dir, pkg, note, true) : fromPackage(root, dir, pkg, note) ?? fromPackageComponentsJson(root, dir, pkg, packages, note) ?? fromImports(root, dir, name, (imported ??= importedSubpaths(root, names)).get(name), note);
|
|
609
682
|
if (result && matchesAny(root, result)) {
|
|
610
683
|
found.push({
|
|
611
684
|
...result,
|
|
@@ -616,11 +689,28 @@ function detectProject(root, tsconfig, trace, declared, packages = new PackageFi
|
|
|
616
689
|
step(candidate, true, `${relativePath(root, dir)}, ${countFiles(root, result)}`);
|
|
617
690
|
} else step(candidate, false, result ? "its components match no file" : unique(reasons).join("; ") || "nothing to read");
|
|
618
691
|
}
|
|
692
|
+
const registries = shadcn && project && fromShadcn && !withDefaults ? fromRegistries(root, shadcn, fromShadcn, found, project, step) : [];
|
|
619
693
|
if (found.length) {
|
|
620
694
|
step("a flat src/ of components", false, "not read: a design system was found");
|
|
695
|
+
const named = (source) => registries.length ? `${source}; from shadcn registries, read-only: ${registries.map((r) => r.source).join(", ")}` : source;
|
|
696
|
+
const read = registries.map((f) => ({
|
|
697
|
+
source: f.source,
|
|
698
|
+
components: f.components,
|
|
699
|
+
imports: 0,
|
|
700
|
+
registry: f.registry
|
|
701
|
+
}));
|
|
621
702
|
if (found.length === 1) {
|
|
622
703
|
const [only] = found;
|
|
623
|
-
|
|
704
|
+
const source = only?.source ?? "";
|
|
705
|
+
if (!only || !read.length) return detection(root, found, withDefaults, source);
|
|
706
|
+
return {
|
|
707
|
+
...detection(root, [...found, ...registries], withDefaults, named(source)),
|
|
708
|
+
designSystems: [{
|
|
709
|
+
source,
|
|
710
|
+
components: only.components,
|
|
711
|
+
imports: 0
|
|
712
|
+
}, ...read]
|
|
713
|
+
};
|
|
624
714
|
}
|
|
625
715
|
const counts = importCounts(root, found.map((f) => f.prefixes ?? []));
|
|
626
716
|
const ranked = found.map((f, i) => ({
|
|
@@ -631,12 +721,12 @@ function detectProject(root, tsconfig, trace, declared, packages = new PackageFi
|
|
|
631
721
|
const [primary, ...rest] = ranked;
|
|
632
722
|
const describe = (r) => `${r.f.source} (${r.imports.toLocaleString("en-US")} ${r.imports === 1 ? "import" : "imports"})`;
|
|
633
723
|
return {
|
|
634
|
-
...detection(root, ranked.map((r) => r.f), withDefaults, `primary: ${primary ? describe(primary) : ""}; also: ${rest.map(describe).join(", ")}`),
|
|
635
|
-
designSystems: ranked.map((r) => ({
|
|
724
|
+
...detection(root, [...ranked.map((r) => r.f), ...registries], withDefaults, named(`primary: ${primary ? describe(primary) : ""}; also: ${rest.map(describe).join(", ")}`)),
|
|
725
|
+
designSystems: [...ranked.map((r) => ({
|
|
636
726
|
source: r.f.source,
|
|
637
727
|
components: r.f.components,
|
|
638
728
|
imports: r.imports
|
|
639
|
-
}))
|
|
729
|
+
})), ...read]
|
|
640
730
|
};
|
|
641
731
|
}
|
|
642
732
|
let flatWhy = "no package.json";
|
|
@@ -778,7 +868,7 @@ function fromComponentsJson(root, dir, json, project, packages, note = ignore) {
|
|
|
778
868
|
const pkgDir = name ? packages.find(name) : void 0;
|
|
779
869
|
const pkg = pkgDir ? readJson$1(path.join(pkgDir, "package.json")) : void 0;
|
|
780
870
|
if (!name || !pkgDir || !pkg) {
|
|
781
|
-
note(`the ui alias ${ui} resolves through neither tsconfig paths nor a workspace package`);
|
|
871
|
+
note(`the ui alias ${ui} resolves through neither ${project.configFile ? path.basename(project.configFile) : "tsconfig"} paths nor a workspace package`);
|
|
782
872
|
return;
|
|
783
873
|
}
|
|
784
874
|
imports = exportMappings(root, pkgDir, pkg);
|
|
@@ -799,6 +889,101 @@ function fromComponentsJson(root, dir, json, project, packages, note = ignore) {
|
|
|
799
889
|
...ownerPackage(root, uiDir, ui)
|
|
800
890
|
};
|
|
801
891
|
}
|
|
892
|
+
/** How a shadcn registry is named in components.json: `@` and a name, so none leads out of the folder. */
|
|
893
|
+
const REGISTRY_NAMESPACE = /^@[a-z0-9][\w-]*$/i;
|
|
894
|
+
/**
|
|
895
|
+
* The registries `components.json` names (`"registries": { "@signoff-ui": … }`), sorted, and
|
|
896
|
+
* where `npx shadcn add` copies their code: the components alias, through tsconfig `paths`, when
|
|
897
|
+
* it names a folder in the project. `why` says why there is none.
|
|
898
|
+
*/
|
|
899
|
+
function registryBase(root, json, project) {
|
|
900
|
+
const namespaces = Object.keys(asRecord(json.registries)).filter((n) => REGISTRY_NAMESPACE.test(n)).sort();
|
|
901
|
+
const alias = asRecord(json.aliases).components;
|
|
902
|
+
if (typeof alias !== "string") return {
|
|
903
|
+
namespaces,
|
|
904
|
+
why: "no components alias"
|
|
905
|
+
};
|
|
906
|
+
const base = namespaces.length ? resolveThroughPaths(alias, project) : void 0;
|
|
907
|
+
if (!base || !isInside(root, base)) return {
|
|
908
|
+
namespaces,
|
|
909
|
+
why: `the components alias ${alias} names no folder in the project through tsconfig paths`
|
|
910
|
+
};
|
|
911
|
+
return {
|
|
912
|
+
namespaces,
|
|
913
|
+
base,
|
|
914
|
+
alias
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
/** The folder of a registry's code: the namespace without its `@`, under the components folder. */
|
|
918
|
+
function registryFolder(root, base, namespace) {
|
|
919
|
+
const dir = relativePath(root, path.join(base, namespace.slice(1)));
|
|
920
|
+
return {
|
|
921
|
+
namespace,
|
|
922
|
+
dir,
|
|
923
|
+
glob: `${escapePath(dir)}/**/*.{tsx,jsx}`
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* The folders `npx shadcn add` copies the code of the registries `components.json` names into,
|
|
928
|
+
* whether they hold files yet or not, outside the ui folder ({@link fromRegistries}). A config
|
|
929
|
+
* that lists them in `components` reads them as zero config does: read-only, after the rest.
|
|
930
|
+
*/
|
|
931
|
+
function registryFolders(root, tsconfig) {
|
|
932
|
+
const json = readJson$1(path.join(root, "components.json"));
|
|
933
|
+
if (!json || !Object.keys(asRecord(json.registries)).length) return [];
|
|
934
|
+
const project = readProjectConfig(root, tsconfig);
|
|
935
|
+
const { namespaces, base } = registryBase(root, json, project);
|
|
936
|
+
if (!base) return [];
|
|
937
|
+
const ui = asRecord(json.aliases).ui;
|
|
938
|
+
const uiDir = typeof ui === "string" ? resolveThroughPaths(ui, project) : void 0;
|
|
939
|
+
return namespaces.filter((namespace) => !uiDir || !isInside(uiDir, path.join(base, namespace.slice(1)))).map((namespace) => registryFolder(root, base, namespace));
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* The folders `npx shadcn add` copies the code of the registries `components.json` names into
|
|
943
|
+
* (`@/components` and `@signoff-ui` → `components/signoff-ui`). Each one that holds a .tsx or
|
|
944
|
+
* .jsx file is a read-only design system: the app's code is checked against its components, and
|
|
945
|
+
* its own files are skipped as the ui folder's are. Not when a design system found is imported
|
|
946
|
+
* through a package's `exports` (`@workspace/ui`): a config that names that package would give
|
|
947
|
+
* registry code its name too. A folder inside the ui folder is already part of it. Code a
|
|
948
|
+
* registry copies loose into the components folder (React Bits) is not set apart.
|
|
949
|
+
*/
|
|
950
|
+
function fromRegistries(root, json, ui, others, project, step) {
|
|
951
|
+
const { namespaces, base, alias, why } = registryBase(root, json, project);
|
|
952
|
+
if (!namespaces.length) return [];
|
|
953
|
+
const no = (reason) => {
|
|
954
|
+
for (const namespace of namespaces) step(`registry ${namespace}`, false, reason);
|
|
955
|
+
return [];
|
|
956
|
+
};
|
|
957
|
+
const exported = others.find((f) => f.imports.length);
|
|
958
|
+
if (exported) return no(`not read: ${exported.package ?? "a package"} is imported through its exports, and the registry's code is not`);
|
|
959
|
+
if (!base || alias === void 0) return no(why ?? "no components alias");
|
|
960
|
+
const found = [];
|
|
961
|
+
for (const namespace of namespaces) {
|
|
962
|
+
const candidate = `registry ${namespace}`;
|
|
963
|
+
const { dir, glob } = registryFolder(root, base, namespace);
|
|
964
|
+
const absolute = path.join(root, dir);
|
|
965
|
+
if (ui.dirs.some((d) => isInside(d, absolute))) {
|
|
966
|
+
step(candidate, false, `${dir} is in the ui folder`);
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
const files = isDirectory$1(absolute) ? componentGlob(root, [glob]).length : 0;
|
|
970
|
+
if (!files) {
|
|
971
|
+
step(candidate, false, `${dir} ${isDirectory$1(absolute) ? "holds no .tsx or .jsx file yet" : "does not exist yet"}`);
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
step(candidate, true, `${dir}, ${plural(files, "component file")}, read-only`);
|
|
975
|
+
found.push({
|
|
976
|
+
components: [glob],
|
|
977
|
+
tokens: [],
|
|
978
|
+
imports: [],
|
|
979
|
+
dirs: [],
|
|
980
|
+
source: `${dir} (${namespace}, ${plural(files, "file")})`,
|
|
981
|
+
prefixes: [`${alias}/${namespace.slice(1)}`],
|
|
982
|
+
registry: namespace
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
return found;
|
|
986
|
+
}
|
|
802
987
|
/**
|
|
803
988
|
* The workspace package a `components.json` folder belongs to, when it is not the root
|
|
804
989
|
* itself (`@coss/ui/components`, through tsconfig `paths` or the package), and the
|
|
@@ -841,22 +1026,29 @@ function fromPackageComponentsJson(root, dir, pkg, packages, note = ignore) {
|
|
|
841
1026
|
* A package whose `exports` point at component source files is a design system. An export
|
|
842
1027
|
* that leads to a barrel (`./components/button` → `components/button/index.ts`, or `.` →
|
|
843
1028
|
* `src/index.tsx` behind `dist/`) stands for the files the barrel re-exports, each
|
|
844
|
-
* suggested with the export's specifier.
|
|
845
|
-
|
|
846
|
-
|
|
1029
|
+
* suggested with the export's specifier. An `installed` package's exports are read as an app
|
|
1030
|
+
* resolves them ({@link APP_CONDITIONS}), not from `dist/` back to `src/`: a widget kit
|
|
1031
|
+
* publishes `src/` beside `dist/` for its source maps, or points a `source` condition at it for
|
|
1032
|
+
* its own repository, and it is not the app's design system. Its compiled files ({@link isBuildOutput})
|
|
1033
|
+
* are not read at all: no barrel, component or stylesheet in them. Nor is an export that points
|
|
1034
|
+
* at JavaScript outside a build folder ({@link isCompiledExport}): its re-exports are not followed.
|
|
1035
|
+
*/
|
|
1036
|
+
function fromPackage(root, dir, pkg, note = ignore, installed = false) {
|
|
847
1037
|
const imports = [];
|
|
848
1038
|
const components = [];
|
|
849
1039
|
const add = (mapping, component) => {
|
|
850
1040
|
imports.push(mapping);
|
|
851
1041
|
if (component && !components.some((m) => m.target === mapping.target)) components.push(mapping);
|
|
852
1042
|
};
|
|
853
|
-
|
|
1043
|
+
const mappings = exportMappings(root, dir, pkg, installed);
|
|
1044
|
+
for (const mapping of mappings) {
|
|
854
1045
|
const file = path.resolve(root, mapping.target);
|
|
1046
|
+
if (installed && isCompiledExport(dir, file)) continue;
|
|
855
1047
|
if (mapping.target.includes("*") || !SCRIPT_FILE.test(file) || !isFile(file)) {
|
|
856
1048
|
add(mapping, COMPONENT_FILE.test(mapping.target));
|
|
857
1049
|
continue;
|
|
858
1050
|
}
|
|
859
|
-
for (const reached of exportedFiles(file, dir)) if (COMPONENT_FILE.test(reached)) add({
|
|
1051
|
+
for (const reached of exportedFiles(file, dir)) if (COMPONENT_FILE.test(reached) && !(installed && isBuildOutput(dir, reached))) add({
|
|
860
1052
|
specifier: mapping.specifier,
|
|
861
1053
|
target: relativePath(root, reached)
|
|
862
1054
|
}, true);
|
|
@@ -864,8 +1056,9 @@ function fromPackage(root, dir, pkg, note = ignore) {
|
|
|
864
1056
|
const files = components.filter((m) => !m.target.includes("*"));
|
|
865
1057
|
const patterns = components.filter((m) => m.target.includes("*"));
|
|
866
1058
|
if (files.length < 3 && !patterns.length) {
|
|
867
|
-
const built =
|
|
868
|
-
|
|
1059
|
+
const built = mappings.filter((m) => /(?:^|\/)(?:dist|build|lib|out|esm|cjs)\//.test(m.target) || installed && isCompiledExport(dir, path.resolve(root, m.target)));
|
|
1060
|
+
const output = built.slice(0, 2).map((m) => m.target).join(", ");
|
|
1061
|
+
note(pkg.exports === void 0 && typeof pkg.main !== "string" ? "no exports" : built.length ? installed ? `its exports point at build output (${output}), not at component sources, and only sources are read` : `its exports point at build output with no source found (${output})` : `its exports lead to ${plural(files.length, "component file")}; a design system exports at least 3`);
|
|
869
1062
|
return;
|
|
870
1063
|
}
|
|
871
1064
|
const stylesheets = imports.filter((m) => m.target.endsWith(".css") && !m.target.includes("*")).map((m) => path.resolve(root, m.target)).filter((f) => fs.existsSync(f));
|
|
@@ -983,10 +1176,11 @@ function importedSubpaths(root, names) {
|
|
|
983
1176
|
}
|
|
984
1177
|
/**
|
|
985
1178
|
* `exports` subpaths with a source target, as specifiers relative to `root`. A target
|
|
986
|
-
* built to `dist/` maps back to its source (`src/`, through the build config's entries)
|
|
987
|
-
*
|
|
1179
|
+
* built to `dist/` maps back to its source (`src/`, through the build config's entries),
|
|
1180
|
+
* unless `asWritten`: then each target is the one an app that installs the package resolves
|
|
1181
|
+
* ({@link APP_CONDITIONS}), as it is. Without an `exports` map, `main` stands for `.`.
|
|
988
1182
|
*/
|
|
989
|
-
function exportMappings(root, dir, pkg) {
|
|
1183
|
+
function exportMappings(root, dir, pkg, asWritten = false) {
|
|
990
1184
|
const name = typeof pkg.name === "string" ? pkg.name : void 0;
|
|
991
1185
|
if (!name) return [];
|
|
992
1186
|
const exports = pkg.exports ?? (typeof pkg.main === "string" && /^\.?\/?(?:dist|build|lib)\//.test(pkg.main) ? pkg.main : void 0);
|
|
@@ -994,9 +1188,9 @@ function exportMappings(root, dir, pkg) {
|
|
|
994
1188
|
let build;
|
|
995
1189
|
const mappings = [];
|
|
996
1190
|
for (const [key, value] of entries) {
|
|
997
|
-
const target = exportTarget(value);
|
|
1191
|
+
const target = exportTarget(value, asWritten ? APP_CONDITIONS : void 0);
|
|
998
1192
|
if (!target) continue;
|
|
999
|
-
const source = target.includes("*") ? sourcePattern(dir, target) : sourceTarget(dir, key, target, build ??= buildEntries(dir));
|
|
1193
|
+
const source = asWritten ? void 0 : target.includes("*") ? sourcePattern(dir, target) : sourceTarget(dir, key, target, build ??= buildEntries(dir));
|
|
1000
1194
|
mappings.push({
|
|
1001
1195
|
specifier: key === "." ? name : `${name}/${key.replace(/^\.\//, "")}`,
|
|
1002
1196
|
target: relativePath(root, path.resolve(dir, source ?? target))
|
|
@@ -1004,6 +1198,23 @@ function exportMappings(root, dir, pkg) {
|
|
|
1004
1198
|
}
|
|
1005
1199
|
return mappings;
|
|
1006
1200
|
}
|
|
1201
|
+
/**
|
|
1202
|
+
* A compiled file in package `dir`'s build folder: JavaScript, JSX or CSS (`dist/index.mjs`,
|
|
1203
|
+
* `lib/button.jsx`, `dist/index.css`), or a pattern of them (`dist/*.jsx`). TypeScript there
|
|
1204
|
+
* (`lib/utils.ts`) is source.
|
|
1205
|
+
*/
|
|
1206
|
+
function isBuildOutput(dir, file) {
|
|
1207
|
+
return /^(?:dist|build|lib|out|esm|cjs)\/.*\.(?:[cm]?jsx?|css)$/.test(toPosix(path.relative(dir, file)));
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* An `exports` target of installed package `dir` that is compiled: its build output, or
|
|
1211
|
+
* JavaScript in any folder (`./index.js`, `./es/index.mjs`, `./build-module/index.js`), which a
|
|
1212
|
+
* package published from TypeScript or JSX sources ships compiled. Its re-exports are not
|
|
1213
|
+
* followed, so a package that ships a `.js` barrel in front of `.jsx` sources is not read.
|
|
1214
|
+
*/
|
|
1215
|
+
function isCompiledExport(dir, file) {
|
|
1216
|
+
return isBuildOutput(dir, file) || /\.[cm]?js$/.test(file);
|
|
1217
|
+
}
|
|
1007
1218
|
/** `./dist/*.mjs` → `src/*.tsx` (or `.ts`) when files match it there; anything else as it is. */
|
|
1008
1219
|
function sourcePattern(dir, target) {
|
|
1009
1220
|
const relative = target.replace(/^\.\//, "");
|
|
@@ -1019,19 +1230,40 @@ function sourcePattern(dir, target) {
|
|
|
1019
1230
|
}
|
|
1020
1231
|
return relative;
|
|
1021
1232
|
}
|
|
1022
|
-
/**
|
|
1023
|
-
|
|
1233
|
+
/**
|
|
1234
|
+
* The `exports` conditions an app resolves an installed package through: Node.js's own
|
|
1235
|
+
* (`import`, `require`, `module-sync`, `node`, `default`), and the `module`, `browser` and
|
|
1236
|
+
* `style` that bundlers and Tailwind CSS add. Others, such as `source`, `expo-source` and
|
|
1237
|
+
* `development`, lead the package's own repository to its sources, and an app that installs it
|
|
1238
|
+
* builds the target after them: `@expo/ui`'s `"default": "./build/universal/index.js"`, not its
|
|
1239
|
+
* `"expo-source": "./src/universal/index.ts"` before it.
|
|
1240
|
+
*/
|
|
1241
|
+
const APP_CONDITIONS = /* @__PURE__ */ new Set([
|
|
1242
|
+
"import",
|
|
1243
|
+
"require",
|
|
1244
|
+
"module-sync",
|
|
1245
|
+
"node",
|
|
1246
|
+
"module",
|
|
1247
|
+
"browser",
|
|
1248
|
+
"style",
|
|
1249
|
+
"default"
|
|
1250
|
+
]);
|
|
1251
|
+
/**
|
|
1252
|
+
* The source file an export condition map points at: the first string target that is not a
|
|
1253
|
+
* declaration file, through any condition but `types`, or through `conditions` alone.
|
|
1254
|
+
*/
|
|
1255
|
+
function exportTarget(value, conditions) {
|
|
1024
1256
|
if (typeof value === "string") return value.endsWith(".d.ts") ? void 0 : value;
|
|
1025
1257
|
if (Array.isArray(value)) {
|
|
1026
1258
|
for (const item of value) {
|
|
1027
|
-
const target = exportTarget(item);
|
|
1259
|
+
const target = exportTarget(item, conditions);
|
|
1028
1260
|
if (target) return target;
|
|
1029
1261
|
}
|
|
1030
1262
|
return;
|
|
1031
1263
|
}
|
|
1032
1264
|
if (value && typeof value === "object") for (const [condition, nested] of Object.entries(value)) {
|
|
1033
|
-
if (condition === "types") continue;
|
|
1034
|
-
const target = exportTarget(nested);
|
|
1265
|
+
if (condition === "types" || conditions && !conditions.has(condition)) continue;
|
|
1266
|
+
const target = exportTarget(nested, conditions);
|
|
1035
1267
|
if (target) return target;
|
|
1036
1268
|
}
|
|
1037
1269
|
}
|
|
@@ -1090,6 +1322,140 @@ function moduleResolver(root) {
|
|
|
1090
1322
|
};
|
|
1091
1323
|
}
|
|
1092
1324
|
/**
|
|
1325
|
+
* {@link moduleResolver} for a Tailwind config's presets, then a workspace package's
|
|
1326
|
+
* `exports` for what it leaves unresolved: `@repo/tailwind-config` through
|
|
1327
|
+
* `"exports": { ".": "./tailwind.config.ts" }`, as Turborepo's template has it.
|
|
1328
|
+
*/
|
|
1329
|
+
function presetResolver(root) {
|
|
1330
|
+
const resolve = moduleResolver(root);
|
|
1331
|
+
const packages = new PackageFinder(root);
|
|
1332
|
+
return (specifier, fromFile) => {
|
|
1333
|
+
const file = resolve(specifier, fromFile);
|
|
1334
|
+
if (file || specifier.startsWith(".")) return file;
|
|
1335
|
+
const name = packageName(specifier);
|
|
1336
|
+
const dir = name ? packages.find(name) : void 0;
|
|
1337
|
+
return name && dir ? exportedFile(dir, `.${specifier.slice(name.length)}`, (f) => SCRIPT_FILE.test(f)) : void 0;
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Resolves a stylesheet's `@import` to a project file: a path next to the stylesheet
|
|
1342
|
+
* (`./theme.css`, or `theme.css` as plain CSS reads it), or a stylesheet of a workspace
|
|
1343
|
+
* package (`@acme/theme/colors.css`, or `@acme/theme` through its `exports` or its `style`
|
|
1344
|
+
* field). An installed package resolves only when zero config reads it as a design system
|
|
1345
|
+
* ({@link installedDesignSystem}), and a stylesheet in it imports others of that package alone.
|
|
1346
|
+
* In an installed package, a stylesheet in a build folder (`dist/index.css`) is compiled, and
|
|
1347
|
+
* resolves to nothing. Other installed packages (`tailwindcss`) and URLs resolve to nothing.
|
|
1348
|
+
*/
|
|
1349
|
+
function stylesheetResolver(root) {
|
|
1350
|
+
const packages = new PackageFinder(root);
|
|
1351
|
+
const designSystems = /* @__PURE__ */ new Map();
|
|
1352
|
+
const designSystem = (name) => {
|
|
1353
|
+
if (!designSystems.has(name)) designSystems.set(name, installedDesignSystem(root, name, packages));
|
|
1354
|
+
return designSystems.get(name);
|
|
1355
|
+
};
|
|
1356
|
+
const stylesheet = (file, within) => [file, `${file}.css`].find((f) => f.endsWith(".css") && (within === void 0 || isInside(within, f) && !isBuildOutput(within, f)) && !/(?:^|[\\/])node_modules[\\/]/.test(within === void 0 ? f : path.relative(within, f)) && isFile(f));
|
|
1357
|
+
return (specifier, fromFile) => {
|
|
1358
|
+
if (/^(?:[a-z][\w+.-]*:|\/)/i.test(specifier)) return void 0;
|
|
1359
|
+
const relative = stylesheet(path.resolve(path.dirname(fromFile), specifier), installedPackageOf(fromFile));
|
|
1360
|
+
if (relative || specifier.startsWith(".")) return relative;
|
|
1361
|
+
const name = packageName(specifier);
|
|
1362
|
+
const workspace = name ? packages.find(name) : void 0;
|
|
1363
|
+
const installed = name && !workspace ? designSystem(name) : void 0;
|
|
1364
|
+
const dir = workspace ?? installed;
|
|
1365
|
+
if (!name || !dir) return void 0;
|
|
1366
|
+
const subpath = specifier.slice(name.length + 1);
|
|
1367
|
+
const key = subpath ? `./${subpath}` : ".";
|
|
1368
|
+
const exported = installed ? installedStylesheet(installed, key) : exportedFile(dir, key, (f) => f.endsWith(".css"));
|
|
1369
|
+
if (exported !== void 0) return exported ?? void 0;
|
|
1370
|
+
const style = readJson$1(path.join(dir, "package.json"))?.style;
|
|
1371
|
+
return subpath ? stylesheet(path.join(dir, subpath), installed) : stylesheet(path.resolve(dir, typeof style === "string" ? style : "index.css"), installed);
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* The stylesheet an app resolves `subpath` (`./globals.css`) of installed package `dir` to,
|
|
1376
|
+
* as the package's exported stylesheets are found ({@link exportTarget}): the most specific
|
|
1377
|
+
* `exports` entry that maps it decides, through its first stylesheet target under
|
|
1378
|
+
* {@link APP_CONDITIONS}, and a later condition is not tried. `null` when that entry leads to no
|
|
1379
|
+
* stylesheet, or to one in a build folder (`{ "style": "./dist/x.css", "default": "./src/x.css" }`
|
|
1380
|
+
* gives apps the compiled `dist/x.css`); undefined when no entry maps the subpath.
|
|
1381
|
+
*/
|
|
1382
|
+
function installedStylesheet(dir, subpath) {
|
|
1383
|
+
const [match] = exportMatches(dir, subpath);
|
|
1384
|
+
if (!match) return void 0;
|
|
1385
|
+
const target = exportTargets(match.value, APP_CONDITIONS).map((t) => t.replaceAll("*", match.middle)).find((t) => t.endsWith(".css"));
|
|
1386
|
+
const file = target === void 0 ? void 0 : path.resolve(dir, target);
|
|
1387
|
+
return file && isInside(dir, file) && !isBuildOutput(dir, file) && isFile(file) ? file : null;
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* The folder of `name` installed from a registry, when zero config reads it as a design system:
|
|
1391
|
+
* named like one, with `exports` that point at the component sources it publishes.
|
|
1392
|
+
*/
|
|
1393
|
+
function installedDesignSystem(root, name, packages) {
|
|
1394
|
+
if (!isDesignSystemName(name)) return void 0;
|
|
1395
|
+
const dir = packages.installed(name);
|
|
1396
|
+
const pkg = dir ? readJson$1(path.join(dir, "package.json")) : void 0;
|
|
1397
|
+
const found = dir && pkg ? fromPackage(root, dir, pkg, ignore, true) : void 0;
|
|
1398
|
+
return found && matchesAny(root, found) ? dir : void 0;
|
|
1399
|
+
}
|
|
1400
|
+
/** The installed package a file is in: `…/node_modules/@acme/ui` for `…/node_modules/@acme/ui/src/a.css`. */
|
|
1401
|
+
function installedPackageOf(file) {
|
|
1402
|
+
const parts = path.resolve(file).split(path.sep);
|
|
1403
|
+
const at = parts.lastIndexOf("node_modules");
|
|
1404
|
+
const first = parts[at + 1] ?? ".";
|
|
1405
|
+
if (at === -1 || first.startsWith(".")) return void 0;
|
|
1406
|
+
const end = at + (first.startsWith("@") ? 3 : 2);
|
|
1407
|
+
return end < parts.length ? parts.slice(0, end).join(path.sep) : void 0;
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* The file a package's `exports` map `subpath` (`.`, `./theme`) to, through any condition
|
|
1411
|
+
* (`style`, `import`, `default`) and `*` patterns: the first target on disk, inside the
|
|
1412
|
+
* package, that `accept` takes.
|
|
1413
|
+
*/
|
|
1414
|
+
function exportedFile(dir, subpath, accept) {
|
|
1415
|
+
for (const { middle, value } of exportMatches(dir, subpath)) for (const target of exportTargets(value)) {
|
|
1416
|
+
const file = path.resolve(dir, target.replaceAll("*", middle));
|
|
1417
|
+
if (isInside(dir, file) && accept(file) && isFile(file)) return file;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* The entries of package `dir`'s `exports` that map `subpath`, with what their `*` stands for:
|
|
1422
|
+
* an exact subpath first, then the most specific pattern, as Node.js picks them.
|
|
1423
|
+
*/
|
|
1424
|
+
function exportMatches(dir, subpath) {
|
|
1425
|
+
const exports = readJson$1(path.join(dir, "package.json"))?.exports;
|
|
1426
|
+
const entries = exports && typeof exports === "object" && !Array.isArray(exports) ? Object.keys(exports).every((k) => k.startsWith(".")) ? Object.entries(exports) : [[".", exports]] : exports === void 0 ? [] : [[".", exports]];
|
|
1427
|
+
const matches = [];
|
|
1428
|
+
for (const [key, value] of entries) {
|
|
1429
|
+
const star = key.indexOf("*");
|
|
1430
|
+
if (star === -1) {
|
|
1431
|
+
if (key === subpath) matches.push({
|
|
1432
|
+
middle: "",
|
|
1433
|
+
value,
|
|
1434
|
+
rank: Infinity
|
|
1435
|
+
});
|
|
1436
|
+
continue;
|
|
1437
|
+
}
|
|
1438
|
+
const prefix = key.slice(0, star);
|
|
1439
|
+
const suffix = key.slice(star + 1);
|
|
1440
|
+
if (subpath.startsWith(prefix) && subpath.endsWith(suffix) && subpath.length >= prefix.length + suffix.length) {
|
|
1441
|
+
const middle = subpath.slice(prefix.length, subpath.length - suffix.length);
|
|
1442
|
+
matches.push({
|
|
1443
|
+
middle,
|
|
1444
|
+
value,
|
|
1445
|
+
rank: prefix.length
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
return matches.sort((a, b) => b.rank - a.rank);
|
|
1450
|
+
}
|
|
1451
|
+
/** Every target of an export condition map, in order, but its types, or those of `conditions` alone. */
|
|
1452
|
+
function exportTargets(value, conditions) {
|
|
1453
|
+
if (typeof value === "string") return [value];
|
|
1454
|
+
if (Array.isArray(value)) return value.flatMap((item) => exportTargets(item, conditions));
|
|
1455
|
+
if (value && typeof value === "object") return Object.entries(value).flatMap(([condition, nested]) => condition === "types" || conditions && !conditions.has(condition) ? [] : exportTargets(nested, conditions));
|
|
1456
|
+
return [];
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1093
1459
|
* Where an import from a workspace package points: its file, or `{}` for a
|
|
1094
1460
|
* subpath the package's `exports` declare but whose target is not on disk
|
|
1095
1461
|
* (built to `dist/`). Undefined when no such module exists. The linter tells
|
|
@@ -1261,14 +1627,8 @@ var PackageFinder = class {
|
|
|
1261
1627
|
* checkout that is not installed (only then is the workspace scanned). With its package.json.
|
|
1262
1628
|
*/
|
|
1263
1629
|
locate(name) {
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
const json = cachedManifest(path.join(dir, "package.json"));
|
|
1267
|
-
if (json) return {
|
|
1268
|
-
dir,
|
|
1269
|
-
json
|
|
1270
|
-
};
|
|
1271
|
-
}
|
|
1630
|
+
const found = this.inNodeModules(name);
|
|
1631
|
+
if (found) return found;
|
|
1272
1632
|
this.linkable ??= scanWorkspace(this.root, false);
|
|
1273
1633
|
const dir = this.linkable.get(name);
|
|
1274
1634
|
const json = dir ? cachedManifest(path.join(dir, "package.json")) : void 0;
|
|
@@ -1293,6 +1653,30 @@ var PackageFinder = class {
|
|
|
1293
1653
|
boundary: installed ? dir : this.boundaryOf(dir)
|
|
1294
1654
|
};
|
|
1295
1655
|
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Where `name` is installed from a registry, rather than linked to a workspace package: its
|
|
1658
|
+
* real folder, where pnpm puts its own dependencies beside it, expressed from the root. When
|
|
1659
|
+
* that is outside the root and its workspace (pnpm's store elsewhere on the machine), the
|
|
1660
|
+
* folder in `node_modules` it was found by, the only way the loader reads it.
|
|
1661
|
+
*/
|
|
1662
|
+
installed(name) {
|
|
1663
|
+
const located = this.inNodeModules(name);
|
|
1664
|
+
if (!located) return void 0;
|
|
1665
|
+
const placed = this.place(located.dir);
|
|
1666
|
+
if (!placed.installed) return void 0;
|
|
1667
|
+
return isInside(this.root, placed.dir) || this.boundaryOf(placed.dir) !== placed.dir ? placed.dir : path.resolve(located.dir);
|
|
1668
|
+
}
|
|
1669
|
+
/** `name` in the nearest `node_modules` at or above the root that has it, with its package.json. */
|
|
1670
|
+
inNodeModules(name) {
|
|
1671
|
+
for (const nodeModules of this.nodeModulesDirs()) {
|
|
1672
|
+
const dir = path.join(nodeModules, ...name.split("/"));
|
|
1673
|
+
const json = cachedManifest(path.join(dir, "package.json"));
|
|
1674
|
+
if (json) return {
|
|
1675
|
+
dir,
|
|
1676
|
+
json
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1296
1680
|
/** The `node_modules` folders at and above the root, nearest first, looked up once. */
|
|
1297
1681
|
nodeModulesDirs() {
|
|
1298
1682
|
if (!this.nodeModules) {
|
|
@@ -1305,24 +1689,47 @@ var PackageFinder = class {
|
|
|
1305
1689
|
}
|
|
1306
1690
|
return this.nodeModules;
|
|
1307
1691
|
}
|
|
1308
|
-
/**
|
|
1692
|
+
/**
|
|
1693
|
+
* The workspace root when `dir` is in it, else `dir` itself. Never the home folder or above:
|
|
1694
|
+
* a stray `~/package.json` with `workspaces` would let a package's config name
|
|
1695
|
+
* `../../../.aws/credentials`.
|
|
1696
|
+
*/
|
|
1309
1697
|
boundaryOf(dir) {
|
|
1310
|
-
if (this.workspace === void 0)
|
|
1698
|
+
if (this.workspace === void 0) {
|
|
1699
|
+
const workspace = workspaceRootOf(this.root);
|
|
1700
|
+
this.workspace = workspace !== void 0 && !isHomeOrAbove(workspace) ? workspace : null;
|
|
1701
|
+
}
|
|
1311
1702
|
return this.workspace !== null && isInside(this.workspace, dir) ? this.workspace : dir;
|
|
1312
1703
|
}
|
|
1313
1704
|
};
|
|
1314
1705
|
/**
|
|
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.
|
|
1706
|
+
* A monorepo root: a pnpm-workspace.yaml whose `packages` name more than the root,
|
|
1707
|
+
* `workspaces` in package.json (npm, Yarn, Bun), a lerna.json with `packages`, or an nx.json.
|
|
1708
|
+
* Turborepo runs on the package manager's workspaces, so its roots are among these. pnpm 11
|
|
1709
|
+
* keeps its settings in a pnpm-workspace.yaml in a single app too (`allowBuilds`, as
|
|
1710
|
+
* create-next-app --use-pnpm leaves it), and reads one without `packages`, or with only
|
|
1711
|
+
* `- '.'`, as the root alone: that is not a monorepo.
|
|
1318
1712
|
*/
|
|
1319
1713
|
function isWorkspaceRoot(dir) {
|
|
1320
|
-
if (
|
|
1714
|
+
if (pnpmWorkspacePackages(dir).length) return true;
|
|
1321
1715
|
if (fs.existsSync(path.join(dir, "nx.json"))) return true;
|
|
1322
1716
|
const pkg = readJson$1(path.join(dir, "package.json"));
|
|
1323
1717
|
if (pkg !== void 0 && pkg.workspaces !== void 0) return true;
|
|
1324
1718
|
return Array.isArray(readJson$1(path.join(dir, "lerna.json"))?.packages);
|
|
1325
1719
|
}
|
|
1720
|
+
/**
|
|
1721
|
+
* The `packages` of the pnpm-workspace.yaml in `dir` when they name a package other than the
|
|
1722
|
+
* root (`.`); none otherwise, and none without the file: then it holds only settings.
|
|
1723
|
+
*/
|
|
1724
|
+
function pnpmWorkspacePackages(dir) {
|
|
1725
|
+
let list;
|
|
1726
|
+
try {
|
|
1727
|
+
list = pnpmPackages(fs.readFileSync(path.join(dir, "pnpm-workspace.yaml"), "utf8"));
|
|
1728
|
+
} catch {
|
|
1729
|
+
return [];
|
|
1730
|
+
}
|
|
1731
|
+
return list.some((p) => !p.startsWith("!") && path.posix.normalize(toPosix(p).replace(/\/+$/, "")) !== ".") ? list : [];
|
|
1732
|
+
}
|
|
1326
1733
|
/** The nearest workspace root at or above `start`. */
|
|
1327
1734
|
function workspaceRootOf(start) {
|
|
1328
1735
|
for (let dir = start;; dir = path.dirname(dir)) {
|
|
@@ -1380,7 +1787,8 @@ function stampOf(files) {
|
|
|
1380
1787
|
}
|
|
1381
1788
|
/**
|
|
1382
1789
|
* `packages` from pnpm-workspace.yaml, `workspaces` from package.json (npm, Yarn, Bun), or
|
|
1383
|
-
* `packages` from lerna.json, in that order of precedence.
|
|
1790
|
+
* `packages` from lerna.json, in that order of precedence. A pnpm-workspace.yaml that names
|
|
1791
|
+
* no package beyond the root holds only settings, so the next source decides.
|
|
1384
1792
|
*/
|
|
1385
1793
|
function workspacePatterns(dir) {
|
|
1386
1794
|
const split = (list, source) => ({
|
|
@@ -1388,8 +1796,8 @@ function workspacePatterns(dir) {
|
|
|
1388
1796
|
exclude: list.filter((p) => p.startsWith("!")).map((p) => toPosix(p.slice(1))),
|
|
1389
1797
|
source
|
|
1390
1798
|
});
|
|
1391
|
-
const
|
|
1392
|
-
if (
|
|
1799
|
+
const pnpm = pnpmWorkspacePackages(dir);
|
|
1800
|
+
if (pnpm.length) return split(pnpm, "pnpm-workspace.yaml");
|
|
1393
1801
|
const workspaces = readJson$1(path.join(dir, "package.json"))?.workspaces;
|
|
1394
1802
|
const list = Array.isArray(workspaces) ? workspaces : asRecord(workspaces).packages;
|
|
1395
1803
|
if (Array.isArray(list)) return split(list.filter((p) => typeof p === "string"), "package.json workspaces");
|
|
@@ -1428,12 +1836,13 @@ function workspacePackageDirs(root, nx = true) {
|
|
|
1428
1836
|
}
|
|
1429
1837
|
/**
|
|
1430
1838
|
* 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
|
|
1839
|
+
* not) or flow (`[apps/*, packages/*]`) style, with comments, a quoted key or a
|
|
1840
|
+
* leading byte order mark. Only that key is read, so this stays a few lines
|
|
1841
|
+
* rather than a YAML parser.
|
|
1433
1842
|
*/
|
|
1434
1843
|
function pnpmPackages(text) {
|
|
1435
|
-
const lines = text.split(/\r?\n/);
|
|
1436
|
-
const start = lines.findIndex((line) => /^packages\s*:/.test(line));
|
|
1844
|
+
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
1845
|
+
const start = lines.findIndex((line) => /^(['"]?)packages\1\s*:/.test(line));
|
|
1437
1846
|
if (start === -1) return [];
|
|
1438
1847
|
const uncomment = (s) => s.replace(/(?:^|\s)#.*$/, "").trim();
|
|
1439
1848
|
const unquote = (s) => s.trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
@@ -1506,9 +1915,21 @@ function isBareHsl(value) {
|
|
|
1506
1915
|
return BARE_HSL.test(value.trim());
|
|
1507
1916
|
}
|
|
1508
1917
|
/**
|
|
1918
|
+
* RGB channels without the function, `22 163 74` (commas and `/ alpha` allowed): how
|
|
1919
|
+
* Tailwind v3's docs store colors, to be used as `rgb(var(--success) / <alpha-value>)`.
|
|
1920
|
+
* Three numbers alone are no sure color, so they count as one only in a token already
|
|
1921
|
+
* known to be a color: one the Tailwind config reads with `rgb()`.
|
|
1922
|
+
*/
|
|
1923
|
+
const BARE_RGB = /^(\d{1,3})(?:\s*,\s*|\s+)(\d{1,3})(?:\s*,\s*|\s+)(\d{1,3})(?:\s*\/\s*(\d*\.?\d+%?))?$/;
|
|
1924
|
+
function isBareRgb(value) {
|
|
1925
|
+
const match = BARE_RGB.exec(value.trim());
|
|
1926
|
+
return match !== null && match.slice(1, 4).every((channel) => Number(channel) <= 255);
|
|
1927
|
+
}
|
|
1928
|
+
/**
|
|
1509
1929
|
* Parses any CSS color (hex, rgb(), hsl(), oklch(), named, ...) into OKLCH.
|
|
1510
1930
|
* With `bareHsl`, for token values, HSL channels count too: in a stylesheet
|
|
1511
1931
|
* they can only be a color, while in a class or style they are not one.
|
|
1932
|
+
* With `bareRgb`, for the values of color tokens, RGB channels count too.
|
|
1512
1933
|
*/
|
|
1513
1934
|
function parseColor(value, options = {}) {
|
|
1514
1935
|
const text = value.trim();
|
|
@@ -1519,10 +1940,27 @@ function parseColor(value, options = {}) {
|
|
|
1519
1940
|
const [, h, deg = "", s, l, alpha] = hsl;
|
|
1520
1941
|
parsed = parse(`hsl(${h}${deg} ${s}% ${l}%${alpha ? ` / ${alpha}` : ""})`);
|
|
1521
1942
|
}
|
|
1943
|
+
const rgb = !parsed && options.bareRgb && isBareRgb(text) ? BARE_RGB.exec(text) : null;
|
|
1944
|
+
if (rgb) {
|
|
1945
|
+
const [, r, g, b, alpha] = rgb;
|
|
1946
|
+
parsed = parse(`rgb(${r} ${g} ${b}${alpha ? ` / ${alpha}` : ""})`);
|
|
1947
|
+
}
|
|
1522
1948
|
return parsed ? toOklch$1(parsed) : void 0;
|
|
1523
1949
|
}
|
|
1950
|
+
/** A color's opacity, from 0 (transparent) to 1 (opaque). */
|
|
1951
|
+
function opacity(color) {
|
|
1952
|
+
return color.alpha ?? 1;
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* How much a difference in opacity weighs against ΔE, which leaves it out. Translucency is
|
|
1956
|
+
* part of what a color does (an overlay, a tint, a frosted header), so a token more than
|
|
1957
|
+
* 0.02 apart in opacity is never close (5 × 0.02 is CLOSE_COLOR): `bg-[#e7000b33]`, the red
|
|
1958
|
+
* at 20%, is not `bg-destructive`. One step of an 8-digit hex (1/255) is still the same color.
|
|
1959
|
+
*/
|
|
1960
|
+
const OPACITY_WEIGHT = 5;
|
|
1961
|
+
/** ΔE between two colors, plus their difference in opacity. */
|
|
1524
1962
|
function colorDistance(a, b) {
|
|
1525
|
-
return okDistance(a, b);
|
|
1963
|
+
return okDistance(a, b) + OPACITY_WEIGHT * Math.abs(opacity(a) - opacity(b));
|
|
1526
1964
|
}
|
|
1527
1965
|
function isNamedColor(value) {
|
|
1528
1966
|
return Object.hasOwn(colorsNamed, value.toLowerCase());
|
|
@@ -1602,6 +2040,25 @@ function sameHue(color, token, tinted = isTinted(color), tolerance = 15) {
|
|
|
1602
2040
|
function formatDeltaE(deltaE) {
|
|
1603
2041
|
return deltaE < 5e-4 ? "exact match" : `ΔE ${deltaE.toFixed(3)}`;
|
|
1604
2042
|
}
|
|
2043
|
+
/** `opaque`, `20% opaque` or `transparent`, to the nearest percent. */
|
|
2044
|
+
function formatOpacity(color) {
|
|
2045
|
+
const percent = Math.round(opacity(color) * 100);
|
|
2046
|
+
return percent === 100 ? "opaque" : percent === 0 ? "transparent" : `${String(percent)}% opaque`;
|
|
2047
|
+
}
|
|
2048
|
+
/**
|
|
2049
|
+
* When a token's value (`token`) and a color differ in opacity, a sentence that says so:
|
|
2050
|
+
* `destructive is opaque, and this color is 20% opaque.`
|
|
2051
|
+
*/
|
|
2052
|
+
function formatOpacityGap(color, token, name) {
|
|
2053
|
+
const [own, its] = [formatOpacity(color), formatOpacity(token)];
|
|
2054
|
+
return own === its ? void 0 : `${name} is ${its}, and this color is ${own}.`;
|
|
2055
|
+
}
|
|
2056
|
+
/** ΔE between a color and a token's value, which `formatOpacityGap` completes. */
|
|
2057
|
+
function formatDistance(color, token) {
|
|
2058
|
+
const deltaE = okDistance(color, token);
|
|
2059
|
+
if (deltaE < 5e-4 && formatOpacityGap(color, token, "")) return "same color";
|
|
2060
|
+
return formatDeltaE(deltaE);
|
|
2061
|
+
}
|
|
1605
2062
|
//#endregion
|
|
1606
2063
|
//#region src/libraries/mui.ts
|
|
1607
2064
|
/** The palette groups MUI's components take as a `color` value (`<Button color="primary">`). */
|
|
@@ -1945,21 +2402,57 @@ function themeTokens(options, source) {
|
|
|
1945
2402
|
const LIBRARY_IDS = ["mui"];
|
|
1946
2403
|
/** The libraries onsystem can read, by package. */
|
|
1947
2404
|
const LIBRARIES = [mui];
|
|
2405
|
+
/**
|
|
2406
|
+
* Published component libraries onsystem does not read yet, by package. A project that depends
|
|
2407
|
+
* on one is told so, since nothing in it is checked and a clean result would look like a
|
|
2408
|
+
* misconfigured one.
|
|
2409
|
+
*/
|
|
2410
|
+
const UNREAD_LIBRARIES = [
|
|
2411
|
+
{
|
|
2412
|
+
package: "@chakra-ui/react",
|
|
2413
|
+
title: "Chakra UI"
|
|
2414
|
+
},
|
|
2415
|
+
{
|
|
2416
|
+
package: "@mantine/core",
|
|
2417
|
+
title: "Mantine"
|
|
2418
|
+
},
|
|
2419
|
+
{
|
|
2420
|
+
package: "@heroui/react",
|
|
2421
|
+
title: "HeroUI"
|
|
2422
|
+
},
|
|
2423
|
+
{
|
|
2424
|
+
package: "@radix-ui/themes",
|
|
2425
|
+
title: "Radix Themes"
|
|
2426
|
+
},
|
|
2427
|
+
{
|
|
2428
|
+
package: "antd",
|
|
2429
|
+
title: "Ant Design"
|
|
2430
|
+
}
|
|
2431
|
+
];
|
|
1948
2432
|
function libraryAdapter(name) {
|
|
1949
2433
|
return LIBRARIES.find((library) => library.package === name);
|
|
1950
2434
|
}
|
|
2435
|
+
/** A library's name for messages, read or not: `MUI`, `Chakra UI`. */
|
|
2436
|
+
function libraryTitle(name) {
|
|
2437
|
+
return [...LIBRARIES, ...UNREAD_LIBRARIES].find((l) => l.package === name)?.title ?? name;
|
|
2438
|
+
}
|
|
2439
|
+
/** Why a library in `UNREAD_LIBRARIES` is not read: `a dependency onsystem does not read yet (…)`. */
|
|
2440
|
+
function notReadYet() {
|
|
2441
|
+
return `a dependency onsystem does not read yet (it reads only ${LIBRARIES.map((l) => l.title).join(", ")} so far)`;
|
|
2442
|
+
}
|
|
1951
2443
|
/**
|
|
1952
2444
|
* The libraries `root`'s package.json depends on (dependencies, devDependencies or
|
|
1953
2445
|
* peerDependencies), each found where Node.js would find it: in `node_modules` at the root or a
|
|
1954
2446
|
* folder above it, as a monorepo hoists it. Nothing in the package is run. `ids` is the config's
|
|
1955
2447
|
* `libraries`: undefined reads every library onsystem supports, false none, a list those named;
|
|
1956
|
-
* the dependencies it leaves out are `off`.
|
|
2448
|
+
* the dependencies it leaves out are `off`. Those in `UNREAD_LIBRARIES` are `unread`.
|
|
1957
2449
|
*/
|
|
1958
2450
|
function detectLibraries(root, ids) {
|
|
1959
2451
|
const pkg = readJson(path.join(root, "package.json"));
|
|
1960
2452
|
if (!pkg) return {
|
|
1961
2453
|
read: [],
|
|
1962
|
-
off: []
|
|
2454
|
+
off: [],
|
|
2455
|
+
unread: []
|
|
1963
2456
|
};
|
|
1964
2457
|
const dependencies = new Set([
|
|
1965
2458
|
"dependencies",
|
|
@@ -1969,23 +2462,24 @@ function detectLibraries(root, ids) {
|
|
|
1969
2462
|
const value = pkg[field];
|
|
1970
2463
|
return value && typeof value === "object" ? Object.keys(value) : [];
|
|
1971
2464
|
}));
|
|
1972
|
-
const
|
|
1973
|
-
const dir = installedPackage(root,
|
|
2465
|
+
const sourceOf = (name) => {
|
|
2466
|
+
const dir = installedPackage(root, name);
|
|
1974
2467
|
const version = dir ? readJson(path.join(dir, "package.json"))?.version : void 0;
|
|
1975
|
-
|
|
1976
|
-
package:
|
|
2468
|
+
return {
|
|
2469
|
+
package: name,
|
|
1977
2470
|
dir,
|
|
1978
2471
|
version: typeof version === "string" ? version : void 0
|
|
1979
2472
|
};
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
});
|
|
2473
|
+
};
|
|
2474
|
+
const found = LIBRARIES.filter((library) => dependencies.has(library.package)).map((library) => ({
|
|
2475
|
+
id: library.id,
|
|
2476
|
+
source: sourceOf(library.package)
|
|
2477
|
+
}));
|
|
1985
2478
|
const wanted = (id) => ids === void 0 || ids !== false && ids.includes(id);
|
|
1986
2479
|
return {
|
|
1987
2480
|
read: found.filter((f) => wanted(f.id)).map((f) => f.source),
|
|
1988
|
-
off: found.filter((f) => !wanted(f.id)).map((f) => f.source)
|
|
2481
|
+
off: found.filter((f) => !wanted(f.id)).map((f) => f.source),
|
|
2482
|
+
unread: UNREAD_LIBRARIES.filter((library) => dependencies.has(library.package)).map((library) => sourceOf(library.package))
|
|
1989
2483
|
};
|
|
1990
2484
|
}
|
|
1991
2485
|
/** The folder `name` is installed in for code in `root`, looking up through `node_modules` folders. */
|
|
@@ -2249,12 +2743,15 @@ function usageFor(token, tailwindNamespace) {
|
|
|
2249
2743
|
}
|
|
2250
2744
|
/**
|
|
2251
2745
|
* The CSS value that uses the token: `var(--primary)`, or `hsl(var(--primary))`
|
|
2252
|
-
* when the property holds bare HSL
|
|
2253
|
-
* on their own.
|
|
2746
|
+
* (`rgb(var(--success))`) when the property holds bare HSL (RGB) channels
|
|
2747
|
+
* (Tailwind v3), which are no color on their own.
|
|
2254
2748
|
*/
|
|
2255
2749
|
function cssReference(token) {
|
|
2256
2750
|
if (!token.cssVar) return void 0;
|
|
2257
|
-
|
|
2751
|
+
if (token.category !== "color") return `var(${token.cssVar})`;
|
|
2752
|
+
if (isBareHsl(token.value)) return `hsl(var(${token.cssVar}))`;
|
|
2753
|
+
if (isBareRgb(token.value)) return `rgb(var(${token.cssVar}))`;
|
|
2754
|
+
return `var(${token.cssVar})`;
|
|
2258
2755
|
}
|
|
2259
2756
|
function utilities(namespace, key) {
|
|
2260
2757
|
switch (namespace) {
|
|
@@ -2301,6 +2798,7 @@ const MAX_FILES = 12;
|
|
|
2301
2798
|
*/
|
|
2302
2799
|
function readTailwindColors(file, resolve) {
|
|
2303
2800
|
const colors = /* @__PURE__ */ new Map();
|
|
2801
|
+
const rgb = /* @__PURE__ */ new Set();
|
|
2304
2802
|
const files = [];
|
|
2305
2803
|
const queue = [file];
|
|
2306
2804
|
while (queue.length && files.length < MAX_FILES) {
|
|
@@ -2316,7 +2814,7 @@ function readTailwindColors(file, resolve) {
|
|
|
2316
2814
|
const source = ts.createSourceFile(current, text, ts.ScriptTarget.Latest, true, scriptKind(current));
|
|
2317
2815
|
const visit = (node) => {
|
|
2318
2816
|
if (ts.isPropertyAssignment(node) && propertyName(node.name) === "colors" && ts.isObjectLiteralExpression(node.initializer)) {
|
|
2319
|
-
collect(node.initializer, [], colors);
|
|
2817
|
+
collect(node.initializer, [], colors, rgb);
|
|
2320
2818
|
return;
|
|
2321
2819
|
}
|
|
2322
2820
|
const specifier = importedSpecifier(node);
|
|
@@ -2328,11 +2826,12 @@ function readTailwindColors(file, resolve) {
|
|
|
2328
2826
|
}
|
|
2329
2827
|
return {
|
|
2330
2828
|
colors,
|
|
2829
|
+
rgb,
|
|
2331
2830
|
files
|
|
2332
2831
|
};
|
|
2333
2832
|
}
|
|
2334
2833
|
/** `primary: { DEFAULT: "hsl(var(--primary))", foreground: … }` → `--primary` → `primary`, `--primary-foreground` → `primary-foreground`. */
|
|
2335
|
-
function collect(object, keys, colors) {
|
|
2834
|
+
function collect(object, keys, colors, rgb) {
|
|
2336
2835
|
for (const property of object.properties) {
|
|
2337
2836
|
if (!ts.isPropertyAssignment(property)) continue;
|
|
2338
2837
|
const key = propertyName(property.name);
|
|
@@ -2340,13 +2839,15 @@ function collect(object, keys, colors) {
|
|
|
2340
2839
|
const value = property.initializer;
|
|
2341
2840
|
const nested = [...keys, key];
|
|
2342
2841
|
if (ts.isObjectLiteralExpression(value)) {
|
|
2343
|
-
collect(value, nested, colors);
|
|
2842
|
+
collect(value, nested, colors, rgb);
|
|
2344
2843
|
continue;
|
|
2345
2844
|
}
|
|
2346
2845
|
if (!ts.isStringLiteralLike(value)) continue;
|
|
2347
2846
|
const variable = /var\(\s*(--[\w-]+)/.exec(value.text)?.[1];
|
|
2348
2847
|
const name = nested.filter((k) => k !== "DEFAULT").join("-");
|
|
2349
|
-
if (variable
|
|
2848
|
+
if (!variable || !name || colors.has(variable)) continue;
|
|
2849
|
+
colors.set(variable, name);
|
|
2850
|
+
if (/^\s*rgba?\(\s*var\(/.test(value.text)) rgb.add(variable);
|
|
2350
2851
|
}
|
|
2351
2852
|
}
|
|
2352
2853
|
/**
|
|
@@ -2449,9 +2950,9 @@ function applyTailwindRadii(tokens, radii, configFile = "tailwind.config") {
|
|
|
2449
2950
|
havePx.add(px);
|
|
2450
2951
|
}
|
|
2451
2952
|
}
|
|
2452
|
-
/** The specifier of `require("x")`, `import x from "x"` or `import("x")`. */
|
|
2953
|
+
/** The specifier of `require("x")`, `import x from "x"`, `export { default } from "x"` or `import("x")`. */
|
|
2453
2954
|
function importedSpecifier(node) {
|
|
2454
|
-
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) return node.moduleSpecifier.text;
|
|
2955
|
+
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) return node.moduleSpecifier.text;
|
|
2455
2956
|
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
2957
|
}
|
|
2457
2958
|
function scriptKind(file) {
|
|
@@ -2500,9 +3001,13 @@ const SHADCN_COLORS = /* @__PURE__ */ new Set([
|
|
|
2500
3001
|
* Gives color tokens their Tailwind v3 class names: the key the config maps
|
|
2501
3002
|
* the custom property to, or, when the config has no colors we can read,
|
|
2502
3003
|
* shadcn/ui's convention. Tokens that already have a key (`@theme`) keep it.
|
|
3004
|
+
* A property the config reads as RGB channels (`rgb(var(--success) / <alpha-value>)`)
|
|
3005
|
+
* is a color token when its values are such channels (`22 163 74`), which the
|
|
3006
|
+
* stylesheet alone leaves undecided.
|
|
2503
3007
|
*/
|
|
2504
|
-
function applyTailwindColors(tokens, colors) {
|
|
3008
|
+
function applyTailwindColors(tokens, colors, rgb = /* @__PURE__ */ new Set()) {
|
|
2505
3009
|
for (const token of tokens) {
|
|
3010
|
+
if (token.category === "other" && token.cssVar && rgb.has(token.cssVar) && [token.value, ...Object.values(token.modes ?? {})].every(isBareRgb)) token.category = "color";
|
|
2506
3011
|
if (token.category !== "color" || token.tailwind !== void 0 || !token.cssVar) continue;
|
|
2507
3012
|
const name = token.cssVar.slice(2);
|
|
2508
3013
|
const key = colors.size ? colors.get(token.cssVar) : SHADCN_COLORS.has(name) ? name.replace(/^sidebar-background$/, "sidebar") : void 0;
|
|
@@ -2511,6 +3016,49 @@ function applyTailwindColors(tokens, colors) {
|
|
|
2511
3016
|
token.usage = usageFor(token, "--color");
|
|
2512
3017
|
}
|
|
2513
3018
|
}
|
|
3019
|
+
/** Enough of a package's stylesheets to find its theme, without reading a whole design system. */
|
|
3020
|
+
const MAX_PRESET_STYLESHEETS = 200;
|
|
3021
|
+
/**
|
|
3022
|
+
* The stylesheets that set the custom properties a Tailwind v3 config maps to colors, when
|
|
3023
|
+
* they sit in a workspace package the config reads its presets from rather than in the
|
|
3024
|
+
* project: Dub's `@dub/tailwind-config`, whose `themes.css` sets `--content-error` for the
|
|
3025
|
+
* `content-error` its config maps. Each CSS file of such a package (outside `node_modules`
|
|
3026
|
+
* and build output) that declares one of them, in path order. The project's own folder, and
|
|
3027
|
+
* a folder that holds it (a preset at the workspace root), are not searched.
|
|
3028
|
+
*/
|
|
3029
|
+
function presetStylesheets(read, root) {
|
|
3030
|
+
if (!read.colors.size) return [];
|
|
3031
|
+
const packages = /* @__PURE__ */ new Set();
|
|
3032
|
+
for (const file of read.files) {
|
|
3033
|
+
const dir = packageDir(path.dirname(file));
|
|
3034
|
+
if (dir && !isInside(dir, root)) packages.add(dir);
|
|
3035
|
+
}
|
|
3036
|
+
const out = [];
|
|
3037
|
+
for (const dir of packages) {
|
|
3038
|
+
const sheets = globSync("**/*.css", {
|
|
3039
|
+
cwd: dir,
|
|
3040
|
+
absolute: true,
|
|
3041
|
+
ignore: ["**/node_modules/**", "**/{dist,build,out,.next,.turbo,coverage}/**"]
|
|
3042
|
+
}).sort().slice(0, MAX_PRESET_STYLESHEETS);
|
|
3043
|
+
for (const sheet of sheets) {
|
|
3044
|
+
let text;
|
|
3045
|
+
try {
|
|
3046
|
+
text = fs.readFileSync(sheet, "utf8");
|
|
3047
|
+
} catch {
|
|
3048
|
+
continue;
|
|
3049
|
+
}
|
|
3050
|
+
if ([...text.matchAll(/(--[\w-]+)\s*:/g)].some((m) => read.colors.has(m[1] ?? ""))) out.push(path.resolve(sheet));
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
return out;
|
|
3054
|
+
}
|
|
3055
|
+
/** The nearest folder at or above `dir` with a package.json. */
|
|
3056
|
+
function packageDir(dir) {
|
|
3057
|
+
for (let current = dir;; current = path.dirname(current)) {
|
|
3058
|
+
if (fs.existsSync(path.join(current, "package.json"))) return current;
|
|
3059
|
+
if (current === path.dirname(current)) return void 0;
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
2514
3062
|
/** The project's Tailwind v3 config: `tailwind.config` in components.json, else `tailwind.config.*` in the root. */
|
|
2515
3063
|
function findTailwindConfig(root) {
|
|
2516
3064
|
try {
|
|
@@ -2589,7 +3137,7 @@ const librariesSchema = z.union([z.literal(false), z.array(z.enum(LIBRARY_IDS))]
|
|
|
2589
3137
|
const configShape = {
|
|
2590
3138
|
$schema: z.string().optional(),
|
|
2591
3139
|
components: globsSchema.optional().describe("Globs for component source files, relative to the root."),
|
|
2592
|
-
exclude: z.array(z.string()).optional().describe("Globs to
|
|
3140
|
+
exclude: z.array(z.string()).optional().describe("Globs for files to leave out, relative to the root: `check` in folders and globs, the ESLint plugin, the Claude Code hook and check_ui leave them out, and finding components skips them. A folder (`app/legacy`) covers everything in it. Adds to the defaults (node_modules, .d.ts files, tests and stories); a pattern that starts with `!` takes out the default it names, such as `!**/*.d.ts`."),
|
|
2593
3141
|
tokens: z.union([tokenSourceSchema, z.array(tokenSourceSchema)]).optional().describe("Token files: W3C DTCG JSON (*.json), CSS custom properties (*.css) or a DESIGN.md's front matter (*.md)."),
|
|
2594
3142
|
docs: globsSchema.optional().describe("Globs for per-component Markdown/MDX docs."),
|
|
2595
3143
|
tsconfig: z.string().optional().describe("tsconfig used to resolve types and path aliases."),
|
|
@@ -2709,6 +3257,7 @@ async function loadConfigWith(options, context = {}) {
|
|
|
2709
3257
|
const excludeFromBase = (e) => {
|
|
2710
3258
|
if (!inherited || e.startsWith("**/")) return e;
|
|
2711
3259
|
if (declared) return fromBase(e);
|
|
3260
|
+
if (e === toProject) return "**";
|
|
2712
3261
|
return e.startsWith(`${toProject}/`) ? e.slice(toProject.length + 1) : void 0;
|
|
2713
3262
|
};
|
|
2714
3263
|
const outside = [];
|
|
@@ -2756,7 +3305,14 @@ async function loadConfigWith(options, context = {}) {
|
|
|
2756
3305
|
path: p,
|
|
2757
3306
|
fallback: true
|
|
2758
3307
|
}))];
|
|
2759
|
-
const
|
|
3308
|
+
const tidy = (e) => slashGlob(e).replace(/^(?:\.\/)+/, "").replace(/(.)\/+$/, "$1");
|
|
3309
|
+
const listed = config.exclude ?? [];
|
|
3310
|
+
const dropped = new Set(listed.filter((e) => e.startsWith("!")).map((e) => tidy(e.slice(1))));
|
|
3311
|
+
for (const pattern of dropped) {
|
|
3312
|
+
if (DEFAULT_EXCLUDE.includes(pattern)) continue;
|
|
3313
|
+
warnings.push(`"!${pattern}" in "exclude" takes out none of the defaults, which are ${DEFAULT_EXCLUDE.map((d) => `"${d}"`).join(", ")}: a pattern that starts with "!" names one of them.`);
|
|
3314
|
+
}
|
|
3315
|
+
const exclude = [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDE.filter((d) => !dropped.has(d)), ...listed.filter((e) => !e.startsWith("!")).map(tidy)])].map(excludeFromBase).filter((e) => e !== void 0);
|
|
2760
3316
|
return {
|
|
2761
3317
|
root,
|
|
2762
3318
|
configFile,
|
|
@@ -2767,6 +3323,7 @@ async function loadConfigWith(options, context = {}) {
|
|
|
2767
3323
|
...t,
|
|
2768
3324
|
path: slashGlob(t.path)
|
|
2769
3325
|
})),
|
|
3326
|
+
...!options.tokens?.length && configTokens === void 0 ? { tokensDetected: true } : {},
|
|
2770
3327
|
docs: (options.docs?.length ? options.docs : configDocs === void 0 ? [.../* @__PURE__ */ new Set([...DEFAULT_DOCS, ...detection?.docs ?? []])] : configDocs.map(fromBase)).map(slashGlob),
|
|
2771
3328
|
tsconfig,
|
|
2772
3329
|
importPath: config.importPath,
|
|
@@ -2777,6 +3334,7 @@ async function loadConfigWith(options, context = {}) {
|
|
|
2777
3334
|
libraries: libraries.read,
|
|
2778
3335
|
libraryIds: config.libraries,
|
|
2779
3336
|
librariesOff: libraries.off,
|
|
3337
|
+
librariesUnread: libraries.unread,
|
|
2780
3338
|
elements: config.elements ?? {},
|
|
2781
3339
|
includeDesignSystem: options.includeDesignSystem ?? (declared ? void 0 : config.includeDesignSystem) ?? false,
|
|
2782
3340
|
rules,
|
|
@@ -2836,6 +3394,20 @@ function resolveRule(setting) {
|
|
|
2836
3394
|
options: setting[1] ?? {}
|
|
2837
3395
|
};
|
|
2838
3396
|
}
|
|
3397
|
+
/** The default test and story patterns, which `--include-tests` lifts. */
|
|
3398
|
+
function isTestPattern(pattern) {
|
|
3399
|
+
return pattern === "**/*.{test,spec,stories}.{ts,tsx,js,jsx}" || pattern === "**/__{tests,stories,mocks}__/**";
|
|
3400
|
+
}
|
|
3401
|
+
/**
|
|
3402
|
+
* The `exclude` pattern that leaves `file` (relative to the root) out, or undefined when none
|
|
3403
|
+
* does. The one test `check` (in folders and globs, and for files named outright with
|
|
3404
|
+
* `--skip-excluded`), the ESLint plugin, the Claude Code hook and check_ui all apply, so they
|
|
3405
|
+
* leave out the same files. A pattern covers the files it matches and everything in a folder it
|
|
3406
|
+
* matches. With `includeTests`, the default test and story patterns do not count.
|
|
3407
|
+
*/
|
|
3408
|
+
function excludedBy(config, file, options = {}) {
|
|
3409
|
+
return config.exclude.find((pattern) => !(options.includeTests && isTestPattern(pattern)) && coveredByGlob(file, pattern));
|
|
3410
|
+
}
|
|
2839
3411
|
/**
|
|
2840
3412
|
* The rules for one file: the config's `rules`, then every override whose
|
|
2841
3413
|
* `files` match the path (relative to the root), in order.
|
|
@@ -3118,8 +3690,14 @@ function buildComponent$1(context, candidate) {
|
|
|
3118
3690
|
const definitions = [...primary, ...nested];
|
|
3119
3691
|
const { props, inherits, openProps } = extractProps(context, candidate, typeNode, param);
|
|
3120
3692
|
const own = new Set(primary.flatMap((d) => d.variants.map((v) => v.name)));
|
|
3121
|
-
const
|
|
3122
|
-
|
|
3693
|
+
const adds = (name) => !own.has(name) && props.some((p) => p.name === name);
|
|
3694
|
+
const variants = mergeVariants([...primary.flatMap((d) => d.variants), ...nested.flatMap((d) => d.variants).filter((v) => adds(v.name))]);
|
|
3695
|
+
const layouts = /* @__PURE__ */ new Map();
|
|
3696
|
+
for (const definition of definitions) for (const [name, layout] of variantLayouts(definition)) {
|
|
3697
|
+
if (!primary.includes(definition) && !adds(name)) continue;
|
|
3698
|
+
layouts.set(name, [...layouts.get(name) ?? [], ...layout]);
|
|
3699
|
+
}
|
|
3700
|
+
mergeVariantProps(props, variants, layouts);
|
|
3123
3701
|
applyDestructuredDefaults(props, param);
|
|
3124
3702
|
const position = sourceFile.getLineAndCharacterOfPosition(candidate.declaration.getStart());
|
|
3125
3703
|
const info = {
|
|
@@ -3280,10 +3858,11 @@ function extractProps(context, candidate, typeNode, param) {
|
|
|
3280
3858
|
if (checker.getIndexInfosOfType(t).length) openProps = true;
|
|
3281
3859
|
for (const symbol of checker.getPropertiesOfType(t)) {
|
|
3282
3860
|
const entry = own.get(symbol.getName()) ?? {
|
|
3283
|
-
|
|
3861
|
+
symbols: [],
|
|
3284
3862
|
requiredIn: 0,
|
|
3285
3863
|
origin: originOf(symbol)
|
|
3286
3864
|
};
|
|
3865
|
+
if (!entry.symbols.length || type.isUnion() && !entry.symbols.includes(symbol)) entry.symbols.push(symbol);
|
|
3287
3866
|
if (!(symbol.flags & ts.SymbolFlags.Optional)) entry.requiredIn++;
|
|
3288
3867
|
own.set(symbol.getName(), entry);
|
|
3289
3868
|
}
|
|
@@ -3292,9 +3871,9 @@ function extractProps(context, candidate, typeNode, param) {
|
|
|
3292
3871
|
for (const [name, { origin }] of own) byOrigin.set(origin, [...byOrigin.get(origin) ?? [], name]);
|
|
3293
3872
|
const summarised = new Set([...byOrigin].filter(([origin, names]) => origin === "react" || origin !== "own" && names.length > MAX_LISTED_INHERITED$1).map(([origin]) => origin));
|
|
3294
3873
|
const props = [];
|
|
3295
|
-
for (const [name, {
|
|
3874
|
+
for (const [name, { symbols, requiredIn, origin }] of own) {
|
|
3296
3875
|
if (summarised.has(origin)) continue;
|
|
3297
|
-
props.push(describeProp$1(context, name,
|
|
3876
|
+
props.push(describeProp$1(context, name, symbols, location, requiredIn === types.length, origin));
|
|
3298
3877
|
}
|
|
3299
3878
|
const inherits = [];
|
|
3300
3879
|
for (const origin of summarised) {
|
|
@@ -3302,7 +3881,7 @@ function extractProps(context, candidate, typeNode, param) {
|
|
|
3302
3881
|
const key = registerPropSet(context.propSets, names);
|
|
3303
3882
|
const from = origin === "react" ? inheritedLabel(context, typeNode) : origin;
|
|
3304
3883
|
const deprecated = names.filter((name) => {
|
|
3305
|
-
return (own.get(name)?.
|
|
3884
|
+
return (own.get(name)?.symbols[0])?.getJsDocTags(checker).some((tag) => tag.name === "deprecated");
|
|
3306
3885
|
});
|
|
3307
3886
|
inherits.push({
|
|
3308
3887
|
from,
|
|
@@ -3374,21 +3953,35 @@ function originOf(symbol) {
|
|
|
3374
3953
|
if (pkg === "@types/react" || pkg === "typescript") return "react";
|
|
3375
3954
|
return pkg;
|
|
3376
3955
|
}
|
|
3377
|
-
|
|
3956
|
+
/**
|
|
3957
|
+
* A prop that several members of a union declare takes what any of them allows: its type is
|
|
3958
|
+
* the union of theirs, and so are its values when each declares a set of string literals.
|
|
3959
|
+
*/
|
|
3960
|
+
function describeProp$1(context, name, symbols, location, required, origin) {
|
|
3378
3961
|
const { checker } = context;
|
|
3379
|
-
const
|
|
3380
|
-
const
|
|
3962
|
+
const allows = (symbol) => !(checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(symbol, location)).flags & ts.TypeFlags.Never);
|
|
3963
|
+
const members = symbols.some(allows) ? symbols.filter(allows) : symbols;
|
|
3964
|
+
const printed = /* @__PURE__ */ new Map();
|
|
3965
|
+
let values = [];
|
|
3966
|
+
for (const symbol of members) {
|
|
3967
|
+
const declared = checker.getTypeOfSymbolAtLocation(symbol, location);
|
|
3968
|
+
const type = checker.getNonNullableType(declared);
|
|
3969
|
+
const text = unwrapParens(checker.typeToString(declared, void 0, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope).replace(/^(?:(?:undefined|null) \| )+|(?: \| (?:undefined|null))+$/g, ""));
|
|
3970
|
+
if (text) printed.set(text, !type.isUnion() && (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0));
|
|
3971
|
+
const found = literalValues$2(type);
|
|
3972
|
+
values = found && values ? [...values, ...found] : void 0;
|
|
3973
|
+
}
|
|
3381
3974
|
const prop = {
|
|
3382
3975
|
name,
|
|
3383
|
-
type: truncate(
|
|
3976
|
+
type: truncate([...printed].map(([text, fn]) => fn && printed.size > 1 ? `(${text})` : text).join(" | ") || "unknown", 200),
|
|
3384
3977
|
required,
|
|
3385
3978
|
kind: "prop"
|
|
3386
3979
|
};
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
const description = ts.displayPartsToString(
|
|
3980
|
+
if (values) prop.values = unique(values);
|
|
3981
|
+
const documented = members.find((s) => s.getDocumentationComment(checker).length) ?? members[0];
|
|
3982
|
+
const description = documented ? ts.displayPartsToString(documented.getDocumentationComment(checker)).trim() : "";
|
|
3390
3983
|
if (description) prop.description = description;
|
|
3391
|
-
for (const tag of
|
|
3984
|
+
for (const tag of documented?.getJsDocTags(checker) ?? []) {
|
|
3392
3985
|
const text = ts.displayPartsToString(tag.text).trim();
|
|
3393
3986
|
if ((tag.name === "default" || tag.name === "defaultValue") && text) prop.default = text;
|
|
3394
3987
|
if (tag.name === "deprecated") prop.deprecated = text || true;
|
|
@@ -3482,11 +4075,47 @@ function mergeVariants(variants) {
|
|
|
3482
4075
|
}
|
|
3483
4076
|
return [...merged.values()];
|
|
3484
4077
|
}
|
|
3485
|
-
/**
|
|
3486
|
-
function
|
|
4078
|
+
/** The layout of each variant a cva/tv definition declares, read as `parseDefinition` reads it. */
|
|
4079
|
+
function variantLayouts(definition) {
|
|
4080
|
+
const layouts = /* @__PURE__ */ new Map();
|
|
4081
|
+
const [first, second] = definition.node.arguments;
|
|
4082
|
+
const config = second ?? first;
|
|
4083
|
+
if (!config || !ts.isObjectLiteralExpression(config)) return layouts;
|
|
4084
|
+
const named = (name) => config.properties.find((p) => !ts.isSpreadAssignment(p) && propertyName(p.name) === name);
|
|
4085
|
+
const variants = named("variants");
|
|
4086
|
+
if (!variants || !ts.isPropertyAssignment(variants)) return layouts;
|
|
4087
|
+
if (!ts.isObjectLiteralExpression(variants.initializer)) return layouts;
|
|
4088
|
+
const extended = named("extend") !== void 0;
|
|
4089
|
+
for (const prop of variants.initializer.properties) {
|
|
4090
|
+
if (!ts.isPropertyAssignment(prop) || !ts.isObjectLiteralExpression(prop.initializer)) continue;
|
|
4091
|
+
const name = propertyName(prop.name);
|
|
4092
|
+
if (name === void 0) continue;
|
|
4093
|
+
const layout = extended ? [void 0] : [];
|
|
4094
|
+
for (const option of prop.initializer.properties) layout.push(ts.isPropertyAssignment(option) ? propertyName(option.name) : void 0);
|
|
4095
|
+
layouts.set(name, layout);
|
|
4096
|
+
}
|
|
4097
|
+
return layouts;
|
|
4098
|
+
}
|
|
4099
|
+
/** `values` in a layout's order, those it does not list where it takes them (or last). */
|
|
4100
|
+
function ordered(values, layout) {
|
|
4101
|
+
const unlisted = values.filter((v) => !layout.includes(v));
|
|
4102
|
+
const placed = layout.flatMap((key) => key === void 0 ? unlisted : values.includes(key) ? [key] : []);
|
|
4103
|
+
return unique(layout.includes(void 0) ? placed : [...placed, ...unlisted]);
|
|
4104
|
+
}
|
|
4105
|
+
/**
|
|
4106
|
+
* Adds or enriches variant props from cva/tv definitions (works without type info). A prop the
|
|
4107
|
+
* checker types as a set of strings keeps that set: the literal can take values it does not list
|
|
4108
|
+
* (`{ ...base, brand }`, tv's `extend`) and only gives their order. Without one, the literal's
|
|
4109
|
+
* keys are the values, unless it does not list them all: then the prop takes any value (or, when
|
|
4110
|
+
* the keys it lists are `true`/`false`, stays a boolean).
|
|
4111
|
+
*/
|
|
4112
|
+
function mergeVariantProps(props, variants, layouts) {
|
|
3487
4113
|
for (const variant of variants) {
|
|
3488
|
-
const
|
|
4114
|
+
const layout = layouts.get(variant.name) ?? variant.values;
|
|
4115
|
+
const open = layout.includes(void 0);
|
|
4116
|
+
const isBoolean = (variant.values.length > 0 || !open) && variant.values.every((v) => v === "true" || v === "false");
|
|
3489
4117
|
const existing = props.find((p) => p.name === variant.name);
|
|
4118
|
+
const typed = existing?.values;
|
|
3490
4119
|
const prop = existing ?? {
|
|
3491
4120
|
name: variant.name,
|
|
3492
4121
|
type: isBoolean ? "boolean" : variant.values.map((v) => JSON.stringify(v)).join(" | "),
|
|
@@ -3494,7 +4123,13 @@ function mergeVariantProps(props, variants) {
|
|
|
3494
4123
|
kind: "variant"
|
|
3495
4124
|
};
|
|
3496
4125
|
prop.kind = "variant";
|
|
3497
|
-
if (
|
|
4126
|
+
if (typed?.length) {
|
|
4127
|
+
prop.values = ordered(typed, layout);
|
|
4128
|
+
prop.type = prop.values.map((v) => JSON.stringify(v)).join(" | ");
|
|
4129
|
+
variant.values = [...prop.values];
|
|
4130
|
+
} else if (open && !isBoolean) {
|
|
4131
|
+
if (!existing) prop.type = "string";
|
|
4132
|
+
} else if (!isBoolean) {
|
|
3498
4133
|
prop.values = [...variant.values];
|
|
3499
4134
|
prop.type = variant.values.map((v) => JSON.stringify(v)).join(" | ");
|
|
3500
4135
|
}
|
|
@@ -4346,6 +4981,17 @@ function walk$1(root, enter) {
|
|
|
4346
4981
|
}
|
|
4347
4982
|
}
|
|
4348
4983
|
const VARIANT_FUNCTIONS = /^(cva|tv)$/;
|
|
4984
|
+
/** How many names deep a class value is followed: `className={tone}` with `const tone = base`. */
|
|
4985
|
+
const MAX_FOLLOW = 4;
|
|
4986
|
+
/** The node around `node`, or undefined at the file. */
|
|
4987
|
+
function parentScope(node) {
|
|
4988
|
+
return ts.isSourceFile(node) ? void 0 : node.parent;
|
|
4989
|
+
}
|
|
4990
|
+
/** Whether a declared name (`tone`, or one in `{ tone, size }`) binds `name`. */
|
|
4991
|
+
function binds(declared, name) {
|
|
4992
|
+
if (ts.isIdentifier(declared)) return declared.text === name;
|
|
4993
|
+
return declared.elements.some((e) => !ts.isOmittedExpression(e) && binds(e.name, name));
|
|
4994
|
+
}
|
|
4349
4995
|
function analyze(sourceFile) {
|
|
4350
4996
|
const analysis = {
|
|
4351
4997
|
imports: /* @__PURE__ */ new Map(),
|
|
@@ -4394,10 +5040,100 @@ function analyze(sourceFile) {
|
|
|
4394
5040
|
for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]);
|
|
4395
5041
|
}
|
|
4396
5042
|
};
|
|
5043
|
+
/**
|
|
5044
|
+
* The `const` value or function a name refers to where it is used, declared in this file and
|
|
5045
|
+
* in scope there: undefined for a parameter, an import, a `let` or anything else that can
|
|
5046
|
+
* hold another value.
|
|
5047
|
+
*/
|
|
5048
|
+
const declarationOf = (use) => {
|
|
5049
|
+
const name = use.text;
|
|
5050
|
+
for (let node = use.parent; node; node = parentScope(node)) {
|
|
5051
|
+
if (ts.isFunctionLike(node) && node.parameters.some((p) => binds(p.name, name))) return;
|
|
5052
|
+
if (ts.isCatchClause(node) && node.variableDeclaration) {
|
|
5053
|
+
if (binds(node.variableDeclaration.name, name)) return;
|
|
5054
|
+
}
|
|
5055
|
+
if ((ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node)) && node.initializer && ts.isVariableDeclarationList(node.initializer) && node.initializer.declarations.some((d) => binds(d.name, name))) return;
|
|
5056
|
+
const statements = ts.isBlock(node) || ts.isSourceFile(node) || ts.isModuleBlock(node) || ts.isCaseClause(node) || ts.isDefaultClause(node) ? node.statements : void 0;
|
|
5057
|
+
for (const statement of statements ?? []) {
|
|
5058
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === name) return statement;
|
|
5059
|
+
if ((ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement)) && statement.name?.text === name) return;
|
|
5060
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
5061
|
+
const list = statement.declarationList;
|
|
5062
|
+
const declaration = list.declarations.find((d) => binds(d.name, name));
|
|
5063
|
+
if (!declaration) continue;
|
|
5064
|
+
return (list.flags & ts.NodeFlags.Const) !== 0 && ts.isIdentifier(declaration.name) ? declaration.initializer : void 0;
|
|
5065
|
+
}
|
|
5066
|
+
}
|
|
5067
|
+
};
|
|
5068
|
+
/** A same-file `const` value: `"bg-green-50"`, a condition, an object map. */
|
|
5069
|
+
const constValue = (use) => {
|
|
5070
|
+
const declaration = declarationOf(use);
|
|
5071
|
+
return declaration && !ts.isFunctionDeclaration(declaration) ? declaration : void 0;
|
|
5072
|
+
};
|
|
5073
|
+
/** The object literals a value can be: a `const` map, or a map in one (`styles.badge`). */
|
|
5074
|
+
const objectsOf = (expr, depth) => {
|
|
5075
|
+
if (depth > MAX_FOLLOW) return [];
|
|
5076
|
+
return valueBranches(expr).flatMap((leaf) => {
|
|
5077
|
+
if (ts.isObjectLiteralExpression(leaf)) return [leaf];
|
|
5078
|
+
if (ts.isIdentifier(leaf)) {
|
|
5079
|
+
const value = constValue(leaf);
|
|
5080
|
+
return value ? objectsOf(value, depth + 1) : [];
|
|
5081
|
+
}
|
|
5082
|
+
if (ts.isPropertyAccessExpression(leaf) || ts.isElementAccessExpression(leaf)) return entriesOf(leaf, depth).flatMap((value) => objectsOf(value, depth + 1));
|
|
5083
|
+
return [];
|
|
5084
|
+
});
|
|
5085
|
+
};
|
|
5086
|
+
/** What a key reads from a same-file map: `styles.badge`, or every value of `styles[status]`. */
|
|
5087
|
+
const entriesOf = (access, depth) => {
|
|
5088
|
+
const key = ts.isPropertyAccessExpression(access) ? access.name.text : ts.isStringLiteralLike(access.argumentExpression) || ts.isNumericLiteral(access.argumentExpression) ? access.argumentExpression.text : void 0;
|
|
5089
|
+
return objectsOf(access.expression, depth + 1).flatMap((object) => object.properties.flatMap((prop) => ts.isPropertyAssignment(prop) && (key === void 0 || propertyName(prop.name) === key) ? [prop.initializer] : []));
|
|
5090
|
+
};
|
|
5091
|
+
/** What a call to a function of this file returns: `toneOf(status)`. */
|
|
5092
|
+
const returnsOf = (call) => {
|
|
5093
|
+
if (!ts.isIdentifier(call.expression)) return [];
|
|
5094
|
+
const declaration = declarationOf(call.expression);
|
|
5095
|
+
if (declaration && ts.isFunctionDeclaration(declaration)) return returnedExpressions(declaration);
|
|
5096
|
+
const fn = declaration && asFunction(declaration);
|
|
5097
|
+
return fn ? returnedExpressions(fn) : [];
|
|
5098
|
+
};
|
|
5099
|
+
const followed = /* @__PURE__ */ new Set();
|
|
5100
|
+
const follow = (value, element, depth) => {
|
|
5101
|
+
if (followed.has(value)) return;
|
|
5102
|
+
followed.add(value);
|
|
5103
|
+
classValue(value, element, depth + 1);
|
|
5104
|
+
};
|
|
5105
|
+
/**
|
|
5106
|
+
* The classes a class value holds where it is not written out: a `const` (`className={tone}`,
|
|
5107
|
+
* `cn("p-4", extra)`), a key of a `const` map (`statusStyles[status]`), what a function of
|
|
5108
|
+
* this file returns (`toneOf(status)`), and the parts of a template or a concatenation,
|
|
5109
|
+
* through conditions. Strings written out in it are collected as well, once.
|
|
5110
|
+
*/
|
|
5111
|
+
const classValue = (expr, element, depth) => {
|
|
5112
|
+
if (depth > MAX_FOLLOW) return;
|
|
5113
|
+
for (const leaf of valueBranches(expr)) if (ts.isStringLiteralLike(leaf) || ts.isObjectLiteralExpression(leaf)) collectStrings(leaf, element);
|
|
5114
|
+
else if (ts.isTemplateExpression(leaf)) {
|
|
5115
|
+
collectStrings(leaf, element);
|
|
5116
|
+
for (const span of leaf.templateSpans) classValue(span.expression, element, depth);
|
|
5117
|
+
} else if (ts.isBinaryExpression(leaf) && leaf.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
5118
|
+
classValue(leaf.left, element, depth);
|
|
5119
|
+
classValue(leaf.right, element, depth);
|
|
5120
|
+
} else if (ts.isArrayLiteralExpression(leaf)) for (const item of leaf.elements) classValue(ts.isSpreadElement(item) ? item.expression : item, element, depth);
|
|
5121
|
+
else if (ts.isIdentifier(leaf)) {
|
|
5122
|
+
const value = constValue(leaf);
|
|
5123
|
+
if (value) follow(value, element, depth);
|
|
5124
|
+
} else if (ts.isPropertyAccessExpression(leaf) || ts.isElementAccessExpression(leaf)) for (const value of entriesOf(leaf, depth)) follow(value, element, depth);
|
|
5125
|
+
else if (ts.isCallExpression(leaf)) {
|
|
5126
|
+
const callee = leaf.expression.getText(sourceFile);
|
|
5127
|
+
if (CLASS_FUNCTIONS.test(callee)) {
|
|
5128
|
+
if (depth === 0 && !VARIANT_FUNCTIONS.test(callee)) for (const arg of leaf.arguments) classValue(arg, element, depth);
|
|
5129
|
+
} else for (const value of returnsOf(leaf)) follow(value, element, depth);
|
|
5130
|
+
}
|
|
5131
|
+
};
|
|
4397
5132
|
/** A class value, or an object of them: tv slots, and variant options that style slots. */
|
|
4398
5133
|
const collectClassValues = (node) => {
|
|
4399
5134
|
if (!ts.isObjectLiteralExpression(node)) {
|
|
4400
5135
|
collectStrings(node);
|
|
5136
|
+
classValue(node, void 0, 0);
|
|
4401
5137
|
return;
|
|
4402
5138
|
}
|
|
4403
5139
|
for (const prop of node.properties) if (ts.isPropertyAssignment(prop)) collectClassValues(prop.initializer);
|
|
@@ -4462,7 +5198,10 @@ function analyze(sourceFile) {
|
|
|
4462
5198
|
const name = attribute.name.getText(sourceFile);
|
|
4463
5199
|
const init = attribute.initializer;
|
|
4464
5200
|
if (!init) continue;
|
|
4465
|
-
if (name === "className" || name === "class")
|
|
5201
|
+
if (name === "className" || name === "class") {
|
|
5202
|
+
collectStrings(init, element);
|
|
5203
|
+
if (ts.isJsxExpression(init) && init.expression) classValue(init.expression, element, 0);
|
|
5204
|
+
}
|
|
4466
5205
|
if (name === "style" && ts.isJsxExpression(init) && init.expression) for (const object of styleObjects(init.expression)) analysis.styles.push({
|
|
4467
5206
|
element,
|
|
4468
5207
|
object
|
|
@@ -4473,7 +5212,10 @@ function analyze(sourceFile) {
|
|
|
4473
5212
|
const variants = VARIANT_FUNCTIONS.test(node.expression.getText(sourceFile));
|
|
4474
5213
|
node.arguments.forEach((arg) => {
|
|
4475
5214
|
if (variants && ts.isObjectLiteralExpression(arg)) collectVariantConfig(arg);
|
|
4476
|
-
else
|
|
5215
|
+
else {
|
|
5216
|
+
collectStrings(arg);
|
|
5217
|
+
classValue(arg, void 0, 0);
|
|
5218
|
+
}
|
|
4477
5219
|
});
|
|
4478
5220
|
}
|
|
4479
5221
|
return true;
|
|
@@ -4544,6 +5286,7 @@ function valueBranches(expr) {
|
|
|
4544
5286
|
* style functions (`style={({ isPressed }) => ({ ... })}`) are read through this.
|
|
4545
5287
|
*/
|
|
4546
5288
|
function returnedExpressions(fn) {
|
|
5289
|
+
if (!fn.body) return [];
|
|
4547
5290
|
if (!ts.isBlock(fn.body)) return [fn.body];
|
|
4548
5291
|
const out = [];
|
|
4549
5292
|
const stack = [fn.body];
|
|
@@ -4583,7 +5326,7 @@ function resolveElement(element, analysis, target, file) {
|
|
|
4583
5326
|
const [head = "", ...rest] = tag.split(".");
|
|
4584
5327
|
const binding = analysis.imports.get(head);
|
|
4585
5328
|
if (binding?.imported === "*" && rest.length) {
|
|
4586
|
-
if (!target.
|
|
5329
|
+
if (!target.checksImport(binding.source, file)) return { kind: "external" };
|
|
4587
5330
|
const name = rest.join(".");
|
|
4588
5331
|
const library = target.libraryOf(binding.source);
|
|
4589
5332
|
if (library) return fromLibrary(target, library, name, binding.source, rest.length > 1);
|
|
@@ -4610,7 +5353,7 @@ function resolveElement(element, analysis, target, file) {
|
|
|
4610
5353
|
if (resolved.kind !== "component") return resolved;
|
|
4611
5354
|
owner = resolved.component;
|
|
4612
5355
|
} else if (binding) {
|
|
4613
|
-
if (!target.
|
|
5356
|
+
if (!target.checksImport(binding.source, file)) return { kind: "external" };
|
|
4614
5357
|
const imported = binding.imported === "default" ? target.defaultExport(binding.source, file)?.name ?? head : binding.imported;
|
|
4615
5358
|
owner = target.componentFor(imported, binding.source, file);
|
|
4616
5359
|
if (!owner && target.components.has(imported)) return { kind: "external" };
|
|
@@ -5007,6 +5750,11 @@ const TAILWIND_PALETTE = {
|
|
|
5007
5750
|
950: "oklch(14.7% 0.004 49.3)"
|
|
5008
5751
|
}
|
|
5009
5752
|
};
|
|
5753
|
+
/** Tailwind's colors without shades: `white` and `black`. */
|
|
5754
|
+
const TAILWIND_KEYWORD_COLORS = {
|
|
5755
|
+
black: "#000",
|
|
5756
|
+
white: "#fff"
|
|
5757
|
+
};
|
|
5010
5758
|
//#endregion
|
|
5011
5759
|
//#region src/tokens/css.ts
|
|
5012
5760
|
/** Tailwind v4 theme namespaces → token category. Longest prefixes first. */
|
|
@@ -5596,7 +6344,7 @@ function parseDtcgTokens(text, file, options = {}) {
|
|
|
5596
6344
|
const type = typeOf(t);
|
|
5597
6345
|
const format = (value) => {
|
|
5598
6346
|
const resolved = resolve(value, /* @__PURE__ */ new Set([path]));
|
|
5599
|
-
return t.own?.channels === "hsl" && hslChannels(resolved) || formatValue(resolved, type);
|
|
6347
|
+
return t.own?.channels === "hsl" && hslChannels(resolved) || t.own?.channels === "rgb" && rgbChannels(resolved) || formatValue(resolved, type);
|
|
5600
6348
|
};
|
|
5601
6349
|
const segments = [options.prefix, ...t.path.filter((p) => p !== "$root")].filter(Boolean);
|
|
5602
6350
|
const generated = segments.length ? `--${segments.join("-").replace(/[^\w-]+/g, "-")}` : void 0;
|
|
@@ -5751,7 +6499,7 @@ function onsystemExtension(extensions) {
|
|
|
5751
6499
|
if (typeof own.name === "string" && /^[^\s{}]+$/.test(own.name)) out.name = own.name;
|
|
5752
6500
|
if (typeof own.cssVar === "string" && /^--[\w-]+$/.test(own.cssVar)) out.cssVar = own.cssVar;
|
|
5753
6501
|
if (typeof own.tailwind === "string" && themeVariable(own.tailwind)) out.tailwind = own.tailwind;
|
|
5754
|
-
if (own.channels === "hsl") out.channels =
|
|
6502
|
+
if (own.channels === "hsl" || own.channels === "rgb") out.channels = own.channels;
|
|
5755
6503
|
if (isObject$2(own.modes)) out.modes = own.modes;
|
|
5756
6504
|
return out;
|
|
5757
6505
|
}
|
|
@@ -5791,6 +6539,13 @@ function hslChannels(value) {
|
|
|
5791
6539
|
if (h === void 0 || s === void 0 || l === void 0) return void 0;
|
|
5792
6540
|
return `${h} ${s}% ${l}%${typeof value.alpha === "number" && value.alpha < 1 ? ` / ${value.alpha}` : ""}`;
|
|
5793
6541
|
}
|
|
6542
|
+
/** `22 163 74`: an sRGB color as the bare channels a Tailwind v3 custom property holds. */
|
|
6543
|
+
function rgbChannels(value) {
|
|
6544
|
+
if (!isObject$2(value) || value.colorSpace !== "srgb" || !Array.isArray(value.components)) return;
|
|
6545
|
+
const [r, g, b] = value.components.map((c) => typeof c === "number" ? String(Math.round(c * 255)) : void 0);
|
|
6546
|
+
if (r === void 0 || g === void 0 || b === void 0) return void 0;
|
|
6547
|
+
return `${r} ${g} ${b}${typeof value.alpha === "number" && value.alpha < 1 ? ` / ${value.alpha}` : ""}`;
|
|
6548
|
+
}
|
|
5794
6549
|
function formatValue(value, type) {
|
|
5795
6550
|
if (typeof value === "string") return value;
|
|
5796
6551
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
@@ -5974,6 +6729,24 @@ function isStatusToken(token) {
|
|
|
5974
6729
|
function tokenKey(token) {
|
|
5975
6730
|
return (token.tailwind || token.name).toLowerCase().replace(/[.\s_]+/g, "-").replace(/^colou?rs?-/, "");
|
|
5976
6731
|
}
|
|
6732
|
+
/** Name segments of a line color: `card-border`, `input`, `ring`. */
|
|
6733
|
+
const LINE_WORDS = [
|
|
6734
|
+
"border",
|
|
6735
|
+
"input",
|
|
6736
|
+
"ring",
|
|
6737
|
+
"outline",
|
|
6738
|
+
"divider",
|
|
6739
|
+
"separator"
|
|
6740
|
+
];
|
|
6741
|
+
/** Name segments of a surface itself, as opposed to `muted`, which also describes text. */
|
|
6742
|
+
const SURFACE_NOUNS = [
|
|
6743
|
+
"background",
|
|
6744
|
+
"bg",
|
|
6745
|
+
"surface",
|
|
6746
|
+
"canvas",
|
|
6747
|
+
"card",
|
|
6748
|
+
"popover"
|
|
6749
|
+
];
|
|
5977
6750
|
/** Name segments that say what a token is for, checked in this order (`card-border` is a border). */
|
|
5978
6751
|
const KIND_WORDS = [
|
|
5979
6752
|
["foreground", [
|
|
@@ -5981,21 +6754,9 @@ const KIND_WORDS = [
|
|
|
5981
6754
|
"fg",
|
|
5982
6755
|
"text"
|
|
5983
6756
|
]],
|
|
5984
|
-
["line",
|
|
5985
|
-
"border",
|
|
5986
|
-
"input",
|
|
5987
|
-
"ring",
|
|
5988
|
-
"outline",
|
|
5989
|
-
"divider",
|
|
5990
|
-
"separator"
|
|
5991
|
-
]],
|
|
6757
|
+
["line", LINE_WORDS],
|
|
5992
6758
|
["surface", [
|
|
5993
|
-
|
|
5994
|
-
"bg",
|
|
5995
|
-
"surface",
|
|
5996
|
-
"canvas",
|
|
5997
|
-
"card",
|
|
5998
|
-
"popover",
|
|
6759
|
+
...SURFACE_NOUNS,
|
|
5999
6760
|
"muted",
|
|
6000
6761
|
"secondary",
|
|
6001
6762
|
"accent"
|
|
@@ -6014,9 +6775,17 @@ function roleKey(token) {
|
|
|
6014
6775
|
/** What a token is for, from its name: shadcn/ui's conventions and common DTCG names. */
|
|
6015
6776
|
function kindOf(key) {
|
|
6016
6777
|
const segments = key.split("-");
|
|
6778
|
+
if (segments[0] === "on") return "foreground";
|
|
6779
|
+
const namesLineOrSurface = (s) => LINE_WORDS.includes(s) || SURFACE_NOUNS.includes(s);
|
|
6780
|
+
if (segments[0] === "content" && !segments.some(namesLineOrSurface)) return "foreground";
|
|
6017
6781
|
for (const [kind, words] of KIND_WORDS) if (segments.some((s) => words.includes(s))) return kind;
|
|
6018
6782
|
return "other";
|
|
6019
6783
|
}
|
|
6784
|
+
/** The role a token is made for, from its name (`text` for `content-muted`); undefined for `brand` or `destructive`. */
|
|
6785
|
+
function tokenRole(token) {
|
|
6786
|
+
const kind = kindOf(roleKey(token));
|
|
6787
|
+
return kind === "foreground" ? "text" : kind === "other" ? void 0 : kind;
|
|
6788
|
+
}
|
|
6020
6789
|
/** Tokens made for each role, in the order to prefer them when their values tie. */
|
|
6021
6790
|
const PREFERRED = {
|
|
6022
6791
|
text: ["foreground", "muted-foreground"],
|
|
@@ -6137,7 +6906,7 @@ function toDtcg(tokens) {
|
|
|
6137
6906
|
if (token.cssVar) extension.cssVar = token.cssVar;
|
|
6138
6907
|
const tailwind = tailwindVariable(token);
|
|
6139
6908
|
if (tailwind) extension.tailwind = tailwind;
|
|
6140
|
-
if (own.channels) extension.channels =
|
|
6909
|
+
if (own.channels) extension.channels = own.channels;
|
|
6141
6910
|
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
6911
|
if (Object.keys(extension).length) node.$extensions = { [ONSYSTEM_EXTENSION]: extension };
|
|
6143
6912
|
insert(tree, path, node);
|
|
@@ -6259,14 +7028,17 @@ function typed(value, token, lookup) {
|
|
|
6259
7028
|
}
|
|
6260
7029
|
}
|
|
6261
7030
|
function colorValue(value) {
|
|
6262
|
-
if (!parseColor(value, {
|
|
6263
|
-
|
|
6264
|
-
|
|
7031
|
+
if (!parseColor(value, {
|
|
7032
|
+
bareHsl: true,
|
|
7033
|
+
bareRgb: true
|
|
7034
|
+
})) return { value };
|
|
7035
|
+
const channels = isBareHsl(value) ? "hsl" : isBareRgb(value) ? "rgb" : void 0;
|
|
7036
|
+
const color = colorObject(channels ? `${channels}(${value.replace(/,/g, " ")})` : value);
|
|
6265
7037
|
if (!color) return { value };
|
|
6266
|
-
return
|
|
7038
|
+
return channels ? {
|
|
6267
7039
|
type: "color",
|
|
6268
7040
|
value: color,
|
|
6269
|
-
channels
|
|
7041
|
+
channels
|
|
6270
7042
|
} : {
|
|
6271
7043
|
type: "color",
|
|
6272
7044
|
value: color
|
|
@@ -6705,8 +7477,11 @@ function frontMatterValue(token, key, lookup) {
|
|
|
6705
7477
|
const value = token.value.trim();
|
|
6706
7478
|
switch (key) {
|
|
6707
7479
|
case "colors":
|
|
6708
|
-
if (!parseColor(value, {
|
|
6709
|
-
|
|
7480
|
+
if (!parseColor(value, {
|
|
7481
|
+
bareHsl: true,
|
|
7482
|
+
bareRgb: true
|
|
7483
|
+
})) return void 0;
|
|
7484
|
+
return isBareHsl(value) ? `hsl(${value})` : isBareRgb(value) ? `rgb(${value})` : value;
|
|
6710
7485
|
case "rounded":
|
|
6711
7486
|
case "spacing": {
|
|
6712
7487
|
if (DESIGN_DIMENSION.test(value) || value === "0") return value;
|
|
@@ -6870,6 +7645,11 @@ const ROUNDED_FULL = {
|
|
|
6870
7645
|
line: 0
|
|
6871
7646
|
}
|
|
6872
7647
|
};
|
|
7648
|
+
/** A color token's value: bare HSL or RGB channels are its color (Tailwind v3). */
|
|
7649
|
+
const TOKEN_COLOR = {
|
|
7650
|
+
bareHsl: true,
|
|
7651
|
+
bareRgb: true
|
|
7652
|
+
};
|
|
6873
7653
|
/** Query helpers over the token list: lookups and nearest-token search. */
|
|
6874
7654
|
var TokenIndex = class {
|
|
6875
7655
|
tokens;
|
|
@@ -6899,7 +7679,7 @@ var TokenIndex = class {
|
|
|
6899
7679
|
const resolve = (name) => this.byVar.get(name)?.value;
|
|
6900
7680
|
let unit;
|
|
6901
7681
|
for (const token of tokens) if (token.category === "color") {
|
|
6902
|
-
const color = parseColor(token.value,
|
|
7682
|
+
const color = parseColor(token.value, TOKEN_COLOR);
|
|
6903
7683
|
if (color) this.colors.push({
|
|
6904
7684
|
token,
|
|
6905
7685
|
color,
|
|
@@ -6988,20 +7768,38 @@ var TokenIndex = class {
|
|
|
6988
7768
|
candidate: named.candidate,
|
|
6989
7769
|
distance: named.distance
|
|
6990
7770
|
};
|
|
6991
|
-
if (match)
|
|
6992
|
-
match:
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
7771
|
+
if (match) {
|
|
7772
|
+
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];
|
|
7773
|
+
return {
|
|
7774
|
+
match: nearest,
|
|
7775
|
+
nearest,
|
|
7776
|
+
sameValue,
|
|
7777
|
+
closeStatus: status && {
|
|
7778
|
+
candidate: status.candidate,
|
|
7779
|
+
distance: status.distance
|
|
7780
|
+
}
|
|
7781
|
+
};
|
|
7782
|
+
}
|
|
6996
7783
|
if (!named.hue && named.status && named.looseHue && tinted) return {
|
|
6997
7784
|
nearest,
|
|
6998
7785
|
reason: "status",
|
|
6999
7786
|
hueOff: Math.round(hueDistance(color, valueOf(named.candidate))),
|
|
7000
7787
|
sameValue
|
|
7001
7788
|
};
|
|
7789
|
+
const other = pool.filter((s) => s.hue && !s.foreign && s.fit.tier === 2 && s.distance < .1).sort(byDistance)[0];
|
|
7790
|
+
const reason = !named.hue ? "hue" : named.distance >= .1 ? other ? "role" : "far" : named.foreign ? "scoped" : "role";
|
|
7791
|
+
if (reason === "role" && other && other !== named) return {
|
|
7792
|
+
nearest,
|
|
7793
|
+
reason,
|
|
7794
|
+
otherRole: {
|
|
7795
|
+
candidate: other.candidate,
|
|
7796
|
+
distance: other.distance
|
|
7797
|
+
},
|
|
7798
|
+
sameValue
|
|
7799
|
+
};
|
|
7002
7800
|
return {
|
|
7003
7801
|
nearest,
|
|
7004
|
-
reason
|
|
7802
|
+
reason,
|
|
7005
7803
|
sameValue
|
|
7006
7804
|
};
|
|
7007
7805
|
}
|
|
@@ -7057,7 +7855,7 @@ var TokenIndex = class {
|
|
|
7057
7855
|
function colorModes(token) {
|
|
7058
7856
|
const modes = {};
|
|
7059
7857
|
for (const [mode, value] of Object.entries(token.modes ?? {})) {
|
|
7060
|
-
const color = parseColor(value,
|
|
7858
|
+
const color = parseColor(value, TOKEN_COLOR);
|
|
7061
7859
|
if (color) modes[mode] = color;
|
|
7062
7860
|
}
|
|
7063
7861
|
return Object.keys(modes).length ? { modes } : {};
|
|
@@ -7551,6 +8349,16 @@ var LintTarget = class {
|
|
|
7551
8349
|
return this.importPrefixes.some((prefix) => specifier.startsWith(prefix) || specifier === prefix.slice(0, -1));
|
|
7552
8350
|
}
|
|
7553
8351
|
/**
|
|
8352
|
+
* Whether the component rules judge the components imported from `specifier`: it points at
|
|
8353
|
+
* the design system, and the model has components to judge them by. A model with none (an
|
|
8354
|
+
* app whose design-system package publishes only compiled `dist/`) still matches the
|
|
8355
|
+
* configured `importPath`, but the component rules do not run there, as `check` says, so
|
|
8356
|
+
* what comes from it is not called invented.
|
|
8357
|
+
*/
|
|
8358
|
+
checksImport(specifier, fromFile = "snippet.tsx") {
|
|
8359
|
+
return this.model.components.length > 0 && this.isDesignSystemImport(specifier, fromFile);
|
|
8360
|
+
}
|
|
8361
|
+
/**
|
|
7554
8362
|
* Whether `name`, imported from the design system, may be a real export the
|
|
7555
8363
|
* model left out rather than an invented one: its module exists outside the
|
|
7556
8364
|
* folders extraction read and exports `name`, or re-exports from such a
|
|
@@ -7798,6 +8606,7 @@ const preferDesignSystemComponent = {
|
|
|
7798
8606
|
}
|
|
7799
8607
|
const component = context.target.elements.get(key);
|
|
7800
8608
|
if (!component) continue;
|
|
8609
|
+
if (fromRegistry(component, context) && context.target.config.elements[key] === void 0) continue;
|
|
7801
8610
|
if (hiddenFromUser(element)) continue;
|
|
7802
8611
|
if (styledByParent(element, context)) continue;
|
|
7803
8612
|
if (isBare(element)) continue;
|
|
@@ -7815,6 +8624,14 @@ const preferDesignSystemComponent = {
|
|
|
7815
8624
|
}
|
|
7816
8625
|
}
|
|
7817
8626
|
};
|
|
8627
|
+
/**
|
|
8628
|
+
* Whether `component` is in the folder of a shadcn registry that components.json names
|
|
8629
|
+
* (`components/signoff-ui`): a read-only design system, ranked after the others.
|
|
8630
|
+
*/
|
|
8631
|
+
function fromRegistry(component, context) {
|
|
8632
|
+
const file = component.source.file;
|
|
8633
|
+
return (context.target.config.designSystems ?? []).some((source) => source.registry !== void 0 && source.components.some((g) => matchesGlob(file, g)));
|
|
8634
|
+
}
|
|
7818
8635
|
const definitionsByFile = /* @__PURE__ */ new WeakMap();
|
|
7819
8636
|
/** Whether the element's `className` calls a `cva(...)` or `tv(...)` definition declared in this file. */
|
|
7820
8637
|
function styledByOwnVariants(element, context) {
|
|
@@ -7969,7 +8786,10 @@ const noUnknownComponent = {
|
|
|
7969
8786
|
}
|
|
7970
8787
|
}
|
|
7971
8788
|
};
|
|
7972
|
-
/**
|
|
8789
|
+
/**
|
|
8790
|
+
* Prop names from other libraries → the conventional name in shadcn-style systems, which mean the
|
|
8791
|
+
* same thing there, so the rename is a fix.
|
|
8792
|
+
*/
|
|
7973
8793
|
const PROP_SYNONYMS = {
|
|
7974
8794
|
variant: [
|
|
7975
8795
|
"tone",
|
|
@@ -7999,20 +8819,20 @@ const PROP_SYNONYMS = {
|
|
|
7999
8819
|
"show",
|
|
8000
8820
|
"shown"
|
|
8001
8821
|
],
|
|
8002
|
-
onOpenChange: [
|
|
8003
|
-
"onClose",
|
|
8004
|
-
"onDismiss",
|
|
8005
|
-
"onToggle",
|
|
8006
|
-
"onOpen",
|
|
8007
|
-
"onVisibleChange"
|
|
8008
|
-
],
|
|
8009
|
-
asChild: [
|
|
8010
|
-
"as",
|
|
8011
|
-
"component",
|
|
8012
|
-
"render"
|
|
8013
|
-
]
|
|
8822
|
+
onOpenChange: ["onVisibleChange"]
|
|
8014
8823
|
};
|
|
8015
8824
|
/**
|
|
8825
|
+
* Handlers other libraries call on one side of a change, against `onOpenChange`, which runs on
|
|
8826
|
+
* open and on close with the new state: renamed, `onClose={() => setOpen(false)}` would keep a
|
|
8827
|
+
* dialog shut. The message shows the check to add instead of offering the rename.
|
|
8828
|
+
*/
|
|
8829
|
+
const OPEN_CHANGE_HANDLERS = /* @__PURE__ */ new Map([
|
|
8830
|
+
["onClose", "{ if (!open) … }"],
|
|
8831
|
+
["onDismiss", "{ if (!open) … }"],
|
|
8832
|
+
["onOpen", "{ if (open) … }"],
|
|
8833
|
+
["onToggle", "…"]
|
|
8834
|
+
]);
|
|
8835
|
+
/**
|
|
8016
8836
|
* Props that mean the same thing in different conventions: native elements and Radix on one
|
|
8017
8837
|
* side, React Aria Components on the other. A native field states `isInvalid` as `aria-invalid`. Whichever spelling an agent writes, the one the
|
|
8018
8838
|
* component takes is suggested, in either direction. Event handlers are left out: their
|
|
@@ -8036,14 +8856,96 @@ const PROP_EQUIVALENTS = [
|
|
|
8036
8856
|
["defaultChecked", "defaultSelected"],
|
|
8037
8857
|
["indeterminate", "isIndeterminate"]
|
|
8038
8858
|
];
|
|
8859
|
+
/** Props other libraries compose with, which no prop of a similar spelling stands in for. */
|
|
8860
|
+
const FOREIGN_COMPOSITION = /* @__PURE__ */ new Set([
|
|
8861
|
+
"href",
|
|
8862
|
+
"as",
|
|
8863
|
+
"component"
|
|
8864
|
+
]);
|
|
8039
8865
|
/**
|
|
8040
8866
|
* 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
|
-
*
|
|
8867
|
+
* element; shadcn/ui ships both, and agents mix them up, or bring `as`, `component`
|
|
8868
|
+
* or `href` from other libraries. Renaming the prop is not enough (`asChild` is a
|
|
8869
|
+
* boolean, and a button with an `href` is still no link), so this explains the
|
|
8870
|
+
* other way instead of offering a fix.
|
|
8043
8871
|
*/
|
|
8044
8872
|
function compositionHint(name, known, tag) {
|
|
8045
|
-
|
|
8046
|
-
|
|
8873
|
+
const radix = `compose with asChild and a single child`;
|
|
8874
|
+
const radixExample = `<${tag} asChild><Link href="…">…</Link></${tag}>`;
|
|
8875
|
+
const base = `compose with render`;
|
|
8876
|
+
const baseExample = `<${tag} render={<Link href="…" />}>…</${tag}>`;
|
|
8877
|
+
if (name === "href") {
|
|
8878
|
+
if (known.has("asChild")) return {
|
|
8879
|
+
prop: "asChild",
|
|
8880
|
+
text: `For a link, Radix components ${radix}: ${radixExample}.`
|
|
8881
|
+
};
|
|
8882
|
+
if (known.has("render")) return {
|
|
8883
|
+
prop: "render",
|
|
8884
|
+
text: `For a link, Base UI components ${base}: ${baseExample}.`
|
|
8885
|
+
};
|
|
8886
|
+
return;
|
|
8887
|
+
}
|
|
8888
|
+
if ([
|
|
8889
|
+
"asChild",
|
|
8890
|
+
"as",
|
|
8891
|
+
"component"
|
|
8892
|
+
].includes(name) && known.has("render")) return {
|
|
8893
|
+
prop: "render",
|
|
8894
|
+
text: `Base UI components ${base} instead: ${baseExample}.`
|
|
8895
|
+
};
|
|
8896
|
+
if ([
|
|
8897
|
+
"render",
|
|
8898
|
+
"as",
|
|
8899
|
+
"component"
|
|
8900
|
+
].includes(name) && known.has("asChild")) return {
|
|
8901
|
+
prop: "asChild",
|
|
8902
|
+
text: `Radix components ${radix} instead: ${radixExample}.`
|
|
8903
|
+
};
|
|
8904
|
+
}
|
|
8905
|
+
/** `onClick`, `onValueChange`: a handler, whose arguments a spelling match does not tell. */
|
|
8906
|
+
const HANDLER = /^on[A-Z]/;
|
|
8907
|
+
/**
|
|
8908
|
+
* The prop an unknown one most likely means, and whether the rename is safe as a fix: an
|
|
8909
|
+
* equivalent, a synonym or the same name in other case (`onclick`, `tabindex`) means the same,
|
|
8910
|
+
* and so does an own prop one edit away (`varient`, `onPres`). A forwarded attribute of a
|
|
8911
|
+
* similar spelling is only named, since one letter can make another prop (`disable` is
|
|
8912
|
+
* `disabled`, but `href` is not `ref`). `ref`, own or forwarded, and forwarded handlers, whose
|
|
8913
|
+
* events differ (`onValueChange` is not `onVolumeChange`), are not guessed by spelling at all,
|
|
8914
|
+
* and neither is anything for `href`, `as` or `component` (`ref`, `is`), which compose.
|
|
8915
|
+
*/
|
|
8916
|
+
function guessProp(name, own, known) {
|
|
8917
|
+
if (FOREIGN_COMPOSITION.has(name)) return void 0;
|
|
8918
|
+
const equivalents = PROP_EQUIVALENTS.find((group) => group.includes(name)) ?? [];
|
|
8919
|
+
const equivalent = equivalents.find((other) => other !== name && own.includes(other)) ?? equivalents.find((other) => other !== name && known.has(other));
|
|
8920
|
+
if (equivalent) return {
|
|
8921
|
+
prop: equivalent,
|
|
8922
|
+
fix: true
|
|
8923
|
+
};
|
|
8924
|
+
const synonym = Object.entries(PROP_SYNONYMS).find(([target, aliases]) => known.has(target) && aliases.includes(name))?.[0];
|
|
8925
|
+
if (synonym) return {
|
|
8926
|
+
prop: synonym,
|
|
8927
|
+
fix: true
|
|
8928
|
+
};
|
|
8929
|
+
if (OPEN_CHANGE_HANDLERS.has(name) && known.has("onOpenChange")) return {
|
|
8930
|
+
prop: "onOpenChange",
|
|
8931
|
+
fix: false
|
|
8932
|
+
};
|
|
8933
|
+
const lower = name.toLowerCase();
|
|
8934
|
+
const recased = [...known].find((prop) => prop.toLowerCase() === lower);
|
|
8935
|
+
if (recased) return {
|
|
8936
|
+
prop: recased,
|
|
8937
|
+
fix: true
|
|
8938
|
+
};
|
|
8939
|
+
const typo = closest(name, own.filter((prop) => prop !== "ref"));
|
|
8940
|
+
if (typo) return {
|
|
8941
|
+
prop: typo,
|
|
8942
|
+
fix: editDistance(lower, typo.toLowerCase()) <= 1
|
|
8943
|
+
};
|
|
8944
|
+
const similar = closest(name, [...known].filter((prop) => prop !== "ref" && !HANDLER.test(prop)), .25);
|
|
8945
|
+
return similar ? {
|
|
8946
|
+
prop: similar,
|
|
8947
|
+
fix: false
|
|
8948
|
+
} : void 0;
|
|
8047
8949
|
}
|
|
8048
8950
|
const noUnknownProp = {
|
|
8049
8951
|
id: "no-unknown-prop",
|
|
@@ -8066,27 +8968,25 @@ const noUnknownProp = {
|
|
|
8066
8968
|
context.report({
|
|
8067
8969
|
start: attribute.name.getStart(context.sourceFile),
|
|
8068
8970
|
end: attribute.name.end,
|
|
8069
|
-
message: `<${element.tag}> has no prop "${name}". ${composition}`,
|
|
8070
|
-
suggestion:
|
|
8971
|
+
message: `<${element.tag}> has no prop "${name}". ${composition.text}`,
|
|
8972
|
+
suggestion: composition.prop
|
|
8071
8973
|
});
|
|
8072
8974
|
continue;
|
|
8073
8975
|
}
|
|
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;
|
|
8976
|
+
const guess = guessProp(name, own, known);
|
|
8977
|
+
const prop = guess ? component.props.find((p) => p.name === guess.prop) : void 0;
|
|
8079
8978
|
const values = prop?.values?.length ? ` (${prop.values.map((v) => `"${v}"`).join(" | ")})` : "";
|
|
8979
|
+
const handler = guess?.prop === "onOpenChange" ? OPEN_CHANGE_HANDLERS.get(name) : void 0;
|
|
8080
8980
|
const valueOk = !prop?.values || literalValues(attribute).every((l) => prop.values?.includes(l.text));
|
|
8081
|
-
const taken = guess !== void 0 && findAttribute(element, guess) !== void 0;
|
|
8981
|
+
const taken = guess !== void 0 && findAttribute(element, guess.prop) !== void 0;
|
|
8082
8982
|
context.report({
|
|
8083
8983
|
start: attribute.name.getStart(context.sourceFile),
|
|
8084
8984
|
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 ? [{
|
|
8985
|
+
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}}.` : ""),
|
|
8986
|
+
suggestion: guess?.prop,
|
|
8987
|
+
fix: guess?.fix && valueOk && !taken ? [{
|
|
8088
8988
|
range: [attribute.name.getStart(context.sourceFile), attribute.name.end],
|
|
8089
|
-
text: guess
|
|
8989
|
+
text: guess.prop
|
|
8090
8990
|
}] : void 0
|
|
8091
8991
|
});
|
|
8092
8992
|
}
|
|
@@ -8177,11 +9077,16 @@ const VALUE_GROUPS = [
|
|
|
8177
9077
|
"href",
|
|
8178
9078
|
"inline"
|
|
8179
9079
|
],
|
|
9080
|
+
[
|
|
9081
|
+
"xs",
|
|
9082
|
+
"xsmall",
|
|
9083
|
+
"x-small",
|
|
9084
|
+
"extra-small"
|
|
9085
|
+
],
|
|
8180
9086
|
[
|
|
8181
9087
|
"sm",
|
|
8182
9088
|
"small",
|
|
8183
9089
|
"s",
|
|
8184
|
-
"xs",
|
|
8185
9090
|
"compact",
|
|
8186
9091
|
"tiny"
|
|
8187
9092
|
],
|
|
@@ -8200,6 +9105,13 @@ const VALUE_GROUPS = [
|
|
|
8200
9105
|
"l",
|
|
8201
9106
|
"big"
|
|
8202
9107
|
],
|
|
9108
|
+
[
|
|
9109
|
+
"xl",
|
|
9110
|
+
"xlarge",
|
|
9111
|
+
"x-large",
|
|
9112
|
+
"extra-large",
|
|
9113
|
+
"huge"
|
|
9114
|
+
],
|
|
8203
9115
|
[
|
|
8204
9116
|
"icon",
|
|
8205
9117
|
"iconOnly",
|
|
@@ -8208,14 +9120,72 @@ const VALUE_GROUPS = [
|
|
|
8208
9120
|
"icon-button"
|
|
8209
9121
|
]
|
|
8210
9122
|
];
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
|
|
8214
|
-
|
|
8215
|
-
|
|
8216
|
-
|
|
9123
|
+
/**
|
|
9124
|
+
* A value with the x's of its size step counted, so two spellings of one step compare equal:
|
|
9125
|
+
* `xxl` and `2xl` both give `2xl`, `icon-XXS` and `icon-2xs` both give `icon-2xs`.
|
|
9126
|
+
*/
|
|
9127
|
+
function stepSpelling(value) {
|
|
9128
|
+
return value.toLowerCase().replace(/(^|-)(x{2,})([sl])$/, (_, start, xs, end) => `${start}${xs.length}x${end}`);
|
|
9129
|
+
}
|
|
9130
|
+
/** {@link VALUE_GROUPS} spelled as {@link stepSpelling} spells a value, to compare the two. */
|
|
9131
|
+
const SPELLED_GROUPS = VALUE_GROUPS.map((group) => group.map(stepSpelling));
|
|
9132
|
+
/**
|
|
9133
|
+
* An allowed value that means the same: the same step spelled another way (`xxl` → `2xl`,
|
|
9134
|
+
* `XL` → `xl`), or a synonym (`danger` → `destructive`, `small` → `sm`). A group is searched
|
|
9135
|
+
* in its own order, not the scale's: TypeScript orders a union by when the program first met
|
|
9136
|
+
* each literal, so another file can reorder the scale, and `positive` must stay `success` on
|
|
9137
|
+
* a scale that also has `active`.
|
|
9138
|
+
*/
|
|
9139
|
+
function synonymValue(value, allowed) {
|
|
9140
|
+
const spelling = stepSpelling(value);
|
|
9141
|
+
const same = allowed.find((member) => member !== value && stepSpelling(member) === spelling);
|
|
9142
|
+
if (same) return same;
|
|
9143
|
+
for (const group of SPELLED_GROUPS) {
|
|
9144
|
+
if (!group.includes(spelling)) continue;
|
|
9145
|
+
for (const member of group) {
|
|
9146
|
+
if (member === spelling) continue;
|
|
9147
|
+
const match = allowed.find((candidate) => stepSpelling(candidate) === member);
|
|
9148
|
+
if (match) return match;
|
|
9149
|
+
}
|
|
8217
9150
|
}
|
|
8218
|
-
|
|
9151
|
+
}
|
|
9152
|
+
/**
|
|
9153
|
+
* The step a size one past an end of the scale stands next to, with its x's counted and its
|
|
9154
|
+
* prefix kept: `2xl` → `xl`, `3xl` → `2xl`, `2xs` → `xs`, `xs` → `sm`, `icon-2xl` → `icon-xl`.
|
|
9155
|
+
*/
|
|
9156
|
+
function stepInside(spelling) {
|
|
9157
|
+
const match = /^(.*-)?(\d*)x([sl])$/.exec(spelling);
|
|
9158
|
+
if (!match) return void 0;
|
|
9159
|
+
const prefix = match[1] ?? "";
|
|
9160
|
+
const end = match[3];
|
|
9161
|
+
if (!match[2]) return end === "s" ? `${prefix}sm` : void 0;
|
|
9162
|
+
const count = Number(match[2]);
|
|
9163
|
+
if (count < 2) return void 0;
|
|
9164
|
+
return `${prefix}${count === 2 ? "" : count - 1}x${end}`;
|
|
9165
|
+
}
|
|
9166
|
+
/**
|
|
9167
|
+
* The end of the scale a size one step past it stands next to, under whichever name the scale
|
|
9168
|
+
* gives it: `2xl` or `xxl` → `xl`, `3xl` → `2xl` or `xxl`, `xs` → `sm` or `small`, `icon-xxl`
|
|
9169
|
+
* → `icon-xl`. It renders another size than the one written, so it is named in the message
|
|
9170
|
+
* but not fixed.
|
|
9171
|
+
*/
|
|
9172
|
+
function nextStep(value, allowed) {
|
|
9173
|
+
const step = stepInside(stepSpelling(value));
|
|
9174
|
+
if (!step) return void 0;
|
|
9175
|
+
return allowed.find((member) => stepSpelling(member) === step) ?? synonymValue(step, allowed);
|
|
9176
|
+
}
|
|
9177
|
+
/** The step of a size scale a value names, alone or at its end: `xs`, `lg`, `2xl`, `icon-sm`. */
|
|
9178
|
+
const SIZE_STEP = /(?:^|-)(\d*x*[sml]|sm|md|lg)$/i;
|
|
9179
|
+
/**
|
|
9180
|
+
* An allowed value spelled close to `value`, for the message only: it may be another value
|
|
9181
|
+
* altogether. Two steps of a size scale are never guessed from each other, since one letter
|
|
9182
|
+
* there is another size (`xl` and `xs`, `icon-xl` and `icon-xs`).
|
|
9183
|
+
*/
|
|
9184
|
+
function spelledLike(value, allowed) {
|
|
9185
|
+
const guess = closest(value, allowed, .5);
|
|
9186
|
+
const step = SIZE_STEP.exec(value)?.[1]?.toLowerCase();
|
|
9187
|
+
const guessStep = guess ? SIZE_STEP.exec(guess)?.[1]?.toLowerCase() : void 0;
|
|
9188
|
+
return step && guessStep && step !== guessStep ? void 0 : guess;
|
|
8219
9189
|
}
|
|
8220
9190
|
const noUnknownVariant = {
|
|
8221
9191
|
id: "no-unknown-variant",
|
|
@@ -8230,7 +9200,8 @@ const noUnknownVariant = {
|
|
|
8230
9200
|
if (!prop?.values) continue;
|
|
8231
9201
|
for (const literal of literalValues(attribute)) {
|
|
8232
9202
|
if (prop.values.includes(literal.text)) continue;
|
|
8233
|
-
const
|
|
9203
|
+
const synonym = synonymValue(literal.text, prop.values);
|
|
9204
|
+
const guess = synonym ?? nextStep(literal.text, prop.values) ?? spelledLike(literal.text, prop.values);
|
|
8234
9205
|
const start = literal.getStart(context.sourceFile) + 1;
|
|
8235
9206
|
const end = literal.end - 1;
|
|
8236
9207
|
context.report({
|
|
@@ -8238,9 +9209,9 @@ const noUnknownVariant = {
|
|
|
8238
9209
|
end: end + 1,
|
|
8239
9210
|
message: `"${literal.text}" is not a valid ${name} for <${element.tag}>. Allowed: ${prop.values.join(", ")}.` + (guess ? ` Did you mean "${guess}"?` : ""),
|
|
8240
9211
|
suggestion: guess ? `${name}="${guess}"` : void 0,
|
|
8241
|
-
fix:
|
|
9212
|
+
fix: synonym ? [{
|
|
8242
9213
|
range: [start, end],
|
|
8243
|
-
text:
|
|
9214
|
+
text: synonym
|
|
8244
9215
|
}] : void 0
|
|
8245
9216
|
});
|
|
8246
9217
|
}
|
|
@@ -8248,27 +9219,205 @@ const noUnknownVariant = {
|
|
|
8248
9219
|
}
|
|
8249
9220
|
}
|
|
8250
9221
|
};
|
|
9222
|
+
/**
|
|
9223
|
+
* Labels guessed from an icon's name, for the message. The third pattern names the icons that mean
|
|
9224
|
+
* one thing on a button (a trash can deletes, a magnifier searches), matched against the name
|
|
9225
|
+
* without its digits or `Icon` suffix (`Trash2Icon` is `Trash`): only their label is fixed. A plus
|
|
9226
|
+
* may add or expand, a chevron page or expand, an X close, remove or clear, so those are fixed only
|
|
9227
|
+
* where the code says which (`iconLabel`), and the rest are named without a fix.
|
|
9228
|
+
*/
|
|
8251
9229
|
const ICON_LABELS = [
|
|
8252
|
-
[
|
|
9230
|
+
[
|
|
9231
|
+
/^(Trash|Delete|Bin)/,
|
|
9232
|
+
"Delete",
|
|
9233
|
+
/^(?:Trash|Delete|Bin)$/
|
|
9234
|
+
],
|
|
8253
9235
|
[/^(X|Close|Cross)(Icon)?$|^X(Circle|Square)/, "Close"],
|
|
8254
9236
|
[/^(Plus|Add)/, "Add"],
|
|
8255
|
-
[
|
|
8256
|
-
|
|
9237
|
+
[
|
|
9238
|
+
/^(Pencil|Edit|PenLine|SquarePen)/,
|
|
9239
|
+
"Edit",
|
|
9240
|
+
/^(?:Pencil|Edit|SquarePen)$/
|
|
9241
|
+
],
|
|
9242
|
+
[
|
|
9243
|
+
/^Search/,
|
|
9244
|
+
"Search",
|
|
9245
|
+
/^Search$/
|
|
9246
|
+
],
|
|
8257
9247
|
[/^(Menu|Hamburger)/, "Open menu"],
|
|
8258
|
-
[
|
|
8259
|
-
|
|
8260
|
-
|
|
8261
|
-
|
|
9248
|
+
[
|
|
9249
|
+
/^(Settings|Cog|Gear)/,
|
|
9250
|
+
"Settings",
|
|
9251
|
+
/^(?:Settings|Cog|Gear)$/
|
|
9252
|
+
],
|
|
9253
|
+
[
|
|
9254
|
+
/^(Copy|Clipboard)/,
|
|
9255
|
+
"Copy",
|
|
9256
|
+
/^(?:Copy|ClipboardCopy)$/
|
|
9257
|
+
],
|
|
9258
|
+
[
|
|
9259
|
+
/^Download/,
|
|
9260
|
+
"Download",
|
|
9261
|
+
/^Download$/
|
|
9262
|
+
],
|
|
9263
|
+
[
|
|
9264
|
+
/^Upload/,
|
|
9265
|
+
"Upload",
|
|
9266
|
+
/^Upload$/
|
|
9267
|
+
],
|
|
8262
9268
|
[/^(MoreHorizontal|MoreVertical|Ellipsis|DotsHorizontal|DotsVertical)/, "More options"],
|
|
8263
9269
|
[/^ChevronLeft|^ArrowLeft/, "Previous"],
|
|
8264
9270
|
[/^ChevronRight|^ArrowRight/, "Next"],
|
|
8265
9271
|
[/^(UserMinus|UserX)/, "Remove member"],
|
|
8266
9272
|
[/^(UserPlus)/, "Invite member"],
|
|
8267
9273
|
[/^(RefreshCw|RefreshCcw|RotateCw)/, "Refresh"],
|
|
8268
|
-
[
|
|
8269
|
-
|
|
9274
|
+
[
|
|
9275
|
+
/^(Share)/,
|
|
9276
|
+
"Share",
|
|
9277
|
+
/^Share$/
|
|
9278
|
+
],
|
|
9279
|
+
[
|
|
9280
|
+
/^(Filter)/,
|
|
9281
|
+
"Filter",
|
|
9282
|
+
/^Filter$/
|
|
9283
|
+
],
|
|
8270
9284
|
[/^(Info)/, "More information"]
|
|
8271
9285
|
];
|
|
9286
|
+
/** A part that expands and collapses: `CollapsibleTrigger`, `Accordion.Trigger`, `DisclosureButton`. */
|
|
9287
|
+
const TOGGLE_PART = /(?:Collapsible|Accordion|Disclosure)\w*\.?\w*(?:Trigger|Button)$/;
|
|
9288
|
+
/** A part that closes its dialog, sheet or popover: `DialogClose`, `Dialog.Close`. */
|
|
9289
|
+
const CLOSE_PART = /Close$/;
|
|
9290
|
+
/** A part that opens a menu: `DropdownMenuTrigger`, `Menu.Trigger`, Headless UI's `MenuButton`. */
|
|
9291
|
+
const MENU_TRIGGER = /Menu\w*\.?\w*Trigger$|^(?:\w+\.)?Menu\.?Button$/;
|
|
9292
|
+
/** Classes styled by an expanded state: `data-[state=open]:rotate-90`, `aria-expanded:bg-muted`. */
|
|
9293
|
+
const EXPANDED_CLASS = /data-\[state=(?:open|closed)\]|aria-expanded:/;
|
|
9294
|
+
/** The tags of the button and of every element around it, its `render` host included. */
|
|
9295
|
+
function enclosingTags(context, element) {
|
|
9296
|
+
const tags = [element.tag];
|
|
9297
|
+
ts.findAncestor(element.node.parent, (node) => {
|
|
9298
|
+
if (ts.isJsxElement(node)) tags.push(node.openingElement.tagName.getText(context.sourceFile));
|
|
9299
|
+
return false;
|
|
9300
|
+
});
|
|
9301
|
+
return tags;
|
|
9302
|
+
}
|
|
9303
|
+
/** The names a callee is reached by: `deleteMutation.mutate` is `deleteMutation` and `mutate`. */
|
|
9304
|
+
function calleeNames(expression) {
|
|
9305
|
+
const names = [];
|
|
9306
|
+
let current = expression;
|
|
9307
|
+
for (;;) if (ts.isNonNullExpression(current) || ts.isParenthesizedExpression(current)) current = current.expression;
|
|
9308
|
+
else if (ts.isPropertyAccessExpression(current)) {
|
|
9309
|
+
names.unshift(current.name.text);
|
|
9310
|
+
current = current.expression;
|
|
9311
|
+
} else {
|
|
9312
|
+
if (ts.isIdentifier(current)) names.unshift(current.text);
|
|
9313
|
+
return names;
|
|
9314
|
+
}
|
|
9315
|
+
}
|
|
9316
|
+
/** The words that name each action in a function's name. */
|
|
9317
|
+
const ACTION_WORDS = [
|
|
9318
|
+
["Remove", ["remove", "delete"]],
|
|
9319
|
+
["Clear", ["clear", "reset"]],
|
|
9320
|
+
["Close", [
|
|
9321
|
+
"close",
|
|
9322
|
+
"dismiss",
|
|
9323
|
+
"hide"
|
|
9324
|
+
]]
|
|
9325
|
+
];
|
|
9326
|
+
/** Calls that tidy up around the action rather than name it: `clearTimeout(timer)`. */
|
|
9327
|
+
const HOUSEKEEPING = /^(?:clearTimeout|clearInterval|clearImmediate|removeEventListener|removeListener)$/;
|
|
9328
|
+
/**
|
|
9329
|
+
* What one call or handler reference does, by its name: `removeAssertion`, `onClose`,
|
|
9330
|
+
* `setOpen(false)`. A name with words of two actions (`closeDeleteDialog`, `hideRemoveConfirm`) or
|
|
9331
|
+
* with `cancel` (`onCancelDelete`) says none.
|
|
9332
|
+
*/
|
|
9333
|
+
function actionOf(names, argument) {
|
|
9334
|
+
const last = names.at(-1) ?? "";
|
|
9335
|
+
if (HOUSEKEEPING.test(last)) return void 0;
|
|
9336
|
+
const words = names.flatMap((name) => name.split(/(?=[A-Z])|_/)).map((w) => w.toLowerCase());
|
|
9337
|
+
if (argument?.kind === ts.SyntaxKind.FalseKeyword && words.some((w) => w === "open" || w === "show" || w === "visible")) return "Close";
|
|
9338
|
+
if (/^set[A-Z]/.test(last) || words.includes("cancel")) return void 0;
|
|
9339
|
+
const actions = ACTION_WORDS.filter(([, named]) => words.some((w) => named.includes(w)));
|
|
9340
|
+
return actions.length === 1 ? actions[0]?.[0] : void 0;
|
|
9341
|
+
}
|
|
9342
|
+
/**
|
|
9343
|
+
* What a button's `onClick` (or React Aria's `onPress`) does, by the names of the functions it
|
|
9344
|
+
* calls or passes, and whether that is sure: only when every name says the same action. A close
|
|
9345
|
+
* button that also tidies up (`form.reset(); setOpen(false)`) or a button that removes a row and
|
|
9346
|
+
* closes a popover is named "Close", or "Remove" when it removes and clears, without a fix.
|
|
9347
|
+
* Callbacks inside the handler run later, on their own events (`mutate(id, { onSuccess: () =>
|
|
9348
|
+
* toast.dismiss() })`), so they do not count.
|
|
9349
|
+
*/
|
|
9350
|
+
function handlerAction(attributes) {
|
|
9351
|
+
const found = /* @__PURE__ */ new Set();
|
|
9352
|
+
for (const attribute of attributes) {
|
|
9353
|
+
if (!/^on(?:Click|Press)$/.test(attributeName(attribute))) continue;
|
|
9354
|
+
const init = attribute.initializer;
|
|
9355
|
+
const value = init && ts.isJsxExpression(init) ? init.expression : void 0;
|
|
9356
|
+
if (!value) continue;
|
|
9357
|
+
const reference = actionOf(calleeNames(value));
|
|
9358
|
+
if (reference) found.add(reference);
|
|
9359
|
+
const stack = [value];
|
|
9360
|
+
for (let node = stack.pop(); node; node = stack.pop()) {
|
|
9361
|
+
if (ts.isCallExpression(node)) {
|
|
9362
|
+
const action = actionOf(calleeNames(node.expression), node.arguments[0]);
|
|
9363
|
+
if (action) found.add(action);
|
|
9364
|
+
}
|
|
9365
|
+
ts.forEachChild(node, (child) => {
|
|
9366
|
+
if (!ts.isFunctionLike(child)) stack.push(child);
|
|
9367
|
+
});
|
|
9368
|
+
}
|
|
9369
|
+
}
|
|
9370
|
+
const [only] = found;
|
|
9371
|
+
if (!only) return void 0;
|
|
9372
|
+
if (found.size === 1) return {
|
|
9373
|
+
action: only,
|
|
9374
|
+
sure: true
|
|
9375
|
+
};
|
|
9376
|
+
return {
|
|
9377
|
+
action: found.has("Close") ? "Close" : "Remove",
|
|
9378
|
+
sure: false
|
|
9379
|
+
};
|
|
9380
|
+
}
|
|
9381
|
+
/**
|
|
9382
|
+
* The label for an icon-only button, and whether the fix may write it: only when the icon means
|
|
9383
|
+
* one thing on a button, or the code says what it does. A chevron that expands or collapses gets
|
|
9384
|
+
* no direction; an X is named by its handler (`Remove`, `Clear`, `Close`), and `Close` is fixed
|
|
9385
|
+
* in a close part; "More options" is fixed on a menu's trigger.
|
|
9386
|
+
*/
|
|
9387
|
+
function iconLabel(context, element, host, icon) {
|
|
9388
|
+
const entry = icon ? ICON_LABELS.find(([pattern]) => pattern.test(icon)) : void 0;
|
|
9389
|
+
if (!icon || !entry) return { fixed: false };
|
|
9390
|
+
const [, label, plain] = entry;
|
|
9391
|
+
const tags = enclosingTags(context, element);
|
|
9392
|
+
const owners = host ? [element, host] : [element];
|
|
9393
|
+
const attributes = owners.flatMap((owner) => owner.attributes);
|
|
9394
|
+
if (label === "Next" || label === "Previous") return {
|
|
9395
|
+
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,
|
|
9396
|
+
fixed: false
|
|
9397
|
+
};
|
|
9398
|
+
if (label === "Close") {
|
|
9399
|
+
if (tags.some((tag) => CLOSE_PART.test(tag))) return {
|
|
9400
|
+
label,
|
|
9401
|
+
fixed: true
|
|
9402
|
+
};
|
|
9403
|
+
const handler = handlerAction(attributes);
|
|
9404
|
+
return handler ? {
|
|
9405
|
+
label: handler.action,
|
|
9406
|
+
fixed: handler.sure
|
|
9407
|
+
} : {
|
|
9408
|
+
label,
|
|
9409
|
+
fixed: false
|
|
9410
|
+
};
|
|
9411
|
+
}
|
|
9412
|
+
if (label === "More options") return {
|
|
9413
|
+
label,
|
|
9414
|
+
fixed: tags.some((tag) => MENU_TRIGGER.test(tag))
|
|
9415
|
+
};
|
|
9416
|
+
return {
|
|
9417
|
+
label,
|
|
9418
|
+
fixed: plain?.test(icon.replace(/Icon$/, "").replace(/\d+$/, "")) ?? false
|
|
9419
|
+
};
|
|
9420
|
+
}
|
|
8272
9421
|
/** Text wins, then content the rule cannot read, then icons. */
|
|
8273
9422
|
const PRECEDENCE = [
|
|
8274
9423
|
"text",
|
|
@@ -8456,14 +9605,58 @@ function isHidden(element) {
|
|
|
8456
9605
|
return opening !== void 0 && hiddenBy(opening.attributes);
|
|
8457
9606
|
}) !== void 0;
|
|
8458
9607
|
}
|
|
9608
|
+
/**
|
|
9609
|
+
* A link a design system renders through `asChild`: `<a>`, a component that renders one, or a
|
|
9610
|
+
* router's `Link` (`next/link`, `NavLink`) with its `href` or `to`, not an icon named `ExternalLink`.
|
|
9611
|
+
*/
|
|
9612
|
+
function isLink(context, element) {
|
|
9613
|
+
const resolution = context.resolve(element);
|
|
9614
|
+
if (resolution.kind === "intrinsic") return resolution.tag === "a";
|
|
9615
|
+
if (resolution.kind === "component" && resolution.component.element === "a") return true;
|
|
9616
|
+
return /Link$/.test(element.tag) && Boolean(findAttribute(element, "href") ?? findAttribute(element, "to"));
|
|
9617
|
+
}
|
|
9618
|
+
/**
|
|
9619
|
+
* `<Button asChild size="icon"><Link href="/users/1/edit"><Pencil /></Link></Button>`: Radix's
|
|
9620
|
+
* Slot renders the link with the button's props, so the link is the button, and an icon alone
|
|
9621
|
+
* in it names nothing. A label on either counts (`aria-label`, `title`, visually hidden text in
|
|
9622
|
+
* the link). A child that is a button itself is checked on its own; any other child (a `<span>`
|
|
9623
|
+
* under a tooltip's trigger) is not a control.
|
|
9624
|
+
*/
|
|
9625
|
+
function checkAsChildLink(context, button) {
|
|
9626
|
+
const children = button.children.filter((c) => !ts.isJsxText(c) || c.text.trim());
|
|
9627
|
+
const [child] = children;
|
|
9628
|
+
if (children.length !== 1 || !child) return;
|
|
9629
|
+
const link = context.analysis.elements.find((e) => e.node === child);
|
|
9630
|
+
if (!link || link.hasSpread || !isLink(context, link)) return;
|
|
9631
|
+
if (hasLabel(button.attributes) || hasLabel(link.attributes)) return;
|
|
9632
|
+
if (labelledByParent(context, button)) return;
|
|
9633
|
+
const icons = [];
|
|
9634
|
+
const content = classify(link.children, context, icons);
|
|
9635
|
+
const sizeIcon = attributeLiterals(button, "size").some((v) => v.text.includes("icon"));
|
|
9636
|
+
if (content !== "icon" && !(content === "empty" && sizeIcon)) return;
|
|
9637
|
+
const { label, fixed } = iconLabel(context, link, button, icons[0]);
|
|
9638
|
+
const attribute = `aria-label="${label ?? "…"}"`;
|
|
9639
|
+
context.report({
|
|
9640
|
+
...tagRange(context, link),
|
|
9641
|
+
message: content === "icon" ? `Icon-only <${link.tag}> in <${button.tag} asChild> has no accessible name. Add ${attribute} describing the action, or visually hidden text.` : `<${link.tag}> in <${button.tag} asChild> has no content and no accessible name. Add ${attribute}.`,
|
|
9642
|
+
suggestion: attribute,
|
|
9643
|
+
fix: fixed ? [{
|
|
9644
|
+
range: [link.tagName.end, link.tagName.end],
|
|
9645
|
+
text: ` ${attribute}`
|
|
9646
|
+
}] : void 0
|
|
9647
|
+
});
|
|
9648
|
+
}
|
|
8459
9649
|
const iconButtonAccessibleName = {
|
|
8460
9650
|
id: "icon-button-accessible-name",
|
|
8461
9651
|
description: "Buttons whose only content is an icon need an aria-label (or visually hidden text).",
|
|
8462
9652
|
run(context) {
|
|
8463
9653
|
for (const element of context.analysis.elements) {
|
|
8464
9654
|
const resolution = context.resolve(element);
|
|
8465
|
-
if (!isButton(context, element) || element.hasSpread) continue;
|
|
8466
|
-
if (findAttribute(element, "asChild")
|
|
9655
|
+
if (!isButton(context, element) || element.hasSpread || isHidden(element)) continue;
|
|
9656
|
+
if (findAttribute(element, "asChild")) {
|
|
9657
|
+
checkAsChildLink(context, element);
|
|
9658
|
+
continue;
|
|
9659
|
+
}
|
|
8467
9660
|
const host = renderHost(context, element);
|
|
8468
9661
|
if (host === "object") continue;
|
|
8469
9662
|
if (host && (isButton(context, host) || host.hasSpread)) continue;
|
|
@@ -8474,14 +9667,13 @@ const iconButtonAccessibleName = {
|
|
|
8474
9667
|
const content = host && !children.length ? "unknown" : classify(children, context, icons);
|
|
8475
9668
|
const sizeIcon = attributeLiterals(element, "size").some((v) => v.text.includes("icon"));
|
|
8476
9669
|
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;
|
|
9670
|
+
const { label, fixed } = iconLabel(context, element, host, icons[0]);
|
|
8479
9671
|
const attribute = `aria-label="${label ?? "…"}"`;
|
|
8480
9672
|
context.report({
|
|
8481
9673
|
...tagRange(context, element),
|
|
8482
9674
|
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
9675
|
suggestion: attribute,
|
|
8484
|
-
fix:
|
|
9676
|
+
fix: fixed ? [{
|
|
8485
9677
|
range: [element.tagName.end, element.tagName.end],
|
|
8486
9678
|
text: ` ${attribute}`
|
|
8487
9679
|
}] : void 0
|
|
@@ -8491,6 +9683,60 @@ const iconButtonAccessibleName = {
|
|
|
8491
9683
|
};
|
|
8492
9684
|
//#endregion
|
|
8493
9685
|
//#region src/lint/rules/hardcoded.ts
|
|
9686
|
+
/** `var(--color-green-50)`: a shade of Tailwind's palette through its CSS variable. */
|
|
9687
|
+
const PALETTE_VARIABLE = /^var\(\s*--color-([a-z]+-(?:50|[1-9]00|950))\s*\)$/;
|
|
9688
|
+
/** Luminance values, not design colors: `white`/`black` (and hex twins). */
|
|
9689
|
+
const MASK_LUMINANCE = /* @__PURE__ */ new Set([
|
|
9690
|
+
"white",
|
|
9691
|
+
"black",
|
|
9692
|
+
"#fff",
|
|
9693
|
+
"#ffffff",
|
|
9694
|
+
"#000",
|
|
9695
|
+
"#000000"
|
|
9696
|
+
]);
|
|
9697
|
+
/**
|
|
9698
|
+
* Whether the element sits inside an SVG `<mask>`: a stop's luminance sets
|
|
9699
|
+
* opacity there, so `white`/`black` stops are not design-system colors (#59).
|
|
9700
|
+
*/
|
|
9701
|
+
function isInsideMask(element) {
|
|
9702
|
+
const sourceFile = element.node.getSourceFile();
|
|
9703
|
+
return ts.findAncestor(element.node, (node) => {
|
|
9704
|
+
if (!ts.isJsxElement(node) && !ts.isJsxSelfClosingElement(node)) return false;
|
|
9705
|
+
return (ts.isJsxElement(node) ? node.openingElement : node).tagName.getText(sourceFile) === "mask";
|
|
9706
|
+
}) !== void 0;
|
|
9707
|
+
}
|
|
9708
|
+
/** `id` of the wrapping gradient, if the stop sits directly in one. */
|
|
9709
|
+
function gradientIdOfStop(element) {
|
|
9710
|
+
const gradient = ts.findAncestor(element.node.parent, (node) => ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node));
|
|
9711
|
+
if (gradient === void 0) return void 0;
|
|
9712
|
+
const opening = ts.isJsxElement(gradient) ? gradient.openingElement : gradient;
|
|
9713
|
+
const tag = opening.tagName.getText(element.node.getSourceFile());
|
|
9714
|
+
if (tag !== "linearGradient" && tag !== "radialGradient") return;
|
|
9715
|
+
const idAttr = opening.attributes.properties.filter(ts.isJsxAttribute).find((a) => attributeName(a) === "id");
|
|
9716
|
+
return idAttr?.initializer && ts.isStringLiteralLike(idAttr.initializer) ? idAttr.initializer.text : void 0;
|
|
9717
|
+
}
|
|
9718
|
+
const URL_REF = /url\(\s*#([^)\s]+)\s*\)/g;
|
|
9719
|
+
/**
|
|
9720
|
+
* Gradient ids referenced via `fill="url(#id)"` (or `stroke`) from inside a
|
|
9721
|
+
* `<mask>` subtree: stops of those gradients paint mask luminance, not
|
|
9722
|
+
* colors (#59, cal.com gridFade/gridMask shape).
|
|
9723
|
+
*/
|
|
9724
|
+
function maskReferencedGradientIds(elements) {
|
|
9725
|
+
const ids = /* @__PURE__ */ new Set();
|
|
9726
|
+
const refsFrom = (node) => {
|
|
9727
|
+
if (ts.isJsxAttribute(node)) {
|
|
9728
|
+
const name = attributeName(node);
|
|
9729
|
+
if ((name === "fill" || name === "stroke") && node.initializer && ts.isStringLiteralLike(node.initializer)) {
|
|
9730
|
+
URL_REF.lastIndex = 0;
|
|
9731
|
+
let match;
|
|
9732
|
+
while ((match = URL_REF.exec(node.initializer.text)) !== null) if (match[1]) ids.add(match[1]);
|
|
9733
|
+
}
|
|
9734
|
+
}
|
|
9735
|
+
ts.forEachChild(node, refsFrom);
|
|
9736
|
+
};
|
|
9737
|
+
for (const element of elements) if (element.tag === "mask") refsFrom(element.node);
|
|
9738
|
+
return ids;
|
|
9739
|
+
}
|
|
8494
9740
|
const noHardcodedColor = {
|
|
8495
9741
|
id: "no-hardcoded-color",
|
|
8496
9742
|
description: "Colors must come from design tokens: no hex/rgb/oklch literals, arbitrary Tailwind colors or default-palette classes.",
|
|
@@ -8498,71 +9744,174 @@ const noHardcodedColor = {
|
|
|
8498
9744
|
const { tokens } = context.target;
|
|
8499
9745
|
if (!tokens.has("color")) return;
|
|
8500
9746
|
const allow = new Set((context.options.allow ?? []).map((v) => v.toLowerCase()));
|
|
8501
|
-
|
|
8502
|
-
|
|
8503
|
-
|
|
9747
|
+
const maskGradients = maskReferencedGradientIds(context.analysis.elements);
|
|
9748
|
+
const lists = /* @__PURE__ */ new Map();
|
|
9749
|
+
for (const classString of context.analysis.classStrings) {
|
|
9750
|
+
const key = classString.element ?? classString;
|
|
9751
|
+
const list = lists.get(key) ?? [];
|
|
9752
|
+
lists.set(key, list);
|
|
8504
9753
|
const element = classString.element;
|
|
8505
|
-
|
|
8506
|
-
const
|
|
8507
|
-
const
|
|
8508
|
-
|
|
8509
|
-
|
|
8510
|
-
|
|
8511
|
-
}
|
|
8512
|
-
|
|
8513
|
-
|
|
8514
|
-
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
|
|
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
|
-
|
|
9754
|
+
for (const token of splitClasses(classString.text, classString.start)) {
|
|
9755
|
+
const utility = parseUtility(token.value);
|
|
9756
|
+
const listed = {
|
|
9757
|
+
value: token.value,
|
|
9758
|
+
prefix: tokenColorPrefix(utility, tokens.colorKeys),
|
|
9759
|
+
element
|
|
9760
|
+
};
|
|
9761
|
+
list.push(listed);
|
|
9762
|
+
const allowed = allow.has(token.value.toLowerCase());
|
|
9763
|
+
if (utility.arbitrary !== void 0 && utility.prefix && COLOR_PREFIXES.includes(utility.prefix)) {
|
|
9764
|
+
const value = utility.arbitrary.replace(/^color:/, "").trim();
|
|
9765
|
+
const shade = PALETTE_VARIABLE.exec(value)?.[1];
|
|
9766
|
+
const viaVariable = shade ? paletteColor(`${utility.prefix}-${shade}`) : void 0;
|
|
9767
|
+
if (viaVariable) {
|
|
9768
|
+
const color = tokens.colorKeys.has(viaVariable.key) ? void 0 : parseColor(viaVariable.value);
|
|
9769
|
+
if (!color) continue;
|
|
9770
|
+
listed.color = color;
|
|
9771
|
+
listed.prefix = viaVariable.prefix;
|
|
9772
|
+
listed.tinted = !viaVariable.gray;
|
|
9773
|
+
if (allowed) continue;
|
|
9774
|
+
listed.report = colorReport(context, {
|
|
9775
|
+
start: token.start,
|
|
9776
|
+
end: token.end,
|
|
9777
|
+
what: `\`${token.value}\` is Tailwind's default palette (\`${viaVariable.key}\`), not a design-system color`,
|
|
9778
|
+
color,
|
|
9779
|
+
tinted: !viaVariable.gray,
|
|
9780
|
+
palette: viaVariable.key,
|
|
9781
|
+
prefix: viaVariable.prefix,
|
|
9782
|
+
place: "class",
|
|
9783
|
+
utility,
|
|
9784
|
+
element,
|
|
9785
|
+
classes: classString.text
|
|
9786
|
+
});
|
|
9787
|
+
continue;
|
|
9788
|
+
}
|
|
9789
|
+
const whole = parseColor(value);
|
|
9790
|
+
const literal = whole ? {
|
|
9791
|
+
text: value,
|
|
9792
|
+
color: whole
|
|
9793
|
+
} : findColorLiterals(value)[0];
|
|
9794
|
+
if (!literal) continue;
|
|
9795
|
+
listed.color = literal.color;
|
|
9796
|
+
listed.prefix = whole ? utility.prefix : void 0;
|
|
9797
|
+
if (allowed || allow.has(literal.text.toLowerCase())) continue;
|
|
9798
|
+
listed.report = colorReport(context, {
|
|
9799
|
+
start: token.start,
|
|
9800
|
+
end: token.end,
|
|
9801
|
+
what: `Hardcoded color \`${token.value}\``,
|
|
9802
|
+
color: literal.color,
|
|
9803
|
+
prefix: listed.prefix,
|
|
9804
|
+
place: "class",
|
|
9805
|
+
utility,
|
|
9806
|
+
element,
|
|
9807
|
+
classes: classString.text
|
|
9808
|
+
});
|
|
9809
|
+
continue;
|
|
9810
|
+
}
|
|
9811
|
+
const property = utility.property ? cssColorProperty(utility.property) : void 0;
|
|
9812
|
+
if (property && utility.arbitrary !== void 0) {
|
|
9813
|
+
const value = utility.arbitrary.trim();
|
|
9814
|
+
const whole = parseColor(value);
|
|
9815
|
+
const literal = whole ? {
|
|
9816
|
+
text: value,
|
|
9817
|
+
color: whole
|
|
9818
|
+
} : findColorLiterals(value)[0];
|
|
9819
|
+
if (!literal) continue;
|
|
9820
|
+
listed.color = literal.color;
|
|
9821
|
+
listed.prefix = whole ? property.prefix : void 0;
|
|
9822
|
+
if (allowed || allow.has(literal.text.toLowerCase())) continue;
|
|
9823
|
+
listed.report = colorReport(context, {
|
|
9824
|
+
start: token.start,
|
|
9825
|
+
end: token.end,
|
|
9826
|
+
what: `Hardcoded color \`${token.value}\``,
|
|
9827
|
+
color: literal.color,
|
|
9828
|
+
prefix: listed.prefix,
|
|
9829
|
+
place: "class",
|
|
9830
|
+
utility,
|
|
9831
|
+
element,
|
|
9832
|
+
classes: classString.text
|
|
9833
|
+
});
|
|
9834
|
+
continue;
|
|
9835
|
+
}
|
|
9836
|
+
const palette = paletteColor(utility.base);
|
|
9837
|
+
if (palette && !tokens.colorKeys.has(palette.key)) {
|
|
9838
|
+
const color = parseColor(palette.value);
|
|
9839
|
+
if (!color) continue;
|
|
9840
|
+
listed.color = color;
|
|
9841
|
+
listed.prefix = palette.prefix;
|
|
9842
|
+
listed.tinted = !palette.gray;
|
|
9843
|
+
if (allowed) continue;
|
|
9844
|
+
listed.report = colorReport(context, {
|
|
9845
|
+
start: token.start,
|
|
9846
|
+
end: token.end,
|
|
9847
|
+
what: `\`${token.value}\` is Tailwind's default palette, not a design-system color`,
|
|
9848
|
+
color,
|
|
9849
|
+
tinted: !palette.gray,
|
|
9850
|
+
palette: palette.key,
|
|
9851
|
+
prefix: palette.prefix,
|
|
9852
|
+
place: "class",
|
|
9853
|
+
utility,
|
|
9854
|
+
element,
|
|
9855
|
+
classes: classString.text
|
|
9856
|
+
});
|
|
9857
|
+
continue;
|
|
9858
|
+
}
|
|
9859
|
+
const keyword = keywordColor(utility, tokens.colorKeys);
|
|
9860
|
+
const tint = keyword ? void 0 : keywordColor(utility, tokens.colorKeys, true);
|
|
9861
|
+
if (tint && colorRole(tint.prefix) === "text" && !(element && isInsideMask(element))) {
|
|
9862
|
+
listed.color = tint.color;
|
|
9863
|
+
listed.prefix = tint.prefix;
|
|
9864
|
+
}
|
|
9865
|
+
if (keyword && !(element && isInsideMask(element))) {
|
|
9866
|
+
listed.color = keyword.color;
|
|
9867
|
+
listed.prefix = keyword.prefix;
|
|
9868
|
+
if (allowed) continue;
|
|
9869
|
+
listed.keyword = {
|
|
9870
|
+
start: token.start,
|
|
9871
|
+
end: token.end,
|
|
9872
|
+
what: `\`${token.value}\` is a fixed color, not a design-system color`,
|
|
9873
|
+
color: keyword.color,
|
|
9874
|
+
tinted: false,
|
|
9875
|
+
keyword: true,
|
|
9876
|
+
prefix: keyword.prefix,
|
|
9877
|
+
place: "class",
|
|
9878
|
+
utility,
|
|
9879
|
+
element,
|
|
9880
|
+
classes: classString.text
|
|
9881
|
+
};
|
|
9882
|
+
}
|
|
8564
9883
|
}
|
|
8565
9884
|
}
|
|
9885
|
+
const surfaces = new Surfaces(context, lists);
|
|
9886
|
+
const texts = [];
|
|
9887
|
+
const fixedTexts = [];
|
|
9888
|
+
for (const list of lists.values()) for (const listed of list) {
|
|
9889
|
+
const text = colorRole(listed.prefix) === "text" && listed.color !== void 0;
|
|
9890
|
+
if (listed.keyword) {
|
|
9891
|
+
if (text) {
|
|
9892
|
+
const placed = surfaces.place(list, listed);
|
|
9893
|
+
texts.push({
|
|
9894
|
+
text: listed,
|
|
9895
|
+
list
|
|
9896
|
+
});
|
|
9897
|
+
if (placed.contrasts) continue;
|
|
9898
|
+
}
|
|
9899
|
+
listed.report = colorReport(context, listed.keyword);
|
|
9900
|
+
} else if (text) (listed.report?.fix ? fixedTexts : texts).push({
|
|
9901
|
+
text: listed,
|
|
9902
|
+
list
|
|
9903
|
+
});
|
|
9904
|
+
}
|
|
9905
|
+
for (const { text, list } of texts) surfaces.holdUnderText(text, list);
|
|
9906
|
+
for (const list of lists.values()) keepStatesApart(list);
|
|
9907
|
+
const stays = new Set(texts.map(({ text }) => text));
|
|
9908
|
+
for (const { text, list } of fixedTexts) {
|
|
9909
|
+
if (text.report?.fix) continue;
|
|
9910
|
+
stays.add(text);
|
|
9911
|
+
surfaces.holdUnderText(text, list);
|
|
9912
|
+
}
|
|
9913
|
+
surfaces.holdUnderInherited(stays);
|
|
9914
|
+
for (const list of lists.values()) for (const { report } of list) if (report) context.report(report);
|
|
8566
9915
|
for (const { object, element } of context.analysis.styles) for (const prop of object.properties) {
|
|
8567
9916
|
if (!ts.isPropertyAssignment(prop)) continue;
|
|
8568
9917
|
const name = propertyName(prop.name);
|
|
@@ -8585,13 +9934,16 @@ const noHardcodedColor = {
|
|
|
8585
9934
|
if (!isColorAttribute(name)) continue;
|
|
8586
9935
|
if (resolution.kind === "component" && resolution.component.props.some((p) => p.name === name && p.values?.length)) continue;
|
|
8587
9936
|
const prefix = name === "fill" || name === "stroke" ? name : "text";
|
|
8588
|
-
for (const literal of literalValues(attribute))
|
|
8589
|
-
|
|
8590
|
-
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
|
|
8594
|
-
|
|
9937
|
+
for (const literal of literalValues(attribute)) {
|
|
9938
|
+
if ((name === "stopColor" || name === "stop-color") && element.tag === "stop" && MASK_LUMINANCE.has(literal.text.toLowerCase()) && (isInsideMask(element) || maskGradients.has(gradientIdOfStop(element) ?? ""))) continue;
|
|
9939
|
+
reportLiteralColors(context, literal, element, {
|
|
9940
|
+
where: `${name}="…"`,
|
|
9941
|
+
prefix,
|
|
9942
|
+
place: "attribute",
|
|
9943
|
+
named: resolution.kind === "intrinsic",
|
|
9944
|
+
allow
|
|
9945
|
+
});
|
|
9946
|
+
}
|
|
8595
9947
|
}
|
|
8596
9948
|
}
|
|
8597
9949
|
}
|
|
@@ -8611,7 +9963,7 @@ function reportLiteralColors(context, literal, element, options) {
|
|
|
8611
9963
|
}
|
|
8612
9964
|
for (const match of matches) {
|
|
8613
9965
|
if (options.allow.has(match.text.toLowerCase())) continue;
|
|
8614
|
-
|
|
9966
|
+
const report = colorReport(context, {
|
|
8615
9967
|
start: start + match.index,
|
|
8616
9968
|
end: start + match.index + match.text.length,
|
|
8617
9969
|
what: `Hardcoded color \`${match.text}\` in ${options.where}`,
|
|
@@ -8620,9 +9972,11 @@ function reportLiteralColors(context, literal, element, options) {
|
|
|
8620
9972
|
place: options.place,
|
|
8621
9973
|
element
|
|
8622
9974
|
});
|
|
9975
|
+
if (report) context.report(report);
|
|
8623
9976
|
}
|
|
8624
9977
|
}
|
|
8625
|
-
|
|
9978
|
+
/** The finding for a color, or undefined when the design system has no color to name. */
|
|
9979
|
+
function colorReport(context, input) {
|
|
8626
9980
|
const role = colorRole(input.prefix ?? input.utility?.prefix);
|
|
8627
9981
|
const mode = input.utility?.variants.includes("dark") ? "dark" : void 0;
|
|
8628
9982
|
const suggestion = context.target.tokens.suggestColor(input.color, {
|
|
@@ -8631,13 +9985,56 @@ function reportColor(context, input) {
|
|
|
8631
9985
|
tinted: input.tinted,
|
|
8632
9986
|
mode
|
|
8633
9987
|
});
|
|
8634
|
-
if (!suggestion) return;
|
|
8635
|
-
const { match, nearest, reason, sameValue, hueOff } = suggestion;
|
|
9988
|
+
if (!suggestion) return void 0;
|
|
9989
|
+
const { match, nearest, reason, sameValue, hueOff, otherRole, closeStatus } = suggestion;
|
|
8636
9990
|
const token = nearest.candidate.token;
|
|
8637
|
-
const cls
|
|
8638
|
-
const cssVar = cssReference(token);
|
|
9991
|
+
const { cls, replacement, advice, edit } = writing(input, token);
|
|
8639
9992
|
const close = match !== void 0;
|
|
9993
|
+
const held = close && input.palette && nearest.distance >= .02 ? heldBack(input.palette, valueIn(nearest.candidate, mode), nearest.distance) : void 0;
|
|
9994
|
+
const status = close && closeStatus ? statusTooClose(input, mode, closeStatus, token, advice) : void 0;
|
|
8640
9995
|
const variant = cls && input.element ? variantApplying(context, input.element, cls) : void 0;
|
|
9996
|
+
const same = sameValue.length ? `, same value as ${sameValue.map((t) => t.name).join(", ")}` : "";
|
|
9997
|
+
const inMode = mode && token.modes?.[mode] !== void 0 ? ` in ${mode} mode` : "";
|
|
9998
|
+
const value = valueIn(nearest.candidate, mode);
|
|
9999
|
+
const found = `${token.name}${inMode} (${formatDistance(input.color, value)}${same})`;
|
|
10000
|
+
const opacityGap = formatOpacityGap(input.color, value, `${token.name}${inMode}`);
|
|
10001
|
+
const tinted = input.tinted ?? isTinted(input.color);
|
|
10002
|
+
const noHue = !close && reason === "hue" && tinted;
|
|
10003
|
+
const keywordText = input.keyword === true && role === "text";
|
|
10004
|
+
const keptBack = input.keyword && close && edit && !held && !status ? keywordText ? "White or black text depends on the surface under it, so this is not applied automatically: use the foreground token of that surface." : nearest.distance >= .02 ? "Only a token of exactly this color is applied automatically." : void 0 : void 0;
|
|
10005
|
+
let message;
|
|
10006
|
+
if (close) {
|
|
10007
|
+
message = `${input.what}. ${nearest.distance < .02 ? "Matches token" : "Nearest token"} ${found}`;
|
|
10008
|
+
if (advice) message += ` → ${advice}`;
|
|
10009
|
+
if (variant) message += `, or use ${variant}`;
|
|
10010
|
+
message += ".";
|
|
10011
|
+
if (opacityGap) message += ` ${opacityGap}`;
|
|
10012
|
+
if (status) message += ` ${status}`;
|
|
10013
|
+
else if (held) message += ` ${held}`;
|
|
10014
|
+
else if (keptBack) message += ` ${keptBack}`;
|
|
10015
|
+
} else if (noHue) message = `${input.what}. ${NO_TOKEN_HAS_HUE}`;
|
|
10016
|
+
else message = `${input.what}. ${noMatch(reason, role, found, hueOff)}${meantForAnother(otherRole, input.color, mode)}${opacityGap ? ` ${opacityGap}` : ""} Pick the semantic token that fits.`;
|
|
10017
|
+
const owner = !variant && input.prefix && input.element ? variantProp(context, input.element, input.prefix) : void 0;
|
|
10018
|
+
if (owner) message += ` <${owner.component}> already sets ${input.prefix}-* through \`${owner.prop}\`; prefer a variant over overriding it.`;
|
|
10019
|
+
return {
|
|
10020
|
+
start: input.start,
|
|
10021
|
+
end: input.end,
|
|
10022
|
+
message,
|
|
10023
|
+
suggestion: close ? variant ?? replacement : void 0,
|
|
10024
|
+
fix: close && edit && !held && !status && !keptBack ? [{
|
|
10025
|
+
range: [input.start, input.end],
|
|
10026
|
+
text: edit
|
|
10027
|
+
}] : void 0,
|
|
10028
|
+
severity: noHue || keywordText ? "warning" : void 0
|
|
10029
|
+
};
|
|
10030
|
+
}
|
|
10031
|
+
/**
|
|
10032
|
+
* How code writes `token` where the color is: the class (`bg-primary`), what to suggest, the
|
|
10033
|
+
* message's advice and the text a fix puts in place of the color.
|
|
10034
|
+
*/
|
|
10035
|
+
function writing(input, token) {
|
|
10036
|
+
const cls = input.prefix && token.tailwind ? `${input.prefix}-${token.tailwind}` : void 0;
|
|
10037
|
+
const cssVar = cssReference(token);
|
|
8641
10038
|
let replacement;
|
|
8642
10039
|
let advice;
|
|
8643
10040
|
let edit;
|
|
@@ -8654,45 +10051,621 @@ function reportColor(context, input) {
|
|
|
8654
10051
|
advice = cls ? `\`${cls}\` in className` : void 0;
|
|
8655
10052
|
}
|
|
8656
10053
|
if (!cls && !cssVar && token.usage.length) advice = token.usage.map((usage) => `\`${usage}\``).join(" or ");
|
|
8657
|
-
|
|
10054
|
+
return {
|
|
10055
|
+
cls,
|
|
10056
|
+
replacement,
|
|
10057
|
+
advice,
|
|
10058
|
+
edit
|
|
10059
|
+
};
|
|
10060
|
+
}
|
|
10061
|
+
/**
|
|
10062
|
+
* Why a close match is not applied when a status color of the color's hue is about as close:
|
|
10063
|
+
* `destructive is close too (ΔE 0.071), and it is a status color, so this is not applied
|
|
10064
|
+
* automatically: use \`bg-destructive\` if the color shows a status, otherwise \`bg-brand\`.`
|
|
10065
|
+
*/
|
|
10066
|
+
function statusTooClose(input, mode, status, found, advice) {
|
|
10067
|
+
const { token } = status.candidate;
|
|
8658
10068
|
const inMode = mode && token.modes?.[mode] !== void 0 ? ` in ${mode} mode` : "";
|
|
8659
|
-
const
|
|
8660
|
-
|
|
8661
|
-
|
|
8662
|
-
|
|
8663
|
-
|
|
8664
|
-
|
|
8665
|
-
|
|
8666
|
-
|
|
8667
|
-
|
|
8668
|
-
|
|
10069
|
+
const distance = formatDistance(input.color, valueIn(status.candidate, mode));
|
|
10070
|
+
const statusAdvice = writing(input, token).advice || token.name;
|
|
10071
|
+
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}.`;
|
|
10072
|
+
}
|
|
10073
|
+
/** Color keywords that are no token but set a color (`bg-white`, `border-transparent`). */
|
|
10074
|
+
const KEYWORD_COLORS = /* @__PURE__ */ new Set([
|
|
10075
|
+
"white",
|
|
10076
|
+
"black",
|
|
10077
|
+
"transparent",
|
|
10078
|
+
"current",
|
|
10079
|
+
"inherit"
|
|
10080
|
+
]);
|
|
10081
|
+
/** The prefix of a class that sets a token's color or a keyword color: `bg` for `bg-primary`. */
|
|
10082
|
+
function tokenColorPrefix(utility, colorKeys) {
|
|
10083
|
+
const key = COLOR_UTILITY.exec(utility.base)?.[1];
|
|
10084
|
+
if (!key || !(colorKeys.has(key) || KEYWORD_COLORS.has(key))) return void 0;
|
|
10085
|
+
return utility.base.slice(0, -key.length - 1);
|
|
10086
|
+
}
|
|
10087
|
+
/** `bg-white`, `border-black`: Tailwind's colors without a shade, as a color utility sets them. */
|
|
10088
|
+
const KEYWORD_CLASS = new RegExp(`^(${COLOR_PREFIXES.join("|")})-(${Object.keys(TAILWIND_KEYWORD_COLORS).join("|")})$`);
|
|
10089
|
+
/**
|
|
10090
|
+
* The white or black an opaque class sets (`bg-white`, `text-black`). With an opacity modifier
|
|
10091
|
+
* (`bg-black/50`, `border-white/10`) it is a tint of whatever is under it, a scrim or a hairline
|
|
10092
|
+
* that suits both modes, so it is left alone: with `tint`, only such a class is read. A theme
|
|
10093
|
+
* that defines `--color-white` (after `--color-*: initial`) makes `bg-white` one of its tokens.
|
|
10094
|
+
*/
|
|
10095
|
+
function keywordColor(utility, colorKeys, tint = false) {
|
|
10096
|
+
if (Boolean(utility.modifier) !== tint || utility.arbitrary !== void 0) return void 0;
|
|
10097
|
+
const [, prefix, name] = KEYWORD_CLASS.exec(utility.base) ?? [];
|
|
10098
|
+
if (!prefix || !name || colorKeys.has(name)) return void 0;
|
|
10099
|
+
const color = parseColor(TAILWIND_KEYWORD_COLORS[name] ?? "");
|
|
10100
|
+
return color ? {
|
|
10101
|
+
prefix,
|
|
10102
|
+
color
|
|
10103
|
+
} : void 0;
|
|
10104
|
+
}
|
|
10105
|
+
/** The node around `node`, or undefined at the file. */
|
|
10106
|
+
function parentOf(node) {
|
|
10107
|
+
return ts.isSourceFile(node) ? void 0 : node.parent;
|
|
10108
|
+
}
|
|
10109
|
+
/** Background classes that set no background of their own: what is under them shows through. */
|
|
10110
|
+
const NO_BACKGROUND = /^bg-(?:transparent|current|inherit|none)$/;
|
|
10111
|
+
/** Style properties that set an element's background, and values that set none. */
|
|
10112
|
+
const STYLE_BACKGROUNDS = /* @__PURE__ */ new Set([
|
|
10113
|
+
"background",
|
|
10114
|
+
"backgroundColor",
|
|
10115
|
+
"backgroundImage"
|
|
10116
|
+
]);
|
|
10117
|
+
const NO_STYLE_BACKGROUND = /^(?:transparent|none|inherit|initial|unset|revert|currentcolor)$/i;
|
|
10118
|
+
/** `var(--primary)`: a custom property, which can be a token's. */
|
|
10119
|
+
const CSS_VARIABLE = /^var\(\s*(--[\w-]+)\s*\)$/;
|
|
10120
|
+
/**
|
|
10121
|
+
* How far apart in lightness (OKLCH L) white or black text and its surface must be for the
|
|
10122
|
+
* surface to be the text's own: white on `bg-indigo-600` or `bg-orange-500` (0.705), not on
|
|
10123
|
+
* `bg-amber-400` or `bg-muted`; black on `bg-yellow-300`, not on `bg-indigo-600`. A token is
|
|
10124
|
+
* judged in each mode the text shows in: shadcn/ui's `primary` is near white in dark mode, so
|
|
10125
|
+
* white text on `bg-primary` is not its own there.
|
|
10126
|
+
*/
|
|
10127
|
+
const SURFACE_CONTRAST = .25;
|
|
10128
|
+
/** Whether text of this color stands out from a surface of this color. */
|
|
10129
|
+
function standsOut(surface, text) {
|
|
10130
|
+
return Math.abs(surface.l - text.l) >= SURFACE_CONTRAST;
|
|
10131
|
+
}
|
|
10132
|
+
/**
|
|
10133
|
+
* WCAG's least contrast for large text and the parts of a control. Lightness alone says too
|
|
10134
|
+
* little near black: `text-neutral-600` is 0.44 lighter than black and has 2.7:1 on it, and
|
|
10135
|
+
* `text-neutral-800` 1.6:1.
|
|
10136
|
+
*/
|
|
10137
|
+
const TEXT_CONTRAST = 3;
|
|
10138
|
+
/** How hardcoded text reads on a surface: far enough apart in lightness, and in WCAG contrast. */
|
|
10139
|
+
function legibility(surface, text) {
|
|
10140
|
+
return {
|
|
10141
|
+
apart: standsOut(surface, text),
|
|
10142
|
+
contrast: wcagContrast(surface, text) >= TEXT_CONTRAST
|
|
10143
|
+
};
|
|
10144
|
+
}
|
|
10145
|
+
/** The color a text, fill or stroke class hardcodes, or undefined when it sets none. */
|
|
10146
|
+
function paintOf(text) {
|
|
10147
|
+
if (colorRole(text.prefix) !== "text" || !text.color) return void 0;
|
|
10148
|
+
return {
|
|
10149
|
+
color: text.color,
|
|
10150
|
+
variants: parseUtility(text.value).variants,
|
|
10151
|
+
element: text.element
|
|
10152
|
+
};
|
|
10153
|
+
}
|
|
10154
|
+
/**
|
|
10155
|
+
* Where white or black text sits, which says whether the text is the surface's contrast color
|
|
10156
|
+
* (shadcn/ui's `bg-destructive text-white`) or the theme's: the background its own element sets,
|
|
10157
|
+
* in `style` or in its class list, its component's variants set (`<Button>` with `bg-*`
|
|
10158
|
+
* variants), or the nearest element around it that sets one. Any other hardcoded text is placed
|
|
10159
|
+
* the same way, to hold the fix of a background it would not stand out from.
|
|
10160
|
+
*/
|
|
10161
|
+
var Surfaces = class {
|
|
10162
|
+
context;
|
|
10163
|
+
lists;
|
|
10164
|
+
byNode = /* @__PURE__ */ new Map();
|
|
10165
|
+
tokenColors = /* @__PURE__ */ new Map();
|
|
10166
|
+
byVar = /* @__PURE__ */ new Map();
|
|
10167
|
+
/** Backgrounds an element sets in `style`: each value's color, by mode. */
|
|
10168
|
+
styled = /* @__PURE__ */ new Map();
|
|
10169
|
+
held = /* @__PURE__ */ new Set();
|
|
10170
|
+
placed = /* @__PURE__ */ new Map();
|
|
10171
|
+
constructor(context, lists) {
|
|
10172
|
+
this.context = context;
|
|
10173
|
+
this.lists = lists;
|
|
10174
|
+
for (const element of context.analysis.elements) this.byNode.set(element.node, element);
|
|
10175
|
+
for (const candidate of context.target.tokens.colors) {
|
|
10176
|
+
const { tailwind, cssVar } = candidate.token;
|
|
10177
|
+
if (tailwind) this.tokenColors.set(tailwind, candidate);
|
|
10178
|
+
if (cssVar) this.byVar.set(cssVar, candidate);
|
|
10179
|
+
}
|
|
10180
|
+
for (const { element, object } of context.analysis.styles) for (const prop of object.properties) {
|
|
10181
|
+
if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue;
|
|
10182
|
+
const name = propertyName(prop.name);
|
|
10183
|
+
if (name === void 0 || !STYLE_BACKGROUNDS.has(name)) continue;
|
|
10184
|
+
const values = ts.isPropertyAssignment(prop) ? valueBranches(prop.initializer) : [prop.name];
|
|
10185
|
+
const colors = this.styled.get(element.node) ?? [];
|
|
10186
|
+
for (const value of values) {
|
|
10187
|
+
const read = this.styleColor(value);
|
|
10188
|
+
if (read) colors.push(read);
|
|
10189
|
+
}
|
|
10190
|
+
if (colors.length) this.styled.set(element.node, colors);
|
|
10191
|
+
}
|
|
8669
10192
|
}
|
|
8670
|
-
|
|
8671
|
-
|
|
8672
|
-
|
|
8673
|
-
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
text:
|
|
8680
|
-
|
|
10193
|
+
/**
|
|
10194
|
+
* Where the text sits, in each mode it shows in: a `dark:` class in dark mode only, another
|
|
10195
|
+
* one in its base mode and in dark mode, unless its class list sets its dark color itself
|
|
10196
|
+
* (`text-black dark:text-white`). It is the surface's own only if it stands out in each.
|
|
10197
|
+
*/
|
|
10198
|
+
place(list, text) {
|
|
10199
|
+
let placed = this.placed.get(text);
|
|
10200
|
+
if (!placed) {
|
|
10201
|
+
const paint = paintOf(text);
|
|
10202
|
+
placed = paint ? this.placeInModes(list, text, paint) : { contrasts: true };
|
|
10203
|
+
this.placed.set(text, placed);
|
|
10204
|
+
}
|
|
10205
|
+
return placed;
|
|
10206
|
+
}
|
|
10207
|
+
placeInModes(list, text, paint) {
|
|
10208
|
+
const { variants } = paint;
|
|
10209
|
+
const modes = variants.includes("dark") ? ["dark"] : hasDarkCounterpart(list, text) ? [void 0] : [void 0, "dark"];
|
|
10210
|
+
let under;
|
|
10211
|
+
for (const mode of modes) {
|
|
10212
|
+
const state = mode === "dark" && !variants.includes("dark") ? [...variants, "dark"] : variants;
|
|
10213
|
+
const placed = this.placeIn(list, paint, state, mode);
|
|
10214
|
+
if (!placed.contrasts) return placed;
|
|
10215
|
+
under ??= placed.under;
|
|
10216
|
+
}
|
|
10217
|
+
return {
|
|
10218
|
+
contrasts: true,
|
|
10219
|
+
under
|
|
10220
|
+
};
|
|
10221
|
+
}
|
|
10222
|
+
/**
|
|
10223
|
+
* Takes back the fix of a hardcoded background when hardcoded text on it stays as it is and may
|
|
10224
|
+
* no longer stand out: `bg-black text-white` or `bg-zinc-900 text-white` would become
|
|
10225
|
+
* `bg-primary text-white`, white on near white where `primary` is light (shadcn/ui's dark
|
|
10226
|
+
* mode), and `bg-white text-neutral-800` would become `bg-background text-neutral-800`, dark
|
|
10227
|
+
* gray on near black. The text is on the backgrounds of its own class list, in their states too
|
|
10228
|
+
* (`hover:bg-black`), and on the surface `place` finds for it.
|
|
10229
|
+
*/
|
|
10230
|
+
holdUnderText(text, list) {
|
|
10231
|
+
this.holdUnder(text, list, list);
|
|
10232
|
+
const { under } = this.place(list, text);
|
|
10233
|
+
if (under && under !== list) this.holdUnder(text, list, under);
|
|
10234
|
+
}
|
|
10235
|
+
/**
|
|
10236
|
+
* Takes back the fixes of backgrounds whose text color comes from around them, as text color is
|
|
10237
|
+
* inherited: in `<div className="text-neutral-800"><p className="bg-white">` the paragraph is
|
|
10238
|
+
* dark gray on white. An element in between that sets a text color, or a component, which can
|
|
10239
|
+
* set one of its own, ends it. An element with nothing in it (a skeleton's `<span />`) shows no
|
|
10240
|
+
* text.
|
|
10241
|
+
*/
|
|
10242
|
+
holdUnderInherited(stays) {
|
|
10243
|
+
for (const list of this.lists.values()) {
|
|
10244
|
+
const element = list[0]?.element;
|
|
10245
|
+
if (!element || !element.children.some((c) => !(ts.isJsxText(c) && c.containsOnlyTriviaWhiteSpaces)) || !list.some((listed) => listed.prefix === "bg" && listed.report?.fix)) continue;
|
|
10246
|
+
for (let node = element.node; node; node = parentOf(node)) {
|
|
10247
|
+
const around = this.byNode.get(node);
|
|
10248
|
+
if (!around) continue;
|
|
10249
|
+
if (this.context.resolve(around).kind !== "intrinsic") break;
|
|
10250
|
+
const aroundList = this.lists.get(around) ?? [];
|
|
10251
|
+
const texts = aroundList.filter((listed) => listed.prefix === "text" && !parseUtility(listed.value).variants.some(stylesAnother));
|
|
10252
|
+
if (!texts.length) continue;
|
|
10253
|
+
if (around !== element) {
|
|
10254
|
+
for (const text of texts) if (stays.has(text)) this.holdUnder(text, aroundList, list);
|
|
10255
|
+
}
|
|
10256
|
+
break;
|
|
10257
|
+
}
|
|
10258
|
+
}
|
|
10259
|
+
}
|
|
10260
|
+
/**
|
|
10261
|
+
* Takes back the fixes of the backgrounds in `under` that the text would not stand out from.
|
|
10262
|
+
* The text loses a token in a mode where it has less than it had on the hardcoded background:
|
|
10263
|
+
* 0.25 of lightness apart, or 3:1 contrast. A token without other modes may still have a dark
|
|
10264
|
+
* one onsystem does not read (`.dark:not(.light)`), so it counts as changing there. The fix
|
|
10265
|
+
* stays when the token keeps that in every mode the text shows in, or the class lists set the
|
|
10266
|
+
* dark-mode background or text (`dark:bg-zinc-900`, `dark:text-white`).
|
|
10267
|
+
*/
|
|
10268
|
+
holdUnder(text, list, under) {
|
|
10269
|
+
const paint = paintOf(text);
|
|
10270
|
+
if (!paint) return;
|
|
10271
|
+
const textIsDark = paint.variants.includes("dark");
|
|
10272
|
+
const textInDark = textIsDark || !hasDarkCounterpart(list, text);
|
|
10273
|
+
for (const surface of under) {
|
|
10274
|
+
const { report, color: surfaceColor } = surface;
|
|
10275
|
+
const fix = report?.fix?.[0];
|
|
10276
|
+
if (!report || !fix || !surfaceColor || surface.prefix !== "bg" || this.held.has(surface)) continue;
|
|
10277
|
+
const surfaceUtility = parseUtility(surface.value);
|
|
10278
|
+
if (surfaceUtility.variants.includes("dark")) continue;
|
|
10279
|
+
const before = legibility(surfaceColor, paint.color);
|
|
10280
|
+
if (!before.apart && !before.contrast) continue;
|
|
10281
|
+
const token = this.fixedTo(fix.text);
|
|
10282
|
+
if (!token) continue;
|
|
10283
|
+
const surfaceInDark = !hasDarkCounterpart(under, surface);
|
|
10284
|
+
const modes = Object.entries(token.modes ?? {});
|
|
10285
|
+
const known = modes.length > 0;
|
|
10286
|
+
if (!known) modes.push(["dark", void 0]);
|
|
10287
|
+
const lost = modes.filter(([mode, value]) => {
|
|
10288
|
+
if (!(mode === "dark" ? textInDark && surfaceInDark : !textIsDark)) return false;
|
|
10289
|
+
if (value === void 0) return true;
|
|
10290
|
+
const after = legibility(value, paint.color);
|
|
10291
|
+
return before.apart && !after.apart || before.contrast && !after.contrast;
|
|
10292
|
+
}).map(([mode]) => mode);
|
|
10293
|
+
if (!lost.length) continue;
|
|
10294
|
+
this.held.add(surface);
|
|
10295
|
+
const textUtility = parseUtility(text.value);
|
|
10296
|
+
const shade = KEYWORD_CLASS.exec(textUtility.base)?.[2] ?? "as it is";
|
|
10297
|
+
const name = token.token.name;
|
|
10298
|
+
const why = !known ? `and ${name} may be another color in a dark mode` : `which would not stand out from ${name} in ${lost.includes("dark") ? "dark mode" : `the ${lost[0] ?? ""} mode`}`;
|
|
10299
|
+
const variants = [...textUtility.variants];
|
|
10300
|
+
if (under === list) {
|
|
10301
|
+
for (const variant of surfaceUtility.variants) if (!variants.includes(variant)) variants.push(variant);
|
|
10302
|
+
}
|
|
10303
|
+
const pair = text.tinted ?? isTinted(paint.color) ? void 0 : this.foregroundOf(token, {
|
|
10304
|
+
...textUtility,
|
|
10305
|
+
variants
|
|
10306
|
+
}, text.prefix);
|
|
10307
|
+
report.message += ` \`${text.value}\` on it stays ${shade}, ${why}, so this is not applied automatically: change the text with it${pair ? `, to \`${pair}\`` : ""}.`;
|
|
10308
|
+
report.fix = void 0;
|
|
10309
|
+
}
|
|
10310
|
+
}
|
|
10311
|
+
/** The token a background's fix puts in: `bg-primary`, or `bg-[var(--chart-bg)]`. */
|
|
10312
|
+
fixedTo(fixed) {
|
|
10313
|
+
const utility = parseUtility(fixed);
|
|
10314
|
+
const variable = utility.arbitrary === void 0 ? void 0 : CSS_VARIABLE.exec(utility.arbitrary.trim())?.[1];
|
|
10315
|
+
if (variable) return this.byVar.get(variable);
|
|
10316
|
+
return this.tokenColors.get(utility.base.slice(3));
|
|
10317
|
+
}
|
|
10318
|
+
/** The text class of the token made for text on `surface`: `text-foreground` on `background`. */
|
|
10319
|
+
foregroundOf(surface, text, prefix) {
|
|
10320
|
+
const key = surface.token.tailwind;
|
|
10321
|
+
if (!key || !prefix) return void 0;
|
|
10322
|
+
const pair = key === "background" ? "foreground" : `${key}-foreground`;
|
|
10323
|
+
return this.tokenColors.has(pair) ? withBase(text, `${prefix}-${pair}`) : void 0;
|
|
10324
|
+
}
|
|
10325
|
+
/**
|
|
10326
|
+
* Where the text sits in one mode, and the class list that says so. Under a tint it does not
|
|
10327
|
+
* stand out from (`bg-white/10` under white text), what is under the tint decides; with nothing
|
|
10328
|
+
* under it in the file, the page's `background` in that mode does.
|
|
10329
|
+
*/
|
|
10330
|
+
placeIn(list, text, state, mode) {
|
|
10331
|
+
const { color, element } = text;
|
|
10332
|
+
/** Whether the text is under a tint it does not stand out from, which shows what is under it. */
|
|
10333
|
+
const seen = { through: false };
|
|
10334
|
+
/** What an element's `style`, then its class list, says: undefined to look further out. */
|
|
10335
|
+
const at = (node, classes, component) => {
|
|
10336
|
+
const styled = node ? this.styleVerdict(node, color, mode) : void 0;
|
|
10337
|
+
if (styled === "through") seen.through = true;
|
|
10338
|
+
if (styled !== void 0) return styled === "through" ? void 0 : { contrasts: styled };
|
|
10339
|
+
const says = this.verdict(classes, color, state, mode);
|
|
10340
|
+
if (says === "through") seen.through = true;
|
|
10341
|
+
else if (says !== void 0) return {
|
|
10342
|
+
contrasts: says,
|
|
10343
|
+
under: classes
|
|
10344
|
+
};
|
|
10345
|
+
if (component && variantProp(this.context, component, "bg")) return { contrasts: true };
|
|
10346
|
+
};
|
|
10347
|
+
const own = at(element?.node, list, element);
|
|
10348
|
+
if (own) return own;
|
|
10349
|
+
for (let node = element?.node.parent; node; node = parentOf(node)) {
|
|
10350
|
+
const around = this.byNode.get(node);
|
|
10351
|
+
if (!around) continue;
|
|
10352
|
+
const placed = at(node, this.lists.get(around) ?? [], around);
|
|
10353
|
+
if (placed) return placed;
|
|
10354
|
+
}
|
|
10355
|
+
if (!seen.through) return { contrasts: false };
|
|
10356
|
+
const page = this.tokenColors.get("background");
|
|
10357
|
+
return { contrasts: !page || standsOut(valueIn(page, mode), color) };
|
|
10358
|
+
}
|
|
10359
|
+
/**
|
|
10360
|
+
* What a class list's background says for the text, or undefined when it sets none. Any
|
|
10361
|
+
* background the text stands out from is its own: a list holds the classes of every branch
|
|
10362
|
+
* of a `cn()` object, which never apply together. Then a tint it does not stand out from,
|
|
10363
|
+
* whose surface is under it: in dark mode, `bg-neutral-50 dark:bg-white/10` is a tint over the
|
|
10364
|
+
* page.
|
|
10365
|
+
*/
|
|
10366
|
+
verdict(list, text, state, mode) {
|
|
10367
|
+
let verdict;
|
|
10368
|
+
for (const { value } of list) {
|
|
10369
|
+
const says = this.excuses(parseUtility(value), text, state, mode);
|
|
10370
|
+
if (says === true) return true;
|
|
10371
|
+
if (says === "through" || verdict === void 0) verdict = says ?? verdict;
|
|
10372
|
+
}
|
|
10373
|
+
return verdict;
|
|
10374
|
+
}
|
|
10375
|
+
/**
|
|
10376
|
+
* What a background set in `style` says for the text: an inline style is over the classes.
|
|
10377
|
+
* A color known only at run time (a swatch) or an image is a surface of its own.
|
|
10378
|
+
*/
|
|
10379
|
+
styleVerdict(node, text, mode) {
|
|
10380
|
+
const colors = this.styled.get(node);
|
|
10381
|
+
if (!colors) return void 0;
|
|
10382
|
+
let verdict = "through";
|
|
10383
|
+
for (const read of colors) {
|
|
10384
|
+
const color = read(mode);
|
|
10385
|
+
if (color === "unknown" || standsOut(color, text)) verdict = true;
|
|
10386
|
+
else if ((color.alpha ?? 1) >= 1) return false;
|
|
10387
|
+
}
|
|
10388
|
+
return verdict;
|
|
10389
|
+
}
|
|
10390
|
+
/** How a style value colors the background: undefined when it sets none. */
|
|
10391
|
+
styleColor(value) {
|
|
10392
|
+
if (value.kind === ts.SyntaxKind.NullKeyword) return void 0;
|
|
10393
|
+
if (ts.isIdentifier(value) && value.text === "undefined") return void 0;
|
|
10394
|
+
if (!ts.isStringLiteralLike(value)) return () => "unknown";
|
|
10395
|
+
const text = value.text.trim();
|
|
10396
|
+
if (!text || NO_STYLE_BACKGROUND.test(text)) return void 0;
|
|
10397
|
+
const variable = CSS_VARIABLE.exec(text)?.[1];
|
|
10398
|
+
const token = variable ? this.byVar.get(variable) : void 0;
|
|
10399
|
+
if (token) return (mode) => valueIn(token, mode);
|
|
10400
|
+
const color = parseColor(text);
|
|
10401
|
+
return () => color ?? "unknown";
|
|
10402
|
+
}
|
|
10403
|
+
/**
|
|
10404
|
+
* Whether a class sets a surface the text contrasts with: a gradient, an image, or a color far
|
|
10405
|
+
* enough from the text's in lightness. An opaque `bg-white` or `bg-black` is the page's own
|
|
10406
|
+
* color, as hardcoded as the text. A tint (`bg-black/60`) is a surface where it stands out;
|
|
10407
|
+
* `bg-white/10` under white text shows what is under it (`through`). Undefined for a class
|
|
10408
|
+
* that sets no background, or sets one only in a state the text is not in: `hover:bg-red-600`
|
|
10409
|
+
* is the surface of `hover:text-white`, not of `text-white`.
|
|
10410
|
+
*/
|
|
10411
|
+
excuses(utility, text, state, mode) {
|
|
10412
|
+
const { base, modifier } = utility;
|
|
10413
|
+
if (!utility.variants.every((variant) => state.includes(variant))) return void 0;
|
|
10414
|
+
if (/^(?:from|via)-/.test(base)) return true;
|
|
10415
|
+
if (!base.startsWith("bg-") || NO_BACKGROUND.test(base)) return void 0;
|
|
10416
|
+
if (keywordColor(utility, this.context.target.tokens.colorKeys)) return false;
|
|
10417
|
+
const color = this.backgroundColor(utility, mode);
|
|
10418
|
+
if (color === "unknown") return true;
|
|
10419
|
+
const contrasts = standsOut(color, text);
|
|
10420
|
+
return modifier && !contrasts ? "through" : contrasts;
|
|
10421
|
+
}
|
|
10422
|
+
/** The color a `bg-*` class sets in a mode: a token's, the palette's, or written in it. */
|
|
10423
|
+
backgroundColor(utility, mode) {
|
|
10424
|
+
const key = utility.base.slice(3);
|
|
10425
|
+
const token = this.tokenColors.get(key);
|
|
10426
|
+
if (token) return valueIn(token, mode);
|
|
10427
|
+
const palette = paletteColor(utility.base);
|
|
10428
|
+
if (palette) return parseColor(palette.value) ?? "unknown";
|
|
10429
|
+
const keyword = TAILWIND_KEYWORD_COLORS[key];
|
|
10430
|
+
if (keyword) return parseColor(keyword) ?? "unknown";
|
|
10431
|
+
if (utility.arbitrary === void 0) return "unknown";
|
|
10432
|
+
const value = utility.arbitrary.replace(/^color:/, "").trim();
|
|
10433
|
+
const shade = PALETTE_VARIABLE.exec(value)?.[1];
|
|
10434
|
+
const variable = shade ? void 0 : CSS_VARIABLE.exec(value)?.[1];
|
|
10435
|
+
const viaVariable = variable ? this.byVar.get(variable) : void 0;
|
|
10436
|
+
if (viaVariable) return valueIn(viaVariable, mode);
|
|
10437
|
+
const color = shade ? paletteColor(`bg-${shade}`)?.value : value;
|
|
10438
|
+
return color && parseColor(color) || "unknown";
|
|
10439
|
+
}
|
|
10440
|
+
};
|
|
10441
|
+
/**
|
|
10442
|
+
* Whether the class list sets the color of `listed` for dark mode too: `text-black dark:text-white`,
|
|
10443
|
+
* `bg-white dark:bg-zinc-900`. Only a class that adds `dark:` and nothing else to its variants,
|
|
10444
|
+
* whatever its color: `dark:bg-darkgray-50` names a color of a config onsystem may not read.
|
|
10445
|
+
*/
|
|
10446
|
+
function hasDarkCounterpart(list, listed) {
|
|
10447
|
+
const { prefix } = listed;
|
|
10448
|
+
if (prefix === void 0) return false;
|
|
10449
|
+
const { variants } = parseUtility(listed.value);
|
|
10450
|
+
if (variants.includes("dark")) return false;
|
|
10451
|
+
return list.some((other) => {
|
|
10452
|
+
if (other === listed) return false;
|
|
10453
|
+
const theirs = parseUtility(other.value);
|
|
10454
|
+
if (other.prefix !== prefix && !(other.prefix === void 0 && setsColor(theirs, prefix))) return false;
|
|
10455
|
+
return theirs.variants.includes("dark") && theirs.variants.every((variant) => variant === "dark" || variants.includes(variant)) && variants.every((variant) => theirs.variants.includes(variant));
|
|
8681
10456
|
});
|
|
8682
10457
|
}
|
|
10458
|
+
/** Utilities of a color prefix that set no color: `bg-cover`, `text-sm`, `stroke-2`. */
|
|
10459
|
+
const NOT_A_COLOR = {
|
|
10460
|
+
bg: /^bg-(?:auto|cover|contain|fixed|local|scroll|repeat.*|no-repeat|center|(?:top|bottom|left|right)(?:-.*)?|clip-.*|origin-.*|blend-.*|gradient-.*|linear.*|radial.*|conic.*|none|size-.*|position-.*|\[url\(.*)$/,
|
|
10461
|
+
text: /^text-(?:xs|sm|base|lg|\d?xl|left|center|right|justify|start|end|wrap|nowrap|balance|pretty|ellipsis|clip|\[\d.*)$/,
|
|
10462
|
+
fill: /^fill-none$/,
|
|
10463
|
+
stroke: /^stroke-(?:none|\d.*|\[\d.*)$/
|
|
10464
|
+
};
|
|
10465
|
+
/** Whether a class of no known color sets the color of `prefix`: `dark:bg-darkgray-50`. */
|
|
10466
|
+
function setsColor(utility, prefix) {
|
|
10467
|
+
if (!utility.base.startsWith(`${prefix}-`) || COLOR_UTILITY.exec(utility.base) === null) return false;
|
|
10468
|
+
if (utility.prefix !== void 0 && utility.prefix !== prefix) return false;
|
|
10469
|
+
return !NOT_A_COLOR[prefix]?.test(utility.base);
|
|
10470
|
+
}
|
|
10471
|
+
/**
|
|
10472
|
+
* Takes back a fix that would make a class under a condition (`hover:`, `focus-visible:`, `md:`)
|
|
10473
|
+
* the same color as the class without it: `bg-[#2563eb] hover:bg-[#1d4ed8]` would become
|
|
10474
|
+
* `bg-primary hover:bg-primary`, and the hover would change nothing. The state class gives way
|
|
10475
|
+
* first; a base class does when the state class already has the token it would get. `dark:` is
|
|
10476
|
+
* no such condition: dark classes are compared with the tokens' dark values, where the same
|
|
10477
|
+
* token is right. Two hardcoded classes of exactly the same color keep their fixes: the
|
|
10478
|
+
* condition changed nothing before either (`border-red-500 focus:border-red-500` keeps an error
|
|
10479
|
+
* border red on focus), and a hardcoded color is the same in every mode. A token class is not:
|
|
10480
|
+
* the class list's `bg-primary` is another color in dark mode than a `hover:bg-[#171717]` next
|
|
10481
|
+
* to it. A `dark:` state class starts from the list's `dark:` class, when it has one.
|
|
10482
|
+
*/
|
|
10483
|
+
function keepStatesApart(list) {
|
|
10484
|
+
const after = (listed) => listed.report?.fix?.[0]?.text ?? listed.value;
|
|
10485
|
+
for (const pass of ["state", "base"]) for (const listed of list) {
|
|
10486
|
+
const { report } = listed;
|
|
10487
|
+
if (!report?.fix?.[0]) continue;
|
|
10488
|
+
for (const other of list) {
|
|
10489
|
+
if (other === listed || oneColor(listed.color, other.color)) continue;
|
|
10490
|
+
const [stateListed, baseListed] = pass === "state" ? [listed, other] : [other, listed];
|
|
10491
|
+
const state = parseUtility(after(stateListed));
|
|
10492
|
+
const base = parseUtility(after(baseListed));
|
|
10493
|
+
const conditions = conditionsBetween(state, base);
|
|
10494
|
+
if (!conditions || hasDarkBase(list, stateListed, baseListed, state, base, after)) continue;
|
|
10495
|
+
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`;
|
|
10496
|
+
const apart = state.modifier ? "such as with another opacity modifier" : `such as with \`${withBase({
|
|
10497
|
+
...state,
|
|
10498
|
+
modifier: "/90"
|
|
10499
|
+
}, state.base)}\``;
|
|
10500
|
+
report.message += ` ${had}, so with this fix \`${conditions}\` would change nothing: it is not applied automatically. Keep the two apart, ${apart}.`;
|
|
10501
|
+
report.fix = void 0;
|
|
10502
|
+
break;
|
|
10503
|
+
}
|
|
10504
|
+
}
|
|
10505
|
+
}
|
|
10506
|
+
/**
|
|
10507
|
+
* Below this ΔE two hardcoded colors are one color written two ways: a palette class and its
|
|
10508
|
+
* hex (`neutral-100` is 0.00015 from `#f5f5f5`), or an OKLCH value rounded to hex (red-500 is
|
|
10509
|
+
* 0.00097 from `#fb2c36`). SAME_COLOR is too loose for this: `#fafafa` is 0.015 from `#fff`,
|
|
10510
|
+
* and a hover from one to the other is a change the fix must not remove.
|
|
10511
|
+
*/
|
|
10512
|
+
const ONE_COLOR = .003;
|
|
10513
|
+
/** Whether two hardcoded colors are exactly one color, so one token for both changes nothing. */
|
|
10514
|
+
function oneColor(a, b) {
|
|
10515
|
+
return a !== void 0 && b !== void 0 && colorDistance(a, b) < ONE_COLOR;
|
|
10516
|
+
}
|
|
10517
|
+
/**
|
|
10518
|
+
* Whether the class list has the base class's dark-mode counterpart, which a `dark:` state class
|
|
10519
|
+
* starts from instead: with `bg-[#171717] dark:bg-[#0a0a0a]`, `dark:hover:` changes the dark
|
|
10520
|
+
* background, not `bg-[#171717]`. Only a class that adds `dark:` and nothing else to the base
|
|
10521
|
+
* class's variants counts: one that adds a condition (`group-hover:`) is off with it.
|
|
10522
|
+
*/
|
|
10523
|
+
function hasDarkBase(list, stateListed, baseListed, state, base, after) {
|
|
10524
|
+
if (baseListed.prefix === void 0) return false;
|
|
10525
|
+
if (base.variants.includes("dark") || !state.variants.includes("dark")) return false;
|
|
10526
|
+
return list.some((other) => {
|
|
10527
|
+
if (other === stateListed || other === baseListed || other.prefix !== baseListed.prefix) return false;
|
|
10528
|
+
const { variants } = parseUtility(after(other));
|
|
10529
|
+
return variants.includes("dark") && variants.every((variant) => variant === "dark" || base.variants.includes(variant)) && base.variants.every((variant) => variants.includes(variant));
|
|
10530
|
+
});
|
|
10531
|
+
}
|
|
10532
|
+
/**
|
|
10533
|
+
* The conditions (`hover:`) under which `state` applies and `base` does too, when both are the
|
|
10534
|
+
* same color: undefined when they are not, or `state` adds no condition but `dark:`, or styles
|
|
10535
|
+
* another element (`placeholder:`, `prose-a:`), whose color the base class does not set.
|
|
10536
|
+
*/
|
|
10537
|
+
function conditionsBetween(state, base) {
|
|
10538
|
+
if (state.base !== base.base || state.modifier !== base.modifier) return void 0;
|
|
10539
|
+
if (!base.variants.every((variant) => state.variants.includes(variant))) return void 0;
|
|
10540
|
+
const added = state.variants.filter((v) => v !== "dark" && !base.variants.includes(v));
|
|
10541
|
+
if (added.some(stylesAnother)) return void 0;
|
|
10542
|
+
return added.length ? `${added.join(":")}:` : void 0;
|
|
10543
|
+
}
|
|
10544
|
+
/** Variants for a pseudo-element or other elements than the one the class is on. */
|
|
10545
|
+
const OTHER_ELEMENT = /* @__PURE__ */ new Set([
|
|
10546
|
+
"*",
|
|
10547
|
+
"**",
|
|
10548
|
+
"after",
|
|
10549
|
+
"backdrop",
|
|
10550
|
+
"before",
|
|
10551
|
+
"details-content",
|
|
10552
|
+
"file",
|
|
10553
|
+
"first-letter",
|
|
10554
|
+
"first-line",
|
|
10555
|
+
"marker",
|
|
10556
|
+
"placeholder",
|
|
10557
|
+
"selection"
|
|
10558
|
+
]);
|
|
10559
|
+
/**
|
|
10560
|
+
* Whether a variant styles something else than the element: a pseudo-element (`placeholder:`),
|
|
10561
|
+
* children (`*:`), the typography plugin's elements (`prose-a:`), or an arbitrary selector that
|
|
10562
|
+
* goes on from `&` (`[&>svg]:`, `[&_a]:`, `[&::after]:`), not one that only narrows it
|
|
10563
|
+
* (`[&:hover]:`, `[.dark_&]:`).
|
|
10564
|
+
*/
|
|
10565
|
+
function stylesAnother(variant) {
|
|
10566
|
+
if (OTHER_ELEMENT.has(variant) || variant.startsWith("prose-")) return true;
|
|
10567
|
+
const selector = /^\[(.*)\]$/.exec(variant)?.[1];
|
|
10568
|
+
if (!selector?.includes("&")) return false;
|
|
10569
|
+
const after = selector.slice(selector.lastIndexOf("&") + 1).replace(/\([^()]*\)|\[[^\]]*\]/g, "");
|
|
10570
|
+
return /[_>+~]|::/.test(after);
|
|
10571
|
+
}
|
|
10572
|
+
/**
|
|
10573
|
+
* When no token has the color's hue: add one, or keep the color and say why. Between JSX
|
|
10574
|
+
* children a `//` line is text, which suppresses nothing and renders, so that place gets the
|
|
10575
|
+
* `{/* *\/}` form.
|
|
10576
|
+
*/
|
|
10577
|
+
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> */}`).";
|
|
10578
|
+
/**
|
|
10579
|
+
* The most Tailwind v3 and v4 differ on one shade, in ΔE: 0.036 for fuchsia-600,
|
|
10580
|
+
* under 0.01 for the grays. A token this close to a class's shade, and nearer it
|
|
10581
|
+
* than to the family's other shades, can be a v3 project's copy of it.
|
|
10582
|
+
*/
|
|
10583
|
+
const PALETTE_DRIFT = .037;
|
|
10584
|
+
/** Tailwind's default palette as colors: family → its shades, lightest first. */
|
|
10585
|
+
const PALETTE_SHADES = /* @__PURE__ */ new Map();
|
|
10586
|
+
/** A family's shades as colors, lightest first: `neutral` → 50, 100, … 950. */
|
|
10587
|
+
function shadesOf(family) {
|
|
10588
|
+
let shades = PALETTE_SHADES.get(family);
|
|
10589
|
+
if (!shades) {
|
|
10590
|
+
shades = [];
|
|
10591
|
+
for (const [shade, value] of Object.entries(TAILWIND_PALETTE[family] ?? {})) {
|
|
10592
|
+
const parsed = parseColor(value);
|
|
10593
|
+
if (parsed) shades.push([shade, parsed]);
|
|
10594
|
+
}
|
|
10595
|
+
PALETTE_SHADES.set(family, shades);
|
|
10596
|
+
}
|
|
10597
|
+
return shades;
|
|
10598
|
+
}
|
|
10599
|
+
/** Index of the color in `colors` nearest to `color`, or -1 when there is none. */
|
|
10600
|
+
function nearestOf(colors, color) {
|
|
10601
|
+
let index = -1;
|
|
10602
|
+
let best = Infinity;
|
|
10603
|
+
colors.forEach(([, value], i) => {
|
|
10604
|
+
const distance = colorDistance(color, value);
|
|
10605
|
+
if (distance < best) [index, best] = [i, distance];
|
|
10606
|
+
});
|
|
10607
|
+
return index;
|
|
10608
|
+
}
|
|
10609
|
+
/** The palette color nearest to `color` across every family (`neutral-500`). */
|
|
10610
|
+
function nearestPaletteColor(color) {
|
|
10611
|
+
const all = Object.keys(TAILWIND_PALETTE).flatMap((family) => shadesOf(family).map(([shade, value]) => [`${family}-${shade}`, value]));
|
|
10612
|
+
return all[nearestOf(all, color)]?.[0];
|
|
10613
|
+
}
|
|
10614
|
+
/**
|
|
10615
|
+
* Why a token that is not an exact match for a palette class (`key`, `neutral-600`)
|
|
10616
|
+
* is suggested but not applied, or undefined when it may be: it is nearer another
|
|
10617
|
+
* shade of the family (Dub's `content-default`, neutral-700, for `text-neutral-600`),
|
|
10618
|
+
* or it is the class's shade but further from it than Tailwind v3 and v4 are from
|
|
10619
|
+
* each other (`content-subtle`, neutral-500, for `text-slate-500`).
|
|
10620
|
+
*/
|
|
10621
|
+
function heldBack(key, color, distance) {
|
|
10622
|
+
const [family = "", own = ""] = key.split("-");
|
|
10623
|
+
const shades = shadesOf(family);
|
|
10624
|
+
const from = shades.findIndex(([shade]) => shade === own);
|
|
10625
|
+
const to = nearestOf(shades, color);
|
|
10626
|
+
if (from === -1 || to === -1) return void 0;
|
|
10627
|
+
const steps = to - from;
|
|
10628
|
+
let what;
|
|
10629
|
+
let intended;
|
|
10630
|
+
if (steps) {
|
|
10631
|
+
const count = Math.abs(steps) === 1 ? "one shade" : `${String(Math.abs(steps))} shades`;
|
|
10632
|
+
const way = steps > 0 ? "darker" : "lighter";
|
|
10633
|
+
what = `${count} ${way} (${family}-${shades[to]?.[0] ?? ""})`;
|
|
10634
|
+
intended = `the ${way} color`;
|
|
10635
|
+
} else {
|
|
10636
|
+
if (distance < PALETTE_DRIFT) return void 0;
|
|
10637
|
+
const closest = nearestPaletteColor(color);
|
|
10638
|
+
what = closest && closest !== key ? `closest to ${closest}, not ${key}` : `a different color from ${key}`;
|
|
10639
|
+
intended = "that color";
|
|
10640
|
+
}
|
|
10641
|
+
return `That is ${what}, so it is not applied automatically: use it if ${intended} is intended, otherwise pick the semantic token that fits.`;
|
|
10642
|
+
}
|
|
10643
|
+
/** A token's value where the class applies: a `dark:` class takes its dark-mode value. */
|
|
10644
|
+
function valueIn(candidate, mode) {
|
|
10645
|
+
return mode && candidate.modes?.[mode] || candidate.color;
|
|
10646
|
+
}
|
|
10647
|
+
/** `content-subtle has this color (exact match), but it is meant for text.` */
|
|
10648
|
+
function meantForAnother(other, color, mode) {
|
|
10649
|
+
if (!other) return "";
|
|
10650
|
+
const { token } = other.candidate;
|
|
10651
|
+
const role = tokenRole(token);
|
|
10652
|
+
const how = other.distance < .02 ? "has this color" : "is close";
|
|
10653
|
+
const inMode = mode && token.modes?.[mode] !== void 0 ? ` in ${mode} mode` : "";
|
|
10654
|
+
return ` ${token.name} ${how}${inMode} (${formatDistance(color, valueIn(other.candidate, mode))}), but it is meant for ${role ? ROLE_WORDS[role] : "another role"}.`;
|
|
10655
|
+
}
|
|
8683
10656
|
const ROLE_WORDS = {
|
|
8684
10657
|
text: "text",
|
|
8685
10658
|
surface: "backgrounds",
|
|
8686
10659
|
line: "borders"
|
|
8687
10660
|
};
|
|
8688
|
-
/**
|
|
8689
|
-
|
|
10661
|
+
/**
|
|
10662
|
+
* Why no token replaces the color, naming the nearest one (`found`). A tinted color
|
|
10663
|
+
* with no token of its hue gets `NO_TOKEN_HAS_HUE` instead, which names none.
|
|
10664
|
+
*/
|
|
10665
|
+
function noMatch(reason, role, found, hueOff) {
|
|
8690
10666
|
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
10667
|
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
|
-
}
|
|
10668
|
+
if (reason === "hue") return `No gray token is close; nearest is ${found}, which is tinted.`;
|
|
8696
10669
|
if (reason === "role" && role) return `No close token is meant for ${ROLE_WORDS[role]}; nearest is ${found}.`;
|
|
8697
10670
|
return `No close token; nearest is ${found}.`;
|
|
8698
10671
|
}
|
|
@@ -8758,17 +10731,19 @@ function lengthRule(id, description, spec) {
|
|
|
8758
10731
|
if (negative && !NEGATIVE_PREFIXES.has(utility.prefix)) continue;
|
|
8759
10732
|
const replacement = lengthReplacement(context, spec.category, px, utility.prefix);
|
|
8760
10733
|
if (!replacement || unnamed(replacement)) continue;
|
|
10734
|
+
if (spec.category === "spacing" && Math.abs(px) <= 1 && (!replacement.exact || negative)) continue;
|
|
8761
10735
|
const fixed = withBase({
|
|
8762
10736
|
...utility,
|
|
8763
10737
|
negative
|
|
8764
10738
|
}, replacement.cls);
|
|
8765
|
-
const
|
|
10739
|
+
const relative = !replacement.pill && FONT_RELATIVE.test(utility.arbitrary.trim());
|
|
10740
|
+
const message = relative ? `\`${token.value}\` is font-relative: ${utility.arbitrary.trim()} is ${formatPx(px)} only at a 16px font. Nearest step: \`${fixed}\` (${formatPx(replacement.px)}), which does not grow with the font, so it is not applied automatically.` : replacement.exact ? `\`${token.value}\` is ${formatPx(px)}, which is on the ${spec.category} scale: use \`${fixed}\`.` : replacement.pill ? `\`${token.value}\` (${formatPx(px)}) is far above the radius scale, so it reads as fully rounded: use \`${fixed}\`.` : `Hardcoded ${spec.category} \`${token.value}\` (${formatPx(px)}) is off the scale. Nearest: \`${fixed}\` (${formatPx(replacement.px)})` + (replacement.far ? ", too far off to replace automatically." : ".");
|
|
8766
10741
|
context.report({
|
|
8767
10742
|
start: token.start,
|
|
8768
10743
|
end: token.end,
|
|
8769
10744
|
message,
|
|
8770
10745
|
suggestion: fixed,
|
|
8771
|
-
fix: replacement.far ? void 0 : [{
|
|
10746
|
+
fix: replacement.far || relative ? void 0 : [{
|
|
8772
10747
|
range: [token.start, token.end],
|
|
8773
10748
|
text: fixed
|
|
8774
10749
|
}]
|
|
@@ -8791,17 +10766,19 @@ function lengthRule(id, description, spec) {
|
|
|
8791
10766
|
if (negative && !NEGATIVE_PREFIXES.has(prefix)) continue;
|
|
8792
10767
|
const replacement = values.length === 1 ? lengthReplacement(context, spec.category, px, prefix) : void 0;
|
|
8793
10768
|
if (replacement && unnamed(replacement)) continue;
|
|
10769
|
+
if (replacement && spec.category === "spacing" && Math.abs(px) <= 1 && (!replacement.exact || negative)) continue;
|
|
8794
10770
|
const cls = replacement && `${negative ? "-" : ""}${replacement.cls}`;
|
|
8795
10771
|
const cssVar = replacement?.cssVar && (negative ? `calc(${replacement.cssVar} * -1)` : replacement.cssVar);
|
|
8796
10772
|
const shown = number !== void 0 ? `${name}: ${raw}` : `${name}: "${raw}"`;
|
|
8797
10773
|
const size = replacement?.pill ? "fully rounded" : formatPx(replacement?.px ?? 0);
|
|
8798
10774
|
const advice = replacement ? replacement.tailwind ? `Use \`${cls}\` (${size}) in className instead of an inline style.` : `Use \`${cssVar ?? cls}\` (${size}).` : `Use ${spec.category} tokens instead.`;
|
|
10775
|
+
const relative = number === void 0 && !replacement?.pill && values.some((v) => FONT_RELATIVE.test(v));
|
|
8799
10776
|
context.report({
|
|
8800
10777
|
start: init.getStart(context.sourceFile),
|
|
8801
10778
|
end: init.end,
|
|
8802
|
-
message: `Hardcoded ${spec.category} \`${shown}\` in style. ${advice}`,
|
|
10779
|
+
message: relative ? `Hardcoded ${spec.category} \`${shown}\` in style, font-relative: ${formatPx(px)} only at a 16px font. ${advice} No step of the scale grows with the font, so it is not applied automatically.` : `Hardcoded ${spec.category} \`${shown}\` in style. ${advice}`,
|
|
8803
10780
|
suggestion: replacement ? replacement.tailwind ? cls : cssVar : void 0,
|
|
8804
|
-
fix: replacement && !replacement.tailwind && cssVar && replacement.exact ? [{
|
|
10781
|
+
fix: replacement && !replacement.tailwind && cssVar && replacement.exact && !relative ? [{
|
|
8805
10782
|
range: [init.getStart(context.sourceFile), init.end],
|
|
8806
10783
|
text: `"${cssVar}"`
|
|
8807
10784
|
}] : void 0
|
|
@@ -8818,6 +10795,8 @@ function lengthRule(id, description, spec) {
|
|
|
8818
10795
|
function unnamed(replacement) {
|
|
8819
10796
|
return replacement.exact && !replacement.tailwind && !replacement.cssVar;
|
|
8820
10797
|
}
|
|
10798
|
+
/** A length in `em`, relative to the element's font size (`0.5em`, not `0.5rem`). */
|
|
10799
|
+
const FONT_RELATIVE = /^-?(?:\d+\.?\d*|\.\d+)em$/i;
|
|
8821
10800
|
/** The nearest step is "far" when it is off by more than half the value and more than 4px. */
|
|
8822
10801
|
const FAR_RATIO = .5;
|
|
8823
10802
|
const FAR_PX = 4;
|
|
@@ -8963,13 +10942,14 @@ function findingKey(diagnostic) {
|
|
|
8963
10942
|
const text = diagnostic.source.replace(/\s+/g, " ").trim();
|
|
8964
10943
|
return (/^(['"`])(.*)\1$/.exec(text)?.[2] ?? text).slice(0, 200);
|
|
8965
10944
|
}
|
|
8966
|
-
/** The baseline
|
|
10945
|
+
/** The baseline, or undefined when the file does not exist. */
|
|
8967
10946
|
function readBaseline(file) {
|
|
8968
10947
|
let text;
|
|
8969
10948
|
try {
|
|
8970
10949
|
text = fs.readFileSync(file, "utf8");
|
|
8971
10950
|
} catch (error) {
|
|
8972
|
-
|
|
10951
|
+
const { code } = error;
|
|
10952
|
+
if (code === "ENOENT" || code === "ENOTDIR") return void 0;
|
|
8973
10953
|
throw new ConfigError(`Could not read baseline ${file}: ${error.message}`);
|
|
8974
10954
|
}
|
|
8975
10955
|
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 +10961,8 @@ function readBaseline(file) {
|
|
|
8981
10961
|
}
|
|
8982
10962
|
if (!isObject(parsed) || parsed.version !== 1) throw invalid("expected \"version\": 1");
|
|
8983
10963
|
if (!isObject(parsed.files)) throw invalid("\"files\" must be an object");
|
|
10964
|
+
if (parsed.onsystem !== void 0 && typeof parsed.onsystem !== "string") throw invalid("\"onsystem\" must be a version string");
|
|
10965
|
+
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
10966
|
const entries = /* @__PURE__ */ new Map();
|
|
8985
10967
|
for (const [name, rules] of Object.entries(parsed.files)) {
|
|
8986
10968
|
if (!isObject(rules)) throw invalid(`"${name}" must map rules to findings`);
|
|
@@ -8996,7 +10978,11 @@ function readBaseline(file) {
|
|
|
8996
10978
|
}
|
|
8997
10979
|
entries.set(name, byRule);
|
|
8998
10980
|
}
|
|
8999
|
-
return
|
|
10981
|
+
return {
|
|
10982
|
+
entries,
|
|
10983
|
+
onsystem: parsed.onsystem,
|
|
10984
|
+
rules: parsed.rules ? new Set(parsed.rules) : void 0
|
|
10985
|
+
};
|
|
9000
10986
|
}
|
|
9001
10987
|
/**
|
|
9002
10988
|
* Removes the findings the baseline accepts for `file`. Within a file and
|
|
@@ -9057,6 +11043,71 @@ function updateBaseline(previous, checked, root, disabled = /* @__PURE__ */ new
|
|
|
9057
11043
|
}
|
|
9058
11044
|
return next;
|
|
9059
11045
|
}
|
|
11046
|
+
/**
|
|
11047
|
+
* The rules an update records as run: those that `ran` now, those the previous baseline lists,
|
|
11048
|
+
* and those with entries in `next`, since a rule with recorded findings ran when they were
|
|
11049
|
+
* recorded. Undefined, as in a baseline written before onsystem recorded rules, when the
|
|
11050
|
+
* previous baseline does not list them and the update may have left out files it covers: the
|
|
11051
|
+
* run checked only a folder or files below the root (`wholeRoot` false), or `next` keeps
|
|
11052
|
+
* entries of files the run did not check. Whatever ran over those files was not recorded, so a
|
|
11053
|
+
* list built from this run could say a rule did not run where it did.
|
|
11054
|
+
*/
|
|
11055
|
+
function rulesRun(previous, next, checked, ran, wholeRoot) {
|
|
11056
|
+
if (previous && !previous.rules && (!wholeRoot || [...next.keys()].some((file) => !checked.has(file)))) return;
|
|
11057
|
+
const known = new Set(RULE_IDS);
|
|
11058
|
+
const rules = /* @__PURE__ */ new Set([...previous?.rules ?? [], ...ran]);
|
|
11059
|
+
for (const byRule of next.values()) for (const rule of byRule.keys()) if (known.has(rule)) rules.add(rule);
|
|
11060
|
+
return [...rules];
|
|
11061
|
+
}
|
|
11062
|
+
/**
|
|
11063
|
+
* The baseline without the entries a run over `checked` (file → its findings) shows no longer
|
|
11064
|
+
* occur: a checked file keeps at most as many of each finding as it still has, plus its entries
|
|
11065
|
+
* for `disabled` rules, and files the run did not check keep theirs while they still exist under
|
|
11066
|
+
* `root`. Nothing is added, so a new finding stays new, unlike with `updateBaseline`.
|
|
11067
|
+
*/
|
|
11068
|
+
function pruneBaseline(previous, checked, root, disabled) {
|
|
11069
|
+
const next = /* @__PURE__ */ new Map();
|
|
11070
|
+
for (const [file, rules] of previous) {
|
|
11071
|
+
const diagnostics = checked.get(file);
|
|
11072
|
+
if (!diagnostics) {
|
|
11073
|
+
if (fs.existsSync(path.resolve(root, file))) next.set(file, rules);
|
|
11074
|
+
continue;
|
|
11075
|
+
}
|
|
11076
|
+
const found = /* @__PURE__ */ new Map();
|
|
11077
|
+
for (const diagnostic of diagnostics) {
|
|
11078
|
+
const id = `${diagnostic.ruleId}\0${findingKey(diagnostic)}`;
|
|
11079
|
+
found.set(id, (found.get(id) ?? 0) + 1);
|
|
11080
|
+
}
|
|
11081
|
+
const off = disabled(file);
|
|
11082
|
+
const kept = /* @__PURE__ */ new Map();
|
|
11083
|
+
for (const [rule, sources] of rules) {
|
|
11084
|
+
if (off.has(rule)) {
|
|
11085
|
+
kept.set(rule, sources);
|
|
11086
|
+
continue;
|
|
11087
|
+
}
|
|
11088
|
+
const left = /* @__PURE__ */ new Map();
|
|
11089
|
+
for (const [source, count] of sources) {
|
|
11090
|
+
const still = Math.min(count, found.get(`${rule}\0${source}`) ?? 0);
|
|
11091
|
+
if (still) left.set(source, still);
|
|
11092
|
+
}
|
|
11093
|
+
if (left.size) kept.set(rule, left);
|
|
11094
|
+
}
|
|
11095
|
+
if (kept.size) next.set(file, kept);
|
|
11096
|
+
}
|
|
11097
|
+
return next;
|
|
11098
|
+
}
|
|
11099
|
+
/**
|
|
11100
|
+
* New findings of rules that did not run when the baseline was recorded, counted by rule: a
|
|
11101
|
+
* rule that starts to run (an upgrade that finds the tokens it needs, or a rule turned on)
|
|
11102
|
+
* reports what was in the code all along. Empty when the baseline does not say which rules ran.
|
|
11103
|
+
*/
|
|
11104
|
+
function unrecordedRules(baseline, diagnostics) {
|
|
11105
|
+
const counts = /* @__PURE__ */ new Map();
|
|
11106
|
+
if (!baseline.rules) return counts;
|
|
11107
|
+
const rules = new Set(RULE_IDS);
|
|
11108
|
+
for (const { ruleId } of diagnostics) if (rules.has(ruleId) && !baseline.rules.has(ruleId)) counts.set(ruleId, (counts.get(ruleId) ?? 0) + 1);
|
|
11109
|
+
return counts;
|
|
11110
|
+
}
|
|
9060
11111
|
function countBaseline(entries) {
|
|
9061
11112
|
let findings = 0;
|
|
9062
11113
|
for (const rules of entries.values()) for (const sources of rules.values()) for (const count of sources.values()) findings += count;
|
|
@@ -9065,8 +11116,11 @@ function countBaseline(entries) {
|
|
|
9065
11116
|
files: entries.size
|
|
9066
11117
|
};
|
|
9067
11118
|
}
|
|
9068
|
-
/**
|
|
9069
|
-
|
|
11119
|
+
/**
|
|
11120
|
+
* Writes the baseline sorted at every level, so it diffs cleanly in review, with the onsystem
|
|
11121
|
+
* version and the rules that ran when given, and creates its folder.
|
|
11122
|
+
*/
|
|
11123
|
+
function writeBaseline(file, entries, about = {}) {
|
|
9070
11124
|
const sorted = (map) => [...map].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
9071
11125
|
const files = {};
|
|
9072
11126
|
for (const [name, rules] of sorted(entries)) {
|
|
@@ -9080,9 +11134,12 @@ function writeBaseline(file, entries) {
|
|
|
9080
11134
|
}
|
|
9081
11135
|
const baseline = {
|
|
9082
11136
|
version: 1,
|
|
11137
|
+
...about.onsystem === void 0 ? {} : { onsystem: about.onsystem },
|
|
11138
|
+
...about.rules === void 0 ? {} : { rules: [...new Set(about.rules)].sort() },
|
|
9083
11139
|
files
|
|
9084
11140
|
};
|
|
9085
11141
|
try {
|
|
11142
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
9086
11143
|
fs.writeFileSync(file, `${JSON.stringify(baseline, null, 2)}\n`);
|
|
9087
11144
|
} catch (error) {
|
|
9088
11145
|
throw new ConfigError(`Could not write baseline ${file}: ${error.message}`);
|
|
@@ -9096,7 +11153,7 @@ function isObject(value) {
|
|
|
9096
11153
|
var name = "onsystem";
|
|
9097
11154
|
//#endregion
|
|
9098
11155
|
//#region src/version.ts
|
|
9099
|
-
const VERSION = "0.
|
|
11156
|
+
const VERSION = "0.8.0";
|
|
9100
11157
|
const NAME = name;
|
|
9101
11158
|
//#endregion
|
|
9102
11159
|
//#region src/lint/sarif.ts
|
|
@@ -9209,6 +11266,10 @@ function fingerprint$1(uri, identity, occurrence) {
|
|
|
9209
11266
|
}
|
|
9210
11267
|
//#endregion
|
|
9211
11268
|
//#region src/lint/format.ts
|
|
11269
|
+
/** From this many findings, `pretty` ends with a summary: more than a screen to scroll back through. */
|
|
11270
|
+
const SUMMARY_FROM = 50;
|
|
11271
|
+
/** The palette classes the summary lists. */
|
|
11272
|
+
const TOP_CLASSES = 10;
|
|
9212
11273
|
const color = (code) => (text, enabled) => enabled ? `\u001b[${code}m${text}\u001b[39m` : text;
|
|
9213
11274
|
const red = color(31);
|
|
9214
11275
|
const yellow = color(33);
|
|
@@ -9252,6 +11313,7 @@ function formatDiagnostics(results, format, options = {}) {
|
|
|
9252
11313
|
lines.push("");
|
|
9253
11314
|
}
|
|
9254
11315
|
const total = errors + warnings;
|
|
11316
|
+
if (total >= SUMMARY_FROM) lines.push(...summarize(results), "");
|
|
9255
11317
|
const notes = [options.baselined === void 0 ? "" : `${options.baselined.toLocaleString("en-US")} in the baseline`, ...options.notes ?? []].filter(Boolean);
|
|
9256
11318
|
const aside = notes.length ? ` (${notes.join("; ")})` : "";
|
|
9257
11319
|
const flagged = results.filter((r) => r.diagnostics.length).length;
|
|
@@ -9260,6 +11322,42 @@ function formatDiagnostics(results, format, options = {}) {
|
|
|
9260
11322
|
if (fixedHint) lines.push(dim(fixedHint, useColor));
|
|
9261
11323
|
return lines.join("\n");
|
|
9262
11324
|
}
|
|
11325
|
+
/**
|
|
11326
|
+
* What a long run's findings add up to: the count per rule, as `init`'s dry run gives it, and
|
|
11327
|
+
* the Tailwind palette classes found most, each with the token to use. On a palette-heavy app
|
|
11328
|
+
* (Dub's web app, over 14,000 lines of findings) that reads as a plan for moving to the tokens.
|
|
11329
|
+
*/
|
|
11330
|
+
function summarize(results) {
|
|
11331
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
11332
|
+
const pairs = /* @__PURE__ */ new Map();
|
|
11333
|
+
const paletteFiles = /* @__PURE__ */ new Set();
|
|
11334
|
+
let palette = 0;
|
|
11335
|
+
for (const result of results) for (const d of result.diagnostics) {
|
|
11336
|
+
byRule.set(d.ruleId, (byRule.get(d.ruleId) ?? 0) + 1);
|
|
11337
|
+
if (d.ruleId !== "no-hardcoded-color") continue;
|
|
11338
|
+
const utility = parseUtility(d.source);
|
|
11339
|
+
if (!paletteColor(utility.base)) continue;
|
|
11340
|
+
palette++;
|
|
11341
|
+
paletteFiles.add(result.file);
|
|
11342
|
+
const to = (d.fix?.length === 1 ? d.fix[0]?.text : void 0) ?? (d.suggestion && /^[^\s"'=]+$/.test(d.suggestion) ? withBase(utility, d.suggestion) : d.suggestion);
|
|
11343
|
+
const nearest = d.message.includes(". Nearest token ") ? " (nearest token)" : "";
|
|
11344
|
+
const text = `${d.source} → ${to ? `${to}${nearest}` : "no close token"}`;
|
|
11345
|
+
const pair = pairs.get(text) ?? {
|
|
11346
|
+
text,
|
|
11347
|
+
count: 0
|
|
11348
|
+
};
|
|
11349
|
+
pair.count++;
|
|
11350
|
+
pairs.set(text, pair);
|
|
11351
|
+
}
|
|
11352
|
+
const lines = [`By rule: ${[...byRule].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([rule, n]) => `${rule} ${n.toLocaleString("en-US")}`).join(", ")}.`];
|
|
11353
|
+
const top = [...pairs.values()].sort((a, b) => b.count - a.count || a.text.localeCompare(b.text)).slice(0, TOP_CLASSES);
|
|
11354
|
+
if (top.length) {
|
|
11355
|
+
const width = Math.max(...top.map((pair) => pair.count.toLocaleString("en-US").length));
|
|
11356
|
+
lines.push(`Tailwind palette classes: ${palette.toLocaleString("en-US")} in ${plural$1(paletteFiles.size, "file")}. The most common, with the token to use:`, ...top.map((pair) => ` ${pair.count.toLocaleString("en-US").padStart(width)}× ${pair.text}`));
|
|
11357
|
+
}
|
|
11358
|
+
return lines;
|
|
11359
|
+
}
|
|
11360
|
+
/** Escapes a workflow command's message, so `%` and line breaks reach GitHub as written. */
|
|
9263
11361
|
function escapeData(value) {
|
|
9264
11362
|
return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
9265
11363
|
}
|
|
@@ -9763,6 +11861,55 @@ function searchComponents(index, query, limit = 5) {
|
|
|
9763
11861
|
}));
|
|
9764
11862
|
}
|
|
9765
11863
|
//#endregion
|
|
11864
|
+
//#region src/tokens/imports.ts
|
|
11865
|
+
/** Enough stylesheets for a theme split over files, without reading a whole design system. */
|
|
11866
|
+
const MAX_IMPORTED = 50;
|
|
11867
|
+
/**
|
|
11868
|
+
* The specifiers a stylesheet `@import`s: `@import "./theme.css" layer(base)`,
|
|
11869
|
+
* `@import url(theme.css)`, `@import "@acme/theme"`. Empty for CSS that does not parse.
|
|
11870
|
+
*/
|
|
11871
|
+
function cssImports(css) {
|
|
11872
|
+
let root;
|
|
11873
|
+
try {
|
|
11874
|
+
root = postcss.parse(css);
|
|
11875
|
+
} catch {
|
|
11876
|
+
return [];
|
|
11877
|
+
}
|
|
11878
|
+
const out = [];
|
|
11879
|
+
for (const node of root.nodes) {
|
|
11880
|
+
if (node.type !== "atrule" || node.name !== "import") continue;
|
|
11881
|
+
const match = /^\s*(?:url\(\s*)?(?:"([^"]+)"|'([^']+)'|([^\s"')]+)\s*\))/.exec(node.params);
|
|
11882
|
+
const specifier = match?.[1] ?? match?.[2] ?? match?.[3];
|
|
11883
|
+
if (specifier) out.push(specifier);
|
|
11884
|
+
}
|
|
11885
|
+
return out;
|
|
11886
|
+
}
|
|
11887
|
+
/**
|
|
11888
|
+
* The stylesheets `files` import, and the ones those import in turn, each once and in the
|
|
11889
|
+
* order they are imported. The files themselves are not repeated.
|
|
11890
|
+
*/
|
|
11891
|
+
function importedStylesheets(files, resolve) {
|
|
11892
|
+
const seen = new Set(files);
|
|
11893
|
+
const out = [];
|
|
11894
|
+
const visit = (file) => {
|
|
11895
|
+
let text;
|
|
11896
|
+
try {
|
|
11897
|
+
text = fs.readFileSync(file, "utf8");
|
|
11898
|
+
} catch {
|
|
11899
|
+
return;
|
|
11900
|
+
}
|
|
11901
|
+
for (const specifier of cssImports(text)) {
|
|
11902
|
+
const target = resolve(specifier, file);
|
|
11903
|
+
if (!target || seen.has(target) || out.length >= MAX_IMPORTED) continue;
|
|
11904
|
+
seen.add(target);
|
|
11905
|
+
out.push(target);
|
|
11906
|
+
visit(target);
|
|
11907
|
+
}
|
|
11908
|
+
};
|
|
11909
|
+
for (const file of files) visit(file);
|
|
11910
|
+
return out;
|
|
11911
|
+
}
|
|
11912
|
+
//#endregion
|
|
9766
11913
|
//#region src/util/log.ts
|
|
9767
11914
|
const stderrLogger = {
|
|
9768
11915
|
info: (message) => process.stderr.write(`[onsystem] ${message}\n`),
|
|
@@ -9784,18 +11931,46 @@ var DesignSystem = class {
|
|
|
9784
11931
|
searchIndex;
|
|
9785
11932
|
/**
|
|
9786
11933
|
* The components the tools offer: all of them, except a library's (MUI's) where the project
|
|
9787
|
-
* has its own of that name, a wrapper the team means to be used
|
|
9788
|
-
*
|
|
11934
|
+
* has its own of that name, a wrapper the team means to be used, and the helpers in a
|
|
11935
|
+
* registry folder's `lib/` (signoff-ui's `Img` and icons). The rules still check code that
|
|
11936
|
+
* imports either directly against them.
|
|
9789
11937
|
*/
|
|
9790
11938
|
offered;
|
|
11939
|
+
/** The design systems the rules rank, with the registry folders a config lists. */
|
|
11940
|
+
sources;
|
|
9791
11941
|
constructor(model, config) {
|
|
9792
11942
|
this.model = model;
|
|
9793
11943
|
this.config = config;
|
|
9794
|
-
this.
|
|
11944
|
+
this.sources = rankedSources(config);
|
|
11945
|
+
this.lint = new LintTarget(model, this.sources === config.designSystems ? config : {
|
|
11946
|
+
...config,
|
|
11947
|
+
designSystems: this.sources
|
|
11948
|
+
}, new ModuleResolver(config));
|
|
9795
11949
|
const own = new Set(model.components.filter((c) => !c.library).flatMap((c) => [c.name, ...c.aliases]));
|
|
9796
|
-
this.offered = model.components.filter((c) => !c.library || !(own.has(c.name) || c.parent !== void 0 && own.has(c.parent)));
|
|
11950
|
+
this.offered = model.components.filter((c) => (!c.library || !(own.has(c.name) || c.parent !== void 0 && own.has(c.parent))) && !this.registryHelper(c));
|
|
9797
11951
|
this.searchIndex = buildSearchIndex(this.offered);
|
|
9798
11952
|
}
|
|
11953
|
+
/**
|
|
11954
|
+
* The shadcn registry a component was copied from (`@signoff-ui`), when its folder is one
|
|
11955
|
+
* components.json names: a read-only design system, whose code is the registry's.
|
|
11956
|
+
*/
|
|
11957
|
+
registryOf(component) {
|
|
11958
|
+
if (component.library) return void 0;
|
|
11959
|
+
const file = component.source.file;
|
|
11960
|
+
for (const source of this.sources ?? []) {
|
|
11961
|
+
if (!source.registry) continue;
|
|
11962
|
+
const folder = source.components.find((g) => matchesGlob(file, g))?.replace(/\/\*\*\/\*\.\{tsx,jsx\}$/, "").replace(/\\(.)/g, "$1");
|
|
11963
|
+
if (folder !== void 0) return {
|
|
11964
|
+
registry: source.registry,
|
|
11965
|
+
folder
|
|
11966
|
+
};
|
|
11967
|
+
}
|
|
11968
|
+
}
|
|
11969
|
+
/** A component in a registry folder's `lib/`: an internal of its items, not one of them. */
|
|
11970
|
+
registryHelper(component) {
|
|
11971
|
+
const from = this.registryOf(component);
|
|
11972
|
+
return from !== void 0 && component.source.file.startsWith(`${from.folder}/lib/`);
|
|
11973
|
+
}
|
|
9799
11974
|
get root() {
|
|
9800
11975
|
return this.model.root;
|
|
9801
11976
|
}
|
|
@@ -9881,17 +12056,125 @@ var DesignSystem = class {
|
|
|
9881
12056
|
}
|
|
9882
12057
|
};
|
|
9883
12058
|
const CONFIGURATION_DOCS = "https://onsystem.vercel.app/docs/configuration";
|
|
12059
|
+
/**
|
|
12060
|
+
* The design systems the rules rank, first to last: zero config's, or, for a config that lists
|
|
12061
|
+
* the folders `npx shadcn add` copies the registries of components.json into (as `init` writes
|
|
12062
|
+
* them), its other components first and each such folder after them, read-only, as zero config
|
|
12063
|
+
* reads them.
|
|
12064
|
+
*/
|
|
12065
|
+
function rankedSources(config) {
|
|
12066
|
+
if (config.designSystems || !config.configured) return config.designSystems;
|
|
12067
|
+
const listed = registryFolders(config.root, config.tsconfig).filter((f) => config.components.some((g) => matchesGlob(`${f.dir}/index.tsx`, g)));
|
|
12068
|
+
if (!listed.length) return void 0;
|
|
12069
|
+
const inRegistry = (glob) => {
|
|
12070
|
+
const base = globBase(glob);
|
|
12071
|
+
return listed.some((f) => base === f.dir || base.startsWith(`${f.dir}/`));
|
|
12072
|
+
};
|
|
12073
|
+
return [{
|
|
12074
|
+
source: "components",
|
|
12075
|
+
components: config.components.filter((g) => !inRegistry(g)),
|
|
12076
|
+
imports: 0
|
|
12077
|
+
}, ...listed.map((f) => ({
|
|
12078
|
+
source: `${f.dir} (${f.namespace})`,
|
|
12079
|
+
components: [f.glob],
|
|
12080
|
+
imports: 0,
|
|
12081
|
+
registry: f.namespace
|
|
12082
|
+
}))];
|
|
12083
|
+
}
|
|
9884
12084
|
/** `border-ring` → token `ring`, for color utilities a token's usage list does not spell out. */
|
|
9885
12085
|
function matchUtility(base, tokens) {
|
|
9886
12086
|
const key = COLOR_UTILITY.exec(base)?.[1];
|
|
9887
12087
|
return key ? tokens.find((t) => t.category === "color" && t.tailwind === key) : void 0;
|
|
9888
12088
|
}
|
|
9889
12089
|
/**
|
|
12090
|
+
* Where the design system's files may really be, links resolved: the project, the workspace
|
|
12091
|
+
* and the git repository it is in, the folder a design-system package's config stays in, and
|
|
12092
|
+
* packages reached through a `node_modules` folder in those, which pnpm can link from a store
|
|
12093
|
+
* elsewhere. A glob follows links, and a config can name `../` and absolute paths, so without
|
|
12094
|
+
* this a cloned repository could have the tools return any file on the machine
|
|
12095
|
+
* (`docs/credentials.md` linked to `~/.aws/credentials`).
|
|
12096
|
+
*/
|
|
12097
|
+
var Containment = class {
|
|
12098
|
+
/** The folders, as given. */
|
|
12099
|
+
dirs;
|
|
12100
|
+
/** The same folders, links resolved. */
|
|
12101
|
+
roots;
|
|
12102
|
+
reals = /* @__PURE__ */ new Map();
|
|
12103
|
+
/** Files left out, as they were found. */
|
|
12104
|
+
outside = /* @__PURE__ */ new Set();
|
|
12105
|
+
constructor(config) {
|
|
12106
|
+
const around = [
|
|
12107
|
+
workspaceRootOf(config.root),
|
|
12108
|
+
repositoryOf(config.root),
|
|
12109
|
+
config.base,
|
|
12110
|
+
config.designSystemPackage?.boundary
|
|
12111
|
+
].filter((dir) => dir !== void 0 && !isHomeOrAbove(dir));
|
|
12112
|
+
this.dirs = unique([config.root, ...around].map((dir) => path.resolve(dir)));
|
|
12113
|
+
this.roots = Promise.all(this.dirs.map((dir) => this.real(dir))).then((reals) => reals.filter((real) => real !== void 0));
|
|
12114
|
+
}
|
|
12115
|
+
/** Where `file` really is, links resolved; undefined when it is not there. */
|
|
12116
|
+
real(file) {
|
|
12117
|
+
let real = this.reals.get(file);
|
|
12118
|
+
if (!real) {
|
|
12119
|
+
real = fsp.realpath(file).catch(() => void 0);
|
|
12120
|
+
this.reals.set(file, real);
|
|
12121
|
+
}
|
|
12122
|
+
return real;
|
|
12123
|
+
}
|
|
12124
|
+
/**
|
|
12125
|
+
* `files` whose real path is inside, in their order, one path per real file: the shortest,
|
|
12126
|
+
* so a folder that links back to its parent does not add its files again.
|
|
12127
|
+
*/
|
|
12128
|
+
async filter(files) {
|
|
12129
|
+
const roots = await this.roots;
|
|
12130
|
+
const reals = await Promise.all(files.map((file) => this.real(file)));
|
|
12131
|
+
const kept = /* @__PURE__ */ new Map();
|
|
12132
|
+
files.forEach((file, i) => {
|
|
12133
|
+
const real = reals[i];
|
|
12134
|
+
if (real === void 0) return;
|
|
12135
|
+
if (!(roots.some((root) => isInside(root, real)) || isInstalled(real) && this.inNodeModules(file))) {
|
|
12136
|
+
this.outside.add(file);
|
|
12137
|
+
return;
|
|
12138
|
+
}
|
|
12139
|
+
const previous = kept.get(real);
|
|
12140
|
+
if (previous === void 0 || file.length < previous.length) kept.set(real, file);
|
|
12141
|
+
});
|
|
12142
|
+
return [...kept.values()];
|
|
12143
|
+
}
|
|
12144
|
+
/**
|
|
12145
|
+
* Whether `file`, as found, is in a package in a `node_modules` folder of the project, the
|
|
12146
|
+
* workspace or the repository, as one pnpm links from its store is. A link elsewhere in the
|
|
12147
|
+
* project to another project's `node_modules` is not.
|
|
12148
|
+
*/
|
|
12149
|
+
inNodeModules(file) {
|
|
12150
|
+
const found = path.resolve(file);
|
|
12151
|
+
return this.dirs.some((dir) => isInside(dir, found) && isInstalled(path.relative(dir, found)));
|
|
12152
|
+
}
|
|
12153
|
+
};
|
|
12154
|
+
/** Whether a path is a file of a package in `node_modules`, not of `.cache` or `.bin` there. */
|
|
12155
|
+
function isInstalled(file) {
|
|
12156
|
+
const parts = file.split(/[\\/]/);
|
|
12157
|
+
const at = parts.lastIndexOf("node_modules");
|
|
12158
|
+
return at !== -1 && at < parts.length - 2 && !(parts[at + 1] ?? ".").startsWith(".");
|
|
12159
|
+
}
|
|
12160
|
+
/** The nearest folder at or above `dir` with a `.git`: the repository it is in. */
|
|
12161
|
+
function repositoryOf(dir) {
|
|
12162
|
+
for (let current = path.resolve(dir);; current = path.dirname(current)) {
|
|
12163
|
+
if (fs.existsSync(path.join(current, ".git"))) return current;
|
|
12164
|
+
if (current === path.dirname(current)) return void 0;
|
|
12165
|
+
}
|
|
12166
|
+
}
|
|
12167
|
+
/** What `inspect` and the server's log say about files that lead outside the project. */
|
|
12168
|
+
function outsideWarning(root, files) {
|
|
12169
|
+
const shown = files.map((file) => relativePath(root, file)).sort();
|
|
12170
|
+
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).`;
|
|
12171
|
+
}
|
|
12172
|
+
/**
|
|
9890
12173
|
* Absolute paths matching `patterns` under the root. `node_modules` is skipped
|
|
9891
12174
|
* even when `exclude` replaces the defaults, except by patterns that name it
|
|
9892
|
-
* (tokens published as a package).
|
|
12175
|
+
* (tokens published as a package). Files that lead outside the project are left out.
|
|
9893
12176
|
*/
|
|
9894
|
-
async function find(config, patterns) {
|
|
12177
|
+
async function find(config, patterns, contain = new Containment(config)) {
|
|
9895
12178
|
const options = {
|
|
9896
12179
|
cwd: config.root,
|
|
9897
12180
|
absolute: true,
|
|
@@ -9901,7 +12184,7 @@ async function find(config, patterns) {
|
|
|
9901
12184
|
const named = patterns.filter((p) => p.includes("node_modules"));
|
|
9902
12185
|
const outside = patterns.filter((p) => !p.includes("node_modules") && p.startsWith("../"));
|
|
9903
12186
|
const rest = patterns.filter((p) => !p.includes("node_modules") && !p.startsWith("../"));
|
|
9904
|
-
|
|
12187
|
+
const found = await Promise.all([
|
|
9905
12188
|
rest.length ? glob(rest, {
|
|
9906
12189
|
...options,
|
|
9907
12190
|
ignore: unique(["**/node_modules/**", ...exclude])
|
|
@@ -9923,7 +12206,8 @@ async function find(config, patterns) {
|
|
|
9923
12206
|
])
|
|
9924
12207
|
});
|
|
9925
12208
|
})
|
|
9926
|
-
])
|
|
12209
|
+
]);
|
|
12210
|
+
return (await contain.filter(unique(found.flat()))).sort();
|
|
9927
12211
|
}
|
|
9928
12212
|
/** The design system's own component files, as absolute paths. */
|
|
9929
12213
|
function componentFiles(config) {
|
|
@@ -9934,28 +12218,51 @@ async function tokenFiles(config) {
|
|
|
9934
12218
|
return (await resolveFiles(config)).tokens.map((t) => t.file);
|
|
9935
12219
|
}
|
|
9936
12220
|
async function resolveFiles(config) {
|
|
9937
|
-
const
|
|
9938
|
-
const
|
|
12221
|
+
const contain = new Containment(config);
|
|
12222
|
+
const components = await find(config, config.components, contain);
|
|
12223
|
+
const docs = await find(config, config.docs, contain);
|
|
9939
12224
|
const tokens = [];
|
|
9940
12225
|
const seen = /* @__PURE__ */ new Set();
|
|
12226
|
+
const isNew = async (file) => {
|
|
12227
|
+
const real = await contain.real(file) ?? path.resolve(file);
|
|
12228
|
+
if (seen.has(real)) return false;
|
|
12229
|
+
seen.add(real);
|
|
12230
|
+
return true;
|
|
12231
|
+
};
|
|
9941
12232
|
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
|
-
}
|
|
12233
|
+
for (const source of sources) for (const file of await find(config, [source.path], contain)) if (await isNew(file)) tokens.push({
|
|
12234
|
+
file,
|
|
12235
|
+
prefix: source.prefix
|
|
12236
|
+
});
|
|
9950
12237
|
};
|
|
9951
12238
|
await add(config.tokens.filter((t) => !t.fallback));
|
|
12239
|
+
if (config.tokensDetected) {
|
|
12240
|
+
const found = tokens.map((t) => path.resolve(t.file));
|
|
12241
|
+
for (const file of await contain.filter(linkedStylesheets(config, found))) if (await isNew(file)) tokens.push({ file });
|
|
12242
|
+
}
|
|
9952
12243
|
if (!tokens.length) await add(config.tokens.filter((t) => t.fallback));
|
|
9953
12244
|
return {
|
|
9954
12245
|
components,
|
|
9955
12246
|
tokens,
|
|
9956
|
-
docs
|
|
12247
|
+
docs,
|
|
12248
|
+
outside: [...contain.outside]
|
|
9957
12249
|
};
|
|
9958
12250
|
}
|
|
12251
|
+
/**
|
|
12252
|
+
* The stylesheets zero config reads besides the ones it found: those they `@import` (by a
|
|
12253
|
+
* path, or from a workspace package: `@import "@acme/theme"`), then those that set the colors
|
|
12254
|
+
* a Tailwind v3 config maps, in the workspace package of a shared preset it reads (Dub's
|
|
12255
|
+
* `@dub/tailwind-config` and its `themes.css`), with what they import. May repeat a file.
|
|
12256
|
+
*/
|
|
12257
|
+
function linkedStylesheets(config, found) {
|
|
12258
|
+
const resolve = stylesheetResolver(config.root);
|
|
12259
|
+
const preset = config.tailwindConfig ? presetStylesheets(readTailwindColors(config.tailwindConfig, presetResolver(config.root)), config.root) : [];
|
|
12260
|
+
return [
|
|
12261
|
+
...importedStylesheets(found.filter((f) => f.endsWith(".css")), resolve),
|
|
12262
|
+
...preset,
|
|
12263
|
+
...importedStylesheets(preset, resolve)
|
|
12264
|
+
];
|
|
12265
|
+
}
|
|
9959
12266
|
/** Size and mtime of files, each read once per build. */
|
|
9960
12267
|
var FileStats = class {
|
|
9961
12268
|
root;
|
|
@@ -9977,11 +12284,11 @@ var FileStats = class {
|
|
|
9977
12284
|
/**
|
|
9978
12285
|
* Everything that, when changed, invalidates the cached model: the tracked
|
|
9979
12286
|
* files, `dependencies` (project files the components import and the tsconfig
|
|
9980
|
-
* chain, known after extraction), the lockfile and the
|
|
12287
|
+
* chain, known after extraction), the lockfile, the config and the files left out.
|
|
9981
12288
|
*/
|
|
9982
12289
|
async function cacheKey(config, files, dependencies, stats = new FileStats(config.root)) {
|
|
9983
12290
|
const extra = [
|
|
9984
|
-
|
|
12291
|
+
...PROJECT_CONFIG_FILES,
|
|
9985
12292
|
"package.json",
|
|
9986
12293
|
"pnpm-lock.yaml",
|
|
9987
12294
|
"package-lock.json",
|
|
@@ -9996,9 +12303,11 @@ async function cacheKey(config, files, dependencies, stats = new FileStats(confi
|
|
|
9996
12303
|
...dependencies
|
|
9997
12304
|
]);
|
|
9998
12305
|
const { rules: _rules, overrides: _overrides, ...relevant } = config;
|
|
12306
|
+
const outside = files.outside.map((file) => relativePath(config.root, file)).sort();
|
|
9999
12307
|
return createHash("sha256").update(JSON.stringify({
|
|
10000
12308
|
version: VERSION,
|
|
10001
12309
|
config: relevant,
|
|
12310
|
+
outside,
|
|
10002
12311
|
stats: await stats.of(all)
|
|
10003
12312
|
})).digest("hex");
|
|
10004
12313
|
}
|
|
@@ -10047,6 +12356,7 @@ async function buildModel(config, options = {}) {
|
|
|
10047
12356
|
});
|
|
10048
12357
|
const docs = [];
|
|
10049
12358
|
const warnings = [...extracted.warnings];
|
|
12359
|
+
if (files.outside.length) warnings.push(outsideWarning(config.root, files.outside));
|
|
10050
12360
|
for (const file of files.docs) try {
|
|
10051
12361
|
docs.push(parseDoc(await fsp.readFile(file, "utf8"), relativePath(config.root, file)));
|
|
10052
12362
|
} catch (error) {
|
|
@@ -10058,11 +12368,11 @@ async function buildModel(config, options = {}) {
|
|
|
10058
12368
|
const libraries = await loadLibraries(config);
|
|
10059
12369
|
warnings.push(...libraries.warnings);
|
|
10060
12370
|
tokens.tokens.push(...libraries.tokens);
|
|
10061
|
-
const tailwind = config.tailwindConfig ? readTailwindColors(config.tailwindConfig,
|
|
10062
|
-
if (tailwind) applyTailwindColors(tokens.tokens, tailwind.colors);
|
|
12371
|
+
const tailwind = config.tailwindConfig ? readTailwindColors(config.tailwindConfig, presetResolver(config.root)) : void 0;
|
|
12372
|
+
if (tailwind) applyTailwindColors(tokens.tokens, tailwind.colors, tailwind.rgb);
|
|
10063
12373
|
let radiiFiles = [];
|
|
10064
12374
|
if (config.tailwindConfig) {
|
|
10065
|
-
const radii = readTailwindRadii(config.tailwindConfig,
|
|
12375
|
+
const radii = readTailwindRadii(config.tailwindConfig, presetResolver(config.root));
|
|
10066
12376
|
applyTailwindRadii(tokens.tokens, radii.radii, config.tailwindConfig);
|
|
10067
12377
|
radiiFiles = radii.files;
|
|
10068
12378
|
}
|
|
@@ -10191,7 +12501,7 @@ var DesignSystemHost = class {
|
|
|
10191
12501
|
configFile,
|
|
10192
12502
|
designSystemPackage && path.join(designSystemPackage.dir, "package.json"),
|
|
10193
12503
|
...DETECTION_FILES.map((f) => path.join(root, f)),
|
|
10194
|
-
path.resolve(root, tsconfig
|
|
12504
|
+
...tsconfig ? [path.resolve(root, tsconfig)] : PROJECT_CONFIG_FILES.map((f) => path.join(root, f)),
|
|
10195
12505
|
...readProjectConfig(root, tsconfig).configFiles.filter(isProjectFile)
|
|
10196
12506
|
].filter((f) => Boolean(f)).map((f) => path.resolve(f));
|
|
10197
12507
|
for (const file of files) this.watchedFiles.add(file);
|
|
@@ -10285,4 +12595,4 @@ var DesignSystemHost = class {
|
|
|
10285
12595
|
}
|
|
10286
12596
|
};
|
|
10287
12597
|
//#endregion
|
|
10288
|
-
export {
|
|
12598
|
+
export { hasOwnConfig as $, TokenIndex as A, parseDoc as B, pruneBaseline as C, isInside as Ct, updateBaseline as D, defaultProjectConfig as Dt, unrecordedRules as E, toPosix as Et, 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, readProjectConfig as Ot, toDtcg as P, excludedBy as Q, parseCssTokens as R, countBaseline as S, SCRIPT_FILE$1 as St, rulesRun as T, relativePath as Tt, ConfigError as U, extractComponents as V, DEFAULT_DOCS as W, configJsonSchema as X, RULE_IDS as Y, defineConfig as Z, NAME as _, workspacePatterns as _t, fingerprint as a, LIBRARIES as at, LEGACY_BASELINE_FILE as b, truncate as bt, silentLogger as c, notReadYet as ct, searchComponents as d, declaredConfig as dt, loadConfig as et, applyFixes as f, detectProject as ft, formatDiagnostics as g, workspacePackageDirs as gt, escapeData as h, isWorkspaceRoot as ht, componentFiles as i, findVariantDefinitions as it, loadTokens as j, RULES as k, stderrLogger as l, PACKAGE_FIELD as lt, uncheckedNotice as m, isDesignSystemName as mt, DesignSystemHost as n, rulesFor as nt, loadDesignSystem as o, LIBRARY_IDS as ot, checkSource as p, findDesignSystemPackage as pt, DEFAULT_TEST_FOLDERS as q, buildModel as r, formatPx as rt, tokenFiles as s, libraryTitle as st, DesignSystem as t, loadConfigWith as tt, buildSearchIndex as u, PackageFinder as ut, VERSION as v, closest as vt, readBaseline as w, matchesGlob as wt, applyBaseline as x, unique as xt, BASELINE_FILE as y, plural$1 as yt, attachDocs as z };
|