@decocms/blocks-cli 7.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.
Files changed (93) hide show
  1. package/package.json +44 -0
  2. package/scripts/analyze-traces.mjs +1117 -0
  3. package/scripts/audit-observability-config.test.ts +446 -0
  4. package/scripts/audit-observability-config.ts +511 -0
  5. package/scripts/deco-migrate-cli.ts +444 -0
  6. package/scripts/fast-deploy-kv.test.ts +131 -0
  7. package/scripts/generate-blocks.test.ts +94 -0
  8. package/scripts/generate-blocks.ts +274 -0
  9. package/scripts/generate-invoke.test.ts +195 -0
  10. package/scripts/generate-invoke.ts +469 -0
  11. package/scripts/generate-loaders.ts +217 -0
  12. package/scripts/generate-schema.ts +1287 -0
  13. package/scripts/generate-sections.ts +237 -0
  14. package/scripts/htmx-analyze.ts +226 -0
  15. package/scripts/lib/blocks-dedupe.test.ts +179 -0
  16. package/scripts/lib/blocks-dedupe.ts +142 -0
  17. package/scripts/lib/cf-kv-rest.ts +78 -0
  18. package/scripts/lib/jsonc.ts +122 -0
  19. package/scripts/lib/kv-snapshot.ts +51 -0
  20. package/scripts/lib/read-decofile.ts +70 -0
  21. package/scripts/lib/sync-helpers.ts +44 -0
  22. package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
  23. package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
  24. package/scripts/migrate/analyzers/island-classifier.ts +96 -0
  25. package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
  26. package/scripts/migrate/analyzers/section-metadata.ts +147 -0
  27. package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
  28. package/scripts/migrate/colors.ts +46 -0
  29. package/scripts/migrate/config.test.ts +202 -0
  30. package/scripts/migrate/config.ts +186 -0
  31. package/scripts/migrate/phase-analyze.test.ts +63 -0
  32. package/scripts/migrate/phase-analyze.ts +782 -0
  33. package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
  34. package/scripts/migrate/phase-cleanup-audit.ts +105 -0
  35. package/scripts/migrate/phase-cleanup.test.ts +141 -0
  36. package/scripts/migrate/phase-cleanup.ts +1588 -0
  37. package/scripts/migrate/phase-compile.test.ts +193 -0
  38. package/scripts/migrate/phase-compile.ts +177 -0
  39. package/scripts/migrate/phase-report.ts +243 -0
  40. package/scripts/migrate/phase-scaffold.ts +593 -0
  41. package/scripts/migrate/phase-transform.ts +310 -0
  42. package/scripts/migrate/phase-verify.test.ts +127 -0
  43. package/scripts/migrate/phase-verify.ts +572 -0
  44. package/scripts/migrate/post-cleanup/rules.ts +1708 -0
  45. package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
  46. package/scripts/migrate/post-cleanup/runner.ts +137 -0
  47. package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
  48. package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
  49. package/scripts/migrate/post-cleanup/types.ts +106 -0
  50. package/scripts/migrate/source-layout.test.ts +111 -0
  51. package/scripts/migrate/source-layout.ts +103 -0
  52. package/scripts/migrate/templates/app-css.ts +366 -0
  53. package/scripts/migrate/templates/cache-config.ts +26 -0
  54. package/scripts/migrate/templates/commerce-loaders.ts +230 -0
  55. package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
  56. package/scripts/migrate/templates/cursor-rules.ts +70 -0
  57. package/scripts/migrate/templates/hooks.test.ts +141 -0
  58. package/scripts/migrate/templates/hooks.ts +152 -0
  59. package/scripts/migrate/templates/knip-config.ts +27 -0
  60. package/scripts/migrate/templates/lib-utils.test.ts +139 -0
  61. package/scripts/migrate/templates/lib-utils.ts +326 -0
  62. package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
  63. package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
  64. package/scripts/migrate/templates/package-json.ts +177 -0
  65. package/scripts/migrate/templates/routes.ts +237 -0
  66. package/scripts/migrate/templates/sdk-gen.ts +59 -0
  67. package/scripts/migrate/templates/section-loaders.ts +456 -0
  68. package/scripts/migrate/templates/server-entry.ts +561 -0
  69. package/scripts/migrate/templates/setup.ts +148 -0
  70. package/scripts/migrate/templates/tsconfig.ts +21 -0
  71. package/scripts/migrate/templates/types-gen.ts +174 -0
  72. package/scripts/migrate/templates/ui-components.ts +144 -0
  73. package/scripts/migrate/templates/vite-config.ts +101 -0
  74. package/scripts/migrate/transforms/dead-code.ts +455 -0
  75. package/scripts/migrate/transforms/deno-isms.ts +85 -0
  76. package/scripts/migrate/transforms/fresh-apis.ts +223 -0
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
  78. package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
  79. package/scripts/migrate/transforms/imports.ts +385 -0
  80. package/scripts/migrate/transforms/jsx.ts +317 -0
  81. package/scripts/migrate/transforms/section-conventions.ts +210 -0
  82. package/scripts/migrate/transforms/tailwind.ts +739 -0
  83. package/scripts/migrate/types.ts +244 -0
  84. package/scripts/migrate-blocks-to-kv.ts +104 -0
  85. package/scripts/migrate-post-cleanup.ts +191 -0
  86. package/scripts/migrate-to-cf-observability.test.ts +215 -0
  87. package/scripts/migrate-to-cf-observability.ts +699 -0
  88. package/scripts/migrate.ts +282 -0
  89. package/scripts/smoke-otlp-errorlog.ts +46 -0
  90. package/scripts/smoke-otlp-meter.ts +48 -0
  91. package/scripts/sync-blocks-to-kv.ts +153 -0
  92. package/scripts/tailwind-lint.ts +518 -0
  93. package/tsconfig.json +7 -0
