onsystem 0.7.0 → 0.9.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 +19 -3
- package/dist/cli.js +513 -87
- package/dist/{config-1XSTYI44.d.ts → config-B98w8bcg.d.ts} +12 -0
- package/dist/{design-system-C4bhqLLw.js → design-system-Bykei5W0.js} +1838 -184
- package/dist/eslint-worker.d.ts +1 -1
- package/dist/eslint-worker.js +3 -5
- package/dist/eslint.js +1 -1
- package/dist/index.d.ts +27 -4
- package/dist/index.js +2 -2
- package/dist/{stdio-BojAQ6JP.js → stdio-B7u6dqLd.js} +347 -86
- package/dist/{workspace-Z0tZf_Dv.js → workspace-Dp1q8qZ8.js} +1 -1
- package/package.json +6 -4
- package/schema.json +1 -1
|
@@ -5,7 +5,7 @@ import * as z from "zod";
|
|
|
5
5
|
import { escapePath, glob, globSync } from "tinyglobby";
|
|
6
6
|
import ts from "typescript";
|
|
7
7
|
import os from "node:os";
|
|
8
|
-
import { clampChroma, colorsNamed, converter, differenceEuclidean, formatHex, parse } from "culori";
|
|
8
|
+
import { clampChroma, colorsNamed, converter, differenceEuclidean, formatHex, parse, wcagContrast } from "culori";
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import fsp from "node:fs/promises";
|
|
11
11
|
import postcss from "postcss";
|
|
@@ -137,6 +137,12 @@ function slashGlob(pattern) {
|
|
|
137
137
|
if (!pattern.includes("/")) return pattern.replaceAll("\\", "/");
|
|
138
138
|
return pattern.replace(/\\(?![()[\]{}*?!+@])/g, "/");
|
|
139
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;
|
|
140
146
|
/** Path relative to `root`, always with forward slashes. */
|
|
141
147
|
function relativePath(root, file) {
|
|
142
148
|
return toPosix(path.relative(root, file));
|
|
@@ -191,6 +197,16 @@ function matchesGlob(file, pattern) {
|
|
|
191
197
|
return regex.test(file.replace(/^\.\//, ""));
|
|
192
198
|
}
|
|
193
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
|
+
}
|
|
194
210
|
function globSource(pattern) {
|
|
195
211
|
let out = "";
|
|
196
212
|
for (let i = 0; i < pattern.length; i++) {
|
|
@@ -582,7 +598,9 @@ const STYLESHEETS = [
|
|
|
582
598
|
* workspace sources, read the same way or through their own `components.json`,
|
|
583
599
|
* or, for a package without `exports` that apps import by path
|
|
584
600
|
* (`@acme/ui/primitives/button`), from the files the root's code imports. They
|
|
585
|
-
* 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/`.
|
|
586
604
|
* 4. A flat folder of components that wrap a primitives library, the way React
|
|
587
605
|
* Aria's Tailwind starter ships them (`src/Button.tsx`, `src/Checkbox.tsx`).
|
|
588
606
|
*
|
|
@@ -590,6 +608,10 @@ const STYLESHEETS = [
|
|
|
590
608
|
* (`designSystems`). A candidate whose globs match no file is skipped. Returns undefined
|
|
591
609
|
* when nothing applies, so the shadcn defaults stay in force.
|
|
592
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
|
+
*
|
|
593
615
|
* `declared` is the dependency whose config the root takes on ({@link findDesignSystemPackage}):
|
|
594
616
|
* it is read first among the dependencies in step 3, whatever its name, and also when it is
|
|
595
617
|
* installed from a registry rather than a workspace package.
|
|
@@ -602,8 +624,9 @@ function detectProject(root, tsconfig, trace, declared, packages = new PackageFi
|
|
|
602
624
|
});
|
|
603
625
|
const found = [];
|
|
604
626
|
const shadcn = readJson$1(path.join(root, "components.json"));
|
|
627
|
+
const project = shadcn ? readProjectConfig(root, tsconfig) : void 0;
|
|
605
628
|
let why = "no ui or components alias";
|
|
606
|
-
const fromShadcn = shadcn ? fromComponentsJson(root, root, shadcn,
|
|
629
|
+
const fromShadcn = shadcn && project ? fromComponentsJson(root, root, shadcn, project, packages, (r) => {
|
|
607
630
|
why = r;
|
|
608
631
|
}) : void 0;
|
|
609
632
|
if (!shadcn) step("components.json", false, fs.existsSync(path.join(root, "components.json")) ? "not valid JSON" : "not found");
|
|
@@ -637,23 +660,34 @@ function detectProject(root, tsconfig, trace, declared, packages = new PackageFi
|
|
|
637
660
|
const names = unique([...declared ? [declared.name] : [], ...dependencies.filter(isDesignSystemName)]);
|
|
638
661
|
if (!names.length) step("workspace design-system packages", false, `no dependency is named like one (@acme/ui, acme-ui, @acme/design-system) among ${plural(dependencies.length, "dependency", "dependencies")}`);
|
|
639
662
|
let imported;
|
|
663
|
+
let declarations;
|
|
664
|
+
/** What the trace adds for an installed package whose sources are not read. */
|
|
665
|
+
const declarationsNote = (name, dir, pkg) => {
|
|
666
|
+
declarations ??= new Set(declarationsPackages(root, packages).map((p) => p.name));
|
|
667
|
+
if (declarations.has(name)) return "; its components are read from its type declarations instead";
|
|
668
|
+
if (!declaresTypes(dir, pkg)) return "";
|
|
669
|
+
const scope = scopeOf$1(name);
|
|
670
|
+
return `; its type declarations are read when it declares its onsystem config${scope ? `, when the app is in its npm scope (${scope}/…)` : ""} or when the app's config sets "importPath" to "${name}"`;
|
|
671
|
+
};
|
|
640
672
|
for (const name of names) {
|
|
641
673
|
const own = name === declared?.name ? declared : void 0;
|
|
642
|
-
const
|
|
674
|
+
const workspaceDir = own ? void 0 : packages.find(name);
|
|
675
|
+
const installedDir = own || workspaceDir ? void 0 : packages.installed(name);
|
|
676
|
+
const kind = own?.installed ?? installedDir !== void 0 ? "installed" : "workspace";
|
|
643
677
|
const candidate = `${kind} package ${name}`;
|
|
644
678
|
if (found.some((f) => f.package === name)) {
|
|
645
679
|
step(candidate, false, "not added twice: components.json points into it");
|
|
646
680
|
continue;
|
|
647
681
|
}
|
|
648
|
-
const dir = own
|
|
682
|
+
const dir = own?.dir ?? workspaceDir ?? installedDir;
|
|
649
683
|
const pkg = dir ? readJson$1(path.join(dir, "package.json")) : void 0;
|
|
650
684
|
if (!dir || !pkg) {
|
|
651
|
-
step(candidate, false, "not a workspace package
|
|
685
|
+
step(candidate, false, "not a workspace package, and not installed");
|
|
652
686
|
continue;
|
|
653
687
|
}
|
|
654
688
|
const reasons = [];
|
|
655
689
|
const note = (r) => reasons.push(r);
|
|
656
|
-
const result = fromPackage(root, dir, pkg, note) ?? fromPackageComponentsJson(root, dir, pkg, packages, note) ?? fromImports(root, dir, name, (imported ??= importedSubpaths(root, names)).get(name), note);
|
|
690
|
+
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);
|
|
657
691
|
if (result && matchesAny(root, result)) {
|
|
658
692
|
found.push({
|
|
659
693
|
...result,
|
|
@@ -662,13 +696,33 @@ function detectProject(root, tsconfig, trace, declared, packages = new PackageFi
|
|
|
662
696
|
prefixes: [name]
|
|
663
697
|
});
|
|
664
698
|
step(candidate, true, `${relativePath(root, dir)}, ${countFiles(root, result)}`);
|
|
665
|
-
} else
|
|
699
|
+
} else {
|
|
700
|
+
const reason = result ? "its components match no file" : unique(reasons).join("; ") || "nothing to read";
|
|
701
|
+
step(candidate, false, kind === "installed" ? `${reason}${declarationsNote(name, dir, pkg)}` : reason);
|
|
702
|
+
}
|
|
666
703
|
}
|
|
704
|
+
const registries = shadcn && project && fromShadcn && !withDefaults ? fromRegistries(root, shadcn, fromShadcn, found, project, step) : [];
|
|
667
705
|
if (found.length) {
|
|
668
706
|
step("a flat src/ of components", false, "not read: a design system was found");
|
|
707
|
+
const named = (source) => registries.length ? `${source}; from shadcn registries, read-only: ${registries.map((r) => r.source).join(", ")}` : source;
|
|
708
|
+
const read = registries.map((f) => ({
|
|
709
|
+
source: f.source,
|
|
710
|
+
components: f.components,
|
|
711
|
+
imports: 0,
|
|
712
|
+
registry: f.registry
|
|
713
|
+
}));
|
|
669
714
|
if (found.length === 1) {
|
|
670
715
|
const [only] = found;
|
|
671
|
-
|
|
716
|
+
const source = only?.source ?? "";
|
|
717
|
+
if (!only || !read.length) return detection(root, found, withDefaults, source);
|
|
718
|
+
return {
|
|
719
|
+
...detection(root, [...found, ...registries], withDefaults, named(source)),
|
|
720
|
+
designSystems: [{
|
|
721
|
+
source,
|
|
722
|
+
components: only.components,
|
|
723
|
+
imports: 0
|
|
724
|
+
}, ...read]
|
|
725
|
+
};
|
|
672
726
|
}
|
|
673
727
|
const counts = importCounts(root, found.map((f) => f.prefixes ?? []));
|
|
674
728
|
const ranked = found.map((f, i) => ({
|
|
@@ -679,12 +733,12 @@ function detectProject(root, tsconfig, trace, declared, packages = new PackageFi
|
|
|
679
733
|
const [primary, ...rest] = ranked;
|
|
680
734
|
const describe = (r) => `${r.f.source} (${r.imports.toLocaleString("en-US")} ${r.imports === 1 ? "import" : "imports"})`;
|
|
681
735
|
return {
|
|
682
|
-
...detection(root, ranked.map((r) => r.f), withDefaults, `primary: ${primary ? describe(primary) : ""}; also: ${rest.map(describe).join(", ")}`),
|
|
683
|
-
designSystems: ranked.map((r) => ({
|
|
736
|
+
...detection(root, [...ranked.map((r) => r.f), ...registries], withDefaults, named(`primary: ${primary ? describe(primary) : ""}; also: ${rest.map(describe).join(", ")}`)),
|
|
737
|
+
designSystems: [...ranked.map((r) => ({
|
|
684
738
|
source: r.f.source,
|
|
685
739
|
components: r.f.components,
|
|
686
740
|
imports: r.imports
|
|
687
|
-
}))
|
|
741
|
+
})), ...read]
|
|
688
742
|
};
|
|
689
743
|
}
|
|
690
744
|
let flatWhy = "no package.json";
|
|
@@ -847,6 +901,101 @@ function fromComponentsJson(root, dir, json, project, packages, note = ignore) {
|
|
|
847
901
|
...ownerPackage(root, uiDir, ui)
|
|
848
902
|
};
|
|
849
903
|
}
|
|
904
|
+
/** How a shadcn registry is named in components.json: `@` and a name, so none leads out of the folder. */
|
|
905
|
+
const REGISTRY_NAMESPACE = /^@[a-z0-9][\w-]*$/i;
|
|
906
|
+
/**
|
|
907
|
+
* The registries `components.json` names (`"registries": { "@signoff-ui": … }`), sorted, and
|
|
908
|
+
* where `npx shadcn add` copies their code: the components alias, through tsconfig `paths`, when
|
|
909
|
+
* it names a folder in the project. `why` says why there is none.
|
|
910
|
+
*/
|
|
911
|
+
function registryBase(root, json, project) {
|
|
912
|
+
const namespaces = Object.keys(asRecord(json.registries)).filter((n) => REGISTRY_NAMESPACE.test(n)).sort();
|
|
913
|
+
const alias = asRecord(json.aliases).components;
|
|
914
|
+
if (typeof alias !== "string") return {
|
|
915
|
+
namespaces,
|
|
916
|
+
why: "no components alias"
|
|
917
|
+
};
|
|
918
|
+
const base = namespaces.length ? resolveThroughPaths(alias, project) : void 0;
|
|
919
|
+
if (!base || !isInside(root, base)) return {
|
|
920
|
+
namespaces,
|
|
921
|
+
why: `the components alias ${alias} names no folder in the project through tsconfig paths`
|
|
922
|
+
};
|
|
923
|
+
return {
|
|
924
|
+
namespaces,
|
|
925
|
+
base,
|
|
926
|
+
alias
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
/** The folder of a registry's code: the namespace without its `@`, under the components folder. */
|
|
930
|
+
function registryFolder(root, base, namespace) {
|
|
931
|
+
const dir = relativePath(root, path.join(base, namespace.slice(1)));
|
|
932
|
+
return {
|
|
933
|
+
namespace,
|
|
934
|
+
dir,
|
|
935
|
+
glob: `${escapePath(dir)}/**/*.{tsx,jsx}`
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* The folders `npx shadcn add` copies the code of the registries `components.json` names into,
|
|
940
|
+
* whether they hold files yet or not, outside the ui folder ({@link fromRegistries}). A config
|
|
941
|
+
* that lists them in `components` reads them as zero config does: read-only, after the rest.
|
|
942
|
+
*/
|
|
943
|
+
function registryFolders(root, tsconfig) {
|
|
944
|
+
const json = readJson$1(path.join(root, "components.json"));
|
|
945
|
+
if (!json || !Object.keys(asRecord(json.registries)).length) return [];
|
|
946
|
+
const project = readProjectConfig(root, tsconfig);
|
|
947
|
+
const { namespaces, base } = registryBase(root, json, project);
|
|
948
|
+
if (!base) return [];
|
|
949
|
+
const ui = asRecord(json.aliases).ui;
|
|
950
|
+
const uiDir = typeof ui === "string" ? resolveThroughPaths(ui, project) : void 0;
|
|
951
|
+
return namespaces.filter((namespace) => !uiDir || !isInside(uiDir, path.join(base, namespace.slice(1)))).map((namespace) => registryFolder(root, base, namespace));
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* The folders `npx shadcn add` copies the code of the registries `components.json` names into
|
|
955
|
+
* (`@/components` and `@signoff-ui` → `components/signoff-ui`). Each one that holds a .tsx or
|
|
956
|
+
* .jsx file is a read-only design system: the app's code is checked against its components, and
|
|
957
|
+
* its own files are skipped as the ui folder's are. Not when a design system found is imported
|
|
958
|
+
* through a package's `exports` (`@workspace/ui`): a config that names that package would give
|
|
959
|
+
* registry code its name too. A folder inside the ui folder is already part of it. Code a
|
|
960
|
+
* registry copies loose into the components folder (React Bits) is not set apart.
|
|
961
|
+
*/
|
|
962
|
+
function fromRegistries(root, json, ui, others, project, step) {
|
|
963
|
+
const { namespaces, base, alias, why } = registryBase(root, json, project);
|
|
964
|
+
if (!namespaces.length) return [];
|
|
965
|
+
const no = (reason) => {
|
|
966
|
+
for (const namespace of namespaces) step(`registry ${namespace}`, false, reason);
|
|
967
|
+
return [];
|
|
968
|
+
};
|
|
969
|
+
const exported = others.find((f) => f.imports.length);
|
|
970
|
+
if (exported) return no(`not read: ${exported.package ?? "a package"} is imported through its exports, and the registry's code is not`);
|
|
971
|
+
if (!base || alias === void 0) return no(why ?? "no components alias");
|
|
972
|
+
const found = [];
|
|
973
|
+
for (const namespace of namespaces) {
|
|
974
|
+
const candidate = `registry ${namespace}`;
|
|
975
|
+
const { dir, glob } = registryFolder(root, base, namespace);
|
|
976
|
+
const absolute = path.join(root, dir);
|
|
977
|
+
if (ui.dirs.some((d) => isInside(d, absolute))) {
|
|
978
|
+
step(candidate, false, `${dir} is in the ui folder`);
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
const files = isDirectory$1(absolute) ? componentGlob(root, [glob]).length : 0;
|
|
982
|
+
if (!files) {
|
|
983
|
+
step(candidate, false, `${dir} ${isDirectory$1(absolute) ? "holds no .tsx or .jsx file yet" : "does not exist yet"}`);
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
step(candidate, true, `${dir}, ${plural(files, "component file")}, read-only`);
|
|
987
|
+
found.push({
|
|
988
|
+
components: [glob],
|
|
989
|
+
tokens: [],
|
|
990
|
+
imports: [],
|
|
991
|
+
dirs: [],
|
|
992
|
+
source: `${dir} (${namespace}, ${plural(files, "file")})`,
|
|
993
|
+
prefixes: [`${alias}/${namespace.slice(1)}`],
|
|
994
|
+
registry: namespace
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
return found;
|
|
998
|
+
}
|
|
850
999
|
/**
|
|
851
1000
|
* The workspace package a `components.json` folder belongs to, when it is not the root
|
|
852
1001
|
* itself (`@coss/ui/components`, through tsconfig `paths` or the package), and the
|
|
@@ -889,22 +1038,29 @@ function fromPackageComponentsJson(root, dir, pkg, packages, note = ignore) {
|
|
|
889
1038
|
* A package whose `exports` point at component source files is a design system. An export
|
|
890
1039
|
* that leads to a barrel (`./components/button` → `components/button/index.ts`, or `.` →
|
|
891
1040
|
* `src/index.tsx` behind `dist/`) stands for the files the barrel re-exports, each
|
|
892
|
-
* suggested with the export's specifier.
|
|
893
|
-
|
|
894
|
-
|
|
1041
|
+
* suggested with the export's specifier. An `installed` package's exports are read as an app
|
|
1042
|
+
* resolves them ({@link APP_CONDITIONS}), not from `dist/` back to `src/`: a widget kit
|
|
1043
|
+
* publishes `src/` beside `dist/` for its source maps, or points a `source` condition at it for
|
|
1044
|
+
* its own repository, and it is not the app's design system. Its compiled files ({@link isBuildOutput})
|
|
1045
|
+
* are not read at all: no barrel, component or stylesheet in them. Nor is an export that points
|
|
1046
|
+
* at JavaScript outside a build folder ({@link isCompiledExport}): its re-exports are not followed.
|
|
1047
|
+
*/
|
|
1048
|
+
function fromPackage(root, dir, pkg, note = ignore, installed = false) {
|
|
895
1049
|
const imports = [];
|
|
896
1050
|
const components = [];
|
|
897
1051
|
const add = (mapping, component) => {
|
|
898
1052
|
imports.push(mapping);
|
|
899
1053
|
if (component && !components.some((m) => m.target === mapping.target)) components.push(mapping);
|
|
900
1054
|
};
|
|
901
|
-
|
|
1055
|
+
const mappings = exportMappings(root, dir, pkg, installed);
|
|
1056
|
+
for (const mapping of mappings) {
|
|
902
1057
|
const file = path.resolve(root, mapping.target);
|
|
1058
|
+
if (installed && isCompiledExport(dir, file)) continue;
|
|
903
1059
|
if (mapping.target.includes("*") || !SCRIPT_FILE.test(file) || !isFile(file)) {
|
|
904
1060
|
add(mapping, COMPONENT_FILE.test(mapping.target));
|
|
905
1061
|
continue;
|
|
906
1062
|
}
|
|
907
|
-
for (const reached of exportedFiles(file, dir)) if (COMPONENT_FILE.test(reached)) add({
|
|
1063
|
+
for (const reached of exportedFiles(file, dir)) if (COMPONENT_FILE.test(reached) && !(installed && isBuildOutput(dir, reached))) add({
|
|
908
1064
|
specifier: mapping.specifier,
|
|
909
1065
|
target: relativePath(root, reached)
|
|
910
1066
|
}, true);
|
|
@@ -912,8 +1068,9 @@ function fromPackage(root, dir, pkg, note = ignore) {
|
|
|
912
1068
|
const files = components.filter((m) => !m.target.includes("*"));
|
|
913
1069
|
const patterns = components.filter((m) => m.target.includes("*"));
|
|
914
1070
|
if (files.length < 3 && !patterns.length) {
|
|
915
|
-
const built =
|
|
916
|
-
|
|
1071
|
+
const built = mappings.filter((m) => /(?:^|\/)(?:dist|build|lib|out|esm|cjs)\//.test(m.target) || installed && isCompiledExport(dir, path.resolve(root, m.target)));
|
|
1072
|
+
const output = built.slice(0, 2).map((m) => m.target).join(", ");
|
|
1073
|
+
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`);
|
|
917
1074
|
return;
|
|
918
1075
|
}
|
|
919
1076
|
const stylesheets = imports.filter((m) => m.target.endsWith(".css") && !m.target.includes("*")).map((m) => path.resolve(root, m.target)).filter((f) => fs.existsSync(f));
|
|
@@ -1031,10 +1188,11 @@ function importedSubpaths(root, names) {
|
|
|
1031
1188
|
}
|
|
1032
1189
|
/**
|
|
1033
1190
|
* `exports` subpaths with a source target, as specifiers relative to `root`. A target
|
|
1034
|
-
* built to `dist/` maps back to its source (`src/`, through the build config's entries)
|
|
1035
|
-
*
|
|
1191
|
+
* built to `dist/` maps back to its source (`src/`, through the build config's entries),
|
|
1192
|
+
* unless `asWritten`: then each target is the one an app that installs the package resolves
|
|
1193
|
+
* ({@link APP_CONDITIONS}), as it is. Without an `exports` map, `main` stands for `.`.
|
|
1036
1194
|
*/
|
|
1037
|
-
function exportMappings(root, dir, pkg) {
|
|
1195
|
+
function exportMappings(root, dir, pkg, asWritten = false) {
|
|
1038
1196
|
const name = typeof pkg.name === "string" ? pkg.name : void 0;
|
|
1039
1197
|
if (!name) return [];
|
|
1040
1198
|
const exports = pkg.exports ?? (typeof pkg.main === "string" && /^\.?\/?(?:dist|build|lib)\//.test(pkg.main) ? pkg.main : void 0);
|
|
@@ -1042,9 +1200,9 @@ function exportMappings(root, dir, pkg) {
|
|
|
1042
1200
|
let build;
|
|
1043
1201
|
const mappings = [];
|
|
1044
1202
|
for (const [key, value] of entries) {
|
|
1045
|
-
const target = exportTarget(value);
|
|
1203
|
+
const target = exportTarget(value, asWritten ? APP_CONDITIONS : void 0);
|
|
1046
1204
|
if (!target) continue;
|
|
1047
|
-
const source = target.includes("*") ? sourcePattern(dir, target) : sourceTarget(dir, key, target, build ??= buildEntries(dir));
|
|
1205
|
+
const source = asWritten ? void 0 : target.includes("*") ? sourcePattern(dir, target) : sourceTarget(dir, key, target, build ??= buildEntries(dir));
|
|
1048
1206
|
mappings.push({
|
|
1049
1207
|
specifier: key === "." ? name : `${name}/${key.replace(/^\.\//, "")}`,
|
|
1050
1208
|
target: relativePath(root, path.resolve(dir, source ?? target))
|
|
@@ -1052,6 +1210,23 @@ function exportMappings(root, dir, pkg) {
|
|
|
1052
1210
|
}
|
|
1053
1211
|
return mappings;
|
|
1054
1212
|
}
|
|
1213
|
+
/**
|
|
1214
|
+
* A compiled file in package `dir`'s build folder: JavaScript, JSX or CSS (`dist/index.mjs`,
|
|
1215
|
+
* `lib/button.jsx`, `dist/index.css`), or a pattern of them (`dist/*.jsx`). TypeScript there
|
|
1216
|
+
* (`lib/utils.ts`) is source.
|
|
1217
|
+
*/
|
|
1218
|
+
function isBuildOutput(dir, file) {
|
|
1219
|
+
return /^(?:dist|build|lib|out|esm|cjs)\/.*\.(?:[cm]?jsx?|css)$/.test(toPosix(path.relative(dir, file)));
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* An `exports` target of installed package `dir` that is compiled: its build output, or
|
|
1223
|
+
* JavaScript in any folder (`./index.js`, `./es/index.mjs`, `./build-module/index.js`), which a
|
|
1224
|
+
* package published from TypeScript or JSX sources ships compiled. Its re-exports are not
|
|
1225
|
+
* followed, so a package that ships a `.js` barrel in front of `.jsx` sources is not read.
|
|
1226
|
+
*/
|
|
1227
|
+
function isCompiledExport(dir, file) {
|
|
1228
|
+
return isBuildOutput(dir, file) || /\.[cm]?js$/.test(file);
|
|
1229
|
+
}
|
|
1055
1230
|
/** `./dist/*.mjs` → `src/*.tsx` (or `.ts`) when files match it there; anything else as it is. */
|
|
1056
1231
|
function sourcePattern(dir, target) {
|
|
1057
1232
|
const relative = target.replace(/^\.\//, "");
|
|
@@ -1067,19 +1242,40 @@ function sourcePattern(dir, target) {
|
|
|
1067
1242
|
}
|
|
1068
1243
|
return relative;
|
|
1069
1244
|
}
|
|
1070
|
-
/**
|
|
1071
|
-
|
|
1245
|
+
/**
|
|
1246
|
+
* The `exports` conditions an app resolves an installed package through: Node.js's own
|
|
1247
|
+
* (`import`, `require`, `module-sync`, `node`, `default`), and the `module`, `browser` and
|
|
1248
|
+
* `style` that bundlers and Tailwind CSS add. Others, such as `source`, `expo-source` and
|
|
1249
|
+
* `development`, lead the package's own repository to its sources, and an app that installs it
|
|
1250
|
+
* builds the target after them: `@expo/ui`'s `"default": "./build/universal/index.js"`, not its
|
|
1251
|
+
* `"expo-source": "./src/universal/index.ts"` before it.
|
|
1252
|
+
*/
|
|
1253
|
+
const APP_CONDITIONS = /* @__PURE__ */ new Set([
|
|
1254
|
+
"import",
|
|
1255
|
+
"require",
|
|
1256
|
+
"module-sync",
|
|
1257
|
+
"node",
|
|
1258
|
+
"module",
|
|
1259
|
+
"browser",
|
|
1260
|
+
"style",
|
|
1261
|
+
"default"
|
|
1262
|
+
]);
|
|
1263
|
+
/**
|
|
1264
|
+
* The source file an export condition map points at: the first string target that is not a
|
|
1265
|
+
* declaration file, through any condition but `types`, or through `conditions` alone.
|
|
1266
|
+
*/
|
|
1267
|
+
function exportTarget(value, conditions) {
|
|
1072
1268
|
if (typeof value === "string") return value.endsWith(".d.ts") ? void 0 : value;
|
|
1073
1269
|
if (Array.isArray(value)) {
|
|
1074
1270
|
for (const item of value) {
|
|
1075
|
-
const target = exportTarget(item);
|
|
1271
|
+
const target = exportTarget(item, conditions);
|
|
1076
1272
|
if (target) return target;
|
|
1077
1273
|
}
|
|
1078
1274
|
return;
|
|
1079
1275
|
}
|
|
1080
1276
|
if (value && typeof value === "object") for (const [condition, nested] of Object.entries(value)) {
|
|
1081
|
-
if (condition === "types") continue;
|
|
1082
|
-
const target = exportTarget(nested);
|
|
1277
|
+
if (condition === "types" || conditions && !conditions.has(condition)) continue;
|
|
1278
|
+
const target = exportTarget(nested, conditions);
|
|
1083
1279
|
if (target) return target;
|
|
1084
1280
|
}
|
|
1085
1281
|
}
|
|
@@ -1157,29 +1353,93 @@ function presetResolver(root) {
|
|
|
1157
1353
|
* Resolves a stylesheet's `@import` to a project file: a path next to the stylesheet
|
|
1158
1354
|
* (`./theme.css`, or `theme.css` as plain CSS reads it), or a stylesheet of a workspace
|
|
1159
1355
|
* package (`@acme/theme/colors.css`, or `@acme/theme` through its `exports` or its `style`
|
|
1160
|
-
* field).
|
|
1161
|
-
|
|
1162
|
-
|
|
1356
|
+
* field). An installed package resolves only when zero config reads it as a design system
|
|
1357
|
+
* ({@link installedDesignSystem}) or reads its type declarations ({@link declarationsPackages}:
|
|
1358
|
+
* by its config, its scope or the config's `importPath`), and a stylesheet in it imports others
|
|
1359
|
+
* of that package alone. In an installed package, a stylesheet in a build folder
|
|
1360
|
+
* (`dist/index.css`) is compiled, and resolves to nothing. Other installed packages
|
|
1361
|
+
* (`tailwindcss`) and URLs resolve to nothing.
|
|
1362
|
+
*/
|
|
1363
|
+
function stylesheetResolver(root, { importPath } = {}) {
|
|
1163
1364
|
const packages = new PackageFinder(root);
|
|
1164
|
-
const
|
|
1365
|
+
const designSystems = /* @__PURE__ */ new Map();
|
|
1366
|
+
let declarations;
|
|
1367
|
+
const designSystem = (name) => {
|
|
1368
|
+
if (!designSystems.has(name)) {
|
|
1369
|
+
declarations ??= new Set(declarationsPackages(root, packages, { importPath }).map((p) => p.name));
|
|
1370
|
+
designSystems.set(name, installedDesignSystem(root, name, packages) ?? (declarations.has(name) ? packages.installed(name) : void 0));
|
|
1371
|
+
}
|
|
1372
|
+
return designSystems.get(name);
|
|
1373
|
+
};
|
|
1374
|
+
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));
|
|
1165
1375
|
return (specifier, fromFile) => {
|
|
1166
1376
|
if (/^(?:[a-z][\w+.-]*:|\/)/i.test(specifier)) return void 0;
|
|
1167
|
-
const relative = stylesheet(path.resolve(path.dirname(fromFile), specifier));
|
|
1377
|
+
const relative = stylesheet(path.resolve(path.dirname(fromFile), specifier), installedPackageOf(fromFile));
|
|
1168
1378
|
if (relative || specifier.startsWith(".")) return relative;
|
|
1169
1379
|
const name = packageName(specifier);
|
|
1170
|
-
const
|
|
1380
|
+
const workspace = name ? packages.find(name) : void 0;
|
|
1381
|
+
const installed = name && !workspace ? designSystem(name) : void 0;
|
|
1382
|
+
const dir = workspace ?? installed;
|
|
1171
1383
|
if (!name || !dir) return void 0;
|
|
1172
1384
|
const subpath = specifier.slice(name.length + 1);
|
|
1385
|
+
const key = subpath ? `./${subpath}` : ".";
|
|
1386
|
+
const exported = installed ? installedStylesheet(installed, key) : exportedFile(dir, key, (f) => f.endsWith(".css"));
|
|
1387
|
+
if (exported !== void 0) return exported ?? void 0;
|
|
1173
1388
|
const style = readJson$1(path.join(dir, "package.json"))?.style;
|
|
1174
|
-
return
|
|
1389
|
+
return subpath ? stylesheet(path.join(dir, subpath), installed) : stylesheet(path.resolve(dir, typeof style === "string" ? style : "index.css"), installed);
|
|
1175
1390
|
};
|
|
1176
1391
|
}
|
|
1177
1392
|
/**
|
|
1393
|
+
* The stylesheet an app resolves `subpath` (`./globals.css`) of installed package `dir` to,
|
|
1394
|
+
* as the package's exported stylesheets are found ({@link exportTarget}): the most specific
|
|
1395
|
+
* `exports` entry that maps it decides, through its first stylesheet target under
|
|
1396
|
+
* {@link APP_CONDITIONS}, and a later condition is not tried. `null` when that entry leads to no
|
|
1397
|
+
* stylesheet, or to one in a build folder (`{ "style": "./dist/x.css", "default": "./src/x.css" }`
|
|
1398
|
+
* gives apps the compiled `dist/x.css`); undefined when no entry maps the subpath.
|
|
1399
|
+
*/
|
|
1400
|
+
function installedStylesheet(dir, subpath) {
|
|
1401
|
+
const [match] = exportMatches(dir, subpath);
|
|
1402
|
+
if (!match) return void 0;
|
|
1403
|
+
const target = exportTargets(match.value, APP_CONDITIONS).map((t) => t.replaceAll("*", match.middle)).find((t) => t.endsWith(".css"));
|
|
1404
|
+
const file = target === void 0 ? void 0 : path.resolve(dir, target);
|
|
1405
|
+
return file && isInside(dir, file) && !isBuildOutput(dir, file) && isFile(file) ? file : null;
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* The folder of `name` installed from a registry, when zero config reads it as a design system:
|
|
1409
|
+
* named like one, with `exports` that point at the component sources it publishes.
|
|
1410
|
+
*/
|
|
1411
|
+
function installedDesignSystem(root, name, packages) {
|
|
1412
|
+
if (!isDesignSystemName(name)) return void 0;
|
|
1413
|
+
const dir = packages.installed(name);
|
|
1414
|
+
const pkg = dir ? readJson$1(path.join(dir, "package.json")) : void 0;
|
|
1415
|
+
const found = dir && pkg ? fromPackage(root, dir, pkg, ignore, true) : void 0;
|
|
1416
|
+
return found && matchesAny(root, found) ? dir : void 0;
|
|
1417
|
+
}
|
|
1418
|
+
/** The installed package a file is in: `…/node_modules/@acme/ui` for `…/node_modules/@acme/ui/src/a.css`. */
|
|
1419
|
+
function installedPackageOf(file) {
|
|
1420
|
+
const parts = path.resolve(file).split(path.sep);
|
|
1421
|
+
const at = parts.lastIndexOf("node_modules");
|
|
1422
|
+
const first = parts[at + 1] ?? ".";
|
|
1423
|
+
if (at === -1 || first.startsWith(".")) return void 0;
|
|
1424
|
+
const end = at + (first.startsWith("@") ? 3 : 2);
|
|
1425
|
+
return end < parts.length ? parts.slice(0, end).join(path.sep) : void 0;
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1178
1428
|
* The file a package's `exports` map `subpath` (`.`, `./theme`) to, through any condition
|
|
1179
1429
|
* (`style`, `import`, `default`) and `*` patterns: the first target on disk, inside the
|
|
1180
1430
|
* package, that `accept` takes.
|
|
1181
1431
|
*/
|
|
1182
1432
|
function exportedFile(dir, subpath, accept) {
|
|
1433
|
+
for (const { middle, value } of exportMatches(dir, subpath)) for (const target of exportTargets(value)) {
|
|
1434
|
+
const file = path.resolve(dir, target.replaceAll("*", middle));
|
|
1435
|
+
if (isInside(dir, file) && accept(file) && isFile(file)) return file;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* The entries of package `dir`'s `exports` that map `subpath`, with what their `*` stands for:
|
|
1440
|
+
* an exact subpath first, then the most specific pattern, as Node.js picks them.
|
|
1441
|
+
*/
|
|
1442
|
+
function exportMatches(dir, subpath) {
|
|
1183
1443
|
const exports = readJson$1(path.join(dir, "package.json"))?.exports;
|
|
1184
1444
|
const entries = exports && typeof exports === "object" && !Array.isArray(exports) ? Object.keys(exports).every((k) => k.startsWith(".")) ? Object.entries(exports) : [[".", exports]] : exports === void 0 ? [] : [[".", exports]];
|
|
1185
1445
|
const matches = [];
|
|
@@ -1204,17 +1464,13 @@ function exportedFile(dir, subpath, accept) {
|
|
|
1204
1464
|
});
|
|
1205
1465
|
}
|
|
1206
1466
|
}
|
|
1207
|
-
matches.sort((a, b) => b.rank - a.rank);
|
|
1208
|
-
for (const { middle, value } of matches) for (const target of exportTargets(value)) {
|
|
1209
|
-
const file = path.resolve(dir, target.replaceAll("*", middle));
|
|
1210
|
-
if (isInside(dir, file) && accept(file) && isFile(file)) return file;
|
|
1211
|
-
}
|
|
1467
|
+
return matches.sort((a, b) => b.rank - a.rank);
|
|
1212
1468
|
}
|
|
1213
|
-
/** Every target of an export condition map, in order, but its types. */
|
|
1214
|
-
function exportTargets(value) {
|
|
1469
|
+
/** Every target of an export condition map, in order, but its types, or those of `conditions` alone. */
|
|
1470
|
+
function exportTargets(value, conditions) {
|
|
1215
1471
|
if (typeof value === "string") return [value];
|
|
1216
|
-
if (Array.isArray(value)) return value.flatMap(exportTargets);
|
|
1217
|
-
if (value && typeof value === "object") return Object.entries(value).flatMap(([condition, nested]) => condition === "types" ? [] : exportTargets(nested));
|
|
1472
|
+
if (Array.isArray(value)) return value.flatMap((item) => exportTargets(item, conditions));
|
|
1473
|
+
if (value && typeof value === "object") return Object.entries(value).flatMap(([condition, nested]) => condition === "types" || conditions && !conditions.has(condition) ? [] : exportTargets(nested, conditions));
|
|
1218
1474
|
return [];
|
|
1219
1475
|
}
|
|
1220
1476
|
/**
|
|
@@ -1328,6 +1584,87 @@ function packageImports(root, dir) {
|
|
|
1328
1584
|
return pkg ? fromPackage(root, dir, pkg)?.imports ?? [] : [];
|
|
1329
1585
|
}
|
|
1330
1586
|
/**
|
|
1587
|
+
* The dependencies of `root` (in `dependencies` or `devDependencies`) whose components are read
|
|
1588
|
+
* from their type declarations: design-system packages installed from a registry that name
|
|
1589
|
+
* `.d.ts` files, and whose component sources the root does not read (`options.components`
|
|
1590
|
+
* matches no file in them), as when they publish only compiled code. A design-system package
|
|
1591
|
+
* is one that declares its onsystem config (set up with `init`); one named like a design system
|
|
1592
|
+
* in the root's own npm scope (`@acme/ui` for `@acme/web`); or the dependency the root's config
|
|
1593
|
+
* names as its `importPath`, whatever its name or scope (`@dub/ui` in an unscoped app). A kit
|
|
1594
|
+
* from another scope named like one (`@sanity/ui`, `@react-email/components`) is not the app's
|
|
1595
|
+
* design system unless the config says so, and is not read. Workspace packages are read from
|
|
1596
|
+
* their sources. None at a workspace root, whose projects each find their own.
|
|
1597
|
+
*/
|
|
1598
|
+
function declarationsPackages(root, packages = new PackageFinder(root), options = {}) {
|
|
1599
|
+
const own = readJson$1(path.join(root, "package.json"));
|
|
1600
|
+
if (!own || isWorkspaceRoot(root)) return [];
|
|
1601
|
+
const scope = scopeOf$1(own.name);
|
|
1602
|
+
const found = [];
|
|
1603
|
+
let sources;
|
|
1604
|
+
for (const name of Object.keys({
|
|
1605
|
+
...asRecord(own.dependencies),
|
|
1606
|
+
...asRecord(own.devDependencies)
|
|
1607
|
+
})) {
|
|
1608
|
+
const located = packages.locate(name);
|
|
1609
|
+
if (!located) continue;
|
|
1610
|
+
const declaration = located.json["onsystem"] === void 0 ? void 0 : declaredConfig(packages.place(located.dir).dir, located.json);
|
|
1611
|
+
const by = declaration !== void 0 && "file" in declaration ? "config" : scope !== void 0 && scopeOf$1(name) === scope && isDesignSystemName(name) ? "scope" : name === options.importPath ? "importPath" : void 0;
|
|
1612
|
+
if (!by) continue;
|
|
1613
|
+
const installed = packages.installed(name);
|
|
1614
|
+
const pkg = installed ? cachedManifest(path.join(installed, "package.json")) : void 0;
|
|
1615
|
+
if (!installed || !pkg || !declaresTypes(installed, pkg)) continue;
|
|
1616
|
+
sources ??= componentSources(root, options.components ?? []);
|
|
1617
|
+
if (sources.some((file) => isInside(realPath(installed), file))) continue;
|
|
1618
|
+
const version = typeof pkg.version === "string" ? pkg.version : void 0;
|
|
1619
|
+
found.push({
|
|
1620
|
+
name,
|
|
1621
|
+
dir: path.resolve(located.dir),
|
|
1622
|
+
version,
|
|
1623
|
+
by
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
return found;
|
|
1627
|
+
}
|
|
1628
|
+
/** `@acme` for `@acme/web`; undefined for an unscoped name. */
|
|
1629
|
+
function scopeOf$1(name) {
|
|
1630
|
+
return typeof name === "string" && /^@[^/]+\/./.test(name) ? name.split("/")[0] : void 0;
|
|
1631
|
+
}
|
|
1632
|
+
/** Whether a package names type declarations: `types`, a `types` export condition, or `index.d.ts`. */
|
|
1633
|
+
function declaresTypes(dir, pkg) {
|
|
1634
|
+
if (typeof pkg.types === "string" || typeof pkg.typings === "string") return true;
|
|
1635
|
+
const hasTypes = (value) => Array.isArray(value) ? value.some(hasTypes) : value !== null && typeof value === "object" && Object.entries(value).some(([key, nested]) => key === "types" || hasTypes(nested));
|
|
1636
|
+
return hasTypes(pkg.exports) || isFile(path.join(dir, "index.d.ts"));
|
|
1637
|
+
}
|
|
1638
|
+
/** Files that hold no component to read: declarations, tests and stories, as `exclude` has them. */
|
|
1639
|
+
const NOT_COMPONENT_SOURCES = [
|
|
1640
|
+
"**/*.d.ts",
|
|
1641
|
+
"**/*.{test,spec,stories}.{ts,tsx,js,jsx}",
|
|
1642
|
+
"**/__{tests,stories,mocks}__/**"
|
|
1643
|
+
];
|
|
1644
|
+
/**
|
|
1645
|
+
* The real paths of the component files in `node_modules` that `patterns` (relative to `root`)
|
|
1646
|
+
* match: the sources of an installed package that the root reads, through a design-system
|
|
1647
|
+
* package's config or zero config. Tests and stories are not components.
|
|
1648
|
+
*/
|
|
1649
|
+
function componentSources(root, patterns) {
|
|
1650
|
+
const named = patterns.filter((p) => p.includes("node_modules"));
|
|
1651
|
+
if (!named.length) return [];
|
|
1652
|
+
return globSync(named, {
|
|
1653
|
+
cwd: root,
|
|
1654
|
+
absolute: true,
|
|
1655
|
+
expandDirectories: false,
|
|
1656
|
+
ignore: NOT_COMPONENT_SOURCES
|
|
1657
|
+
}).map(realPath);
|
|
1658
|
+
}
|
|
1659
|
+
/** A path with its links resolved, or as it is when it cannot be. */
|
|
1660
|
+
function realPath(file) {
|
|
1661
|
+
try {
|
|
1662
|
+
return fs.realpathSync(file);
|
|
1663
|
+
} catch {
|
|
1664
|
+
return path.resolve(file);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1331
1668
|
* Dependencies' package.json files, parsed, by path: read again only when one's size or mtime
|
|
1332
1669
|
* changes, so a config loaded again (the ESLint plugin's and the MCP server's reloads) does not
|
|
1333
1670
|
* read every dependency again.
|
|
@@ -1389,14 +1726,8 @@ var PackageFinder = class {
|
|
|
1389
1726
|
* checkout that is not installed (only then is the workspace scanned). With its package.json.
|
|
1390
1727
|
*/
|
|
1391
1728
|
locate(name) {
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
const json = cachedManifest(path.join(dir, "package.json"));
|
|
1395
|
-
if (json) return {
|
|
1396
|
-
dir,
|
|
1397
|
-
json
|
|
1398
|
-
};
|
|
1399
|
-
}
|
|
1729
|
+
const found = this.inNodeModules(name);
|
|
1730
|
+
if (found) return found;
|
|
1400
1731
|
this.linkable ??= scanWorkspace(this.root, false);
|
|
1401
1732
|
const dir = this.linkable.get(name);
|
|
1402
1733
|
const json = dir ? cachedManifest(path.join(dir, "package.json")) : void 0;
|
|
@@ -1421,6 +1752,30 @@ var PackageFinder = class {
|
|
|
1421
1752
|
boundary: installed ? dir : this.boundaryOf(dir)
|
|
1422
1753
|
};
|
|
1423
1754
|
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Where `name` is installed from a registry, rather than linked to a workspace package: its
|
|
1757
|
+
* real folder, where pnpm puts its own dependencies beside it, expressed from the root. When
|
|
1758
|
+
* that is outside the root and its workspace (pnpm's store elsewhere on the machine), the
|
|
1759
|
+
* folder in `node_modules` it was found by, the only way the loader reads it.
|
|
1760
|
+
*/
|
|
1761
|
+
installed(name) {
|
|
1762
|
+
const located = this.inNodeModules(name);
|
|
1763
|
+
if (!located) return void 0;
|
|
1764
|
+
const placed = this.place(located.dir);
|
|
1765
|
+
if (!placed.installed) return void 0;
|
|
1766
|
+
return isInside(this.root, placed.dir) || this.boundaryOf(placed.dir) !== placed.dir ? placed.dir : path.resolve(located.dir);
|
|
1767
|
+
}
|
|
1768
|
+
/** `name` in the nearest `node_modules` at or above the root that has it, with its package.json. */
|
|
1769
|
+
inNodeModules(name) {
|
|
1770
|
+
for (const nodeModules of this.nodeModulesDirs()) {
|
|
1771
|
+
const dir = path.join(nodeModules, ...name.split("/"));
|
|
1772
|
+
const json = cachedManifest(path.join(dir, "package.json"));
|
|
1773
|
+
if (json) return {
|
|
1774
|
+
dir,
|
|
1775
|
+
json
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1424
1779
|
/** The `node_modules` folders at and above the root, nearest first, looked up once. */
|
|
1425
1780
|
nodeModulesDirs() {
|
|
1426
1781
|
if (!this.nodeModules) {
|
|
@@ -2176,9 +2531,23 @@ const UNREAD_LIBRARIES = [
|
|
|
2176
2531
|
function libraryAdapter(name) {
|
|
2177
2532
|
return LIBRARIES.find((library) => library.package === name);
|
|
2178
2533
|
}
|
|
2179
|
-
/**
|
|
2534
|
+
/** What a design-system package read from its type declarations is called in messages. */
|
|
2535
|
+
const PACKAGE_TITLE = "Design-system package";
|
|
2536
|
+
/** A library's name for messages, read or not: `MUI`, `Chakra UI`, `Design-system package`. */
|
|
2180
2537
|
function libraryTitle(name) {
|
|
2181
|
-
return [...LIBRARIES, ...UNREAD_LIBRARIES].find((l) => l.package === name)?.title ??
|
|
2538
|
+
return [...LIBRARIES, ...UNREAD_LIBRARIES].find((l) => l.package === name)?.title ?? PACKAGE_TITLE;
|
|
2539
|
+
}
|
|
2540
|
+
/**
|
|
2541
|
+
* The adapter for a design-system package read from its type declarations (`@acme/ui`): its
|
|
2542
|
+
* components and their props, with no theme to read, and the app's augmentations of its modules.
|
|
2543
|
+
*/
|
|
2544
|
+
function packageAdapter(name) {
|
|
2545
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2546
|
+
return {
|
|
2547
|
+
package: name,
|
|
2548
|
+
title: PACKAGE_TITLE,
|
|
2549
|
+
augments: new RegExp(`declare\\s+module\\s+['"]${escaped}(?:/[\\w./-]*)?['"]`)
|
|
2550
|
+
};
|
|
2182
2551
|
}
|
|
2183
2552
|
/** Why a library in `UNREAD_LIBRARIES` is not read: `a dependency onsystem does not read yet (…)`. */
|
|
2184
2553
|
function notReadYet() {
|
|
@@ -2190,8 +2559,15 @@ function notReadYet() {
|
|
|
2190
2559
|
* folder above it, as a monorepo hoists it. Nothing in the package is run. `ids` is the config's
|
|
2191
2560
|
* `libraries`: undefined reads every library onsystem supports, false none, a list those named;
|
|
2192
2561
|
* the dependencies it leaves out are `off`. Those in `UNREAD_LIBRARIES` are `unread`.
|
|
2193
|
-
|
|
2194
|
-
|
|
2562
|
+
*
|
|
2563
|
+
* The design-system packages installed without their component sources that the app depends
|
|
2564
|
+
* on ({@link declarationsPackages}) come after them, read from their type declarations unless
|
|
2565
|
+
* `libraries` is false. A list names libraries, not packages, so it leaves them in. `options`
|
|
2566
|
+
* is what the config says of them: its `importPath`, which makes the dependency it names one,
|
|
2567
|
+
* and the component globs that read a package from its sources instead. A library onsystem
|
|
2568
|
+
* knows is never read as one, whatever `importPath` says.
|
|
2569
|
+
*/
|
|
2570
|
+
function detectLibraries(root, ids, options = {}) {
|
|
2195
2571
|
const pkg = readJson(path.join(root, "package.json"));
|
|
2196
2572
|
if (!pkg) return {
|
|
2197
2573
|
read: [],
|
|
@@ -2220,9 +2596,16 @@ function detectLibraries(root, ids) {
|
|
|
2220
2596
|
source: sourceOf(library.package)
|
|
2221
2597
|
}));
|
|
2222
2598
|
const wanted = (id) => ids === void 0 || ids !== false && ids.includes(id);
|
|
2599
|
+
const known = new Set([...LIBRARIES, ...UNREAD_LIBRARIES].map((library) => library.package));
|
|
2600
|
+
const packages = declarationsPackages(root, void 0, options).filter((p) => !known.has(p.name)).map((p) => ({
|
|
2601
|
+
package: p.name,
|
|
2602
|
+
dir: p.dir,
|
|
2603
|
+
version: p.version,
|
|
2604
|
+
designSystem: true
|
|
2605
|
+
}));
|
|
2223
2606
|
return {
|
|
2224
|
-
read: found.filter((f) => wanted(f.id)).map((f) => f.source),
|
|
2225
|
-
off: found.filter((f) => !wanted(f.id)).map((f) => f.source),
|
|
2607
|
+
read: [...found.filter((f) => wanted(f.id)).map((f) => f.source), ...ids === false ? [] : packages],
|
|
2608
|
+
off: [...found.filter((f) => !wanted(f.id)).map((f) => f.source), ...ids === false ? packages : []],
|
|
2226
2609
|
unread: UNREAD_LIBRARIES.filter((library) => dependencies.has(library.package)).map((library) => sourceOf(library.package))
|
|
2227
2610
|
};
|
|
2228
2611
|
}
|
|
@@ -2881,7 +3264,7 @@ const librariesSchema = z.union([z.literal(false), z.array(z.enum(LIBRARY_IDS))]
|
|
|
2881
3264
|
const configShape = {
|
|
2882
3265
|
$schema: z.string().optional(),
|
|
2883
3266
|
components: globsSchema.optional().describe("Globs for component source files, relative to the root."),
|
|
2884
|
-
exclude: z.array(z.string()).optional().describe("Globs to
|
|
3267
|
+
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`."),
|
|
2885
3268
|
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)."),
|
|
2886
3269
|
docs: globsSchema.optional().describe("Globs for per-component Markdown/MDX docs."),
|
|
2887
3270
|
tsconfig: z.string().optional().describe("tsconfig used to resolve types and path aliases."),
|
|
@@ -3001,6 +3384,7 @@ async function loadConfigWith(options, context = {}) {
|
|
|
3001
3384
|
const excludeFromBase = (e) => {
|
|
3002
3385
|
if (!inherited || e.startsWith("**/")) return e;
|
|
3003
3386
|
if (declared) return fromBase(e);
|
|
3387
|
+
if (e === toProject) return "**";
|
|
3004
3388
|
return e.startsWith(`${toProject}/`) ? e.slice(toProject.length + 1) : void 0;
|
|
3005
3389
|
};
|
|
3006
3390
|
const outside = [];
|
|
@@ -3040,7 +3424,11 @@ async function loadConfigWith(options, context = {}) {
|
|
|
3040
3424
|
const absolute = path.resolve(root, slashGlob(file));
|
|
3041
3425
|
if (!/[*?[\]{}()!]/.test(file) && !fs.existsSync(absolute)) throw new ConfigError(`Token file not found: ${absolute} (from ${explicitTokens?.from})`);
|
|
3042
3426
|
}
|
|
3043
|
-
const
|
|
3427
|
+
const components = (options.components?.length ? options.components : configComponents !== void 0 ? configComponents.map(fromBase) : detection ? [...detection.withDefaults ? DEFAULT_COMPONENTS : [], ...detection.components] : DEFAULT_COMPONENTS).map(slashGlob);
|
|
3428
|
+
const libraries = detectLibraries(root, config.libraries, {
|
|
3429
|
+
importPath: config.importPath,
|
|
3430
|
+
components
|
|
3431
|
+
});
|
|
3044
3432
|
const tokenSources = options.tokens?.length ? options.tokens.map((p) => ({ path: p })) : configTokens !== void 0 ? configTokens.map((t) => typeof t === "string" ? { path: fromBase(t) } : {
|
|
3045
3433
|
...t,
|
|
3046
3434
|
path: fromBase(t.path)
|
|
@@ -3048,11 +3436,18 @@ async function loadConfigWith(options, context = {}) {
|
|
|
3048
3436
|
path: p,
|
|
3049
3437
|
fallback: true
|
|
3050
3438
|
}))];
|
|
3051
|
-
const
|
|
3439
|
+
const tidy = (e) => slashGlob(e).replace(/^(?:\.\/)+/, "").replace(/(.)\/+$/, "$1");
|
|
3440
|
+
const listed = config.exclude ?? [];
|
|
3441
|
+
const dropped = new Set(listed.filter((e) => e.startsWith("!")).map((e) => tidy(e.slice(1))));
|
|
3442
|
+
for (const pattern of dropped) {
|
|
3443
|
+
if (DEFAULT_EXCLUDE.includes(pattern)) continue;
|
|
3444
|
+
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.`);
|
|
3445
|
+
}
|
|
3446
|
+
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);
|
|
3052
3447
|
return {
|
|
3053
3448
|
root,
|
|
3054
3449
|
configFile,
|
|
3055
|
-
components
|
|
3450
|
+
components,
|
|
3056
3451
|
exclude: declared && config.exclude ? [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDE, ...exclude])] : exclude,
|
|
3057
3452
|
...declared && config.exclude ? { designSystemExclude: exclude } : {},
|
|
3058
3453
|
tokens: tokenSources.map((t) => ({
|
|
@@ -3130,6 +3525,20 @@ function resolveRule(setting) {
|
|
|
3130
3525
|
options: setting[1] ?? {}
|
|
3131
3526
|
};
|
|
3132
3527
|
}
|
|
3528
|
+
/** The default test and story patterns, which `--include-tests` lifts. */
|
|
3529
|
+
function isTestPattern(pattern) {
|
|
3530
|
+
return pattern === "**/*.{test,spec,stories}.{ts,tsx,js,jsx}" || pattern === "**/__{tests,stories,mocks}__/**";
|
|
3531
|
+
}
|
|
3532
|
+
/**
|
|
3533
|
+
* The `exclude` pattern that leaves `file` (relative to the root) out, or undefined when none
|
|
3534
|
+
* does. The one test `check` (in folders and globs, and for files named outright with
|
|
3535
|
+
* `--skip-excluded`), the ESLint plugin, the Claude Code hook and check_ui all apply, so they
|
|
3536
|
+
* leave out the same files. A pattern covers the files it matches and everything in a folder it
|
|
3537
|
+
* matches. With `includeTests`, the default test and story patterns do not count.
|
|
3538
|
+
*/
|
|
3539
|
+
function excludedBy(config, file, options = {}) {
|
|
3540
|
+
return config.exclude.find((pattern) => !(options.includeTests && isTestPattern(pattern)) && coveredByGlob(file, pattern));
|
|
3541
|
+
}
|
|
3133
3542
|
/**
|
|
3134
3543
|
* The rules for one file: the config's `rules`, then every override whose
|
|
3135
3544
|
* `files` match the path (relative to the root), in order.
|
|
@@ -4203,9 +4612,9 @@ function dedent(code) {
|
|
|
4203
4612
|
const MAX_LISTED_INHERITED = 40;
|
|
4204
4613
|
/**
|
|
4205
4614
|
* Reads a published library's components from its type declarations with the TypeScript
|
|
4206
|
-
* checker: every value its entry exports under a capitalised name whose type takes props
|
|
4207
|
-
* (function and class components, `forwardRef` and MUI's `OverridableComponent`), with
|
|
4208
|
-
* props of each call signature, their string-literal values (custom ones the app declares by
|
|
4615
|
+
* checker: every value its entry exports under a capitalised name whose type takes props and
|
|
4616
|
+
* renders (function and class components, `forwardRef` and MUI's `OverridableComponent`), with
|
|
4617
|
+
* the props of each call signature, their string-literal values (custom ones the app declares by
|
|
4209
4618
|
* module augmentation included), JSDoc, the element it renders, and the module that
|
|
4210
4619
|
* default-exports it. A signature generic over the element to render (MUI's `component`) marks
|
|
4211
4620
|
* that prop as polymorphic instead of adding its props.
|
|
@@ -4227,16 +4636,27 @@ function extractLibrary(options) {
|
|
|
4227
4636
|
return empty(`${adapter.package}: ${dir} could not be read`);
|
|
4228
4637
|
}
|
|
4229
4638
|
const resolve = (specifier) => ts.resolveModuleName(specifier, path.join(root, "__onsystem__.tsx"), options.project.options, ts.sys).resolvedModule?.resolvedFileName;
|
|
4230
|
-
const
|
|
4231
|
-
|
|
4639
|
+
const main = resolve(adapter.package) ?? path.join(real, typesEntry(real));
|
|
4640
|
+
const entries = isDeclarations(main) ? [{
|
|
4641
|
+
specifier: adapter.package,
|
|
4642
|
+
file: main
|
|
4643
|
+
}] : subpathEntries(real, adapter.package, resolve);
|
|
4644
|
+
if (!entries.length) return empty(`${adapter.package}: no type declarations at ${relativePath(root, main)}`);
|
|
4232
4645
|
const program = ts.createProgram({
|
|
4233
|
-
rootNames: [
|
|
4234
|
-
options: options.project.options
|
|
4646
|
+
rootNames: [...entries.map((e) => e.file), ...options.augmentations],
|
|
4647
|
+
options: options.project.options,
|
|
4648
|
+
host: declarationsHost(options.project.options)
|
|
4235
4649
|
});
|
|
4236
4650
|
const checker = program.getTypeChecker();
|
|
4237
|
-
const
|
|
4238
|
-
|
|
4239
|
-
|
|
4651
|
+
const modules = entries.flatMap((e) => {
|
|
4652
|
+
const file = program.getSourceFile(e.file);
|
|
4653
|
+
const symbol = file ? checker.getSymbolAtLocation(file) : void 0;
|
|
4654
|
+
return symbol ? [{
|
|
4655
|
+
...e,
|
|
4656
|
+
symbol
|
|
4657
|
+
}] : [];
|
|
4658
|
+
});
|
|
4659
|
+
if (!modules.length) return empty(`${adapter.package}: ${entries[0]?.file ?? main} has no exports`);
|
|
4240
4660
|
const shown = (file) => {
|
|
4241
4661
|
const absolute = path.resolve(file);
|
|
4242
4662
|
return relativePath(root, isInside(real, absolute) ? path.join(dir, path.relative(real, absolute)) : absolute);
|
|
@@ -4245,38 +4665,45 @@ function extractLibrary(options) {
|
|
|
4245
4665
|
checker,
|
|
4246
4666
|
adapter,
|
|
4247
4667
|
propSets: /* @__PURE__ */ new Map(),
|
|
4248
|
-
scope: adapter.package.startsWith("@") ? `${adapter.package.split("/")[0] ?? ""}/` : adapter.package
|
|
4668
|
+
scope: adapter.package.startsWith("@") ? `${adapter.package.split("/")[0] ?? ""}/` : adapter.package,
|
|
4669
|
+
icons: /* @__PURE__ */ new Set()
|
|
4249
4670
|
};
|
|
4250
4671
|
const exportNames = /* @__PURE__ */ new Set();
|
|
4251
4672
|
const bySymbol = /* @__PURE__ */ new Map();
|
|
4252
4673
|
const components = [];
|
|
4253
|
-
for (const
|
|
4254
|
-
const
|
|
4255
|
-
const
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4674
|
+
for (const module of modules) {
|
|
4675
|
+
const subpath = module.specifier !== adapter.package;
|
|
4676
|
+
for (const exported of checker.getExportsOfModule(module.symbol)) {
|
|
4677
|
+
const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported;
|
|
4678
|
+
if (!(symbol.flags & ts.SymbolFlags.Value)) continue;
|
|
4679
|
+
const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
|
|
4680
|
+
const isDefault = exported.getName() === "default";
|
|
4681
|
+
const name = isDefault && subpath && declaration ? ts.getNameOfDeclaration(declaration)?.getText() ?? "default" : exported.getName();
|
|
4682
|
+
if (isDefault && !/^[A-Z]/.test(name)) continue;
|
|
4683
|
+
exportNames.add(name);
|
|
4684
|
+
if (!/^[A-Z]/.test(name)) continue;
|
|
4685
|
+
const known = bySymbol.get(symbol);
|
|
4686
|
+
if (known) {
|
|
4687
|
+
if (known.name !== name && !known.aliases.includes(name)) known.aliases.push(name);
|
|
4688
|
+
continue;
|
|
4689
|
+
}
|
|
4690
|
+
if (!declaration) continue;
|
|
4691
|
+
const component = buildComponent(context, name, symbol);
|
|
4692
|
+
if (!component) continue;
|
|
4693
|
+
const file = declaration.getSourceFile();
|
|
4694
|
+
const line = file.getLineAndCharacterOfPosition(declaration.getStart(file)).line + 1;
|
|
4695
|
+
component.source = {
|
|
4696
|
+
file: shown(file.fileName),
|
|
4697
|
+
line
|
|
4698
|
+
};
|
|
4699
|
+
if (subpath) component.importPath = module.specifier;
|
|
4700
|
+
const defaultImport = isDefault ? module.specifier : defaultModule(program, checker, resolve, adapter.package, file, symbol);
|
|
4701
|
+
if (defaultImport) component.defaultImport = defaultImport;
|
|
4702
|
+
bySymbol.set(symbol, component);
|
|
4703
|
+
components.push(component);
|
|
4263
4704
|
}
|
|
4264
|
-
const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
|
|
4265
|
-
if (!declaration) continue;
|
|
4266
|
-
const component = buildComponent(context, name, symbol);
|
|
4267
|
-
if (!component) continue;
|
|
4268
|
-
const file = declaration.getSourceFile();
|
|
4269
|
-
const line = file.getLineAndCharacterOfPosition(declaration.getStart(file)).line + 1;
|
|
4270
|
-
component.source = {
|
|
4271
|
-
file: shown(file.fileName),
|
|
4272
|
-
line
|
|
4273
|
-
};
|
|
4274
|
-
const defaultImport = defaultModule(program, checker, resolve, adapter.package, file, symbol);
|
|
4275
|
-
if (defaultImport) component.defaultImport = defaultImport;
|
|
4276
|
-
bySymbol.set(symbol, component);
|
|
4277
|
-
components.push(component);
|
|
4278
4705
|
}
|
|
4279
|
-
linkSlots(components);
|
|
4706
|
+
linkSlots(components, context.icons);
|
|
4280
4707
|
return {
|
|
4281
4708
|
components,
|
|
4282
4709
|
propSets: Object.fromEntries(context.propSets),
|
|
@@ -4285,6 +4712,29 @@ function extractLibrary(options) {
|
|
|
4285
4712
|
dependencies: unique(projectFiles(program))
|
|
4286
4713
|
};
|
|
4287
4714
|
}
|
|
4715
|
+
/**
|
|
4716
|
+
* A compiler host that resolves what the app's own resolution leaves unresolved in a package's
|
|
4717
|
+
* declarations by path, as Node.js's old resolution does. A bundler emits declarations that
|
|
4718
|
+
* import a dependency's file by its path (`class-variance-authority/dist/types`, for the
|
|
4719
|
+
* `ClassProp` behind every `cva()` variant), which that dependency's `exports` do not name: with
|
|
4720
|
+
* `"moduleResolution": "bundler"` the import is `any`, and a component's variants and props go
|
|
4721
|
+
* with it. The app's own files resolve as they do for tsc.
|
|
4722
|
+
*/
|
|
4723
|
+
function declarationsHost(options) {
|
|
4724
|
+
const host = ts.createCompilerHost(options);
|
|
4725
|
+
const cache = ts.createModuleResolutionCache(host.getCurrentDirectory(), (file) => host.getCanonicalFileName(file), options);
|
|
4726
|
+
const byPath = {
|
|
4727
|
+
...options,
|
|
4728
|
+
moduleResolution: ts.ModuleResolutionKind.Node10
|
|
4729
|
+
};
|
|
4730
|
+
host.resolveModuleNameLiterals = (literals, containingFile, redirected, compilerOptions, file) => literals.map((literal) => {
|
|
4731
|
+
const resolved = ts.resolveModuleName(literal.text, containingFile, compilerOptions, host, cache, redirected, ts.getModeForUsageLocation(file, literal, compilerOptions));
|
|
4732
|
+
if (resolved.resolvedModule || !/[\\/]node_modules[\\/].*\.d\.[cm]?ts$/.test(containingFile)) return resolved;
|
|
4733
|
+
const fallback = ts.resolveModuleName(literal.text, containingFile, byPath, host);
|
|
4734
|
+
return fallback.resolvedModule && /\.d\.[cm]?ts$/.test(fallback.resolvedModule.resolvedFileName) ? fallback : resolved;
|
|
4735
|
+
});
|
|
4736
|
+
return host;
|
|
4737
|
+
}
|
|
4288
4738
|
/** The declaration entry of an installed package: its `types`, else `index.d.ts`. */
|
|
4289
4739
|
function typesEntry(dir) {
|
|
4290
4740
|
try {
|
|
@@ -4294,9 +4744,106 @@ function typesEntry(dir) {
|
|
|
4294
4744
|
} catch {}
|
|
4295
4745
|
return "index.d.ts";
|
|
4296
4746
|
}
|
|
4297
|
-
/**
|
|
4298
|
-
function
|
|
4299
|
-
return
|
|
4747
|
+
/** A type declarations file on disk: `.d.ts`, `.d.mts` or `.d.cts`. */
|
|
4748
|
+
function isDeclarations(file) {
|
|
4749
|
+
return /\.d\.[cm]?ts$/.test(file) && fs.existsSync(file);
|
|
4750
|
+
}
|
|
4751
|
+
/** Packages exporting more subpaths than this are read from the first ones, sorted. */
|
|
4752
|
+
const MAX_SUBPATHS = 500;
|
|
4753
|
+
/**
|
|
4754
|
+
* The declarations of each subpath a package without an entry of its own exports (`./button`,
|
|
4755
|
+
* `./card`, or the files a `./*` pattern maps), with the specifier apps import it by
|
|
4756
|
+
* (`@acme/ui/button`): as the app's own imports resolve it, else through the `types` condition
|
|
4757
|
+
* its `exports` give it. Subpaths that name no declarations (`./theme.css`) are left out.
|
|
4758
|
+
*/
|
|
4759
|
+
function subpathEntries(dir, name, resolve) {
|
|
4760
|
+
let exports;
|
|
4761
|
+
try {
|
|
4762
|
+
exports = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).exports;
|
|
4763
|
+
} catch {
|
|
4764
|
+
return [];
|
|
4765
|
+
}
|
|
4766
|
+
if (!exports || typeof exports !== "object" || Array.isArray(exports)) return [];
|
|
4767
|
+
const found = /* @__PURE__ */ new Map();
|
|
4768
|
+
const add = (subpath, target) => {
|
|
4769
|
+
const specifier = `${name}/${subpath}`;
|
|
4770
|
+
const file = resolve(specifier) ?? (target ? path.resolve(dir, target) : void 0);
|
|
4771
|
+
if (file && isInside(dir, path.resolve(file)) && isDeclarations(file)) found.set(specifier, file);
|
|
4772
|
+
};
|
|
4773
|
+
for (const [key, value] of Object.entries(exports)) {
|
|
4774
|
+
if (!key.startsWith("./") || key === "./package.json") continue;
|
|
4775
|
+
const target = typesTarget(value);
|
|
4776
|
+
const star = key.indexOf("*");
|
|
4777
|
+
if (star === -1) {
|
|
4778
|
+
add(key.slice(2), target);
|
|
4779
|
+
continue;
|
|
4780
|
+
}
|
|
4781
|
+
const at = target?.indexOf("*") ?? -1;
|
|
4782
|
+
if (!target || at === -1) continue;
|
|
4783
|
+
const prefix = path.posix.normalize(target.slice(0, at)).replace(/^\.\//, "");
|
|
4784
|
+
const suffix = target.slice(at + 1);
|
|
4785
|
+
const folder = prefix.slice(0, prefix.lastIndexOf("/") + 1);
|
|
4786
|
+
const files = globSync(`${escapePath(folder)}**/*${escapePath(suffix)}`, {
|
|
4787
|
+
cwd: dir,
|
|
4788
|
+
ignore: ["**/node_modules/**"],
|
|
4789
|
+
expandDirectories: false
|
|
4790
|
+
});
|
|
4791
|
+
for (const file of files.sort()) {
|
|
4792
|
+
if (!file.startsWith(prefix) || !file.endsWith(suffix)) continue;
|
|
4793
|
+
const middle = file.slice(prefix.length, file.length - suffix.length);
|
|
4794
|
+
add(`${key.slice(2, star)}${middle}${key.slice(star + 1)}`, path.join(dir, file));
|
|
4795
|
+
}
|
|
4796
|
+
}
|
|
4797
|
+
return [...found].sort(([a], [b]) => a.localeCompare(b)).slice(0, MAX_SUBPATHS).map(([specifier, file]) => ({
|
|
4798
|
+
specifier,
|
|
4799
|
+
file
|
|
4800
|
+
}));
|
|
4801
|
+
}
|
|
4802
|
+
/** The first `types` target an `exports` value gives, through nested conditions. */
|
|
4803
|
+
function typesTarget(value) {
|
|
4804
|
+
if (Array.isArray(value)) {
|
|
4805
|
+
for (const item of value) {
|
|
4806
|
+
const found = typesTarget(item);
|
|
4807
|
+
if (found) return found;
|
|
4808
|
+
}
|
|
4809
|
+
return;
|
|
4810
|
+
}
|
|
4811
|
+
if (!value || typeof value !== "object") return void 0;
|
|
4812
|
+
for (const [condition, nested] of Object.entries(value)) {
|
|
4813
|
+
if (condition === "types" && typeof nested === "string") return nested;
|
|
4814
|
+
const found = typesTarget(nested);
|
|
4815
|
+
if (found) return found;
|
|
4816
|
+
}
|
|
4817
|
+
}
|
|
4818
|
+
/**
|
|
4819
|
+
* Call and construct signatures that take props and render, across a union
|
|
4820
|
+
* (`React.ComponentType`): a call that returns an element, and a class whose instances have
|
|
4821
|
+
* `render`. A capitalised function that returns anything else (`Format(value): string`) is no
|
|
4822
|
+
* component.
|
|
4823
|
+
*/
|
|
4824
|
+
function propSignatures(checker, type) {
|
|
4825
|
+
return (type.isUnion() ? type.types : [type]).flatMap((member) => [...member.getCallSignatures().filter((s) => rendersElement(checker, checker.getReturnTypeOfSignature(s))), ...member.getConstructSignatures().filter((s) => checker.getPropertyOfType(checker.getReturnTypeOfSignature(s), "render") !== void 0)]).filter((signature) => signature.getParameters().length >= 1);
|
|
4826
|
+
}
|
|
4827
|
+
/**
|
|
4828
|
+
* Whether a call's return type is what a component returns: a React element (`JSX.Element`,
|
|
4829
|
+
* `ReactElement`), a `ReactNode` that can be one, a promise of one (a server component), or
|
|
4830
|
+
* nothing (`null`). `any`, where React's types do not resolve, counts.
|
|
4831
|
+
*/
|
|
4832
|
+
function rendersElement(checker, type) {
|
|
4833
|
+
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Null)) return true;
|
|
4834
|
+
const members = (type.isUnion() ? type.types : [type]).filter((member) => !(member.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)));
|
|
4835
|
+
if (!members.length) return true;
|
|
4836
|
+
return members.some((member) => {
|
|
4837
|
+
if (member.flags & ts.TypeFlags.Any) return true;
|
|
4838
|
+
if (checker.getPropertyOfType(member, "props") !== void 0 && checker.getPropertyOfType(member, "type") !== void 0) return true;
|
|
4839
|
+
const awaited = checker.getPropertyOfType(member, "then") ? checker.getAwaitedType(member) : void 0;
|
|
4840
|
+
return awaited !== void 0 && awaited !== member && rendersElement(checker, awaited);
|
|
4841
|
+
});
|
|
4842
|
+
}
|
|
4843
|
+
/** A conditional type the checker cannot resolve yet, or a union or intersection with one. */
|
|
4844
|
+
function hasConditional(type) {
|
|
4845
|
+
if (type.flags & ts.TypeFlags.Conditional) return true;
|
|
4846
|
+
return type.isUnionOrIntersection() && type.types.some((member) => hasConditional(member));
|
|
4300
4847
|
}
|
|
4301
4848
|
/** A React context (`ButtonGroupContext`): callable in React 19's types, but not a component to offer. */
|
|
4302
4849
|
function isContext(checker, type) {
|
|
@@ -4325,7 +4872,7 @@ function buildComponent(context, name, symbol) {
|
|
|
4325
4872
|
const { checker, adapter } = context;
|
|
4326
4873
|
const type = checker.getTypeOfSymbol(symbol);
|
|
4327
4874
|
if (isContext(checker, type)) return void 0;
|
|
4328
|
-
const signatures = propSignatures(type);
|
|
4875
|
+
const signatures = propSignatures(checker, type);
|
|
4329
4876
|
if (!signatures.length) return void 0;
|
|
4330
4877
|
let polymorphic;
|
|
4331
4878
|
const plain = [];
|
|
@@ -4346,6 +4893,7 @@ function buildComponent(context, name, symbol) {
|
|
|
4346
4893
|
openProps = true;
|
|
4347
4894
|
continue;
|
|
4348
4895
|
}
|
|
4896
|
+
if (signature.getTypeParameters()?.length && hasConditional(propsType)) openProps = true;
|
|
4349
4897
|
for (const part of propsType.isUnion() ? propsType.types : [propsType]) {
|
|
4350
4898
|
members++;
|
|
4351
4899
|
if (checker.getIndexInfosOfType(part).length) openProps = true;
|
|
@@ -4372,7 +4920,8 @@ function buildComponent(context, name, symbol) {
|
|
|
4372
4920
|
const byOrigin = /* @__PURE__ */ new Map();
|
|
4373
4921
|
for (const [propName, { origin }] of entries) byOrigin.set(origin, [...byOrigin.get(origin) ?? [], propName]);
|
|
4374
4922
|
const summarised = new Set([...byOrigin].filter(([origin, names]) => !own(origin) && (origin === "react" || names.length > MAX_LISTED_INHERITED)).map(([origin]) => origin));
|
|
4375
|
-
const
|
|
4923
|
+
const rendered = elementText(checker, used.at(-1));
|
|
4924
|
+
const element = renderedElement(rendered);
|
|
4376
4925
|
const props = [];
|
|
4377
4926
|
const variants = [];
|
|
4378
4927
|
for (const [propName, entry] of entries) {
|
|
@@ -4426,6 +4975,7 @@ function buildComponent(context, name, symbol) {
|
|
|
4426
4975
|
};
|
|
4427
4976
|
if (polymorphic) info.polymorphic = polymorphic.name;
|
|
4428
4977
|
if (element) info.element = element;
|
|
4978
|
+
if (rendered && /\bSVG\w*Element\b/.test(rendered)) context.icons.add(info);
|
|
4429
4979
|
const jsdoc = ts.displayPartsToString(symbol.getDocumentationComment(checker)).trim();
|
|
4430
4980
|
const description = jsdoc ? adapter.describe?.(jsdoc, name) ?? jsdoc : void 0;
|
|
4431
4981
|
if (description) info.description = description;
|
|
@@ -4527,15 +5077,23 @@ function literalValues$1(checker, type, depth = 0) {
|
|
|
4527
5077
|
}
|
|
4528
5078
|
return values.length ? unique(values) : void 0;
|
|
4529
5079
|
}
|
|
4530
|
-
/** The native element
|
|
4531
|
-
function renderedElement(
|
|
5080
|
+
/** The native element an {@link elementText} names: `button` for `Ref<HTMLButtonElement>`. */
|
|
5081
|
+
function renderedElement(text) {
|
|
5082
|
+
const element = text ? /\bHTML(\w+?)Element\b/.exec(text)?.[1] : void 0;
|
|
5083
|
+
return element ? ELEMENT_INTERFACES[element] : void 0;
|
|
5084
|
+
}
|
|
5085
|
+
/**
|
|
5086
|
+
* What names the native element behind a signature, as text: the type of the `ref` its props
|
|
5087
|
+
* take, or without one, of the DOM event handler they take (`HTMLAttributes<HTMLSpanElement>`
|
|
5088
|
+
* gives `onFocus: FocusEventHandler<HTMLSpanElement>`), as a function component's props declare
|
|
5089
|
+
* it. An icon's names an SVG element (`FocusEventHandler<SVGSVGElement>`).
|
|
5090
|
+
*/
|
|
5091
|
+
function elementText(checker, signature) {
|
|
4532
5092
|
const param = signature?.getParameters()[0];
|
|
4533
5093
|
if (!param) return void 0;
|
|
4534
|
-
const
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
const element = /\bHTML(\w+?)Element\b/.exec(text)?.[1];
|
|
4538
|
-
return element ? ELEMENT_INTERFACES[element] : void 0;
|
|
5094
|
+
const props = checker.getTypeOfSymbol(param);
|
|
5095
|
+
const typed = checker.getPropertyOfType(props, "ref") ?? checker.getPropertyOfType(props, "onFocus");
|
|
5096
|
+
return typed ? checker.typeToString(checker.getTypeOfSymbol(typed)) : void 0;
|
|
4539
5097
|
}
|
|
4540
5098
|
/**
|
|
4541
5099
|
* The folder module that default-exports a component (`@mui/material/Button` for
|
|
@@ -4553,16 +5111,36 @@ function defaultModule(program, checker, resolve, name, file, symbol) {
|
|
|
4553
5111
|
}
|
|
4554
5112
|
/**
|
|
4555
5113
|
* Styled slots a library exports next to the component they belong to (`SliderThumb` in
|
|
4556
|
-
* `Slider.d.ts`
|
|
4557
|
-
|
|
4558
|
-
|
|
5114
|
+
* `Slider.d.ts`, `CardHeader` and `CardContent` next to `Card` in a bundle's one `index.d.ts`)
|
|
5115
|
+
* become its parts, so lists show the component and its parts under it. A name that only goes
|
|
5116
|
+
* on in lower case (`Tabs` next to `Tab`) is another component, and so is a lone container
|
|
5117
|
+
* (`CheckboxGroup` next to `Checkbox`), as for source components. Icons are no parts and have
|
|
5118
|
+
* none (`UserCheck` next to `User`). In a file that declares several components of their own,
|
|
5119
|
+
* as a bundle does, a component with one such name next to it is no family
|
|
5120
|
+
* (`ButtonTooltip` next to `Button`): only two or more are its parts.
|
|
5121
|
+
*/
|
|
5122
|
+
function linkSlots(components, icons) {
|
|
4559
5123
|
const byFile = /* @__PURE__ */ new Map();
|
|
4560
|
-
for (const component of components)
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
5124
|
+
for (const component of components) {
|
|
5125
|
+
if (icons.has(component)) continue;
|
|
5126
|
+
byFile.set(component.source.file, [...byFile.get(component.source.file) ?? [], component]);
|
|
5127
|
+
}
|
|
5128
|
+
for (const group of byFile.values()) {
|
|
5129
|
+
const parts = /* @__PURE__ */ new Map();
|
|
5130
|
+
for (const component of group) {
|
|
5131
|
+
const owner = group.filter((other) => other !== component && component.name.length > other.name.length && component.name.startsWith(other.name) && /^[A-Z]/.test(component.name.charAt(other.name.length))).sort((a, b) => b.name.length - a.name.length)[0];
|
|
5132
|
+
if (owner) parts.set(owner, [...parts.get(owner) ?? [], component]);
|
|
5133
|
+
}
|
|
5134
|
+
const owned = new Set([...parts.values()].flat());
|
|
5135
|
+
const bundle = group.filter((component) => !owned.has(component)).length > 1;
|
|
5136
|
+
for (const [owner, family] of parts) {
|
|
5137
|
+
if (family.every((part) => /^(?:Group|List)$/.test(part.name.slice(owner.name.length)))) continue;
|
|
5138
|
+
if (bundle && family.length < 2) continue;
|
|
5139
|
+
for (const part of family) {
|
|
5140
|
+
part.parent = owner.name;
|
|
5141
|
+
owner.subcomponents.push(part.name);
|
|
5142
|
+
}
|
|
5143
|
+
}
|
|
4566
5144
|
}
|
|
4567
5145
|
}
|
|
4568
5146
|
//#endregion
|
|
@@ -4583,7 +5161,8 @@ const NOT_SOURCES = [
|
|
|
4583
5161
|
/**
|
|
4584
5162
|
* The components and tokens of the libraries `config` found (`config.libraries`): each one's
|
|
4585
5163
|
* components from its type declarations, read together with the app's files that augment them,
|
|
4586
|
-
* and the tokens of the theme the app creates for it.
|
|
5164
|
+
* and the tokens of the theme the app creates for it. A design-system package installed without
|
|
5165
|
+
* its sources is read the same way, with no theme.
|
|
4587
5166
|
*/
|
|
4588
5167
|
async function loadLibraries(config) {
|
|
4589
5168
|
const loaded = {
|
|
@@ -4595,7 +5174,7 @@ async function loadLibraries(config) {
|
|
|
4595
5174
|
dependencies: []
|
|
4596
5175
|
};
|
|
4597
5176
|
const libraries = (config.libraries ?? []).flatMap((source) => {
|
|
4598
|
-
const adapter = libraryAdapter(source.package);
|
|
5177
|
+
const adapter = libraryAdapter(source.package) ?? (source.designSystem ? packageAdapter(source.package) : void 0);
|
|
4599
5178
|
return adapter ? [{
|
|
4600
5179
|
source,
|
|
4601
5180
|
adapter
|
|
@@ -4703,6 +5282,17 @@ function walk$1(root, enter) {
|
|
|
4703
5282
|
}
|
|
4704
5283
|
}
|
|
4705
5284
|
const VARIANT_FUNCTIONS = /^(cva|tv)$/;
|
|
5285
|
+
/** How many names deep a class value is followed: `className={tone}` with `const tone = base`. */
|
|
5286
|
+
const MAX_FOLLOW = 4;
|
|
5287
|
+
/** The node around `node`, or undefined at the file. */
|
|
5288
|
+
function parentScope(node) {
|
|
5289
|
+
return ts.isSourceFile(node) ? void 0 : node.parent;
|
|
5290
|
+
}
|
|
5291
|
+
/** Whether a declared name (`tone`, or one in `{ tone, size }`) binds `name`. */
|
|
5292
|
+
function binds(declared, name) {
|
|
5293
|
+
if (ts.isIdentifier(declared)) return declared.text === name;
|
|
5294
|
+
return declared.elements.some((e) => !ts.isOmittedExpression(e) && binds(e.name, name));
|
|
5295
|
+
}
|
|
4706
5296
|
function analyze(sourceFile) {
|
|
4707
5297
|
const analysis = {
|
|
4708
5298
|
imports: /* @__PURE__ */ new Map(),
|
|
@@ -4751,10 +5341,100 @@ function analyze(sourceFile) {
|
|
|
4751
5341
|
for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]);
|
|
4752
5342
|
}
|
|
4753
5343
|
};
|
|
5344
|
+
/**
|
|
5345
|
+
* The `const` value or function a name refers to where it is used, declared in this file and
|
|
5346
|
+
* in scope there: undefined for a parameter, an import, a `let` or anything else that can
|
|
5347
|
+
* hold another value.
|
|
5348
|
+
*/
|
|
5349
|
+
const declarationOf = (use) => {
|
|
5350
|
+
const name = use.text;
|
|
5351
|
+
for (let node = use.parent; node; node = parentScope(node)) {
|
|
5352
|
+
if (ts.isFunctionLike(node) && node.parameters.some((p) => binds(p.name, name))) return;
|
|
5353
|
+
if (ts.isCatchClause(node) && node.variableDeclaration) {
|
|
5354
|
+
if (binds(node.variableDeclaration.name, name)) return;
|
|
5355
|
+
}
|
|
5356
|
+
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;
|
|
5357
|
+
const statements = ts.isBlock(node) || ts.isSourceFile(node) || ts.isModuleBlock(node) || ts.isCaseClause(node) || ts.isDefaultClause(node) ? node.statements : void 0;
|
|
5358
|
+
for (const statement of statements ?? []) {
|
|
5359
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === name) return statement;
|
|
5360
|
+
if ((ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement)) && statement.name?.text === name) return;
|
|
5361
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
5362
|
+
const list = statement.declarationList;
|
|
5363
|
+
const declaration = list.declarations.find((d) => binds(d.name, name));
|
|
5364
|
+
if (!declaration) continue;
|
|
5365
|
+
return (list.flags & ts.NodeFlags.Const) !== 0 && ts.isIdentifier(declaration.name) ? declaration.initializer : void 0;
|
|
5366
|
+
}
|
|
5367
|
+
}
|
|
5368
|
+
};
|
|
5369
|
+
/** A same-file `const` value: `"bg-green-50"`, a condition, an object map. */
|
|
5370
|
+
const constValue = (use) => {
|
|
5371
|
+
const declaration = declarationOf(use);
|
|
5372
|
+
return declaration && !ts.isFunctionDeclaration(declaration) ? declaration : void 0;
|
|
5373
|
+
};
|
|
5374
|
+
/** The object literals a value can be: a `const` map, or a map in one (`styles.badge`). */
|
|
5375
|
+
const objectsOf = (expr, depth) => {
|
|
5376
|
+
if (depth > MAX_FOLLOW) return [];
|
|
5377
|
+
return valueBranches(expr).flatMap((leaf) => {
|
|
5378
|
+
if (ts.isObjectLiteralExpression(leaf)) return [leaf];
|
|
5379
|
+
if (ts.isIdentifier(leaf)) {
|
|
5380
|
+
const value = constValue(leaf);
|
|
5381
|
+
return value ? objectsOf(value, depth + 1) : [];
|
|
5382
|
+
}
|
|
5383
|
+
if (ts.isPropertyAccessExpression(leaf) || ts.isElementAccessExpression(leaf)) return entriesOf(leaf, depth).flatMap((value) => objectsOf(value, depth + 1));
|
|
5384
|
+
return [];
|
|
5385
|
+
});
|
|
5386
|
+
};
|
|
5387
|
+
/** What a key reads from a same-file map: `styles.badge`, or every value of `styles[status]`. */
|
|
5388
|
+
const entriesOf = (access, depth) => {
|
|
5389
|
+
const key = ts.isPropertyAccessExpression(access) ? access.name.text : ts.isStringLiteralLike(access.argumentExpression) || ts.isNumericLiteral(access.argumentExpression) ? access.argumentExpression.text : void 0;
|
|
5390
|
+
return objectsOf(access.expression, depth + 1).flatMap((object) => object.properties.flatMap((prop) => ts.isPropertyAssignment(prop) && (key === void 0 || propertyName(prop.name) === key) ? [prop.initializer] : []));
|
|
5391
|
+
};
|
|
5392
|
+
/** What a call to a function of this file returns: `toneOf(status)`. */
|
|
5393
|
+
const returnsOf = (call) => {
|
|
5394
|
+
if (!ts.isIdentifier(call.expression)) return [];
|
|
5395
|
+
const declaration = declarationOf(call.expression);
|
|
5396
|
+
if (declaration && ts.isFunctionDeclaration(declaration)) return returnedExpressions(declaration);
|
|
5397
|
+
const fn = declaration && asFunction(declaration);
|
|
5398
|
+
return fn ? returnedExpressions(fn) : [];
|
|
5399
|
+
};
|
|
5400
|
+
const followed = /* @__PURE__ */ new Set();
|
|
5401
|
+
const follow = (value, element, depth) => {
|
|
5402
|
+
if (followed.has(value)) return;
|
|
5403
|
+
followed.add(value);
|
|
5404
|
+
classValue(value, element, depth + 1);
|
|
5405
|
+
};
|
|
5406
|
+
/**
|
|
5407
|
+
* The classes a class value holds where it is not written out: a `const` (`className={tone}`,
|
|
5408
|
+
* `cn("p-4", extra)`), a key of a `const` map (`statusStyles[status]`), what a function of
|
|
5409
|
+
* this file returns (`toneOf(status)`), and the parts of a template or a concatenation,
|
|
5410
|
+
* through conditions. Strings written out in it are collected as well, once.
|
|
5411
|
+
*/
|
|
5412
|
+
const classValue = (expr, element, depth) => {
|
|
5413
|
+
if (depth > MAX_FOLLOW) return;
|
|
5414
|
+
for (const leaf of valueBranches(expr)) if (ts.isStringLiteralLike(leaf) || ts.isObjectLiteralExpression(leaf)) collectStrings(leaf, element);
|
|
5415
|
+
else if (ts.isTemplateExpression(leaf)) {
|
|
5416
|
+
collectStrings(leaf, element);
|
|
5417
|
+
for (const span of leaf.templateSpans) classValue(span.expression, element, depth);
|
|
5418
|
+
} else if (ts.isBinaryExpression(leaf) && leaf.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
5419
|
+
classValue(leaf.left, element, depth);
|
|
5420
|
+
classValue(leaf.right, element, depth);
|
|
5421
|
+
} else if (ts.isArrayLiteralExpression(leaf)) for (const item of leaf.elements) classValue(ts.isSpreadElement(item) ? item.expression : item, element, depth);
|
|
5422
|
+
else if (ts.isIdentifier(leaf)) {
|
|
5423
|
+
const value = constValue(leaf);
|
|
5424
|
+
if (value) follow(value, element, depth);
|
|
5425
|
+
} else if (ts.isPropertyAccessExpression(leaf) || ts.isElementAccessExpression(leaf)) for (const value of entriesOf(leaf, depth)) follow(value, element, depth);
|
|
5426
|
+
else if (ts.isCallExpression(leaf)) {
|
|
5427
|
+
const callee = leaf.expression.getText(sourceFile);
|
|
5428
|
+
if (CLASS_FUNCTIONS.test(callee)) {
|
|
5429
|
+
if (depth === 0 && !VARIANT_FUNCTIONS.test(callee)) for (const arg of leaf.arguments) classValue(arg, element, depth);
|
|
5430
|
+
} else for (const value of returnsOf(leaf)) follow(value, element, depth);
|
|
5431
|
+
}
|
|
5432
|
+
};
|
|
4754
5433
|
/** A class value, or an object of them: tv slots, and variant options that style slots. */
|
|
4755
5434
|
const collectClassValues = (node) => {
|
|
4756
5435
|
if (!ts.isObjectLiteralExpression(node)) {
|
|
4757
5436
|
collectStrings(node);
|
|
5437
|
+
classValue(node, void 0, 0);
|
|
4758
5438
|
return;
|
|
4759
5439
|
}
|
|
4760
5440
|
for (const prop of node.properties) if (ts.isPropertyAssignment(prop)) collectClassValues(prop.initializer);
|
|
@@ -4819,7 +5499,10 @@ function analyze(sourceFile) {
|
|
|
4819
5499
|
const name = attribute.name.getText(sourceFile);
|
|
4820
5500
|
const init = attribute.initializer;
|
|
4821
5501
|
if (!init) continue;
|
|
4822
|
-
if (name === "className" || name === "class")
|
|
5502
|
+
if (name === "className" || name === "class") {
|
|
5503
|
+
collectStrings(init, element);
|
|
5504
|
+
if (ts.isJsxExpression(init) && init.expression) classValue(init.expression, element, 0);
|
|
5505
|
+
}
|
|
4823
5506
|
if (name === "style" && ts.isJsxExpression(init) && init.expression) for (const object of styleObjects(init.expression)) analysis.styles.push({
|
|
4824
5507
|
element,
|
|
4825
5508
|
object
|
|
@@ -4830,7 +5513,10 @@ function analyze(sourceFile) {
|
|
|
4830
5513
|
const variants = VARIANT_FUNCTIONS.test(node.expression.getText(sourceFile));
|
|
4831
5514
|
node.arguments.forEach((arg) => {
|
|
4832
5515
|
if (variants && ts.isObjectLiteralExpression(arg)) collectVariantConfig(arg);
|
|
4833
|
-
else
|
|
5516
|
+
else {
|
|
5517
|
+
collectStrings(arg);
|
|
5518
|
+
classValue(arg, void 0, 0);
|
|
5519
|
+
}
|
|
4834
5520
|
});
|
|
4835
5521
|
}
|
|
4836
5522
|
return true;
|
|
@@ -4901,6 +5587,7 @@ function valueBranches(expr) {
|
|
|
4901
5587
|
* style functions (`style={({ isPressed }) => ({ ... })}`) are read through this.
|
|
4902
5588
|
*/
|
|
4903
5589
|
function returnedExpressions(fn) {
|
|
5590
|
+
if (!fn.body) return [];
|
|
4904
5591
|
if (!ts.isBlock(fn.body)) return [fn.body];
|
|
4905
5592
|
const out = [];
|
|
4906
5593
|
const stack = [fn.body];
|
|
@@ -5364,6 +6051,11 @@ const TAILWIND_PALETTE = {
|
|
|
5364
6051
|
950: "oklch(14.7% 0.004 49.3)"
|
|
5365
6052
|
}
|
|
5366
6053
|
};
|
|
6054
|
+
/** Tailwind's colors without shades: `white` and `black`. */
|
|
6055
|
+
const TAILWIND_KEYWORD_COLORS = {
|
|
6056
|
+
black: "#000",
|
|
6057
|
+
white: "#fff"
|
|
6058
|
+
};
|
|
5367
6059
|
//#endregion
|
|
5368
6060
|
//#region src/tokens/css.ts
|
|
5369
6061
|
/** Tailwind v4 theme namespaces → token category. Longest prefixes first. */
|
|
@@ -6274,24 +6966,33 @@ const CORE_FAMILIES = /* @__PURE__ */ new Set([
|
|
|
6274
6966
|
"overlay",
|
|
6275
6967
|
"link"
|
|
6276
6968
|
]);
|
|
6277
|
-
/** Name segments of status colors: what happened, rather than how it looks. */
|
|
6278
|
-
const
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6969
|
+
/** Name segments of status colors, by the status they show: what happened, rather than how it looks. */
|
|
6970
|
+
const STATUS_KINDS = {
|
|
6971
|
+
destructive: [
|
|
6972
|
+
"destructive",
|
|
6973
|
+
"danger",
|
|
6974
|
+
"error",
|
|
6975
|
+
"critical",
|
|
6976
|
+
"negative",
|
|
6977
|
+
"invalid"
|
|
6978
|
+
],
|
|
6979
|
+
warning: [
|
|
6980
|
+
"warning",
|
|
6981
|
+
"warn",
|
|
6982
|
+
"caution"
|
|
6983
|
+
],
|
|
6984
|
+
success: [
|
|
6985
|
+
"success",
|
|
6986
|
+
"positive",
|
|
6987
|
+
"valid"
|
|
6988
|
+
],
|
|
6989
|
+
info: [
|
|
6990
|
+
"info",
|
|
6991
|
+
"information",
|
|
6992
|
+
"notice"
|
|
6993
|
+
]
|
|
6994
|
+
};
|
|
6995
|
+
const STATUS_WORDS = new Set(Object.values(STATUS_KINDS).flat());
|
|
6295
6996
|
/** The family a color token belongs to: the first segment of its name (`sidebar`, `sh`, `rainbow`). */
|
|
6296
6997
|
function tokenFamily(token) {
|
|
6297
6998
|
return tokenKey(token).split("-")[0] ?? "";
|
|
@@ -6334,6 +7035,19 @@ function scopesIn(families, ...texts) {
|
|
|
6334
7035
|
function isStatusToken(token) {
|
|
6335
7036
|
return roleKey(token).split("-").some((segment) => STATUS_WORDS.has(segment));
|
|
6336
7037
|
}
|
|
7038
|
+
/**
|
|
7039
|
+
* The statuses a theme has a color for: `success` for `--success`, Dub's `content-success` or a
|
|
7040
|
+
* DTCG `color.feedback.positive`, `destructive` for shadcn/ui's `--destructive` or `error`.
|
|
7041
|
+
*/
|
|
7042
|
+
function statusKinds(tokens) {
|
|
7043
|
+
const kinds = /* @__PURE__ */ new Set();
|
|
7044
|
+
for (const token of tokens) {
|
|
7045
|
+
if (token.category !== "color") continue;
|
|
7046
|
+
const segments = roleKey(token).split("-");
|
|
7047
|
+
for (const [kind, words] of Object.entries(STATUS_KINDS)) if (segments.some((segment) => words.includes(segment))) kinds.add(kind);
|
|
7048
|
+
}
|
|
7049
|
+
return kinds;
|
|
7050
|
+
}
|
|
6337
7051
|
/** `primary` for `--color-primary`, `text-muted` for the DTCG token `color.text.muted`. */
|
|
6338
7052
|
function tokenKey(token) {
|
|
6339
7053
|
return (token.tailwind || token.name).toLowerCase().replace(/[.\s_]+/g, "-").replace(/^colou?rs?-/, "");
|
|
@@ -8215,6 +8929,7 @@ const preferDesignSystemComponent = {
|
|
|
8215
8929
|
}
|
|
8216
8930
|
const component = context.target.elements.get(key);
|
|
8217
8931
|
if (!component) continue;
|
|
8932
|
+
if (fromRegistry(component, context) && context.target.config.elements[key] === void 0) continue;
|
|
8218
8933
|
if (hiddenFromUser(element)) continue;
|
|
8219
8934
|
if (styledByParent(element, context)) continue;
|
|
8220
8935
|
if (isBare(element)) continue;
|
|
@@ -8232,6 +8947,14 @@ const preferDesignSystemComponent = {
|
|
|
8232
8947
|
}
|
|
8233
8948
|
}
|
|
8234
8949
|
};
|
|
8950
|
+
/**
|
|
8951
|
+
* Whether `component` is in the folder of a shadcn registry that components.json names
|
|
8952
|
+
* (`components/signoff-ui`): a read-only design system, ranked after the others.
|
|
8953
|
+
*/
|
|
8954
|
+
function fromRegistry(component, context) {
|
|
8955
|
+
const file = component.source.file;
|
|
8956
|
+
return (context.target.config.designSystems ?? []).some((source) => source.registry !== void 0 && source.components.some((g) => matchesGlob(file, g)));
|
|
8957
|
+
}
|
|
8235
8958
|
const definitionsByFile = /* @__PURE__ */ new WeakMap();
|
|
8236
8959
|
/** Whether the element's `className` calls a `cva(...)` or `tv(...)` definition declared in this file. */
|
|
8237
8960
|
function styledByOwnVariants(element, context) {
|
|
@@ -9157,6 +9880,35 @@ function labelledByParent(context, element) {
|
|
|
9157
9880
|
return !ts.isJsxElement(value) && !ts.isJsxSelfClosingElement(value) && !ts.isJsxFragment(value);
|
|
9158
9881
|
}
|
|
9159
9882
|
/**
|
|
9883
|
+
* What an `id` or `htmlFor` holds, to compare the two: a string as itself (`"dark-mode"`, `{"x"}`),
|
|
9884
|
+
* an expression as written (`{id}`, `` {`${id}-switch`} ``) together with the function it is in,
|
|
9885
|
+
* since two components in one file can each have their own `id`. Undefined when it holds nothing.
|
|
9886
|
+
*/
|
|
9887
|
+
function idValue(context, attribute) {
|
|
9888
|
+
const init = attribute?.initializer;
|
|
9889
|
+
if (!init) return void 0;
|
|
9890
|
+
const value = ts.isJsxExpression(init) ? init.expression : init;
|
|
9891
|
+
if (!value) return void 0;
|
|
9892
|
+
if (ts.isStringLiteralLike(value)) return value.text.trim() ? `"${value.text}"` : void 0;
|
|
9893
|
+
const scope = ts.findAncestor(value, ts.isFunctionLike);
|
|
9894
|
+
return `${String(scope?.pos ?? -1)}:${value.getText(context.sourceFile)}`;
|
|
9895
|
+
}
|
|
9896
|
+
/**
|
|
9897
|
+
* Whether a label with content names the button through its `id`: `<label htmlFor={id}>Dark
|
|
9898
|
+
* mode</label>` next to `<button id={id} role="switch">`, or the design system's `<Label>`. A
|
|
9899
|
+
* button is a labelable element, so the label's text is its accessible name.
|
|
9900
|
+
*/
|
|
9901
|
+
function labelledByFor(context, element) {
|
|
9902
|
+
const id = idValue(context, findAttribute(element, "id"));
|
|
9903
|
+
if (id === void 0) return false;
|
|
9904
|
+
return context.analysis.elements.some((label) => {
|
|
9905
|
+
if (label === element) return false;
|
|
9906
|
+
if (idValue(context, findAttribute(label, "htmlFor") ?? findAttribute(label, "for")) !== id) return false;
|
|
9907
|
+
const content = classify(label.children, context, []);
|
|
9908
|
+
return content === "text" || content === "unknown";
|
|
9909
|
+
});
|
|
9910
|
+
}
|
|
9911
|
+
/**
|
|
9160
9912
|
* The element whose `render` prop this one is, as in Base UI's
|
|
9161
9913
|
* `<Dialog.Close render={<Button size="icon" />}><XIcon /></Dialog.Close>`:
|
|
9162
9914
|
* the rendered button takes the host's children and attributes. Also through
|
|
@@ -9205,19 +9957,64 @@ function isHidden(element) {
|
|
|
9205
9957
|
return opening !== void 0 && hiddenBy(opening.attributes);
|
|
9206
9958
|
}) !== void 0;
|
|
9207
9959
|
}
|
|
9960
|
+
/**
|
|
9961
|
+
* A link a design system renders through `asChild`: `<a>`, a component that renders one, or a
|
|
9962
|
+
* router's `Link` (`next/link`, `NavLink`) with its `href` or `to`, not an icon named `ExternalLink`.
|
|
9963
|
+
*/
|
|
9964
|
+
function isLink(context, element) {
|
|
9965
|
+
const resolution = context.resolve(element);
|
|
9966
|
+
if (resolution.kind === "intrinsic") return resolution.tag === "a";
|
|
9967
|
+
if (resolution.kind === "component" && resolution.component.element === "a") return true;
|
|
9968
|
+
return /Link$/.test(element.tag) && Boolean(findAttribute(element, "href") ?? findAttribute(element, "to"));
|
|
9969
|
+
}
|
|
9970
|
+
/**
|
|
9971
|
+
* `<Button asChild size="icon"><Link href="/users/1/edit"><Pencil /></Link></Button>`: Radix's
|
|
9972
|
+
* Slot renders the link with the button's props, so the link is the button, and an icon alone
|
|
9973
|
+
* in it names nothing. A label on either counts (`aria-label`, `title`, visually hidden text in
|
|
9974
|
+
* the link). A child that is a button itself is checked on its own; any other child (a `<span>`
|
|
9975
|
+
* under a tooltip's trigger) is not a control.
|
|
9976
|
+
*/
|
|
9977
|
+
function checkAsChildLink(context, button) {
|
|
9978
|
+
const children = button.children.filter((c) => !ts.isJsxText(c) || c.text.trim());
|
|
9979
|
+
const [child] = children;
|
|
9980
|
+
if (children.length !== 1 || !child) return;
|
|
9981
|
+
const link = context.analysis.elements.find((e) => e.node === child);
|
|
9982
|
+
if (!link || link.hasSpread || !isLink(context, link)) return;
|
|
9983
|
+
if (hasLabel(button.attributes) || hasLabel(link.attributes)) return;
|
|
9984
|
+
if (labelledByParent(context, button)) return;
|
|
9985
|
+
const icons = [];
|
|
9986
|
+
const content = classify(link.children, context, icons);
|
|
9987
|
+
const sizeIcon = attributeLiterals(button, "size").some((v) => v.text.includes("icon"));
|
|
9988
|
+
if (content !== "icon" && !(content === "empty" && sizeIcon)) return;
|
|
9989
|
+
const { label, fixed } = iconLabel(context, link, button, icons[0]);
|
|
9990
|
+
const attribute = `aria-label="${label ?? "…"}"`;
|
|
9991
|
+
context.report({
|
|
9992
|
+
...tagRange(context, link),
|
|
9993
|
+
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}.`,
|
|
9994
|
+
suggestion: attribute,
|
|
9995
|
+
fix: fixed ? [{
|
|
9996
|
+
range: [link.tagName.end, link.tagName.end],
|
|
9997
|
+
text: ` ${attribute}`
|
|
9998
|
+
}] : void 0
|
|
9999
|
+
});
|
|
10000
|
+
}
|
|
9208
10001
|
const iconButtonAccessibleName = {
|
|
9209
10002
|
id: "icon-button-accessible-name",
|
|
9210
10003
|
description: "Buttons whose only content is an icon need an aria-label (or visually hidden text).",
|
|
9211
10004
|
run(context) {
|
|
9212
10005
|
for (const element of context.analysis.elements) {
|
|
9213
10006
|
const resolution = context.resolve(element);
|
|
9214
|
-
if (!isButton(context, element) || element.hasSpread) continue;
|
|
9215
|
-
if (findAttribute(element, "asChild")
|
|
10007
|
+
if (!isButton(context, element) || element.hasSpread || isHidden(element)) continue;
|
|
10008
|
+
if (findAttribute(element, "asChild")) {
|
|
10009
|
+
checkAsChildLink(context, element);
|
|
10010
|
+
continue;
|
|
10011
|
+
}
|
|
9216
10012
|
const host = renderHost(context, element);
|
|
9217
10013
|
if (host === "object") continue;
|
|
9218
10014
|
if (host && (isButton(context, host) || host.hasSpread)) continue;
|
|
9219
10015
|
if (hasLabel(element.attributes) || host && hasLabel(host.attributes)) continue;
|
|
9220
10016
|
if (labelledByParent(context, element)) continue;
|
|
10017
|
+
if (labelledByFor(context, element) || host && labelledByFor(context, host)) continue;
|
|
9221
10018
|
const icons = [];
|
|
9222
10019
|
const children = host ? [...host.children, ...element.children] : element.children;
|
|
9223
10020
|
const content = host && !children.length ? "unknown" : classify(children, context, icons);
|
|
@@ -9239,6 +10036,8 @@ const iconButtonAccessibleName = {
|
|
|
9239
10036
|
};
|
|
9240
10037
|
//#endregion
|
|
9241
10038
|
//#region src/lint/rules/hardcoded.ts
|
|
10039
|
+
/** `var(--color-green-50)`: a shade of Tailwind's palette through its CSS variable. */
|
|
10040
|
+
const PALETTE_VARIABLE = /^var\(\s*--color-([a-z]+-(?:50|[1-9]00|950))\s*\)$/;
|
|
9242
10041
|
/** Luminance values, not design colors: `white`/`black` (and hex twins). */
|
|
9243
10042
|
const MASK_LUMINANCE = /* @__PURE__ */ new Set([
|
|
9244
10043
|
"white",
|
|
@@ -9304,25 +10103,51 @@ const noHardcodedColor = {
|
|
|
9304
10103
|
const key = classString.element ?? classString;
|
|
9305
10104
|
const list = lists.get(key) ?? [];
|
|
9306
10105
|
lists.set(key, list);
|
|
10106
|
+
const element = classString.element;
|
|
9307
10107
|
for (const token of splitClasses(classString.text, classString.start)) {
|
|
9308
10108
|
const utility = parseUtility(token.value);
|
|
9309
10109
|
const listed = {
|
|
9310
10110
|
value: token.value,
|
|
9311
|
-
prefix: tokenColorPrefix(utility, tokens.colorKeys)
|
|
10111
|
+
prefix: tokenColorPrefix(utility, tokens.colorKeys),
|
|
10112
|
+
element
|
|
9312
10113
|
};
|
|
9313
10114
|
list.push(listed);
|
|
9314
|
-
|
|
9315
|
-
const element = classString.element;
|
|
10115
|
+
const allowed = allow.has(token.value.toLowerCase());
|
|
9316
10116
|
if (utility.arbitrary !== void 0 && utility.prefix && COLOR_PREFIXES.includes(utility.prefix)) {
|
|
9317
10117
|
const value = utility.arbitrary.replace(/^color:/, "").trim();
|
|
10118
|
+
const shade = PALETTE_VARIABLE.exec(value)?.[1];
|
|
10119
|
+
const viaVariable = shade ? paletteColor(`${utility.prefix}-${shade}`) : void 0;
|
|
10120
|
+
if (viaVariable) {
|
|
10121
|
+
const color = tokens.colorKeys.has(viaVariable.key) ? void 0 : parseColor(viaVariable.value);
|
|
10122
|
+
if (!color) continue;
|
|
10123
|
+
listed.color = color;
|
|
10124
|
+
listed.prefix = viaVariable.prefix;
|
|
10125
|
+
listed.tinted = !viaVariable.gray;
|
|
10126
|
+
if (allowed) continue;
|
|
10127
|
+
listed.report = colorReport(context, {
|
|
10128
|
+
start: token.start,
|
|
10129
|
+
end: token.end,
|
|
10130
|
+
what: `\`${token.value}\` is Tailwind's default palette (\`${viaVariable.key}\`), not a design-system color`,
|
|
10131
|
+
color,
|
|
10132
|
+
tinted: !viaVariable.gray,
|
|
10133
|
+
palette: viaVariable.key,
|
|
10134
|
+
prefix: viaVariable.prefix,
|
|
10135
|
+
place: "class",
|
|
10136
|
+
utility,
|
|
10137
|
+
element,
|
|
10138
|
+
classes: classString.text
|
|
10139
|
+
});
|
|
10140
|
+
continue;
|
|
10141
|
+
}
|
|
9318
10142
|
const whole = parseColor(value);
|
|
9319
10143
|
const literal = whole ? {
|
|
9320
10144
|
text: value,
|
|
9321
10145
|
color: whole
|
|
9322
10146
|
} : findColorLiterals(value)[0];
|
|
9323
|
-
if (!literal
|
|
10147
|
+
if (!literal) continue;
|
|
9324
10148
|
listed.color = literal.color;
|
|
9325
10149
|
listed.prefix = whole ? utility.prefix : void 0;
|
|
10150
|
+
if (allowed || allow.has(literal.text.toLowerCase())) continue;
|
|
9326
10151
|
listed.report = colorReport(context, {
|
|
9327
10152
|
start: token.start,
|
|
9328
10153
|
end: token.end,
|
|
@@ -9344,9 +10169,10 @@ const noHardcodedColor = {
|
|
|
9344
10169
|
text: value,
|
|
9345
10170
|
color: whole
|
|
9346
10171
|
} : findColorLiterals(value)[0];
|
|
9347
|
-
if (!literal
|
|
10172
|
+
if (!literal) continue;
|
|
9348
10173
|
listed.color = literal.color;
|
|
9349
10174
|
listed.prefix = whole ? property.prefix : void 0;
|
|
10175
|
+
if (allowed || allow.has(literal.text.toLowerCase())) continue;
|
|
9350
10176
|
listed.report = colorReport(context, {
|
|
9351
10177
|
start: token.start,
|
|
9352
10178
|
end: token.end,
|
|
@@ -9366,6 +10192,8 @@ const noHardcodedColor = {
|
|
|
9366
10192
|
if (!color) continue;
|
|
9367
10193
|
listed.color = color;
|
|
9368
10194
|
listed.prefix = palette.prefix;
|
|
10195
|
+
listed.tinted = !palette.gray;
|
|
10196
|
+
if (allowed) continue;
|
|
9369
10197
|
listed.report = colorReport(context, {
|
|
9370
10198
|
start: token.start,
|
|
9371
10199
|
end: token.end,
|
|
@@ -9379,13 +10207,64 @@ const noHardcodedColor = {
|
|
|
9379
10207
|
element,
|
|
9380
10208
|
classes: classString.text
|
|
9381
10209
|
});
|
|
10210
|
+
continue;
|
|
10211
|
+
}
|
|
10212
|
+
const keyword = keywordColor(utility, tokens.colorKeys);
|
|
10213
|
+
const tint = keyword ? void 0 : keywordColor(utility, tokens.colorKeys, true);
|
|
10214
|
+
if (tint && colorRole(tint.prefix) === "text" && !(element && isInsideMask(element))) {
|
|
10215
|
+
listed.color = tint.color;
|
|
10216
|
+
listed.prefix = tint.prefix;
|
|
10217
|
+
}
|
|
10218
|
+
if (keyword && !(element && isInsideMask(element))) {
|
|
10219
|
+
listed.color = keyword.color;
|
|
10220
|
+
listed.prefix = keyword.prefix;
|
|
10221
|
+
if (allowed) continue;
|
|
10222
|
+
listed.keyword = {
|
|
10223
|
+
start: token.start,
|
|
10224
|
+
end: token.end,
|
|
10225
|
+
what: `\`${token.value}\` is a fixed color, not a design-system color`,
|
|
10226
|
+
color: keyword.color,
|
|
10227
|
+
tinted: false,
|
|
10228
|
+
keyword: true,
|
|
10229
|
+
prefix: keyword.prefix,
|
|
10230
|
+
place: "class",
|
|
10231
|
+
utility,
|
|
10232
|
+
element,
|
|
10233
|
+
classes: classString.text
|
|
10234
|
+
};
|
|
9382
10235
|
}
|
|
9383
10236
|
}
|
|
9384
10237
|
}
|
|
9385
|
-
|
|
9386
|
-
|
|
9387
|
-
|
|
10238
|
+
const surfaces = new Surfaces(context, lists);
|
|
10239
|
+
const texts = [];
|
|
10240
|
+
const fixedTexts = [];
|
|
10241
|
+
for (const list of lists.values()) for (const listed of list) {
|
|
10242
|
+
const text = colorRole(listed.prefix) === "text" && listed.color !== void 0;
|
|
10243
|
+
if (listed.keyword) {
|
|
10244
|
+
if (text) {
|
|
10245
|
+
const placed = surfaces.place(list, listed);
|
|
10246
|
+
texts.push({
|
|
10247
|
+
text: listed,
|
|
10248
|
+
list
|
|
10249
|
+
});
|
|
10250
|
+
if (placed.contrasts) continue;
|
|
10251
|
+
}
|
|
10252
|
+
listed.report = colorReport(context, listed.keyword);
|
|
10253
|
+
} else if (text) (listed.report?.fix ? fixedTexts : texts).push({
|
|
10254
|
+
text: listed,
|
|
10255
|
+
list
|
|
10256
|
+
});
|
|
9388
10257
|
}
|
|
10258
|
+
for (const { text, list } of texts) surfaces.holdUnderText(text, list);
|
|
10259
|
+
for (const list of lists.values()) keepStatesApart(list);
|
|
10260
|
+
const stays = new Set(texts.map(({ text }) => text));
|
|
10261
|
+
for (const { text, list } of fixedTexts) {
|
|
10262
|
+
if (text.report?.fix) continue;
|
|
10263
|
+
stays.add(text);
|
|
10264
|
+
surfaces.holdUnderText(text, list);
|
|
10265
|
+
}
|
|
10266
|
+
surfaces.holdUnderInherited(stays);
|
|
10267
|
+
for (const list of lists.values()) for (const { report } of list) if (report) context.report(report);
|
|
9389
10268
|
for (const { object, element } of context.analysis.styles) for (const prop of object.properties) {
|
|
9390
10269
|
if (!ts.isPropertyAssignment(prop)) continue;
|
|
9391
10270
|
const name = propertyName(prop.name);
|
|
@@ -9420,8 +10299,63 @@ const noHardcodedColor = {
|
|
|
9420
10299
|
}
|
|
9421
10300
|
}
|
|
9422
10301
|
}
|
|
10302
|
+
reportChartTokens(context, allow);
|
|
9423
10303
|
}
|
|
9424
10304
|
};
|
|
10305
|
+
/** Packages that draw charts: Recharts, Chart.js, Nivo, visx, Victory, Tremor, MUI X Charts, D3. */
|
|
10306
|
+
const CHART_LIBRARY = /chart|^@nivo\/|^@visx\/|^victory|^@tremor\/|^d3(?:-|$)/i;
|
|
10307
|
+
/** Roles of an element that is a drawing: a chart drawn by hand is an image. */
|
|
10308
|
+
const DRAWING_ROLE = /^(?:img|figure|graphics-[a-z]+)$/;
|
|
10309
|
+
const CHART_TOKEN = "is a chart color, and this is not a chart: it changes with the chart palette, and it does not say what a status or brand color says. For a status color, add a token for it to the theme, such as `--success`; otherwise use the semantic token that fits.";
|
|
10310
|
+
/**
|
|
10311
|
+
* Warns on a chart token outside a chart: `text-chart-2` on a green "Active" label in a theme
|
|
10312
|
+
* with no success color. The class is a token, so the color rule would call it clean, but it
|
|
10313
|
+
* says nothing a status or brand color says, and it changes with the chart palette (a gray or a
|
|
10314
|
+
* blue in some shadcn/ui themes). A file that draws a chart is in scope throughout, legend dots
|
|
10315
|
+
* and all: one that imports a chart library, renders a tag such as `<ChartContainer>` or
|
|
10316
|
+
* `<BarChart>`, declares a name such as `chartData`, or is named for charts. So is an element
|
|
10317
|
+
* in a drawing (`<svg>`, `role="img"`), as a chart drawn by hand is. When the design system's
|
|
10318
|
+
* own components use chart tokens outside a chart, they are a palette it shares: nothing is
|
|
10319
|
+
* reported.
|
|
10320
|
+
*/
|
|
10321
|
+
function reportChartTokens(context, allow) {
|
|
10322
|
+
const { tokens } = context.target;
|
|
10323
|
+
if (!tokens.scopedFamilies.has("chart")) return;
|
|
10324
|
+
const chart = /* @__PURE__ */ new Set(["chart"]);
|
|
10325
|
+
if ([...tokens.familyFiles.get("chart") ?? []].some((file) => !scopesIn(chart, file).size)) return;
|
|
10326
|
+
const { elements, imports, declared } = context.analysis;
|
|
10327
|
+
if (scopesIn(chart, ...[
|
|
10328
|
+
context.file,
|
|
10329
|
+
...elements.map((e) => e.tag),
|
|
10330
|
+
...declared
|
|
10331
|
+
]).size) return;
|
|
10332
|
+
if ([...imports.values()].some((binding) => CHART_LIBRARY.test(binding.source))) return;
|
|
10333
|
+
for (const classString of context.analysis.classStrings) {
|
|
10334
|
+
const { element } = classString;
|
|
10335
|
+
if (element && inDrawing(element)) continue;
|
|
10336
|
+
for (const cls of splitClasses(classString.text, classString.start)) {
|
|
10337
|
+
const key = COLOR_UTILITY.exec(parseUtility(cls.value).base)?.[1];
|
|
10338
|
+
if (!key?.startsWith("chart-") || !tokens.colorKeys.has(key)) continue;
|
|
10339
|
+
if (allow.has(cls.value.toLowerCase())) continue;
|
|
10340
|
+
context.report({
|
|
10341
|
+
start: cls.start,
|
|
10342
|
+
end: cls.end,
|
|
10343
|
+
message: `\`${cls.value}\` ${CHART_TOKEN}`,
|
|
10344
|
+
severity: "warning"
|
|
10345
|
+
});
|
|
10346
|
+
}
|
|
10347
|
+
}
|
|
10348
|
+
}
|
|
10349
|
+
/** Whether the element is, or is in, a drawing: an `<svg>`, a `<figure>` or a `role="img"`. */
|
|
10350
|
+
function inDrawing(element) {
|
|
10351
|
+
return ts.findAncestor(element.node, (node) => {
|
|
10352
|
+
if (!ts.isJsxElement(node) && !ts.isJsxSelfClosingElement(node)) return false;
|
|
10353
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
10354
|
+
const tag = opening.tagName.getText(element.node.getSourceFile());
|
|
10355
|
+
if (tag === "svg" || tag === "figure") return true;
|
|
10356
|
+
return opening.attributes.properties.some((attribute) => ts.isJsxAttribute(attribute) && attributeName(attribute) === "role" && literalValues(attribute).some((value) => DRAWING_ROLE.test(value.text)));
|
|
10357
|
+
}) !== void 0;
|
|
10358
|
+
}
|
|
9425
10359
|
function reportLiteralColors(context, literal, element, options) {
|
|
9426
10360
|
const start = literal.getStart(context.sourceFile) + 1;
|
|
9427
10361
|
const text = context.text.slice(start, literal.end - 1);
|
|
@@ -9474,6 +10408,8 @@ function colorReport(context, input) {
|
|
|
9474
10408
|
const opacityGap = formatOpacityGap(input.color, value, `${token.name}${inMode}`);
|
|
9475
10409
|
const tinted = input.tinted ?? isTinted(input.color);
|
|
9476
10410
|
const noHue = !close && reason === "hue" && tinted;
|
|
10411
|
+
const keywordText = input.keyword === true && role === "text";
|
|
10412
|
+
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;
|
|
9477
10413
|
let message;
|
|
9478
10414
|
if (close) {
|
|
9479
10415
|
message = `${input.what}. ${nearest.distance < .02 ? "Matches token" : "Nearest token"} ${found}`;
|
|
@@ -9483,6 +10419,7 @@ function colorReport(context, input) {
|
|
|
9483
10419
|
if (opacityGap) message += ` ${opacityGap}`;
|
|
9484
10420
|
if (status) message += ` ${status}`;
|
|
9485
10421
|
else if (held) message += ` ${held}`;
|
|
10422
|
+
else if (keptBack) message += ` ${keptBack}`;
|
|
9486
10423
|
} else if (noHue) message = `${input.what}. ${NO_TOKEN_HAS_HUE}`;
|
|
9487
10424
|
else message = `${input.what}. ${noMatch(reason, role, found, hueOff)}${meantForAnother(otherRole, input.color, mode)}${opacityGap ? ` ${opacityGap}` : ""} Pick the semantic token that fits.`;
|
|
9488
10425
|
const owner = !variant && input.prefix && input.element ? variantProp(context, input.element, input.prefix) : void 0;
|
|
@@ -9492,11 +10429,11 @@ function colorReport(context, input) {
|
|
|
9492
10429
|
end: input.end,
|
|
9493
10430
|
message,
|
|
9494
10431
|
suggestion: close ? variant ?? replacement : void 0,
|
|
9495
|
-
fix: close && edit && !held && !status ? [{
|
|
10432
|
+
fix: close && edit && !held && !status && !keptBack ? [{
|
|
9496
10433
|
range: [input.start, input.end],
|
|
9497
10434
|
text: edit
|
|
9498
10435
|
}] : void 0,
|
|
9499
|
-
severity: noHue ? "warning" : void 0
|
|
10436
|
+
severity: noHue || keywordText ? "warning" : void 0
|
|
9500
10437
|
};
|
|
9501
10438
|
}
|
|
9502
10439
|
/**
|
|
@@ -9555,6 +10492,390 @@ function tokenColorPrefix(utility, colorKeys) {
|
|
|
9555
10492
|
if (!key || !(colorKeys.has(key) || KEYWORD_COLORS.has(key))) return void 0;
|
|
9556
10493
|
return utility.base.slice(0, -key.length - 1);
|
|
9557
10494
|
}
|
|
10495
|
+
/** `bg-white`, `border-black`: Tailwind's colors without a shade, as a color utility sets them. */
|
|
10496
|
+
const KEYWORD_CLASS = new RegExp(`^(${COLOR_PREFIXES.join("|")})-(${Object.keys(TAILWIND_KEYWORD_COLORS).join("|")})$`);
|
|
10497
|
+
/**
|
|
10498
|
+
* The white or black an opaque class sets (`bg-white`, `text-black`). With an opacity modifier
|
|
10499
|
+
* (`bg-black/50`, `border-white/10`) it is a tint of whatever is under it, a scrim or a hairline
|
|
10500
|
+
* that suits both modes, so it is left alone: with `tint`, only such a class is read. A theme
|
|
10501
|
+
* that defines `--color-white` (after `--color-*: initial`) makes `bg-white` one of its tokens.
|
|
10502
|
+
*/
|
|
10503
|
+
function keywordColor(utility, colorKeys, tint = false) {
|
|
10504
|
+
if (Boolean(utility.modifier) !== tint || utility.arbitrary !== void 0) return void 0;
|
|
10505
|
+
const [, prefix, name] = KEYWORD_CLASS.exec(utility.base) ?? [];
|
|
10506
|
+
if (!prefix || !name || colorKeys.has(name)) return void 0;
|
|
10507
|
+
const color = parseColor(TAILWIND_KEYWORD_COLORS[name] ?? "");
|
|
10508
|
+
return color ? {
|
|
10509
|
+
prefix,
|
|
10510
|
+
color
|
|
10511
|
+
} : void 0;
|
|
10512
|
+
}
|
|
10513
|
+
/** The node around `node`, or undefined at the file. */
|
|
10514
|
+
function parentOf(node) {
|
|
10515
|
+
return ts.isSourceFile(node) ? void 0 : node.parent;
|
|
10516
|
+
}
|
|
10517
|
+
/** Background classes that set no background of their own: what is under them shows through. */
|
|
10518
|
+
const NO_BACKGROUND = /^bg-(?:transparent|current|inherit|none)$/;
|
|
10519
|
+
/** Style properties that set an element's background, and values that set none. */
|
|
10520
|
+
const STYLE_BACKGROUNDS = /* @__PURE__ */ new Set([
|
|
10521
|
+
"background",
|
|
10522
|
+
"backgroundColor",
|
|
10523
|
+
"backgroundImage"
|
|
10524
|
+
]);
|
|
10525
|
+
const NO_STYLE_BACKGROUND = /^(?:transparent|none|inherit|initial|unset|revert|currentcolor)$/i;
|
|
10526
|
+
/** `var(--primary)`: a custom property, which can be a token's. */
|
|
10527
|
+
const CSS_VARIABLE = /^var\(\s*(--[\w-]+)\s*\)$/;
|
|
10528
|
+
/**
|
|
10529
|
+
* How far apart in lightness (OKLCH L) white or black text and its surface must be for the
|
|
10530
|
+
* surface to be the text's own: white on `bg-indigo-600` or `bg-orange-500` (0.705), not on
|
|
10531
|
+
* `bg-amber-400` or `bg-muted`; black on `bg-yellow-300`, not on `bg-indigo-600`. A token is
|
|
10532
|
+
* judged in each mode the text shows in: shadcn/ui's `primary` is near white in dark mode, so
|
|
10533
|
+
* white text on `bg-primary` is not its own there.
|
|
10534
|
+
*/
|
|
10535
|
+
const SURFACE_CONTRAST = .25;
|
|
10536
|
+
/** Whether text of this color stands out from a surface of this color. */
|
|
10537
|
+
function standsOut(surface, text) {
|
|
10538
|
+
return Math.abs(surface.l - text.l) >= SURFACE_CONTRAST;
|
|
10539
|
+
}
|
|
10540
|
+
/**
|
|
10541
|
+
* WCAG's least contrast for large text and the parts of a control. Lightness alone says too
|
|
10542
|
+
* little near black: `text-neutral-600` is 0.44 lighter than black and has 2.7:1 on it, and
|
|
10543
|
+
* `text-neutral-800` 1.6:1.
|
|
10544
|
+
*/
|
|
10545
|
+
const TEXT_CONTRAST = 3;
|
|
10546
|
+
/** How hardcoded text reads on a surface: far enough apart in lightness, and in WCAG contrast. */
|
|
10547
|
+
function legibility(surface, text) {
|
|
10548
|
+
return {
|
|
10549
|
+
apart: standsOut(surface, text),
|
|
10550
|
+
contrast: wcagContrast(surface, text) >= TEXT_CONTRAST
|
|
10551
|
+
};
|
|
10552
|
+
}
|
|
10553
|
+
/** The color a text, fill or stroke class hardcodes, or undefined when it sets none. */
|
|
10554
|
+
function paintOf(text) {
|
|
10555
|
+
if (colorRole(text.prefix) !== "text" || !text.color) return void 0;
|
|
10556
|
+
return {
|
|
10557
|
+
color: text.color,
|
|
10558
|
+
variants: parseUtility(text.value).variants,
|
|
10559
|
+
element: text.element
|
|
10560
|
+
};
|
|
10561
|
+
}
|
|
10562
|
+
/**
|
|
10563
|
+
* Where white or black text sits, which says whether the text is the surface's contrast color
|
|
10564
|
+
* (shadcn/ui's `bg-destructive text-white`) or the theme's: the background its own element sets,
|
|
10565
|
+
* in `style` or in its class list, its component's variants set (`<Button>` with `bg-*`
|
|
10566
|
+
* variants), or the nearest element around it that sets one. Any other hardcoded text is placed
|
|
10567
|
+
* the same way, to hold the fix of a background it would not stand out from.
|
|
10568
|
+
*/
|
|
10569
|
+
var Surfaces = class {
|
|
10570
|
+
context;
|
|
10571
|
+
lists;
|
|
10572
|
+
byNode = /* @__PURE__ */ new Map();
|
|
10573
|
+
tokenColors = /* @__PURE__ */ new Map();
|
|
10574
|
+
byVar = /* @__PURE__ */ new Map();
|
|
10575
|
+
/** Backgrounds an element sets in `style`: each value's color, by mode. */
|
|
10576
|
+
styled = /* @__PURE__ */ new Map();
|
|
10577
|
+
held = /* @__PURE__ */ new Set();
|
|
10578
|
+
placed = /* @__PURE__ */ new Map();
|
|
10579
|
+
constructor(context, lists) {
|
|
10580
|
+
this.context = context;
|
|
10581
|
+
this.lists = lists;
|
|
10582
|
+
for (const element of context.analysis.elements) this.byNode.set(element.node, element);
|
|
10583
|
+
for (const candidate of context.target.tokens.colors) {
|
|
10584
|
+
const { tailwind, cssVar } = candidate.token;
|
|
10585
|
+
if (tailwind) this.tokenColors.set(tailwind, candidate);
|
|
10586
|
+
if (cssVar) this.byVar.set(cssVar, candidate);
|
|
10587
|
+
}
|
|
10588
|
+
for (const { element, object } of context.analysis.styles) for (const prop of object.properties) {
|
|
10589
|
+
if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue;
|
|
10590
|
+
const name = propertyName(prop.name);
|
|
10591
|
+
if (name === void 0 || !STYLE_BACKGROUNDS.has(name)) continue;
|
|
10592
|
+
const values = ts.isPropertyAssignment(prop) ? valueBranches(prop.initializer) : [prop.name];
|
|
10593
|
+
const colors = this.styled.get(element.node) ?? [];
|
|
10594
|
+
for (const value of values) {
|
|
10595
|
+
const read = this.styleColor(value);
|
|
10596
|
+
if (read) colors.push(read);
|
|
10597
|
+
}
|
|
10598
|
+
if (colors.length) this.styled.set(element.node, colors);
|
|
10599
|
+
}
|
|
10600
|
+
}
|
|
10601
|
+
/**
|
|
10602
|
+
* Where the text sits, in each mode it shows in: a `dark:` class in dark mode only, another
|
|
10603
|
+
* one in its base mode and in dark mode, unless its class list sets its dark color itself
|
|
10604
|
+
* (`text-black dark:text-white`). It is the surface's own only if it stands out in each.
|
|
10605
|
+
*/
|
|
10606
|
+
place(list, text) {
|
|
10607
|
+
let placed = this.placed.get(text);
|
|
10608
|
+
if (!placed) {
|
|
10609
|
+
const paint = paintOf(text);
|
|
10610
|
+
placed = paint ? this.placeInModes(list, text, paint) : { contrasts: true };
|
|
10611
|
+
this.placed.set(text, placed);
|
|
10612
|
+
}
|
|
10613
|
+
return placed;
|
|
10614
|
+
}
|
|
10615
|
+
placeInModes(list, text, paint) {
|
|
10616
|
+
const { variants } = paint;
|
|
10617
|
+
const modes = variants.includes("dark") ? ["dark"] : hasDarkCounterpart(list, text) ? [void 0] : [void 0, "dark"];
|
|
10618
|
+
let under;
|
|
10619
|
+
for (const mode of modes) {
|
|
10620
|
+
const state = mode === "dark" && !variants.includes("dark") ? [...variants, "dark"] : variants;
|
|
10621
|
+
const placed = this.placeIn(list, paint, state, mode);
|
|
10622
|
+
if (!placed.contrasts) return placed;
|
|
10623
|
+
under ??= placed.under;
|
|
10624
|
+
}
|
|
10625
|
+
return {
|
|
10626
|
+
contrasts: true,
|
|
10627
|
+
under
|
|
10628
|
+
};
|
|
10629
|
+
}
|
|
10630
|
+
/**
|
|
10631
|
+
* Takes back the fix of a hardcoded background when hardcoded text on it stays as it is and may
|
|
10632
|
+
* no longer stand out: `bg-black text-white` or `bg-zinc-900 text-white` would become
|
|
10633
|
+
* `bg-primary text-white`, white on near white where `primary` is light (shadcn/ui's dark
|
|
10634
|
+
* mode), and `bg-white text-neutral-800` would become `bg-background text-neutral-800`, dark
|
|
10635
|
+
* gray on near black. The text is on the backgrounds of its own class list, in their states too
|
|
10636
|
+
* (`hover:bg-black`), and on the surface `place` finds for it.
|
|
10637
|
+
*/
|
|
10638
|
+
holdUnderText(text, list) {
|
|
10639
|
+
this.holdUnder(text, list, list);
|
|
10640
|
+
const { under } = this.place(list, text);
|
|
10641
|
+
if (under && under !== list) this.holdUnder(text, list, under);
|
|
10642
|
+
}
|
|
10643
|
+
/**
|
|
10644
|
+
* Takes back the fixes of backgrounds whose text color comes from around them, as text color is
|
|
10645
|
+
* inherited: in `<div className="text-neutral-800"><p className="bg-white">` the paragraph is
|
|
10646
|
+
* dark gray on white. An element in between that sets a text color, or a component, which can
|
|
10647
|
+
* set one of its own, ends it. An element with nothing in it (a skeleton's `<span />`) shows no
|
|
10648
|
+
* text.
|
|
10649
|
+
*/
|
|
10650
|
+
holdUnderInherited(stays) {
|
|
10651
|
+
for (const list of this.lists.values()) {
|
|
10652
|
+
const element = list[0]?.element;
|
|
10653
|
+
if (!element || !element.children.some((c) => !(ts.isJsxText(c) && c.containsOnlyTriviaWhiteSpaces)) || !list.some((listed) => listed.prefix === "bg" && listed.report?.fix)) continue;
|
|
10654
|
+
for (let node = element.node; node; node = parentOf(node)) {
|
|
10655
|
+
const around = this.byNode.get(node);
|
|
10656
|
+
if (!around) continue;
|
|
10657
|
+
if (this.context.resolve(around).kind !== "intrinsic") break;
|
|
10658
|
+
const aroundList = this.lists.get(around) ?? [];
|
|
10659
|
+
const texts = aroundList.filter((listed) => listed.prefix === "text" && !parseUtility(listed.value).variants.some(stylesAnother));
|
|
10660
|
+
if (!texts.length) continue;
|
|
10661
|
+
if (around !== element) {
|
|
10662
|
+
for (const text of texts) if (stays.has(text)) this.holdUnder(text, aroundList, list);
|
|
10663
|
+
}
|
|
10664
|
+
break;
|
|
10665
|
+
}
|
|
10666
|
+
}
|
|
10667
|
+
}
|
|
10668
|
+
/**
|
|
10669
|
+
* Takes back the fixes of the backgrounds in `under` that the text would not stand out from.
|
|
10670
|
+
* The text loses a token in a mode where it has less than it had on the hardcoded background:
|
|
10671
|
+
* 0.25 of lightness apart, or 3:1 contrast. A token without other modes may still have a dark
|
|
10672
|
+
* one onsystem does not read (`.dark:not(.light)`), so it counts as changing there. The fix
|
|
10673
|
+
* stays when the token keeps that in every mode the text shows in, or the class lists set the
|
|
10674
|
+
* dark-mode background or text (`dark:bg-zinc-900`, `dark:text-white`).
|
|
10675
|
+
*/
|
|
10676
|
+
holdUnder(text, list, under) {
|
|
10677
|
+
const paint = paintOf(text);
|
|
10678
|
+
if (!paint) return;
|
|
10679
|
+
const textIsDark = paint.variants.includes("dark");
|
|
10680
|
+
const textInDark = textIsDark || !hasDarkCounterpart(list, text);
|
|
10681
|
+
for (const surface of under) {
|
|
10682
|
+
const { report, color: surfaceColor } = surface;
|
|
10683
|
+
const fix = report?.fix?.[0];
|
|
10684
|
+
if (!report || !fix || !surfaceColor || surface.prefix !== "bg" || this.held.has(surface)) continue;
|
|
10685
|
+
const surfaceUtility = parseUtility(surface.value);
|
|
10686
|
+
if (surfaceUtility.variants.includes("dark")) continue;
|
|
10687
|
+
const before = legibility(surfaceColor, paint.color);
|
|
10688
|
+
if (!before.apart && !before.contrast) continue;
|
|
10689
|
+
const token = this.fixedTo(fix.text);
|
|
10690
|
+
if (!token) continue;
|
|
10691
|
+
const surfaceInDark = !hasDarkCounterpart(under, surface);
|
|
10692
|
+
const modes = Object.entries(token.modes ?? {});
|
|
10693
|
+
const known = modes.length > 0;
|
|
10694
|
+
if (!known) modes.push(["dark", void 0]);
|
|
10695
|
+
const lost = modes.filter(([mode, value]) => {
|
|
10696
|
+
if (!(mode === "dark" ? textInDark && surfaceInDark : !textIsDark)) return false;
|
|
10697
|
+
if (value === void 0) return true;
|
|
10698
|
+
const after = legibility(value, paint.color);
|
|
10699
|
+
return before.apart && !after.apart || before.contrast && !after.contrast;
|
|
10700
|
+
}).map(([mode]) => mode);
|
|
10701
|
+
if (!lost.length) continue;
|
|
10702
|
+
this.held.add(surface);
|
|
10703
|
+
const textUtility = parseUtility(text.value);
|
|
10704
|
+
const shade = KEYWORD_CLASS.exec(textUtility.base)?.[2] ?? "as it is";
|
|
10705
|
+
const name = token.token.name;
|
|
10706
|
+
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`}`;
|
|
10707
|
+
const variants = [...textUtility.variants];
|
|
10708
|
+
if (under === list) {
|
|
10709
|
+
for (const variant of surfaceUtility.variants) if (!variants.includes(variant)) variants.push(variant);
|
|
10710
|
+
}
|
|
10711
|
+
const pair = text.tinted ?? isTinted(paint.color) ? void 0 : this.foregroundOf(token, {
|
|
10712
|
+
...textUtility,
|
|
10713
|
+
variants
|
|
10714
|
+
}, text.prefix);
|
|
10715
|
+
report.message += ` \`${text.value}\` on it stays ${shade}, ${why}, so this is not applied automatically: change the text with it${pair ? `, to \`${pair}\`` : ""}.`;
|
|
10716
|
+
report.fix = void 0;
|
|
10717
|
+
}
|
|
10718
|
+
}
|
|
10719
|
+
/** The token a background's fix puts in: `bg-primary`, or `bg-[var(--chart-bg)]`. */
|
|
10720
|
+
fixedTo(fixed) {
|
|
10721
|
+
const utility = parseUtility(fixed);
|
|
10722
|
+
const variable = utility.arbitrary === void 0 ? void 0 : CSS_VARIABLE.exec(utility.arbitrary.trim())?.[1];
|
|
10723
|
+
if (variable) return this.byVar.get(variable);
|
|
10724
|
+
return this.tokenColors.get(utility.base.slice(3));
|
|
10725
|
+
}
|
|
10726
|
+
/** The text class of the token made for text on `surface`: `text-foreground` on `background`. */
|
|
10727
|
+
foregroundOf(surface, text, prefix) {
|
|
10728
|
+
const key = surface.token.tailwind;
|
|
10729
|
+
if (!key || !prefix) return void 0;
|
|
10730
|
+
const pair = key === "background" ? "foreground" : `${key}-foreground`;
|
|
10731
|
+
return this.tokenColors.has(pair) ? withBase(text, `${prefix}-${pair}`) : void 0;
|
|
10732
|
+
}
|
|
10733
|
+
/**
|
|
10734
|
+
* Where the text sits in one mode, and the class list that says so. Under a tint it does not
|
|
10735
|
+
* stand out from (`bg-white/10` under white text), what is under the tint decides; with nothing
|
|
10736
|
+
* under it in the file, the page's `background` in that mode does.
|
|
10737
|
+
*/
|
|
10738
|
+
placeIn(list, text, state, mode) {
|
|
10739
|
+
const { color, element } = text;
|
|
10740
|
+
/** Whether the text is under a tint it does not stand out from, which shows what is under it. */
|
|
10741
|
+
const seen = { through: false };
|
|
10742
|
+
/** What an element's `style`, then its class list, says: undefined to look further out. */
|
|
10743
|
+
const at = (node, classes, component) => {
|
|
10744
|
+
const styled = node ? this.styleVerdict(node, color, mode) : void 0;
|
|
10745
|
+
if (styled === "through") seen.through = true;
|
|
10746
|
+
if (styled !== void 0) return styled === "through" ? void 0 : { contrasts: styled };
|
|
10747
|
+
const says = this.verdict(classes, color, state, mode);
|
|
10748
|
+
if (says === "through") seen.through = true;
|
|
10749
|
+
else if (says !== void 0) return {
|
|
10750
|
+
contrasts: says,
|
|
10751
|
+
under: classes
|
|
10752
|
+
};
|
|
10753
|
+
if (component && variantProp(this.context, component, "bg")) return { contrasts: true };
|
|
10754
|
+
};
|
|
10755
|
+
const own = at(element?.node, list, element);
|
|
10756
|
+
if (own) return own;
|
|
10757
|
+
for (let node = element?.node.parent; node; node = parentOf(node)) {
|
|
10758
|
+
const around = this.byNode.get(node);
|
|
10759
|
+
if (!around) continue;
|
|
10760
|
+
const placed = at(node, this.lists.get(around) ?? [], around);
|
|
10761
|
+
if (placed) return placed;
|
|
10762
|
+
}
|
|
10763
|
+
if (!seen.through) return { contrasts: false };
|
|
10764
|
+
const page = this.tokenColors.get("background");
|
|
10765
|
+
return { contrasts: !page || standsOut(valueIn(page, mode), color) };
|
|
10766
|
+
}
|
|
10767
|
+
/**
|
|
10768
|
+
* What a class list's background says for the text, or undefined when it sets none. Any
|
|
10769
|
+
* background the text stands out from is its own: a list holds the classes of every branch
|
|
10770
|
+
* of a `cn()` object, which never apply together. Then a tint it does not stand out from,
|
|
10771
|
+
* whose surface is under it: in dark mode, `bg-neutral-50 dark:bg-white/10` is a tint over the
|
|
10772
|
+
* page.
|
|
10773
|
+
*/
|
|
10774
|
+
verdict(list, text, state, mode) {
|
|
10775
|
+
let verdict;
|
|
10776
|
+
for (const { value } of list) {
|
|
10777
|
+
const says = this.excuses(parseUtility(value), text, state, mode);
|
|
10778
|
+
if (says === true) return true;
|
|
10779
|
+
if (says === "through" || verdict === void 0) verdict = says ?? verdict;
|
|
10780
|
+
}
|
|
10781
|
+
return verdict;
|
|
10782
|
+
}
|
|
10783
|
+
/**
|
|
10784
|
+
* What a background set in `style` says for the text: an inline style is over the classes.
|
|
10785
|
+
* A color known only at run time (a swatch) or an image is a surface of its own.
|
|
10786
|
+
*/
|
|
10787
|
+
styleVerdict(node, text, mode) {
|
|
10788
|
+
const colors = this.styled.get(node);
|
|
10789
|
+
if (!colors) return void 0;
|
|
10790
|
+
let verdict = "through";
|
|
10791
|
+
for (const read of colors) {
|
|
10792
|
+
const color = read(mode);
|
|
10793
|
+
if (color === "unknown" || standsOut(color, text)) verdict = true;
|
|
10794
|
+
else if ((color.alpha ?? 1) >= 1) return false;
|
|
10795
|
+
}
|
|
10796
|
+
return verdict;
|
|
10797
|
+
}
|
|
10798
|
+
/** How a style value colors the background: undefined when it sets none. */
|
|
10799
|
+
styleColor(value) {
|
|
10800
|
+
if (value.kind === ts.SyntaxKind.NullKeyword) return void 0;
|
|
10801
|
+
if (ts.isIdentifier(value) && value.text === "undefined") return void 0;
|
|
10802
|
+
if (!ts.isStringLiteralLike(value)) return () => "unknown";
|
|
10803
|
+
const text = value.text.trim();
|
|
10804
|
+
if (!text || NO_STYLE_BACKGROUND.test(text)) return void 0;
|
|
10805
|
+
const variable = CSS_VARIABLE.exec(text)?.[1];
|
|
10806
|
+
const token = variable ? this.byVar.get(variable) : void 0;
|
|
10807
|
+
if (token) return (mode) => valueIn(token, mode);
|
|
10808
|
+
const color = parseColor(text);
|
|
10809
|
+
return () => color ?? "unknown";
|
|
10810
|
+
}
|
|
10811
|
+
/**
|
|
10812
|
+
* Whether a class sets a surface the text contrasts with: a gradient, an image, or a color far
|
|
10813
|
+
* enough from the text's in lightness. An opaque `bg-white` or `bg-black` is the page's own
|
|
10814
|
+
* color, as hardcoded as the text. A tint (`bg-black/60`) is a surface where it stands out;
|
|
10815
|
+
* `bg-white/10` under white text shows what is under it (`through`). Undefined for a class
|
|
10816
|
+
* that sets no background, or sets one only in a state the text is not in: `hover:bg-red-600`
|
|
10817
|
+
* is the surface of `hover:text-white`, not of `text-white`.
|
|
10818
|
+
*/
|
|
10819
|
+
excuses(utility, text, state, mode) {
|
|
10820
|
+
const { base, modifier } = utility;
|
|
10821
|
+
if (!utility.variants.every((variant) => state.includes(variant))) return void 0;
|
|
10822
|
+
if (/^(?:from|via)-/.test(base)) return true;
|
|
10823
|
+
if (!base.startsWith("bg-") || NO_BACKGROUND.test(base)) return void 0;
|
|
10824
|
+
if (keywordColor(utility, this.context.target.tokens.colorKeys)) return false;
|
|
10825
|
+
const color = this.backgroundColor(utility, mode);
|
|
10826
|
+
if (color === "unknown") return true;
|
|
10827
|
+
const contrasts = standsOut(color, text);
|
|
10828
|
+
return modifier && !contrasts ? "through" : contrasts;
|
|
10829
|
+
}
|
|
10830
|
+
/** The color a `bg-*` class sets in a mode: a token's, the palette's, or written in it. */
|
|
10831
|
+
backgroundColor(utility, mode) {
|
|
10832
|
+
const key = utility.base.slice(3);
|
|
10833
|
+
const token = this.tokenColors.get(key);
|
|
10834
|
+
if (token) return valueIn(token, mode);
|
|
10835
|
+
const palette = paletteColor(utility.base);
|
|
10836
|
+
if (palette) return parseColor(palette.value) ?? "unknown";
|
|
10837
|
+
const keyword = TAILWIND_KEYWORD_COLORS[key];
|
|
10838
|
+
if (keyword) return parseColor(keyword) ?? "unknown";
|
|
10839
|
+
if (utility.arbitrary === void 0) return "unknown";
|
|
10840
|
+
const value = utility.arbitrary.replace(/^color:/, "").trim();
|
|
10841
|
+
const shade = PALETTE_VARIABLE.exec(value)?.[1];
|
|
10842
|
+
const variable = shade ? void 0 : CSS_VARIABLE.exec(value)?.[1];
|
|
10843
|
+
const viaVariable = variable ? this.byVar.get(variable) : void 0;
|
|
10844
|
+
if (viaVariable) return valueIn(viaVariable, mode);
|
|
10845
|
+
const color = shade ? paletteColor(`bg-${shade}`)?.value : value;
|
|
10846
|
+
return color && parseColor(color) || "unknown";
|
|
10847
|
+
}
|
|
10848
|
+
};
|
|
10849
|
+
/**
|
|
10850
|
+
* Whether the class list sets the color of `listed` for dark mode too: `text-black dark:text-white`,
|
|
10851
|
+
* `bg-white dark:bg-zinc-900`. Only a class that adds `dark:` and nothing else to its variants,
|
|
10852
|
+
* whatever its color: `dark:bg-darkgray-50` names a color of a config onsystem may not read.
|
|
10853
|
+
*/
|
|
10854
|
+
function hasDarkCounterpart(list, listed) {
|
|
10855
|
+
const { prefix } = listed;
|
|
10856
|
+
if (prefix === void 0) return false;
|
|
10857
|
+
const { variants } = parseUtility(listed.value);
|
|
10858
|
+
if (variants.includes("dark")) return false;
|
|
10859
|
+
return list.some((other) => {
|
|
10860
|
+
if (other === listed) return false;
|
|
10861
|
+
const theirs = parseUtility(other.value);
|
|
10862
|
+
if (other.prefix !== prefix && !(other.prefix === void 0 && setsColor(theirs, prefix))) return false;
|
|
10863
|
+
return theirs.variants.includes("dark") && theirs.variants.every((variant) => variant === "dark" || variants.includes(variant)) && variants.every((variant) => theirs.variants.includes(variant));
|
|
10864
|
+
});
|
|
10865
|
+
}
|
|
10866
|
+
/** Utilities of a color prefix that set no color: `bg-cover`, `text-sm`, `stroke-2`. */
|
|
10867
|
+
const NOT_A_COLOR = {
|
|
10868
|
+
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\(.*)$/,
|
|
10869
|
+
text: /^text-(?:xs|sm|base|lg|\d?xl|left|center|right|justify|start|end|wrap|nowrap|balance|pretty|ellipsis|clip|\[\d.*)$/,
|
|
10870
|
+
fill: /^fill-none$/,
|
|
10871
|
+
stroke: /^stroke-(?:none|\d.*|\[\d.*)$/
|
|
10872
|
+
};
|
|
10873
|
+
/** Whether a class of no known color sets the color of `prefix`: `dark:bg-darkgray-50`. */
|
|
10874
|
+
function setsColor(utility, prefix) {
|
|
10875
|
+
if (!utility.base.startsWith(`${prefix}-`) || COLOR_UTILITY.exec(utility.base) === null) return false;
|
|
10876
|
+
if (utility.prefix !== void 0 && utility.prefix !== prefix) return false;
|
|
10877
|
+
return !NOT_A_COLOR[prefix]?.test(utility.base);
|
|
10878
|
+
}
|
|
9558
10879
|
/**
|
|
9559
10880
|
* Takes back a fix that would make a class under a condition (`hover:`, `focus-visible:`, `md:`)
|
|
9560
10881
|
* the same color as the class without it: `bg-[#2563eb] hover:bg-[#1d4ed8]` would become
|
|
@@ -9823,13 +11144,14 @@ function lengthRule(id, description, spec) {
|
|
|
9823
11144
|
...utility,
|
|
9824
11145
|
negative
|
|
9825
11146
|
}, replacement.cls);
|
|
9826
|
-
const
|
|
11147
|
+
const relative = !replacement.pill && FONT_RELATIVE.test(utility.arbitrary.trim());
|
|
11148
|
+
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." : ".");
|
|
9827
11149
|
context.report({
|
|
9828
11150
|
start: token.start,
|
|
9829
11151
|
end: token.end,
|
|
9830
11152
|
message,
|
|
9831
11153
|
suggestion: fixed,
|
|
9832
|
-
fix: replacement.far ? void 0 : [{
|
|
11154
|
+
fix: replacement.far || relative ? void 0 : [{
|
|
9833
11155
|
range: [token.start, token.end],
|
|
9834
11156
|
text: fixed
|
|
9835
11157
|
}]
|
|
@@ -9858,12 +11180,13 @@ function lengthRule(id, description, spec) {
|
|
|
9858
11180
|
const shown = number !== void 0 ? `${name}: ${raw}` : `${name}: "${raw}"`;
|
|
9859
11181
|
const size = replacement?.pill ? "fully rounded" : formatPx(replacement?.px ?? 0);
|
|
9860
11182
|
const advice = replacement ? replacement.tailwind ? `Use \`${cls}\` (${size}) in className instead of an inline style.` : `Use \`${cssVar ?? cls}\` (${size}).` : `Use ${spec.category} tokens instead.`;
|
|
11183
|
+
const relative = number === void 0 && !replacement?.pill && values.some((v) => FONT_RELATIVE.test(v));
|
|
9861
11184
|
context.report({
|
|
9862
11185
|
start: init.getStart(context.sourceFile),
|
|
9863
11186
|
end: init.end,
|
|
9864
|
-
message: `Hardcoded ${spec.category} \`${shown}\` in style. ${advice}`,
|
|
11187
|
+
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}`,
|
|
9865
11188
|
suggestion: replacement ? replacement.tailwind ? cls : cssVar : void 0,
|
|
9866
|
-
fix: replacement && !replacement.tailwind && cssVar && replacement.exact ? [{
|
|
11189
|
+
fix: replacement && !replacement.tailwind && cssVar && replacement.exact && !relative ? [{
|
|
9867
11190
|
range: [init.getStart(context.sourceFile), init.end],
|
|
9868
11191
|
text: `"${cssVar}"`
|
|
9869
11192
|
}] : void 0
|
|
@@ -9880,6 +11203,8 @@ function lengthRule(id, description, spec) {
|
|
|
9880
11203
|
function unnamed(replacement) {
|
|
9881
11204
|
return replacement.exact && !replacement.tailwind && !replacement.cssVar;
|
|
9882
11205
|
}
|
|
11206
|
+
/** A length in `em`, relative to the element's font size (`0.5em`, not `0.5rem`). */
|
|
11207
|
+
const FONT_RELATIVE = /^-?(?:\d+\.?\d*|\.\d+)em$/i;
|
|
9883
11208
|
/** The nearest step is "far" when it is off by more than half the value and more than 4px. */
|
|
9884
11209
|
const FAR_RATIO = .5;
|
|
9885
11210
|
const FAR_PX = 4;
|
|
@@ -10067,28 +11392,36 @@ function readBaseline(file) {
|
|
|
10067
11392
|
rules: parsed.rules ? new Set(parsed.rules) : void 0
|
|
10068
11393
|
};
|
|
10069
11394
|
}
|
|
11395
|
+
/** Whether `diagnostic` is on one of the `lines`, such as those a pull request changed. */
|
|
11396
|
+
function onLines(lines, diagnostic) {
|
|
11397
|
+
return lines?.some(([start, end]) => start <= diagnostic.endLine && end >= diagnostic.line) ?? false;
|
|
11398
|
+
}
|
|
10070
11399
|
/**
|
|
10071
11400
|
* Removes the findings the baseline accepts for `file`. Within a file and
|
|
10072
11401
|
* rule, each entry accepts that many findings with the same source text, in
|
|
10073
|
-
* order; any beyond the count are new.
|
|
10074
|
-
*
|
|
11402
|
+
* order; any beyond the count are new. With `changed`, the lines a pull request
|
|
11403
|
+
* changed, findings on other lines are accepted first: a copy of a baselined
|
|
11404
|
+
* line inserted above it is the new one, not the line that was there. Entries
|
|
11405
|
+
* of `disabled` rules are not counted as fixed: the rule is off, not satisfied.
|
|
10075
11406
|
*/
|
|
10076
|
-
function applyBaseline(baseline, file, result, disabled = /* @__PURE__ */ new Set()) {
|
|
11407
|
+
function applyBaseline(baseline, file, result, disabled = /* @__PURE__ */ new Set(), changed) {
|
|
10077
11408
|
const remaining = /* @__PURE__ */ new Map();
|
|
10078
11409
|
for (const [rule, sources] of baseline.get(file) ?? []) {
|
|
10079
11410
|
if (disabled.has(rule)) continue;
|
|
10080
11411
|
for (const [source, count] of sources) remaining.set(`${rule}\0${source}`, count);
|
|
10081
11412
|
}
|
|
10082
|
-
const
|
|
10083
|
-
|
|
10084
|
-
|
|
11413
|
+
const accepted = /* @__PURE__ */ new Set();
|
|
11414
|
+
for (const later of [false, true]) for (const diagnostic of result.diagnostics) {
|
|
11415
|
+
if (onLines(changed, diagnostic) !== later) continue;
|
|
10085
11416
|
const id = `${diagnostic.ruleId}\0${findingKey(diagnostic)}`;
|
|
10086
11417
|
const left = remaining.get(id) ?? 0;
|
|
10087
11418
|
if (left > 0) {
|
|
10088
11419
|
remaining.set(id, left - 1);
|
|
10089
|
-
|
|
10090
|
-
}
|
|
11420
|
+
accepted.add(diagnostic);
|
|
11421
|
+
}
|
|
10091
11422
|
}
|
|
11423
|
+
const diagnostics = result.diagnostics.filter((d) => !accepted.has(d));
|
|
11424
|
+
const baselined = accepted.size;
|
|
10092
11425
|
let fixed = 0;
|
|
10093
11426
|
for (const count of remaining.values()) fixed += count;
|
|
10094
11427
|
return {
|
|
@@ -10103,15 +11436,116 @@ function applyBaseline(baseline, file, result, disabled = /* @__PURE__ */ new Se
|
|
|
10103
11436
|
};
|
|
10104
11437
|
}
|
|
10105
11438
|
/**
|
|
11439
|
+
* Whether the key `file`, relative to the real `root`, still names a file by that spelling. On a
|
|
11440
|
+
* disk that ignores case (macOS, Windows), `Billing.tsx` still opens after a rename to
|
|
11441
|
+
* `billing.tsx`, but `check` keys the file by its new spelling, so the old key is gone, as on
|
|
11442
|
+
* Linux.
|
|
11443
|
+
*/
|
|
11444
|
+
function existsAs(root, file) {
|
|
11445
|
+
try {
|
|
11446
|
+
return relativePath(root, fs.realpathSync.native(path.resolve(root, file))).normalize("NFC") === file;
|
|
11447
|
+
} catch {
|
|
11448
|
+
return false;
|
|
11449
|
+
}
|
|
11450
|
+
}
|
|
11451
|
+
/**
|
|
11452
|
+
* The files with baseline entries that no longer exist under `root`, deleted or renamed, among
|
|
11453
|
+
* those `include` takes. `checked` are files a run read, which exist.
|
|
11454
|
+
*/
|
|
11455
|
+
function goneFiles(entries, root, checked, include = () => true) {
|
|
11456
|
+
return [...entries.keys()].filter((file) => !checked.has(file) && include(file) && !existsAs(root, file));
|
|
11457
|
+
}
|
|
11458
|
+
/**
|
|
11459
|
+
* Renamed and moved files (`git mv`, a folder reorganised): each file of `unrecorded` (file → its
|
|
11460
|
+
* findings), which has no entries of its own, paired with a file of `gone` whose entries match at
|
|
11461
|
+
* least half of its findings and half of those entries match its findings, as git counts a
|
|
11462
|
+
* rename from 50% similarity. Each file of `gone` pairs with one file at most, so a copy of a
|
|
11463
|
+
* file is new however many copies there are. Entries of rules `disabled` for the new file are
|
|
11464
|
+
* left out, as `applyBaseline` leaves them out. Returns new file → old file.
|
|
11465
|
+
*
|
|
11466
|
+
* Best match first: the file with most matching findings that are not `fresh`, then the one with
|
|
11467
|
+
* most matching findings, then the pair with fewest findings and entries the other does not
|
|
11468
|
+
* match. With `fresh`, the findings on the lines a pull request changed, a file the pull request
|
|
11469
|
+
* did not touch keeps the entries it took over in an earlier move, and a copy the pull request
|
|
11470
|
+
* adds is the new one, as `applyBaseline` spends entries on the other lines first.
|
|
11471
|
+
* Without it, as in a plain `check`, ties go to a file that kept the old one's name (a folder
|
|
11472
|
+
* moved), then to one in the old one's folder (a file renamed), then to path order.
|
|
11473
|
+
*/
|
|
11474
|
+
function findMoves(entries, unrecorded, gone, disabled = () => /* @__PURE__ */ new Set(), fresh = () => false) {
|
|
11475
|
+
const recorded = /* @__PURE__ */ new Map();
|
|
11476
|
+
for (const file of gone) for (const [rule, sources] of entries.get(file) ?? []) for (const [source, count] of sources) {
|
|
11477
|
+
const id = `${rule}\0${source}`;
|
|
11478
|
+
const list = recorded.get(id) ?? [];
|
|
11479
|
+
list.push({
|
|
11480
|
+
file,
|
|
11481
|
+
count
|
|
11482
|
+
});
|
|
11483
|
+
recorded.set(id, list);
|
|
11484
|
+
}
|
|
11485
|
+
const pairs = [];
|
|
11486
|
+
for (const [to, diagnostics] of unrecorded) {
|
|
11487
|
+
if (entries.has(to) || !diagnostics.length) continue;
|
|
11488
|
+
const found = /* @__PURE__ */ new Map();
|
|
11489
|
+
for (const diagnostic of diagnostics) {
|
|
11490
|
+
const id = `${diagnostic.ruleId}\0${findingKey(diagnostic)}`;
|
|
11491
|
+
const n = found.get(id) ?? {
|
|
11492
|
+
all: 0,
|
|
11493
|
+
old: 0
|
|
11494
|
+
};
|
|
11495
|
+
n.all += 1;
|
|
11496
|
+
if (!fresh(to, diagnostic)) n.old += 1;
|
|
11497
|
+
found.set(id, n);
|
|
11498
|
+
}
|
|
11499
|
+
const matched = /* @__PURE__ */ new Map();
|
|
11500
|
+
for (const [id, { all, old }] of found) for (const { file, count } of recorded.get(id) ?? []) {
|
|
11501
|
+
const m = matched.get(file) ?? {
|
|
11502
|
+
matched: 0,
|
|
11503
|
+
kept: 0
|
|
11504
|
+
};
|
|
11505
|
+
m.matched += Math.min(all, count);
|
|
11506
|
+
m.kept += Math.min(old, count);
|
|
11507
|
+
matched.set(file, m);
|
|
11508
|
+
}
|
|
11509
|
+
const off = disabled(to);
|
|
11510
|
+
for (const [from, { matched: n, kept }] of matched) {
|
|
11511
|
+
let size = 0;
|
|
11512
|
+
for (const [rule, sources] of entries.get(from) ?? []) {
|
|
11513
|
+
if (off.has(rule)) continue;
|
|
11514
|
+
for (const count of sources.values()) size += count;
|
|
11515
|
+
}
|
|
11516
|
+
const unmatched = diagnostics.length + size - 2 * n;
|
|
11517
|
+
if (n * 2 >= diagnostics.length && n * 2 >= size) pairs.push({
|
|
11518
|
+
to,
|
|
11519
|
+
from,
|
|
11520
|
+
matched: n,
|
|
11521
|
+
kept,
|
|
11522
|
+
unmatched
|
|
11523
|
+
});
|
|
11524
|
+
}
|
|
11525
|
+
}
|
|
11526
|
+
const sameName = (pair) => path.posix.basename(pair.to) === path.posix.basename(pair.from) ? 0 : 1;
|
|
11527
|
+
const sameFolder = (pair) => path.posix.dirname(pair.to) === path.posix.dirname(pair.from) ? 0 : 1;
|
|
11528
|
+
const order = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
11529
|
+
pairs.sort((a, b) => b.kept - a.kept || b.matched - a.matched || a.unmatched - b.unmatched || sameName(a) - sameName(b) || sameFolder(a) - sameFolder(b) || order(a.to, b.to) || order(a.from, b.from));
|
|
11530
|
+
const moves = /* @__PURE__ */ new Map();
|
|
11531
|
+
const taken = /* @__PURE__ */ new Set();
|
|
11532
|
+
for (const { to, from } of pairs) {
|
|
11533
|
+
if (moves.has(to) || taken.has(from)) continue;
|
|
11534
|
+
moves.set(to, from);
|
|
11535
|
+
taken.add(from);
|
|
11536
|
+
}
|
|
11537
|
+
return moves;
|
|
11538
|
+
}
|
|
11539
|
+
/**
|
|
10106
11540
|
* The baseline after a run over `checked` (file → its findings): those files
|
|
10107
11541
|
* get exactly their current findings, plus their previous entries for
|
|
10108
11542
|
* `disabled` rules; other files keep their entries while they still exist
|
|
10109
|
-
* under `root
|
|
11543
|
+
* under `root` by the same spelling.
|
|
10110
11544
|
*/
|
|
10111
11545
|
function updateBaseline(previous, checked, root, disabled = /* @__PURE__ */ new Set()) {
|
|
10112
11546
|
const disabledIn = typeof disabled === "function" ? disabled : () => disabled;
|
|
10113
11547
|
const next = /* @__PURE__ */ new Map();
|
|
10114
|
-
for (const [file, rules] of previous ?? []) if (!checked.has(file) &&
|
|
11548
|
+
for (const [file, rules] of previous ?? []) if (!checked.has(file) && existsAs(root, file)) next.set(file, rules);
|
|
10115
11549
|
for (const [file, diagnostics] of checked) {
|
|
10116
11550
|
const rules = /* @__PURE__ */ new Map();
|
|
10117
11551
|
const off = disabledIn(file);
|
|
@@ -10146,14 +11580,21 @@ function rulesRun(previous, next, checked, ran, wholeRoot) {
|
|
|
10146
11580
|
* The baseline without the entries a run over `checked` (file → its findings) shows no longer
|
|
10147
11581
|
* occur: a checked file keeps at most as many of each finding as it still has, plus its entries
|
|
10148
11582
|
* for `disabled` rules, and files the run did not check keep theirs while they still exist under
|
|
10149
|
-
* `root
|
|
11583
|
+
* `root` by the same spelling. Nothing is added, so a new finding stays new, unlike with
|
|
11584
|
+
* `updateBaseline`. A file of `moves` (new file → old file, from `findMoves`) takes the old
|
|
11585
|
+
* file's entries as its own.
|
|
10150
11586
|
*/
|
|
10151
|
-
function pruneBaseline(previous, checked, root, disabled) {
|
|
11587
|
+
function pruneBaseline(previous, checked, root, disabled, moves = /* @__PURE__ */ new Map()) {
|
|
11588
|
+
const files = new Map(previous);
|
|
11589
|
+
for (const [to, from] of moves) {
|
|
11590
|
+
const rules = previous.get(from);
|
|
11591
|
+
if (rules && !previous.has(to)) files.set(to, rules);
|
|
11592
|
+
}
|
|
10152
11593
|
const next = /* @__PURE__ */ new Map();
|
|
10153
|
-
for (const [file, rules] of
|
|
11594
|
+
for (const [file, rules] of files) {
|
|
10154
11595
|
const diagnostics = checked.get(file);
|
|
10155
11596
|
if (!diagnostics) {
|
|
10156
|
-
if (
|
|
11597
|
+
if (existsAs(root, file)) next.set(file, rules);
|
|
10157
11598
|
continue;
|
|
10158
11599
|
}
|
|
10159
11600
|
const found = /* @__PURE__ */ new Map();
|
|
@@ -10236,7 +11677,7 @@ function isObject(value) {
|
|
|
10236
11677
|
var name = "onsystem";
|
|
10237
11678
|
//#endregion
|
|
10238
11679
|
//#region src/version.ts
|
|
10239
|
-
const VERSION = "0.
|
|
11680
|
+
const VERSION = "0.9.0";
|
|
10240
11681
|
const NAME = name;
|
|
10241
11682
|
//#endregion
|
|
10242
11683
|
//#region src/lint/sarif.ts
|
|
@@ -10349,6 +11790,10 @@ function fingerprint$1(uri, identity, occurrence) {
|
|
|
10349
11790
|
}
|
|
10350
11791
|
//#endregion
|
|
10351
11792
|
//#region src/lint/format.ts
|
|
11793
|
+
/** From this many findings, `pretty` ends with a summary: more than a screen to scroll back through. */
|
|
11794
|
+
const SUMMARY_FROM = 50;
|
|
11795
|
+
/** The palette classes the summary lists. */
|
|
11796
|
+
const TOP_CLASSES = 10;
|
|
10352
11797
|
const color = (code) => (text, enabled) => enabled ? `\u001b[${code}m${text}\u001b[39m` : text;
|
|
10353
11798
|
const red = color(31);
|
|
10354
11799
|
const yellow = color(33);
|
|
@@ -10392,6 +11837,7 @@ function formatDiagnostics(results, format, options = {}) {
|
|
|
10392
11837
|
lines.push("");
|
|
10393
11838
|
}
|
|
10394
11839
|
const total = errors + warnings;
|
|
11840
|
+
if (total >= SUMMARY_FROM) lines.push(...summarize(results), "");
|
|
10395
11841
|
const notes = [options.baselined === void 0 ? "" : `${options.baselined.toLocaleString("en-US")} in the baseline`, ...options.notes ?? []].filter(Boolean);
|
|
10396
11842
|
const aside = notes.length ? ` (${notes.join("; ")})` : "";
|
|
10397
11843
|
const flagged = results.filter((r) => r.diagnostics.length).length;
|
|
@@ -10400,6 +11846,41 @@ function formatDiagnostics(results, format, options = {}) {
|
|
|
10400
11846
|
if (fixedHint) lines.push(dim(fixedHint, useColor));
|
|
10401
11847
|
return lines.join("\n");
|
|
10402
11848
|
}
|
|
11849
|
+
/**
|
|
11850
|
+
* What a long run's findings add up to: the count per rule, as `init`'s dry run gives it, and
|
|
11851
|
+
* the Tailwind palette classes found most, each with the token to use. On a palette-heavy app
|
|
11852
|
+
* (Dub's web app, over 14,000 lines of findings) that reads as a plan for moving to the tokens.
|
|
11853
|
+
*/
|
|
11854
|
+
function summarize(results) {
|
|
11855
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
11856
|
+
const pairs = /* @__PURE__ */ new Map();
|
|
11857
|
+
const paletteFiles = /* @__PURE__ */ new Set();
|
|
11858
|
+
let palette = 0;
|
|
11859
|
+
for (const result of results) for (const d of result.diagnostics) {
|
|
11860
|
+
byRule.set(d.ruleId, (byRule.get(d.ruleId) ?? 0) + 1);
|
|
11861
|
+
if (d.ruleId !== "no-hardcoded-color") continue;
|
|
11862
|
+
const utility = parseUtility(d.source);
|
|
11863
|
+
if (!paletteColor(utility.base)) continue;
|
|
11864
|
+
palette++;
|
|
11865
|
+
paletteFiles.add(result.file);
|
|
11866
|
+
const to = (d.fix?.length === 1 ? d.fix[0]?.text : void 0) ?? (d.suggestion && /^[^\s"'=]+$/.test(d.suggestion) ? withBase(utility, d.suggestion) : d.suggestion);
|
|
11867
|
+
const nearest = d.message.includes(". Nearest token ") ? " (nearest token)" : "";
|
|
11868
|
+
const text = `${d.source} → ${to ? `${to}${nearest}` : "no close token"}`;
|
|
11869
|
+
const pair = pairs.get(text) ?? {
|
|
11870
|
+
text,
|
|
11871
|
+
count: 0
|
|
11872
|
+
};
|
|
11873
|
+
pair.count++;
|
|
11874
|
+
pairs.set(text, pair);
|
|
11875
|
+
}
|
|
11876
|
+
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(", ")}.`];
|
|
11877
|
+
const top = [...pairs.values()].sort((a, b) => b.count - a.count || a.text.localeCompare(b.text)).slice(0, TOP_CLASSES);
|
|
11878
|
+
if (top.length) {
|
|
11879
|
+
const width = Math.max(...top.map((pair) => pair.count.toLocaleString("en-US").length));
|
|
11880
|
+
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}`));
|
|
11881
|
+
}
|
|
11882
|
+
return lines;
|
|
11883
|
+
}
|
|
10403
11884
|
/** Escapes a workflow command's message, so `%` and line breaks reach GitHub as written. */
|
|
10404
11885
|
function escapeData(value) {
|
|
10405
11886
|
return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
@@ -10880,6 +12361,89 @@ const WEIGHTS = {
|
|
|
10880
12361
|
props: 1,
|
|
10881
12362
|
examples: .5
|
|
10882
12363
|
};
|
|
12364
|
+
/**
|
|
12365
|
+
* What the components of shadcn/ui's registry are for, by the name its file exports them
|
|
12366
|
+
* under (`Badge` in `badge.tsx`). Its files have no JSDoc, so without these a stock shadcn/ui
|
|
12367
|
+
* app lists every component as undescribed and search finds them by name alone.
|
|
12368
|
+
*/
|
|
12369
|
+
const SHADCN_DESCRIPTIONS = {
|
|
12370
|
+
Accordion: "Stacked sections whose headings expand and collapse their content.",
|
|
12371
|
+
Alert: "A callout that draws attention to a message, with an optional title and icon.",
|
|
12372
|
+
AlertDialog: "A modal dialog that asks the user to confirm an action, such as a deletion, before it happens.",
|
|
12373
|
+
AspectRatio: "Keeps its content, such as an image or a video, at a fixed width-to-height ratio.",
|
|
12374
|
+
Avatar: "A user's picture, with a fallback such as initials while it loads or when it is missing.",
|
|
12375
|
+
Badge: "A small label for a status, a count or a category.",
|
|
12376
|
+
Breadcrumb: "The path to the current page, as a trail of links.",
|
|
12377
|
+
Button: "Triggers an action, such as submitting a form or opening a dialog.",
|
|
12378
|
+
ButtonGroup: "Joins related buttons into one bar.",
|
|
12379
|
+
Calendar: "A month grid to pick a date or a range of dates.",
|
|
12380
|
+
Card: "A bordered surface that groups related content, with a header, content and footer.",
|
|
12381
|
+
Carousel: "Shows a row of slides one at a time, with previous and next buttons.",
|
|
12382
|
+
Checkbox: "A box the user ticks to turn an option on or off.",
|
|
12383
|
+
Collapsible: "Shows and hides a panel of content from a trigger.",
|
|
12384
|
+
Combobox: "A text input with a list of options that narrows as the user types.",
|
|
12385
|
+
Command: "A searchable list of commands or options, as in a command palette.",
|
|
12386
|
+
ContextMenu: "A menu of actions that opens on right-click.",
|
|
12387
|
+
Dialog: "A modal window over the page, for a task or a message that needs the user first.",
|
|
12388
|
+
Drawer: "A panel that slides in from an edge of the screen, often from the bottom on phones.",
|
|
12389
|
+
DropdownMenu: "A menu of actions or options that opens from a button.",
|
|
12390
|
+
Empty: "An empty state: what to show when there is nothing yet, with an action to start.",
|
|
12391
|
+
Field: "A form field: its label, control, description and error message together.",
|
|
12392
|
+
HoverCard: "A card of preview content that opens while the pointer rests on a link.",
|
|
12393
|
+
Input: "A single-line text field.",
|
|
12394
|
+
InputGroup: "An input with icons, text or buttons inside its border.",
|
|
12395
|
+
InputOTP: "A row of one-character slots for a one-time code.",
|
|
12396
|
+
Item: "A row of content for lists: media, a title, a description and actions.",
|
|
12397
|
+
Kbd: "Shows a keyboard key or shortcut.",
|
|
12398
|
+
Label: "The text label of a form control.",
|
|
12399
|
+
Menubar: "A horizontal bar of menus, as in a desktop app.",
|
|
12400
|
+
NativeSelect: "A styled native <select> element.",
|
|
12401
|
+
NavigationMenu: "A site's main navigation links, with panels that open from them.",
|
|
12402
|
+
Pagination: "Links to move between pages of results, with previous and next.",
|
|
12403
|
+
Popover: "Rich content in a floating panel that opens from a button.",
|
|
12404
|
+
Progress: "A bar that shows how far a task has got.",
|
|
12405
|
+
RadioGroup: "A set of options of which the user picks exactly one.",
|
|
12406
|
+
ScrollArea: "A scrollable region with styled scrollbars.",
|
|
12407
|
+
Select: "Lets the user pick one value from a list that opens from a button.",
|
|
12408
|
+
Separator: "A thin line between content, horizontal or vertical.",
|
|
12409
|
+
Sheet: "A panel that slides in over the page from an edge of the screen, for secondary content.",
|
|
12410
|
+
Sidebar: "The app's side navigation: a collapsible panel of grouped menu items.",
|
|
12411
|
+
Skeleton: "A placeholder shape shown while content loads.",
|
|
12412
|
+
Slider: "Picks a number, or a range, by dragging along a track.",
|
|
12413
|
+
Spinner: "An animated indicator that something is loading.",
|
|
12414
|
+
Switch: "A toggle that turns a setting on or off at once.",
|
|
12415
|
+
Table: "Rows and columns of data.",
|
|
12416
|
+
Tabs: "Switches between panels of content, one visible at a time.",
|
|
12417
|
+
Textarea: "A multi-line text field.",
|
|
12418
|
+
Toggle: "A button that stays pressed or not, such as Bold in a toolbar.",
|
|
12419
|
+
ToggleGroup: "A set of toggle buttons, of which one or several can be on.",
|
|
12420
|
+
Tooltip: "A short hint that appears while the pointer rests on, or focus is in, an element."
|
|
12421
|
+
};
|
|
12422
|
+
/**
|
|
12423
|
+
* What shadcn/ui's component of this name is for, when `component` is laid out as `npx shadcn
|
|
12424
|
+
* add` writes it: exported under the registry's name from the file named for it, in a `ui`
|
|
12425
|
+
* folder (`Badge` from `components/ui/badge.tsx`, components.json's default `ui` alias). A
|
|
12426
|
+
* component of the same name elsewhere (`Sheet` in a design system's `src/sheet.tsx`) is not
|
|
12427
|
+
* shadcn/ui's, and may be something else entirely.
|
|
12428
|
+
*/
|
|
12429
|
+
function shadcnPurpose(component) {
|
|
12430
|
+
if (component.library || component.parent) return void 0;
|
|
12431
|
+
if (!Object.hasOwn(SHADCN_DESCRIPTIONS, component.name)) return void 0;
|
|
12432
|
+
const [folder, file = ""] = component.source.file.split(/[\\/]/).slice(-2);
|
|
12433
|
+
if (folder !== "ui") return void 0;
|
|
12434
|
+
const kebab = splitWords(component.name).join("-").toLowerCase();
|
|
12435
|
+
return file.replace(/\.[jt]sx?$/, "") === kebab ? SHADCN_DESCRIPTIONS[component.name] : void 0;
|
|
12436
|
+
}
|
|
12437
|
+
/**
|
|
12438
|
+
* The component's own description or, for a shadcn/ui component without one (`Badge` in
|
|
12439
|
+
* `components/ui/badge.tsx`, no JSDoc), what shadcn/ui's component of that name is for, said
|
|
12440
|
+
* as shadcn/ui's: `shadcn/ui's Badge: A small label for a status, a count or a category.`
|
|
12441
|
+
*/
|
|
12442
|
+
function descriptionOf(component) {
|
|
12443
|
+
if (component.description) return component.description;
|
|
12444
|
+
const purpose = shadcnPurpose(component);
|
|
12445
|
+
return purpose && `shadcn/ui's ${component.name}: ${purpose}`;
|
|
12446
|
+
}
|
|
10883
12447
|
function buildSearchIndex(components) {
|
|
10884
12448
|
const index = new Bm25Index();
|
|
10885
12449
|
for (const component of components) index.add(component, [
|
|
@@ -10888,7 +12452,7 @@ function buildSearchIndex(components) {
|
|
|
10888
12452
|
...component.aliases,
|
|
10889
12453
|
component.parent ?? ""
|
|
10890
12454
|
].join(" ")), WEIGHTS.name],
|
|
10891
|
-
[tokenize(component.description ?? ""), WEIGHTS.description],
|
|
12455
|
+
[tokenize(component.description ?? shadcnPurpose(component) ?? ""), WEIGHTS.description],
|
|
10892
12456
|
[tokenize(component.variants.flatMap((v) => [v.name, ...v.values]).join(" ")), WEIGHTS.variants],
|
|
10893
12457
|
[tokenize((component.docs?.sections ?? []).map((s) => `${s.heading} ${s.body}`).join(" ")), WEIGHTS.docs],
|
|
10894
12458
|
[tokenize(component.props.map((p) => `${p.name} ${p.description ?? ""}`).join(" ")), WEIGHTS.props],
|
|
@@ -10896,11 +12460,48 @@ function buildSearchIndex(components) {
|
|
|
10896
12460
|
]);
|
|
10897
12461
|
return index;
|
|
10898
12462
|
}
|
|
12463
|
+
/** The query's words, each with the terms that find it: `modal` is found by modal and dialog. */
|
|
12464
|
+
function queryWords(query) {
|
|
12465
|
+
const words = /* @__PURE__ */ new Map();
|
|
12466
|
+
for (const word of splitWords(query)) {
|
|
12467
|
+
const stems = tokenize(word);
|
|
12468
|
+
const key = stems.join(" ");
|
|
12469
|
+
if (stems.length && !words.has(key)) words.set(key, {
|
|
12470
|
+
stems,
|
|
12471
|
+
terms: [...expandQuery(word).keys()]
|
|
12472
|
+
});
|
|
12473
|
+
}
|
|
12474
|
+
return [...words.values()];
|
|
12475
|
+
}
|
|
12476
|
+
/**
|
|
12477
|
+
* Components for `query`, best first. A hit matches clearly when a word of the query is in
|
|
12478
|
+
* its name (`button` for Button) or when it has more than half of the query's words, each
|
|
12479
|
+
* itself or through a synonym (`modal` through `dialog`). The hits that do not are left out
|
|
12480
|
+
* when any does, and otherwise come back marked `weak`: "a dropdown menu to pick a country",
|
|
12481
|
+
* in a design system with no select, finds only a card's action slot that mentions a menu,
|
|
12482
|
+
* which is not an answer to give as the best match.
|
|
12483
|
+
*/
|
|
10899
12484
|
function searchComponents(index, query, limit = 5) {
|
|
10900
|
-
|
|
10901
|
-
|
|
10902
|
-
|
|
10903
|
-
|
|
12485
|
+
const words = queryWords(query);
|
|
12486
|
+
const hits = index.search(expandQuery(query), index.size).map((hit) => {
|
|
12487
|
+
const matched = new Set(hit.terms);
|
|
12488
|
+
const covered = words.filter((word) => word.terms.some((term) => matched.has(term))).length;
|
|
12489
|
+
const names = new Set(tokenize([hit.item.name, ...hit.item.aliases].join(" ")));
|
|
12490
|
+
const clear = covered * 2 > words.length || words.some((word) => word.stems.some((s) => names.has(s)));
|
|
12491
|
+
return {
|
|
12492
|
+
found: {
|
|
12493
|
+
component: hit.item,
|
|
12494
|
+
score: Math.round(hit.score * 100) / 100,
|
|
12495
|
+
matched: hit.terms
|
|
12496
|
+
},
|
|
12497
|
+
clear
|
|
12498
|
+
};
|
|
12499
|
+
});
|
|
12500
|
+
const clear = hits.filter((hit) => hit.clear);
|
|
12501
|
+
if (clear.length) return clear.slice(0, limit).map((hit) => hit.found);
|
|
12502
|
+
return hits.slice(0, limit).map((hit) => ({
|
|
12503
|
+
...hit.found,
|
|
12504
|
+
weak: true
|
|
10904
12505
|
}));
|
|
10905
12506
|
}
|
|
10906
12507
|
//#endregion
|
|
@@ -10974,18 +12575,46 @@ var DesignSystem = class {
|
|
|
10974
12575
|
searchIndex;
|
|
10975
12576
|
/**
|
|
10976
12577
|
* The components the tools offer: all of them, except a library's (MUI's) where the project
|
|
10977
|
-
* has its own of that name, a wrapper the team means to be used
|
|
10978
|
-
*
|
|
12578
|
+
* has its own of that name, a wrapper the team means to be used, and the helpers in a
|
|
12579
|
+
* registry folder's `lib/` (signoff-ui's `Img` and icons). The rules still check code that
|
|
12580
|
+
* imports either directly against them.
|
|
10979
12581
|
*/
|
|
10980
12582
|
offered;
|
|
12583
|
+
/** The design systems the rules rank, with the registry folders a config lists. */
|
|
12584
|
+
sources;
|
|
10981
12585
|
constructor(model, config) {
|
|
10982
12586
|
this.model = model;
|
|
10983
12587
|
this.config = config;
|
|
10984
|
-
this.
|
|
12588
|
+
this.sources = rankedSources(config);
|
|
12589
|
+
this.lint = new LintTarget(model, this.sources === config.designSystems ? config : {
|
|
12590
|
+
...config,
|
|
12591
|
+
designSystems: this.sources
|
|
12592
|
+
}, new ModuleResolver(config));
|
|
10985
12593
|
const own = new Set(model.components.filter((c) => !c.library).flatMap((c) => [c.name, ...c.aliases]));
|
|
10986
|
-
this.offered = model.components.filter((c) => !c.library || !(own.has(c.name) || c.parent !== void 0 && own.has(c.parent)));
|
|
12594
|
+
this.offered = model.components.filter((c) => (!c.library || !(own.has(c.name) || c.parent !== void 0 && own.has(c.parent))) && !this.registryHelper(c));
|
|
10987
12595
|
this.searchIndex = buildSearchIndex(this.offered);
|
|
10988
12596
|
}
|
|
12597
|
+
/**
|
|
12598
|
+
* The shadcn registry a component was copied from (`@signoff-ui`), when its folder is one
|
|
12599
|
+
* components.json names: a read-only design system, whose code is the registry's.
|
|
12600
|
+
*/
|
|
12601
|
+
registryOf(component) {
|
|
12602
|
+
if (component.library) return void 0;
|
|
12603
|
+
const file = component.source.file;
|
|
12604
|
+
for (const source of this.sources ?? []) {
|
|
12605
|
+
if (!source.registry) continue;
|
|
12606
|
+
const folder = source.components.find((g) => matchesGlob(file, g))?.replace(/\/\*\*\/\*\.\{tsx,jsx\}$/, "").replace(/\\(.)/g, "$1");
|
|
12607
|
+
if (folder !== void 0) return {
|
|
12608
|
+
registry: source.registry,
|
|
12609
|
+
folder
|
|
12610
|
+
};
|
|
12611
|
+
}
|
|
12612
|
+
}
|
|
12613
|
+
/** A component in a registry folder's `lib/`: an internal of its items, not one of them. */
|
|
12614
|
+
registryHelper(component) {
|
|
12615
|
+
const from = this.registryOf(component);
|
|
12616
|
+
return from !== void 0 && component.source.file.startsWith(`${from.folder}/lib/`);
|
|
12617
|
+
}
|
|
10989
12618
|
get root() {
|
|
10990
12619
|
return this.model.root;
|
|
10991
12620
|
}
|
|
@@ -11071,6 +12700,31 @@ var DesignSystem = class {
|
|
|
11071
12700
|
}
|
|
11072
12701
|
};
|
|
11073
12702
|
const CONFIGURATION_DOCS = "https://onsystem.vercel.app/docs/configuration";
|
|
12703
|
+
/**
|
|
12704
|
+
* The design systems the rules rank, first to last: zero config's, or, for a config that lists
|
|
12705
|
+
* the folders `npx shadcn add` copies the registries of components.json into (as `init` writes
|
|
12706
|
+
* them), its other components first and each such folder after them, read-only, as zero config
|
|
12707
|
+
* reads them.
|
|
12708
|
+
*/
|
|
12709
|
+
function rankedSources(config) {
|
|
12710
|
+
if (config.designSystems || !config.configured) return config.designSystems;
|
|
12711
|
+
const listed = registryFolders(config.root, config.tsconfig).filter((f) => config.components.some((g) => matchesGlob(`${f.dir}/index.tsx`, g)));
|
|
12712
|
+
if (!listed.length) return void 0;
|
|
12713
|
+
const inRegistry = (glob) => {
|
|
12714
|
+
const base = globBase(glob);
|
|
12715
|
+
return listed.some((f) => base === f.dir || base.startsWith(`${f.dir}/`));
|
|
12716
|
+
};
|
|
12717
|
+
return [{
|
|
12718
|
+
source: "components",
|
|
12719
|
+
components: config.components.filter((g) => !inRegistry(g)),
|
|
12720
|
+
imports: 0
|
|
12721
|
+
}, ...listed.map((f) => ({
|
|
12722
|
+
source: `${f.dir} (${f.namespace})`,
|
|
12723
|
+
components: [f.glob],
|
|
12724
|
+
imports: 0,
|
|
12725
|
+
registry: f.namespace
|
|
12726
|
+
}))];
|
|
12727
|
+
}
|
|
11074
12728
|
/** `border-ring` → token `ring`, for color utilities a token's usage list does not spell out. */
|
|
11075
12729
|
function matchUtility(base, tokens) {
|
|
11076
12730
|
const key = COLOR_UTILITY.exec(base)?.[1];
|
|
@@ -11245,7 +12899,7 @@ async function resolveFiles(config) {
|
|
|
11245
12899
|
* `@dub/tailwind-config` and its `themes.css`), with what they import. May repeat a file.
|
|
11246
12900
|
*/
|
|
11247
12901
|
function linkedStylesheets(config, found) {
|
|
11248
|
-
const resolve = stylesheetResolver(config.root);
|
|
12902
|
+
const resolve = stylesheetResolver(config.root, { importPath: config.importPath });
|
|
11249
12903
|
const preset = config.tailwindConfig ? presetStylesheets(readTailwindColors(config.tailwindConfig, presetResolver(config.root)), config.root) : [];
|
|
11250
12904
|
return [
|
|
11251
12905
|
...importedStylesheets(found.filter((f) => f.endsWith(".css")), resolve),
|
|
@@ -11585,4 +13239,4 @@ var DesignSystemHost = class {
|
|
|
11585
13239
|
}
|
|
11586
13240
|
};
|
|
11587
13241
|
//#endregion
|
|
11588
|
-
export {
|
|
13242
|
+
export { LEGACY_CONFIG_FILES as $, unrecordedRules as A, relativePath as At, statusKinds as B, countBaseline as C, closest as Ct, pruneBaseline as D, SCRIPT_FILE$1 as Dt, onLines as E, unique as Et, loadTokens as F, parseDoc as G, parseDesignMd as H, mergeTokens as I, ConfigError as J, extractComponents as K, toDesignMd as L, writeBaseline as M, defaultProjectConfig as Mt, RULES as N, readProjectConfig as Nt, readBaseline as O, isInside as Ot, TokenIndex as P, DEFAULT_TEST_FOLDERS as Q, toDtcg as R, applyBaseline as S, workspacePatterns as St, goneFiles as T, truncate as Tt, parseCssTokens as U, parseDtcgTokens as V, attachDocs as W, DEFAULT_SEVERITY as X, DEFAULT_DOCS as Y, DEFAULT_TEST_EXCLUDE as Z, formatDiagnostics as _, detectProject as _t, fingerprint as a, loadConfig as at, BASELINE_FILE as b, isWorkspaceRoot as bt, silentLogger as c, formatPx as ct, descriptionOf as d, LIBRARY_IDS as dt, RULE_IDS as et, searchComponents as f, libraryTitle as ft, escapeData as g, declaredConfig as gt, uncheckedNotice as h, PackageFinder as ht, componentFiles as i, hasOwnConfig as it, updateBaseline as j, toPosix as jt, rulesRun as k, matchesGlob as kt, stderrLogger as l, findVariantDefinitions as lt, checkSource as m, PACKAGE_FIELD as mt, DesignSystemHost as n, defineConfig as nt, loadDesignSystem as o, loadConfigWith as ot, applyFixes as p, notReadYet as pt, CONFIG_FILES as q, buildModel as r, excludedBy as rt, tokenFiles as s, rulesFor as st, DesignSystem as t, configJsonSchema as tt, buildSearchIndex as u, LIBRARIES as ut, NAME as v, findDesignSystemPackage as vt, findMoves as w, plural$1 as wt, LEGACY_BASELINE_FILE as x, workspacePackageDirs as xt, VERSION as y, isDesignSystemName as yt, TOKEN_CATEGORIES as z };
|