@pathscale/rsbuild-plugin-ui-css-purge 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -0
- package/dist/generate-manifest.d.ts +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +255 -0
- package/dist/postbuild-purge.d.ts +18 -0
- package/dist/postbuild-purge.js +1448 -0
- package/dist/scan-consumer.d.ts +37 -0
- package/package.json +31 -0
- package/src/generate-manifest.ts +223 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consumer-side JSX scanner.
|
|
3
|
+
*
|
|
4
|
+
* Walks a consumer's source tree, finds component imports from @pathscale/ui,
|
|
5
|
+
* collects prop values, and cross-references with the purge manifest to build
|
|
6
|
+
* Level 1 (class) and Level 2 (attribute) safelists.
|
|
7
|
+
*
|
|
8
|
+
* Usage: bun run src/scan-consumer.ts <consumer-src-dir> <purge-manifest.json>
|
|
9
|
+
*/
|
|
10
|
+
interface ComponentManifest {
|
|
11
|
+
classes: {
|
|
12
|
+
always: string[];
|
|
13
|
+
byProp: Record<string, string[] | Record<string, string[]>>;
|
|
14
|
+
};
|
|
15
|
+
attrs?: Record<string, Record<string, string>>;
|
|
16
|
+
deps?: string[];
|
|
17
|
+
}
|
|
18
|
+
type PurgeManifest = Record<string, ComponentManifest>;
|
|
19
|
+
/** What we collect per component usage from JSX */
|
|
20
|
+
interface PropUsage {
|
|
21
|
+
component: string;
|
|
22
|
+
props: Map<string, string | "DYNAMIC">;
|
|
23
|
+
booleanProps: Set<string>;
|
|
24
|
+
hasSpread: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** Extract @pathscale/ui imports from a parsed module */
|
|
27
|
+
declare function extractUIImports(ast: any): Map<string, string>;
|
|
28
|
+
/** Extract JSX usages of UI components */
|
|
29
|
+
declare function extractJSXUsages(ast: any, uiComponents: Map<string, string>): PropUsage[];
|
|
30
|
+
interface Safelists {
|
|
31
|
+
classSafelist: Set<string>;
|
|
32
|
+
attrSafelist: Set<string>;
|
|
33
|
+
}
|
|
34
|
+
declare function buildSafelists(allUsages: PropUsage[], manifest: PurgeManifest): Safelists;
|
|
35
|
+
declare function scanConsumerSource(srcDir: string): Promise<PropUsage[]>;
|
|
36
|
+
export { extractUIImports, extractJSXUsages, buildSafelists, scanConsumerSource };
|
|
37
|
+
export type { PropUsage, PurgeManifest, ComponentManifest, Safelists };
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pathscale/rsbuild-plugin-ui-css-purge",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"rsbuild-plugin-ui-css-purge": "dist/postbuild-purge.js",
|
|
15
|
+
"generate-manifest": "src/generate-manifest.ts"
|
|
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
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "bun run build.ts",
|
|
25
|
+
"lint": "biome check .",
|
|
26
|
+
"format": "biome format . --write"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"bun-types": "^1.3.12"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lib-side database generator.
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* Usage: bun run src/generate-manifest.ts <path-to-ui-src/components>
|
|
9
|
+
* Output: purge-manifest.json in cwd (or pass --out <path>)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Glob } from "bun";
|
|
13
|
+
import path from "path";
|
|
14
|
+
|
|
15
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
type ClassValue = string | readonly string[];
|
|
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[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type PurgeManifest = Record<string, ComponentManifest>;
|
|
29
|
+
|
|
30
|
+
// ── Tailwind utility filter ───────────────────────────────────────────────────
|
|
31
|
+
|
|
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)($|[-:\[.])/;
|
|
34
|
+
|
|
35
|
+
function isTailwindUtility(cls: string): boolean {
|
|
36
|
+
return twPattern.test(cls);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
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). */
|
|
57
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
58
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
59
|
+
}
|
|
60
|
+
|
|
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;
|
|
102
|
+
}
|
|
103
|
+
|
|
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"]);
|
|
109
|
+
|
|
110
|
+
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;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Main ───────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
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)`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (import.meta.main) {
|
|
222
|
+
main();
|
|
223
|
+
}
|