@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,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-styled-v4 — Embedded Tailwind Engine
|
|
3
|
+
*
|
|
4
|
+
* Compiler menjalankan Tailwind internally — tidak perlu tailwind CLI,
|
|
5
|
+
* tidak perlu postcss config manual.
|
|
6
|
+
*
|
|
7
|
+
* Cara kerja:
|
|
8
|
+
* 1. Compiler extract semua class dari source (via classExtractor)
|
|
9
|
+
* 2. Engine generate CSS hanya untuk class tersebut
|
|
10
|
+
* 3. CSS di-output per route (route-level CSS bundling)
|
|
11
|
+
*
|
|
12
|
+
* Ini membuat CSS output jauh lebih kecil:
|
|
13
|
+
* Tailwind normal: ~300kb global
|
|
14
|
+
* Route CSS: ~2–10kb per route
|
|
15
|
+
*
|
|
16
|
+
* Mode operasi:
|
|
17
|
+
* "jit" → generate CSS saat file berubah (dev mode)
|
|
18
|
+
* "build" → generate semua CSS di akhir build (production)
|
|
19
|
+
* "manual" → tidak generate, hanya extract (default untuk kompatibilitas)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import path from "node:path"
|
|
23
|
+
import { loadTailwindConfig } from "./loadTailwindConfig"
|
|
24
|
+
import { getAllRoutes, getRouteClasses } from "./routeCssCollector"
|
|
25
|
+
|
|
26
|
+
export type TailwindEngineMode = "jit" | "build" | "manual"
|
|
27
|
+
|
|
28
|
+
export interface TailwindEngineOptions {
|
|
29
|
+
mode?: TailwindEngineMode
|
|
30
|
+
cwd?: string
|
|
31
|
+
outputDir?: string
|
|
32
|
+
config?: Record<string, any>
|
|
33
|
+
minify?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CssGenerateResult {
|
|
37
|
+
route: string
|
|
38
|
+
css: string
|
|
39
|
+
classes: string[]
|
|
40
|
+
sizeBytes: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
// Core engine
|
|
45
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Try to use Tailwind's internal API for CSS generation.
|
|
49
|
+
* Fallback ke manual CSS generation jika Tailwind API tidak tersedia.
|
|
50
|
+
*
|
|
51
|
+
* NOTE: Tailwind v4 mengubah API internal — kita support keduanya.
|
|
52
|
+
*/
|
|
53
|
+
export async function generateCssForClasses(
|
|
54
|
+
classes: string[],
|
|
55
|
+
config?: Record<string, any>,
|
|
56
|
+
cwd = process.cwd()
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
const twConfig = config ?? loadTailwindConfig(cwd)
|
|
59
|
+
|
|
60
|
+
// Strategy 1: Tailwind v4 (@tailwindcss/postcss atau native)
|
|
61
|
+
try {
|
|
62
|
+
return await generateViaTailwindV4(classes, twConfig, cwd)
|
|
63
|
+
} catch {
|
|
64
|
+
// not available
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Strategy 2: Tailwind v3 API
|
|
68
|
+
try {
|
|
69
|
+
return await generateViaTailwindV3(classes, twConfig)
|
|
70
|
+
} catch {
|
|
71
|
+
// not available
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Strategy 3: Manual atomic CSS generation (always available)
|
|
75
|
+
return generateManualCss(classes)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Tailwind v4 CSS generation (via @tailwindcss/postcss)
|
|
80
|
+
*/
|
|
81
|
+
async function generateViaTailwindV4(
|
|
82
|
+
classes: string[],
|
|
83
|
+
_config: Record<string, any>,
|
|
84
|
+
cwd: string
|
|
85
|
+
): Promise<string> {
|
|
86
|
+
// Tailwind v4 uses @import "tailwindcss" syntax
|
|
87
|
+
// We generate a virtual CSS file that imports tailwindcss + safelist
|
|
88
|
+
const virtualCss = [
|
|
89
|
+
`@import "tailwindcss";`,
|
|
90
|
+
`@layer utilities {`,
|
|
91
|
+
` /* Generated by tailwind-styled-v4 */`,
|
|
92
|
+
`}`,
|
|
93
|
+
].join("\n")
|
|
94
|
+
|
|
95
|
+
const postcss = require("postcss")
|
|
96
|
+
const tailwindcss = require("@tailwindcss/postcss")
|
|
97
|
+
|
|
98
|
+
const result = await postcss([
|
|
99
|
+
tailwindcss({
|
|
100
|
+
optimize: { minify: false },
|
|
101
|
+
}),
|
|
102
|
+
]).process(virtualCss, {
|
|
103
|
+
from: path.join(cwd, "virtual.css"),
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Filter to only include classes we need
|
|
107
|
+
return filterCssForClasses(result.css, classes)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Tailwind v3 CSS generation (via tailwindcss package API)
|
|
112
|
+
*/
|
|
113
|
+
async function generateViaTailwindV3(
|
|
114
|
+
classes: string[],
|
|
115
|
+
config: Record<string, any>
|
|
116
|
+
): Promise<string> {
|
|
117
|
+
const postcss = require("postcss")
|
|
118
|
+
const tailwindcss = require("tailwindcss")
|
|
119
|
+
|
|
120
|
+
// Create virtual content with only our classes
|
|
121
|
+
const virtualContent = classes.map((c) => `<div class="${c}">`).join("\n")
|
|
122
|
+
|
|
123
|
+
const twConfigWithContent = {
|
|
124
|
+
...config,
|
|
125
|
+
content: [{ raw: virtualContent, extension: "html" }],
|
|
126
|
+
safelist: classes,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const inputCss = `@tailwind base;\n@tailwind components;\n@tailwind utilities;`
|
|
130
|
+
|
|
131
|
+
const result = await postcss([tailwindcss(twConfigWithContent)]).process(inputCss, {
|
|
132
|
+
from: undefined,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return result.css
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Manual atomic CSS generation — always available, no Tailwind dependency.
|
|
140
|
+
* Menggunakan AtomicCss module yang sudah ada.
|
|
141
|
+
*/
|
|
142
|
+
function generateManualCss(classes: string[]): string {
|
|
143
|
+
const { generateAtomicCss, parseAtomicClass } = require("./atomicCss")
|
|
144
|
+
|
|
145
|
+
const rules = classes.map((c: string) => parseAtomicClass(c)).filter(Boolean)
|
|
146
|
+
|
|
147
|
+
if (rules.length === 0) return ""
|
|
148
|
+
|
|
149
|
+
const header = `/* Generated by tailwind-styled-v4 — ${new Date().toISOString()} */\n`
|
|
150
|
+
return header + generateAtomicCss(rules)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Filter compiled Tailwind CSS to only include needed class selectors.
|
|
155
|
+
* Dipakai untuk optimize output CSS size.
|
|
156
|
+
*/
|
|
157
|
+
function filterCssForClasses(fullCss: string, classes: string[]): string {
|
|
158
|
+
// Keep @base rules + only selectors matching our classes
|
|
159
|
+
const _classSet = new Set(classes)
|
|
160
|
+
|
|
161
|
+
const lines = fullCss.split("\n")
|
|
162
|
+
const kept: string[] = []
|
|
163
|
+
let inBlock = false
|
|
164
|
+
let keepBlock = false
|
|
165
|
+
let braceDepth = 0
|
|
166
|
+
|
|
167
|
+
for (const line of lines) {
|
|
168
|
+
// Always keep @layer base (reset styles)
|
|
169
|
+
if (line.includes("@layer base") || line.includes("*, *::before")) {
|
|
170
|
+
kept.push(line)
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Check if line is a selector for one of our classes
|
|
175
|
+
if (!inBlock) {
|
|
176
|
+
const isOurClass = classes.some((cls) => {
|
|
177
|
+
const escaped = cls.replace(/[:/[\].!%]/g, "\\$&")
|
|
178
|
+
return line.includes(`.${escaped}`) || line.includes(`.${cls}`)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
if (isOurClass) {
|
|
182
|
+
keepBlock = true
|
|
183
|
+
inBlock = true
|
|
184
|
+
braceDepth = 0
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (inBlock) {
|
|
189
|
+
if (keepBlock) kept.push(line)
|
|
190
|
+
if (line.includes("{")) braceDepth++
|
|
191
|
+
if (line.includes("}")) {
|
|
192
|
+
braceDepth--
|
|
193
|
+
if (braceDepth <= 0) {
|
|
194
|
+
inBlock = false
|
|
195
|
+
keepBlock = false
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return kept.join("\n")
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
205
|
+
// Build-time CSS generation (dipakai di withTailwindStyled buildEnd hook)
|
|
206
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
export async function generateAllRouteCss(
|
|
209
|
+
opts: TailwindEngineOptions = {}
|
|
210
|
+
): Promise<CssGenerateResult[]> {
|
|
211
|
+
const { cwd = process.cwd(), outputDir, config, minify = true } = opts
|
|
212
|
+
|
|
213
|
+
const results: CssGenerateResult[] = []
|
|
214
|
+
const routes = getAllRoutes()
|
|
215
|
+
const twConfig = config ?? loadTailwindConfig(cwd)
|
|
216
|
+
|
|
217
|
+
for (const route of routes) {
|
|
218
|
+
const classes = Array.from(getRouteClasses(route))
|
|
219
|
+
if (classes.length === 0) continue
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
let css = await generateCssForClasses(classes, twConfig, cwd)
|
|
223
|
+
|
|
224
|
+
if (minify) {
|
|
225
|
+
css = minifyCss(css)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
results.push({
|
|
229
|
+
route,
|
|
230
|
+
css,
|
|
231
|
+
classes,
|
|
232
|
+
sizeBytes: Buffer.byteLength(css, "utf8"),
|
|
233
|
+
})
|
|
234
|
+
} catch (e) {
|
|
235
|
+
console.warn(`[tailwind-styled-v4] CSS generation failed for route ${route}:`, e)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Emit CSS files if outputDir provided
|
|
240
|
+
if (outputDir) {
|
|
241
|
+
const fs = require("node:fs")
|
|
242
|
+
fs.mkdirSync(outputDir, { recursive: true })
|
|
243
|
+
|
|
244
|
+
for (const result of results) {
|
|
245
|
+
const filename = routeToFilename(result.route)
|
|
246
|
+
const filepath = path.join(outputDir, filename)
|
|
247
|
+
fs.mkdirSync(path.dirname(filepath), { recursive: true })
|
|
248
|
+
fs.writeFileSync(filepath, result.css)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const totalSize = results.reduce((sum, r) => sum + r.sizeBytes, 0)
|
|
252
|
+
console.log(
|
|
253
|
+
`[tailwind-styled-v4] Route CSS generated: ${results.length} routes, ${formatBytes(totalSize)} total`
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return results
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
261
|
+
// Helpers
|
|
262
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
function routeToFilename(route: string): string {
|
|
265
|
+
if (route === "/") return "index.css"
|
|
266
|
+
if (route === "__global") return "_global.css"
|
|
267
|
+
return `${route.replace(/^\//, "").replace(/\//g, "_")}.css`
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function minifyCss(css: string): string {
|
|
271
|
+
return css
|
|
272
|
+
.replace(/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g, "") // remove comments
|
|
273
|
+
.replace(/\s+/g, " ") // collapse whitespace
|
|
274
|
+
.replace(/\s*{\s*/g, "{")
|
|
275
|
+
.replace(/\s*}\s*/g, "}")
|
|
276
|
+
.replace(/\s*:\s*/g, ":")
|
|
277
|
+
.replace(/\s*;\s*/g, ";")
|
|
278
|
+
.trim()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function formatBytes(bytes: number): string {
|
|
282
|
+
if (bytes < 1024) return `${bytes}B`
|
|
283
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
|
284
|
+
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
|
285
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-styled-v4 — twDetector
|
|
3
|
+
*
|
|
4
|
+
* Regex-based detector untuk semua syntax tw yang valid.
|
|
5
|
+
* Dipakai sebelum transform — jika tidak ada tw usage, skip file.
|
|
6
|
+
*
|
|
7
|
+
* FIXED: trailing space bug di TEMPLATE_RE (#02)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** tw.div`...` — FIX: removed trailing space before /g */
|
|
11
|
+
export const TEMPLATE_RE = /\btw\.(server\.)?(\w+)`((?:[^`\\]|\\.)*)`/g
|
|
12
|
+
|
|
13
|
+
/** tw.div({ base: "...", variants: {...} }) */
|
|
14
|
+
export const OBJECT_RE = /\btw\.(server\.)?(\w+)\(\s*(\{[\s\S]*?\})\s*\)/g
|
|
15
|
+
|
|
16
|
+
/** tw(Component)`...` */
|
|
17
|
+
export const WRAP_RE = /\btw\((\w+)\)`((?:[^`\\]|\\.)*)`/g
|
|
18
|
+
|
|
19
|
+
/** Card.extend`...` */
|
|
20
|
+
export const EXTEND_RE = /(\w+)\.extend`((?:[^`\\]|\\.)*)`/g
|
|
21
|
+
|
|
22
|
+
/** import { tw } from "tailwind-styled-v4" */
|
|
23
|
+
export const IMPORT_RE = /from\s*["']tailwind-styled-v4["']/
|
|
24
|
+
|
|
25
|
+
/** Transform already-applied marker — idempotency guard (#08) */
|
|
26
|
+
export const TRANSFORM_MARKER = "/* @tw-transformed */"
|
|
27
|
+
|
|
28
|
+
export function hasTwUsage(source: string): boolean {
|
|
29
|
+
return IMPORT_RE.test(source) || source.includes("tw.")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Check if file was already transformed — prevents double processing (#08) */
|
|
33
|
+
export function isAlreadyTransformed(source: string): boolean {
|
|
34
|
+
return source.includes(TRANSFORM_MARKER)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isTwTemplateLiteral(source: string, index: number): boolean {
|
|
38
|
+
const before = source.slice(Math.max(0, index - 20), index)
|
|
39
|
+
return /\btw\.\w+$/.test(before) || /\btw\(\w+\)$/.test(before)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isDynamic(content: string): boolean {
|
|
43
|
+
return content.includes("${")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isServerComponent(source: string): boolean {
|
|
47
|
+
return !source.includes('"use client"') && !source.includes("'use client'")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function hasInteractiveFeatures(content: string): boolean {
|
|
51
|
+
return /\b(hover:|focus:|active:|group-hover:|peer-|on[A-Z]|useState|useEffect|useRef)\b/.test(
|
|
52
|
+
content
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-styled-v4 — variantCompiler
|
|
3
|
+
*
|
|
4
|
+
* FIXES:
|
|
5
|
+
* #01 — Don't pre-merge base into variant table values (double-merge bug)
|
|
6
|
+
* #06 — Use proper AST parser instead of fragile regex
|
|
7
|
+
*
|
|
8
|
+
* BEFORE (double-merge):
|
|
9
|
+
* compileVariants: table["size"]["sm"] = "px-4 py-2 text-sm" ← base included
|
|
10
|
+
* astTransform: [base, table["size"][...]] ← base AGAIN → DUPE
|
|
11
|
+
*
|
|
12
|
+
* AFTER (correct):
|
|
13
|
+
* compileVariants: table["size"]["sm"] = "text-sm" ← variant only
|
|
14
|
+
* astTransform: [base, table["size"][...], className] ← base once, correct
|
|
15
|
+
*
|
|
16
|
+
* Input:
|
|
17
|
+
* { base: "px-4 py-2", variants: { size: { sm: "text-sm" } } }
|
|
18
|
+
*
|
|
19
|
+
* Output code:
|
|
20
|
+
* const __vt_abc123 = { size: { sm: "text-sm" } }
|
|
21
|
+
* // className = [base, table[variant]] → no duplication
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { parseComponentConfig } from "./astParser"
|
|
25
|
+
import { normalizeClasses } from "./classMerger"
|
|
26
|
+
|
|
27
|
+
export interface CompiledVariants {
|
|
28
|
+
base: string
|
|
29
|
+
table: Record<string, Record<string, string>>
|
|
30
|
+
compounds: Array<{ class: string; [key: string]: any }>
|
|
31
|
+
defaults: Record<string, string>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compile variant config into lookup table.
|
|
36
|
+
*
|
|
37
|
+
* FIX #01: Do NOT pre-merge base into table values.
|
|
38
|
+
* Table contains variant-specific classes only.
|
|
39
|
+
* Base is always injected separately in the component className array.
|
|
40
|
+
*/
|
|
41
|
+
export function compileVariants(
|
|
42
|
+
base: string,
|
|
43
|
+
variants: Record<string, Record<string, string>>,
|
|
44
|
+
compounds: Array<{ class: string; [key: string]: any }> = [],
|
|
45
|
+
defaults: Record<string, string> = {}
|
|
46
|
+
): CompiledVariants {
|
|
47
|
+
const table: Record<string, Record<string, string>> = {}
|
|
48
|
+
|
|
49
|
+
for (const key in variants) {
|
|
50
|
+
table[key] = {}
|
|
51
|
+
for (const val in variants[key]) {
|
|
52
|
+
// FIX #01: variant classes only — NOT merged with base
|
|
53
|
+
// Base is injected separately in renderVariantComponent
|
|
54
|
+
table[key][val] = normalizeClasses(variants[key][val])
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { base, table, compounds, defaults }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function generateVariantCode(id: string, compiled: CompiledVariants): string {
|
|
62
|
+
const { table, compounds, defaults } = compiled
|
|
63
|
+
|
|
64
|
+
const tableJson = JSON.stringify(table, null, 2)
|
|
65
|
+
const compoundsJson = JSON.stringify(compounds, null, 2)
|
|
66
|
+
const defaultsJson = JSON.stringify(defaults, null, 2)
|
|
67
|
+
|
|
68
|
+
return `const __vt_${id} = ${tableJson};
|
|
69
|
+
const __vc_${id} = ${compoundsJson};
|
|
70
|
+
const __vd_${id} = ${defaultsJson};`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse object config string.
|
|
75
|
+
* UPGRADE #4: Uses proper AST parser — handles all edge cases.
|
|
76
|
+
*
|
|
77
|
+
* FIX #02 (indirect): classExtractor no longer needs .slice(0, -1) workaround
|
|
78
|
+
* since TEMPLATE_RE trailing space is fixed in twDetector.ts
|
|
79
|
+
*/
|
|
80
|
+
export function parseObjectConfig(objectStr: string): {
|
|
81
|
+
base: string
|
|
82
|
+
variants: Record<string, Record<string, string>>
|
|
83
|
+
compounds: Array<{ class: string; [key: string]: any }>
|
|
84
|
+
defaults: Record<string, string>
|
|
85
|
+
} {
|
|
86
|
+
return parseComponentConfig(objectStr)
|
|
87
|
+
}
|