@pathscale/rsbuild-plugin-ui-css-purge 0.9.5 → 0.9.6

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.
@@ -60,5 +60,5 @@ declare function extractUIImports(ast: unknown): Map<string, string>;
60
60
  declare function extractJSXUsages(ast: unknown, uiComponents: Map<string, string>): PropUsage[];
61
61
  declare function buildSafelists(allUsages: PropUsage[], manifest: PurgeManifest): Safelists;
62
62
  declare function scanConsumerSource(srcDir: string): Promise<PropUsage[]>;
63
- export { extractUIImports, extractJSXUsages, buildSafelists, scanConsumerSource, };
64
- export type { LegacyComponentManifest, PropUsage, PurgeManifest, PurgeDatabaseV2, ComponentPurgeRecord, LegacyComponentManifest as ComponentManifest, Safelists, };
63
+ export type { ComponentPurgeRecord, LegacyComponentManifest as ComponentManifest, LegacyComponentManifest, PropUsage, PurgeDatabaseV2, PurgeManifest, Safelists, };
64
+ export { buildSafelists, extractJSXUsages, extractUIImports, scanConsumerSource, };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pathscale/rsbuild-plugin-ui-css-purge",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -14,20 +14,24 @@
14
14
  "rsbuild-plugin-ui-css-purge": "dist/postbuild-purge.js",
15
15
  "generate-manifest": "src/generate-manifest.ts"
16
16
  },
17
- "files": ["dist", "src/generate-manifest.ts"],
18
- "dependencies": {
19
- "@swc/core": "^1.15.24",
20
- "lightningcss": "^1.32.0",
21
- "postcss": "^8.5.9"
22
- },
17
+ "files": [
18
+ "dist",
19
+ "src/generate-manifest.ts"
20
+ ],
23
21
  "scripts": {
24
22
  "build": "bun run build.ts",
25
- "test": "bun test",
23
+ "format": "biome format . --write",
26
24
  "lint": "biome check .",
27
- "format": "biome format . --write"
25
+ "test": "bun test"
26
+ },
27
+ "dependencies": {
28
+ "@swc/core": "^1.15.40",
29
+ "lightningcss": "^1.32.0",
30
+ "postcss": "^8.5.15"
28
31
  },
29
32
  "devDependencies": {
30
- "@biomejs/biome": "1.9.4",
31
- "bun-types": "^1.3.12"
33
+ "@biomejs/biome": "2.4.16",
34
+ "@types/bun": "^1.3.14",
35
+ "typescript": "^6.0.3"
32
36
  }
33
37
  }
@@ -10,12 +10,21 @@
10
10
  */
11
11
 
12
12
  import { Glob } from "bun";
13
- import postcss from "postcss";
13
+ import {
14
+ type AnimationName,
15
+ type AttrSelectorOperator,
16
+ type Combinator,
17
+ type Declaration,
18
+ type Selector,
19
+ type SelectorComponent,
20
+ transform,
21
+ type UnparsedProperty,
22
+ type Visitor,
23
+ type WebKitScrollbarPseudoElement,
24
+ } from "lightningcss";
14
25
 
15
26
  // ── Types ──────────────────────────────────────────────────────────────────────
16
27
 
17
- type ClassValue = string | readonly string[];
18
-
19
28
  interface ComponentPurgeRecord {
20
29
  key: string;
21
30
  component: string;
@@ -70,8 +79,9 @@ interface CssFacts {
70
79
 
71
80
  /** Matches Tailwind utility class prefixes — these should NOT appear as owned component classes. */
72
81
  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)($|[-:\[.])/;
82
+ /^(-?)(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)($|[-:[.])/;
74
83
 
84
+ // TODO: possibly find a more reliable way of checking
75
85
  function isTailwindUtility(cls: string): boolean {
76
86
  return twPattern.test(cls);
77
87
  }
@@ -196,40 +206,84 @@ function emptyCssFacts(): CssFacts {
196
206
  };
197
207
  }
198
208
 
209
+ const AnimationNameKeywords = new Set(["none", "initial", "inherit", "unset"]);
199
210
  async function scanCssFacts(componentDir: string): Promise<CssFacts> {
200
211
  const facts = emptyCssFacts();
201
212
  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
+ const visitor: Visitor<never> = {
214
+ Rule(rule) {
215
+ if (rule.type === "keyframes") {
216
+ const { name } = rule.value;
217
+ facts.keyframes.declared.push(name.value);
213
218
  }
214
- });
219
+ },
215
220
 
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);
221
+ Declaration(decl) {
222
+ // "animation" with var(), unparsed due to var() interfering with arg position detection
223
+ const animationNamesUnparsed = (p: UnparsedProperty): AnimationName[] => {
224
+ let valid = true;
225
+ const names: AnimationName[] = [];
226
+ if (p.propertyId.property !== "animation") return names;
227
+ for (const { value: token } of p.value) {
228
+ if (!(typeof token === "object" && "type" in token)) continue;
229
+ if (token.type === "comma") valid = true; // name only appears after comma or at start
230
+ if (token.type === "comma" || token.type === "white-space") continue;
231
+ if (valid && (token.type === "ident" || token.type === "string")) {
232
+ names.push(token);
226
233
  }
234
+ valid = false;
235
+ }
236
+ return names;
237
+ };
238
+
239
+ const animationNames = (decl: Declaration): AnimationName[] => {
240
+ switch (decl.property) {
241
+ case "animation":
242
+ return decl.value.map((item) => item.name);
243
+ case "animation-name":
244
+ return decl.value;
245
+ case "unparsed":
246
+ return animationNamesUnparsed(decl.value);
247
+ default:
248
+ return [];
227
249
  }
250
+ };
251
+
252
+ for (const name of animationNames(decl)) {
253
+ if (name.type === "none") continue;
254
+ if (AnimationNameKeywords.has(name.value)) continue;
255
+ facts.keyframes.referenced.push(name.value);
228
256
  }
229
- });
230
257
 
