@decocms/blocks-cli 8.0.0 → 8.1.0-next.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 (100) hide show
  1. package/package.json +5 -3
  2. package/scripts/analyze-traces.mjs +1 -0
  3. package/scripts/cdn-rules.test.ts +105 -0
  4. package/scripts/cdn-rules.ts +192 -0
  5. package/scripts/deco-migrate-cli.ts +6 -7
  6. package/scripts/fast-deploy-kv.test.ts +172 -0
  7. package/scripts/generate-blocks.test.ts +11 -0
  8. package/scripts/generate-blocks.ts +0 -10
  9. package/scripts/generate-invoke.test.ts +2 -2
  10. package/scripts/generate-invoke.ts +9 -21
  11. package/scripts/generate-loaders.test.ts +2 -2
  12. package/scripts/generate-loaders.ts +0 -10
  13. package/scripts/generate-schema-loader-aliases.test.ts +186 -0
  14. package/scripts/generate-schema-workspaces.test.ts +180 -0
  15. package/scripts/generate-schema.test.ts +68 -29
  16. package/scripts/generate-schema.ts +101 -89
  17. package/scripts/generate-sections.test.ts +104 -35
  18. package/scripts/generate-sections.ts +57 -11
  19. package/scripts/generate-workspaces.test.ts +179 -0
  20. package/scripts/generate.test.ts +1 -2
  21. package/scripts/generate.ts +43 -45
  22. package/scripts/lib/blocks-dedupe.test.ts +1 -1
  23. package/scripts/lib/cf-kv-rest.ts +49 -1
  24. package/scripts/lib/installedPackages.ts +42 -0
  25. package/scripts/lib/invokeSource.ts +18 -0
  26. package/scripts/lib/kv-snapshot.ts +44 -0
  27. package/scripts/lib/read-decofile.ts +13 -2
  28. package/scripts/lib/upgrade-6-to-7.ts +1 -1
  29. package/scripts/lib/wrangler-config.test.ts +49 -0
  30. package/scripts/lib/wrangler-config.ts +31 -0
  31. package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
  32. package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
  33. package/scripts/migrate/config.ts +2 -2
  34. package/scripts/migrate/delete-sets.ts +75 -0
  35. package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
  36. package/scripts/migrate/icon-casing.test.ts +63 -0
  37. package/scripts/migrate/phase-analyze.test.ts +66 -4
  38. package/scripts/migrate/phase-analyze.ts +79 -60
  39. package/scripts/migrate/phase-cleanup.test.ts +3 -1
  40. package/scripts/migrate/phase-cleanup.ts +1 -1
  41. package/scripts/migrate/phase-compile.test.ts +3 -1
  42. package/scripts/migrate/phase-report.test.ts +2 -0
  43. package/scripts/migrate/phase-report.ts +3 -4
  44. package/scripts/migrate/phase-scaffold.ts +150 -45
  45. package/scripts/migrate/phase-transform.ts +107 -2
  46. package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
  47. package/scripts/migrate/phase-verify.test.ts +3 -1
  48. package/scripts/migrate/phase-verify.ts +87 -33
  49. package/scripts/migrate/post-cleanup/rules.ts +29 -11
  50. package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
  51. package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
  52. package/scripts/migrate/source-layout.test.ts +43 -0
  53. package/scripts/migrate/source-layout.ts +32 -5
  54. package/scripts/migrate/templates/app-css.test.ts +24 -0
  55. package/scripts/migrate/templates/app-css.ts +27 -0
  56. package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
  57. package/scripts/migrate/templates/ci-yml.ts +182 -0
  58. package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
  59. package/scripts/migrate/templates/hooks.test.ts +3 -1
  60. package/scripts/migrate/templates/lib-utils.ts +3 -3
  61. package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
  62. package/scripts/migrate/templates/package-json.ts +10 -0
  63. package/scripts/migrate/templates/parity-yml.ts +136 -0
  64. package/scripts/migrate/templates/perf-yml.ts +449 -0
  65. package/scripts/migrate/templates/playwright-yml.ts +127 -0
  66. package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
  67. package/scripts/migrate/templates/routes.test.ts +31 -0
  68. package/scripts/migrate/templates/routes.ts +19 -9
  69. package/scripts/migrate/templates/section-loaders.ts +3 -3
  70. package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
  71. package/scripts/migrate/templates/server-entry.ts +19 -14
  72. package/scripts/migrate/templates/setup.ts +11 -3
  73. package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
  74. package/scripts/migrate/templates/ui-components.ts +3 -0
  75. package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
  76. package/scripts/migrate/transforms/ctx-compat.ts +93 -1
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
  78. package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
  79. package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
  80. package/scripts/migrate/transforms/jsx.ts +10 -0
  81. package/scripts/migrate/transforms/picture.test.ts +56 -0
  82. package/scripts/migrate/transforms/picture.ts +95 -0
  83. package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
  84. package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
  85. package/scripts/migrate/transforms/timer-types.test.ts +30 -0
  86. package/scripts/migrate/transforms/timer-types.ts +48 -0
  87. package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
  88. package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
  89. package/scripts/migrate/types.ts +13 -1
  90. package/scripts/migrate-blocks-to-kv.ts +16 -2
  91. package/scripts/migrate-to-cf-observability.test.ts +5 -5
  92. package/scripts/migrate-to-cf-observability.ts +1 -1
  93. package/scripts/migrate.ts +244 -6
  94. package/scripts/reconcile.test.ts +162 -0
  95. package/scripts/reconcile.ts +303 -0
  96. package/scripts/sync-blocks-bot.test.ts +276 -0
  97. package/scripts/sync-blocks-bot.ts +567 -0
  98. package/scripts/sync-blocks-to-kv.ts +20 -2
  99. package/scripts/upgrade-6-to-7.ts +0 -0
  100. package/scripts/lib/legacyArtifact.ts +0 -20
