@decocms/start 2.21.0 → 2.23.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/.agents/skills/deco-to-tanstack-migration/SKILL.md +12 -7
- package/.agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md +21 -0
- package/.agents/skills/deco-to-tanstack-migration/references/platform-hooks/README.md +57 -47
- package/.agents/skills/deco-to-tanstack-migration/references/platform-hooks-factories.md +186 -0
- package/MIGRATION_TOOLING_PLAN.md +293 -6
- package/package.json +1 -1
- package/scripts/migrate/phase-transform.ts +7 -1
- package/scripts/migrate/post-cleanup/rules.ts +62 -7
- package/scripts/migrate/post-cleanup/runner.test.ts +77 -1
- package/scripts/migrate/templates/commerce-loaders.ts +2 -1
- package/scripts/migrate/templates/routes.ts +15 -10
- package/scripts/migrate/templates/server-entry.ts +13 -55
- package/scripts/migrate/templates/vite-config.ts +0 -35
- package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
- package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
|
@@ -216,50 +216,20 @@ declare module "@tanstack/react-router" {
|
|
|
216
216
|
|
|
217
217
|
function generateRuntime(): string {
|
|
218
218
|
return `/**
|
|
219
|
-
* Runtime invoke proxy.
|
|
219
|
+
* Runtime invoke proxy — re-exports the framework canonical from @decocms/start/sdk.
|
|
220
220
|
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
221
|
+
* The implementation (typed RPC over /deco/invoke, dotted-path proxy, .ts
|
|
222
|
+
* suffix fallback) lives in @decocms/start/sdk/invoke. This file exists so
|
|
223
|
+
* existing site code can keep \`import { invoke } from "~/runtime"\` and
|
|
224
|
+
* \`Runtime.invoke\` shapes without churn.
|
|
225
225
|
*
|
|
226
|
-
*
|
|
227
|
-
* (registered loaders may have ".ts" extensions in their keys).
|
|
226
|
+
* Don't reimplement here — extend @decocms/start/sdk/invoke instead.
|
|
228
227
|
*/
|
|
229
|
-
|
|
230
|
-
return new Proxy(
|
|
231
|
-
Object.assign(async (props: any) => {
|
|
232
|
-
const key = path.join("/");
|
|
233
|
-
for (const k of [key, \`\${key}.ts\`]) {
|
|
234
|
-
const response = await fetch(\`/deco/invoke/\${k}\`, {
|
|
235
|
-
method: "POST",
|
|
236
|
-
headers: { "Content-Type": "application/json" },
|
|
237
|
-
body: JSON.stringify(props ?? {}),
|
|
238
|
-
});
|
|
239
|
-
if (response.status === 404) continue;
|
|
240
|
-
if (!response.ok) {
|
|
241
|
-
throw new Error(\`invoke(\${k}) failed: \${response.status}\`);
|
|
242
|
-
}
|
|
243
|
-
return response.json();
|
|
244
|
-
}
|
|
245
|
-
throw new Error(\`invoke(\${key}) failed: handler not found\`);
|
|
246
|
-
}, {}),
|
|
247
|
-
{
|
|
248
|
-
get(_target: any, prop: string) {
|
|
249
|
-
if (prop === "then" || prop === "catch" || prop === "finally") {
|
|
250
|
-
return undefined;
|
|
251
|
-
}
|
|
252
|
-
return createNestedInvokeProxy([...path, prop]);
|
|
253
|
-
},
|
|
254
|
-
},
|
|
255
|
-
);
|
|
256
|
-
}
|
|
228
|
+
import { invoke } from "@decocms/start/sdk";
|
|
257
229
|
|
|
258
|
-
export
|
|
230
|
+
export { invoke };
|
|
259
231
|
|
|
260
|
-
export const Runtime = {
|
|
261
|
-
invoke,
|
|
262
|
-
};
|
|
232
|
+
export const Runtime = { invoke };
|
|
263
233
|
`;
|
|
264
234
|
}
|
|
265
235
|
|
|
@@ -299,11 +269,8 @@ export const invoke = {} as const;
|
|
|
299
269
|
* auto-generated in invoke.gen.ts. Run \`npm run generate:invoke\` to update.
|
|
300
270
|
*/
|
|
301
271
|
import { createServerFn } from "@tanstack/react-start";
|
|
302
|
-
import {
|
|
303
|
-
|
|
304
|
-
getResponseHeaders,
|
|
305
|
-
setResponseHeader,
|
|
306
|
-
} from "@tanstack/react-start/server";
|
|
272
|
+
import { getRequestHeader } from "@tanstack/react-start/server";
|
|
273
|
+
import { forwardResponseCookies } from "@decocms/start/sdk/cookiePassthrough";
|
|
307
274
|
import { vtexActions } from "./invoke.gen";
|
|
308
275
|
${hasVtexAuthLoader ? `import vtexAuthLoader from "../loaders/vtex-auth-loader";\n` : ""}import {
|
|
309
276
|
extractVtexCookiesFromHeader,
|
|
@@ -314,15 +281,6 @@ ${hasVtexAuthLoader ? `import vtexAuthLoader from "../loaders/vtex-auth-loader";
|
|
|
314
281
|
|
|
315
282
|
export type { OrderForm } from "./invoke.gen";
|
|
316
283
|
|
|
317
|
-
function mergeSetCookies(newCookies: string[]): void {
|
|
318
|
-
if (newCookies.length === 0) return;
|
|
319
|
-
const existing: string[] =
|
|
320
|
-
typeof getResponseHeaders().getSetCookie === "function"
|
|
321
|
-
? getResponseHeaders().getSetCookie()
|
|
322
|
-
: [];
|
|
323
|
-
setResponseHeader("set-cookie", [...existing, ...newCookies]);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
284
|
function getVtexCookies(): string {
|
|
327
285
|
return extractVtexCookiesFromHeader(getRequestHeader("cookie") ?? "");
|
|
328
286
|
}
|
|
@@ -336,7 +294,7 @@ ${hasVtexAuthLoader ? `const _vtexAuth = createServerFn({ method: "POST" })
|
|
|
336
294
|
} as any);
|
|
337
295
|
if (result instanceof Response) {
|
|
338
296
|
const setCookies = result.headers.getSetCookie?.() ?? [];
|
|
339
|
-
|
|
297
|
+
forwardResponseCookies(stripCookieDomain(setCookies));
|
|
340
298
|
return result.json();
|
|
341
299
|
}
|
|
342
300
|
return result;
|
|
@@ -344,7 +302,7 @@ ${hasVtexAuthLoader ? `const _vtexAuth = createServerFn({ method: "POST" })
|
|
|
344
302
|
` : ""}const _logout = createServerFn({ method: "POST" }).handler(
|
|
345
303
|
async (): Promise<{ success: boolean }> => {
|
|
346
304
|
const { setCookies } = await performVtexLogout(getVtexCookies());
|
|
347
|
-
|
|
305
|
+
forwardResponseCookies(setCookies);
|
|
348
306
|
return { success: true };
|
|
349
307
|
},
|
|
350
308
|
);
|
|
@@ -53,41 +53,6 @@ export default defineConfig({
|
|
|
53
53
|
}),
|
|
54
54
|
tailwindcss(),
|
|
55
55
|
decoVitePlugin(),
|
|
56
|
-
{
|
|
57
|
-
name: "site-manual-chunks",
|
|
58
|
-
config(_cfg, { command }) {
|
|
59
|
-
if (command !== "build") return;
|
|
60
|
-
return {
|
|
61
|
-
build: {
|
|
62
|
-
rollupOptions: {
|
|
63
|
-
output: {
|
|
64
|
-
manualChunks(id: string) {
|
|
65
|
-
if (id.includes("node_modules/react-dom") || id.includes("node_modules/react/"))
|
|
66
|
-
return "vendor-react";
|
|
67
|
-
if (id.includes("@tanstack/react-router") || id.includes("@tanstack/start"))
|
|
68
|
-
return "vendor-router";
|
|
69
|
-
if (id.includes("@tanstack/react-query")) return "vendor-query";
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
};
|
|
75
|
-
},
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
name: "deco-stub-meta-gen",
|
|
79
|
-
enforce: "pre" as const,
|
|
80
|
-
resolveId(id, importer, options) {
|
|
81
|
-
if (!options?.ssr && importer && id.includes("meta.gen")) {
|
|
82
|
-
return "\\0stub:meta-gen";
|
|
83
|
-
}
|
|
84
|
-
},
|
|
85
|
-
load(id) {
|
|
86
|
-
if (id === "\\0stub:meta-gen") {
|
|
87
|
-
return "export default {};";
|
|
88
|
-
}
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
56
|
],
|
|
92
57
|
build: {
|
|
93
58
|
sourcemap: "hidden",
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
_internals,
|
|
4
|
+
transformHtmxOnEvents,
|
|
5
|
+
} from "./htmx-on-events";
|
|
6
|
+
|
|
7
|
+
const { STANDARD_EVENT_MAP, TODO_MARKER } = _internals;
|
|
8
|
+
|
|
9
|
+
describe("transformHtmxOnEvents — basic renames", () => {
|
|
10
|
+
it("renames hx-on:click → onClick (colon variant)", () => {
|
|
11
|
+
const src = `<button hx-on:click={() => alert("hi")}>click</button>`;
|
|
12
|
+
const r = transformHtmxOnEvents(src);
|
|
13
|
+
expect(r.changed).toBe(true);
|
|
14
|
+
expect(r.content).toBe(
|
|
15
|
+
`<button onClick={() => alert("hi")}>click</button>`,
|
|
16
|
+
);
|
|
17
|
+
expect(r.notes[0]).toContain("Renamed 1 hx-on:* attribute(s)");
|
|
18
|
+
expect(r.notes[0]).toContain("onClick=1");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("renames hx-on-click → onClick (dash variant)", () => {
|
|
22
|
+
const src = `<button hx-on-click={fn}>click</button>`;
|
|
23
|
+
const r = transformHtmxOnEvents(src);
|
|
24
|
+
expect(r.changed).toBe(true);
|
|
25
|
+
expect(r.content).toBe(`<button onClick={fn}>click</button>`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("preserves whitespace around `=`", () => {
|
|
29
|
+
const src = `<button hx-on:click = {fn}>x</button>`;
|
|
30
|
+
const r = transformHtmxOnEvents(src);
|
|
31
|
+
expect(r.content).toBe(`<button onClick = {fn}>x</button>`);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("renames every standard event in the map", () => {
|
|
35
|
+
for (const [htmxEvent, reactName] of Object.entries(STANDARD_EVENT_MAP)) {
|
|
36
|
+
const src = `<x hx-on:${htmxEvent}={fn}/>`;
|
|
37
|
+
const r = transformHtmxOnEvents(src);
|
|
38
|
+
expect(r.content, `event=${htmxEvent}`).toBe(`<x ${reactName}={fn}/>`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("renames every standard event in the map (dash variant)", () => {
|
|
43
|
+
for (const [htmxEvent, reactName] of Object.entries(STANDARD_EVENT_MAP)) {
|
|
44
|
+
const src = `<x hx-on-${htmxEvent}={fn}/>`;
|
|
45
|
+
const r = transformHtmxOnEvents(src);
|
|
46
|
+
expect(r.content, `event=${htmxEvent}`).toBe(`<x ${reactName}={fn}/>`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("handles multiple events on the same element", () => {
|
|
51
|
+
const src = `<input hx-on:change={a} hx-on:keyup={b} hx-on:focus={c}/>`;
|
|
52
|
+
const r = transformHtmxOnEvents(src);
|
|
53
|
+
expect(r.content).toBe(
|
|
54
|
+
`<input onChange={a} onKeyUp={b} onFocus={c}/>`,
|
|
55
|
+
);
|
|
56
|
+
expect(r.notes[0]).toContain("Renamed 3 hx-on:* attribute(s)");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("handles multi-line attribute values", () => {
|
|
60
|
+
const src = `<button
|
|
61
|
+
hx-on:click={() => {
|
|
62
|
+
setLoading(true);
|
|
63
|
+
doStuff();
|
|
64
|
+
}}>
|
|
65
|
+
Submit
|
|
66
|
+
</button>`;
|
|
67
|
+
const r = transformHtmxOnEvents(src);
|
|
68
|
+
expect(r.content).toContain("onClick={() => {");
|
|
69
|
+
expect(r.content).toContain("setLoading(true);");
|
|
70
|
+
expect(r.content).not.toContain("hx-on");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("handles string-valued events (rare but legal htmx)", () => {
|
|
74
|
+
const src = `<button hx-on:click="alert('hi')">x</button>`;
|
|
75
|
+
const r = transformHtmxOnEvents(src);
|
|
76
|
+
expect(r.content).toBe(`<button onClick="alert('hi')">x</button>`);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("transformHtmxOnEvents — what stays untouched", () => {
|
|
81
|
+
it("leaves htmx lifecycle events alone (htmx-config-request, htmx-before-request, ...)", () => {
|
|
82
|
+
const src = `<form hx-post="/x"
|
|
83
|
+
hx-on:htmx-before-request={a}
|
|
84
|
+
hx-on:htmx-config-request={b}
|
|
85
|
+
hx-on-htmx-after-swap={c}
|
|
86
|
+
/>`;
|
|
87
|
+
const r = transformHtmxOnEvents(src);
|
|
88
|
+
expect(r.changed).toBe(false);
|
|
89
|
+
expect(r.content).toBe(src);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("renames standard events but preserves htmx lifecycle on the same element", () => {
|
|
93
|
+
const src = `<form hx-post="/x" hx-on:submit={validate} hx-on:htmx-before-request={addCsrf}>...</form>`;
|
|
94
|
+
const r = transformHtmxOnEvents(src);
|
|
95
|
+
expect(r.changed).toBe(true);
|
|
96
|
+
expect(r.content).toBe(
|
|
97
|
+
`<form hx-post="/x" onSubmit={validate} hx-on:htmx-before-request={addCsrf}>...</form>`,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("leaves unknown/custom events alone (no React synthetic equivalent)", () => {
|
|
102
|
+
const src = `<x hx-on:my-custom-event={fn} hx-on-other-thing={fn2}/>`;
|
|
103
|
+
const r = transformHtmxOnEvents(src);
|
|
104
|
+
expect(r.changed).toBe(false);
|
|
105
|
+
expect(r.content).toBe(src);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("leaves non-event hx-* attributes alone (hx-post, hx-target, hx-swap, ...)", () => {
|
|
109
|
+
const src = `<form hx-post="/x" hx-target="#r" hx-swap="innerHTML" hx-trigger="submit"/>`;
|
|
110
|
+
const r = transformHtmxOnEvents(src);
|
|
111
|
+
expect(r.changed).toBe(false);
|
|
112
|
+
expect(r.content).toBe(src);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("leaves already-React onClick alone", () => {
|
|
116
|
+
const src = `<button onClick={fn}>x</button>`;
|
|
117
|
+
const r = transformHtmxOnEvents(src);
|
|
118
|
+
expect(r.changed).toBe(false);
|
|
119
|
+
expect(r.content).toBe(src);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("does not match attribute names that merely contain 'hx-on' as a substring", () => {
|
|
123
|
+
const src = `<x data-hx-onfoo={fn} aria-hx-on="x"/>`;
|
|
124
|
+
const r = transformHtmxOnEvents(src);
|
|
125
|
+
expect(r.changed).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns unchanged on files with no hx-on at all (fast path)", () => {
|
|
129
|
+
const src = `<button onClick={fn}>x</button>\n<form hx-post="/x"/>`;
|
|
130
|
+
const r = transformHtmxOnEvents(src);
|
|
131
|
+
expect(r.changed).toBe(false);
|
|
132
|
+
expect(r.content).toBe(src);
|
|
133
|
+
expect(r.notes).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("transformHtmxOnEvents — Fresh-isms TODO injection", () => {
|
|
138
|
+
it("injects a top-of-file MIGRATION TODO when handler references useScript()", () => {
|
|
139
|
+
const src = `import { useScript } from "site/sdk/useScript.ts";
|
|
140
|
+
|
|
141
|
+
export default function X() {
|
|
142
|
+
return <button hx-on:click={useScript(handler)}>x</button>;
|
|
143
|
+
}
|
|
144
|
+
`;
|
|
145
|
+
const r = transformHtmxOnEvents(src);
|
|
146
|
+
expect(r.changed).toBe(true);
|
|
147
|
+
expect(r.content.startsWith(TODO_MARKER)).toBe(true);
|
|
148
|
+
expect(r.content).toContain("onClick={useScript(handler)}");
|
|
149
|
+
expect(r.notes.some((n) => n.includes("Injected MIGRATION TODO"))).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("injects a top-of-file MIGRATION TODO when handler references globalThis.window.STOREFRONT", () => {
|
|
153
|
+
const src = `<button hx-on:click={() => { globalThis.window.STOREFRONT.CART.addToCart({}); }}>buy</button>`;
|
|
154
|
+
const r = transformHtmxOnEvents(src);
|
|
155
|
+
expect(r.content.startsWith(TODO_MARKER)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("injects a top-of-file MIGRATION TODO when handler references STOREFRONT.* (shorthand)", () => {
|
|
159
|
+
const src = `import { STOREFRONT } from "site/sdk";\n<button hx-on:click={() => STOREFRONT.CART.addToCart()}>x</button>`;
|
|
160
|
+
const r = transformHtmxOnEvents(src);
|
|
161
|
+
expect(r.content.startsWith(TODO_MARKER)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("does NOT inject a TODO when no Fresh-isms are detected", () => {
|
|
165
|
+
const src = `<button hx-on:click={() => setOpen(true)}>x</button>`;
|
|
166
|
+
const r = transformHtmxOnEvents(src);
|
|
167
|
+
expect(r.changed).toBe(true);
|
|
168
|
+
expect(r.content.startsWith(TODO_MARKER)).toBe(false);
|
|
169
|
+
expect(r.content).toBe(`<button onClick={() => setOpen(true)}>x</button>`);
|
|
170
|
+
expect(r.notes.some((n) => n.includes("Injected MIGRATION TODO"))).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("preserves a leading shebang and inserts the TODO after it", () => {
|
|
174
|
+
const src = `#!/usr/bin/env node\n<button hx-on:click={useScript(fn)}>x</button>\n`;
|
|
175
|
+
const r = transformHtmxOnEvents(src);
|
|
176
|
+
expect(r.content.startsWith("#!/usr/bin/env node\n")).toBe(true);
|
|
177
|
+
expect(r.content.split("\n")[1]).toBe(TODO_MARKER);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("does not inject the TODO twice when the codemod is rerun (idempotent)", () => {
|
|
181
|
+
const src = `<button hx-on:click={useScript(fn)}>x</button>`;
|
|
182
|
+
const first = transformHtmxOnEvents(src);
|
|
183
|
+
const second = transformHtmxOnEvents(first.content);
|
|
184
|
+
expect(second.changed).toBe(false);
|
|
185
|
+
const occurrences = first.content.split(TODO_MARKER).length - 1;
|
|
186
|
+
expect(occurrences).toBe(1);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("transformHtmxOnEvents — idempotency + edge cases", () => {
|
|
191
|
+
it("is idempotent on a clean rewritten file (rerunning is a no-op)", () => {
|
|
192
|
+
const src = `<button onClick={fn}>x</button>`;
|
|
193
|
+
const first = transformHtmxOnEvents(src);
|
|
194
|
+
const second = transformHtmxOnEvents(first.content);
|
|
195
|
+
expect(first.changed).toBe(false);
|
|
196
|
+
expect(second.changed).toBe(false);
|
|
197
|
+
expect(second.content).toBe(src);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("is idempotent on a file that just got rewritten (rerun produces same output)", () => {
|
|
201
|
+
const src = `<button hx-on:click={fn}>x</button>`;
|
|
202
|
+
const first = transformHtmxOnEvents(src);
|
|
203
|
+
const second = transformHtmxOnEvents(first.content);
|
|
204
|
+
expect(second.changed).toBe(false);
|
|
205
|
+
expect(second.content).toBe(first.content);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("handles JSX components (capitalized tag names) just like lowercase intrinsics", () => {
|
|
209
|
+
const src = `<Accordion.Trigger hx-on-click={toggle}>open</Accordion.Trigger>`;
|
|
210
|
+
const r = transformHtmxOnEvents(src);
|
|
211
|
+
expect(r.content).toBe(
|
|
212
|
+
`<Accordion.Trigger onClick={toggle}>open</Accordion.Trigger>`,
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("renames within a long file containing many tags", () => {
|
|
217
|
+
const src = Array.from({ length: 20 })
|
|
218
|
+
.map((_, i) => `<button key={${i}} hx-on:click={() => setCount(${i})}>${i}</button>`)
|
|
219
|
+
.join("\n");
|
|
220
|
+
const r = transformHtmxOnEvents(src);
|
|
221
|
+
expect(r.notes[0]).toContain("Renamed 20 hx-on:* attribute(s)");
|
|
222
|
+
expect(r.content.split("onClick=").length - 1).toBe(20);
|
|
223
|
+
expect(r.content.includes("hx-on")).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("transformHtmxOnEvents — als-shaped fixtures", () => {
|
|
228
|
+
it("AddToBagButton: hx-on-click + Fresh useScript → onClick + TODO", () => {
|
|
229
|
+
const src = `import { useScript } from "site/sdk/useScript.ts";
|
|
230
|
+
|
|
231
|
+
interface Props { productId: string; }
|
|
232
|
+
|
|
233
|
+
export default function AddToBagButton({ productId }: Props) {
|
|
234
|
+
const handler = (id: string) => {
|
|
235
|
+
globalThis.window.STOREFRONT.CART.addToCart({ id });
|
|
236
|
+
};
|
|
237
|
+
return (
|
|
238
|
+
<button
|
|
239
|
+
class="btn"
|
|
240
|
+
hx-on-click={useScript(handler, productId)}
|
|
241
|
+
>
|
|
242
|
+
Add to bag
|
|
243
|
+
</button>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
`;
|
|
247
|
+
const r = transformHtmxOnEvents(src);
|
|
248
|
+
expect(r.changed).toBe(true);
|
|
249
|
+
expect(r.content).toContain("onClick={useScript(handler, productId)}");
|
|
250
|
+
expect(r.content.startsWith(TODO_MARKER)).toBe(true);
|
|
251
|
+
const codeBody = r.content.split("§ Pattern 1 (event-handler).\n")[1];
|
|
252
|
+
expect(codeBody).not.toMatch(/\bhx-on\b/);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("SearchInput: keeps hx-post/hx-target/etc, renames hx-on:change", () => {
|
|
256
|
+
const src = `<input
|
|
257
|
+
type="search"
|
|
258
|
+
hx-post={searchUrl}
|
|
259
|
+
hx-target="#suggestions"
|
|
260
|
+
hx-swap="innerHTML"
|
|
261
|
+
hx-trigger="keyup changed delay:300ms"
|
|
262
|
+
hx-sync="closest form:abort"
|
|
263
|
+
hx-on:change={(e) => { e.preventDefault(); }}
|
|
264
|
+
/>`;
|
|
265
|
+
const r = transformHtmxOnEvents(src);
|
|
266
|
+
expect(r.changed).toBe(true);
|
|
267
|
+
expect(r.content).toContain(`onChange={(e) => { e.preventDefault(); }}`);
|
|
268
|
+
expect(r.content).toContain("hx-post={searchUrl}");
|
|
269
|
+
expect(r.content).toContain('hx-target="#suggestions"');
|
|
270
|
+
expect(r.content).toContain('hx-swap="innerHTML"');
|
|
271
|
+
expect(r.content).toContain('hx-sync="closest form:abort"');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("RecoveryPassword: form with mixed standard + htmx lifecycle hooks", () => {
|
|
275
|
+
const src = `<form
|
|
276
|
+
hx-post={url}
|
|
277
|
+
hx-target="#form"
|
|
278
|
+
hx-swap="outerHTML"
|
|
279
|
+
hx-trigger="submit"
|
|
280
|
+
hx-disabled-elt="find button"
|
|
281
|
+
hx-indicator="#loader"
|
|
282
|
+
hx-select="#form"
|
|
283
|
+
hx-on:submit={(e) => { /* validate */ }}
|
|
284
|
+
hx-on-htmx-before-request={(e) => addCsrf(e)}
|
|
285
|
+
>
|
|
286
|
+
…
|
|
287
|
+
</form>`;
|
|
288
|
+
const r = transformHtmxOnEvents(src);
|
|
289
|
+
expect(r.changed).toBe(true);
|
|
290
|
+
expect(r.content).toContain(`onSubmit={(e) => { /* validate */ }}`);
|
|
291
|
+
expect(r.content).toContain("hx-on-htmx-before-request={(e) => addCsrf(e)}");
|
|
292
|
+
expect(r.content).toContain("hx-post={url}");
|
|
293
|
+
expect(r.content).toContain('hx-trigger="submit"');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("Footer.tsx-style: simple onClick with no Fresh-ism — no TODO", () => {
|
|
297
|
+
const src = `<button hx-on:click={() => setOpen((p) => !p)}>menu</button>`;
|
|
298
|
+
const r = transformHtmxOnEvents(src);
|
|
299
|
+
expect(r.changed).toBe(true);
|
|
300
|
+
expect(r.content.startsWith(TODO_MARKER)).toBe(false);
|
|
301
|
+
expect(r.content).toBe(
|
|
302
|
+
`<button onClick={() => setOpen((p) => !p)}>menu</button>`,
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { TransformResult } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* htmx → React event-name mapping for the seven `hx-on:*` / `hx-on-*`
|
|
5
|
+
* patterns that have a 1:1 React equivalent.
|
|
6
|
+
*
|
|
7
|
+
* The codemod is intentionally narrow: only events present in this map
|
|
8
|
+
* get renamed. Custom DOM events (`hx-on:my-thing`), htmx lifecycle
|
|
9
|
+
* events (`hx-on:htmx-config-request`), and any event that requires a
|
|
10
|
+
* React `addEventListener` in `useEffect` get left alone — the
|
|
11
|
+
* `htmx-residue` audit rule catches them and points at the per-pattern
|
|
12
|
+
* `htmx-rewrite.md` skill.
|
|
13
|
+
*
|
|
14
|
+
* Kept lowercase to mirror htmx's own naming and to keep the regex
|
|
15
|
+
* simple. JSX attribute names ARE case-sensitive; htmx writers always
|
|
16
|
+
* use lowercase.
|
|
17
|
+
*/
|
|
18
|
+
const STANDARD_EVENT_MAP: Record<string, string> = {
|
|
19
|
+
click: "onClick",
|
|
20
|
+
dblclick: "onDoubleClick",
|
|
21
|
+
submit: "onSubmit",
|
|
22
|
+
reset: "onReset",
|
|
23
|
+
change: "onChange",
|
|
24
|
+
input: "onInput",
|
|
25
|
+
keyup: "onKeyUp",
|
|
26
|
+
keydown: "onKeyDown",
|
|
27
|
+
keypress: "onKeyPress",
|
|
28
|
+
focus: "onFocus",
|
|
29
|
+
blur: "onBlur",
|
|
30
|
+
focusin: "onFocus",
|
|
31
|
+
focusout: "onBlur",
|
|
32
|
+
mouseover: "onMouseOver",
|
|
33
|
+
mouseout: "onMouseOut",
|
|
34
|
+
mouseenter: "onMouseEnter",
|
|
35
|
+
mouseleave: "onMouseLeave",
|
|
36
|
+
mousedown: "onMouseDown",
|
|
37
|
+
mouseup: "onMouseUp",
|
|
38
|
+
mousemove: "onMouseMove",
|
|
39
|
+
contextmenu: "onContextMenu",
|
|
40
|
+
load: "onLoad",
|
|
41
|
+
scroll: "onScroll",
|
|
42
|
+
paste: "onPaste",
|
|
43
|
+
copy: "onCopy",
|
|
44
|
+
cut: "onCut",
|
|
45
|
+
dragstart: "onDragStart",
|
|
46
|
+
drag: "onDrag",
|
|
47
|
+
dragend: "onDragEnd",
|
|
48
|
+
drop: "onDrop",
|
|
49
|
+
dragenter: "onDragEnter",
|
|
50
|
+
dragleave: "onDragLeave",
|
|
51
|
+
dragover: "onDragOver",
|
|
52
|
+
wheel: "onWheel",
|
|
53
|
+
touchstart: "onTouchStart",
|
|
54
|
+
touchend: "onTouchEnd",
|
|
55
|
+
touchmove: "onTouchMove",
|
|
56
|
+
touchcancel: "onTouchCancel",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Marker comment we inject when a rename happened *and* the surviving
|
|
61
|
+
* handler bodies reference Fresh-only globals (`useScript`,
|
|
62
|
+
* `globalThis.window.STOREFRONT`, `STOREFRONT.*`). The comment is the
|
|
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
|
|
66
|
+
* so re-running the codemod does not double-inject.
|
|
67
|
+
*/
|
|
68
|
+
const TODO_MARKER = "// MIGRATION TODO (codemod: htmx-on-event-rename):";
|
|
69
|
+
|
|
70
|
+
const TODO_BLOCK = `${TODO_MARKER}
|
|
71
|
+
// hx-on:* attributes were auto-renamed to React event handlers, but
|
|
72
|
+
// the handler bodies were preserved verbatim. They may reference
|
|
73
|
+
// Fresh-only globals like \`globalThis.window.STOREFRONT\` or
|
|
74
|
+
// \`useScript(...)\`. Verify each handler matches a TanStack Start
|
|
75
|
+
// equivalent (state hook, platform hook, or server function) — see
|
|
76
|
+
// .agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md
|
|
77
|
+
// § Pattern 1 (event-handler).`;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Matches a single `hx-on:eventname=` or `hx-on-eventname=` attribute
|
|
81
|
+
* occurrence in the source.
|
|
82
|
+
*
|
|
83
|
+
* `\b` before `hx-on` keeps us from matching inside identifiers like
|
|
84
|
+
* `withHx-on` (impossible in TS but defensive). The separator capture
|
|
85
|
+
* group distinguishes colon vs dash so we can flip both syntactic
|
|
86
|
+
* variants in one pass.
|
|
87
|
+
*
|
|
88
|
+
* The event name allows a-zA-Z0-9 and `-` so multi-segment htmx events
|
|
89
|
+
* (`htmx-config-request`, `htmx-before-request`) are captured intact and
|
|
90
|
+
* we can decide *after* the match whether to rename or skip.
|
|
91
|
+
*/
|
|
92
|
+
const HX_ON_ATTR_RE = /\bhx-on([:\-])([a-zA-Z][a-zA-Z0-9-]*)(\s*=)/g;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Heuristic patterns for handler bodies that reference Fresh-specific
|
|
96
|
+
* globals. Used only to gate TODO injection — false positives are
|
|
97
|
+
* harmless (extra comment), false negatives are tolerable (audit will
|
|
98
|
+
* still catch htmx residue elsewhere).
|
|
99
|
+
*/
|
|
100
|
+
const FRESH_BODY_PATTERNS: readonly RegExp[] = [
|
|
101
|
+
/\buseScript\s*\(/,
|
|
102
|
+
/\bglobalThis\.window\.STOREFRONT\b/,
|
|
103
|
+
/\bSTOREFRONT\./,
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Rewrite `hx-on:click={...}` and `hx-on-click={...}` attributes to
|
|
108
|
+
* the React equivalent (`onClick={...}`), preserving the handler value
|
|
109
|
+
* verbatim. Renames only happen for events with a known React mapping.
|
|
110
|
+
*
|
|
111
|
+
* - htmx lifecycle events (`htmx-config-request`, `htmx-before-request`,
|
|
112
|
+
* `htmx-after-swap`, etc.) are left alone — they require manual
|
|
113
|
+
* rewrite per the htmx-rewrite skill, and the `htmx-residue` audit
|
|
114
|
+
* rule will catch them post-migration.
|
|
115
|
+
* - Unknown custom events (e.g. `hx-on:my-custom-thing`) are left alone
|
|
116
|
+
* — React doesn't have synthetic equivalents for arbitrary custom
|
|
117
|
+
* events; the engineer must wire those via `addEventListener` in
|
|
118
|
+
* `useEffect`, which the codemod cannot generate safely.
|
|
119
|
+
*
|
|
120
|
+
* If any rename happens AND the file contains Fresh-only body
|
|
121
|
+
* patterns, a single file-level TODO comment is injected at the top so
|
|
122
|
+
* reviewers know the bodies still need attention. Idempotent — running
|
|
123
|
+
* the codemod twice produces identical output.
|
|
124
|
+
*/
|
|
125
|
+
export function transformHtmxOnEvents(content: string): TransformResult {
|
|
126
|
+
if (!content.includes("hx-on")) {
|
|
127
|
+
return { content, changed: false, notes: [] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const renamesByEvent = new Map<string, number>();
|
|
131
|
+
let renamed = 0;
|
|
132
|
+
|
|
133
|
+
const next = content.replace(
|
|
134
|
+
HX_ON_ATTR_RE,
|
|
135
|
+
(match, _sep: string, eventName: string, equals: string) => {
|
|
136
|
+
const lower = eventName.toLowerCase();
|
|
137
|
+
if (lower.startsWith("htmx-")) return match;
|
|
138
|
+
|
|
139
|
+
const reactName = STANDARD_EVENT_MAP[lower];
|
|
140
|
+
if (!reactName) return match;
|
|
141
|
+
|
|
142
|
+
renamed += 1;
|
|
143
|
+
renamesByEvent.set(reactName, (renamesByEvent.get(reactName) ?? 0) + 1);
|
|
144
|
+
return `${reactName}${equals}`;
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (renamed === 0) {
|
|
149
|
+
return { content, changed: false, notes: [] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const notes: string[] = [];
|
|
153
|
+
const breakdown = [...renamesByEvent.entries()]
|
|
154
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
155
|
+
.map(([name, n]) => `${name}=${n}`)
|
|
156
|
+
.join(", ");
|
|
157
|
+
notes.push(`Renamed ${renamed} hx-on:* attribute(s) → React events (${breakdown})`);
|
|
158
|
+
|
|
159
|
+
const hasFreshIsms = FRESH_BODY_PATTERNS.some((re) => re.test(next));
|
|
160
|
+
const hasMarker = next.includes(TODO_MARKER);
|
|
161
|
+
|
|
162
|
+
if (!hasFreshIsms || hasMarker) {
|
|
163
|
+
return { content: next, changed: true, notes };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const final = injectTopOfFileTodo(next);
|
|
167
|
+
notes.push(
|
|
168
|
+
"Injected MIGRATION TODO — handler body references Fresh-only globals (useScript / globalThis.window.STOREFRONT)",
|
|
169
|
+
);
|
|
170
|
+
return { content: final, changed: true, notes };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Insert TODO_BLOCK as a top-of-file comment, *after* any leading
|
|
175
|
+
* shebang line or block comment. Keeps directives like `"use client"`
|
|
176
|
+
* intact (they live below the comment block, which is fine).
|
|
177
|
+
*/
|
|
178
|
+
function injectTopOfFileTodo(source: string): string {
|
|
179
|
+
if (source.startsWith("#!")) {
|
|
180
|
+
const newlineIdx = source.indexOf("\n");
|
|
181
|
+
if (newlineIdx === -1) return `${source}\n${TODO_BLOCK}\n`;
|
|
182
|
+
return `${source.slice(0, newlineIdx + 1)}${TODO_BLOCK}\n${source.slice(newlineIdx + 1)}`;
|
|
183
|
+
}
|
|
184
|
+
return `${TODO_BLOCK}\n${source}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Exported for direct unit tests. */
|
|
188
|
+
export const _internals = {
|
|
189
|
+
STANDARD_EVENT_MAP,
|
|
190
|
+
HX_ON_ATTR_RE,
|
|
191
|
+
TODO_MARKER,
|
|
192
|
+
FRESH_BODY_PATTERNS,
|
|
193
|
+
};
|