@pathscale/rsbuild-plugin-ui-css-purge 0.9.2 → 0.9.4

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.
@@ -1,223 +1,468 @@
1
1
  /**
2
- * Lib-side database generator.
2
+ * Lib-side purge database generator.
3
3
  *
4
- * Reads all `*.classes.ts` files from @pathscale/ui's component tree
5
- * and produces a `purge-manifest.json` that the consumer-side plugin uses
6
- * to build safelists.
4
+ * Reads all `*.classes.ts` files from @pathscale/ui's component tree and
5
+ * produces a versioned `purge-manifest.json` database consumed by the postbuild
6
+ * purge script.
7
7
  *
8
8
  * Usage: bun run src/generate-manifest.ts <path-to-ui-src/components>
9
9
  * Output: purge-manifest.json in cwd (or pass --out <path>)
10
10
  */
11
11
 
12
12
  import { Glob } from "bun";
13
- import path from "path";
13
+ import postcss from "postcss";
14
14
 
15
15
  // ── Types ──────────────────────────────────────────────────────────────────────
16
16
 
17
17
  type ClassValue = string | readonly string[];
18
18
 
19
- interface ComponentManifest {
20
- classes: {
21
- always: string[];
22
- byProp: Record<string, string[] | Record<string, string[]>>;
23
- };
24
- attrs?: Record<string, Record<string, string>>;
25
- deps?: string[];
19
+ interface ComponentPurgeRecord {
20
+ key: string;
21
+ component: string;
22
+ part?: string;
23
+ classes: {
24
+ always: string[];
25
+ byProp: Record<string, string[] | Record<string, string[]>>;
26
+ };
27
+ attrs?: Record<string, Record<string, string>>;
28
+ attributeSelectors: string[];
29
+ selectors: string[];
30
+ cssVars: {
31
+ declared: string[];
32
+ referenced: string[];
33
+ };
34
+ keyframes: {
35
+ declared: string[];
36
+ referenced: string[];
37
+ };
38
+ deps?: string[];
26
39
  }
27
40
 
28
- type PurgeManifest = Record<string, ComponentManifest>;
41
+ interface PurgeDatabaseV2 {
42
+ version: 2;
43
+ components: Record<string, ComponentPurgeRecord>;
44
+ shared: {
45
+ selectors: { selector: string; components: string[] }[];
46
+ cssVars: Record<string, { declaredBy: string[]; referencedBy: string[] }>;
47
+ keyframes: Record<string, { declaredBy: string[]; referencedBy: string[] }>;
48
+ };
49
+ }
50
+
51
+ interface ClassWalkResult {
52
+ classes: ComponentPurgeRecord["classes"];
53
+ attrs?: Record<string, Record<string, string>>;
54
+ }
55
+
56
+ interface CssFacts {
57
+ selectors: string[];
58
+ attributeSelectors: string[];
59
+ cssVars: {
60
+ declared: string[];
61
+ referenced: string[];
62
+ };
63
+ keyframes: {
64
+ declared: string[];
65
+ referenced: string[];
66
+ };
67
+ }
29
68
 
30
69
  // ── Tailwind utility filter ───────────────────────────────────────────────────
31
70
 
32
- /** Matches Tailwind utility class prefixes — these should NOT appear in the manifest. */
33
- const twPattern = /^(-?)(flex|grid|gap|items|justify|self|place|order|col|row|auto|basis|grow|shrink|space|overflow|relative|absolute|fixed|sticky|static|block|inline|hidden|visible|invisible|z|inset|top|right|bottom|left|float|clear|isolate|object|aspect|container|columns|break|box|display|table|caption|border|rounded|outline|ring|shadow|opacity|mix|bg|from|via|to|text|font|leading|tracking|indent|align|whitespace|word|hyphens|content|list|decoration|underline|overline|line|no-underline|uppercase|lowercase|capitalize|normal|italic|not-italic|antialiased|subpixel|truncate|w|h|min|max|p|px|py|pt|pr|pb|pl|m|mx|my|mt|mr|mb|ml|size|scroll|snap|touch|select|resize|cursor|caret|pointer|will|appearance|accent|transition|duration|delay|ease|animate|scale|rotate|translate|skew|transform|origin|filter|blur|brightness|contrast|drop|grayscale|hue|invert|saturate|sepia|backdrop|sr|forced|print|motion|lg|md|sm|xl|2xl|dark|hover|focus|active|disabled|first|last|odd|even|group|peer)($|[-:\[.])/;
71
+ /** Matches Tailwind utility class prefixes — these should NOT appear as owned component classes. */
72
+ const twPattern =
73
+ /^(-?)(flex|grid|gap|items|justify|self|place|order|col|row|auto|basis|grow|shrink|space|overflow|relative|absolute|fixed|sticky|static|block|inline|hidden|visible|invisible|z|inset|top|right|bottom|left|float|clear|isolate|object|aspect|container|columns|break|box|display|table|caption|border|rounded|outline|ring|shadow|opacity|mix|bg|from|via|to|text|font|leading|tracking|indent|align|whitespace|word|hyphens|content|list|decoration|underline|overline|line|no-underline|uppercase|lowercase|capitalize|normal|italic|not-italic|antialiased|subpixel|truncate|w|h|min|max|p|px|py|pt|pr|pb|pl|m|mx|my|mt|mr|mb|ml|size|scroll|snap|touch|select|resize|cursor|caret|pointer|will|appearance|accent|transition|duration|delay|ease|animate|scale|rotate|translate|skew|transform|origin|filter|blur|brightness|contrast|drop|grayscale|hue|invert|saturate|sepia|backdrop|sr|forced|print|motion|lg|md|sm|xl|2xl|dark|hover|focus|active|disabled|first|last|odd|even|group|peer)($|[-:\[.])/;
34
74
 
35
75
  function isTailwindUtility(cls: string): boolean {
36
- return twPattern.test(cls);
76
+ return twPattern.test(cls);
37
77
  }
38
78
 
39
79
  // ── Helpers ────────────────────────────────────────────────────────────────────
40
80
 
41
- /** Flatten a class value (string, string[], or nested object) into an array of individual class names, filtering out Tailwind utilities. */
42
- function flattenClasses(val: ClassValue): string[] {
43
- let classes: string[];
44
- if (typeof val === "string") {
45
- classes = val.split(/\s+/).filter(Boolean);
46
- } else if (Array.isArray(val)) {
47
- classes = val.flatMap((s) => typeof s === "string" ? s.split(/\s+/).filter(Boolean) : flattenClasses(s));
48
- } else if (val !== null && typeof val === "object") {
49
- classes = Object.values(val).flatMap((v) => flattenClasses(v as ClassValue));
50
- } else {
51
- return [];
52
- }
53
- return classes.filter((cls) => !isTailwindUtility(cls));
54
- }
55
-
56
- /** Check if a value is a plain object (not array, not null). */
81
+ /** Flatten a class value into individual non-Tailwind component class names. */
82
+ function flattenClasses(val: unknown): string[] {
83
+ let classes: string[];
84
+ if (typeof val === "string") {
85
+ classes = val.split(/\s+/).filter(Boolean);
86
+ } else if (Array.isArray(val)) {
87
+ classes = val.flatMap((s) => flattenClasses(s));
88
+ } else if (val !== null && typeof val === "object") {
89
+ classes = Object.values(val).flatMap((v) => flattenClasses(v));
90
+ } else {
91
+ return [];
92
+ }
93
+ return [...new Set(classes.filter((cls) => !isTailwindUtility(cls)))];
94
+ }
95
+
57
96
  function isRecord(v: unknown): v is Record<string, unknown> {
58
- return v !== null && typeof v === "object" && !Array.isArray(v);
97
+ return v !== null && typeof v === "object" && !Array.isArray(v);
59
98
  }
60
99
 
61
- /**
62
- * Walk a single CLASSES object (one component or one part of a compound component)
63
- * and produce the manifest entry.
64
- */
65
- function walkClassesObject(obj: Record<string, unknown>): ComponentManifest {
66
- const always: string[] = [];
67
- const byProp: Record<string, string[] | Record<string, string[]>> = {};
68
- let attrs: Record<string, Record<string, string>> | undefined;
69
-
70
- for (const [slot, value] of Object.entries(obj)) {
71
- if (slot === "base") {
72
- always.push(...flattenClasses(value as ClassValue));
73
- } else if (slot === "attrs") {
74
- if (isRecord(value)) {
75
- attrs = {};
76
- for (const [propName, attrMap] of Object.entries(value)) {
77
- if (isRecord(attrMap)) {
78
- attrs[propName] = attrMap as Record<string, string>;
79
- }
80
- }
81
- }
82
- } else if (slot === "flag") {
83
- if (isRecord(value)) {
84
- for (const [propName, classVal] of Object.entries(value)) {
85
- byProp[propName] = flattenClasses(classVal as ClassValue);
86
- }
87
- }
88
- } else if (isRecord(value)) {
89
- const enumMap: Record<string, string[]> = {};
90
- for (const [enumKey, classVal] of Object.entries(value)) {
91
- enumMap[enumKey] = flattenClasses(classVal as ClassValue);
92
- }
93
- byProp[slot] = enumMap;
94
- }
95
- }
96
-
97
- const manifest: ComponentManifest = { classes: { always, byProp } };
98
- if (attrs && Object.keys(attrs).length > 0) {
99
- manifest.attrs = attrs;
100
- }
101
- return manifest;
100
+ function kebabToPascal(s: string): string {
101
+ return s
102
+ .split("-")
103
+ .filter(Boolean)
104
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
105
+ .join("");
102
106
  }
103
107
 
104
- /**
105
- * Detect whether CLASSES is compound (top-level keys are part names like Root, Item)
106
- * or flat (top-level keys are slots like base, variant, flag).
107
- */
108
- const KNOWN_SLOTS = new Set(["base", "variant", "size", "flag", "color", "attrs"]);
108
+ function joinPath(base: string, rel: string): string {
109
+ return `${base.replace(/\/+$/, "")}/${rel.replace(/^\/+/, "")}`;
110
+ }
111
+
112
+ function dirname(filePath: string): string {
113
+ const parts = filePath.split(/[\\/]/);
114
+ parts.pop();
115
+ return parts.length === 0 ? "." : parts.join("/");
116
+ }
117
+
118
+ function basename(filePath: string, suffix = ""): string {
119
+ const name = filePath.split(/[\\/]/).pop() ?? filePath;
120
+ return suffix && name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
121
+ }
122
+
123
+ function resolvePath(filePath: string): string {
124
+ if (filePath.startsWith("/")) return filePath;
125
+ return joinPath(process.cwd(), filePath);
126
+ }
127
+
128
+ function canonicalComponentFromRelPath(relPath: string): string {
129
+ const stem = basename(relPath, ".classes.ts");
130
+ const dir = dirname(relPath);
131
+ const dirParts = dir === "." ? [] : dir.split(/[\\/]/).filter(Boolean);
132
+ const source =
133
+ stem === "classes" && dirParts.length > 0
134
+ ? (dirParts.at(-1) ?? stem)
135
+ : stem;
136
+ return kebabToPascal(source);
137
+ }
138
+
139
+ function walkClassesObject(obj: Record<string, unknown>): ClassWalkResult {
140
+ const always: string[] = [];
141
+ const byProp: Record<string, string[] | Record<string, string[]>> = {};
142
+ let attrs: Record<string, Record<string, string>> | undefined;
143
+
144
+ for (const [slot, value] of Object.entries(obj)) {
145
+ if (slot === "base") {
146
+ always.push(...flattenClasses(value));
147
+ } else if (slot === "attrs") {
148
+ if (isRecord(value)) {
149
+ attrs = {};
150
+ for (const [propName, attrMap] of Object.entries(value)) {
151
+ if (isRecord(attrMap))
152
+ attrs[propName] = attrMap as Record<string, string>;
153
+ }
154
+ }
155
+ } else if (slot === "flag") {
156
+ if (isRecord(value)) {
157
+ for (const [propName, classVal] of Object.entries(value)) {
158
+ byProp[propName] = flattenClasses(classVal);
159
+ }
160
+ }
161
+ } else if (isRecord(value)) {
162
+ const enumMap: Record<string, string[]> = {};
163
+ for (const [enumKey, classVal] of Object.entries(value)) {
164
+ enumMap[enumKey] = flattenClasses(classVal);
165
+ }
166
+ byProp[slot] = enumMap;
167
+ }
168
+ }
169
+
170
+ return { classes: { always: [...new Set(always)], byProp }, attrs };
171
+ }
172
+
173
+ const KNOWN_SLOTS = new Set([
174
+ "base",
175
+ "variant",
176
+ "size",
177
+ "flag",
178
+ "color",
179
+ "tone",
180
+ "attrs",
181
+ ]);
109
182
 
110
183
  function isCompound(obj: Record<string, unknown>): boolean {
111
- for (const key of Object.keys(obj)) {
112
- if (KNOWN_SLOTS.has(key)) return false;
113
- }
114
- return true;
184
+ for (const key of Object.keys(obj)) {
185
+ if (KNOWN_SLOTS.has(key)) return false;
186
+ }
187
+ return true;
188
+ }
189
+
190
+ function emptyCssFacts(): CssFacts {
191
+ return {
192
+ selectors: [],
193
+ attributeSelectors: [],
194
+ cssVars: { declared: [], referenced: [] },
195
+ keyframes: { declared: [], referenced: [] },
196
+ };
197
+ }
198
+
199
+ async function scanCssFacts(componentDir: string): Promise<CssFacts> {
200
+ const facts = emptyCssFacts();
201
+ const glob = new Glob("**/*.css");
202
+
203
+ for await (const relPath of glob.scan({ cwd: componentDir })) {
204
+ const css = await Bun.file(joinPath(componentDir, relPath)).text();
205
+ const root = postcss.parse(css);
206
+
207
+ root.walkRules((rule) => {
208
+ for (const selector of rule.selectors) {
209
+ facts.selectors.push(selector);
210
+ for (const attr of extractAttrsFromSelector(selector)) {
211
+ facts.attributeSelectors.push(attr);
212
+ }
213
+ }
214
+ });
215
+
216
+ root.walkDecls((decl) => {
217
+ if (decl.prop.startsWith("--")) facts.cssVars.declared.push(decl.prop);
218
+ for (const ref of decl.value.matchAll(/var\(\s*(--[a-zA-Z0-9_-]+)/g)) {
219
+ facts.cssVars.referenced.push(ref[1]);
220
+ }
221
+ if (/^animation(-name)?$/.test(decl.prop)) {
222
+ for (const part of decl.value.split(",")) {
223
+ const name = part.trim().split(/\s+/)[0];
224
+ if (name && !["none", "initial", "inherit", "unset"].includes(name)) {
225
+ facts.keyframes.referenced.push(name);
226
+ }
227
+ }
228
+ }
229
+ });
230
+
231
+ root.walkAtRules("keyframes", (atRule) => {
232
+ facts.keyframes.declared.push(atRule.params.trim());
233
+ });
234
+ }
235
+
236
+ return {
237
+ selectors: [...new Set(facts.selectors)].sort(),
238
+ attributeSelectors: [...new Set(facts.attributeSelectors)].sort(),
239
+ cssVars: {
240
+ declared: [...new Set(facts.cssVars.declared)].sort(),
241
+ referenced: [...new Set(facts.cssVars.referenced)].sort(),
242
+ },
243
+ keyframes: {
244
+ declared: [...new Set(facts.keyframes.declared)].sort(),
245
+ referenced: [...new Set(facts.keyframes.referenced)].sort(),
246
+ },
247
+ };
248
+ }
249
+
250
+ function extractAttrsFromSelector(selector: string): string[] {
251
+ const matches = selector.matchAll(
252
+ /\[(data-[a-zA-Z0-9_-]+|aria-[a-zA-Z0-9_-]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]+)))?\]/g,
253
+ );
254
+ return [...matches].map((m) => {
255
+ const value = m[2] ?? m[3] ?? m[4];
256
+ return value === undefined ? `[${m[1]}]` : `[${m[1]}="${value.trim()}"]`;
257
+ });
258
+ }
259
+
260
+ function createRecord(
261
+ key: string,
262
+ component: string,
263
+ part: string | undefined,
264
+ classFacts: ClassWalkResult,
265
+ cssFacts: CssFacts,
266
+ ): ComponentPurgeRecord {
267
+ const record: ComponentPurgeRecord = {
268
+ key,
269
+ component,
270
+ part,
271
+ classes: classFacts.classes,
272
+ attributeSelectors: cssFacts.attributeSelectors,
273
+ selectors: cssFacts.selectors,
274
+ cssVars: cssFacts.cssVars,
275
+ keyframes: cssFacts.keyframes,
276
+ };
277
+ if (classFacts.attrs && Object.keys(classFacts.attrs).length > 0) {
278
+ record.attrs = classFacts.attrs;
279
+ }
280
+ return record;
281
+ }
282
+
283
+ function updateShared(db: PurgeDatabaseV2) {
284
+ const selectorOwners = new Map<string, Set<string>>();
285
+
286
+ for (const [key, record] of Object.entries(db.components)) {
287
+ for (const selector of record.selectors) {
288
+ const owners = selectorOwners.get(selector) ?? new Set<string>();
289
+ owners.add(key);
290
+ selectorOwners.set(selector, owners);
291
+ }
292
+
293
+ for (const varName of record.cssVars.declared) {
294
+ const entry = db.shared.cssVars[varName] ?? {
295
+ declaredBy: [],
296
+ referencedBy: [],
297
+ };
298
+ entry.declaredBy.push(key);
299
+ db.shared.cssVars[varName] = entry;
300
+ }
301
+ for (const varName of record.cssVars.referenced) {
302
+ const entry = db.shared.cssVars[varName] ?? {
303
+ declaredBy: [],
304
+ referencedBy: [],
305
+ };
306
+ entry.referencedBy.push(key);
307
+ db.shared.cssVars[varName] = entry;
308
+ }
309
+
310
+ for (const name of record.keyframes.declared) {
311
+ const entry = db.shared.keyframes[name] ?? {
312
+ declaredBy: [],
313
+ referencedBy: [],
314
+ };
315
+ entry.declaredBy.push(key);
316
+ db.shared.keyframes[name] = entry;
317
+ }
318
+ for (const name of record.keyframes.referenced) {
319
+ const entry = db.shared.keyframes[name] ?? {
320
+ declaredBy: [],
321
+ referencedBy: [],
322
+ };
323
+ entry.referencedBy.push(key);
324
+ db.shared.keyframes[name] = entry;
325
+ }
326
+ }
327
+
328
+ db.shared.selectors = [...selectorOwners.entries()]
329
+ .map(([selector, owners]) => ({ selector, components: [...owners].sort() }))
330
+ .sort((a, b) => a.selector.localeCompare(b.selector));
331
+
332
+ for (const entry of Object.values(db.shared.cssVars)) {
333
+ entry.declaredBy = [...new Set(entry.declaredBy)].sort();
334
+ entry.referencedBy = [...new Set(entry.referencedBy)].sort();
335
+ }
336
+ for (const entry of Object.values(db.shared.keyframes)) {
337
+ entry.declaredBy = [...new Set(entry.declaredBy)].sort();
338
+ entry.referencedBy = [...new Set(entry.referencedBy)].sort();
339
+ }
115
340
  }
116
341
 
117
342
  // ── Main ───────────────────────────────────────────────────────────────────────
118
343
 
119
344
  async function main() {
120
- const args = process.argv.slice(2);
121
- let componentsDir = args[0];
122
- let outPath = "purge-manifest.json";
123
-
124
- const outIdx = args.indexOf("--out");
125
- if (outIdx !== -1 && args[outIdx + 1]) {
126
- outPath = args[outIdx + 1];
127
- }
128
-
129
- if (!componentsDir) {
130
- console.error("Usage: bun run src/generate-manifest.ts <path-to-components-dir> [--out <path>]");
131
- process.exit(1);
132
- }
133
-
134
- componentsDir = path.resolve(componentsDir);
135
- console.log(`Scanning ${componentsDir} for *.classes.ts files…`);
136
-
137
- const manifest: PurgeManifest = {};
138
- const glob = new Glob("**/*.classes.ts");
139
-
140
- for await (const relPath of glob.scan({ cwd: componentsDir })) {
141
- const fullPath = path.join(componentsDir, relPath);
142
-
143
- const mod = await import(fullPath);
144
- const CLASSES = mod.CLASSES;
145
-
146
- if (!CLASSES || typeof CLASSES !== "object") {
147
- console.warn(` SKIP ${relPath} — no CLASSES export found`);
148
- continue;
149
- }
150
-
151
- const fileName = path.basename(relPath, ".classes.ts");
152
-
153
- if (isCompound(CLASSES as Record<string, unknown>)) {
154
- for (const [partName, partObj] of Object.entries(CLASSES as Record<string, unknown>)) {
155
- if (isRecord(partObj)) {
156
- const entryName = `${fileName}.${partName}`;
157
- manifest[entryName] = walkClassesObject(partObj as Record<string, unknown>);
158
- console.log(` ✓ ${entryName}`);
159
- }
160
- }
161
- } else {
162
- manifest[fileName] = walkClassesObject(CLASSES as Record<string, unknown>);
163
- console.log(` ✓ ${fileName}`);
164
- }
165
- }
166
-
167
- // ── Scan inter-component dependencies ──────────────────────────────────────
168
- const { readdirSync } = await import("fs");
169
- const dirs = readdirSync(componentsDir, { withFileTypes: true })
170
- .filter((d: any) => d.isDirectory())
171
- .map((d: any) => d.name);
172
-
173
- function kebabToPascal(s: string): string {
174
- return s.split("-").map((p: string) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
175
- }
176
-
177
- const dirToPascal = new Map<string, string>();
178
- for (const dir of dirs) {
179
- dirToPascal.set(dir, kebabToPascal(dir));
180
- }
181
-
182
- const importRegex = /from\s+["']\.\.\/([^/"']+)/g;
183
- const depGlob = new Glob("**/*.{tsx,ts,js,mjs}");
184
-
185
- for (const dir of dirs) {
186
- const pascal = dirToPascal.get(dir)!;
187
- const componentDeps = new Set<string>();
188
- const scanDir = path.join(componentsDir, dir);
189
-
190
- for await (const relFile of depGlob.scan({ cwd: scanDir })) {
191
- const fullFile = path.join(scanDir, relFile);
192
- const code = await Bun.file(fullFile).text();
193
-
194
- for (const match of code.matchAll(importRegex)) {
195
- const depDir = match[1];
196
- if (depDir === "types" || depDir === "utils" || depDir === "..") continue;
197
- const depPascal = dirToPascal.get(depDir);
198
- if (depPascal && depPascal !== pascal) {
199
- componentDeps.add(depPascal);
200
- }
201
- }
202
- }
203
-
204
- if (componentDeps.size > 0) {
205
- const depsArray = [...componentDeps].sort();
206
- // Attach deps to the base component entry (or all compound parts)
207
- for (const key of Object.keys(manifest)) {
208
- if (key === pascal || key.startsWith(`${pascal}.`)) {
209
- manifest[key].deps = depsArray;
210
- }
211
- }
212
- console.log(` → ${pascal} deps: ${depsArray.join(", ")}`);
213
- }
214
- }
215
-
216
- const json = JSON.stringify(manifest, null, 2);
217
- await Bun.write(outPath, json);
218
- console.log(`\nWrote ${outPath} (${Object.keys(manifest).length} entries, ${json.length} bytes)`);
345
+ const args = process.argv.slice(2);
346
+ let componentsDir = args[0];
347
+ let outPath = "purge-manifest.json";
348
+
349
+ const outIdx = args.indexOf("--out");
350
+ if (outIdx !== -1 && args[outIdx + 1]) {
351
+ outPath = args[outIdx + 1];
352
+ }
353
+
354
+ if (!componentsDir) {
355
+ console.error(
356
+ "Usage: bun run src/generate-manifest.ts <path-to-components-dir> [--out <path>]",
357
+ );
358
+ process.exit(1);
359
+ }
360
+
361
+ componentsDir = resolvePath(componentsDir);
362
+ console.log(`Scanning ${componentsDir} for *.classes.ts files...`);
363
+
364
+ const db: PurgeDatabaseV2 = {
365
+ version: 2,
366
+ components: {},
367
+ shared: {
368
+ selectors: [],
369
+ cssVars: {},
370
+ keyframes: {},
371
+ },
372
+ };
373
+ const glob = new Glob("**/*.classes.ts");
374
+
375
+ for await (const relPath of glob.scan({ cwd: componentsDir })) {
376
+ const fullPath = joinPath(componentsDir, relPath);
377
+ const mod = await import(fullPath);
378
+ const classesExport = mod.CLASSES;
379
+
380
+ if (!classesExport || typeof classesExport !== "object") {
381
+ console.warn(` SKIP ${relPath} - no CLASSES export found`);
382
+ continue;
383
+ }
384
+
385
+ const component = canonicalComponentFromRelPath(relPath);
386
+ const componentDir = dirname(fullPath);
387
+ const cssFacts = await scanCssFacts(componentDir);
388
+
389
+ if (isCompound(classesExport as Record<string, unknown>)) {
390
+ for (const [partName, partObj] of Object.entries(
391
+ classesExport as Record<string, unknown>,
392
+ )) {
393
+ if (isRecord(partObj)) {
394
+ const key = `${component}.${partName}`;
395
+ db.components[key] = createRecord(
396
+ key,
397
+ component,
398
+ partName,
399
+ walkClassesObject(partObj),
400
+ cssFacts,
401
+ );
402
+ console.log(` ok ${key}`);
403
+ }
404
+ }
405
+ } else {
406
+ db.components[component] = createRecord(
407
+ component,
408
+ component,
409
+ undefined,
410
+ walkClassesObject(classesExport as Record<string, unknown>),
411
+ cssFacts,
412
+ );
413
+ console.log(` ok ${component}`);
414
+ }
415
+ }
416
+
417
+ await attachDependencies(componentsDir, db);
418
+ updateShared(db);
419
+
420
+ const json = JSON.stringify(db, null, 2);
421
+ await Bun.write(outPath, json);
422
+ console.log(
423
+ `\nWrote ${outPath} (${Object.keys(db.components).length} records, ${json.length} bytes)`,
424
+ );
425
+ }
426
+
427
+ async function attachDependencies(componentsDir: string, db: PurgeDatabaseV2) {
428
+ const dirs = new Set<string>();
429
+ const classesGlob = new Glob("*/*.classes.ts");
430
+ for await (const relPath of classesGlob.scan({ cwd: componentsDir })) {
431
+ dirs.add(relPath.split(/[\\/]/)[0]);
432
+ }
433
+ const dirToPascal = new Map(
434
+ [...dirs].map((dir) => [dir, kebabToPascal(dir)]),
435
+ );
436
+ const importRegex = /from\s+["']\.\.\/([^/"']+)/g;
437
+ const depGlob = new Glob("**/*.{tsx,ts,js,mjs}");
438
+
439
+ for (const dir of dirs) {
440
+ const component = dirToPascal.get(dir);
441
+ if (!component) continue;
442
+ const componentDeps = new Set<string>();
443
+ const scanDir = joinPath(componentsDir, dir);
444
+
445
+ for await (const relFile of depGlob.scan({ cwd: scanDir })) {
446
+ const code = await Bun.file(joinPath(scanDir, relFile)).text();
447
+ for (const match of code.matchAll(importRegex)) {
448
+ const depDir = match[1];
449
+ if (depDir === "types" || depDir === "utils" || depDir === "..")
450
+ continue;
451
+ const dep = dirToPascal.get(depDir);
452
+ if (dep && dep !== component) componentDeps.add(dep);
453
+ }
454
+ }
455
+
456
+ if (componentDeps.size > 0) {
457
+ const deps = [...componentDeps].sort();
458
+ for (const record of Object.values(db.components)) {
459
+ if (record.component === component) record.deps = deps;
460
+ }
461
+ console.log(` deps ${component}: ${deps.join(", ")}`);
462
+ }
463
+ }
219
464
  }
220
465
 
221
466
  if (import.meta.main) {
222
- main();
467
+ main();
223
468
  }