@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,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 }
|
package/src/atomicCss.ts
ADDED
|
@@ -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
|
+
}
|