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.
@@ -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, readProjectConfig(root, tsconfig), packages, (r) => {
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 kind = own?.installed ? "installed" : "workspace";
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 ? own.dir : packages.find(name);
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: an installed package is compiled, and only sources are read");
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 step(candidate, false, result ? "its components match no file" : unique(reasons).join("; ") || "nothing to read");
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
- return detection(root, found, withDefaults, only?.source ?? "");
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
- function fromPackage(root, dir, pkg, note = ignore) {
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
- for (const mapping of exportMappings(root, dir, pkg)) {
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 = exportMappings(root, dir, pkg).filter((m) => /(?:^|\/)(?:dist|build|lib|out|esm|cjs)\//.test(m.target));
916
- note(pkg.exports === void 0 && typeof pkg.main !== "string" ? "no exports" : built.length ? `its exports point at build output with no source found (${built.slice(0, 2).map((m) => m.target).join(", ")})` : `its exports lead to ${plural(files.length, "component file")}; a design system exports at least 3`);
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
- * without an `exports` map, `main` stands for `.`.
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
- /** The source file an export condition map points at: the first string target that is not a declaration file. */
1071
- function exportTarget(value) {
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). Installed packages (`tailwindcss`) and URLs resolve to nothing.
1161
- */
1162
- function stylesheetResolver(root) {
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 stylesheet = (file) => [file, `${file}.css`].find((f) => f.endsWith(".css") && !/[\\/]node_modules[\\/]/.test(f) && isFile(f));
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 dir = name ? packages.find(name) : void 0;
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 exportedFile(dir, subpath ? `./${subpath}` : ".", (f) => f.endsWith(".css")) ?? (subpath ? stylesheet(path.join(dir, subpath)) : stylesheet(path.resolve(dir, typeof style === "string" ? style : "index.css")));
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
- for (const nodeModules of this.nodeModulesDirs()) {
1393
- const dir = path.join(nodeModules, ...name.split("/"));
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
- /** A library's name for messages, read or not: `MUI`, `Chakra UI`. */
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 ?? name;
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
- function detectLibraries(root, ids) {
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 ignore when finding components and when `check` expands folders and globs (stories, tests). Replaces the defaults; node_modules is skipped either way unless a pattern names it."),
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 libraries = detectLibraries(root, config.libraries);
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 exclude = (config.exclude ?? DEFAULT_EXCLUDE).map(slashGlob).map(excludeFromBase).filter((e) => e !== void 0);
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: (options.components?.length ? options.components : configComponents !== void 0 ? configComponents.map(fromBase) : detection ? [...detection.withDefaults ? DEFAULT_COMPONENTS : [], ...detection.components] : DEFAULT_COMPONENTS).map(slashGlob),
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 the
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 entry = resolve(adapter.package) ?? path.join(real, typesEntry(real));
4231
- if (!fs.existsSync(entry) || !/\.d\.[cm]?ts$/.test(entry)) return empty(`${adapter.package}: no type declarations at ${relativePath(root, entry)}`);
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: [entry, ...options.augmentations],
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 entryFile = program.getSourceFile(entry);
4238
- const moduleSymbol = entryFile ? checker.getSymbolAtLocation(entryFile) : void 0;
4239
- if (!moduleSymbol) return empty(`${adapter.package}: ${entry} has no exports`);
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 exported of checker.getExportsOfModule(moduleSymbol)) {
4254
- const name = exported.getName();
4255
- const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported;
4256
- if (!(symbol.flags & ts.SymbolFlags.Value)) continue;
4257
- exportNames.add(name);
4258
- if (!/^[A-Z]/.test(name)) continue;
4259
- const known = bySymbol.get(symbol);
4260
- if (known) {
4261
- known.aliases.push(name);
4262
- continue;
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
- /** Call and construct signatures that take props, across a union (`React.ComponentType`). */
4298
- function propSignatures(type) {
4299
- return (type.isUnion() ? type.types : [type]).flatMap((member) => [...member.getCallSignatures(), ...member.getConstructSignatures()]).filter((signature) => signature.getParameters().length >= 1);
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 element = renderedElement(checker, used.at(-1));
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 behind a signature, from the type of the `ref` its props take. */
4531
- function renderedElement(checker, signature) {
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 ref = checker.getPropertyOfType(checker.getTypeOfSymbol(param), "ref");
4535
- if (!ref) return void 0;
4536
- const text = checker.typeToString(checker.getTypeOfSymbol(ref));
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`) become its parts, so lists show the component and its parts under it.
4557
- */
4558
- function linkSlots(components) {
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) byFile.set(component.source.file, [...byFile.get(component.source.file) ?? [], component]);
4561
- for (const group of byFile.values()) for (const component of group) {
4562
- const owner = group.filter((other) => other !== component && component.name.length > other.name.length && component.name.startsWith(other.name)).sort((a, b) => b.name.length - a.name.length)[0];
4563
- if (!owner) continue;
4564
- component.parent = owner.name;
4565
- owner.subcomponents.push(component.name);
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") collectStrings(init, element);
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 collectStrings(arg);
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 STATUS_WORDS = /* @__PURE__ */ new Set([
6279
- "destructive",
6280
- "danger",
6281
- "error",
6282
- "critical",
6283
- "negative",
6284
- "invalid",
6285
- "warning",
6286
- "warn",
6287
- "caution",
6288
- "success",
6289
- "positive",
6290
- "valid",
6291
- "info",
6292
- "information",
6293
- "notice"
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") || isHidden(element)) continue;
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
- if (allow.has(token.value.toLowerCase())) continue;
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 || allow.has(literal.text.toLowerCase())) continue;
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 || allow.has(literal.text.toLowerCase())) continue;
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
- for (const list of lists.values()) {
9386
- keepStatesApart(list);
9387
- for (const { report } of list) if (report) context.report(report);
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 message = 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." : ".");
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. Entries of `disabled` rules are not
10074
- * counted as fixed: the rule is off, not satisfied.
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 diagnostics = [];
10083
- let baselined = 0;
10084
- for (const diagnostic of result.diagnostics) {
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
- baselined++;
10090
- } else diagnostics.push(diagnostic);
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) && fs.existsSync(path.resolve(root, file))) next.set(file, rules);
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`. Nothing is added, so a new finding stays new, unlike with `updateBaseline`.
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 previous) {
11594
+ for (const [file, rules] of files) {
10154
11595
  const diagnostics = checked.get(file);
10155
11596
  if (!diagnostics) {
10156
- if (fs.existsSync(path.resolve(root, file))) next.set(file, rules);
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.7.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
- return index.search(expandQuery(query), limit).map((hit) => ({
10901
- component: hit.item,
10902
- score: Math.round(hit.score * 100) / 100,
10903
- matched: hit.terms
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. The rules still check code
10978
- * that imports the library's directly against the library's.
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.lint = new LintTarget(model, config, new ModuleResolver(config));
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 { loadConfig as $, TokenIndex as A, parseDoc as B, pruneBaseline as C, relativePath as Ct, updateBaseline as D, unrecordedRules as E, TOKEN_CATEGORIES as F, DEFAULT_SEVERITY as G, CONFIG_FILES as H, parseDtcgTokens as I, LEGACY_CONFIG_FILES as J, DEFAULT_TEST_EXCLUDE as K, parseDesignMd as L, mergeTokens as M, toDesignMd as N, writeBaseline as O, toDtcg as P, hasOwnConfig as Q, parseCssTokens as R, countBaseline as S, matchesGlob as St, rulesRun as T, defaultProjectConfig as Tt, ConfigError as U, extractComponents as V, DEFAULT_DOCS as W, configJsonSchema as X, RULE_IDS as Y, defineConfig as Z, NAME as _, closest as _t, fingerprint as a, LIBRARY_IDS as at, LEGACY_BASELINE_FILE as b, unique as bt, silentLogger as c, PACKAGE_FIELD as ct, searchComponents as d, detectProject as dt, loadConfigWith as et, applyFixes as f, findDesignSystemPackage as ft, formatDiagnostics as g, workspacePatterns as gt, escapeData as h, workspacePackageDirs as ht, componentFiles as i, LIBRARIES as it, loadTokens as j, RULES as k, stderrLogger as l, PackageFinder as lt, uncheckedNotice as m, isWorkspaceRoot as mt, DesignSystemHost as n, formatPx as nt, loadDesignSystem as o, libraryTitle as ot, checkSource as p, isDesignSystemName as pt, DEFAULT_TEST_FOLDERS as q, buildModel as r, findVariantDefinitions as rt, tokenFiles as s, notReadYet as st, DesignSystem as t, rulesFor as tt, buildSearchIndex as u, declaredConfig as ut, VERSION as v, plural$1 as vt, readBaseline as w, toPosix as wt, applyBaseline as x, isInside as xt, BASELINE_FILE as y, truncate as yt, attachDocs as z };
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 };