@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,359 @@
1
+ /**
2
+ * tailwind-styled-v4 v2 — AST Transform (RSC-Aware)
3
+ *
4
+ * FIXES:
5
+ * #01 — Double-merge base in variant component className array
6
+ * #08 — Idempotency guard — skip if already transformed
7
+ *
8
+ * Pipeline:
9
+ * source code
10
+ * ↓ idempotency check (new)
11
+ * ↓ analyze RSC context
12
+ * ↓ hoist components (if needed)
13
+ * ↓ detect tw.server.* vs tw.*
14
+ * ↓ extract + merge classes
15
+ * ↓ compile variants → lookup table (variant-only, no base dupe)
16
+ * ↓ generate React.forwardRef component
17
+ * ↓ auto "use client" if interactive
18
+ * ↓ strip tw import
19
+ * ↓ inject transform marker
20
+ */
21
+
22
+ import { normalizeClasses } from "./classMerger"
23
+ import { hoistComponents } from "./componentHoister"
24
+ import { analyzeFile, injectClientDirective } from "./rscAnalyzer"
25
+ import { hasTwUsage, isAlreadyTransformed, isDynamic, TRANSFORM_MARKER } from "./twDetector"
26
+ import { compileVariants, generateVariantCode, parseObjectConfig } from "./variantCompiler"
27
+
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+ // Types
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ export interface TransformOptions {
33
+ mode?: "zero-runtime" | "runtime" | "extract-only"
34
+ autoClientBoundary?: boolean
35
+ addDataAttr?: boolean
36
+ hoist?: boolean
37
+ filename?: string
38
+ }
39
+
40
+ export interface TransformResult {
41
+ code: string
42
+ classes: string[]
43
+ rsc?: {
44
+ isServer: boolean
45
+ needsClientDirective: boolean
46
+ clientReasons: string[]
47
+ }
48
+ changed: boolean
49
+ }
50
+
51
+ // ─────────────────────────────────────────────────────────────────────────────
52
+ // Patterns — updated to include server. group
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+
55
+ const TEMPLATE_RE = /\btw\.(server\.)?(\w+)`((?:[^`\\]|\\.)*)`/g
56
+ const OBJECT_RE = /\btw\.(server\.)?(\w+)\(\s*(\{[\s\S]*?\})\s*\)/g
57
+ const EXTEND_RE = /(\w+)\.extend`((?:[^`\\]|\\.)*)`/g
58
+ const WRAP_RE = /\btw\((\w+)\)`((?:[^`\\]|\\.)*)`/g
59
+
60
+ let _idCounter = 0
61
+ function genId(): string {
62
+ return `c${(++_idCounter).toString(36)}`
63
+ }
64
+
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+ // Static component output
67
+ // ─────────────────────────────────────────────────────────────────────────────
68
+
69
+ function renderStaticComponent(
70
+ tag: string,
71
+ classes: string,
72
+ opts: { addDataAttr: boolean; isServer: boolean; compName?: string }
73
+ ): string {
74
+ const { addDataAttr, compName } = opts
75
+ const fnName = compName ? `_Tw_${compName}` : `_Tw_${tag}`
76
+ const dataAttr = addDataAttr
77
+ ? `, "data-tw": "${fnName}:${classes.split(" ").slice(0, 3).join(" ")}${classes.split(" ").length > 3 ? "..." : ""}"`
78
+ : ""
79
+
80
+ return `React.forwardRef(function ${fnName}(props, ref) {
81
+ var _c = props.className;
82
+ var _r = Object.assign({}, props);
83
+ delete _r.className;
84
+ return React.createElement("${tag}", Object.assign({ ref }, _r${dataAttr}, { className: [${JSON.stringify(classes)}, _c].filter(Boolean).join(" ") }));
85
+ })`
86
+ }
87
+
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+ // Variant component output
90
+ //
91
+ // FIX #01: base is injected here in className array.
92
+ // lookup table contains ONLY variant-specific classes (not base).
93
+ // Previously: compileVariants pre-merged base into table → double base.
94
+ // ─────────────────────────────────────────────────────────────────────────────
95
+
96
+ function renderVariantComponent(
97
+ tag: string,
98
+ id: string,
99
+ base: string,
100
+ variantKeys: string[],
101
+ defaults: Record<string, string>,
102
+ opts: { addDataAttr: boolean; isServer: boolean }
103
+ ): string {
104
+ const { addDataAttr } = opts
105
+ const fnName = `_TwV_${tag}_${id}`
106
+ const dataAttr = addDataAttr ? `, "data-tw": "${fnName}"` : ""
107
+
108
+ // Destructure variant props to prevent leaking to DOM
109
+ const vKeys = variantKeys.map((k) => `"${k}"`).join(", ")
110
+ const destructure =
111
+ variantKeys.length > 0
112
+ ? `var _vp = {}; [${vKeys}].forEach(function(k){ _vp[k] = props[k]; delete _rest[k]; });`
113
+ : ""
114
+
115
+ // FIX #01: table values are variant-only (no base pre-merged).
116
+ // base is injected separately as first element — correct, no duplication.
117
+ const variantLookup =
118
+ variantKeys.length > 0
119
+ ? variantKeys
120
+ .map(
121
+ (k) =>
122
+ `(__vt_${id}["${k}"] && __vt_${id}["${k}"][_vp["${k}"] ?? ${JSON.stringify(defaults[k] ?? "")}] || "")`
123
+ )
124
+ .join(", ")
125
+ : ""
126
+
127
+ // FIX #01: [base, ...variantClasses, className] — base appears exactly once
128
+ const classParts =
129
+ variantKeys.length > 0
130
+ ? `[${JSON.stringify(base)}, ${variantLookup}, _rest.className]`
131
+ : `[${JSON.stringify(base)}, _rest.className]`
132
+
133
+ return `React.forwardRef(function ${fnName}(props, ref) {
134
+ var _rest = Object.assign({}, props);
135
+ delete _rest.className;
136
+ ${destructure}
137
+ return React.createElement("${tag}", Object.assign({ ref }, _rest${dataAttr}, { className: ${classParts}.filter(Boolean).join(" ") }));
138
+ })`
139
+ }
140
+
141
+ // ─────────────────────────────────────────────────────────────────────────────
142
+ // Main transform — RSC-Aware pipeline
143
+ // ─────────────────────────────────────────────────────────────────────────────
144
+
145
+ export function transformSource(source: string, opts: TransformOptions = {}): TransformResult {
146
+ const {
147
+ mode = "zero-runtime",
148
+ autoClientBoundary = true,
149
+ addDataAttr = false,
150
+ hoist = true,
151
+ filename = "",
152
+ } = opts
153
+
154
+ // ── Fast exits ────────────────────────────────────────────────────────
155
+ if (!hasTwUsage(source)) {
156
+ return { code: source, classes: [], changed: false }
157
+ }
158
+
159
+ // FIX #08: Idempotency guard — do not transform already-transformed code
160
+ if (isAlreadyTransformed(source)) {
161
+ return { code: source, classes: [], changed: false }
162
+ }
163
+
164
+ if (mode === "runtime" || mode === "extract-only") {
165
+ return { code: source, classes: [], changed: false }
166
+ }
167
+
168
+ // ── STEP 1: RSC Analysis ───────────────────────────────────────────────
169
+ const rscAnalysis = analyzeFile(source, filename)
170
+
171
+ // ── STEP 2: Component Hoisting ─────────────────────────────────────────
172
+ let code = source
173
+ if (hoist) {
174
+ const hoistResult = hoistComponents(source)
175
+ if (hoistResult.hoisted.length > 0) {
176
+ code = hoistResult.code
177
+ if (process.env.NODE_ENV !== "production") {
178
+ for (const w of hoistResult.warnings) {
179
+ console.warn(w)
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ let changed = false
186
+ const allClasses: string[] = []
187
+ const prelude: string[] = []
188
+ let needsReact = false
189
+
190
+ // ── STEP 3a: tw.tag`classes` → static forwardRef ───────────────────────
191
+ code = code.replace(
192
+ TEMPLATE_RE,
193
+ (match, serverMark: string | undefined, tag: string, content: string) => {
194
+ if (isDynamic(content)) return match
195
+
196
+ const classes = normalizeClasses(content)
197
+ if (!classes) return match
198
+
199
+ const isServerOnly = !!serverMark
200
+ allClasses.push(...classes.split(/\s+/).filter(Boolean))
201
+ changed = true
202
+ needsReact = true
203
+
204
+ const rendered = renderStaticComponent(tag, classes, {
205
+ addDataAttr,
206
+ isServer: rscAnalysis.isServer || isServerOnly,
207
+ })
208
+
209
+ return isServerOnly ? `/* @server-only */ ${rendered}` : rendered
210
+ }
211
+ )
212
+
213
+ // ── STEP 3b: tw.tag({...}) → lookup table + variant forwardRef ─────────
214
+ code = code.replace(
215
+ OBJECT_RE,
216
+ (match, serverMark: string | undefined, tag: string, objectStr: string) => {
217
+ const { base, variants, compounds, defaults } = parseObjectConfig(objectStr)
218
+ if (!base && Object.keys(variants).length === 0) return match
219
+
220
+ const isServerOnly = !!serverMark
221
+
222
+ allClasses.push(...base.split(/\s+/).filter(Boolean))
223
+ for (const vMap of Object.values(variants)) {
224
+ for (const cls of Object.values(vMap)) {
225
+ allClasses.push(...cls.split(/\s+/).filter(Boolean))
226
+ }
227
+ }
228
+
229
+ changed = true
230
+ needsReact = true
231
+
232
+ const id = genId()
233
+ // FIX #01: compileVariants no longer merges base into table
234
+ const compiled = compileVariants(base, variants, compounds, defaults)
235
+ prelude.push(generateVariantCode(id, compiled))
236
+
237
+ const variantKeys = Object.keys(variants)
238
+ const rendered = renderVariantComponent(tag, id, base, variantKeys, defaults, {
239
+ addDataAttr,
240
+ isServer: rscAnalysis.isServer || isServerOnly,
241
+ })
242
+
243
+ return isServerOnly ? `/* @server-only */ ${rendered}` : rendered
244
+ }
245
+ )
246
+
247
+ // ── STEP 3c: tw(Component)`classes` ─────────────────────────────────────
248
+ code = code.replace(WRAP_RE, (match, compName: string, content: string) => {
249
+ if (isDynamic(content)) return match
250
+
251
+ const classes = normalizeClasses(content)
252
+ if (!classes) return match
253
+
254
+ allClasses.push(...classes.split(/\s+/).filter(Boolean))
255
+ changed = true
256
+ needsReact = true
257
+
258
+ return `React.forwardRef(function _TwWrap_${compName}(props, ref) {
259
+ var _c = [${JSON.stringify(classes)}, props.className].filter(Boolean).join(" ");
260
+ return React.createElement(${compName}, Object.assign({}, props, { ref, className: _c }));
261
+ })`
262
+ })
263
+
264
+ // ── STEP 3d: Component.extend`classes` ──────────────────────────────────
265
+ code = code.replace(EXTEND_RE, (match, compName: string, content: string) => {
266
+ if (isDynamic(content)) return match
267
+
268
+ const extra = normalizeClasses(content)
269
+ if (!extra) return match
270
+
271
+ allClasses.push(...extra.split(/\s+/).filter(Boolean))
272
+ changed = true
273
+ needsReact = true
274
+
275
+ return `React.forwardRef(function _TwExt_${compName}(props, ref) {
276
+ var _c = [${JSON.stringify(extra)}, props.className].filter(Boolean).join(" ");
277
+ return React.createElement(${compName}, Object.assign({}, props, { ref, className: _c }));
278
+ })`
279
+ })
280
+
281
+ if (!changed) {
282
+ return { code: source, classes: [], rsc: rscAnalysis, changed: false }
283
+ }
284
+
285
+ // ── STEP 4: Inject variant lookup tables (prelude) ─────────────────────
286
+ if (prelude.length > 0) {
287
+ const importEnd = findAfterImports(code)
288
+ code = `${code.slice(0, importEnd)}\n${prelude.join("\n")}\n${code.slice(importEnd)}`
289
+ }
290
+
291
+ // ── STEP 5: Ensure React import ─────────────────────────────────────────
292
+ if (needsReact && !hasReactImport(source)) {
293
+ code = `import React from "react";\n${code}`
294
+ }
295
+
296
+ // ── STEP 6: RSC auto client boundary ────────────────────────────────────
297
+ if (autoClientBoundary && rscAnalysis.needsClientDirective) {
298
+ code = injectClientDirective(code)
299
+ }
300
+
301
+ // ── STEP 7: Strip tw import when fully transformed ──────────────────────
302
+ const stillUsesTw = /\btw\.(server\.)?\w+[`(]/.test(code) || /\btw\(\w+\)/.test(code)
303
+ if (!stillUsesTw) {
304
+ code = code.replace(
305
+ /import\s*\{[^}]*\btw\b[^}]*\}\s*from\s*["']tailwind-styled-v4["'];?\n?/g,
306
+ ""
307
+ )
308
+ }
309
+
310
+ // ── STEP 8: Inject transform marker (FIX #08 — idempotency) ─────────────
311
+ code = `${TRANSFORM_MARKER}\n${code}`
312
+
313
+ return {
314
+ code,
315
+ classes: Array.from(new Set(allClasses)),
316
+ rsc: {
317
+ isServer: rscAnalysis.isServer,
318
+ needsClientDirective: rscAnalysis.needsClientDirective,
319
+ clientReasons: rscAnalysis.clientReasons,
320
+ },
321
+ changed: true,
322
+ }
323
+ }
324
+
325
+ // ─────────────────────────────────────────────────────────────────────────────
326
+ // Helpers
327
+ // ─────────────────────────────────────────────────────────────────────────────
328
+
329
+ function hasReactImport(source: string): boolean {
330
+ return (
331
+ source.includes("import React") ||
332
+ source.includes("from 'react'") ||
333
+ source.includes('from "react"')
334
+ )
335
+ }
336
+
337
+ function findAfterImports(source: string): number {
338
+ const lines = source.split("\n")
339
+ let lastImportIdx = 0
340
+
341
+ for (let i = 0; i < lines.length; i++) {
342
+ const line = lines[i].trim()
343
+ if (
344
+ line.startsWith("import ") ||
345
+ line.startsWith('"use client"') ||
346
+ line.startsWith("'use client'") ||
347
+ line.startsWith(TRANSFORM_MARKER) ||
348
+ line === ""
349
+ ) {
350
+ lastImportIdx = i
351
+ } else if (line && !line.startsWith("//") && !line.startsWith("/*")) {
352
+ break
353
+ }
354
+ }
355
+
356
+ return lines.slice(0, lastImportIdx + 1).join("\n").length + 1
357
+ }
358
+
359
+ export { hasTwUsage as shouldProcess }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * tailwind-styled-v4 — Atomic CSS Mode (Optional)
3
+ *
4
+ * Mode opsional yang mengubah Tailwind classes menjadi atomic CSS rules.
5
+ * Mirip konsep StyleX dari Meta — setiap class menghasilkan satu CSS rule.
6
+ *
7
+ * Keuntungan:
8
+ * - CSS global deduplicated (p-4 hanya satu rule di seluruh app)
9
+ * - Bundle CSS lebih kecil untuk app besar
10
+ * - Zero duplicate styles
11
+ *
12
+ * Mode ini TIDAK mengganti Tailwind — tetap pakai Tailwind utilities,
13
+ * hanya menambahkan layer extraction untuk SSR streaming.
14
+ *
15
+ * Usage:
16
+ * withTailwindStyled({ atomic: true })(nextConfig)
17
+ */
18
+
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // Atomic class registry (singleton per build)
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ const REGISTRY = new Map<string, AtomicRule>()
24
+
25
+ export interface AtomicRule {
26
+ /** Original Tailwind class */
27
+ twClass: string
28
+ /** Generated atomic class name */
29
+ atomicName: string
30
+ /** CSS property */
31
+ property: string
32
+ /** CSS value */
33
+ value: string
34
+ /** Modifier (hover:, md:, etc.) */
35
+ modifier?: string
36
+ }
37
+
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+ // Tailwind → CSS property mapping
40
+ // (subset — full mapping lebih baik generate dari Tailwind JIT)
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+
43
+ const TW_PROPERTY_MAP: Record<string, { prop: string; transform?: (val: string) => string }> = {
44
+ // Spacing
45
+ p: { prop: "padding", transform: (v) => `${Number(v) * 0.25}rem` },
46
+ px: { prop: "padding-inline", transform: (v) => `${Number(v) * 0.25}rem` },
47
+ py: { prop: "padding-block", transform: (v) => `${Number(v) * 0.25}rem` },
48
+ pt: { prop: "padding-top", transform: (v) => `${Number(v) * 0.25}rem` },
49
+ pb: { prop: "padding-bottom", transform: (v) => `${Number(v) * 0.25}rem` },
50
+ pl: { prop: "padding-left", transform: (v) => `${Number(v) * 0.25}rem` },
51
+ pr: { prop: "padding-right", transform: (v) => `${Number(v) * 0.25}rem` },
52
+ m: { prop: "margin", transform: (v) => `${Number(v) * 0.25}rem` },
53
+ mx: { prop: "margin-inline", transform: (v) => `${Number(v) * 0.25}rem` },
54
+ my: { prop: "margin-block", transform: (v) => `${Number(v) * 0.25}rem` },
55
+ mt: { prop: "margin-top", transform: (v) => `${Number(v) * 0.25}rem` },
56
+ mb: { prop: "margin-bottom", transform: (v) => `${Number(v) * 0.25}rem` },
57
+ ml: { prop: "margin-left", transform: (v) => `${Number(v) * 0.25}rem` },
58
+ mr: { prop: "margin-right", transform: (v) => `${Number(v) * 0.25}rem` },
59
+ gap: { prop: "gap", transform: (v) => `${Number(v) * 0.25}rem` },
60
+ // Sizing
61
+ w: { prop: "width", transform: sizeValue },
62
+ h: { prop: "height", transform: sizeValue },
63
+ // Typography
64
+ text: { prop: "font-size", transform: textSize },
65
+ font: { prop: "font-weight", transform: fontWeight },
66
+ leading: { prop: "line-height", transform: leadingValue },
67
+ // Misc
68
+ opacity: { prop: "opacity", transform: (v) => String(Number(v) / 100) },
69
+ z: { prop: "z-index" },
70
+ rounded: { prop: "border-radius", transform: (v) => roundedValue(v) },
71
+ }
72
+
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+ // Helpers
75
+ // ─────────────────────────────────────────────────────────────────────────────
76
+
77
+ function sizeValue(v: string): string {
78
+ const num = Number(v)
79
+ if (!Number.isNaN(num)) return `${num * 0.25}rem`
80
+ const special: Record<string, string> = {
81
+ full: "100%",
82
+ screen: "100vw",
83
+ auto: "auto",
84
+ min: "min-content",
85
+ max: "max-content",
86
+ fit: "fit-content",
87
+ svw: "100svw",
88
+ svh: "100svh",
89
+ }
90
+ return special[v] ?? v
91
+ }
92
+
93
+ function textSize(v: string): string {
94
+ const map: Record<string, string> = {
95
+ xs: "0.75rem",
96
+ sm: "0.875rem",
97
+ base: "1rem",
98
+ lg: "1.125rem",
99
+ xl: "1.25rem",
100
+ "2xl": "1.5rem",
101
+ "3xl": "1.875rem",
102
+ "4xl": "2.25rem",
103
+ "5xl": "3rem",
104
+ "6xl": "3.75rem",
105
+ "7xl": "4.5rem",
106
+ "8xl": "6rem",
107
+ "9xl": "8rem",
108
+ }
109
+ return map[v] ?? v
110
+ }
111
+
112
+ function fontWeight(v: string): string {
113
+ const map: Record<string, string> = {
114
+ thin: "100",
115
+ extralight: "200",
116
+ light: "300",
117
+ normal: "400",
118
+ medium: "500",
119
+ semibold: "600",
120
+ bold: "700",
121
+ extrabold: "800",
122
+ black: "900",
123
+ }
124
+ return map[v] ?? v
125
+ }
126
+
127
+ function leadingValue(v: string): string {
128
+ const map: Record<string, string> = {
129
+ none: "1",
130
+ tight: "1.25",
131
+ snug: "1.375",
132
+ normal: "1.5",
133
+ relaxed: "1.625",
134
+ loose: "2",
135
+ }
136
+ return map[v] ?? v
137
+ }
138
+
139
+ function roundedValue(v: string): string {
140
+ const map: Record<string, string> = {
141
+ "": "0.25rem",
142
+ sm: "0.125rem",
143
+ md: "0.375rem",
144
+ lg: "0.5rem",
145
+ xl: "0.75rem",
146
+ "2xl": "1rem",
147
+ "3xl": "1.5rem",
148
+ full: "9999px",
149
+ none: "0",
150
+ }
151
+ return map[v] ?? `${v}rem`
152
+ }
153
+
154
+ function sanitizeClassName(cls: string): string {
155
+ return cls.replace(/[/:[\].!%]/g, "_")
156
+ }
157
+
158
+ // ─────────────────────────────────────────────────────────────────────────────
159
+ // Main: parse a Tailwind class into atomic rule
160
+ // ─────────────────────────────────────────────────────────────────────────────
161
+
162
+ export function parseAtomicClass(twClass: string): AtomicRule | null {
163
+ if (REGISTRY.has(twClass)) return REGISTRY.get(twClass)!
164
+
165
+ // Strip modifier (hover:, md:, etc.)
166
+ const colonIdx = twClass.lastIndexOf(":")
167
+ const modifier = colonIdx > -1 ? twClass.slice(0, colonIdx) : undefined
168
+ const base = colonIdx > -1 ? twClass.slice(colonIdx + 1) : twClass
169
+
170
+ // Parse prefix and value
171
+ const dashIdx = base.indexOf("-")
172
+ if (dashIdx === -1) return null
173
+
174
+ const prefix = base.slice(0, dashIdx)
175
+ const value = base.slice(dashIdx + 1)
176
+
177
+ const mapping = TW_PROPERTY_MAP[prefix]
178
+ if (!mapping) return null
179
+
180
+ const cssValue = mapping.transform ? mapping.transform(value) : value
181
+ const atomicName = `_tw_${sanitizeClassName(twClass)}`
182
+
183
+ const rule: AtomicRule = {
184
+ twClass,
185
+ atomicName,
186
+ property: mapping.prop,
187
+ value: cssValue,
188
+ modifier,
189
+ }
190
+
191
+ REGISTRY.set(twClass, rule)
192
+ return rule
193
+ }
194
+
195
+ // ─────────────────────────────────────────────────────────────────────────────
196
+ // Generate CSS string for a set of atomic rules
197
+ // ─────────────────────────────────────────────────────────────────────────────
198
+
199
+ export function generateAtomicCss(rules: AtomicRule[]): string {
200
+ const lines: string[] = []
201
+
202
+ for (const rule of rules) {
203
+ const selector = `.${rule.atomicName}`
204
+
205
+ if (rule.modifier) {
206
+ // Responsive modifiers
207
+ const breakpoints: Record<string, string> = {
208
+ sm: "640px",
209
+ md: "768px",
210
+ lg: "1024px",
211
+ xl: "1280px",
212
+ "2xl": "1536px",
213
+ }
214
+ if (breakpoints[rule.modifier]) {
215
+ lines.push(
216
+ `@media (min-width: ${breakpoints[rule.modifier]}) {`,
217
+ ` ${selector} { ${rule.property}: ${rule.value}; }`,
218
+ `}`
219
+ )
220
+ continue
221
+ }
222
+ // Pseudo-class modifiers
223
+ lines.push(`${selector}:${rule.modifier} { ${rule.property}: ${rule.value}; }`)
224
+ } else {
225
+ lines.push(`${selector} { ${rule.property}: ${rule.value}; }`)
226
+ }
227
+ }
228
+
229
+ return lines.join("\n")
230
+ }
231
+
232
+ // ─────────────────────────────────────────────────────────────────────────────
233
+ // Convert class string → atomic class string
234
+ // ─────────────────────────────────────────────────────────────────────────────
235
+
236
+ export function toAtomicClasses(twClasses: string): {
237
+ atomicClasses: string
238
+ rules: AtomicRule[]
239
+ unknownClasses: string[]
240
+ } {
241
+ const parts = twClasses.split(/\s+/).filter(Boolean)
242
+ const atomicNames: string[] = []
243
+ const rules: AtomicRule[] = []
244
+ const unknownClasses: string[] = []
245
+
246
+ for (const cls of parts) {
247
+ const rule = parseAtomicClass(cls)
248
+ if (rule) {
249
+ atomicNames.push(rule.atomicName)
250
+ rules.push(rule)
251
+ } else {
252
+ // Unknown class — keep as-is (Tailwind will handle it)
253
+ unknownClasses.push(cls)
254
+ atomicNames.push(cls)
255
+ }
256
+ }
257
+
258
+ return {
259
+ atomicClasses: atomicNames.join(" "),
260
+ rules,
261
+ unknownClasses,
262
+ }
263
+ }
264
+
265
+ // ─────────────────────────────────────────────────────────────────────────────
266
+ // Export registry for safelist / CSS file generation
267
+ // ─────────────────────────────────────────────────────────────────────────────
268
+
269
+ export function getAtomicRegistry(): Map<string, AtomicRule> {
270
+ return REGISTRY
271
+ }
272
+
273
+ export function clearAtomicRegistry(): void {
274
+ REGISTRY.clear()
275
+ }