@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,462 @@
1
+ /**
2
+ * tailwind-styled-v4 — Style Registry
3
+ *
4
+ * Global singleton yang menjadi pusat semua style yang di-generate compiler.
5
+ * Ini adalah jantung atomic CSS system.
6
+ *
7
+ * Fitur:
8
+ * - Deterministic class generation: hash("padding:16px") → "tw-p16" (selalu sama)
9
+ * - Cross-file deduplication: 2 file pakai p-4 → 1 class saja di CSS output
10
+ * - CSS layering engine: base → components → variants → utilities (seperti Tailwind)
11
+ * - Build cache: registry persist antar incremental build
12
+ *
13
+ * Pipeline:
14
+ * Compiler encounters tw.div`p-4 bg-blue-500`
15
+ * ↓ parse "p-4" → CSS property "padding: 1rem"
16
+ * ↓ registry.register("padding: 1rem") → "tw-a1" (atau existing if already seen)
17
+ * ↓ output: <div class="tw-a1 tw-b3" />
18
+ * ↓ at build end: extract all → styles.css
19
+ */
20
+
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ // Types
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ export type CssLayer = "tokens" | "base" | "components" | "variants" | "utilities"
26
+
27
+ export interface StyleEntry {
28
+ /** Original Tailwind class */
29
+ twClass: string
30
+ /** Generated atomic class name */
31
+ atomicClass: string
32
+ /** CSS declaration (e.g. "padding: 1rem") */
33
+ declaration: string
34
+ /** CSS selector modifier (e.g. ":hover", "@media (min-width: 640px)") */
35
+ modifier?: string
36
+ /** Layer for ordering */
37
+ layer: CssLayer
38
+ /** How many times this class was referenced (for dead code analysis) */
39
+ refCount: number
40
+ }
41
+
42
+ export interface RegistryStats {
43
+ totalEntries: number
44
+ totalRefCount: number
45
+ layerCounts: Record<CssLayer, number>
46
+ estimatedCssKb: number
47
+ }
48
+
49
+ // ─────────────────────────────────────────────────────────────────────────────
50
+ // Deterministic Hash — based on declaration + modifier
51
+ //
52
+ // Requirements:
53
+ // - Same input → always same output (deterministic)
54
+ // - Stable across builds (not random)
55
+ // - Short output (minimize CSS selector length)
56
+ // - Collision safe for practical CSS property range
57
+ // ─────────────────────────────────────────────────────────────────────────────
58
+
59
+ const BASE36_CHARS = "0123456789abcdefghijklmnopqrstuvwxyz"
60
+
61
+ /**
62
+ * FNV-1a hash — fast, deterministic, good distribution.
63
+ * Produces same result for same input string across all runs.
64
+ */
65
+ function fnv1a(str: string): number {
66
+ let hash = 2166136261 // FNV offset basis
67
+ for (let i = 0; i < str.length; i++) {
68
+ hash ^= str.charCodeAt(i)
69
+ // FNV prime multiply (32-bit)
70
+ hash = (hash * 16777619) >>> 0
71
+ }
72
+ return hash
73
+ }
74
+
75
+ /**
76
+ * Convert number to base36 string (compact class names).
77
+ * 6 digits base36 = 2.18 billion unique classes (more than enough)
78
+ */
79
+ function toBase36(n: number, length = 4): string {
80
+ let result = ""
81
+ let num = n
82
+ for (let i = 0; i < length; i++) {
83
+ result = BASE36_CHARS[num % 36] + result
84
+ num = Math.floor(num / 36)
85
+ }
86
+ return result
87
+ }
88
+
89
+ /**
90
+ * Generate a deterministic, stable atomic class name.
91
+ *
92
+ * @example
93
+ * generateAtomicClass("padding: 1rem") → "tw-p16k"
94
+ * generateAtomicClass("padding: 1rem", ":hover") → "tw-h2p8"
95
+ */
96
+ export function generateAtomicClass(declaration: string, modifier?: string): string {
97
+ const key = modifier ? `${declaration}::${modifier}` : declaration
98
+ return `tw-${toBase36(fnv1a(key))}`
99
+ }
100
+
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+ // Style Registry — singleton per build
103
+ // ─────────────────────────────────────────────────────────────────────────────
104
+
105
+ export class StyleRegistry {
106
+ private entries = new Map<string, StyleEntry>() // key: declaration::modifier
107
+ private twClassMap = new Map<string, string>() // twClass → atomicClass
108
+ private atomicToEntry = new Map<string, StyleEntry>() // atomicClass → entry
109
+
110
+ // ── Registration ────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Register a CSS declaration and get its atomic class.
114
+ * If already registered, increments refCount and returns existing class.
115
+ *
116
+ * @param twClass - Original Tailwind class (for debug/devtools)
117
+ * @param declaration - CSS declaration ("padding: 1rem")
118
+ * @param modifier - Optional modifier (":hover", "@media ...")
119
+ * @param layer - CSS layer for ordering
120
+ */
121
+ register(
122
+ twClass: string,
123
+ declaration: string,
124
+ modifier?: string,
125
+ layer: CssLayer = "utilities"
126
+ ): string {
127
+ const key = modifier ? `${declaration}::${modifier}` : declaration
128
+
129
+ // Return existing
130
+ if (this.entries.has(key)) {
131
+ const entry = this.entries.get(key)!
132
+ entry.refCount++
133
+ return entry.atomicClass
134
+ }
135
+
136
+ // Generate new atomic class
137
+ const atomicClass = generateAtomicClass(declaration, modifier)
138
+ const entry: StyleEntry = {
139
+ twClass,
140
+ atomicClass,
141
+ declaration,
142
+ modifier,
143
+ layer,
144
+ refCount: 1,
145
+ }
146
+
147
+ this.entries.set(key, entry)
148
+ this.twClassMap.set(twClass, atomicClass)
149
+ this.atomicToEntry.set(atomicClass, entry)
150
+
151
+ return atomicClass
152
+ }
153
+
154
+ /**
155
+ * Register multiple Tailwind classes at once.
156
+ * Returns space-separated atomic class string.
157
+ */
158
+ registerClasses(twClasses: string, layer: CssLayer = "utilities"): string {
159
+ const parts = twClasses.split(/\s+/).filter(Boolean)
160
+ const atomicClasses: string[] = []
161
+
162
+ for (const cls of parts) {
163
+ // Parse modifier (hover:, md:, focus:, etc.)
164
+ const colonIdx = cls.lastIndexOf(":")
165
+ if (colonIdx > 0) {
166
+ const mod = cls.slice(0, colonIdx)
167
+ const base = cls.slice(colonIdx + 1)
168
+ const decl = this.twToDeclaration(base)
169
+ if (decl) {
170
+ atomicClasses.push(this.register(cls, decl, this.modifierToSelector(mod), layer))
171
+ } else {
172
+ atomicClasses.push(cls) // unknown — pass through
173
+ }
174
+ } else {
175
+ const decl = this.twToDeclaration(cls)
176
+ if (decl) {
177
+ atomicClasses.push(this.register(cls, decl, undefined, layer))
178
+ } else {
179
+ atomicClasses.push(cls) // unknown — pass through
180
+ }
181
+ }
182
+ }
183
+
184
+ return atomicClasses.join(" ")
185
+ }
186
+
187
+ // ── CSS Generation ───────────────────────────────────────────────────────
188
+
189
+ /**
190
+ * Generate full CSS output, ordered by layer.
191
+ * Layer order: tokens → base → components → variants → utilities
192
+ *
193
+ * This ensures predictable specificity — same as Tailwind's approach.
194
+ */
195
+ generateCss(opts: { minify?: boolean; includeComments?: boolean } = {}): string {
196
+ const { minify = false, includeComments = !minify } = opts
197
+
198
+ const layerOrder: CssLayer[] = ["tokens", "base", "components", "variants", "utilities"]
199
+ const sections: string[] = []
200
+
201
+ for (const layer of layerOrder) {
202
+ const layerEntries = Array.from(this.entries.values()).filter((e) => e.layer === layer)
203
+
204
+ if (layerEntries.length === 0) continue
205
+
206
+ if (includeComments) {
207
+ sections.push(`/* ── ${layer} ── */`)
208
+ }
209
+
210
+ // Separate regular rules from modifier rules
211
+ const regular = layerEntries.filter((e) => !e.modifier || !e.modifier.startsWith("@"))
212
+ const atRules = layerEntries.filter((e) => e.modifier?.startsWith("@"))
213
+ const pseudo = layerEntries.filter((e) => e.modifier && !e.modifier.startsWith("@"))
214
+
215
+ for (const entry of regular) {
216
+ sections.push(this.entryToCss(entry, minify))
217
+ }
218
+
219
+ for (const entry of pseudo) {
220
+ sections.push(this.entryToCss(entry, minify))
221
+ }
222
+
223
+ // Group @media rules by query
224
+ const mediaGroups = new Map<string, StyleEntry[]>()
225
+ for (const entry of atRules) {
226
+ const key = entry.modifier!
227
+ if (!mediaGroups.has(key)) mediaGroups.set(key, [])
228
+ mediaGroups.get(key)!.push(entry)
229
+ }
230
+
231
+ for (const [query, entries] of mediaGroups) {
232
+ const inner = entries.map((e) => this.entryToCss(e, minify, " ")).join(minify ? "" : "\n")
233
+ sections.push(`${query} {\n${inner}\n}`)
234
+ }
235
+ }
236
+
237
+ return sections.join(minify ? "" : "\n\n")
238
+ }
239
+
240
+ private entryToCss(entry: StyleEntry, minify: boolean, indent = ""): string {
241
+ const selector =
242
+ entry.modifier && !entry.modifier.startsWith("@")
243
+ ? `.${entry.atomicClass}${entry.modifier}`
244
+ : `.${entry.atomicClass}`
245
+
246
+ if (minify) {
247
+ return `${selector}{${entry.declaration}}`
248
+ }
249
+ return `${indent}${selector} { ${entry.declaration} }`
250
+ }
251
+
252
+ // ── Lookup ───────────────────────────────────────────────────────────────
253
+
254
+ getAtomicClass(twClass: string): string | undefined {
255
+ return this.twClassMap.get(twClass)
256
+ }
257
+
258
+ getEntry(atomicClass: string): StyleEntry | undefined {
259
+ return this.atomicToEntry.get(atomicClass)
260
+ }
261
+
262
+ getAllEntries(): StyleEntry[] {
263
+ return Array.from(this.entries.values())
264
+ }
265
+
266
+ stats(): RegistryStats {
267
+ const counts: Record<CssLayer, number> = {
268
+ tokens: 0,
269
+ base: 0,
270
+ components: 0,
271
+ variants: 0,
272
+ utilities: 0,
273
+ }
274
+ let totalRef = 0
275
+ for (const e of this.entries.values()) {
276
+ counts[e.layer]++
277
+ totalRef += e.refCount
278
+ }
279
+ return {
280
+ totalEntries: this.entries.size,
281
+ totalRefCount: totalRef,
282
+ layerCounts: counts,
283
+ estimatedCssKb: this.entries.size * 0.04, // ~40 bytes per rule avg
284
+ }
285
+ }
286
+
287
+ clear(): void {
288
+ this.entries.clear()
289
+ this.twClassMap.clear()
290
+ this.atomicToEntry.clear()
291
+ }
292
+
293
+ // ── Tailwind class → CSS declaration mapping ─────────────────────────────
294
+
295
+ private modifierToSelector(mod: string): string {
296
+ const pseudo: Record<string, string> = {
297
+ hover: ":hover",
298
+ focus: ":focus",
299
+ "focus-visible": ":focus-visible",
300
+ active: ":active",
301
+ disabled: ":disabled",
302
+ visited: ":visited",
303
+ checked: ":checked",
304
+ placeholder: "::placeholder",
305
+ before: "::before",
306
+ after: "::after",
307
+ first: ":first-child",
308
+ last: ":last-child",
309
+ odd: ":nth-child(odd)",
310
+ even: ":nth-child(even)",
311
+ }
312
+
313
+ const responsive: Record<string, string> = {
314
+ sm: "@media (min-width: 640px)",
315
+ md: "@media (min-width: 768px)",
316
+ lg: "@media (min-width: 1024px)",
317
+ xl: "@media (min-width: 1280px)",
318
+ "2xl": "@media (min-width: 1536px)",
319
+ }
320
+
321
+ return pseudo[mod] ?? responsive[mod] ?? `:${mod}`
322
+ }
323
+
324
+ private twToDeclaration(cls: string): string | null {
325
+ // Spacing
326
+ const spacingRe = /^(p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+(?:\.\d+)?)$/
327
+ const sMatch = cls.match(spacingRe)
328
+ if (sMatch) {
329
+ const [, prefix, val] = sMatch
330
+ const props: Record<string, string> = {
331
+ p: "padding",
332
+ px: "padding-inline",
333
+ py: "padding-block",
334
+ pt: "padding-top",
335
+ pb: "padding-bottom",
336
+ pl: "padding-left",
337
+ pr: "padding-right",
338
+ m: "margin",
339
+ mx: "margin-inline",
340
+ my: "margin-block",
341
+ mt: "margin-top",
342
+ mb: "margin-bottom",
343
+ ml: "margin-left",
344
+ mr: "margin-right",
345
+ gap: "gap",
346
+ }
347
+ return `${props[prefix]}: ${parseFloat(val) * 0.25}rem`
348
+ }
349
+
350
+ // Opacity
351
+ const opacityMatch = cls.match(/^opacity-(\d+)$/)
352
+ if (opacityMatch) return `opacity: ${parseInt(opacityMatch[1], 10) / 100}`
353
+
354
+ // Z-index
355
+ const zMatch = cls.match(/^z-(\d+)$/)
356
+ if (zMatch) return `z-index: ${zMatch[1]}`
357
+
358
+ // Display
359
+ const display: Record<string, string> = {
360
+ block: "display: block",
361
+ "inline-block": "display: inline-block",
362
+ flex: "display: flex",
363
+ "inline-flex": "display: inline-flex",
364
+ grid: "display: grid",
365
+ hidden: "display: none",
366
+ table: "display: table",
367
+ }
368
+ if (display[cls]) return display[cls]
369
+
370
+ // Flex
371
+ const flex: Record<string, string> = {
372
+ "flex-row": "flex-direction: row",
373
+ "flex-col": "flex-direction: column",
374
+ "flex-wrap": "flex-wrap: wrap",
375
+ "flex-nowrap": "flex-wrap: nowrap",
376
+ "flex-1": "flex: 1 1 0%",
377
+ "flex-auto": "flex: 1 1 auto",
378
+ "flex-none": "flex: none",
379
+ "items-center": "align-items: center",
380
+ "items-start": "align-items: flex-start",
381
+ "items-end": "align-items: flex-end",
382
+ "items-stretch": "align-items: stretch",
383
+ "justify-center": "justify-content: center",
384
+ "justify-start": "justify-content: flex-start",
385
+ "justify-end": "justify-content: flex-end",
386
+ "justify-between": "justify-content: space-between",
387
+ "justify-around": "justify-content: space-around",
388
+ "justify-evenly": "justify-content: space-evenly",
389
+ }
390
+ if (flex[cls]) return flex[cls]
391
+
392
+ // Position
393
+ const pos: Record<string, string> = {
394
+ relative: "position: relative",
395
+ absolute: "position: absolute",
396
+ fixed: "position: fixed",
397
+ sticky: "position: sticky",
398
+ static: "position: static",
399
+ "inset-0": "inset: 0",
400
+ "inset-x-0": "inset-inline: 0",
401
+ "inset-y-0": "inset-block: 0",
402
+ }
403
+ if (pos[cls]) return pos[cls]
404
+
405
+ // Width/Height
406
+ const wMatch = cls.match(/^w-(.+)$/)
407
+ if (wMatch) return `width: ${sizeVal(wMatch[1])}`
408
+ const hMatch = cls.match(/^h-(.+)$/)
409
+ if (hMatch) return `height: ${sizeVal(hMatch[1])}`
410
+
411
+ // Border radius
412
+ const rrMap: Record<string, string> = {
413
+ "rounded-none": "border-radius: 0",
414
+ "rounded-sm": "border-radius: 0.125rem",
415
+ rounded: "border-radius: 0.25rem",
416
+ "rounded-md": "border-radius: 0.375rem",
417
+ "rounded-lg": "border-radius: 0.5rem",
418
+ "rounded-xl": "border-radius: 0.75rem",
419
+ "rounded-2xl": "border-radius: 1rem",
420
+ "rounded-3xl": "border-radius: 1.5rem",
421
+ "rounded-full": "border-radius: 9999px",
422
+ }
423
+ if (rrMap[cls]) return rrMap[cls]
424
+
425
+ return null
426
+ }
427
+ }
428
+
429
+ // ─────────────────────────────────────────────────────────────────────────────
430
+ // Singleton
431
+ // ─────────────────────────────────────────────────────────────────────────────
432
+
433
+ let _globalRegistry: StyleRegistry | null = null
434
+
435
+ export function getStyleRegistry(): StyleRegistry {
436
+ if (!_globalRegistry) _globalRegistry = new StyleRegistry()
437
+ return _globalRegistry
438
+ }
439
+
440
+ export function resetStyleRegistry(): void {
441
+ _globalRegistry = new StyleRegistry()
442
+ }
443
+
444
+ // ─────────────────────────────────────────────────────────────────────────────
445
+ // Helper
446
+ // ─────────────────────────────────────────────────────────────────────────────
447
+
448
+ function sizeVal(v: string): string {
449
+ const num = parseFloat(v)
450
+ if (!Number.isNaN(num)) return `${num * 0.25}rem`
451
+ const special: Record<string, string> = {
452
+ full: "100%",
453
+ screen: "100vw",
454
+ svh: "100svh",
455
+ svw: "100svw",
456
+ auto: "auto",
457
+ min: "min-content",
458
+ max: "max-content",
459
+ fit: "fit-content",
460
+ }
461
+ return special[v] ?? v
462
+ }