@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.
- package/package.json +44 -0
- package/scripts/analyze-traces.mjs +1117 -0
- package/scripts/audit-observability-config.test.ts +446 -0
- package/scripts/audit-observability-config.ts +511 -0
- package/scripts/deco-migrate-cli.ts +444 -0
- package/scripts/fast-deploy-kv.test.ts +131 -0
- package/scripts/generate-blocks.test.ts +94 -0
- package/scripts/generate-blocks.ts +274 -0
- package/scripts/generate-invoke.test.ts +195 -0
- package/scripts/generate-invoke.ts +469 -0
- package/scripts/generate-loaders.ts +217 -0
- package/scripts/generate-schema.ts +1287 -0
- package/scripts/generate-sections.ts +237 -0
- package/scripts/htmx-analyze.ts +226 -0
- package/scripts/lib/blocks-dedupe.test.ts +179 -0
- package/scripts/lib/blocks-dedupe.ts +142 -0
- package/scripts/lib/cf-kv-rest.ts +78 -0
- package/scripts/lib/jsonc.ts +122 -0
- package/scripts/lib/kv-snapshot.ts +51 -0
- package/scripts/lib/read-decofile.ts +70 -0
- package/scripts/lib/sync-helpers.ts +44 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
- package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
- package/scripts/migrate/analyzers/island-classifier.ts +96 -0
- package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
- package/scripts/migrate/analyzers/section-metadata.ts +147 -0
- package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
- package/scripts/migrate/colors.ts +46 -0
- package/scripts/migrate/config.test.ts +202 -0
- package/scripts/migrate/config.ts +186 -0
- package/scripts/migrate/phase-analyze.test.ts +63 -0
- package/scripts/migrate/phase-analyze.ts +782 -0
- package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
- package/scripts/migrate/phase-cleanup-audit.ts +105 -0
- package/scripts/migrate/phase-cleanup.test.ts +141 -0
- package/scripts/migrate/phase-cleanup.ts +1588 -0
- package/scripts/migrate/phase-compile.test.ts +193 -0
- package/scripts/migrate/phase-compile.ts +177 -0
- package/scripts/migrate/phase-report.ts +243 -0
- package/scripts/migrate/phase-scaffold.ts +593 -0
- package/scripts/migrate/phase-transform.ts +310 -0
- package/scripts/migrate/phase-verify.test.ts +127 -0
- package/scripts/migrate/phase-verify.ts +572 -0
- package/scripts/migrate/post-cleanup/rules.ts +1708 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
- package/scripts/migrate/post-cleanup/runner.ts +137 -0
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
- package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
- package/scripts/migrate/post-cleanup/types.ts +106 -0
- package/scripts/migrate/source-layout.test.ts +111 -0
- package/scripts/migrate/source-layout.ts +103 -0
- package/scripts/migrate/templates/app-css.ts +366 -0
- package/scripts/migrate/templates/cache-config.ts +26 -0
- package/scripts/migrate/templates/commerce-loaders.ts +230 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
- package/scripts/migrate/templates/cursor-rules.ts +70 -0
- package/scripts/migrate/templates/hooks.test.ts +141 -0
- package/scripts/migrate/templates/hooks.ts +152 -0
- package/scripts/migrate/templates/knip-config.ts +27 -0
- package/scripts/migrate/templates/lib-utils.test.ts +139 -0
- package/scripts/migrate/templates/lib-utils.ts +326 -0
- package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
- package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
- package/scripts/migrate/templates/package-json.ts +177 -0
- package/scripts/migrate/templates/routes.ts +237 -0
- package/scripts/migrate/templates/sdk-gen.ts +59 -0
- package/scripts/migrate/templates/section-loaders.ts +456 -0
- package/scripts/migrate/templates/server-entry.ts +561 -0
- package/scripts/migrate/templates/setup.ts +148 -0
- package/scripts/migrate/templates/tsconfig.ts +21 -0
- package/scripts/migrate/templates/types-gen.ts +174 -0
- package/scripts/migrate/templates/ui-components.ts +144 -0
- package/scripts/migrate/templates/vite-config.ts +101 -0
- package/scripts/migrate/transforms/dead-code.ts +455 -0
- package/scripts/migrate/transforms/deno-isms.ts +85 -0
- package/scripts/migrate/transforms/fresh-apis.ts +223 -0
- package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
- package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
- package/scripts/migrate/transforms/imports.ts +385 -0
- package/scripts/migrate/transforms/jsx.ts +317 -0
- package/scripts/migrate/transforms/section-conventions.ts +210 -0
- package/scripts/migrate/transforms/tailwind.ts +739 -0
- package/scripts/migrate/types.ts +244 -0
- package/scripts/migrate-blocks-to-kv.ts +104 -0
- package/scripts/migrate-post-cleanup.ts +191 -0
- package/scripts/migrate-to-cf-observability.test.ts +215 -0
- package/scripts/migrate-to-cf-observability.ts +699 -0
- package/scripts/migrate.ts +282 -0
- package/scripts/smoke-otlp-errorlog.ts +46 -0
- package/scripts/smoke-otlp-meter.ts +48 -0
- package/scripts/sync-blocks-to-kv.ts +153 -0
- package/scripts/tailwind-lint.ts +518 -0
- 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
|
+
}
|