@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,786 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-styled-v4 — Incremental CSS Compiler
|
|
3
|
+
*
|
|
4
|
+
* Hanya compile ulang file yang berubah, bukan semua file.
|
|
5
|
+
* Hasil: hot-reload styling dalam 5–20ms, bukan 3–10s.
|
|
6
|
+
*
|
|
7
|
+
* Pipeline:
|
|
8
|
+
* file watcher detects change
|
|
9
|
+
* ↓ hash check → skip jika file belum berubah
|
|
10
|
+
* ↓ update dependency graph (hapus rule lama, tambah rule baru)
|
|
11
|
+
* ↓ compute CSS diff (only changed rules)
|
|
12
|
+
* ↓ write diff ke output — bukan rewrite seluruh file
|
|
13
|
+
* ↓ hot reload
|
|
14
|
+
*
|
|
15
|
+
* Integrasi ke webpack/turbopack loader:
|
|
16
|
+
* import { incrementalEngine } from "./incrementalEngine"
|
|
17
|
+
* incrementalEngine.processFile(filepath, source, extractedClasses)
|
|
18
|
+
*
|
|
19
|
+
* Cache disimpan di `.tw-cache/` — persist antar build sessions.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import crypto from "node:crypto"
|
|
23
|
+
import fs from "node:fs"
|
|
24
|
+
import path from "node:path"
|
|
25
|
+
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
// Types
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/** Satu style node dalam dependency graph */
|
|
31
|
+
export interface StyleNode {
|
|
32
|
+
/** Tailwind class, e.g. "p-4" */
|
|
33
|
+
twClass: string
|
|
34
|
+
/** CSS declaration, e.g. "padding: 1rem" */
|
|
35
|
+
declaration: string
|
|
36
|
+
/** Modifier (pseudo/media), e.g. ":hover" atau "@media (min-width: 768px)" */
|
|
37
|
+
modifier?: string
|
|
38
|
+
/** Generated atomic class name, e.g. "tw-a1b2" */
|
|
39
|
+
atomicClass: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Graph: filepath → style nodes yang dihasilkan file itu */
|
|
43
|
+
export type FileDependencyGraph = Map<string, StyleNode[]>
|
|
44
|
+
|
|
45
|
+
/** Cache hash file untuk change detection */
|
|
46
|
+
export type FileHashCache = Map<string, string>
|
|
47
|
+
|
|
48
|
+
/** Diff antara build sebelum dan setelah */
|
|
49
|
+
export interface CssDiff {
|
|
50
|
+
/** Rule yang perlu ditambah ke CSS output */
|
|
51
|
+
added: StyleNode[]
|
|
52
|
+
/** Atomic class names yang perlu dihapus dari CSS output */
|
|
53
|
+
removed: string[]
|
|
54
|
+
/** true jika tidak ada perubahan */
|
|
55
|
+
noChange: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Summary setelah process satu file */
|
|
59
|
+
export interface ProcessResult {
|
|
60
|
+
/** File yang diproses */
|
|
61
|
+
filepath: string
|
|
62
|
+
/** true jika file berubah dan registry di-update */
|
|
63
|
+
changed: boolean
|
|
64
|
+
/** CSS diff untuk file ini */
|
|
65
|
+
diff: CssDiff
|
|
66
|
+
/** Durasi proses dalam ms */
|
|
67
|
+
durationMs: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Stats engine */
|
|
71
|
+
export interface IncrementalStats {
|
|
72
|
+
totalFiles: number
|
|
73
|
+
changedFiles: number
|
|
74
|
+
skippedFiles: number
|
|
75
|
+
addedRules: number
|
|
76
|
+
removedRules: number
|
|
77
|
+
buildTimeMs: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
81
|
+
// Cache persistence — simpan ke disk supaya antar restart tetap cepat
|
|
82
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
const CACHE_DIR = ".tw-cache"
|
|
85
|
+
const HASH_CACHE_FILE = path.join(CACHE_DIR, "file-hashes.json")
|
|
86
|
+
const GRAPH_CACHE_FILE = path.join(CACHE_DIR, "dep-graph.json")
|
|
87
|
+
|
|
88
|
+
function ensureCacheDir(): void {
|
|
89
|
+
if (!fs.existsSync(CACHE_DIR)) {
|
|
90
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function loadHashCache(): FileHashCache {
|
|
95
|
+
try {
|
|
96
|
+
if (fs.existsSync(HASH_CACHE_FILE)) {
|
|
97
|
+
const raw = fs.readFileSync(HASH_CACHE_FILE, "utf-8")
|
|
98
|
+
return new Map(Object.entries(JSON.parse(raw)))
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
/* corrupt cache — start fresh */
|
|
102
|
+
}
|
|
103
|
+
return new Map()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function saveHashCache(cache: FileHashCache): void {
|
|
107
|
+
try {
|
|
108
|
+
ensureCacheDir()
|
|
109
|
+
const obj = Object.fromEntries(cache)
|
|
110
|
+
fs.writeFileSync(HASH_CACHE_FILE, JSON.stringify(obj, null, 2))
|
|
111
|
+
} catch {
|
|
112
|
+
/* non-fatal */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function loadGraphCache(): FileDependencyGraph {
|
|
117
|
+
try {
|
|
118
|
+
if (fs.existsSync(GRAPH_CACHE_FILE)) {
|
|
119
|
+
const raw = fs.readFileSync(GRAPH_CACHE_FILE, "utf-8")
|
|
120
|
+
const data = JSON.parse(raw) as Record<string, StyleNode[]>
|
|
121
|
+
return new Map(Object.entries(data))
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
/* corrupt cache */
|
|
125
|
+
}
|
|
126
|
+
return new Map()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function saveGraphCache(graph: FileDependencyGraph): void {
|
|
130
|
+
try {
|
|
131
|
+
ensureCacheDir()
|
|
132
|
+
const obj = Object.fromEntries(graph)
|
|
133
|
+
fs.writeFileSync(GRAPH_CACHE_FILE, JSON.stringify(obj, null, 2))
|
|
134
|
+
} catch {
|
|
135
|
+
/* non-fatal */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
// Hash — FNV-1a untuk konsitensi dengan styleRegistry
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
function fnv1a(str: string): number {
|
|
144
|
+
let h = 2166136261
|
|
145
|
+
for (let i = 0; i < str.length; i++) {
|
|
146
|
+
h ^= str.charCodeAt(i)
|
|
147
|
+
h = (h * 16777619) >>> 0
|
|
148
|
+
}
|
|
149
|
+
return h
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function toBase36(n: number, len = 4): string {
|
|
153
|
+
const chars = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
154
|
+
let result = ""
|
|
155
|
+
let num = n
|
|
156
|
+
for (let i = 0; i < len; i++) {
|
|
157
|
+
result = chars[num % 36] + result
|
|
158
|
+
num = Math.floor(num / 36)
|
|
159
|
+
}
|
|
160
|
+
return result
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Hash konten file untuk change detection.
|
|
165
|
+
* Pakai MD5 — cepat, tidak perlu kriptografi.
|
|
166
|
+
*/
|
|
167
|
+
function hashFileContent(content: string): string {
|
|
168
|
+
return crypto.createHash("md5").update(content).digest("hex").slice(0, 8)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Generate atomic class name yang konsisten dengan styleRegistry.
|
|
173
|
+
*/
|
|
174
|
+
function makeAtomicClass(declaration: string, modifier?: string): string {
|
|
175
|
+
const key = modifier ? `${declaration}::${modifier}` : declaration
|
|
176
|
+
return `tw-${toBase36(fnv1a(key))}`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
180
|
+
// CSS Diff Engine
|
|
181
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Hitung diff antara style nodes lama vs baru dari satu file.
|
|
185
|
+
*
|
|
186
|
+
* @param oldNodes - Style nodes dari build sebelumnya
|
|
187
|
+
* @param newNodes - Style nodes dari build terkini
|
|
188
|
+
* @returns CssDiff dengan added/removed rules
|
|
189
|
+
*/
|
|
190
|
+
function computeDiff(oldNodes: StyleNode[], newNodes: StyleNode[]): CssDiff {
|
|
191
|
+
const oldMap = new Map(oldNodes.map((n) => [n.atomicClass, n]))
|
|
192
|
+
const newMap = new Map(newNodes.map((n) => [n.atomicClass, n]))
|
|
193
|
+
|
|
194
|
+
const added: StyleNode[] = []
|
|
195
|
+
const removed: string[] = []
|
|
196
|
+
|
|
197
|
+
// Rule baru yang belum ada di build lama
|
|
198
|
+
for (const [cls, node] of newMap) {
|
|
199
|
+
if (!oldMap.has(cls)) added.push(node)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Rule lama yang tidak ada di build baru
|
|
203
|
+
for (const cls of oldMap.keys()) {
|
|
204
|
+
if (!newMap.has(cls)) removed.push(cls)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
added,
|
|
209
|
+
removed,
|
|
210
|
+
noChange: added.length === 0 && removed.length === 0,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
// Global Atomic Registry — track semua rule aktif di seluruh project
|
|
216
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Registry global yang aggregates semua StyleNode dari semua file.
|
|
220
|
+
* Key: atomicClass → StyleNode + refCount (berapa file yang pakai rule ini)
|
|
221
|
+
*/
|
|
222
|
+
interface GlobalEntry {
|
|
223
|
+
node: StyleNode
|
|
224
|
+
/** File-file yang menghasilkan rule ini (untuk proper dedup) */
|
|
225
|
+
sources: Set<string>
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
class GlobalAtomicRegistry {
|
|
229
|
+
private entries = new Map<string, GlobalEntry>()
|
|
230
|
+
|
|
231
|
+
/** Tambah node dari file tertentu */
|
|
232
|
+
add(filepath: string, node: StyleNode): void {
|
|
233
|
+
const existing = this.entries.get(node.atomicClass)
|
|
234
|
+
if (existing) {
|
|
235
|
+
existing.sources.add(filepath)
|
|
236
|
+
} else {
|
|
237
|
+
this.entries.set(node.atomicClass, {
|
|
238
|
+
node,
|
|
239
|
+
sources: new Set([filepath]),
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Hapus referensi dari file tertentu; jika tidak ada source lain, rule dihapus */
|
|
245
|
+
remove(filepath: string, atomicClass: string): boolean {
|
|
246
|
+
const entry = this.entries.get(atomicClass)
|
|
247
|
+
if (!entry) return false
|
|
248
|
+
entry.sources.delete(filepath)
|
|
249
|
+
if (entry.sources.size === 0) {
|
|
250
|
+
this.entries.delete(atomicClass)
|
|
251
|
+
return true // rule benar-benar dihapus
|
|
252
|
+
}
|
|
253
|
+
return false // masih dipakai file lain
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Cek apakah rule ada di registry global */
|
|
257
|
+
has(atomicClass: string): boolean {
|
|
258
|
+
return this.entries.has(atomicClass)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Semua entries untuk CSS generation */
|
|
262
|
+
all(): StyleNode[] {
|
|
263
|
+
return Array.from(this.entries.values()).map((e) => e.node)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Total unique rules */
|
|
267
|
+
size(): number {
|
|
268
|
+
return this.entries.size
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
273
|
+
// CSS Writer — write/update CSS file secara incremental
|
|
274
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Generate CSS string dari satu StyleNode.
|
|
278
|
+
*/
|
|
279
|
+
function nodeToCSS(node: StyleNode): string {
|
|
280
|
+
const { atomicClass, declaration, modifier } = node
|
|
281
|
+
|
|
282
|
+
if (!modifier) {
|
|
283
|
+
return `.${atomicClass}{${declaration}}`
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (modifier.startsWith("@")) {
|
|
287
|
+
// Media query
|
|
288
|
+
return `${modifier}{.${atomicClass}{${declaration}}}`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Pseudo selector
|
|
292
|
+
return `.${atomicClass}${modifier}{${declaration}}`
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* CSS Diff Writer — update CSS file tanpa rewrite penuh.
|
|
297
|
+
*
|
|
298
|
+
* Strategi: simpan registry CSS sebagai Map<atomicClass, cssRule>,
|
|
299
|
+
* serialize ke file. Lebih cepat dari string manipulation.
|
|
300
|
+
*/
|
|
301
|
+
class CssDiffWriter {
|
|
302
|
+
private ruleMap = new Map<string, string>()
|
|
303
|
+
private outputPath: string
|
|
304
|
+
private dirty = false
|
|
305
|
+
|
|
306
|
+
constructor(outputPath: string) {
|
|
307
|
+
this.outputPath = outputPath
|
|
308
|
+
this.loadFromDisk()
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private loadFromDisk(): void {
|
|
312
|
+
try {
|
|
313
|
+
if (fs.existsSync(this.outputPath)) {
|
|
314
|
+
// Parse existing CSS untuk reconstruct ruleMap
|
|
315
|
+
const css = fs.readFileSync(this.outputPath, "utf-8")
|
|
316
|
+
// Extract rule blocks — simpel: split per .tw-XXXX pattern
|
|
317
|
+
const ruleRe =
|
|
318
|
+
/(\.tw-[a-z0-9]+(?::[\w-]+)?)\{([^}]+)\}|(@[^{]+)\{(\.tw-[a-z0-9]+)\{([^}]+)\}\}/g
|
|
319
|
+
let m: RegExpExecArray | null
|
|
320
|
+
while ((m = ruleRe.exec(css)) !== null) {
|
|
321
|
+
if (m[1]) {
|
|
322
|
+
const cls = m[1].replace(/\.[^:]+:.*/, (match) => match.split(".")[1].split(":")[0])
|
|
323
|
+
this.ruleMap.set(cls, m[0])
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} catch {
|
|
328
|
+
/* start fresh */
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Apply diff ke internal map */
|
|
333
|
+
applyDiff(diff: CssDiff): void {
|
|
334
|
+
if (diff.noChange) return
|
|
335
|
+
|
|
336
|
+
for (const node of diff.added) {
|
|
337
|
+
this.ruleMap.set(node.atomicClass, nodeToCSS(node))
|
|
338
|
+
}
|
|
339
|
+
for (const cls of diff.removed) {
|
|
340
|
+
this.ruleMap.delete(cls)
|
|
341
|
+
}
|
|
342
|
+
this.dirty = true
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Write ke disk jika ada perubahan. Async untuk tidak block loader. */
|
|
346
|
+
async flush(): Promise<void> {
|
|
347
|
+
if (!this.dirty) return
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
ensureCacheDir()
|
|
351
|
+
const css = Array.from(this.ruleMap.values()).join("\n")
|
|
352
|
+
await fs.promises.writeFile(this.outputPath, css, "utf-8")
|
|
353
|
+
this.dirty = false
|
|
354
|
+
} catch {
|
|
355
|
+
/* non-fatal */
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Sync flush untuk build end */
|
|
360
|
+
flushSync(): void {
|
|
361
|
+
if (!this.dirty) return
|
|
362
|
+
try {
|
|
363
|
+
ensureCacheDir()
|
|
364
|
+
const css = Array.from(this.ruleMap.values()).join("\n")
|
|
365
|
+
fs.writeFileSync(this.outputPath, css, "utf-8")
|
|
366
|
+
this.dirty = false
|
|
367
|
+
} catch {
|
|
368
|
+
/* non-fatal */
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
size(): number {
|
|
373
|
+
return this.ruleMap.size
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
378
|
+
// IncrementalEngine — main orchestrator
|
|
379
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
export interface IncrementalEngineOptions {
|
|
382
|
+
/** Output path untuk CSS file incremental. Default: ".tw-cache/atomic.css" */
|
|
383
|
+
outputPath?: string
|
|
384
|
+
/** Apakah persist cache ke disk. Default: true */
|
|
385
|
+
persistCache?: boolean
|
|
386
|
+
/** Verbose logging. Default: false */
|
|
387
|
+
verbose?: boolean
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export class IncrementalEngine {
|
|
391
|
+
private hashCache: FileHashCache
|
|
392
|
+
private depGraph: FileDependencyGraph
|
|
393
|
+
private globalReg: GlobalAtomicRegistry
|
|
394
|
+
private cssWriter: CssDiffWriter
|
|
395
|
+
private opts: Required<IncrementalEngineOptions>
|
|
396
|
+
|
|
397
|
+
// Stats untuk current build session
|
|
398
|
+
private stats: IncrementalStats = {
|
|
399
|
+
totalFiles: 0,
|
|
400
|
+
changedFiles: 0,
|
|
401
|
+
skippedFiles: 0,
|
|
402
|
+
addedRules: 0,
|
|
403
|
+
removedRules: 0,
|
|
404
|
+
buildTimeMs: 0,
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private sessionStart = Date.now()
|
|
408
|
+
|
|
409
|
+
constructor(opts: IncrementalEngineOptions = {}) {
|
|
410
|
+
this.opts = {
|
|
411
|
+
outputPath: opts.outputPath ?? path.join(CACHE_DIR, "atomic.css"),
|
|
412
|
+
persistCache: opts.persistCache ?? true,
|
|
413
|
+
verbose: opts.verbose ?? false,
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
this.hashCache = this.opts.persistCache ? loadHashCache() : new Map()
|
|
417
|
+
this.depGraph = this.opts.persistCache ? loadGraphCache() : new Map()
|
|
418
|
+
this.globalReg = new GlobalAtomicRegistry()
|
|
419
|
+
this.cssWriter = new CssDiffWriter(this.opts.outputPath)
|
|
420
|
+
|
|
421
|
+
// Reconstruct global registry dari loaded dep graph
|
|
422
|
+
for (const [filepath, nodes] of this.depGraph) {
|
|
423
|
+
for (const node of nodes) {
|
|
424
|
+
this.globalReg.add(filepath, node)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Proses satu file. Core method dipanggil oleh webpack/turbopack loader.
|
|
431
|
+
*
|
|
432
|
+
* @param filepath - Absolute path ke file
|
|
433
|
+
* @param source - Source code file (untuk hashing)
|
|
434
|
+
* @param extractedNodes - Style nodes yang di-extract compiler dari file ini
|
|
435
|
+
* @returns ProcessResult dengan diff dan stats
|
|
436
|
+
*/
|
|
437
|
+
processFile(filepath: string, source: string, extractedNodes: StyleNode[]): ProcessResult {
|
|
438
|
+
const t0 = Date.now()
|
|
439
|
+
this.stats.totalFiles++
|
|
440
|
+
|
|
441
|
+
// ── 1. Change detection ──────────────────────────────────────────────────
|
|
442
|
+
const currentHash = hashFileContent(source)
|
|
443
|
+
const cachedHash = this.hashCache.get(filepath)
|
|
444
|
+
|
|
445
|
+
if (cachedHash === currentHash) {
|
|
446
|
+
// File tidak berubah — skip sepenuhnya
|
|
447
|
+
this.stats.skippedFiles++
|
|
448
|
+
this.log(`[skip] ${path.relative(process.cwd(), filepath)}`)
|
|
449
|
+
return {
|
|
450
|
+
filepath,
|
|
451
|
+
changed: false,
|
|
452
|
+
diff: { added: [], removed: [], noChange: true },
|
|
453
|
+
durationMs: Date.now() - t0,
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── 2. Hash update ───────────────────────────────────────────────────────
|
|
458
|
+
this.hashCache.set(filepath, currentHash)
|
|
459
|
+
this.stats.changedFiles++
|
|
460
|
+
this.log(`[change] ${path.relative(process.cwd(), filepath)}`)
|
|
461
|
+
|
|
462
|
+
// ── 3. Compute diff antara nodes lama dan baru ───────────────────────────
|
|
463
|
+
const oldNodes = this.depGraph.get(filepath) ?? []
|
|
464
|
+
const diff = computeDiff(oldNodes, extractedNodes)
|
|
465
|
+
|
|
466
|
+
// ── 4. Update dependency graph ───────────────────────────────────────────
|
|
467
|
+
this.depGraph.set(filepath, extractedNodes)
|
|
468
|
+
|
|
469
|
+
// ── 5. Update global atomic registry ────────────────────────────────────
|
|
470
|
+
// Hapus rule lama yang dihapus dari file ini
|
|
471
|
+
const trulyRemoved: string[] = []
|
|
472
|
+
for (const cls of diff.removed) {
|
|
473
|
+
const wasRemoved = this.globalReg.remove(filepath, cls)
|
|
474
|
+
if (wasRemoved) trulyRemoved.push(cls)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Tambah rule baru
|
|
478
|
+
const trulyAdded: StyleNode[] = []
|
|
479
|
+
for (const node of diff.added) {
|
|
480
|
+
if (!this.globalReg.has(node.atomicClass)) {
|
|
481
|
+
trulyAdded.push(node)
|
|
482
|
+
}
|
|
483
|
+
this.globalReg.add(filepath, node)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── 6. Build final diff untuk CSS writer ────────────────────────────────
|
|
487
|
+
const finalDiff: CssDiff = {
|
|
488
|
+
added: trulyAdded,
|
|
489
|
+
removed: trulyRemoved,
|
|
490
|
+
noChange: trulyAdded.length === 0 && trulyRemoved.length === 0,
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
this.cssWriter.applyDiff(finalDiff)
|
|
494
|
+
this.stats.addedRules += trulyAdded.length
|
|
495
|
+
this.stats.removedRules += trulyRemoved.length
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
filepath,
|
|
499
|
+
changed: true,
|
|
500
|
+
diff: finalDiff,
|
|
501
|
+
durationMs: Date.now() - t0,
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Dipanggil di akhir build. Flush CSS ke disk, persist cache.
|
|
507
|
+
*/
|
|
508
|
+
async buildEnd(): Promise<void> {
|
|
509
|
+
this.stats.buildTimeMs = Date.now() - this.sessionStart
|
|
510
|
+
|
|
511
|
+
await this.cssWriter.flush()
|
|
512
|
+
|
|
513
|
+
if (this.opts.persistCache) {
|
|
514
|
+
saveHashCache(this.hashCache)
|
|
515
|
+
saveGraphCache(this.depGraph)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
this.log(
|
|
519
|
+
`[build] done in ${this.stats.buildTimeMs}ms | ` +
|
|
520
|
+
`changed: ${this.stats.changedFiles}/${this.stats.totalFiles} files | ` +
|
|
521
|
+
`+${this.stats.addedRules} -${this.stats.removedRules} rules | ` +
|
|
522
|
+
`total rules: ${this.cssWriter.size()}`
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Sync version untuk webpack buildEnd hook */
|
|
527
|
+
buildEndSync(): void {
|
|
528
|
+
this.stats.buildTimeMs = Date.now() - this.sessionStart
|
|
529
|
+
this.cssWriter.flushSync()
|
|
530
|
+
|
|
531
|
+
if (this.opts.persistCache) {
|
|
532
|
+
saveHashCache(this.hashCache)
|
|
533
|
+
saveGraphCache(this.depGraph)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Invalidate satu file (untuk hot reload — file dihapus atau renamed).
|
|
539
|
+
*/
|
|
540
|
+
invalidateFile(filepath: string): void {
|
|
541
|
+
const oldNodes = this.depGraph.get(filepath) ?? []
|
|
542
|
+
for (const node of oldNodes) {
|
|
543
|
+
this.globalReg.remove(filepath, node.atomicClass)
|
|
544
|
+
}
|
|
545
|
+
this.depGraph.delete(filepath)
|
|
546
|
+
this.hashCache.delete(filepath)
|
|
547
|
+
this.log(`[invalidate] ${path.relative(process.cwd(), filepath)}`)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Get all active style nodes — untuk full CSS generation */
|
|
551
|
+
getAllNodes(): StyleNode[] {
|
|
552
|
+
return this.globalReg.all()
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Get stats untuk current build session */
|
|
556
|
+
getStats(): Readonly<IncrementalStats> {
|
|
557
|
+
return { ...this.stats, buildTimeMs: Date.now() - this.sessionStart }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Get output CSS path */
|
|
561
|
+
getOutputPath(): string {
|
|
562
|
+
return this.opts.outputPath
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Reset stats untuk build session baru */
|
|
566
|
+
resetStats(): void {
|
|
567
|
+
this.stats = {
|
|
568
|
+
totalFiles: 0,
|
|
569
|
+
changedFiles: 0,
|
|
570
|
+
skippedFiles: 0,
|
|
571
|
+
addedRules: 0,
|
|
572
|
+
removedRules: 0,
|
|
573
|
+
buildTimeMs: 0,
|
|
574
|
+
}
|
|
575
|
+
this.sessionStart = Date.now()
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Reset semua cache — untuk clean build */
|
|
579
|
+
reset(): void {
|
|
580
|
+
this.hashCache.clear()
|
|
581
|
+
this.depGraph.clear()
|
|
582
|
+
this.globalReg = new GlobalAtomicRegistry()
|
|
583
|
+
this.cssWriter = new CssDiffWriter(this.opts.outputPath)
|
|
584
|
+
this.resetStats()
|
|
585
|
+
this.log("[reset] incremental cache cleared")
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private log(msg: string): void {
|
|
589
|
+
if (this.opts.verbose) {
|
|
590
|
+
console.log(`[tailwind-styled/incremental] ${msg}`)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
596
|
+
// StyleNode extractor helpers — convert extracted Tailwind classes → StyleNodes
|
|
597
|
+
// Dipanggil oleh loader setelah transformSource()
|
|
598
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Parse daftar Tailwind classes menjadi StyleNodes untuk incremental engine.
|
|
602
|
+
* Supports: responsive variants (md:, lg:), pseudo (hover:, focus:), arbitrary.
|
|
603
|
+
*
|
|
604
|
+
* @example
|
|
605
|
+
* parseClassesToNodes(["p-4", "hover:bg-blue-500", "md:text-lg"])
|
|
606
|
+
*/
|
|
607
|
+
export function parseClassesToNodes(classes: string[]): StyleNode[] {
|
|
608
|
+
const nodes: StyleNode[] = []
|
|
609
|
+
|
|
610
|
+
for (const cls of classes) {
|
|
611
|
+
const node = parseOneClass(cls)
|
|
612
|
+
if (node) nodes.push(node)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return nodes
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function parseOneClass(cls: string): StyleNode | null {
|
|
619
|
+
// Split modifier:utility
|
|
620
|
+
const colonIdx = cls.lastIndexOf(":")
|
|
621
|
+
let modifier: string | undefined
|
|
622
|
+
let utility: string
|
|
623
|
+
|
|
624
|
+
if (colonIdx > 0) {
|
|
625
|
+
const modStr = cls.slice(0, colonIdx)
|
|
626
|
+
utility = cls.slice(colonIdx + 1)
|
|
627
|
+
modifier = resolveModifier(modStr)
|
|
628
|
+
} else {
|
|
629
|
+
utility = cls
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const declaration = twToDeclaration(utility)
|
|
633
|
+
if (!declaration) return null // unknown class — skip
|
|
634
|
+
|
|
635
|
+
const atomicClass = makeAtomicClass(declaration, modifier)
|
|
636
|
+
|
|
637
|
+
return { twClass: cls, declaration, modifier, atomicClass }
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function resolveModifier(mod: string): string {
|
|
641
|
+
const pseudoMap: Record<string, string> = {
|
|
642
|
+
hover: ":hover",
|
|
643
|
+
focus: ":focus",
|
|
644
|
+
active: ":active",
|
|
645
|
+
disabled: ":disabled",
|
|
646
|
+
visited: ":visited",
|
|
647
|
+
checked: ":checked",
|
|
648
|
+
first: ":first-child",
|
|
649
|
+
last: ":last-child",
|
|
650
|
+
odd: ":nth-child(odd)",
|
|
651
|
+
even: ":nth-child(even)",
|
|
652
|
+
}
|
|
653
|
+
const mediaMap: Record<string, string> = {
|
|
654
|
+
sm: "@media (min-width: 640px)",
|
|
655
|
+
md: "@media (min-width: 768px)",
|
|
656
|
+
lg: "@media (min-width: 1024px)",
|
|
657
|
+
xl: "@media (min-width: 1280px)",
|
|
658
|
+
"2xl": "@media (min-width: 1536px)",
|
|
659
|
+
dark: "@media (prefers-color-scheme: dark)",
|
|
660
|
+
print: "@media print",
|
|
661
|
+
}
|
|
662
|
+
return pseudoMap[mod] ?? mediaMap[mod] ?? `:${mod}`
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Minimal Tailwind → CSS declaration mapping (shared dengan styleRegistry) */
|
|
666
|
+
function twToDeclaration(cls: string): string | null {
|
|
667
|
+
// Spacing
|
|
668
|
+
const sp = cls.match(/^(p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-([\d.]+)$/)
|
|
669
|
+
if (sp) {
|
|
670
|
+
const propMap: Record<string, string> = {
|
|
671
|
+
p: "padding",
|
|
672
|
+
px: "padding-inline",
|
|
673
|
+
py: "padding-block",
|
|
674
|
+
pt: "padding-top",
|
|
675
|
+
pb: "padding-bottom",
|
|
676
|
+
pl: "padding-left",
|
|
677
|
+
pr: "padding-right",
|
|
678
|
+
m: "margin",
|
|
679
|
+
mx: "margin-inline",
|
|
680
|
+
my: "margin-block",
|
|
681
|
+
mt: "margin-top",
|
|
682
|
+
mb: "margin-bottom",
|
|
683
|
+
ml: "margin-left",
|
|
684
|
+
mr: "margin-right",
|
|
685
|
+
gap: "gap",
|
|
686
|
+
}
|
|
687
|
+
return `${propMap[sp[1]]}: ${parseFloat(sp[2]) * 0.25}rem`
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Sizing
|
|
691
|
+
const w = cls.match(/^w-(.+)$/)
|
|
692
|
+
if (w) return `width: ${sizeVal(w[1])}`
|
|
693
|
+
const h = cls.match(/^h-(.+)$/)
|
|
694
|
+
if (h) return `height: ${sizeVal(h[1])}`
|
|
695
|
+
|
|
696
|
+
// Opacity, z-index
|
|
697
|
+
const op = cls.match(/^opacity-(\d+)$/)
|
|
698
|
+
if (op) return `opacity: ${parseInt(op[1], 10) / 100}`
|
|
699
|
+
const z = cls.match(/^z-(\d+)$/)
|
|
700
|
+
if (z) return `z-index: ${z[1]}`
|
|
701
|
+
|
|
702
|
+
// Common utilities
|
|
703
|
+
const map: Record<string, string> = {
|
|
704
|
+
block: "display: block",
|
|
705
|
+
"inline-block": "display: inline-block",
|
|
706
|
+
flex: "display: flex",
|
|
707
|
+
"inline-flex": "display: inline-flex",
|
|
708
|
+
grid: "display: grid",
|
|
709
|
+
hidden: "display: none",
|
|
710
|
+
relative: "position: relative",
|
|
711
|
+
absolute: "position: absolute",
|
|
712
|
+
fixed: "position: fixed",
|
|
713
|
+
sticky: "position: sticky",
|
|
714
|
+
"flex-row": "flex-direction: row",
|
|
715
|
+
"flex-col": "flex-direction: column",
|
|
716
|
+
"items-center": "align-items: center",
|
|
717
|
+
"items-start": "align-items: flex-start",
|
|
718
|
+
"items-end": "align-items: flex-end",
|
|
719
|
+
"justify-center": "justify-content: center",
|
|
720
|
+
"justify-between": "justify-content: space-between",
|
|
721
|
+
"justify-start": "justify-content: flex-start",
|
|
722
|
+
"justify-end": "justify-content: flex-end",
|
|
723
|
+
"font-thin": "font-weight: 100",
|
|
724
|
+
"font-light": "font-weight: 300",
|
|
725
|
+
"font-normal": "font-weight: 400",
|
|
726
|
+
"font-medium": "font-weight: 500",
|
|
727
|
+
"font-semibold": "font-weight: 600",
|
|
728
|
+
"font-bold": "font-weight: 700",
|
|
729
|
+
"font-extrabold": "font-weight: 800",
|
|
730
|
+
"text-xs": "font-size: 0.75rem",
|
|
731
|
+
"text-sm": "font-size: 0.875rem",
|
|
732
|
+
"text-base": "font-size: 1rem",
|
|
733
|
+
"text-lg": "font-size: 1.125rem",
|
|
734
|
+
"text-xl": "font-size: 1.25rem",
|
|
735
|
+
"text-2xl": "font-size: 1.5rem",
|
|
736
|
+
"text-3xl": "font-size: 1.875rem",
|
|
737
|
+
"text-4xl": "font-size: 2.25rem",
|
|
738
|
+
rounded: "border-radius: 0.25rem",
|
|
739
|
+
"rounded-md": "border-radius: 0.375rem",
|
|
740
|
+
"rounded-lg": "border-radius: 0.5rem",
|
|
741
|
+
"rounded-xl": "border-radius: 0.75rem",
|
|
742
|
+
"rounded-full": "border-radius: 9999px",
|
|
743
|
+
"overflow-hidden": "overflow: hidden",
|
|
744
|
+
"overflow-auto": "overflow: auto",
|
|
745
|
+
"cursor-pointer": "cursor: pointer",
|
|
746
|
+
"cursor-default": "cursor: default",
|
|
747
|
+
"select-none": "user-select: none",
|
|
748
|
+
"pointer-events-none": "pointer-events: none",
|
|
749
|
+
truncate: "overflow: hidden; text-overflow: ellipsis; white-space: nowrap",
|
|
750
|
+
transition:
|
|
751
|
+
"transition-property: color,background-color,border-color,opacity,box-shadow,transform; transition-duration: 150ms",
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return map[cls] ?? null
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function sizeVal(v: string): string {
|
|
758
|
+
const num = parseFloat(v)
|
|
759
|
+
if (!Number.isNaN(num)) return `${num * 0.25}rem`
|
|
760
|
+
const special: Record<string, string> = {
|
|
761
|
+
full: "100%",
|
|
762
|
+
screen: "100vw",
|
|
763
|
+
auto: "auto",
|
|
764
|
+
min: "min-content",
|
|
765
|
+
max: "max-content",
|
|
766
|
+
fit: "fit-content",
|
|
767
|
+
}
|
|
768
|
+
return special[v] ?? v
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
772
|
+
// Singleton — satu engine per build process
|
|
773
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
774
|
+
|
|
775
|
+
let _engine: IncrementalEngine | null = null
|
|
776
|
+
|
|
777
|
+
export function getIncrementalEngine(opts?: IncrementalEngineOptions): IncrementalEngine {
|
|
778
|
+
if (!_engine) {
|
|
779
|
+
_engine = new IncrementalEngine(opts)
|
|
780
|
+
}
|
|
781
|
+
return _engine
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export function resetIncrementalEngine(): void {
|
|
785
|
+
_engine = null
|
|
786
|
+
}
|