@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,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-styled-v4 — classExtractor
|
|
3
|
+
*
|
|
4
|
+
* FIX #02: Remove .slice(0, -1) workaround for broken TEMPLATE_RE.
|
|
5
|
+
* TEMPLATE_RE trailing space is now fixed in twDetector.ts.
|
|
6
|
+
*
|
|
7
|
+
* Ekstrak semua Tailwind class dari source untuk safelist generation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { parseComponentConfig } from "./astParser"
|
|
11
|
+
import { EXTEND_RE, OBJECT_RE, TEMPLATE_RE } from "./twDetector"
|
|
12
|
+
|
|
13
|
+
const VALID_CLASS_RE = /^[-a-z0-9:/[\]!.()+%]+$/
|
|
14
|
+
|
|
15
|
+
function parseClasses(raw: string): string[] {
|
|
16
|
+
return raw
|
|
17
|
+
.split(/[\n\s]+/)
|
|
18
|
+
.map((c) => c.trim())
|
|
19
|
+
.filter((c) => c.length > 0 && VALID_CLASS_RE.test(c))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function extractAllClasses(source: string): string[] {
|
|
23
|
+
const classes = new Set<string>()
|
|
24
|
+
const add = (str: string) => {
|
|
25
|
+
for (const c of parseClasses(str)) classes.add(c)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let m: RegExpExecArray | null
|
|
29
|
+
|
|
30
|
+
// FIX #02: Use TEMPLATE_RE directly — no more .slice(0, -1) workaround
|
|
31
|
+
// because trailing space in TEMPLATE_RE is now fixed in twDetector.ts
|
|
32
|
+
const re1 = new RegExp(TEMPLATE_RE.source, "g")
|
|
33
|
+
while ((m = re1.exec(source)) !== null) {
|
|
34
|
+
add(m[3]) // group 3 = content (after adding server. group to TEMPLATE_RE)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// UPGRADE #4: Use proper AST parser for object configs
|
|
38
|
+
const re2 = new RegExp(OBJECT_RE.source, "g")
|
|
39
|
+
while ((m = re2.exec(source)) !== null) {
|
|
40
|
+
const parsed = parseComponentConfig(m[3])
|
|
41
|
+
if (parsed.base) add(parsed.base)
|
|
42
|
+
for (const vMap of Object.values(parsed.variants)) {
|
|
43
|
+
for (const cls of Object.values(vMap)) add(cls)
|
|
44
|
+
}
|
|
45
|
+
for (const compound of parsed.compounds) {
|
|
46
|
+
if (compound.class) add(compound.class)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const re3 = new RegExp(EXTEND_RE.source, "g")
|
|
51
|
+
while ((m = re3.exec(source)) !== null) add(m[2])
|
|
52
|
+
|
|
53
|
+
// className="..." in JSX
|
|
54
|
+
const classNameRe = /className\s*=\s*["']([^"']+)["']/g
|
|
55
|
+
while ((m = classNameRe.exec(source)) !== null) add(m[1])
|
|
56
|
+
|
|
57
|
+
return Array.from(classes).sort()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { parseClasses }
|
|
61
|
+
// Re-export for backward compat — now use parseComponentConfig from astParser
|
|
62
|
+
export function extractBaseFromObject(objectStr: string): string {
|
|
63
|
+
return parseComponentConfig(objectStr).base
|
|
64
|
+
}
|
|
65
|
+
export function extractVariantsFromObject(
|
|
66
|
+
objectStr: string
|
|
67
|
+
): Record<string, Record<string, string>> {
|
|
68
|
+
return parseComponentConfig(objectStr).variants
|
|
69
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-styled-v4 — classMerger
|
|
3
|
+
*
|
|
4
|
+
* FIX #05: Ganti custom UTILITY_GROUPS resolver dengan twMerge.
|
|
5
|
+
*
|
|
6
|
+
* WHY: Custom regex resolver memiliki banyak edge case yang salah
|
|
7
|
+
* (ring-, text- grouping, dll). tailwind-merge sudah jadi dependency,
|
|
8
|
+
* lebih akurat, dan di-maintain oleh komunitas Tailwind.
|
|
9
|
+
*
|
|
10
|
+
* RESULT: Output compile-time dan runtime kini identik — tidak ada
|
|
11
|
+
* behavior perbedaan antara dev mode dan production build.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { twMerge } from "tailwind-merge"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Merge Tailwind classes statically at compile time.
|
|
18
|
+
* Menggunakan tailwind-merge untuk conflict resolution yang akurat.
|
|
19
|
+
*
|
|
20
|
+
* FIX #05: Sebelumnya pakai custom UTILITY_GROUPS regex yang tidak
|
|
21
|
+
* kompatibel dengan tailwind-merge runtime. Sekarang keduanya identik.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* mergeClassesStatic("p-4 p-2 bg-red-500 bg-blue-500")
|
|
25
|
+
* → "p-2 bg-blue-500"
|
|
26
|
+
*
|
|
27
|
+
* mergeClassesStatic("ring-2 ring-4")
|
|
28
|
+
* → "ring-4" ✓ (custom resolver dulu return "ring-2 ring-4" — salah!)
|
|
29
|
+
*/
|
|
30
|
+
export function mergeClassesStatic(classes: string): string {
|
|
31
|
+
return twMerge(classes)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Normalize raw class string — trim, dedupe whitespace, join lines.
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeClasses(raw: string): string {
|
|
38
|
+
return raw
|
|
39
|
+
.split("\n")
|
|
40
|
+
.map((l) => l.trim())
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.join(" ")
|
|
43
|
+
.replace(/\s+/g, " ")
|
|
44
|
+
.trim()
|
|
45
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-styled-v4 — componentGenerator
|
|
3
|
+
*
|
|
4
|
+
* Mengubah hasil compiler menjadi static React component.
|
|
5
|
+
* Output tidak butuh runtime variant engine — pure className props.
|
|
6
|
+
*
|
|
7
|
+
* Input: tw.div`p-4 bg-white`
|
|
8
|
+
* Output: React.forwardRef((props, ref) => <div ref={ref} {...props} className={twMerge("p-4 bg-white", props.className)} />)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { normalizeClasses } from "./classMerger"
|
|
12
|
+
|
|
13
|
+
export interface GenerateOptions {
|
|
14
|
+
/** tambah RSC boundary hint */
|
|
15
|
+
rscHint?: boolean
|
|
16
|
+
/** tambah "use client" jika ada interactive features */
|
|
17
|
+
autoClientBoundary?: boolean
|
|
18
|
+
/** tambah data-tw debug attribute */
|
|
19
|
+
addDataAttr?: boolean
|
|
20
|
+
/** hash untuk component ID */
|
|
21
|
+
hash?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate static component untuk tw.tag`classes`
|
|
26
|
+
*/
|
|
27
|
+
export function generateStaticComponent(
|
|
28
|
+
varName: string,
|
|
29
|
+
tag: string,
|
|
30
|
+
classes: string,
|
|
31
|
+
opts: GenerateOptions = {}
|
|
32
|
+
): string {
|
|
33
|
+
const normalized = normalizeClasses(classes)
|
|
34
|
+
const dataAttr = opts.addDataAttr && opts.hash ? ` "data-tw": "${opts.hash}",` : ""
|
|
35
|
+
|
|
36
|
+
return `const ${varName} = /*@tw-static*/ React.forwardRef(function ${varName}(props, ref) {
|
|
37
|
+
const { className, ...rest } = props;
|
|
38
|
+
return React.createElement("${tag}", {
|
|
39
|
+
ref,
|
|
40
|
+
...rest,${dataAttr}
|
|
41
|
+
className: [${JSON.stringify(normalized)}, className].filter(Boolean).join(" "),
|
|
42
|
+
});
|
|
43
|
+
});`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate variant component untuk tw.tag({ base, variants })
|
|
48
|
+
*/
|
|
49
|
+
export function generateVariantComponent(
|
|
50
|
+
varName: string,
|
|
51
|
+
tag: string,
|
|
52
|
+
id: string,
|
|
53
|
+
base: string,
|
|
54
|
+
defaultVariants: Record<string, string>,
|
|
55
|
+
variantKeys: string[],
|
|
56
|
+
opts: GenerateOptions = {}
|
|
57
|
+
): string {
|
|
58
|
+
const dataAttr = opts.addDataAttr ? ` "data-tw": "${varName}",` : ""
|
|
59
|
+
const _defaults = JSON.stringify(defaultVariants)
|
|
60
|
+
|
|
61
|
+
const variantResolution = variantKeys
|
|
62
|
+
.map((k) => `const __v_${k} = props.${k} ?? ${JSON.stringify(defaultVariants[k] ?? null)};`)
|
|
63
|
+
.join("\n ")
|
|
64
|
+
|
|
65
|
+
const classLookups = variantKeys.map((k) => `(__vt_${id}.${k}?.[__v_${k}] ?? "")`).join(", ")
|
|
66
|
+
|
|
67
|
+
return `const ${varName} = /*@tw-variant*/ React.forwardRef(function ${varName}(props, ref) {
|
|
68
|
+
const { className, ${variantKeys.join(", ")}, ...rest } = props;
|
|
69
|
+
${variantResolution}
|
|
70
|
+
const __cls = [${JSON.stringify(base)}, ${classLookups}, className].filter(Boolean).join(" ");
|
|
71
|
+
return React.createElement("${tag}", {
|
|
72
|
+
ref,
|
|
73
|
+
...rest,${dataAttr}
|
|
74
|
+
className: __cls,
|
|
75
|
+
});
|
|
76
|
+
});`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Generate "use client" directive if interactive features detected
|
|
81
|
+
*/
|
|
82
|
+
export function maybeClientDirective(source: string, classes: string): string {
|
|
83
|
+
const interactive =
|
|
84
|
+
/\b(hover:|focus:|active:|group-hover:|peer-|useState|useEffect|useRef|onClick|onChange)\b/
|
|
85
|
+
if (interactive.test(classes) || interactive.test(source)) {
|
|
86
|
+
if (!source.startsWith('"use client"') && !source.startsWith("'use client'")) {
|
|
87
|
+
return '"use client";\n'
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return ""
|
|
91
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-styled-v4 — Component Hoister
|
|
3
|
+
*
|
|
4
|
+
* Problem: Component yang didefinisikan di dalam fungsi lain
|
|
5
|
+
* akan direcreate setiap render — sangat buruk untuk performa.
|
|
6
|
+
*
|
|
7
|
+
* BEFORE (buruk):
|
|
8
|
+
* export default function Page() {
|
|
9
|
+
* const Box = tw.div`p-4` ← dibuat ulang tiap render!
|
|
10
|
+
* return <Box/>
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* AFTER (benar):
|
|
14
|
+
* const Box = tw.div`p-4` ← module scope, dibuat sekali
|
|
15
|
+
* export default function Page() {
|
|
16
|
+
* return <Box/>
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* Hoister mendeteksi pola ini dan memindahkan deklarasi ke module scope.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
// Patterns
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
// Match: const Name = tw.tag`...` atau const Name = tw.tag({...})
|
|
27
|
+
// yang ada di dalam function body (indent > 0)
|
|
28
|
+
const INDENTED_TW_DECL_RE = /^([ \t]+)(const|let)\s+([A-Z]\w*)\s*=\s*tw\.[\w]+[`(]/gm
|
|
29
|
+
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
// Hoist analysis
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export interface HoistResult {
|
|
35
|
+
code: string
|
|
36
|
+
hoisted: string[]
|
|
37
|
+
warnings: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function hoistComponents(source: string): HoistResult {
|
|
41
|
+
const hoisted: string[] = []
|
|
42
|
+
const warnings: string[] = []
|
|
43
|
+
|
|
44
|
+
// Cari semua tw declarations yang indented (di dalam function body)
|
|
45
|
+
const indentedDecls: Array<{
|
|
46
|
+
fullMatch: string
|
|
47
|
+
indent: string
|
|
48
|
+
keyword: string
|
|
49
|
+
name: string
|
|
50
|
+
startIndex: number
|
|
51
|
+
}> = []
|
|
52
|
+
|
|
53
|
+
let m: RegExpExecArray | null
|
|
54
|
+
const re = new RegExp(INDENTED_TW_DECL_RE.source, "gm")
|
|
55
|
+
|
|
56
|
+
while ((m = re.exec(source)) !== null) {
|
|
57
|
+
const indent = m[1]
|
|
58
|
+
const keyword = m[2]
|
|
59
|
+
const name = m[3]
|
|
60
|
+
|
|
61
|
+
// Hanya hoist components (PascalCase), bukan variables biasa
|
|
62
|
+
if (!/^[A-Z]/.test(name)) continue
|
|
63
|
+
// Hanya hoist jika di dalam function (indent > 0)
|
|
64
|
+
if (indent.length === 0) continue
|
|
65
|
+
|
|
66
|
+
indentedDecls.push({
|
|
67
|
+
fullMatch: m[0],
|
|
68
|
+
indent,
|
|
69
|
+
keyword,
|
|
70
|
+
name,
|
|
71
|
+
startIndex: m.index,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (indentedDecls.length === 0) {
|
|
76
|
+
return { code: source, hoisted: [], warnings: [] }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Untuk setiap indented declaration, extract full statement
|
|
80
|
+
// dan pindahkan ke top of file
|
|
81
|
+
let code = source
|
|
82
|
+
const hoistedDecls: string[] = []
|
|
83
|
+
|
|
84
|
+
// Process in reverse order to maintain correct indices
|
|
85
|
+
for (const decl of [...indentedDecls].reverse()) {
|
|
86
|
+
const { startIndex, indent, name } = decl
|
|
87
|
+
|
|
88
|
+
// Cari end of the tw statement (sampai semicolon atau newline setelah `)`)
|
|
89
|
+
const lineStart = code.lastIndexOf("\n", startIndex) + 1
|
|
90
|
+
const restFromDecl = code.slice(lineStart)
|
|
91
|
+
|
|
92
|
+
// Extract full statement — bisa multi-line untuk template literals
|
|
93
|
+
const fullStmt = extractFullStatement(restFromDecl)
|
|
94
|
+
if (!fullStmt) continue
|
|
95
|
+
|
|
96
|
+
// Dedent statement
|
|
97
|
+
const dedented = fullStmt
|
|
98
|
+
.split("\n")
|
|
99
|
+
.map((line) => (line.startsWith(indent) ? line.slice(indent.length) : line))
|
|
100
|
+
.join("\n")
|
|
101
|
+
.trim()
|
|
102
|
+
|
|
103
|
+
// Remove from original position
|
|
104
|
+
code = code.slice(0, lineStart) + code.slice(lineStart + fullStmt.length)
|
|
105
|
+
|
|
106
|
+
// Collect for hoisting
|
|
107
|
+
hoistedDecls.unshift(dedented)
|
|
108
|
+
hoisted.push(name)
|
|
109
|
+
|
|
110
|
+
warnings.push(
|
|
111
|
+
`[tw-hoist] '${name}' moved to module scope for better performance. ` +
|
|
112
|
+
`Avoid defining tw components inside render functions.`
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Inject hoisted declarations after imports
|
|
117
|
+
if (hoistedDecls.length > 0) {
|
|
118
|
+
const insertPoint = findAfterImports(code)
|
|
119
|
+
const hoistBlock = `\n${hoistedDecls.join("\n\n")}\n`
|
|
120
|
+
code = code.slice(0, insertPoint) + hoistBlock + code.slice(insertPoint)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { code, hoisted, warnings }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
// Helpers
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
function extractFullStatement(source: string): string | null {
|
|
131
|
+
// Match tw template literal statement
|
|
132
|
+
const templateRe = /^[ \t]*(const|let)\s+\w+\s*=\s*tw\.\w+`[^`]*`.*\n?/
|
|
133
|
+
const templateMatch = source.match(templateRe)
|
|
134
|
+
if (templateMatch) return templateMatch[0]
|
|
135
|
+
|
|
136
|
+
// Match tw object config statement — may span multiple lines
|
|
137
|
+
// Find balancing braces
|
|
138
|
+
const objStart = source.indexOf("tw.")
|
|
139
|
+
if (objStart === -1) return null
|
|
140
|
+
|
|
141
|
+
const parenStart = source.indexOf("(", objStart)
|
|
142
|
+
if (parenStart === -1) return null
|
|
143
|
+
|
|
144
|
+
let depth = 0
|
|
145
|
+
let i = parenStart
|
|
146
|
+
|
|
147
|
+
while (i < source.length) {
|
|
148
|
+
if (source[i] === "(") depth++
|
|
149
|
+
if (source[i] === ")") {
|
|
150
|
+
depth--
|
|
151
|
+
if (depth === 0) {
|
|
152
|
+
// Include trailing semicolon and newline
|
|
153
|
+
const end = source.indexOf("\n", i)
|
|
154
|
+
return source.slice(0, end === -1 ? i + 1 : end + 1)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
i++
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function findAfterImports(source: string): number {
|
|
164
|
+
const lines = source.split("\n")
|
|
165
|
+
let lastImportLine = 0
|
|
166
|
+
|
|
167
|
+
for (let i = 0; i < lines.length; i++) {
|
|
168
|
+
const line = lines[i].trim()
|
|
169
|
+
if (
|
|
170
|
+
line.startsWith("import ") ||
|
|
171
|
+
line.startsWith("'use client'") ||
|
|
172
|
+
line.startsWith('"use client"')
|
|
173
|
+
) {
|
|
174
|
+
lastImportLine = i
|
|
175
|
+
} else if (line && !line.startsWith("//") && !line.startsWith("/*") && lastImportLine > 0) {
|
|
176
|
+
// First non-import, non-comment line after imports
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Return character index after last import line
|
|
182
|
+
return lines.slice(0, lastImportLine + 1).join("\n").length + 1
|
|
183
|
+
}
|