231
- root.walkAtRules("keyframes", (atRule) => {
232
- facts.keyframes.declared.push(atRule.params.trim());
258
+ // variable declarations
259
+ (() => {
260
+ if (decl.property !== "custom") return;
261
+ if (!decl.value.name.startsWith("--")) return;
262
+ facts.cssVars.declared.push(decl.value.name);
263
+ })();
264
+ },
265
+
266
+ // var() statements
267
+ Variable(variable) {
268
+ facts.cssVars.referenced.push(variable.name.ident);
269
+ },
270
+
271
+ Selector(selector) {
272
+ facts.selectors.push(stringifySelector(selector));
273
+ const attrs = extractSelectorAttrs(selector).map(stringifySelectorAttr);
274
+ facts.attributeSelectors.push(...attrs);
275
+ },
276
+ };
277
+
278
+ for await (const relPath of glob.scan({ cwd: componentDir })) {
279
+ const css = await Bun.file(joinPath(componentDir, relPath)).text();
280
+ transform({
281
+ filename: relPath,
282
+ code: Buffer.from(css),
283
+ minify: false,
284
+ sourceMap: false,
285
+ errorRecovery: true,
286
+ visitor,
233
287
  });
234
288
  }
235
289
 
@@ -247,14 +301,162 @@ async function scanCssFacts(componentDir: string): Promise<CssFacts> {
247
301
  };
248
302
  }
