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