@tailwind-styled/compiler 2.0.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/package.json +37 -0
- package/src/astParser.ts +397 -0
- package/src/astTransform.ts +359 -0
- package/src/atomicCss.ts +275 -0
- package/src/classExtractor.ts +69 -0
- package/src/classMerger.ts +45 -0
- package/src/componentGenerator.ts +91 -0
- package/src/componentHoister.ts +183 -0
- package/src/deadStyleEliminator.ts +398 -0
- package/src/incrementalEngine.ts +786 -0
- package/src/index.ts +100 -0
- package/src/loadTailwindConfig.ts +144 -0
- package/src/routeCssCollector.ts +188 -0
- package/src/rscAnalyzer.ts +240 -0
- package/src/safelistGenerator.ts +116 -0
- package/src/staticVariantCompiler.ts +240 -0
- package/src/styleBucketSystem.ts +498 -0
- package/src/styleRegistry.ts +462 -0
- package/src/tailwindEngine.ts +285 -0
- package/src/twDetector.ts +54 -0
- package/src/variantCompiler.ts +87 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-styled-v4 — Dead Style Eliminator
|
|
3
|
+
*
|
|
4
|
+
* Build-time analysis yang scan component usage dan hapus variant + class
|
|
5
|
+
* yang tidak pernah dipakai. Hasilnya: CSS output yang sangat kecil.
|
|
6
|
+
*
|
|
7
|
+
* Pipeline:
|
|
8
|
+
* scan all .tsx/.ts files
|
|
9
|
+
* ↓ extract component usage (JSX props)
|
|
10
|
+
* ↓ compare dengan registered variants
|
|
11
|
+
* ↓ mark unused variants as dead
|
|
12
|
+
* ↓ remove from CSS output
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const Button = tw.button({
|
|
16
|
+
* base: "px-4 py-2",
|
|
17
|
+
* variants: {
|
|
18
|
+
* size: { sm: "text-sm", lg: "text-lg", xl: "text-xl" }, // xl never used!
|
|
19
|
+
* intent: { primary: "bg-blue-500", danger: "bg-red-500" }
|
|
20
|
+
* }
|
|
21
|
+
* })
|
|
22
|
+
*
|
|
23
|
+
* // In codebase: only <Button size="sm"> and <Button size="lg"> appear
|
|
24
|
+
* // Eliminator removes: size.xl → saves CSS
|
|
25
|
+
*
|
|
26
|
+
* Result:
|
|
27
|
+
* Before: 3 size variants in CSS
|
|
28
|
+
* After: 2 size variants in CSS (xl eliminated)
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import fs from "node:fs"
|
|
32
|
+
import path from "node:path"
|
|
33
|
+
|
|
34
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
// Types
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export interface VariantUsage {
|
|
39
|
+
/** Component name */
|
|
40
|
+
component: string
|
|
41
|
+
/** Which variant props were used with which values */
|
|
42
|
+
usedValues: Record<string, Set<string>>
|
|
43
|
+
/** Files where component is used */
|
|
44
|
+
usedInFiles: string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface EliminationReport {
|
|
48
|
+
/** Total unused variant values found */
|
|
49
|
+
unusedCount: number
|
|
50
|
+
/** Estimated bytes saved */
|
|
51
|
+
bytesSaved: number
|
|
52
|
+
/** Details per component */
|
|
53
|
+
components: Record<
|
|
54
|
+
string,
|
|
55
|
+
{
|
|
56
|
+
usedVariants: Record<string, string[]>
|
|
57
|
+
unusedVariants: Record<string, string[]>
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
// Step 1: Scan files for component usage
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Extract all JSX component usages from source.
|
|
68
|
+
* Finds: <ComponentName prop="value" /> patterns.
|
|
69
|
+
*/
|
|
70
|
+
export function extractComponentUsage(source: string): Record<string, Record<string, Set<string>>> {
|
|
71
|
+
const usage: Record<string, Record<string, Set<string>>> = {}
|
|
72
|
+
|
|
73
|
+
// Match JSX elements: <ComponentName ...props...>
|
|
74
|
+
// Only match PascalCase (components, not HTML tags)
|
|
75
|
+
const jsxRe = /<([A-Z][A-Za-z0-9]*)\s([^>]*?)(?:\/?>)/g
|
|
76
|
+
let m: RegExpExecArray | null
|
|
77
|
+
|
|
78
|
+
while ((m = jsxRe.exec(source)) !== null) {
|
|
79
|
+
const compName = m[1]
|
|
80
|
+
const propsStr = m[2]
|
|
81
|
+
|
|
82
|
+
if (!usage[compName]) usage[compName] = {}
|
|
83
|
+
|
|
84
|
+
// Extract static prop="value" patterns
|
|
85
|
+
const propRe = /(\w+)=["']([^"']+)["']/g
|
|
86
|
+
let p: RegExpExecArray | null
|
|
87
|
+
while ((p = propRe.exec(propsStr)) !== null) {
|
|
88
|
+
const [, propName, propValue] = p
|
|
89
|
+
// Skip non-variant props
|
|
90
|
+
if (["className", "style", "id", "href", "src", "alt", "type"].includes(propName)) continue
|
|
91
|
+
|
|
92
|
+
if (!usage[compName][propName]) {
|
|
93
|
+
usage[compName][propName] = new Set()
|
|
94
|
+
}
|
|
95
|
+
usage[compName][propName].add(propValue)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return usage
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
// Step 2: Scan directory for all usages
|
|
104
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
const SCAN_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"]
|
|
107
|
+
const SKIP_DIRS = new Set(["node_modules", ".next", "dist", ".git", "out"])
|
|
108
|
+
|
|
109
|
+
function scanFiles(dir: string): string[] {
|
|
110
|
+
const files: string[] = []
|
|
111
|
+
|
|
112
|
+
function walk(current: string) {
|
|
113
|
+
if (!fs.existsSync(current)) return
|
|
114
|
+
const entries = fs.readdirSync(current, { withFileTypes: true })
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
117
|
+
const fullPath = path.join(current, entry.name)
|
|
118
|
+
if (entry.isDirectory()) {
|
|
119
|
+
walk(fullPath)
|
|
120
|
+
} else if (SCAN_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
|
|
121
|
+
files.push(fullPath)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
walk(dir)
|
|
127
|
+
return files
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Scan entire project for component usage.
|
|
132
|
+
*
|
|
133
|
+
* @param dirs - Directories to scan (e.g. ["src"])
|
|
134
|
+
* @param cwd - Project root
|
|
135
|
+
*/
|
|
136
|
+
export function scanProjectUsage(
|
|
137
|
+
dirs: string[],
|
|
138
|
+
cwd = process.cwd()
|
|
139
|
+
): Record<string, Record<string, Set<string>>> {
|
|
140
|
+
const combined: Record<string, Record<string, Set<string>>> = {}
|
|
141
|
+
|
|
142
|
+
for (const dir of dirs) {
|
|
143
|
+
const absDir = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir)
|
|
144
|
+
const files = scanFiles(absDir)
|
|
145
|
+
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
try {
|
|
148
|
+
const source = fs.readFileSync(file, "utf-8")
|
|
149
|
+
const usage = extractComponentUsage(source)
|
|
150
|
+
|
|
151
|
+
for (const [comp, props] of Object.entries(usage)) {
|
|
152
|
+
if (!combined[comp]) combined[comp] = {}
|
|
153
|
+
for (const [prop, values] of Object.entries(props)) {
|
|
154
|
+
if (!combined[comp][prop]) combined[comp][prop] = new Set()
|
|
155
|
+
values.forEach((v) => combined[comp][prop].add(v))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// skip unreadable
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return combined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
168
|
+
// Step 3: Compare with registered variant configs
|
|
169
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
export interface RegisteredComponent {
|
|
172
|
+
name: string
|
|
173
|
+
variants: Record<string, Record<string, string>> // { size: { sm: "text-sm", lg: "text-lg" } }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Find unused variant values by comparing registered components with actual usage.
|
|
178
|
+
*/
|
|
179
|
+
export function findDeadVariants(
|
|
180
|
+
registered: RegisteredComponent[],
|
|
181
|
+
projectUsage: Record<string, Record<string, Set<string>>>
|
|
182
|
+
): EliminationReport {
|
|
183
|
+
const report: EliminationReport = {
|
|
184
|
+
unusedCount: 0,
|
|
185
|
+
bytesSaved: 0,
|
|
186
|
+
components: {},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const component of registered) {
|
|
190
|
+
const usage = projectUsage[component.name] ?? {}
|
|
191
|
+
const usedVariants: Record<string, string[]> = {}
|
|
192
|
+
const unusedVariants: Record<string, string[]> = {}
|
|
193
|
+
|
|
194
|
+
for (const [variantKey, variantValues] of Object.entries(component.variants)) {
|
|
195
|
+
usedVariants[variantKey] = []
|
|
196
|
+
unusedVariants[variantKey] = []
|
|
197
|
+
|
|
198
|
+
const usedValueSet = usage[variantKey] ?? new Set()
|
|
199
|
+
|
|
200
|
+
for (const [valueName, classes] of Object.entries(variantValues)) {
|
|
201
|
+
if (usedValueSet.has(valueName)) {
|
|
202
|
+
usedVariants[variantKey].push(valueName)
|
|
203
|
+
} else {
|
|
204
|
+
unusedVariants[variantKey].push(valueName)
|
|
205
|
+
report.unusedCount++
|
|
206
|
+
// Rough estimate: avg 20 bytes per class, avg 3 classes per variant
|
|
207
|
+
report.bytesSaved += classes.split(/\s+/).length * 20
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (report.unusedCount > 0) {
|
|
213
|
+
report.components[component.name] = { usedVariants, unusedVariants }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return report
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
221
|
+
// Step 4: Eliminate dead CSS from compiled output
|
|
222
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Filter a CSS string to remove selectors for unused classes.
|
|
226
|
+
*
|
|
227
|
+
* @param css - Full CSS string
|
|
228
|
+
* @param deadClasses - Set of class names to remove
|
|
229
|
+
*/
|
|
230
|
+
export function eliminateDeadCss(css: string, deadClasses: Set<string>): string {
|
|
231
|
+
if (deadClasses.size === 0) return css
|
|
232
|
+
|
|
233
|
+
const lines = css.split("\n")
|
|
234
|
+
const kept: string[] = []
|
|
235
|
+
let inBlock = false
|
|
236
|
+
let removeBlock = false
|
|
237
|
+
let depth = 0
|
|
238
|
+
|
|
239
|
+
for (const line of lines) {
|
|
240
|
+
if (!inBlock) {
|
|
241
|
+
// Check if this selector matches a dead class
|
|
242
|
+
const isDead = Array.from(deadClasses).some((cls) => {
|
|
243
|
+
const escaped = cls.replace(/[:/[\].!%]/g, "\\$&")
|
|
244
|
+
return line.includes(`.${escaped}`) || line.includes(`.${cls}`)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
if (isDead && line.includes("{")) {
|
|
248
|
+
removeBlock = true
|
|
249
|
+
inBlock = true
|
|
250
|
+
depth = 1
|
|
251
|
+
continue
|
|
252
|
+
} else if (line.includes("{") && !line.trim().startsWith("@")) {
|
|
253
|
+
inBlock = true
|
|
254
|
+
depth = 1
|
|
255
|
+
removeBlock = false
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
if (line.includes("{")) depth++
|
|
259
|
+
if (line.includes("}")) {
|
|
260
|
+
depth--
|
|
261
|
+
if (depth <= 0) {
|
|
262
|
+
inBlock = false
|
|
263
|
+
if (removeBlock) {
|
|
264
|
+
removeBlock = false
|
|
265
|
+
continue // skip closing brace of dead block
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (removeBlock) continue
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
kept.push(line)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return kept.join("\n")
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
279
|
+
// CSS Performance Optimizer
|
|
280
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Merge duplicate CSS rules and deduplicate media queries.
|
|
284
|
+
* Reduces final CSS size for atomic outputs.
|
|
285
|
+
*
|
|
286
|
+
* @example
|
|
287
|
+
* optimizeCss(".tw-a1{padding:16px} .tw-b1{padding:16px}")
|
|
288
|
+
* → ".tw-a1,.tw-b1{padding:16px}"
|
|
289
|
+
*/
|
|
290
|
+
export function optimizeCss(css: string): string {
|
|
291
|
+
// Parse rules into { declaration: selector[] }
|
|
292
|
+
const ruleMap = new Map<string, Set<string>>()
|
|
293
|
+
const _mediaRules = new Map<string, string[]>()
|
|
294
|
+
const _others: string[] = []
|
|
295
|
+
|
|
296
|
+
// Simple rule parser (handles single-line rules)
|
|
297
|
+
const ruleRe = /^(\.[\w-]+)\s*\{([^}]+)\}$/gm
|
|
298
|
+
const mediaRe = /@media[^{]+\{[\s\S]*?\}\s*\}/g
|
|
299
|
+
|
|
300
|
+
let m: RegExpExecArray | null
|
|
301
|
+
let remaining = css
|
|
302
|
+
|
|
303
|
+
// Extract @media blocks first (preserve order)
|
|
304
|
+
const mediaBlocks: string[] = []
|
|
305
|
+
remaining = remaining.replace(mediaRe, (block) => {
|
|
306
|
+
mediaBlocks.push(block)
|
|
307
|
+
return ""
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// Process regular rules
|
|
311
|
+
while ((m = ruleRe.exec(remaining)) !== null) {
|
|
312
|
+
const selector = m[1].trim()
|
|
313
|
+
const declaration = m[2].trim()
|
|
314
|
+
|
|
315
|
+
if (!ruleMap.has(declaration)) {
|
|
316
|
+
ruleMap.set(declaration, new Set())
|
|
317
|
+
}
|
|
318
|
+
ruleMap.get(declaration)!.add(selector)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Build optimized CSS
|
|
322
|
+
const lines: string[] = []
|
|
323
|
+
|
|
324
|
+
for (const [declaration, selectors] of ruleMap) {
|
|
325
|
+
lines.push(`${Array.from(selectors).join(",")} { ${declaration} }`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Re-append @media blocks
|
|
329
|
+
lines.push(...mediaBlocks)
|
|
330
|
+
|
|
331
|
+
return lines.join("\n")
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
335
|
+
// Main: runElimination — full pipeline
|
|
336
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
export interface EliminationOptions {
|
|
339
|
+
dirs?: string[]
|
|
340
|
+
cwd?: string
|
|
341
|
+
registered?: RegisteredComponent[]
|
|
342
|
+
inputCss: string
|
|
343
|
+
verbose?: boolean
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Run full dead style elimination pipeline.
|
|
348
|
+
*
|
|
349
|
+
* @example
|
|
350
|
+
* const result = await runElimination({
|
|
351
|
+
* dirs: ["src"],
|
|
352
|
+
* inputCss: fs.readFileSync("dist/styles.css", "utf-8"),
|
|
353
|
+
* registered: [...componentConfigs],
|
|
354
|
+
* })
|
|
355
|
+
* fs.writeFileSync("dist/styles.min.css", result.css)
|
|
356
|
+
* console.log(result.report)
|
|
357
|
+
*/
|
|
358
|
+
export function runElimination(opts: EliminationOptions): {
|
|
359
|
+
css: string
|
|
360
|
+
report: EliminationReport
|
|
361
|
+
} {
|
|
362
|
+
const { dirs = ["src"], cwd = process.cwd(), registered = [], inputCss, verbose = false } = opts
|
|
363
|
+
|
|
364
|
+
// Step 1: Scan project
|
|
365
|
+
const usage = scanProjectUsage(dirs, cwd)
|
|
366
|
+
|
|
367
|
+
// Step 2: Find dead variants
|
|
368
|
+
const report = findDeadVariants(registered, usage)
|
|
369
|
+
|
|
370
|
+
// Step 3: Collect dead classes
|
|
371
|
+
const deadClasses = new Set<string>()
|
|
372
|
+
for (const [, { unusedVariants }] of Object.entries(report.components)) {
|
|
373
|
+
for (const values of Object.values(unusedVariants)) {
|
|
374
|
+
values.forEach((v) => deadClasses.add(v))
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Step 4: Eliminate + optimize
|
|
379
|
+
let css = eliminateDeadCss(inputCss, deadClasses)
|
|
380
|
+
css = optimizeCss(css)
|
|
381
|
+
|
|
382
|
+
if (verbose) {
|
|
383
|
+
const saved = (report.bytesSaved / 1024).toFixed(1)
|
|
384
|
+
console.log(`[tailwind-styled-v4] Dead style elimination:`)
|
|
385
|
+
console.log(` Unused variants: ${report.unusedCount}`)
|
|
386
|
+
console.log(` Estimated savings: ~${saved}KB`)
|
|
387
|
+
|
|
388
|
+
for (const [comp, { unusedVariants }] of Object.entries(report.components)) {
|
|
389
|
+
for (const [variant, values] of Object.entries(unusedVariants)) {
|
|
390
|
+
if (values.length > 0) {
|
|
391
|
+
console.log(` ${comp}.${variant}: ${values.join(", ")} (unused)`)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { css, report }
|
|
398
|
+
}
|