249
303
 
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
- });
304
+ type SelectorAttribute = Extract<SelectorComponent, { type: "attribute" }>;
305
+ function extractSelectorAttrs(selector: Selector): SelectorAttribute[] {
306
+ const attrs: SelectorAttribute[] = [];
307
+ const pseudo = (c: SelectorComponent) =>
308
+ c.type === "pseudo-class" || c.type === "pseudo-element";
309
+ const extract = (sel: Selector) => {
310
+ const prefixes = ["data-", "aria-"];
311
+ for (const comp of sel) {
312
+ if (comp.type !== "attribute") continue;
313
+ if (prefixes.every((p) => !comp.name.startsWith(p))) continue;
314
+ attrs.push(comp);
315
+ }
316
+ };
317
+
318
+ // From top level
319
+ extract(selector);
320
+ // From pseudo classes/elements
321
+ const ps = selector.filter(pseudo);
322
+ for (const p of ps) for (const s of extractSelectors(p)) extract(s);
323
+
324
+ return attrs;
325
+ }
326
+
327
+ type PseudoClass = Extract<SelectorComponent, { type: "pseudo-class" }>;
328
+ type PseudoElement = Extract<SelectorComponent, { type: "pseudo-element" }>;
329
+ type Pseudo = PseudoClass | PseudoElement;
330
+ function extractSelectors(ps: Pseudo): Selector[] {
331
+ if (ps.kind === "host") return ps.selectors ? [ps.selectors] : [];
332
+ if ("selectors" in ps) return ps.selectors;
333
+ if ("selector" in ps) return [ps.selector];
334
+ return [];
335
+ }
336
+
337
+ function extractStrings(ps: Pseudo): string[] {
338
+ if (Array.isArray(ps.type)) return ps.type;
339
+ if ("languages" in ps) return ps.languages;
340
+ if ("direction" in ps) return [ps.direction];
341
+ if ("state" in ps) return [ps.state];
342
+ if ("names" in ps) return ps.names;
343
+ if ("identifier" in ps) return [ps.identifier];
344
+ return [];
345
+ }
346
+
347
+ const OperatorMap: Record<AttrSelectorOperator, string> = {
348
+ equal: "=",
349
+ includes: "~=",
350
+ "dash-match": "|=",
351
+ prefix: "^=",
352
+ suffix: "$=",
353
+ substring: "*=",
354
+ } as const;
355
+
356
+ const CombinatorMap: Record<Combinator, string> = {
357
+ child: " > ",
358
+ descendant: " ",
359
+ "next-sibling": " + ",
360
+ "later-sibling": " ~ ",
361
+ deep: " /deep/ ",
362
+ "deep-descendant": " >>> ",
363
+ "pseudo-element": "::",
364
+ part: "::part",
365
+ "slot-assignment": "::slotted",
366
+ } as const;
367
+
368
+ const WSElementMap: Record<WebKitScrollbarPseudoElement, string> = {
369
+ scrollbar: "::-webkit-scrollbar",
370
+ button: "::-webkit-scrollbar-button",
371
+ track: "::-webkit-scrollbar-track",
372
+ "track-piece": "::-webkit-scrollbar-track-piece",
373
+ thumb: "::-webkit-scrollbar-thumb",
374
+ corner: "::-webkit-scrollbar-corner",
375
+ resizer: "::-webkit-resizer",
376
+ } as const;
377
+
378
+ const ShortElements: Set<PseudoElement["kind"]> = new Set([
379
+ "before",
380
+ "after",
381
+ "first-letter",
382
+ "first-line",
383
+ ]);
384
+
385
+ // TODO: use ToCSS from napi-rs module | use transform with dummy rule | use pure Rust | do not rely on stringified selectors and extract what we need
386
+ function stringifySelector(selector: Selector): string {
387
+ const selectors = (p: Pseudo) =>
388
+ extractSelectors(p).map(stringifySelector).concat(extractStrings(p));
389
+
390
+ return selector
391
+ .map((comp) => {
392
+ switch (comp.type) {
393
+ case "universal":
394
+ return "*";
395
+ case "nesting":
396
+ return "&";
397
+ case "type":
398
+ return comp.name;
399
+ case "id":
400
+ return `#${comp.name}`;
401
+ case "class":
402
+ return `.${comp.name}`;
403
+ case "combinator":
404
+ return CombinatorMap[comp.value];
405
+ case "attribute":
406
+ return stringifySelectorAttr(comp);
407
+ case "namespace":
408
+ if (comp.kind === "named") return `${comp.prefix}|`;
409
+ if (comp.kind === "any") return "*|";
410
+ return "|";
411
+ case "pseudo-class": {
412
+ const s = selectors(comp);
413
+ if (comp.kind === "custom") return `:${comp.name}`;
414
+ if (comp.kind === "webkit-scrollbar") return `:${comp.value}`;
415
+ if ("a" in comp && "b" in comp) return stringifyNth(comp);
416
+ if (s.length === 0) return `:${comp.kind}`;
417
+ return `:${comp.kind}(${s.join(", ")})`;
418
+ }
419
+ case "pseudo-element": {
420
+ const s = selectors(comp);
421
+ const p = ShortElements.has(comp.kind) ? ":" : "::";
422
+ if (comp.kind === "custom") return `${p}${comp.name}`;
423
+ if (comp.kind === "webkit-scrollbar") return WSElementMap[comp.value];
424
+ if (comp.kind === "part") return `${p}${comp.kind}(${s.join(" ")})`;
425
+ if (s.length === 0) return `${p}${comp.kind}`;
426
+ return `${p}${comp.kind}(${s.join(", ")})`;
427
+ }
428
+ default:
429
+ return "";
430
+ }
431
+ })
432
+ .join("");
433
+ }
434
+
435
+ function stringifySelectorAttr(attr: SelectorAttribute): string {
436
+ const { name, operation } = attr;
437
+ if (!operation) return `[${name}]`;
438
+ const { operator, value } = operation;
439
+ return `[${name}${OperatorMap[operator]}"${value}"]`;
440
+ }
441
+
442
+ type Nth = Extract<SelectorComponent, { a: number; b: number }>;
443
+ function stringifyNth(nth: Nth): string {
444
+ const signed = (n: number) => `${n >= 0 ? "+" : ""}${n}`;
445
+ const anb = (a: number, b: number): string => {
446
+ if (a === 0 && b === 0) return "0";
447
+ if (a === 1 && b === 0) return "n";
448
+ if (a === -1 && b === 0) return "-n";
449
+ if (b === 0) return `${a}n`;
450
+ if (a === 2 && b === 1) return "odd";
451
+ if (a === 0) return b.toString();
452
+ if (a === 1) return `n${signed(b)}`;
453
+ if (a === -1) return `-n${signed(b)}`;
454
+ return `${a}n${signed(b)}`;
455
+ };
456
+ let rule = anb(nth.a, nth.b);
457
+ const s = (("of" in nth && nth.of) || []).map(stringifySelector);
458
+ if (s.length > 0) rule += ` of ${s.join(", ")}`;
459
+ return `:${nth.kind}(${rule})`;
258
460
  }
259
461
 
260
462
  function createRecord(