@@ -57,6 +57,43 @@ describe("transformCtxCompat", () => {
57
57
  expect(r.content).toContain("ctx?: AppContext");
58
58
  });
59
59
 
60
+ // The scanner walks raw characters, so without a literal/comment guard it
61
+ // rewrote `ctx.` inside strings too — patching the call site correctly while
62
+ // silently corrupting the message next to it.
63
+ it("does not rewrite ctx inside string literals", () => {
64
+ const r = transformCtxCompat(withLoader(" console.log('ctx.device:', ctx.device);"));
65
+ expect(r.content).toContain("'ctx.device:'");
66
+ expect(r.content).toContain("console.log('ctx.device:', ctx?.device)");
67
+ });
68
+
69
+ it("does not rewrite ctx inside double-quoted strings or escaped quotes", () => {
70
+ const r = transformCtxCompat(
71
+ withLoader(' const s = "he said \\"ctx.device\\""; return ctx.device;'),
72
+ );
73
+ expect(r.content).toContain('"he said \\"ctx.device\\""');
74
+ expect(r.content).toContain("return ctx?.device");
75
+ });
76
+
77
+ it("does not rewrite ctx inside comments", () => {
78
+ const r = transformCtxCompat(
79
+ withLoader(" // don't touch ctx.device here\n /* nor ctx.invoke */\n return ctx.device;"),
80
+ );
81
+ expect(r.content).toContain("// don't touch ctx.device here");
82
+ expect(r.content).toContain("/* nor ctx.invoke */");
83
+ expect(r.content).toContain("return ctx?.device");
84
+ });
85
+
86
+ it("does not rewrite template text but DOES rewrite ${} interpolation", () => {
87
+ const r = transformCtxCompat(withLoader(" const u = `ctx.device is ${ctx.vtex.account}`;"));
88
+ // literal text stays, the interpolated expression is real code
89
+ expect(r.content).toContain("`ctx.device is ${ctx?.vtex?.account}`");
90
+ });
91
+
92
+ it("handles nested template literals", () => {
93
+ const r = transformCtxCompat(withLoader(" const u = `a${`b${ctx.device}`}c`;"));
94
+ expect(r.content).toContain("`a${`b${ctx?.device}`}c`");
95
+ });
96
+
60
97
  it("does not match identifiers that merely contain ctx", () => {
61
98
  const src = withLoader(" const c = canvasCtx.foo;\n const d = a.ctx.bar;");
62
99
  const r = transformCtxCompat(src);
@@ -11,7 +11,7 @@ import type { TransformResult } from "../types";
11
11
  * *non-optional* deep read like `ctx.salesforce.cartExtension[0]` still throws
12
12
  * — and `withSectionLoader`'s try/catch would swallow it, dropping the
13
13
  * section's props (blank render). The hand-fixed reference migration
14
- * (`granadobr-tanstack`) solves this by optional-chaining every `ctx` read.
14
+ * (a production storefront) solves this by optional-chaining every `ctx` read.
15
15
  *
16
16
  * This codemod reproduces that: every `ctx.` member-access chain becomes an
17
17
  * optional chain (`ctx?.a?.b?.[0]`). Optional chaining short-circuits to
@@ -112,7 +112,99 @@ export function transformCtxCompat(content: string): TransformResult {
112
112
  let i = 0;
113
113
  let count = 0;
114
114
 
115
+ // Contexts we are inside of, innermost last. Without this the scanner walks
116
+ // raw characters and happily rewrites `ctx.` inside string literals and
117
+ // comments: `console.log('ctx.device:', ctx.device)` became
118
+ // `console.log('ctx?.device:', ctx?.device)` — the call site correctly
119
+ // patched, the message silently corrupted.
120
+ //
121
+ // A template literal pushes "template" (its text is data), and a `${` inside
122
+ // one pushes "expr" (real code, where rewriting must resume) — so
123
+ // `` `${ctx.device}` `` is still rewritten while `` `ctx.device` `` is not.
124
+ //
125
+ // Regex literals are deliberately NOT tracked: telling `/re/` from division
126
+ // needs real parsing, and guessing wrong would consume live code. A `ctx.`
127
+ // inside a regex in a loader is vanishingly rare next to that risk.
128
+ const stack: ("template" | "expr")[] = [];
129
+
115
130
  while (i < content.length) {
131
+ const inTemplateText = stack[stack.length - 1] === "template";
132
+
133
+ if (inTemplateText) {
134
+ if (content[i] === "\\") {
135
+ out += content.slice(i, i + 2);
136
+ i += 2;
137
+ continue;
138
+ }
139
+ if (content[i] === "`") {
140
+ stack.pop();
141
+ out += content[i];
142
+ i += 1;
143
+ continue;
144
+ }
145
+ if (content.startsWith("${", i)) {
146
+ stack.push("expr");
147
+ out += "${";
148
+ i += 2;
149
+ continue;
150
+ }
151
+ out += content[i];
152
+ i += 1;
153
+ continue;
154
+ }
155
+
156
+ // Line comment — copy to end of line.
157
+ if (content.startsWith("//", i)) {
158
+ const nl = content.indexOf("\n", i);
159
+ const end = nl === -1 ? content.length : nl;
160
+ out += content.slice(i, end);
161
+ i = end;
162
+ continue;
163
+ }
164
+
165
+ // Block comment — copy to the closing delimiter.
166
+ if (content.startsWith("/*", i)) {
167
+ const close = content.indexOf("*/", i + 2);
168
+ const end = close === -1 ? content.length : close + 2;
169
+ out += content.slice(i, end);
170
+ i = end;
171
+ continue;
172
+ }
173
+
174
+ // Single/double quoted string — copy to the unescaped closing quote.
175
+ if (content[i] === "'" || content[i] === '"') {
176
+ const quote = content[i];
177
+ let j = i + 1;
178
+ while (j < content.length) {
179
+ if (content[j] === "\\") {
180
+ j += 2;
181
+ continue;
182
+ }
183
+ if (content[j] === quote || content[j] === "\n") {
184
+ j += 1;
185
+ break;
186
+ }
187
+ j += 1;
188
+ }
189
+ out += content.slice(i, j);
190
+ i = j;
191
+ continue;
192
+ }
193
+
194
+ if (content[i] === "`") {
195
+ stack.push("template");
196
+ out += content[i];
197
+ i += 1;
198
+ continue;
199
+ }
200
+
201
+ if (content[i] === "}" && stack[stack.length - 1] === "expr") {
202
+ stack.pop();
203
+ out += content[i];
204
+ i += 1;
205
+ continue;
206
+ }
207
+
116
208
  const isCtxToken =
117
209
  content.startsWith("ctx", i) &&
118
210
  !isIdentChar(content[i - 1]) &&
@@ -224,7 +224,7 @@ describe("transformHtmxOnEvents — idempotency + edge cases", () => {
224
224
  });
225
225
  });
226
226
 
227
- describe("transformHtmxOnEvents — als-shaped fixtures", () => {
227
+ describe("transformHtmxOnEvents — htmx-shaped fixtures", () => {
228
228
  it("AddToBagButton: hx-on-click + Fresh useScript → onClick + TODO", () => {
229
229
  const src = `import { useScript } from "site/sdk/useScript.ts";
230
230
 
@@ -61,8 +61,8 @@ const STANDARD_EVENT_MAP: Record<string, string> = {
61
61
  * handler bodies reference Fresh-only globals (`useScript`,
62
62
  * `globalThis.window.STOREFRONT`, `STOREFRONT.*`). The comment is the
63
63
  * only file-level annotation the codemod emits — per-occurrence
64
- * comments would balloon the diff for a 88-rename file like
65
- * als-storefront's hot paths. It is detected by an idempotency check
64
+ * comments would balloon the diff for an 88-rename file like a
65
+ * production storefront's hot paths. It is detected by an idempotency check
66
66
  * so re-running the codemod does not double-inject.
67
67
  */
68
68
  const TODO_MARKER = "// MIGRATION TODO (codemod: htmx-on-event-rename):";
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { transformJsx } from "./jsx";
3
+
4
+ describe("transformJsx — class in type annotations", () => {
5
+ it("renames an INLINE `class?:` type property (mid-line)", () => {
6
+ const src = `function P({ height, className: _c }: { height: string; class?: string }) {}`;
7
+ const out = transformJsx(src).content;
8
+ expect(out).toContain("className?: string");
9
+ expect(out).not.toContain("class?: string");
10
+ // the destructuring rename is preserved
11
+ expect(out).toContain("className: _c");
12
+ });
13
+
14
+ it("still renames a line-start `class?:` in an interface", () => {
15
+ const out = transformJsx(`interface Props {\n class?: string;\n}`).content;
16
+ expect(out).toContain("className?: string");
17
+ });
18
+
19
+ it("renames `class?:` inside a union member", () => {
20
+ const out = transformJsx(`type T = { a: 1 } | { class?: number };`).content;
21
+ expect(out).toContain("className?: number");
22
+ });
23
+
24
+ it("does NOT touch a runtime `class:` value (no `?`)", () => {
25
+ const out = transformJsx(`const x = { class: "btn" };`).content;
26
+ expect(out).toContain(`class: "btn"`);
27
+ });
28
+ });
@@ -195,6 +195,16 @@ export function transformJsx(content: string): TransformResult {
195
195
  notes.push("Replaced 'class' in interface definitions with 'className'");
196
196
  }
197
197
 
198
+ // `class?:` MID-LINE, inside an INLINE type literal
199
+ // (e.g. `{ height: string; class?: string }`). The `?` marks it as a type
200
+ // property — runtime objects never use `?:` — so this can't hit a real
201
+ // `class:` value. The line-start rule above misses the inline form.
202
+ if (/[{;,|(]\s*class\?\s*:/.test(result)) {
203
+ result = result.replace(/([{;,|(]\s*)class(\?\s*:)/g, "$1className$2");
204
+ changed = true;
205
+ notes.push("Replaced inline 'class?:' type property with 'className?:'");
206
+ }
207
+
198
208
  // Remove `alt` prop from non-img elements (<a>, <iframe>, <div>, etc.)
199
209
  // In React, `alt` is only valid on <img>, <input type="image">, <area>
200
210
  const altOnNonImgRegex = /(<(?:a|iframe|div|span|button|section)\s[^>]*?)\s+alt=(?:\{[^}]*\}|"[^"]*"|'[^']*')/g;
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { transformPicture } from "./picture";
3
+
4
+ const CLASSIC = `<Picture preload className="grid-pos">
5
+ <Source src={image.mobile} width={360} height={120} media="(max-width: 767px)" />
6
+ <Source src={image.desktop} width={1440} height={200} media="(min-width: 767px)" />
7
+ <img className="w-full" src={image.desktop} alt={image.alt ?? title} />
8
+ </Picture>`;
9
+
10
+ describe("transformPicture", () => {
11
+ it("converts <Source>+<img> children into sources[] + top-level props", () => {
12
+ const { content, changed, notes } = transformPicture(CLASSIC);
13
+ expect(changed).toBe(true);
14
+ expect(content).toContain("sources={[");
15
+ // both sources, values unwrapped into an object literal
16
+ expect(content).toContain(
17
+ `{ src: image.mobile, width: 360, height: 120, media: "(max-width: 767px)" }`,
18
+ );
19
+ expect(content).toContain(`{ src: image.desktop, width: 1440, height: 200, media:`);
20
+ // required-for-typecheck props lifted from the <img> fallback
21
+ expect(content).toContain("src={image.desktop}");
22
+ expect(content).toContain("width={1440}");
23
+ expect(content).toContain("alt={image.alt ?? title}");
24
+ // self-closing, children gone
25
+ expect(content).not.toContain("<Source");
26
+ expect(content).not.toContain("</Picture>");
27
+ expect(notes.some((n) => n.includes("sources[]"))).toBe(true);
28
+ });
29
+
30
+ it("does not emit a duplicate className (Picture's own wins)", () => {
31
+ const { content } = transformPicture(CLASSIC);
32
+ expect((content.match(/className=/g) ?? []).length).toBe(1);
33
+ expect(content).toContain(`className="grid-pos"`);
34
+ expect(content).not.toContain(`"w-full"`);
35
+ });
36
+
37
+ it("lifts the <img> className when the Picture declares none", () => {
38
+ const input = `<Picture preload><Source src={a} width={10} media="m" /><img className="w" src={a} alt={t} /></Picture>`;
39
+ expect(transformPicture(input).content).toContain(`className="w"`);
40
+ });
41
+
42
+ it("leaves a prop-based Picture (no <Source> children) untouched", () => {
43
+ const already = `<Picture sources={x} src={y} width={10} />`;
44
+ const { content, changed } = transformPicture(already);
45
+ expect(changed).toBe(false);
46
+ expect(content).toBe(already);
47
+ });
48
+
49
+ it("handles a Picture with no <img> fallback (top props from last Source)", () => {
50
+ const input = `<Picture><Source src={a} width={800} media="all" /></Picture>`;
51
+ const { content } = transformPicture(input);
52
+ expect(content).toContain("sources={[{ src: a, width: 800, media: \"all\" }]}");
53
+ expect(content).toContain("src={a}");
54
+ expect(content).toContain("width={800}");
55
+ });
56
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Picture API migration: children → props.
3
+ *
4
+ * Deco-fresh's `<Picture>` wrapped `<Source>` + an `<img>` fallback as CHILDREN.
5
+ * The new `@decocms/blocks` `Picture` is prop-based — `PictureProps` requires a
6
+ * `sources: PictureSourceProps[]` array plus `src`/`width` (from ImageProps).
7
+ * The children still render (`children ?? sources`) so a naive keep-children
8
+ * approach both fails the typecheck AND double-renders (children + the bottom
9
+ * `<Image>`). This transform converts the classic shape:
10
+ *
11
+ * <Picture ATTRS>
12
+ * <Source src={a} width={360} media="..." />
13
+ * <Source src={b} width={1440} media="..." />
14
+ * <img src={b} alt={x} className="..." />
15
+ * </Picture>
16
+ *
17
+ * into the prop form:
18
+ *
19
+ * <Picture ATTRS
20
+ * sources={[{ src: a, width: 360, media: "..." }, { src: b, width: 1440, media: "..." }]}
21
+ * src={b} width={1440} alt={x} className="..." />
22
+ *
23
+ * Only Pictures with `<Source>` children are touched; a Picture already using
24
+ * `sources={...}` props is left as-is.
25
+ */
26
+
27
+ import type { TransformResult } from "../types";
28
+
29
+ /** Parse a JSX tag's attribute string into name → raw value (`{expr}` / `"str"` / boolean). */
30
+ function parseAttrs(attrStr: string): Record<string, string> {
31
+ const attrs: Record<string, string> = {};
32
+ const re = /([A-Za-z_][\w-]*)(?:\s*=\s*(\{[^{}]*\}|"[^"]*"|'[^']*'))?/g;
33
+ let m: RegExpExecArray | null;
34
+ while ((m = re.exec(attrStr)) !== null) {
35
+ if (m[1]) attrs[m[1]] = m[2] ?? "";
36
+ }
37
+ return attrs;
38
+ }
39
+
40
+ /** Unwrap a raw JSX value for an object-literal field: `{image.x}` → `image.x`, `"s"` → `"s"`. */
41
+ function unwrap(v: string): string {
42
+ return v.startsWith("{") && v.endsWith("}") ? v.slice(1, -1) : v;
43
+ }
44
+
45
+ export function transformPicture(content: string): TransformResult {
46
+ const notes: string[] = [];
47
+ let changed = false;
48
+
49
+ const result = content.replace(
50
+ /<Picture(\s[^>]*?)?>([\s\S]*?)<\/Picture>/g,
51
+ (whole, attrStr: string | undefined, inner: string) => {
52
+ const sourceMatches = [...inner.matchAll(/<Source\s+([^/>]*?)\/>/g)];
53
+ if (sourceMatches.length === 0) return whole; // not the children pattern
54
+
55
+ const sources = sourceMatches.map((mm) => parseAttrs(mm[1]));
56
+ const imgMatch = inner.match(/<img\s+([^/>]*?)\/>/);
57
+ const img = imgMatch ? parseAttrs(imgMatch[1]) : {};
58
+ const last = sources[sources.length - 1]!;
59
+
60
+ // sources={[{ src, width, height, media, sizes }, ...]}
61
+ const srcArr = sources
62
+ .map((s) => {
63
+ const fields = (["src", "width", "height", "media", "sizes"] as const)
64
+ .filter((k) => s[k] !== undefined)
65
+ .map((k) => `${k}: ${unwrap(s[k]!)}`);
66
+ return `{ ${fields.join(", ")} }`;
67
+ })
68
+ .join(", ");
69
+
70
+ // The required-for-typecheck props (src/width/height/alt) come from the
71
+ // <img> fallback, else the last <Source>.
72
+ const pick = (k: string): string | undefined => img[k] ?? last[k];
73
+ const top: string[] = [];
74
+ for (const k of ["src", "width", "height", "alt"] as const) {
75
+ const v = pick(k);
76
+ if (v !== undefined && v !== "") top.push(`${k}=${v}`);
77
+ }
78
+ // className/loading: only lift from the <img> if the <Picture> doesn't
79
+ // already declare it — otherwise we'd emit a duplicate attribute (React
80
+ // keeps the last, silently dropping the Picture's own classes).
81
+ const pictureAttrs = parseAttrs(attrStr ?? "");
82
+ for (const k of ["className", "loading"] as const) {
83
+ if (img[k] !== undefined && img[k] !== "" && pictureAttrs[k] === undefined) {
84
+ top.push(`${k}=${img[k]}`);
85
+ }
86
+ }
87
+
88
+ changed = true;
89
+ notes.push(`Picture: ${sources.length} <Source> children → sources[] props`);
90
+ return `<Picture${attrStr ?? ""} sources={[${srcArr}]} ${top.join(" ")} />`;
91
+ },
92
+ );
93
+
94
+ return { content: result, changed, notes };
95
+ }
@@ -35,3 +35,32 @@ describe("renameToken", () => {
35
35
  expect(renameToken("md:bg-red-500")).toBe("md:bg-red-500");
36
36
  });
37
37
  });
38
+
39
+ import { detectDaisyUiV5StructuralIssues } from "./tailwind-renames";
40
+
41
+ describe("detectDaisyUiV5StructuralIssues", () => {
42
+ it("detects menu-compact removal", () => {
43
+ const findings = detectDaisyUiV5StructuralIssues(`<ul className="menu menu-compact">`);
44
+ expect(findings.some((f) => f.message.includes("menu-compact"))).toBe(true);
45
+ });
46
+
47
+ it("detects tab-bordered removal", () => {
48
+ const findings = detectDaisyUiV5StructuralIssues(`<a className="tab tab-bordered">`);
49
+ expect(findings.some((f) => f.message.includes("tab-bordered"))).toBe(true);
50
+ });
51
+
52
+ it("detects bare alert in className without a color modifier", () => {
53
+ const findings = detectDaisyUiV5StructuralIssues(`<div className="alert">`);
54
+ expect(findings.some((f) => f.message.includes("alert"))).toBe(true);
55
+ });
56
+
57
+ it("does not flag alert when a color modifier is present", () => {
58
+ const findings = detectDaisyUiV5StructuralIssues(`<div className="alert alert-info">`);
59
+ expect(findings.some((f) => f.message.includes("alert default styling"))).toBe(false);
60
+ });
61
+
62
+ it("does not flag alert() JS function calls as a DaisyUI alert", () => {
63
+ const findings = detectDaisyUiV5StructuralIssues(`window.alert("something went wrong");`);
64
+ expect(findings.some((f) => f.message.includes("alert default styling"))).toBe(false);
65
+ });
66
+ });
@@ -79,6 +79,8 @@ export const CLASS_RENAMES: Record<string, string> = {
79
79
  export const DAISYUI_RENAMES: Record<string, string> = {
80
80
  "badge-ghost": "badge-soft",
81
81
  "card-compact": "card-sm",
82
+ "card-normal": "card-md",
83
+ "tabs-boxed": "tabs-box",
82
84
  };
83
85
 
84
86
  /**
@@ -183,6 +185,38 @@ export function detectDaisyUiV5StructuralIssues(content: string): GotchaFinding[
183
185
  });
184
186
  }
185
187
 
188
+ if (/\bmenu-compact\b/.test(content)) {
189
+ findings.push({
190
+ gotcha: 37,
191
+ message:
192
+ "DaisyUI v4 `menu-compact` was removed in v5 with no drop-in class — use `menu-sm` for a smaller menu or remove the modifier.",
193
+ });
194
+ }
195
+
196
+ if (/\btab-bordered\b/.test(content)) {
197
+ findings.push({
198
+ gotcha: 37,
199
+ message:
200
+ "DaisyUI v4 `tab-bordered` was removed in v5 — use `tabs-bordered` on the `tabs` wrapper instead of a modifier on each `tab` element.",
201
+ });
202
+ }
203
+
204
+ // Detect bare `alert` without a color modifier — DaisyUI v5 changed the
205
+ // default alert background; alerts without alert-info/success/warning/error
206
+ // may appear with no background color.
207
+ // Scoped to className= / class= attribute values to avoid matching the JS
208
+ // global `alert()` function, variable names, or comments in the same file.
209
+ if (
210
+ /className=["'][^"']*\balert\b[^"']*["']/.test(content) &&
211
+ !/\balert-(?:info|success|warning|error)\b/.test(content)
212
+ ) {
213
+ findings.push({
214
+ gotcha: 37,
215
+ message:
216
+ "DaisyUI v5 changed `alert` default styling — a bare `alert` without a color modifier (alert-info/success/warning/error) may render with no background. Add an explicit color modifier or use `alert-soft` for a neutral variant.",
217
+ });
218
+ }
219
+
186
220
  return findings;
187
221
  }
188
222
 
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { transformTimerTypes } from "./timer-types";
3
+
4
+ describe("transformTimerTypes", () => {
5
+ it("retypes a `number` handle assigned setTimeout", () => {
6
+ const r = transformTimerTypes(`let hideTimeout: number;\nhideTimeout = setTimeout(() => {}, 100);`);
7
+ expect(r.changed).toBe(true);
8
+ expect(r.content).toContain("let hideTimeout: ReturnType<typeof setTimeout>");
9
+ });
10
+
11
+ it("retypes a setInterval handle", () => {
12
+ const r = transformTimerTypes(`let poll: number;\npoll = setInterval(fn, 1000);`);
13
+ expect(r.content).toContain("let poll: ReturnType<typeof setInterval>");
14
+ });
15
+
16
+ it("leaves an unrelated numeric variable alone", () => {
17
+ const r = transformTimerTypes(`let count: number;\ncount = 5;\nlet t: number;\nt = setTimeout(f, 1);`);
18
+ expect(r.content).toContain("let count: number");
19
+ expect(r.content).toContain("let t: ReturnType<typeof setTimeout>");
20
+ });
21
+
22
+ it("handles the window.setTimeout form", () => {
23
+ const r = transformTimerTypes(`let x: number;\nx = window.setTimeout(f, 1);`);
24
+ expect(r.content).toContain("let x: ReturnType<typeof setTimeout>");
25
+ });
26
+
27
+ it("is a no-op when the handle is never assigned a timer", () => {
28
+ expect(transformTimerTypes(`let y: number;`).changed).toBe(false);
29
+ });
30
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `let x: number = setTimeout(...)` typing fix.
3
+ *
4
+ * Deco-fresh (Deno DOM types) had `setTimeout`/`setInterval` return `number`, so
5
+ * sites typed their handles `let hideTimeout: number`. Under Node/TanStack types
6
+ * they return `NodeJS.Timeout`, so the assignment fails ("Type 'Timeout' is not
7
+ * assignable to type 'number'"). Rather than `window.setTimeout` (unsafe in
8
+ * server `.ts`), retype the DECLARATION to `ReturnType<typeof setTimeout>` — the
9
+ * portable handle type, correct in both browser and worker/node.
10
+ *
11
+ * Only a `let|const|var NAME: number` declaration whose NAME is assigned a
12
+ * `setTimeout(` / `setInterval(` elsewhere in the file is retyped; a plain
13
+ * numeric variable is never touched.
14
+ */
15
+
16
+ import type { TransformResult } from "../types";
17
+
18
+ export function transformTimerTypes(content: string): TransformResult {
19
+ const notes: string[] = [];
20
+ let changed = false;
21
+ let result = content;
22
+
23
+ for (const kind of ["setTimeout", "setInterval"] as const) {
24
+ // Names assigned this timer: `NAME = setTimeout(` (and `= window.setTimeout(`).
25
+ const assigned = new Set<string>();
26
+ for (const m of content.matchAll(
27
+ new RegExp(`\\b([A-Za-z_$][\\w$]*)\\s*=\\s*(?:window\\.)?${kind}\\s*\\(`, "g"),
28
+ )) {
29
+ assigned.add(m[1]!);
30
+ }
31
+
32
+ for (const name of assigned) {
33
+ // Retype only the declaration's `: number` (allowing `| undefined`/`| null`).
34
+ const declRe = new RegExp(
35
+ `\\b(let|const|var)(\\s+${name}\\s*:\\s*)number\\b`,
36
+ "g",
37
+ );
38
+ const next = result.replace(declRe, `$1$2ReturnType<typeof ${kind}>`);
39
+ if (next !== result) {
40
+ result = next;
41
+ changed = true;
42
+ notes.push(`Timer type: ${name}: number → ReturnType<typeof ${kind}>`);
43
+ }
44
+ }
45
+ }
46
+
47
+ return { content: result, changed, notes };
48
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { transformUseScriptHandlers } from "./use-script-handlers";
3
+
4
+ describe("transformUseScriptHandlers", () => {
5
+ it("rewrites an identifier callback + args to an arrow", () => {
6
+ const r = transformUseScriptHandlers(`<button onClick={useScript(onClick, -1)} />`);
7
+ expect(r.changed).toBe(true);
8
+ expect(r.content).toContain(`onClick={() => onClick(-1)}`);
9
+ });
10
+
11
+ it("handles a no-arg identifier callback", () => {
12
+ const r = transformUseScriptHandlers(`<button onClick={useScript(handleClick)} />`);
13
+ expect(r.content).toContain(`onClick={() => handleClick()}`);
14
+ });
15
+
16
+ it("handles multiple args and any onEvent name", () => {
17
+ const r = transformUseScriptHandlers(`<a onMouseEnter={useScript(prefetch, id, "x")} />`);
18
+ expect(r.content).toContain(`onMouseEnter={() => prefetch(id, "x")}`);
19
+ });
20
+
21
+ it("leaves an INLINE arrow untouched (per-site closure → manual)", () => {
22
+ const src = `<button onClick={useScript(({ x }) => { doThing(x); })} />`;
23
+ const r = transformUseScriptHandlers(src);
24
+ expect(r.changed).toBe(false);
25
+ expect(r.content).toBe(src);
26
+ });
27
+
28
+ it("leaves an arg with nested parens untouched (safety)", () => {
29
+ const src = `<button onClick={useScript(fn, foo(x))} />`;
30
+ expect(transformUseScriptHandlers(src).changed).toBe(false);
31
+ });
32
+
33
+ it("does not touch a non-handler useScript call", () => {
34
+ const src = `const s = useScript(fn, 1);`;
35
+ expect(transformUseScriptHandlers(src).changed).toBe(false);
36
+ });
37
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `useScript` in React event handlers → a plain arrow.
3
+ *
4
+ * Deco-fresh's `useScript(fn, ...args)` serialized `fn` to a client-side script
5
+ * STRING (hydration-free interactivity), so `onClick={useScript(onClick, -1)}`
6
+ * passed a string to `onClick`. React wants a FUNCTION and the stricter TanStack
7
+ * typecheck fails ("Type 'string' is not assignable to MouseEventHandler").
8
+ * Hydrated React just calls the function directly:
9
+ *
10
+ * onClick={useScript(onClick, -1)} → onClick={() => onClick(-1)}
11
+ *
12
+ * Only the SIMPLE, unambiguous shape is rewritten — an identifier callback plus
13
+ * literal args (no nested parens/braces). An INLINE arrow
14
+ * (`useScript(({x}) => {…})`) depends on how the closure was serialized and is a
15
+ * per-site call, so it's left for the `htmx-residue` audit + manual rewrite.
16
+ */
17
+
18
+ import type { TransformResult } from "../types";
19
+
20
+ export function transformUseScriptHandlers(content: string): TransformResult {
21
+ const notes: string[] = [];
22
+ let changed = false;
23
+
24
+ // onEvent={useScript(IDENT[, simple args])} — IDENT is a bare identifier /
25
+ // member expr; args contain no nested parens or braces (keeps it safe).
26
+ const re = /\b(on[A-Z]\w+)=\{useScript\(\s*([A-Za-z_$][\w$.]*)\s*((?:,[^{}()]*)?)\)\}/g;
27
+
28
+ const result = content.replace(re, (_whole, event: string, fn: string, argsRaw: string) => {
29
+ const args = argsRaw.replace(/^\s*,\s*/, "").trim();
30
+ changed = true;
31
+ notes.push(`useScript handler: ${event}={useScript(${fn}…)} → () => ${fn}(…)`);
32
+ return `${event}={() => ${fn}(${args})}`;
33
+ });
34
+
35
+ return { content: result, changed, notes };
36
+ }
@@ -132,9 +132,15 @@ export interface LoaderInfo {
132
132
 
133
133
  export interface MigrationContext {
134
134
  sourceDir: string;
135
+ /**
136
+ * Source directory layout: `"classic"` (sections/ at root) or `"modern"`
137
+ * (src/sections/). Drives whether the analyzer also scans `src/`. Set in
138
+ * migrate.ts after detection; defaults to `"classic"`.
139
+ */
140
+ layout: import("./source-layout").SourceLayout;
135
141
  siteName: string;
136
142
  platform: Platform;
137
- /** VTEX account name (e.g. "casaevideonewio") — extracted from source code */
143
+ /** VTEX account name (e.g. "acme") — extracted from source code */
138
144
  vtexAccount: string | null;
139
145
  gtmId: string | null;
140
146
 
@@ -156,6 +162,8 @@ export interface MigrationContext {
156
162
  themeColors: Record<string, string>;
157
163
  /** Font family from CMS config */
158
164
  fontFamily: string | null;
165
+ /** Google Fonts URLs extracted from routes/_app.tsx or CSS @import */
166
+ googleFonts: { preconnects: string[]; stylesheets: string[] };
159
167
 
160
168
  /**
161
169
  * Statically-extracted content of the source site's `tailwind.config.ts`
@@ -217,6 +225,7 @@ export function createContext(
217
225
  ): MigrationContext {
218
226
  return {
219
227
  sourceDir,
228
+ layout: "classic",
220
229
  siteName: "",
221
230
  platform: "custom",
222
231
  vtexAccount: null,
@@ -226,10 +235,13 @@ export function createContext(
226
235
  discoveredNpmDeps: {},
227
236
  themeColors: {},
228
237
  fontFamily: null,
238
+ googleFonts: { preconnects: [], stylesheets: [] },
229
239
  tailwindConfig: {
230
240
  colors: {},
231
241
  fontFamily: {},
232
242
  screens: {},
243
+ animations: {},
244
+ keyframes: {},
233
245
  safelist: [],
234
246
  safelistPatterns: [],
235
247
  plugins: [],