@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.
@@ -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
- * Turns nested property access into a typed RPC call to /deco/invoke.
222
- * Converts dot-notation paths to slash-separated keys:
223
- * invoke.vtex.loaders.productList(props)
224
- * POST /deco/invoke/vtex/loaders/productList
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
- * The .ts suffix variant is also tried if the primary key isn't found
227
- * (registered loaders may have ".ts" extensions in their keys).
226
+ * Don't reimplement here extend @decocms/start/sdk/invoke instead.
228
227
  */
229
- function createNestedInvokeProxy(path: string[] = []): any {
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 const invoke = createNestedInvokeProxy() as any;
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
- getRequestHeader,
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
- mergeSetCookies(stripCookieDomain(setCookies));
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
- mergeSetCookies(setCookies);
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
+ };