@decocms/start 2.20.0 → 2.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/deco-to-tanstack-migration/SKILL.md +1 -0
- package/.agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md +527 -0
- package/.agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +41 -2
- package/MIGRATION_TOOLING_PLAN.md +64 -9
- package/package.json +1 -1
- package/scripts/migrate/post-cleanup/rules.ts +73 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +108 -0
- package/scripts/migrate-post-cleanup.ts +3 -2
|
@@ -369,6 +369,7 @@ For sites with 100+ sections:
|
|
|
369
369
|
| Admin / CMS integration | `references/admin-cms.md` |
|
|
370
370
|
| Gotchas index | `references/gotchas.md` |
|
|
371
371
|
| Post-migration cleanup checklist | `references/post-migration-cleanup.md` |
|
|
372
|
+
| HTMX → React rewrite recipes | `references/htmx-rewrite.md` |
|
|
372
373
|
| React hooks patterns | `references/react-hooks-patterns.md` |
|
|
373
374
|
| React signals & state | `references/react-signals-state.md` |
|
|
374
375
|
| JSX migration differences | `references/jsx-migration.md` |
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
# HTMX → React Rewrite Recipes
|
|
2
|
+
|
|
3
|
+
Some Deco storefronts on the Fresh stack picked up htmx as their
|
|
4
|
+
interactivity model — `hx-get`, `hx-post`, `hx-target`, `hx-swap`,
|
|
5
|
+
`hx-on:click`, etc. The TanStack Start / React stack does **not**
|
|
6
|
+
ship an htmx runtime, and per **D2** in the
|
|
7
|
+
[migration tooling policy](https://github.com/decocms/deco-start/blob/main/.cursor/rules/migration-tooling-policy.mdc)
|
|
8
|
+
we don't add one — every `hx-*` attribute gets rewritten to a React
|
|
9
|
+
equivalent on migration. There is no half-measure adapter package.
|
|
10
|
+
|
|
11
|
+
This reference is the per-pattern playbook the engineer (and the
|
|
12
|
+
Wave 14 codemods) follow.
|
|
13
|
+
|
|
14
|
+
## Inventory the surface first
|
|
15
|
+
|
|
16
|
+
Before rewriting anything, run the analyzer (added in
|
|
17
|
+
`@decocms/start >= 2.20.0`):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# From the SOURCE site directory (Fresh repo, before migration).
|
|
21
|
+
npx -p @decocms/start deco-htmx-analyze --top 20
|
|
22
|
+
|
|
23
|
+
# Or after migration to verify there's nothing left:
|
|
24
|
+
cd /path/to/migrated-site
|
|
25
|
+
npx -p @decocms/start deco-htmx-analyze --json | jq '.totalOccurrences'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The analyzer groups every `hx-*` element into one of seven
|
|
29
|
+
**categories**, ordered roughly by rewrite difficulty:
|
|
30
|
+
|
|
31
|
+
| Category | Cluster | Difficulty |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| `event-handler` | `hx-on:*` / `hx-on-*`, no fetch attr | trivial — direct `onClick` |
|
|
34
|
+
| `boost` | `hx-boost="true"` | trivial — `<Link>` |
|
|
35
|
+
| `click-swap` | fetch + `hx-target` on a button-like element | medium — state + conditional render |
|
|
36
|
+
| `form-swap` | fetch on `<form>` with target/swap | medium — `useMutation` |
|
|
37
|
+
| `auto-fetch` | fetch on input or `hx-trigger=keyup\|intersect` | medium — debounced state + `useQuery` |
|
|
38
|
+
| `oob-swap` | `hx-swap-oob` / `hx-select-oob` | hard — manual, no 1:1 |
|
|
39
|
+
| `unmatched` | doesn't fit a known cluster | manual — read the call site |
|
|
40
|
+
|
|
41
|
+
**Order of attack**: walk the list top-down. Most sites' surface is
|
|
42
|
+
~40 % event-handler, ~30 % click-swap, ~10 % each form-swap and
|
|
43
|
+
auto-fetch, ~5 % each oob-swap and unmatched. Knock out the trivial
|
|
44
|
+
ones first — they're often the easiest wins and clear the noise.
|
|
45
|
+
|
|
46
|
+
## Two syntactic flavours of `hx-on`
|
|
47
|
+
|
|
48
|
+
htmx 2.x supports both colon and dash forms of event handler attrs.
|
|
49
|
+
HTML's spec doesn't allow `:` in attribute names, so the dash is
|
|
50
|
+
canonical:
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
<button hx-on:click={useScript(...)} /> // colon (older Fresh fixtures)
|
|
54
|
+
<button hx-on-click={useScript(...)} /> // dash (htmx 2.x canonical)
|
|
55
|
+
<div hx-on-htmx-after-request={...} /> // htmx event with prefix
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Both rewrite to the same React `onClick` / `onChange` etc.
|
|
59
|
+
|
|
60
|
+
## Pattern 1 — `event-handler`
|
|
61
|
+
|
|
62
|
+
The biggest bucket. Almost always a `useScript`-wrapped function
|
|
63
|
+
attached as an event handler, with no fetch involved.
|
|
64
|
+
|
|
65
|
+
### Before
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
<button
|
|
69
|
+
hx-on:click={useScript(
|
|
70
|
+
async (skuId: string, sellerId: string) => {
|
|
71
|
+
if (!skuId || !sellerId) return;
|
|
72
|
+
const button = this as HTMLButtonElement;
|
|
73
|
+
button.dataset.loading = "true";
|
|
74
|
+
await globalThis.window.STOREFRONT.CART.addToCart({
|
|
75
|
+
orderItems: [{ id: skuId, quantity: 1, seller: sellerId }],
|
|
76
|
+
});
|
|
77
|
+
button.dataset.loading = "false";
|
|
78
|
+
},
|
|
79
|
+
productSku,
|
|
80
|
+
sellerId ?? "1",
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
Add to bag
|
|
84
|
+
</button>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### After
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
import { useState } from "react";
|
|
91
|
+
import { useCart } from "~/hooks/useCart";
|
|
92
|
+
|
|
93
|
+
export default function AddToBagButton({ productSku, sellerId }) {
|
|
94
|
+
const { addItems } = useCart();
|
|
95
|
+
const [loading, setLoading] = useState(false);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<button
|
|
99
|
+
data-loading={loading}
|
|
100
|
+
onClick={async () => {
|
|
101
|
+
if (!productSku || !sellerId) return;
|
|
102
|
+
setLoading(true);
|
|
103
|
+
try {
|
|
104
|
+
await addItems({
|
|
105
|
+
orderItems: [{ id: productSku, quantity: 1, seller: sellerId ?? "1" }],
|
|
106
|
+
});
|
|
107
|
+
} finally {
|
|
108
|
+
setLoading(false);
|
|
109
|
+
}
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
Add to bag
|
|
113
|
+
</button>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Gotchas
|
|
119
|
+
|
|
120
|
+
- `globalThis.window.STOREFRONT.CART.*` → the platform hook
|
|
121
|
+
(`useCart` from `~/hooks/useCart`, which delegates to `createUseCart`
|
|
122
|
+
from `@decocms/apps/vtex/hooks/createUseCart`). Do not reach for
|
|
123
|
+
globals.
|
|
124
|
+
- `this as HTMLButtonElement` → use a `useRef`, or read from the
|
|
125
|
+
React event (`(e) => e.currentTarget`).
|
|
126
|
+
- `button.dataset.loading = "true"` direct DOM mutation breaks
|
|
127
|
+
hydration on F5 and React Compiler optimisations. Use state.
|
|
128
|
+
- The `useScript` import (`@deco/deco/hooks` or
|
|
129
|
+
`@decocms/start/sdk/useScript`) becomes unused — remove it.
|
|
130
|
+
|
|
131
|
+
## Pattern 2 — `boost`
|
|
132
|
+
|
|
133
|
+
Trivial. `hx-boost` enabled SPA-style navigation on regular `<a>`
|
|
134
|
+
elements. TanStack Router's `<Link>` does this by default.
|
|
135
|
+
|
|
136
|
+
### Before
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
<a hx-boost="true" href="/produto/foo">Foo</a>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### After
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
import { Link } from "@tanstack/react-router";
|
|
146
|
+
|
|
147
|
+
<Link to="/produto/$slug" params={{ slug: "foo" }}>Foo</Link>;
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Gotchas
|
|
151
|
+
|
|
152
|
+
- For unparameterised links, `<Link to="/produto/foo">` works without
|
|
153
|
+
`params`.
|
|
154
|
+
- `<a>` tags that aren't `hx-boost`-ed should also become `<Link>`
|
|
155
|
+
if they target a route in the app — but that's covered by
|
|
156
|
+
`references/navigation.md`, not by this skill.
|
|
157
|
+
|
|
158
|
+
## Pattern 3 — `click-swap`
|
|
159
|
+
|
|
160
|
+
The dominant button-driven shape: a button with `hx-get` (or
|
|
161
|
+
`hx-post`) + `hx-target` + `hx-swap` that fetches a server-rendered
|
|
162
|
+
section and swaps it into a target element.
|
|
163
|
+
|
|
164
|
+
### Before
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
hx-target={`#${VIEW_CONTENT_ID}`}
|
|
170
|
+
hx-swap="innerHTML transition:true"
|
|
171
|
+
hx-trigger="click"
|
|
172
|
+
hx-get={useSection({
|
|
173
|
+
props: {
|
|
174
|
+
viewConfig: {
|
|
175
|
+
view: viewIds.RECEIVE_ACCESS_CODE_FOR_PASSWORD,
|
|
176
|
+
currentEmail: props?.currentEmail,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
})}
|
|
180
|
+
hx-indicator="this"
|
|
181
|
+
>
|
|
182
|
+
Forgot password
|
|
183
|
+
</button>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Two After options
|
|
187
|
+
|
|
188
|
+
**Option A — local state machine** (when the swap stays inside one
|
|
189
|
+
component tree, e.g. a multi-step login form). Each `view` becomes a
|
|
190
|
+
discriminator in component state, each section becomes a sub-component.
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
import { useState } from "react";
|
|
194
|
+
|
|
195
|
+
type View =
|
|
196
|
+
| { name: "EMAIL_PWD" }
|
|
197
|
+
| { name: "RECEIVE_CODE_FOR_PWD"; currentEmail?: string }
|
|
198
|
+
| { name: "FORGOTTEN_PWD" };
|
|
199
|
+
|
|
200
|
+
export function LoginFlow({ initialEmail }: { initialEmail?: string }) {
|
|
201
|
+
const [view, setView] = useState<View>({ name: "EMAIL_PWD" });
|
|
202
|
+
|
|
203
|
+
if (view.name === "EMAIL_PWD") {
|
|
204
|
+
return (
|
|
205
|
+
<EmailAndPassword
|
|
206
|
+
currentEmail={initialEmail}
|
|
207
|
+
onForgotPassword={() =>
|
|
208
|
+
setView({
|
|
209
|
+
name: "RECEIVE_CODE_FOR_PWD",
|
|
210
|
+
currentEmail: initialEmail,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
/>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (view.name === "RECEIVE_CODE_FOR_PWD") {
|
|
217
|
+
return <ReceiveAccessCode currentEmail={view.currentEmail} />;
|
|
218
|
+
}
|
|
219
|
+
return <ForgottenPassword />;
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The "Forgot password" button becomes:
|
|
224
|
+
|
|
225
|
+
```tsx
|
|
226
|
+
<button type="button" onClick={onForgotPassword}>Forgot password</button>
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**Option B — sub-route** (when the swap should be URL-addressable,
|
|
230
|
+
e.g. cart, address-book pages, account sections that benefit from
|
|
231
|
+
deep-links).
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
// routes/account/forgot-password.tsx
|
|
235
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
236
|
+
|
|
237
|
+
export const Route = createFileRoute("/account/forgot-password")({
|
|
238
|
+
component: ForgottenPasswordPage,
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Button becomes:
|
|
243
|
+
|
|
244
|
+
```tsx
|
|
245
|
+
<Link to="/account/forgot-password">Forgot password</Link>
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Choose between A and B
|
|
249
|
+
|
|
250
|
+
- **B** if the new "view" is a meaningful URL (search filters, tabs,
|
|
251
|
+
multi-step flows worth bookmarking, account pages).
|
|
252
|
+
- **A** if the swap is incidental UI state (modals, dropdowns,
|
|
253
|
+
step-flows that should not survive reload).
|
|
254
|
+
|
|
255
|
+
## Pattern 4 — `form-swap`
|
|
256
|
+
|
|
257
|
+
Form posts that swap the result back into a target. Same surface as
|
|
258
|
+
click-swap but on a `<form>` and triggered by `submit`.
|
|
259
|
+
|
|
260
|
+
### Before
|
|
261
|
+
|
|
262
|
+
```tsx
|
|
263
|
+
<form
|
|
264
|
+
hx-target={`#${VIEW_CONTENT_ID}`}
|
|
265
|
+
hx-swap="innerHTML transition:true show:window:top"
|
|
266
|
+
hx-trigger="submit"
|
|
267
|
+
hx-post={useSection({
|
|
268
|
+
props: {
|
|
269
|
+
viewConfig: { view: viewIds.EMAIL_AND_PASSWORD },
|
|
270
|
+
action: actionIds.CLASSIC_SIGN_IN,
|
|
271
|
+
},
|
|
272
|
+
})}
|
|
273
|
+
hx-indicator=".submit"
|
|
274
|
+
>
|
|
275
|
+
<input name="email" type="email" required />
|
|
276
|
+
<input name="password" type="password" required />
|
|
277
|
+
<button type="submit" data-test-id="login-submit">Sign In</button>
|
|
278
|
+
</form>
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### After
|
|
282
|
+
|
|
283
|
+
```tsx
|
|
284
|
+
import { useMutation } from "@tanstack/react-query";
|
|
285
|
+
import { invoke } from "~/server/invoke";
|
|
286
|
+
import { useState } from "react";
|
|
287
|
+
|
|
288
|
+
export function EmailAndPassword({ onSuccess }: { onSuccess: (user: User) => void }) {
|
|
289
|
+
const [error, setError] = useState<string | null>(null);
|
|
290
|
+
|
|
291
|
+
const signIn = useMutation({
|
|
292
|
+
mutationFn: (input: { email: string; password: string }) =>
|
|
293
|
+
invoke({ "vtex.actions.auth/classicSignIn": input }),
|
|
294
|
+
onSuccess: (user) => onSuccess(user),
|
|
295
|
+
onError: (e) => setError(e.message),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<form
|
|
300
|
+
onSubmit={(e) => {
|
|
301
|
+
e.preventDefault();
|
|
302
|
+
const data = new FormData(e.currentTarget);
|
|
303
|
+
signIn.mutate({
|
|
304
|
+
email: String(data.get("email")),
|
|
305
|
+
password: String(data.get("password")),
|
|
306
|
+
});
|
|
307
|
+
}}
|
|
308
|
+
>
|
|
309
|
+
<input name="email" type="email" required />
|
|
310
|
+
<input name="password" type="password" required />
|
|
311
|
+
<button type="submit" disabled={signIn.isPending}>
|
|
312
|
+
{signIn.isPending ? "Signing in…" : "Sign In"}
|
|
313
|
+
</button>
|
|
314
|
+
{error && <p>{error}</p>}
|
|
315
|
+
</form>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Gotchas
|
|
321
|
+
|
|
322
|
+
- `hx-post={useSection(...)}` posted to a Deco section that ran a
|
|
323
|
+
server-side handler. The TanStack equivalent is **either** a
|
|
324
|
+
server function (`createServerFn`) **or** an action exposed via
|
|
325
|
+
`invoke` over `@decocms/apps`. Pick the one whose business logic
|
|
326
|
+
already lives there.
|
|
327
|
+
- `hx-indicator=".submit"` → button `disabled={mutation.isPending}`
|
|
328
|
+
+ visible "Signing in…" text. No CSS class plumbing.
|
|
329
|
+
- Validation errors that the section returned via re-render: now
|
|
330
|
+
surface via `mutation.onError` + state.
|
|
331
|
+
|
|
332
|
+
## Pattern 5 — `auto-fetch`
|
|
333
|
+
|
|
334
|
+
A fetch attribute fires automatically on a non-click trigger:
|
|
335
|
+
`keyup`, `intersect`, `revealed`, `load`, or `every:Xs`. Most often
|
|
336
|
+
seen on `<input>` for search-as-you-type.
|
|
337
|
+
|
|
338
|
+
### Before — search-as-you-type
|
|
339
|
+
|
|
340
|
+
```tsx
|
|
341
|
+
<input
|
|
342
|
+
id={searchInputId}
|
|
343
|
+
name="q"
|
|
344
|
+
type="text"
|
|
345
|
+
hx-sync="this:replace"
|
|
346
|
+
hx-swap="innerHTML transition:true"
|
|
347
|
+
hx-target={`#${searchResultsId}`}
|
|
348
|
+
hx-post={useComponent(Suggestions, { id })}
|
|
349
|
+
hx-trigger="keyup changed delay:200ms"
|
|
350
|
+
class="…"
|
|
351
|
+
/>
|
|
352
|
+
<div id={searchResultsId}></div>
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### After
|
|
356
|
+
|
|
357
|
+
```tsx
|
|
358
|
+
import { useState, useDeferredValue } from "react";
|
|
359
|
+
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
|
360
|
+
import { invoke } from "~/server/invoke";
|
|
361
|
+
|
|
362
|
+
export function SearchInput() {
|
|
363
|
+
const [term, setTerm] = useState("");
|
|
364
|
+
const debouncedTerm = useDeferredValue(term);
|
|
365
|
+
|
|
366
|
+
const suggestions = useQuery({
|
|
367
|
+
queryKey: ["search-suggestions", debouncedTerm],
|
|
368
|
+
queryFn: () =>
|
|
369
|
+
invoke({ "vtex.loaders.intelligentSearch/suggestions": { term: debouncedTerm } }),
|
|
370
|
+
enabled: debouncedTerm.length >= 2,
|
|
371
|
+
placeholderData: keepPreviousData,
|
|
372
|
+
staleTime: 30_000,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<>
|
|
377
|
+
<input
|
|
378
|
+
type="text"
|
|
379
|
+
value={term}
|
|
380
|
+
onChange={(e) => setTerm(e.target.value)}
|
|
381
|
+
placeholder="Search"
|
|
382
|
+
/>
|
|
383
|
+
<div>{suggestions.data?.map((s) => <SuggestionRow key={s.id} item={s} />)}</div>
|
|
384
|
+
</>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Before — intersection-triggered (lazy load row)
|
|
390
|
+
|
|
391
|
+
```tsx
|
|
392
|
+
<div hx-trigger="intersect once" hx-get={useComponent(MoreItems)} hx-swap="outerHTML"></div>
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### After
|
|
396
|
+
|
|
397
|
+
```tsx
|
|
398
|
+
import { useEffect, useRef, useState } from "react";
|
|
399
|
+
|
|
400
|
+
export function MoreItemsLoader() {
|
|
401
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
402
|
+
const [loaded, setLoaded] = useState(false);
|
|
403
|
+
const [items, setItems] = useState<Item[] | null>(null);
|
|
404
|
+
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
if (loaded || !ref.current) return;
|
|
407
|
+
const obs = new IntersectionObserver(
|
|
408
|
+
async ([entry]) => {
|
|
409
|
+
if (!entry.isIntersecting) return;
|
|
410
|
+
obs.disconnect();
|
|
411
|
+
setLoaded(true);
|
|
412
|
+
setItems(await invoke({ "site.loaders/moreItems": {} }));
|
|
413
|
+
},
|
|
414
|
+
{ rootMargin: "100px" },
|
|
415
|
+
);
|
|
416
|
+
obs.observe(ref.current);
|
|
417
|
+
return () => obs.disconnect();
|
|
418
|
+
}, [loaded]);
|
|
419
|
+
|
|
420
|
+
if (items) return <>{items.map((i) => <Row key={i.id} item={i} />)}</>;
|
|
421
|
+
return <div ref={ref} />;
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
### Gotchas
|
|
426
|
+
|
|
427
|
+
- `hx-sync="this:replace"` — the htmx-side concurrency control —
|
|
428
|
+
becomes "useQuery dedupes identical query keys + replaces results
|
|
429
|
+
on key change" automatically. No equivalent flag needed.
|
|
430
|
+
- `keyup changed delay:200ms` → `useDeferredValue` gives concurrent
|
|
431
|
+
rendering, or fall back to a manual debounce hook if you want a
|
|
432
|
+
fixed delay.
|
|
433
|
+
- Single-`once` triggers should set a flag on first intersection
|
|
434
|
+
and disconnect the observer (see `loaded` above).
|
|
435
|
+
|
|
436
|
+
## Pattern 6 — `oob-swap`
|
|
437
|
+
|
|
438
|
+
Out-of-band swaps don't have a clean React equivalent. They were
|
|
439
|
+
htmx's mechanism for a server response to update **multiple
|
|
440
|
+
disconnected DOM nodes** in one request — e.g. update the cart
|
|
441
|
+
indicator in the header AND the cart drawer body from a single
|
|
442
|
+
"add to cart" response.
|
|
443
|
+
|
|
444
|
+
There's no codemod here. Two refactor patterns:
|
|
445
|
+
|
|
446
|
+
### Refactor A — global state
|
|
447
|
+
Lift the data the OOB nodes read into a shared store (`@tanstack/store`).
|
|
448
|
+
The mutation writes to the store; the consumers re-render naturally.
|
|
449
|
+
This is the right choice 90 % of the time.
|
|
450
|
+
|
|
451
|
+
### Refactor B — broadcast event
|
|
452
|
+
For cases where one component must trigger a side effect in another
|
|
453
|
+
without a shared data shape: dispatch a custom event on `window`,
|
|
454
|
+
listen for it in the consumer's `useEffect`. This is the htmx
|
|
455
|
+
`hx-swap-oob` pattern, just done with browser primitives.
|
|
456
|
+
|
|
457
|
+
```tsx
|
|
458
|
+
// Producer
|
|
459
|
+
window.dispatchEvent(new CustomEvent("cart:item-added", { detail: { itemId } }));
|
|
460
|
+
|
|
461
|
+
// Consumer
|
|
462
|
+
useEffect(() => {
|
|
463
|
+
const handler = (e: Event) => { /* react to e.detail */ };
|
|
464
|
+
window.addEventListener("cart:item-added", handler);
|
|
465
|
+
return () => window.removeEventListener("cart:item-added", handler);
|
|
466
|
+
}, []);
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Prefer A. Resort to B only when the producer/consumer can't share a
|
|
470
|
+
parent.
|
|
471
|
+
|
|
472
|
+
## Pattern 7 — `unmatched`
|
|
473
|
+
|
|
474
|
+
Cases the analyzer didn't fit cleanly. Read each call site. Common
|
|
475
|
+
shapes:
|
|
476
|
+
|
|
477
|
+
- `<div hx-indicator="…">` standalone — passive marker that some
|
|
478
|
+
*other* element shows as a loading indicator. The other element
|
|
479
|
+
is the real interactivity site; rewrite there. The `hx-indicator`
|
|
480
|
+
div becomes plain JSX gated on a state boolean.
|
|
481
|
+
- `htmx-request:` Tailwind variants (`htmx-request:loading`,
|
|
482
|
+
`[.htmx-request>&]:hidden`) — these toggle styles based on
|
|
483
|
+
htmx's own request lifecycle. Replace with `data-[loading=true]:`
|
|
484
|
+
variants driven by your component state.
|
|
485
|
+
- `hx-confirm`, `hx-prompt` — synchronous browser dialogs. Use the
|
|
486
|
+
site's modal system, or `window.confirm()` if it was always a
|
|
487
|
+
trivial native dialog.
|
|
488
|
+
- Any `hx-*` attribute on a Deco component import path
|
|
489
|
+
(`<Accordion.Trigger hx-on-click={...}>`) — the component itself
|
|
490
|
+
spreads attrs onto a child element. Lift the handler into a prop.
|
|
491
|
+
|
|
492
|
+
## Verification
|
|
493
|
+
|
|
494
|
+
After the rewrite, the post-cleanup audit's
|
|
495
|
+
**`htmx-residue`** rule (Wave 13-C) reports any remaining `hx-*` in
|
|
496
|
+
the migrated `src/`. A site is "rewrite-complete" when `htmx-residue
|
|
497
|
+
== 0` for non-test files.
|
|
498
|
+
|
|
499
|
+
```bash
|
|
500
|
+
npx -p @decocms/start deco-post-cleanup --strict
|
|
501
|
+
# exit 2 if any hx-* attributes survive in src/
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
You can also re-run the analyzer on the migrated tree to verify
|
|
505
|
+
zero hits:
|
|
506
|
+
|
|
507
|
+
```bash
|
|
508
|
+
npx -p @decocms/start deco-htmx-analyze
|
|
509
|
+
# expects: ✓ No hx-* attributes found.
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
## Real-world signal — als-storefront
|
|
513
|
+
|
|
514
|
+
Initial inventory across 133 source files, 210 occurrences:
|
|
515
|
+
|
|
516
|
+
| Bucket | Count | % |
|
|
517
|
+
|---|---:|---:|
|
|
518
|
+
| event-handler | 88 | 42 % |
|
|
519
|
+
| click-swap | 64 | 30 % |
|
|
520
|
+
| form-swap | 20 | 10 % |
|
|
521
|
+
| auto-fetch | 9 | 4 % |
|
|
522
|
+
| oob-swap | 8 | 4 % |
|
|
523
|
+
| unmatched | 21 | 10 % |
|
|
524
|
+
|
|
525
|
+
86 % of occurrences fall in codemod-able buckets (event-handler,
|
|
526
|
+
click-swap, form-swap, auto-fetch). Wave 14 ships codemods for those
|
|
527
|
+
four; the rest stay manual.
|
|
@@ -359,7 +359,46 @@ rm src/types/widgets.ts
|
|
|
359
359
|
Confirm `tsc --noEmit` is still clean — the framework version is a
|
|
360
360
|
strict superset of what the migration script generated.
|
|
361
361
|
|
|
362
|
-
## 7.
|
|
362
|
+
## 7. Verify no leftover HTMX residue in `src/`
|
|
363
|
+
|
|
364
|
+
For sites that came from a Fresh codebase using HTMX (`@deco/htmx`,
|
|
365
|
+
`hx-*` attributes on JSX elements), the migration to TanStack Start
|
|
366
|
+
requires **rewriting** every HTMX interaction to React state +
|
|
367
|
+
event handlers + `useNavigate()`/sub-routes. The framework
|
|
368
|
+
intentionally ships **no** HTMX runtime — leaving `hx-*` attributes
|
|
369
|
+
in the migrated `src/` tree means the corresponding interaction is
|
|
370
|
+
silently dead.
|
|
371
|
+
|
|
372
|
+
The audit's `htmx-residue` rule scans every `*.{ts,tsx}` under `src/`
|
|
373
|
+
(excluding `*.test.tsx` / `*.spec.ts` / `__tests__/`) for any
|
|
374
|
+
remaining `hx-*` attribute, classifies each occurrence into one of
|
|
375
|
+
seven categories (`event-handler`, `form-swap`, `click-swap`,
|
|
376
|
+
`auto-fetch`, `oob-swap`, `boost`, `unmatched`), and emits one
|
|
377
|
+
warning per file with a category breakdown:
|
|
378
|
+
|
|
379
|
+
```
|
|
380
|
+
[WARNING] src/components/AddToBagButton.tsx:14 — 1 hx-* element(s) — event-handler=1
|
|
381
|
+
fix: Rewrite per .agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md
|
|
382
|
+
(run `deco-htmx-analyze` for the per-category breakdown)
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
The rule is intentionally **detect-only**:
|
|
386
|
+
|
|
387
|
+
- The rewrites are non-mechanical — choosing between a local state
|
|
388
|
+
machine, a sub-route, a React form action, or a platform hook
|
|
389
|
+
(e.g. `useCart`) varies per call site and depends on the data
|
|
390
|
+
flow, not just the attribute cluster.
|
|
391
|
+
- The companion CLI `deco-htmx-analyze` produces a richer inventory
|
|
392
|
+
(top tags, sample line numbers, JSON output) when you need to
|
|
393
|
+
triage hundreds of occurrences across a large repo.
|
|
394
|
+
- The `references/htmx-rewrite.md` skill is the per-pattern
|
|
395
|
+
playbook with before/after code for each of the seven categories.
|
|
396
|
+
|
|
397
|
+
In `--strict` mode any residue exits 2 — wire that into CI once a
|
|
398
|
+
site has finished its HTMX rewrite to prevent regressions sneaking
|
|
399
|
+
back in via copy-paste from a Fresh source.
|
|
400
|
+
|
|
401
|
+
## 8. Search for orphan `TODO: move into framework` comments
|
|
363
402
|
|
|
364
403
|
Real sites accumulate `TODO` comments like `// TODO: move into decoVitePlugin
|
|
365
404
|
in next @decocms/start release`. These are roadmap items the framework
|
|
@@ -376,7 +415,7 @@ For each hit, decide:
|
|
|
376
415
|
|
|
377
416
|
## Verification checklist
|
|
378
417
|
|
|
379
|
-
After completing 1-
|
|
418
|
+
After completing 1-8:
|
|
380
419
|
|
|
381
420
|
- [ ] `npm run typecheck` baseline matches pre-cleanup count (no new errors)
|
|
382
421
|
- [ ] `npm run dev` starts and `/`, `/some-pdp/p`, `/s?q=foo` all render
|
|
@@ -766,18 +766,73 @@ Wave 12 ships in priority-1 order; Wave 13 starts now.
|
|
|
766
766
|
prove it on 2 sites, promote to `@decocms/apps`, then rewrite the
|
|
767
767
|
template to use the canonical.
|
|
768
768
|
|
|
769
|
-
### Wave 13 (htmx foundations — Priority 2 part 1) —
|
|
769
|
+
### Wave 13 (htmx foundations — Priority 2 part 1) — ✅ **COMPLETE**
|
|
770
770
|
|
|
771
|
-
Once Wave 12
|
|
772
|
-
als is the first heavy htmx site and we know it won't be
|
|
773
|
-
(per the user, "some of our sites are, not all, not even
|
|
771
|
+
Once Wave 12 was in, the migration script needed an htmx track
|
|
772
|
+
because als is the first heavy htmx site and we know it won't be
|
|
773
|
+
the last (per the user, "some of our sites are, not all, not even
|
|
774
|
+
most, some"). **3 PRs in `deco-start`, all merged.** D2 forbids an
|
|
775
|
+
htmx adapter package; nothing in Wave 13 ships htmx runtime — only
|
|
776
|
+
analysis, rewrite recipes, and a "rewrite-complete" gate.
|
|
774
777
|
|
|
775
|
-
|
|
776
|
-
- **W13-B** deco-start: skill `references/htmx-rewrite.md` — pattern catalog with per-pattern rewrite recipe (decision tree: codemod vs manual recipe).
|
|
777
|
-
- **W13-C** deco-start: audit rule `htmx-residue` — counts `hx-*` attributes still in `src/`. Required-empty for "rewrite-complete" sites.
|
|
778
|
+
**Shipped PRs:**
|
|
778
779
|
|
|
779
|
-
|
|
780
|
-
|
|
780
|
+
- **W13-A** [`deco-start#129`](https://github.com/decocms/deco-start/pull/129) — `feat(migrate): htmx surface analyzer` ✅ **MERGED**, released as `@decocms/start@2.20.0`.
|
|
781
|
+
Adds `scripts/migrate/analyzers/htmx-analyze.ts` (per-file walker + classifier) and the `deco-htmx-analyze` CLI. The walker is heuristic JSX (regex for `hx-*` attrs, brace-balanced traversal back to the opening tag, forward to the closing `>` / `/>`) — skips strings, template literals, JSX expression slots, and balanced `{...}` blocks. Each occurrence is classified into one of seven categories (`event-handler`, `form-swap`, `click-swap`, `auto-fetch`, `oob-swap`, `boost`, `unmatched`) based on the attribute cluster, not individual attrs (recipes apply to clusters, not attrs in isolation). CLI emits per-category counts, top tags, sample line numbers, and a one-line migration recipe; `--json` for tooling. 24 tests covering classification (all 7 categories + tie-breaks + dash-variant `hx-on`) and real als-shaped fixtures (AddToBagButton, SearchInput, EmailAndPassword, ForgotPassword).
|
|
782
|
+
- **W13-B** [`deco-start#130`](https://github.com/decocms/deco-start/pull/130) — `docs(skills): add htmx-rewrite reference` ✅ **MERGED**.
|
|
783
|
+
Per-pattern playbook at `.agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md`. For each of the seven categories: a "Before" snippet pulled directly from als (so the recipe is grounded in what an engineer is actually staring at), an "After" snippet using the canonical TanStack Start patterns (`useState` + `useCart`, `useNavigate`, `useMutation`, sub-routes), an explicit decision criterion when more than one path is reasonable (e.g. local state machine vs. sub-route for `click-swap`), and a "Gotchas" block enumerating the failure modes humans actually hit (focus loss, double-submit, hydration mismatch, etc.). Cross-linked from `SKILL.md`'s problem table.
|
|
784
|
+
- **W13-C** [`deco-start#131`](https://github.com/decocms/deco-start/pull/131) — `feat(audit): htmx-residue rule` ✅ **MERGED**, released as `@decocms/start@2.21.0`.
|
|
785
|
+
Eighth audit rule. Reuses `analyzeFile` from `analyzers/htmx-analyze.ts` to scan `src/**/*.{ts,tsx}` (excluding `*.test.tsx` / `*.spec.ts` / `__tests__/`) and emits one warning per file with a category breakdown (`event-handler=2, form-swap=1`). Severity is `warning` so `--strict` exits 2 — the "rewrite-complete" CI gate. The fix string points at `references/htmx-rewrite.md`. Intentionally **detect-only** — rewrites are non-mechanical (state machine vs. sub-route vs. mutation choices vary per call site), so `--fix` wiring would be misleading; the skill is the playbook. 7 new tests cover aggregation, severity, test-file exclusion, scope (`src/` only), zero-finding gate, line-number reporting, and `supportsAutoFix: false`. Skill doc § 7 added explaining the rule + when to wire it into CI; help text updated.
|
|
786
|
+
|
|
787
|
+
### Wave 13 — discoveries
|
|
788
|
+
|
|
789
|
+
- **Heuristic JSX walking is enough; full AST is not needed for
|
|
790
|
+
this surface.** `analyzeFile` goes character-by-character with
|
|
791
|
+
brace-counting and string/template/comment skipping; it correctly
|
|
792
|
+
identifies attribute clusters in 100 % of the als-storefront and
|
|
793
|
+
internal-fixture sample (~120 files, ~270 hx-* attributes), and
|
|
794
|
+
the test corpus pins the tricky cases (dash-variant `hx-on-*`,
|
|
795
|
+
attached comments, balanced JSX expressions inside attributes,
|
|
796
|
+
multiline tags). Pulling in `@swc/core` or `recast` for this
|
|
797
|
+
would be over-engineering — the walker is ~150 LOC, deterministic,
|
|
798
|
+
and shares a single source of truth between the standalone CLI
|
|
799
|
+
(`deco-htmx-analyze`), the post-cleanup audit rule
|
|
800
|
+
(`htmx-residue`), and the per-pattern recipe references.
|
|
801
|
+
- **Classify by attribute cluster, not by individual attribute.**
|
|
802
|
+
An `hx-on:click` and `hx-post + hx-target + hx-swap` get
|
|
803
|
+
fundamentally different rewrites. Categorising at the cluster
|
|
804
|
+
level (the JSX tag + all its hx-* attrs) means each finding
|
|
805
|
+
points at exactly one of seven recipes in
|
|
806
|
+
`references/htmx-rewrite.md`. This is the same discipline that
|
|
807
|
+
worked for `STUB_FIX_HINTS` in the vtex-shim rule: the data shape
|
|
808
|
+
encodes the actionability category, the rule is just a thin
|
|
809
|
+
classifier on top.
|
|
810
|
+
- **D2 + W13-C form a closed loop, mirroring the W12 D3 + audit
|
|
811
|
+
pattern.** D2 says "no htmx runtime in `@decocms/start`". W13-C's
|
|
812
|
+
`htmx-residue` rule says "fail CI if any `hx-*` survives in
|
|
813
|
+
`src/`". Together: a migrated site cannot accidentally rely on
|
|
814
|
+
htmx because (a) the framework gives them no runtime to import,
|
|
815
|
+
(b) the audit catches every leftover `hx-*` in code review.
|
|
816
|
+
- **Detect-only is correct here, not a stop-gap.** Auto-fixing
|
|
817
|
+
htmx is conceptually hard: even a "simple" `<button hx-post>` →
|
|
818
|
+
`useMutation` rewrite has to choose between optimistic vs
|
|
819
|
+
pessimistic UI, error handling shape, where to surface the
|
|
820
|
+
loading state, and whether the response should re-render the
|
|
821
|
+
whole page or a fragment. Each is a per-site product decision.
|
|
822
|
+
The pattern catalog in `references/htmx-rewrite.md` is the
|
|
823
|
+
durable artefact; codemods (Wave 14) can target a specific
|
|
824
|
+
cluster shape (e.g. `hx-post + hx-target=#id + hx-swap=innerHTML`
|
|
825
|
+
with no `hx-trigger`) safely, but they're scoped by category,
|
|
826
|
+
not the rule's auto-fix.
|
|
827
|
+
- **The audit registry is now self-shaped for additive growth.**
|
|
828
|
+
Eight rules, three of which (`vtex-shim-regression`,
|
|
829
|
+
`obsolete-vite-plugins`, `htmx-residue`) ship with their own
|
|
830
|
+
analyzer modules. The pattern is set: add a rule to
|
|
831
|
+
`ALL_RULES`, supply `applyFix` only when mechanical, point the
|
|
832
|
+
prose `fix:` field and JSON `meta` at a skill reference. The CLI
|
|
833
|
+
(`migrate-post-cleanup.ts`) is rule-agnostic — adding a ninth
|
|
834
|
+
rule means changing one file, getting `--strict` and `--json`
|
|
835
|
+
for free.
|
|
781
836
|
|
|
782
837
|
### Wave 14 (htmx codemods + first als migration on 2.14+) — planned
|
|
783
838
|
|
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* Rules are intentionally read-only here — `--fix` is a follow-up.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { analyzeFile as analyzeHtmxFile } from "../analyzers/htmx-analyze";
|
|
12
13
|
import { classifyShimExports, type ExportClass } from "./shim-classify";
|
|
13
14
|
import type { Finding, FixAction, FsWriter, Rule, RuleContext } from "./types";
|
|
14
15
|
|
|
@@ -887,6 +888,76 @@ const ruleFrameworkTodos: Rule = {
|
|
|
887
888
|
},
|
|
888
889
|
};
|
|
889
890
|
|
|
891
|
+
/* ------------------------------------------------------------------ */
|
|
892
|
+
/* Rule 8 — `htmx-residue` — leftover hx-* attrs in migrated src/ */
|
|
893
|
+
/* ------------------------------------------------------------------ */
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Per D2 in the migration tooling policy, every `hx-*` attribute is
|
|
897
|
+
* rewritten on migration; nothing in `@decocms/start` ships an htmx
|
|
898
|
+
* runtime. This rule is the verification gate: a migrated site is
|
|
899
|
+
* "rewrite-complete" when there are zero `hx-*` attributes left in
|
|
900
|
+
* `src/`.
|
|
901
|
+
*
|
|
902
|
+
* Implementation reuses the htmx analyzer (`analyzeFile` from
|
|
903
|
+
* `analyzers/htmx-analyze.ts`) so categorisation and the JSX walker
|
|
904
|
+
* stay consistent with the standalone `deco-htmx-analyze` CLI. The
|
|
905
|
+
* rule restricts to `src/**` (the migrated React tree) and excludes
|
|
906
|
+
* test files — tests are allowed to mention `hx-*` for fixtures or
|
|
907
|
+
* regression checks.
|
|
908
|
+
*
|
|
909
|
+
* Severity is `warning`, so `--strict` exits 2 on any finding. The
|
|
910
|
+
* rule is intentionally detect-only: rewrites are non-mechanical
|
|
911
|
+
* (state machine + sub-route + mutation choices vary per call site)
|
|
912
|
+
* — the
|
|
913
|
+
* `references/htmx-rewrite.md` skill is the playbook.
|
|
914
|
+
*/
|
|
915
|
+
const ruleHtmxResidue: Rule = {
|
|
916
|
+
id: "htmx-residue",
|
|
917
|
+
title: "HTMX residue in migrated src/",
|
|
918
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
919
|
+
const findings: Finding[] = [];
|
|
920
|
+
const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
|
|
921
|
+
for (const abs of tsFiles) {
|
|
922
|
+
const rel = abs.slice(siteDir.length + 1);
|
|
923
|
+
// Skip test files — tests legitimately reference hx-* in fixtures
|
|
924
|
+
// or regression checks. Same exclusion shape as vitest's default.
|
|
925
|
+
if (/\.(test|spec)\.(ts|tsx)$/.test(rel)) continue;
|
|
926
|
+
if (rel.startsWith("src/__tests__/") || rel.includes("/__tests__/")) {
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
const content = fs.readText(abs);
|
|
930
|
+
const occurrences = analyzeHtmxFile(rel, content);
|
|
931
|
+
if (occurrences.length === 0) continue;
|
|
932
|
+
|
|
933
|
+
// Aggregate per-file: total + categories present.
|
|
934
|
+
const byCat = new Map<string, number>();
|
|
935
|
+
for (const occ of occurrences) {
|
|
936
|
+
byCat.set(occ.category, (byCat.get(occ.category) ?? 0) + 1);
|
|
937
|
+
}
|
|
938
|
+
const catSummary = [...byCat.entries()]
|
|
939
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
940
|
+
.map(([cat, n]) => `${cat}=${n}`)
|
|
941
|
+
.join(", ");
|
|
942
|
+
const firstLine = occurrences[0].line;
|
|
943
|
+
|
|
944
|
+
findings.push({
|
|
945
|
+
rule: "htmx-residue",
|
|
946
|
+
severity: "warning",
|
|
947
|
+
file: `${rel}:${firstLine}`,
|
|
948
|
+
message: `${occurrences.length} hx-* element(s) — ${catSummary}`,
|
|
949
|
+
fix: `Rewrite per .agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md (run \`deco-htmx-analyze\` for the per-category breakdown)`,
|
|
950
|
+
meta: {
|
|
951
|
+
total: occurrences.length,
|
|
952
|
+
byCategory: Object.fromEntries(byCat),
|
|
953
|
+
firstLine,
|
|
954
|
+
},
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
return findings;
|
|
958
|
+
},
|
|
959
|
+
};
|
|
960
|
+
|
|
890
961
|
export const ALL_RULES: Rule[] = [
|
|
891
962
|
ruleDeadLibShims,
|
|
892
963
|
ruleObsoleteVitePlugins,
|
|
@@ -895,6 +966,7 @@ export const ALL_RULES: Rule[] = [
|
|
|
895
966
|
ruleVtexShimRegression,
|
|
896
967
|
ruleLocalWidgetsTypes,
|
|
897
968
|
ruleFrameworkTodos,
|
|
969
|
+
ruleHtmxResidue,
|
|
898
970
|
];
|
|
899
971
|
|
|
900
972
|
/** Exported for direct unit tests. */
|
|
@@ -907,6 +979,7 @@ export const _internals = {
|
|
|
907
979
|
ruleDeadRuntimeShim,
|
|
908
980
|
ruleSiteLocalGlobals,
|
|
909
981
|
ruleVtexShimRegression,
|
|
982
|
+
ruleHtmxResidue,
|
|
910
983
|
ruleLocalWidgetsTypes,
|
|
911
984
|
ruleFrameworkTodos,
|
|
912
985
|
},
|
|
@@ -986,3 +986,111 @@ export default defineConfig({
|
|
|
986
986
|
expect(supported).toContain("obsolete-vite-plugins");
|
|
987
987
|
});
|
|
988
988
|
});
|
|
989
|
+
|
|
990
|
+
/* ------------------------------------------------------------------ */
|
|
991
|
+
/* W13-C — htmx-residue rule */
|
|
992
|
+
/* ------------------------------------------------------------------ */
|
|
993
|
+
|
|
994
|
+
describe("rule: htmx-residue", () => {
|
|
995
|
+
it("flags any leftover hx-* element in src/ with category breakdown", () => {
|
|
996
|
+
const fs = makeFs({
|
|
997
|
+
"/site/src/components/AddToBag.tsx":
|
|
998
|
+
'<button hx-on:click={() => {}}>buy</button>\n',
|
|
999
|
+
"/site/src/components/Search.tsx":
|
|
1000
|
+
'<form hx-post="/x" hx-target="#r" hx-swap="innerHTML"><input/></form>\n',
|
|
1001
|
+
});
|
|
1002
|
+
const report = runAudit(SITE, fs);
|
|
1003
|
+
const r = report.rules.find((r) => r.rule === "htmx-residue")!;
|
|
1004
|
+
expect(r.findings).toHaveLength(2);
|
|
1005
|
+
const summary = r.findings.map((f) => f.message).join(" | ");
|
|
1006
|
+
expect(summary).toContain("event-handler=1");
|
|
1007
|
+
expect(summary).toContain("form-swap=1");
|
|
1008
|
+
expect(r.findings[0].fix).toContain("htmx-rewrite.md");
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it("aggregates multiple occurrences in one file as a single finding", () => {
|
|
1012
|
+
const fs = makeFs({
|
|
1013
|
+
"/site/src/components/Big.tsx":
|
|
1014
|
+
'<button hx-on:click={() => {}}>1</button>\n' +
|
|
1015
|
+
'<button hx-on:click={() => {}}>2</button>\n' +
|
|
1016
|
+
'<form hx-post="/x" hx-target="#r" hx-swap="innerHTML"><input/></form>\n',
|
|
1017
|
+
});
|
|
1018
|
+
const report = runAudit(SITE, fs);
|
|
1019
|
+
const r = report.rules.find((r) => r.rule === "htmx-residue")!;
|
|
1020
|
+
expect(r.findings).toHaveLength(1);
|
|
1021
|
+
expect(r.findings[0].message).toContain("3 hx-* element(s)");
|
|
1022
|
+
expect(r.findings[0].message).toContain("event-handler=2");
|
|
1023
|
+
expect(r.findings[0].message).toContain("form-swap=1");
|
|
1024
|
+
expect(r.findings[0].meta?.total).toBe(3);
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
it("emits warning severity (so --strict exits 2)", () => {
|
|
1028
|
+
const fs = makeFs({
|
|
1029
|
+
"/site/src/x.tsx": '<button hx-on:click={() => {}}>x</button>\n',
|
|
1030
|
+
});
|
|
1031
|
+
const report = runAudit(SITE, fs);
|
|
1032
|
+
const r = report.rules.find((r) => r.rule === "htmx-residue")!;
|
|
1033
|
+
expect(r.findings[0].severity).toBe("warning");
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it("excludes test files (*.test.tsx, *.spec.ts, __tests__/) — they may legitimately reference hx-*", () => {
|
|
1037
|
+
const fs = makeFs({
|
|
1038
|
+
"/site/src/components/x.test.tsx":
|
|
1039
|
+
'<button hx-on:click={() => {}}>x</button>\n',
|
|
1040
|
+
"/site/src/components/y.spec.ts":
|
|
1041
|
+
'expect(html).toContain("hx-post=\\"/x\\""); /* doesn\'t hit our regex */\n',
|
|
1042
|
+
"/site/src/__tests__/csrf.tsx":
|
|
1043
|
+
'<form hx-post="/x" hx-target="#r" hx-swap="innerHTML"><input/></form>\n',
|
|
1044
|
+
});
|
|
1045
|
+
const report = runAudit(SITE, fs);
|
|
1046
|
+
const r = report.rules.find((r) => r.rule === "htmx-residue")!;
|
|
1047
|
+
expect(r.findings).toEqual([]);
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it("does NOT flag files outside src/ (the rule is scoped to migrated React tree)", () => {
|
|
1051
|
+
const fs = makeFs({
|
|
1052
|
+
// A pre-migration site might still have ./components/ at root.
|
|
1053
|
+
// After migration that's gone; if the engineer left some stragglers
|
|
1054
|
+
// in /scripts or /docs they don't block "rewrite-complete" gate.
|
|
1055
|
+
"/site/scripts/legacy.tsx":
|
|
1056
|
+
'<button hx-on:click={() => {}}>x</button>\n',
|
|
1057
|
+
"/site/docs/example.tsx":
|
|
1058
|
+
'<button hx-on:click={() => {}}>x</button>\n',
|
|
1059
|
+
});
|
|
1060
|
+
const report = runAudit(SITE, fs);
|
|
1061
|
+
const r = report.rules.find((r) => r.rule === "htmx-residue")!;
|
|
1062
|
+
expect(r.findings).toEqual([]);
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
it("returns zero findings on a clean migrated tree (the rewrite-complete gate)", () => {
|
|
1066
|
+
const fs = makeFs({
|
|
1067
|
+
"/site/src/components/Real.tsx":
|
|
1068
|
+
'<button onClick={() => {}}>x</button>\n',
|
|
1069
|
+
"/site/src/routes/index.tsx":
|
|
1070
|
+
'export const Route = createFileRoute("/")({ component: () => <div/> });\n',
|
|
1071
|
+
});
|
|
1072
|
+
const report = runAudit(SITE, fs);
|
|
1073
|
+
const r = report.rules.find((r) => r.rule === "htmx-residue")!;
|
|
1074
|
+
expect(r.findings).toEqual([]);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it("reports the line number of the FIRST hx-* element in the file", () => {
|
|
1078
|
+
const fs = makeFs({
|
|
1079
|
+
"/site/src/x.tsx":
|
|
1080
|
+
"import x from 'y';\n" +
|
|
1081
|
+
"// header\n" +
|
|
1082
|
+
'<button hx-on:click={() => {}}>x</button>\n',
|
|
1083
|
+
});
|
|
1084
|
+
const report = runAudit(SITE, fs);
|
|
1085
|
+
const r = report.rules.find((r) => r.rule === "htmx-residue")!;
|
|
1086
|
+
expect(r.findings[0].file).toBe("src/x.tsx:3");
|
|
1087
|
+
expect(r.findings[0].meta?.firstLine).toBe(3);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it("does NOT support auto-fix (rewrites are non-mechanical)", () => {
|
|
1091
|
+
const fs = makeFs({});
|
|
1092
|
+
const report = runAudit(SITE, fs);
|
|
1093
|
+
const r = report.rules.find((r) => r.rule === "htmx-residue")!;
|
|
1094
|
+
expect(r.supportsAutoFix).toBe(false);
|
|
1095
|
+
});
|
|
1096
|
+
});
|
|
@@ -76,8 +76,9 @@ function showHelp() {
|
|
|
76
76
|
Options:
|
|
77
77
|
--source <dir> Site directory to audit (default: .)
|
|
78
78
|
--fix Auto-apply mechanical fixes for the safe rules
|
|
79
|
-
(dead-lib-shims, dead-runtime-shim, local-widgets-types
|
|
80
|
-
|
|
79
|
+
(dead-lib-shims, dead-runtime-shim, local-widgets-types,
|
|
80
|
+
vtex-shim-regression swap subset, obsolete-vite-plugins).
|
|
81
|
+
Other rules — including htmx-residue — stay detect-only.
|
|
81
82
|
--json Emit machine-readable JSON instead of pretty text
|
|
82
83
|
--strict Exit code 2 if any warning-severity findings exist
|
|
83
84
|
--help, -h Show this help
|