@decocms/start 2.21.0 → 2.22.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.
@@ -62,6 +62,27 @@ Both rewrite to the same React `onClick` / `onChange` etc.
62
62
  The biggest bucket. Almost always a `useScript`-wrapped function
63
63
  attached as an event handler, with no fetch involved.
64
64
 
65
+ > **Codemod available** (since `@decocms/start >= 2.21.0`). The
66
+ > migration script's `transforms` pipeline now runs
67
+ > `htmx-on-event-rename`, which mechanically rewrites
68
+ > `hx-on:click={…}` → `onClick={…}` (and every other standard DOM
69
+ > event in the [STANDARD_EVENT_MAP](https://github.com/decocms/deco-start/blob/main/scripts/migrate/transforms/htmx-on-events.ts)
70
+ > table) for both colon and dash variants. Handler bodies are
71
+ > preserved verbatim; if the body references Fresh-only globals
72
+ > (`useScript(…)`, `globalThis.window.STOREFRONT`, `STOREFRONT.…`),
73
+ > the codemod injects a single MIGRATION TODO comment at the top
74
+ > of the file pointing back here. **htmx lifecycle events**
75
+ > (`hx-on:htmx-config-request`, `hx-on-htmx-before-request`, etc.)
76
+ > and unknown custom events (`hx-on:my-custom-thing`) are left
77
+ > alone — those need manual rewrite, and the `htmx-residue` audit
78
+ > rule catches them.
79
+ >
80
+ > Smoke result on als-storefront (754 files): codemod renames 98
81
+ > `hx-on:*` attributes across 71 files; 67 of those files (94 %)
82
+ > get the body-TODO. Engineers still own the body rewrite below;
83
+ > the codemod just removes the dead `hx-*` attribute name so the
84
+ > file compiles in React.
85
+
65
86
  ### Before
66
87
 
67
88
  ```tsx
@@ -239,6 +239,36 @@ Each item carries a status: ⬜ pending, 🟡 in progress, ✅ done, 🚫 blocke
239
239
 
240
240
  > Append-only. Each entry: date, what we found, where it impacts the plan.
241
241
 
242
+ ### 2026-05-01 — Wave 14-A rescoped from three codemods to one based on real als data
243
+
244
+ - **Pre-data plan vs post-data plan.** The plan called for three
245
+ htmx codemods (`event-handler`, `form-swap`, `click-swap`).
246
+ After running `deco-htmx-analyze` against als-storefront's
247
+ actual code (210 occurrences across 133 files), only the
248
+ `event-handler` bucket (88 occurrences, 42 %) genuinely admits
249
+ a mechanical rewrite — the other buckets need per-call-site
250
+ product decisions a codemod cannot encode. **Decided: ship
251
+ one codemod (W14-A: `htmx-on-event-rename`), defer the other
252
+ two to W15+.** Rationale captured in the Wave 14 — discoveries
253
+ block.
254
+ - **Codemod shape generalises:** rename + preserve body +
255
+ conditional file-level TODO. Three outputs, one mechanical,
256
+ one verbatim, one conditional on body-content heuristics. This
257
+ is the shape any future per-pattern codemod should target.
258
+ - **Smoke against the real source tree validated the design in
259
+ five minutes.** 754 files scanned, 71 changed, 98 renames, 67
260
+ TODO injections (94 % of changed files). Without that smoke
261
+ step we'd have shipped blind on edge cases like multi-line
262
+ values, mixed standard + lifecycle hooks on the same element,
263
+ and the colon-vs-dash variants both showing up in the same
264
+ file.
265
+ - **The codemod + audit pair closes another loop.** Same shape
266
+ as W12 (D3 throwing stubs + audit `--fix` for swap-able
267
+ stubs). The codemod removes the mechanical half of the htmx
268
+ surface; the `htmx-residue` audit catches the surviving half
269
+ in CI. Engineers can never silently ship a half-rewritten
270
+ file.
271
+
242
272
  ### 2026-05-01 — als-storefront surfaces the htmx track + policy reset
243
273
 
244
274
  - **als-storefront is the third migration target and the first
@@ -834,12 +864,125 @@ analysis, rewrite recipes, and a "rewrite-complete" gate.
834
864
  rule means changing one file, getting `--strict` and `--json`
835
865
  for free.
836
866
 
837
- ### Wave 14 (htmx codemods + first als migration on 2.14+) — planned
838
-
839
- - **W14-A** deco-start: codemod `transforms/htmx-form-post-swap.ts` `<form hx-post={url} hx-target hx-swap>` → `useMutation` + state setter
840
- - **W14-B** deco-start: codemod `transforms/htmx-click-fetch-swap.ts` `<button hx-get={url}>` → onClick + invoke + state
841
- - **W14-C** deco-start: codemod `transforms/htmx-on-click-script.ts` `hx-on:click={useScript(...)}` `onClick` handler
842
- - **W14-D** als: rm -rf old als-tanstack, fresh `deco-migrate` run on 2.14+ with new htmx codemods. Per D5 (no --restart), this is the only restart UX.
867
+ ### Wave 14 (htmx codemod Priority 2 part 2) — ✅ **PARTIAL / RESCOPED**
868
+
869
+ After shipping the W13 htmx foundations and gathering real data
870
+ from als-storefront with `deco-htmx-analyze`, the planned three-codemod
871
+ scope was reduced to **one codemod** + **one inventory artefact**.
872
+ The other two codemods (form-swap, click-swap) were deferred to W15+,
873
+ to be designed *after* als migration data exposes which exact
874
+ attribute clusters dominate. **Rationale logged in W14 discoveries.**
875
+
876
+ **Shipped:**
877
+
878
+ - **W14-A** [`deco-start#132`](https://github.com/decocms/deco-start/pull/132) — `feat(migrate): htmx-on-event-rename codemod` ✅ **MERGED**, released as `@decocms/start@2.22.0`.
879
+ Adds `scripts/migrate/transforms/htmx-on-events.ts` to the migrate
880
+ `transforms/` pipeline. Mechanically rewrites `hx-on:event=` and
881
+ `hx-on-event=` (colon + dash variants) to the React equivalent
882
+ for every standard DOM event in `STANDARD_EVENT_MAP` (40 entries:
883
+ click, submit, change, input, key*, mouse*, focus*, drag*, touch*,
884
+ paste/copy/cut, scroll, wheel, load, contextmenu). Handler bodies
885
+ are preserved verbatim. **Idempotent** — running twice is a no-op.
886
+ Two safety hatches: htmx lifecycle events (`hx-on:htmx-*`) and
887
+ unknown custom events left alone (the `htmx-residue` audit catches
888
+ them); a single top-of-file MIGRATION TODO comment is injected
889
+ when the body references Fresh-only globals (`useScript(…)`,
890
+ `globalThis.window.STOREFRONT`, `STOREFRONT.…`) so engineers
891
+ don't ship a syntactically-clean file with broken runtime calls.
892
+ 29 unit tests + als-shaped fixtures (AddToBagButton, SearchInput,
893
+ RecoveryPassword form, Footer.tsx). 339/339 pass; typecheck clean.
894
+ htmx-rewrite skill § Pattern 1 cross-references the codemod.
895
+ - **W14-B** [`deco-start#132`](https://github.com/decocms/deco-start/pull/132) — captured the **als-storefront htmx inventory** in this plan (this section) as a fixture for future W15+ codemod design.
896
+
897
+ **Deferred (intentionally) — see Wave 14 discoveries:**
898
+
899
+ - ~~**W14-C** codemod `transforms/htmx-form-post-swap.ts`~~ — moved to W15+. The form-swap rewrite is genuinely non-mechanical (per-call-site decisions about optimistic vs pessimistic UI, where to surface loading state, which response handler shape). A speculative codemod would produce React skeletons that still need ~80 % manual work.
900
+ - ~~**W14-D** codemod `transforms/htmx-click-fetch-swap.ts`~~ — moved to W15+. Same logic; on top of that, choosing between local state machine vs sub-route is a routing-architecture decision that varies per page.
901
+
902
+ #### W14-A smoke + als inventory (captured 2026-05-01)
903
+
904
+ The W13-A `deco-htmx-analyze` CLI run against als-storefront's
905
+ production Fresh tree:
906
+
907
+ | Category | Count | % | Notes |
908
+ |---|---:|---:|---|
909
+ | `event-handler` | 88 | 42 % | **Codemoded by W14-A** — mechanical rename |
910
+ | `click-swap` | 64 | 30 % | Manual (W15+) — needs state vs sub-route decision |
911
+ | `form-swap` | 20 | 10 % | Manual (W15+) — needs `useMutation` shape decision |
912
+ | `auto-fetch` | 9 | 4 % | Manual — debounced state + `useQuery` |
913
+ | `oob-swap` | 8 | 4 % | Manual — no 1:1 React equivalent |
914
+ | `unmatched` | 21 | 10 % | Mostly typed-generic noise (`<string>` from `Map<string,X>`) |
915
+ | **Total** | **210** | | across 133 files |
916
+
917
+ W14-A codemod sweep against the same tree (754 ts/tsx files):
918
+
919
+ | Metric | Value |
920
+ |---|---:|
921
+ | Files scanned | 754 |
922
+ | Files changed | 71 |
923
+ | Total `hx-on:*` attributes renamed | 98 |
924
+ | Files getting the MIGRATION TODO | 67 (94 % of changed) |
925
+
926
+ The 98 vs 88 discrepancy is expected: the analyzer counts attribute
927
+ *clusters* per element (an `<input hx-post hx-target hx-on:change>`
928
+ classifies as one `auto-fetch`); the codemod counts individual
929
+ `hx-on:*` attribute renames (the same element gets one rename
930
+ plus the `auto-fetch` cluster left intact for the engineer to
931
+ finish). Net effect: ~98 mechanical wins, leaving ~112 cluster
932
+ rewrites (click-swap + form-swap + auto-fetch + oob-swap +
933
+ unmatched) for the engineer — matching the manual rewrite
934
+ recipes in `references/htmx-rewrite.md`.
935
+
936
+ ### Wave 14 — discoveries
937
+
938
+ - **Speculative codemods are over-engineering; data-driven scope
939
+ is better.** The pre-data plan said three codemods (event-handler,
940
+ form-swap, click-swap). After running `deco-htmx-analyze` against
941
+ als-storefront's actual code, only the event-handler bucket
942
+ (88 occurrences, 42 % of the surface) genuinely admits a
943
+ mechanical rewrite. The other two buckets need per-call-site
944
+ product decisions (state machine vs sub-route, optimistic vs
945
+ pessimistic UI, response-handler shape) that a codemod cannot
946
+ encode without producing React skeletons that still need ~80 %
947
+ manual work — net negative versus the recipe in
948
+ `references/htmx-rewrite.md`. **New rule: codemods come *after*
949
+ the analyzer data, not before.**
950
+ - **The smoke-against-real-site step is the design feedback loop.**
951
+ Running the codemod against als's full 754-file tree (98
952
+ renames, 71 files changed, 67 with TODO injection) validated
953
+ three things in five minutes: (a) the rename surface matches
954
+ the inventory (98 vs 88 ratio explained), (b) the TODO
955
+ injection rate is high (94 %) — the marker is essential, not
956
+ defensive, (c) the codemod is idempotent at scale (re-running
957
+ produces zero diffs). Without this step we'd ship blind.
958
+ - **The three-output codemod shape (rename + preserve body +
959
+ conditional TODO) generalises.** Same shape any future
960
+ per-pattern codemod should target: do the mechanical part,
961
+ preserve the human-decision-required part, leave a single
962
+ file-level marker the engineer can grep for. Over-eager
963
+ body rewriting is what produces the ~80 % manual cleanup load
964
+ that justifies leaving form-swap / click-swap codemods out for
965
+ now.
966
+ - **`htmx-residue` audit + W14-A codemod close another loop.**
967
+ Same pattern as W12 (D3 throwing stubs + audit `--fix` for
968
+ swap-able stubs). The codemod removes the easy half of the
969
+ htmx surface; the audit catches the surviving half. Engineers
970
+ can never accidentally ship a half-rewritten file: the
971
+ attribute is either gone (codemod ran, body might still need
972
+ work — TODO), or it's still there (audit fires in CI).
973
+ - **als-storefront's profile probably generalises to other htmx
974
+ sites.** 42 % event-handler is a strong skew toward
975
+ trivially-mechanical rewrites; even if other sites differ,
976
+ this codemod alone removes the largest single bucket. If a
977
+ future site shows 80 % click-swap, *that* would be the cue to
978
+ build the click-swap codemod — not pre-emptively now.
979
+ - **Pipeline order matters.** Codemod runs after `transformJsx`
980
+ (which renames `class` → `className` and `onInput` → `onChange`)
981
+ and before `transformFreshApis` (which removes `useScript`
982
+ imports). If `transformFreshApis` ran first, the codemod's
983
+ TODO marker would still fire (we look for `useScript(` calls,
984
+ not the import), but the import-removal would create dead
985
+ references. Order is correct.
843
986
 
844
987
  ### Wave 15+ (htmx cleanup PRs on als + propagation to other sites) — Priority 3 / 4
845
988
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.21.0",
3
+ "version": "2.22.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -9,6 +9,7 @@ import { transformFreshApis } from "./transforms/fresh-apis";
9
9
  import { transformDenoIsms } from "./transforms/deno-isms";
10
10
  import { transformTailwind } from "./transforms/tailwind";
11
11
  import { transformDeadCode } from "./transforms/dead-code";
12
+ import { transformHtmxOnEvents } from "./transforms/htmx-on-events";
12
13
  import { createSectionConventionsTransform } from "./transforms/section-conventions";
13
14
 
14
15
  /** Map of section path → metadata, populated per-run */
@@ -54,10 +55,15 @@ function applyTransforms(content: string, filePath: string, ctx?: MigrationConte
54
55
  return { content, changed: false, notes: [] };
55
56
  }
56
57
 
57
- // Pipeline: imports → jsx → fresh-apis → dead-code → deno-isms → tailwind
58
+ // Pipeline: imports → jsx → htmx-on-events → fresh-apis → dead-code → deno-isms → tailwind
59
+ // htmx-on-events runs after jsx (which renames class/onChange) and
60
+ // before fresh-apis (which removes useScript imports the htmx
61
+ // codemod's TODO might still reference). The codemod is a no-op on
62
+ // files without hx-on, so it never adds latency to non-htmx sites.
58
63
  const pipeline: Array<{ name: string; fn: (content: string) => TransformResult }> = [
59
64
  { name: "imports", fn: (c) => transformImports(c, ctx?.islandWrapperTargets) },
60
65
  { name: "jsx", fn: transformJsx },
66
+ { name: "htmx-on-events", fn: transformHtmxOnEvents },
61
67
  { name: "fresh-apis", fn: transformFreshApis },
62
68
  { name: "dead-code", fn: (c) => transformDeadCode(c, ctx?.platform) },
63
69
  { name: "deno-isms", fn: transformDenoIsms },
@@ -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
+ };