@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.
@@ -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
+ }