@@ -0,0 +1,739 @@
1
+ import type { TransformResult } from "../types";
2
+
3
+ /**
4
+ * Tailwind v3 → v4 class migration transform.
5
+ *
6
+ * Handles:
7
+ * 1. Renamed/removed utility classes
8
+ * 2. DaisyUI v4 → v5 class changes
9
+ * 3. Responsive class ordering (base → sm → md → lg → xl → 2xl)
10
+ * 4. Arbitrary values → native equivalents (px-[16px] → px-4)
11
+ * 5. Deprecated patterns
12
+ */
13
+
14
+ // ── Breakpoint order (mobile-first) ─────────────────────────────
15
+ const BREAKPOINT_ORDER = ["sm", "md", "lg", "xl", "2xl"] as const;
16
+ const BP_INDEX: Record<string, number> = {};
17
+ BREAKPOINT_ORDER.forEach((bp, i) => {
18
+ BP_INDEX[bp] = i + 1; // base = 0
19
+ });
20
+
21
+ // ── Tailwind v3 → v4 class renames ──────────────────────────────
22
+ // These are direct 1:1 replacements
23
+ const CLASS_RENAMES: Record<string, string> = {
24
+ // Flexbox/Grid
25
+ "flex-grow-0": "grow-0",
26
+ "flex-grow": "grow",
27
+ "flex-shrink-0": "shrink-0",
28
+ "flex-shrink": "shrink",
29
+
30
+ // Overflow
31
+ "overflow-ellipsis": "text-ellipsis",
32
+
33
+ // Decoration
34
+ "decoration-clone": "box-decoration-clone",
35
+ "decoration-slice": "box-decoration-slice",
36
+
37
+ // Transforms (v4 applies transforms automatically)
38
+ "transform": "", // remove — v4 applies automatically
39
+ "transform-gpu": "",
40
+ "transform-none": "transform-none", // this one stays
41
+
42
+ // Blur/filter (v4 applies automatically)
43
+ "filter": "", // remove
44
+ "backdrop-filter": "", // remove
45
+
46
+ // Ring width default
47
+ "ring": "ring-3", // v4 changed default from 3px to 1px
48
+ };
49
+
50
+ // ── DaisyUI v4 → v5 class renames ──────────────────────────────
51
+ const DAISYUI_RENAMES: Record<string, string> = {
52
+ // Button changes
53
+ "btn-ghost": "btn-ghost", // kept
54
+ "btn-outline": "btn-outline", // kept
55
+ "btn-active": "btn-active", // kept
56
+
57
+ // Alert/Badge
58
+ "badge-ghost": "badge-soft",
59
+ "alert-info": "alert-info",
60
+ "alert-success": "alert-success",
61
+ "alert-warning": "alert-warning",
62
+ "alert-error": "alert-error",
63
+
64
+ // Card
65
+ "card-compact": "card-sm",
66
+
67
+ // Modal
68
+ "modal-open": "modal-open",
69
+
70
+ // Drawer
71
+ "drawer-end": "drawer-end",
72
+
73
+ // Menu
74
+ "menu-horizontal": "menu-horizontal",
75
+
76
+ // Toast position classes (daisy v5 uses different system)
77
+ "toast-top": "toast-top",
78
+ "toast-bottom": "toast-bottom",
79
+ "toast-center": "toast-center",
80
+ "toast-end": "toast-end",
81
+ "toast-start": "toast-start",
82
+ "toast-middle": "toast-middle",
83
+
84
+ // Loading
85
+ "loading-spinner": "loading-spinner",
86
+ "loading-dots": "loading-dots",
87
+ "loading-ring": "loading-ring",
88
+ "loading-ball": "loading-ball",
89
+ "loading-bars": "loading-bars",
90
+ "loading-infinity": "loading-infinity",
91
+
92
+ // Sizes (daisy v5 naming)
93
+ "btn-xs": "btn-xs",
94
+ "btn-sm": "btn-sm",
95
+ "btn-md": "btn-md",
96
+ "btn-lg": "btn-lg",
97
+ };
98
+
99
+ // ── Spacing scale: px → Tailwind unit ───────────────────────────
100
+ const PX_TO_SPACING: Record<number, string> = {};
101
+ for (let i = 0; i <= 96; i++) {
102
+ PX_TO_SPACING[i * 4] = String(i);
103
+ }
104
+ PX_TO_SPACING[2] = "0.5";
105
+ PX_TO_SPACING[6] = "1.5";
106
+ PX_TO_SPACING[10] = "2.5";
107
+ PX_TO_SPACING[14] = "3.5";
108
+
109
+ // Text size: px → native class
110
+ const TEXT_SIZE_MAP: Record<string, string> = {
111
+ "12": "xs",
112
+ "14": "sm",
113
+ "16": "base",
114
+ "18": "lg",
115
+ "20": "xl",
116
+ "24": "2xl",
117
+ "30": "3xl",
118
+ "36": "4xl",
119
+ "48": "5xl",
120
+ "60": "6xl",
121
+ "72": "7xl",
122
+ "96": "8xl",
123
+ "128": "9xl",
124
+ };
125
+
126
+ // Properties that accept spacing values
127
+ const SPACING_PROPS = new Set([
128
+ "p", "px", "py", "pt", "pb", "pl", "pr",
129
+ "m", "mx", "my", "mt", "mb", "ml", "mr",
130
+ "gap", "gap-x", "gap-y", "space-x", "space-y",
131
+ "w", "h", "min-w", "min-h", "max-w", "max-h",
132
+ "top", "right", "bottom", "left", "inset", "inset-x", "inset-y",
133
+ "rounded", "rounded-t", "rounded-b", "rounded-l", "rounded-r",
134
+ "rounded-tl", "rounded-tr", "rounded-bl", "rounded-br",
135
+ "border", "border-t", "border-b", "border-l", "border-r",
136
+ "text",
137
+ ]);
138
+
139
+ // ── CSS category resolution (avoid false positives) ─────────────
140
+ const TEXT_SIZE_VALUES = new Set([
141
+ "xs", "sm", "base", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl",
142
+ "7xl", "8xl", "9xl",
143
+ ]);
144
+ const TEXT_ALIGN_VALUES = new Set([
145
+ "left", "center", "right", "justify", "start", "end",
146
+ ]);
147
+
148
+ function getCssCategory(prop: string, value: string): string {
149
+ if (prop === "text" || prop === "-text") {
150
+ if (TEXT_SIZE_VALUES.has(value) || /^\[\d/.test(value)) return "text-size";
151
+ if (TEXT_ALIGN_VALUES.has(value)) return "text-align";
152
+ return "text-color";
153
+ }
154
+ if (prop === "flex") {
155
+ if (value === "") return "display";
156
+ if (["1", "auto", "initial", "none"].includes(value)) return "flex-grow";
157
+ if (["row", "col", "row-reverse", "col-reverse"].includes(value)) return "flex-direction";
158
+ if (["wrap", "nowrap", "wrap-reverse"].includes(value)) return "flex-wrap";
159
+ return "flex";
160
+ }
161
+ if (prop === "font") {
162
+ if (["bold", "semibold", "medium", "normal", "light", "thin", "extrabold", "black", "extralight"].includes(value)) return "font-weight";
163
+ return "font-family";
164
+ }
165
+ return prop;
166
+ }
167
+
168
+ // ── Parse class ─────────────────────────────────────────────────
169
+ interface ParsedClass {
170
+ raw: string;
171
+ modifiers: string[];
172
+ bpIndex: number;
173
+ property: string;
174
+ value: string;
175
+ cssCategory: string;
176
+ }
177
+
178
+ function parseClass(cls: string): ParsedClass {
179
+ const parts = cls.split(":");
180
+ const utility = parts.pop()!;
181
+ const modifiers = parts;
182
+
183
+ let bpIndex = 0;
184
+ for (const mod of modifiers) {
185
+ if (BP_INDEX[mod] !== undefined && BP_INDEX[mod] > bpIndex) {
186
+ bpIndex = BP_INDEX[mod];
187
+ }
188
+ }
189
+
190
+ const negMatch = utility.match(/^(-?)(.+)-(.+)$/);
191
+ let property = utility;
192
+ let value = "";
193
+ if (negMatch) {
194
+ property = negMatch[1] + negMatch[2];
195
+ value = negMatch[3];
196
+ }
197
+
198
+ return { raw: cls, modifiers, bpIndex, property, value, cssCategory: getCssCategory(property, value) };
199
+ }
200
+
201
+ // ── Fix class renames ───────────────────────────────────────────
202
+ function fixRenames(cls: string): string {
203
+ const parts = cls.split(":");
204
+ const utility = parts.pop()!;
205
+
206
+ // Check direct rename
207
+ if (CLASS_RENAMES[utility] !== undefined) {
208
+ const renamed = CLASS_RENAMES[utility];
209
+ if (renamed === "") return ""; // Remove class entirely
210
+ parts.push(renamed);
211
+ return parts.join(":");
212
+ }
213
+
214
+ // Check DaisyUI rename
215
+ if (DAISYUI_RENAMES[utility] && DAISYUI_RENAMES[utility] !== utility) {
216
+ parts.push(DAISYUI_RENAMES[utility]);
217
+ return parts.join(":");
218
+ }
219
+
220
+ return cls;
221
+ }
222
+
223
+ // ── Fix arbitrary values ────────────────────────────────────────
224
+ function fixArbitrary(cls: string): string {
225
+ const parsed = parseClass(cls);
226
+ const arbMatch = parsed.value.match(/^\[(-?\d+(?:\.\d+)?)(px|rem|%)?\]$/);
227
+ if (!arbMatch) {
228
+ // w-[100%] → w-full, h-[100%] → h-full
229
+ if (parsed.value === "[100%]" && (parsed.property === "w" || parsed.property === "h")) {
230
+ const prefix = parsed.modifiers.length ? parsed.modifiers.join(":") + ":" : "";
231
+ return `${prefix}${parsed.property}-full`;
232
+ }
233
+ if (parsed.value === "[auto]" && (parsed.property === "w" || parsed.property === "h")) {
234
+ const prefix = parsed.modifiers.length ? parsed.modifiers.join(":") + ":" : "";
235
+ return `${prefix}${parsed.property}-auto`;
236
+ }
237
+ return cls;
238
+ }
239
+
240
+ const num = parseFloat(arbMatch[1]);
241
+ const unit = arbMatch[2] || "px";
242
+ const baseProp = parsed.property.replace(/^-/, "");
243
+ const isNeg = parsed.property.startsWith("-");
244
+ const prefix = parsed.modifiers.length ? parsed.modifiers.join(":") + ":" : "";
245
+ const negPrefix = isNeg ? "-" : "";
246
+
247
+ // text-[Npx] → text-{size}
248
+ if (baseProp === "text" && unit === "px") {
249
+ const native = TEXT_SIZE_MAP[String(num)];
250
+ if (native) return `${prefix}text-${native}`;
251
+ return cls;
252
+ }
253
+
254
+ // Spacing: px-[16px] → px-4
255
+ if (SPACING_PROPS.has(baseProp)) {
256
+ let pxValue: number | null = null;
257
+ if (unit === "px") pxValue = num;
258
+ else if (unit === "rem") pxValue = num * 16;
259
+
260
+ if (pxValue !== null && PX_TO_SPACING[pxValue] !== undefined) {
261
+ return `${prefix}${negPrefix}${baseProp}-${PX_TO_SPACING[pxValue]}`;
262
+ }
263
+ }
264
+
265
+ return cls;
266
+ }
267
+
268
+ // ── Fix responsive ordering ─────────────────────────────────────
269
+ function fixResponsiveOrder(classes: string[]): string[] {
270
+ const parsed = classes.map((cls, i) => ({ ...parseClass(cls), idx: i }));
271
+
272
+ // Group by CSS category
273
+ const groups: Record<string, typeof parsed> = {};
274
+ for (const p of parsed) {
275
+ if (!groups[p.cssCategory]) groups[p.cssCategory] = [];
276
+ groups[p.cssCategory].push(p);
277
+ }
278
+
279
+ const result = [...classes];
280
+ for (const group of Object.values(groups)) {
281
+ if (group.length < 2) continue;
282
+ const positions = group.map((g) => g.idx).sort((a, b) => a - b);
283
+ const sorted = [...group].sort((a, b) => a.bpIndex - b.bpIndex);
284
+ for (let i = 0; i < positions.length; i++) {
285
+ result[positions[i]] = sorted[i].raw;
286
+ }
287
+ }
288
+
289
+ return result;
290
+ }
291
+
292
+ // ── Check if ordering is wrong ──────────────────────────────────
293
+ function hasOrderIssues(classes: string[]): boolean {
294
+ const parsed = classes.map((cls, i) => ({ ...parseClass(cls), idx: i }));
295
+ const groups: Record<string, typeof parsed> = {};
296
+ for (const p of parsed) {
297
+ if (!groups[p.cssCategory]) groups[p.cssCategory] = [];
298
+ groups[p.cssCategory].push(p);
299
+ }
300
+
301
+ for (const group of Object.values(groups)) {
302
+ if (group.length < 2) continue;
303
+ for (let i = 0; i < group.length; i++) {
304
+ for (let j = i + 1; j < group.length; j++) {
305
+ const a = group[i];
306
+ const b = group[j];
307
+ if (a.idx < b.idx && a.bpIndex > b.bpIndex) return true;
308
+ if (b.idx < a.idx && b.bpIndex > a.bpIndex) return true;
309
+ }
310
+ }
311
+ }
312
+ return false;
313
+ }
314
+
315
+ // ── Fix opacity modifier classes within a class list ────────────
316
+ // Handles non-adjacent cases like: "bg-black/50 flex ... hover:bg-opacity-30"
317
+ // Finds the base color class and merges the opacity into it.
318
+ function fixOrphanedOpacity(classList: string[]): { result: string[]; changes: string[] } {
319
+ const changes: string[] = [];
320
+ const prefixes = ["bg", "text", "border", "ring", "divide", "placeholder"];
321
+
322
+ // Find base color classes: bg-{color} or bg-{color}/{opacity}
323
+ const colorClasses: Record<string, { color: string; prefix: string }> = {};
324
+ for (const cls of classList) {
325
+ for (const pfx of prefixes) {
326
+ const match = cls.match(new RegExp(`^${pfx}-(\\w[\\w-]*?)(?:\\/(\\d+))?$`));
327
+ if (match && match[1] !== "opacity") {
328
+ colorClasses[pfx] = { color: match[1], prefix: pfx };
329
+ }
330
+ }
331
+ }
332
+
333
+ // Replace orphaned opacity classes with proper merged versions
334
+ const result: string[] = [];
335
+ for (const cls of classList) {
336
+ const opMatch = cls.match(/^((?:hover:|focus:|active:)*)(\w+)-opacity-(\d+)$/);
337
+ if (!opMatch) {
338
+ result.push(cls);
339
+ continue;
340
+ }
341
+
342
+ const modifier = opMatch[1]; // "hover:" or ""
343
+ const prefix = opMatch[2]; // "bg", "text", etc.
344
+ const opacity = opMatch[3]; // "20", "50", etc.
345
+
346
+ const base = colorClasses[prefix];
347
+ if (!base) {
348
+ result.push(cls); // No base color found, keep as-is
349
+ continue;
350
+ }
351
+
352
+ const opacityStr = opacity === "100" ? "" : `/${opacity}`;
353
+ const replacement = `${modifier}${prefix}-${base.color}${opacityStr}`;
354
+ result.push(replacement);
355
+ changes.push(`Merged ${cls} → ${replacement}`);
356
+ }
357
+
358
+ return { result, changes };
359
+ }
360
+
361
+ // ── Fix a className string ──────────────────────────────────────
362
+ function fixClassNameString(classes: string): { fixed: string; changes: string[] } {
363
+ const changes: string[] = [];
364
+ let classList = classes.split(/\s+/).filter(Boolean);
365
+
366
+ // 1. Apply renames
367
+ classList = classList.map((cls) => {
368
+ const renamed = fixRenames(cls);
369
+ if (renamed !== cls) {
370
+ if (renamed === "") {
371
+ changes.push(`Removed deprecated: ${cls}`);
372
+ } else {
373
+ changes.push(`Renamed: ${cls} → ${renamed}`);
374
+ }
375
+ }
376
+ return renamed;
377
+ }).filter(Boolean); // Remove empty strings (deleted classes)
378
+
379
+ // 2. Fix arbitrary values
380
+ classList = classList.map((cls) => {
381
+ if (!cls.includes("[")) return cls;
382
+ const fixed = fixArbitrary(cls);
383
+ if (fixed !== cls) {
384
+ changes.push(`Arbitrary: ${cls} → ${fixed}`);
385
+ }
386
+ return fixed;
387
+ });
388
+
389
+ // 3. Fix orphaned opacity classes (non-adjacent to color class)
390
+ const opacityFix = fixOrphanedOpacity(classList);
391
+ if (opacityFix.changes.length > 0) {
392
+ classList = opacityFix.result;
393
+ changes.push(...opacityFix.changes);
394
+ }
395
+
396
+ // 4. Fix responsive ordering
397
+ if (hasOrderIssues(classList)) {
398
+ const reordered = fixResponsiveOrder(classList);
399
+ if (reordered.join(" ") !== classList.join(" ")) {
400
+ changes.push("Reordered responsive classes (mobile-first)");
401
+ classList = reordered;
402
+ }
403
+ }
404
+
405
+ return { fixed: classList.join(" "), changes };
406
+ }
407
+
408
+ // ── Fix negative z-index on background images ──────────────────
409
+ // In Tailwind v3, `-z-10` on an absolute image inside a relative parent worked
410
+ // to push the image behind the parent's content. In Tailwind v4 + React,
411
+ // stacking contexts from wrappers (section elements, animation, etc.) can trap
412
+ // the negative z-index, making the image invisible.
413
+ // Fix: replace `-z-{n}` with `z-0` on images. Since the image comes first in DOM,
414
+ // content siblings (which have z-index: auto) render on top naturally.
415
+ const NEG_Z_ON_IMAGE_REGEX = /\b-z-\d+\b/g;
416
+
417
+ function fixNegativeZIndex(content: string): { content: string; changed: boolean; notes: string[] } {
418
+ const notes: string[] = [];
419
+ let changed = false;
420
+ let result = content;
421
+
422
+ // Step 1: Replace -z-{n} with z-0 on img/Image elements + add inset-0
423
+ result = result.replace(
424
+ /<(?:img|Image)\b[\s\S]*?(?:\/>|>)/g,
425
+ (tag) => {
426
+ if (!/-z-\d+/.test(tag)) return tag;
427
+ let fixed = tag.replace(/(?<=\s|"|`)-z-(\d+)\b/g, (m) => {
428
+ changed = true;
429
+ notes.push(`Background image: replaced ${m} with z-0`);
430
+ return "z-0";
431
+ });
432
+ // Add inset-0 if not present (ensures absolute image covers parent)
433
+ if (fixed.includes("absolute") && !fixed.includes("inset-0")) {
434
+ fixed = fixed.replace(/\babsolute\b/, "absolute inset-0");
435
+ notes.push("Added inset-0 to absolute background image");
436
+ }
437
+ return fixed;
438
+ },
439
+ );
440
+
441
+ // Step 2: When parent div has backgroundColor inline style + child img with z-0,
442
+ // extract backgroundColor into a separate overlay div and bump content to z-20.
443
+ // Use a simple two-pass approach to avoid JSX nesting issues:
444
+ // a) Extract and remove backgroundColor from parent div
445
+ // b) Insert overlay div before the content div (after the image conditional block)
446
+ if (changed && /style=\{\{[^}]*backgroundColor/.test(result)) {
447
+ const bgMatch = result.match(/style=\{\{\s*backgroundColor:\s*"([^"]+)"\s*\}\}/);
448
+ if (bgMatch) {
449
+ const bgValue = bgMatch[1];
450
+
451
+ // Remove the style attribute from the parent div
452
+ result = result.replace(/\s*style=\{\{\s*backgroundColor:\s*"[^"]+"\s*\}\}/, "");
453
+
454
+ // Insert overlay div. Find the closing of the image conditional block:
455
+ // Pattern: )} followed by whitespace then <div
456
+ // This handles {imageBg && (<Image ... />)} <div content>
457
+ let insertedOverlay = false;
458
+
459
+ // Try pattern: )} then <div (conditional image)
460
+ if (/\)\}\s*\n\s*<div/.test(result)) {
461
+ result = result.replace(
462
+ /(\)\})([\s\n]*)(<div\b)/,
463
+ (m, closing, ws, divTag) => {
464
+ if (insertedOverlay) return m;
465
+ insertedOverlay = true;
466
+ return `${closing}${ws}{/* Overlay */}\n <div className="absolute inset-0 z-10" style={{ backgroundColor: "${bgValue}" }} />${ws}${divTag}`;
467
+ },
468
+ );
469
+ }
470
+
471
+ // Try pattern: /> then <div (direct image, no conditional)
472
+ if (!insertedOverlay && /\/>\s*\n\s*<div/.test(result)) {
473
+ result = result.replace(
474
+ /(\/>\s*\n)([\s]*)(<div\b)/,
475
+ (m, closing, ws, divTag) => {
476
+ if (insertedOverlay) return m;
477
+ insertedOverlay = true;
478
+ return `${closing}${ws}{/* Overlay */}\n${ws}<div className="absolute inset-0 z-10" style={{ backgroundColor: "${bgValue}" }} />\n${ws}${divTag}`;
479
+ },
480
+ );
481
+ }
482
+
483
+ if (insertedOverlay) {
484
+ // Bump the first content div after the overlay to z-20.
485
+ // Find the overlay marker, then the next className= string after it.
486
+ const overlayMarker = `style={{ backgroundColor: "${bgValue}" }} />`;
487
+ const overlayIdx = result.indexOf(overlayMarker);
488
+ if (overlayIdx !== -1) {
489
+ const afterOverlay = result.substring(overlayIdx + overlayMarker.length);
490
+ // Find first className= after overlay (handles both className="..." and className={clx("...")})
491
+ const classMatch = afterOverlay.match(/className=(?:\{clx\(\s*)?[""`]([^""`]*)/);
492
+ if (classMatch && classMatch[1] && !/z-\d+/.test(classMatch[1])) {
493
+ const originalClass = classMatch[1];
494
+ const fixedClass = `relative z-20 ${originalClass}`;
495
+ // Replace only the first occurrence after the overlay
496
+ const beforeOverlay = result.substring(0, overlayIdx + overlayMarker.length);
497
+ const fixed = afterOverlay.replace(originalClass, fixedClass);
498
+ result = beforeOverlay + fixed;
499
+ }
500
+ }
501
+ notes.push(`Extracted backgroundColor overlay: ${bgValue}`);
502
+ }
503
+ }
504
+ }
505
+
506
+ // Step 3: For content divs that are siblings of z-0 images,
507
+ // add `relative z-10` so content renders above the background image.
508
+ // Uses a line-by-line approach to avoid regex issues with JSX nesting.
509
+ if (changed) {
510
+ const lines = result.split("\n");
511
+ let foundZ0Image = false;
512
+ let needsContentZIndex = false;
513
+
514
+ for (let i = 0; i < lines.length; i++) {
515
+ const line = lines[i];
516
+
517
+ // Detect start of an image element (may span multiple lines)
518
+ if (/<(?:img|Image)\b/.test(line)) {
519
+ foundZ0Image = true;
520
+ if (/\bz-0\b/.test(line)) {
521
+ needsContentZIndex = true;
522
+ }
523
+ continue;
524
+ }
525
+ // Detect z-0 on a subsequent line of the image element
526
+ if (foundZ0Image && !needsContentZIndex && /\bz-0\b/.test(line)) {
527
+ needsContentZIndex = true;
528
+ continue;
529
+ }
530
+ // Detect end of multi-line image element (self-closing />)
531
+ if (foundZ0Image && !needsContentZIndex && /\/>/.test(line)) {
532
+ // Image closed without z-0, reset
533
+ foundZ0Image = false;
534
+ continue;
535
+ }
536
+
537
+ // When we hit the closing of an Image block, start looking for content div
538
+ if (foundZ0Image && /\)\}/.test(line)) {
539
+ // The conditional image block closed, content div should be next
540
+ continue;
541
+ }
542
+
543
+ // Skip overlay divs we inserted
544
+ if (/Overlay/.test(line)) continue;
545
+ if (/absolute inset-0 z-10/.test(line)) continue;
546
+
547
+ // Find the first content div after the image
548
+ if (needsContentZIndex && /^\s*<div\b/.test(line)) {
549
+ // Check if this div already has a z-index
550
+ if (/z-\d+/.test(line)) {
551
+ needsContentZIndex = false;
552
+ foundZ0Image = false;
553
+ continue;
554
+ }
555
+ // Also check the next few lines for z-index (multi-line className)
556
+ let hasZ = false;
557
+ for (let j = i; j < Math.min(i + 5, lines.length); j++) {
558
+ if (/z-\d+/.test(lines[j])) { hasZ = true; break; }
559
+ if (/>/.test(lines[j]) && j !== i) break;
560
+ }
561
+ if (hasZ) {
562
+ needsContentZIndex = false;
563
+ foundZ0Image = false;
564
+ continue;
565
+ }
566
+
567
+ // Add relative z-10 to the className — may be on this line or a subsequent one
568
+ let classLine = i;
569
+ for (let k = i; k < Math.min(i + 4, lines.length); k++) {
570
+ if (/className=/.test(lines[k])) {
571
+ classLine = k;
572
+ break;
573
+ }
574
+ }
575
+
576
+ if (/className="/.test(lines[classLine])) {
577
+ lines[classLine] = lines[classLine].replace(/className="/, 'className="relative z-10 ');
578
+ notes.push("Added relative z-10 to content div sibling of background image");
579
+ } else if (/className=\{clx\(/.test(lines[classLine])) {
580
+ // clx( may have its first string on the same line or the next
581
+ if (/className=\{clx\(\s*"/.test(lines[classLine])) {
582
+ lines[classLine] = lines[classLine].replace(/className=\{clx\(\s*"/, 'className={clx("relative z-10 ');
583
+ } else {
584
+ // First string argument is on the next line
585
+ for (let k = classLine + 1; k < Math.min(classLine + 3, lines.length); k++) {
586
+ if (/^\s*"/.test(lines[k])) {
587
+ lines[k] = lines[k].replace(/^(\s*)"/, '$1"relative z-10 ');
588
+ break;
589
+ }
590
+ }
591
+ }
592
+ notes.push("Added relative z-10 to content div sibling of background image");
593
+ } else if (/className=\{`/.test(lines[classLine])) {
594
+ lines[classLine] = lines[classLine].replace(/className=\{`/, 'className={`relative z-10 ');
595
+ notes.push("Added relative z-10 to content div sibling of background image");
596
+ }
597
+
598
+ needsContentZIndex = false;
599
+ foundZ0Image = false;
600
+ }
601
+ }
602
+
603
+ result = lines.join("\n");
604
+ }
605
+
606
+ // Step 4: Flag remaining -z-{n} on non-image elements for manual review
607
+ const remainingNegZ = result.match(/(?<=\s|"|`)-z-\d+/g);
608
+ if (remainingNegZ) {
609
+ notes.push(`MANUAL: ${remainingNegZ.length} remaining negative z-index usage(s) — may need manual fix for stacking context issues`);
610
+ }
611
+
612
+ return { content: result, changed, notes };
613
+ }
614
+
615
+ /**
616
+ * Transform Tailwind classes in a file.
617
+ *
618
+ * Finds all className="..." and class="..." attributes and applies:
619
+ * - v3→v4 class renames
620
+ * - DaisyUI v4→v5 renames
621
+ * - Arbitrary value → native equivalent
622
+ * - Responsive class ordering fix
623
+ */
624
+ export function transformTailwind(content: string): TransformResult {
625
+ const notes: string[] = [];
626
+ let changed = false;
627
+ let result = content;
628
+
629
+ // ── Fix negative z-index on background images ──────────────────
630
+ const zFix = fixNegativeZIndex(result);
631
+ if (zFix.changed) {
632
+ result = zFix.content;
633
+ changed = true;
634
+ notes.push(...zFix.notes);
635
+ }
636
+
637
+ // ── Fix opacity utility pattern (Tailwind v4 breaking change) ──
638
+ // bg-black bg-opacity-20 → bg-black/20
639
+ // border-white border-opacity-20 → border-white/20
640
+ // text-gray-600 text-opacity-50 → text-gray-600/50
641
+ //
642
+ // The pattern is: {prefix}-{color} {prefix}-opacity-{N} → {prefix}-{color}/{N}
643
+ // prefix can be: bg, text, border, ring, divide, placeholder
644
+ if (/(?:bg|text|border|ring|divide|placeholder)-opacity-\d+/.test(result)) {
645
+ // Match: bg-{color} bg-opacity-{N}
646
+ result = result.replace(
647
+ /\b(bg-[\w-]+?)\s+bg-opacity-(\d+)/g,
648
+ "$1/$2",
649
+ );
650
+ // Match: text-{color} text-opacity-{N}
651
+ result = result.replace(
652
+ /\b(text-[\w-]+?)\s+text-opacity-(\d+)/g,
653
+ "$1/$2",
654
+ );
655
+ // Match: border-{color} border-opacity-{N}
656
+ result = result.replace(
657
+ /\b(border-[\w-]+?)\s+border-opacity-(\d+)/g,
658
+ "$1/$2",
659
+ );
660
+ // Match: ring-{color} ring-opacity-{N}
661
+ result = result.replace(
662
+ /\b(ring-[\w-]+?)\s+ring-opacity-(\d+)/g,
663
+ "$1/$2",
664
+ );
665
+ // Match: divide-{color} divide-opacity-{N}
666
+ result = result.replace(
667
+ /\b(divide-[\w-]+?)\s+divide-opacity-(\d+)/g,
668
+ "$1/$2",
669
+ );
670
+ // Match: placeholder-{color} placeholder-opacity-{N}
671
+ result = result.replace(
672
+ /\b(placeholder-[\w-]+?)\s+placeholder-opacity-(\d+)/g,
673
+ "$1/$2",
674
+ );
675
+ // Handle hover:/focus:/active: prefixed opacity (e.g. hover:bg-opacity-100)
676
+ result = result.replace(
677
+ /\b((?:hover:|focus:|active:)(?:bg|text|border|ring)-[\w-]+?)\s+(?:hover:|focus:|active:)(?:bg|text|border|ring)-opacity-(\d+)/g,
678
+ "$1/$2",
679
+ );
680
+ // Handle hover:bg-opacity-N when bg-{color}/{N} already exists
681
+ // e.g. "bg-white/80 hover:bg-opacity-100" → "bg-white/80 hover:bg-white"
682
+ // e.g. "bg-black/60 hover:bg-opacity-50" → "bg-black/60 hover:bg-black/50"
683
+ result = result.replace(
684
+ /\b(bg|text|border|ring)-([\w-]+?)\/(\d+)\s+((?:hover:|focus:|active:)+)\1-opacity-(\d+)/g,
685
+ (_m, prefix, color, _baseOp, modifier, hoverOp) => {
686
+ const opacityStr = hoverOp === "100" ? "" : `/${hoverOp}`;
687
+ return `${prefix}-${color}/${_baseOp} ${modifier}${prefix}-${color}${opacityStr}`;
688
+ },
689
+ );
690
+
691
+ // Handle standalone orphaned opacity classes that weren't caught
692
+ if (/(?:bg|text|border|ring)-opacity-\d+/.test(result)) {
693
+ notes.push("MANUAL: Some *-opacity-N classes remain — color class may not be adjacent");
694
+ }
695
+ changed = true;
696
+ notes.push("Converted *-opacity-N to modifier syntax (e.g. bg-black/20)");
697
+ }
698
+
699
+ // Match className="...", className={`...`}, class="..."
700
+ const patterns = [
701
+ /(?<=className\s*=\s*")([^"]+)(?=")/g,
702
+ /(?<=className\s*=\s*{`)([^`]+)(?=`})/g,
703
+ /(?<=className\s*=\s*{\s*")([^"]+)(?="\s*})/g,
704
+ /(?<=class\s*=\s*")([^"]+)(?=")/g,
705
+ ];
706
+
707
+ for (const pattern of patterns) {
708
+ result = result.replace(pattern, (match) => {
709
+ // Handle multiline class strings
710
+ if (match.includes("\n")) {
711
+ const lines = match.split("\n");
712
+ const fixedLines = lines.map((line) => {
713
+ const trimmed = line.trim();
714
+ if (!trimmed) return line;
715
+ const indent = line.match(/^(\s*)/)?.[1] ?? "";
716
+ const { fixed, changes } = fixClassNameString(trimmed);
717
+ if (changes.length > 0) {
718
+ changed = true;
719
+ notes.push(...changes);
720
+ }
721
+ return indent + fixed;
722
+ });
723
+ return fixedLines.join("\n");
724
+ }
725
+
726
+ const { fixed, changes } = fixClassNameString(match);
727
+ if (changes.length > 0) {
728
+ changed = true;
729
+ notes.push(...changes);
730
+ }
731
+ return fixed;
732
+ });
733
+ }
734
+
735
+ // Deduplicate notes
736
+ const uniqueNotes = [...new Set(notes)];
737
+
738
+ return { content: result, changed, notes: uniqueNotes };
739
+ }