@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.
- package/package.json +5 -3
- package/scripts/analyze-traces.mjs +1 -0
- package/scripts/cdn-rules.test.ts +105 -0
- package/scripts/cdn-rules.ts +192 -0
- package/scripts/deco-migrate-cli.ts +6 -7
- package/scripts/fast-deploy-kv.test.ts +172 -0
- package/scripts/generate-blocks.test.ts +11 -0
- package/scripts/generate-blocks.ts +0 -10
- package/scripts/generate-invoke.test.ts +2 -2
- package/scripts/generate-invoke.ts +9 -21
- package/scripts/generate-loaders.test.ts +2 -2
- package/scripts/generate-loaders.ts +0 -10
- package/scripts/generate-schema-loader-aliases.test.ts +186 -0
- package/scripts/generate-schema-workspaces.test.ts +180 -0
- package/scripts/generate-schema.test.ts +68 -29
- package/scripts/generate-schema.ts +101 -89
- package/scripts/generate-sections.test.ts +104 -35
- package/scripts/generate-sections.ts +57 -11
- package/scripts/generate-workspaces.test.ts +179 -0
- package/scripts/generate.test.ts +1 -2
- package/scripts/generate.ts +43 -45
- package/scripts/lib/blocks-dedupe.test.ts +1 -1
- package/scripts/lib/cf-kv-rest.ts +49 -1
- package/scripts/lib/installedPackages.ts +42 -0
- package/scripts/lib/invokeSource.ts +18 -0
- package/scripts/lib/kv-snapshot.ts +44 -0
- package/scripts/lib/read-decofile.ts +13 -2
- package/scripts/lib/upgrade-6-to-7.ts +1 -1
- package/scripts/lib/wrangler-config.test.ts +49 -0
- package/scripts/lib/wrangler-config.ts +31 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
- package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
- package/scripts/migrate/config.ts +2 -2
- package/scripts/migrate/delete-sets.ts +75 -0
- package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
- package/scripts/migrate/icon-casing.test.ts +63 -0
- package/scripts/migrate/phase-analyze.test.ts +66 -4
- package/scripts/migrate/phase-analyze.ts +79 -60
- package/scripts/migrate/phase-cleanup.test.ts +3 -1
- package/scripts/migrate/phase-cleanup.ts +1 -1
- package/scripts/migrate/phase-compile.test.ts +3 -1
- package/scripts/migrate/phase-report.test.ts +2 -0
- package/scripts/migrate/phase-report.ts +3 -4
- package/scripts/migrate/phase-scaffold.ts +150 -45
- package/scripts/migrate/phase-transform.ts +107 -2
- package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
- package/scripts/migrate/phase-verify.test.ts +3 -1
- package/scripts/migrate/phase-verify.ts +87 -33
- package/scripts/migrate/post-cleanup/rules.ts +29 -11
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
- package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
- package/scripts/migrate/source-layout.test.ts +43 -0
- package/scripts/migrate/source-layout.ts +32 -5
- package/scripts/migrate/templates/app-css.test.ts +24 -0
- package/scripts/migrate/templates/app-css.ts +27 -0
- package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
- package/scripts/migrate/templates/ci-yml.ts +182 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
- package/scripts/migrate/templates/hooks.test.ts +3 -1
- package/scripts/migrate/templates/lib-utils.ts +3 -3
- package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
- package/scripts/migrate/templates/package-json.ts +10 -0
- package/scripts/migrate/templates/parity-yml.ts +136 -0
- package/scripts/migrate/templates/perf-yml.ts +449 -0
- package/scripts/migrate/templates/playwright-yml.ts +127 -0
- package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
- package/scripts/migrate/templates/routes.test.ts +31 -0
- package/scripts/migrate/templates/routes.ts +19 -9
- package/scripts/migrate/templates/section-loaders.ts +3 -3
- package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
- package/scripts/migrate/templates/server-entry.ts +19 -14
- package/scripts/migrate/templates/setup.ts +11 -3
- package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
- package/scripts/migrate/templates/ui-components.ts +3 -0
- package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
- package/scripts/migrate/transforms/ctx-compat.ts +93 -1
- package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
- package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
- package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
- package/scripts/migrate/transforms/jsx.ts +10 -0
- package/scripts/migrate/transforms/picture.test.ts +56 -0
- package/scripts/migrate/transforms/picture.ts +95 -0
- package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
- package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
- package/scripts/migrate/transforms/timer-types.test.ts +30 -0
- package/scripts/migrate/transforms/timer-types.ts +48 -0
- package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
- package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
- package/scripts/migrate/types.ts +13 -1
- package/scripts/migrate-blocks-to-kv.ts +16 -2
- package/scripts/migrate-to-cf-observability.test.ts +5 -5
- package/scripts/migrate-to-cf-observability.ts +1 -1
- package/scripts/migrate.ts +244 -6
- package/scripts/reconcile.test.ts +162 -0
- package/scripts/reconcile.ts +303 -0
- package/scripts/sync-blocks-bot.test.ts +276 -0
- package/scripts/sync-blocks-bot.ts +567 -0
- package/scripts/sync-blocks-to-kv.ts +20 -2
- package/scripts/upgrade-6-to-7.ts +0 -0
- 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
|
-
* (
|
|
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 —
|
|
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
|
|
65
|
-
*
|
|
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
|
+
}
|
package/scripts/migrate/types.ts
CHANGED
|
@@ -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. "
|
|
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: [],
|