@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.
- package/.agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md +21 -0
- package/MIGRATION_TOOLING_PLAN.md +149 -6
- package/package.json +1 -1
- package/scripts/migrate/phase-transform.ts +7 -1
- package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
- package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
|
@@ -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
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
@@ -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
|
+
};
|