@decocms/start 2.18.0 → 2.20.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/post-migration-cleanup.md +22 -10
- package/MIGRATION_TOOLING_PLAN.md +74 -18
- package/package.json +3 -2
- package/scripts/htmx-analyze.ts +226 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
- package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
- package/scripts/migrate/post-cleanup/rules.ts +299 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +182 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
analyzeFile,
|
|
4
|
+
analyzeHtmx,
|
|
5
|
+
classify,
|
|
6
|
+
type HtmxCategory,
|
|
7
|
+
} from "./htmx-analyze";
|
|
8
|
+
import type { FsAdapter } from "../post-cleanup/types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* In-memory FsAdapter mirroring the post-cleanup tests' helper. Kept
|
|
12
|
+
* local to this test file to avoid coupling — the analyzer's adapter
|
|
13
|
+
* surface is a strict subset of the audit's.
|
|
14
|
+
*/
|
|
15
|
+
function makeFs(files: Record<string, string>): FsAdapter {
|
|
16
|
+
const norm = Object.fromEntries(
|
|
17
|
+
Object.entries(files).map(([k, v]) => [k.replace(/\\/g, "/"), v]),
|
|
18
|
+
);
|
|
19
|
+
return {
|
|
20
|
+
exists(absPath) {
|
|
21
|
+
return absPath.replace(/\\/g, "/") in norm;
|
|
22
|
+
},
|
|
23
|
+
readText(absPath) {
|
|
24
|
+
const k = absPath.replace(/\\/g, "/");
|
|
25
|
+
if (!(k in norm)) throw new Error(`ENOENT: ${absPath}`);
|
|
26
|
+
return norm[k];
|
|
27
|
+
},
|
|
28
|
+
glob(siteDir, pattern, excludeDirs = []) {
|
|
29
|
+
const root = siteDir.replace(/\\/g, "/");
|
|
30
|
+
const all = Object.keys(norm).filter((p) => p.startsWith(`${root}/`));
|
|
31
|
+
const filtered = all.filter((p) => {
|
|
32
|
+
const rel = p.slice(root.length + 1);
|
|
33
|
+
return !excludeDirs.some((dir) => rel.startsWith(`${dir}/`));
|
|
34
|
+
});
|
|
35
|
+
const branches = pattern.includes("{")
|
|
36
|
+
? pattern
|
|
37
|
+
.match(/\{([^{}]+)\}/)![1]
|
|
38
|
+
.split(",")
|
|
39
|
+
.map((b) => pattern.replace(/\{[^{}]+\}/, b.trim()))
|
|
40
|
+
: [pattern];
|
|
41
|
+
const regexes = branches.map((p) => {
|
|
42
|
+
const re = p
|
|
43
|
+
.replace(/[.+^$()|]/g, "\\$&")
|
|
44
|
+
.replace(/\*\*\//g, "<<DBL>>")
|
|
45
|
+
.replace(/\*\*/g, "<<DBL>>")
|
|
46
|
+
.replace(/\*/g, "[^/]*")
|
|
47
|
+
.replace(/<<DBL>>/g, "(?:.*/)?");
|
|
48
|
+
return new RegExp(`^${re}$`);
|
|
49
|
+
});
|
|
50
|
+
return filtered
|
|
51
|
+
.filter((p) => {
|
|
52
|
+
const rel = p.slice(root.length + 1);
|
|
53
|
+
return regexes.some((re) => re.test(rel));
|
|
54
|
+
})
|
|
55
|
+
.sort();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const SITE = "/site";
|
|
61
|
+
|
|
62
|
+
/* ------------------------------------------------------------------ */
|
|
63
|
+
/* classify() — pure unit tests */
|
|
64
|
+
/* ------------------------------------------------------------------ */
|
|
65
|
+
|
|
66
|
+
describe("classify (pure)", () => {
|
|
67
|
+
it("classifies hx-boost as boost regardless of other attrs", () => {
|
|
68
|
+
expect(classify("a", ["hx-boost", "hx-target"])).toBe("boost");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("classifies hx-swap-oob / hx-select-oob as oob-swap", () => {
|
|
72
|
+
expect(classify("div", ["hx-swap-oob"])).toBe("oob-swap");
|
|
73
|
+
expect(classify("div", ["hx-select-oob"])).toBe("oob-swap");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("classifies a form with hx-post + hx-target + hx-swap as form-swap", () => {
|
|
77
|
+
expect(classify("form", ["hx-post", "hx-swap", "hx-target"])).toBe(
|
|
78
|
+
"form-swap",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("classifies a button with hx-get + hx-target as click-swap", () => {
|
|
83
|
+
expect(classify("button", ["hx-get", "hx-target", "hx-swap"])).toBe(
|
|
84
|
+
"click-swap",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("classifies an input with hx-post + hx-trigger as auto-fetch", () => {
|
|
89
|
+
expect(
|
|
90
|
+
classify("input", ["hx-post", "hx-target", "hx-trigger", "hx-swap"]),
|
|
91
|
+
).toBe("auto-fetch");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("classifies a button with only hx-on:click as event-handler", () => {
|
|
95
|
+
expect(classify("button", ["hx-on:click"])).toBe("event-handler");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("classifies hx-on-click (dash variant, htmx 2.x) as event-handler", () => {
|
|
99
|
+
// HTML spec doesn't allow `:` in attribute names; htmx 2.x
|
|
100
|
+
// canonicalised the dash form. We must recognise both.
|
|
101
|
+
expect(classify("button", ["hx-on-click"])).toBe("event-handler");
|
|
102
|
+
expect(classify("Accordion.Trigger", ["hx-on-click"])).toBe(
|
|
103
|
+
"event-handler",
|
|
104
|
+
);
|
|
105
|
+
// htmx-specific bare events e.g. `hx-on-htmx-config-request` —
|
|
106
|
+
// when paired with a fetch they fold into click-swap (fetch
|
|
107
|
+
// wins); standalone they are still event-handler shape.
|
|
108
|
+
expect(classify("div", ["hx-on-htmx-after-request"])).toBe(
|
|
109
|
+
"event-handler",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("classifies a div with only hx-trigger and no fetch attr as unmatched", () => {
|
|
114
|
+
// hx-trigger alone is meaningless; flag for manual review.
|
|
115
|
+
expect(classify("div", ["hx-trigger"])).toBe("unmatched");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("treats hx-on with a fetch attr as click-swap (the fetch wins)", () => {
|
|
119
|
+
// Real example from als (EmailAndPassword button): hx-get +
|
|
120
|
+
// hx-target + hx-trigger=click — engineer often piles
|
|
121
|
+
// hx-on alongside, but the dominant migration path is the
|
|
122
|
+
// click-swap recipe.
|
|
123
|
+
expect(
|
|
124
|
+
classify("button", ["hx-get", "hx-on:click", "hx-target", "hx-trigger"]),
|
|
125
|
+
).toBe("click-swap");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/* ------------------------------------------------------------------ */
|
|
130
|
+
/* analyzeFile() — JSX walker tests using real als-shaped fixtures */
|
|
131
|
+
/* ------------------------------------------------------------------ */
|
|
132
|
+
|
|
133
|
+
describe("analyzeFile (real shapes)", () => {
|
|
134
|
+
it("detects hx-on:click={useScript(...)} click handler (als AddToBagButton shape)", () => {
|
|
135
|
+
const file = `
|
|
136
|
+
import { useScript } from "@deco/deco/hooks";
|
|
137
|
+
export default function AddToBagButton() {
|
|
138
|
+
return (
|
|
139
|
+
<button
|
|
140
|
+
hx-on:click={useScript(async (skuId, sellerId) => {
|
|
141
|
+
if (!skuId || !sellerId) return;
|
|
142
|
+
const button = this as HTMLButtonElement;
|
|
143
|
+
button.dataset.loading = "true";
|
|
144
|
+
await globalThis.window.STOREFRONT.CART.addToCart({ orderItems: [{ id: skuId, quantity: 1 }] });
|
|
145
|
+
}, "sku", "1")}
|
|
146
|
+
class="add-to-bag"
|
|
147
|
+
>
|
|
148
|
+
Add to bag
|
|
149
|
+
</button>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
`;
|
|
153
|
+
const out = analyzeFile("AddToBagButton.tsx", file);
|
|
154
|
+
expect(out).toHaveLength(1);
|
|
155
|
+
expect(out[0].category).toBe("event-handler");
|
|
156
|
+
expect(out[0].tag).toBe("button");
|
|
157
|
+
expect(out[0].attrs).toEqual(["hx-on:click"]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("detects hx-post + hx-target + hx-trigger=keyup on an input as auto-fetch (als SearchInput shape)", () => {
|
|
161
|
+
const file = `
|
|
162
|
+
<input
|
|
163
|
+
id={searchInputId}
|
|
164
|
+
name="q"
|
|
165
|
+
type="text"
|
|
166
|
+
hx-sync="this:replace"
|
|
167
|
+
hx-swap="innerHTML transition:true"
|
|
168
|
+
hx-target={\`#\${searchResultsId}\`}
|
|
169
|
+
hx-post={useComponent(Suggestions, { id })}
|
|
170
|
+
hx-on:change={useScript(() => { /* analytics */ }, searchInputId)}
|
|
171
|
+
hx-trigger="keyup changed delay:200ms"
|
|
172
|
+
class="…"
|
|
173
|
+
/>
|
|
174
|
+
`;
|
|
175
|
+
const out = analyzeFile("SearchInput.tsx", file);
|
|
176
|
+
expect(out).toHaveLength(1);
|
|
177
|
+
expect(out[0].category).toBe("auto-fetch");
|
|
178
|
+
expect(out[0].tag).toBe("input");
|
|
179
|
+
expect(out[0].attrs).toContain("hx-post");
|
|
180
|
+
expect(out[0].attrs).toContain("hx-trigger");
|
|
181
|
+
expect(out[0].attrs).toContain("hx-target");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("detects hx-post + hx-target + hx-swap on a form as form-swap (als EmailAndPassword shape)", () => {
|
|
185
|
+
const file = `
|
|
186
|
+
<form
|
|
187
|
+
class="flex flex-col w-full"
|
|
188
|
+
hx-target={\`#\${VIEW_CONTENT_ID}\`}
|
|
189
|
+
hx-swap="innerHTML transition:true show:window:top"
|
|
190
|
+
hx-trigger="submit"
|
|
191
|
+
hx-post={useSection({ props: { viewConfig: { view: viewIds.EMAIL_AND_PASSWORD } } })}
|
|
192
|
+
hx-indicator=".submit"
|
|
193
|
+
>
|
|
194
|
+
<fieldset>…</fieldset>
|
|
195
|
+
</form>
|
|
196
|
+
`;
|
|
197
|
+
const out = analyzeFile("EmailAndPassword.tsx", file);
|
|
198
|
+
expect(out).toHaveLength(1);
|
|
199
|
+
expect(out[0].category).toBe("form-swap");
|
|
200
|
+
expect(out[0].tag).toBe("form");
|
|
201
|
+
expect(out[0].attrs).toContain("hx-post");
|
|
202
|
+
expect(out[0].attrs).toContain("hx-target");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("detects hx-get on a button as click-swap (als ForgotPassword shape)", () => {
|
|
206
|
+
const file = `
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
hx-target={\`#\${VIEW_CONTENT_ID}\`}
|
|
210
|
+
hx-swap="innerHTML transition:true"
|
|
211
|
+
hx-trigger="click"
|
|
212
|
+
hx-get={useSection({ props: { viewConfig: { view: viewIds.RECEIVE_ACCESS_CODE_FOR_PASSWORD } } })}
|
|
213
|
+
hx-indicator="this"
|
|
214
|
+
>
|
|
215
|
+
Forgot password
|
|
216
|
+
</button>
|
|
217
|
+
`;
|
|
218
|
+
const out = analyzeFile("ForgotPassword.tsx", file);
|
|
219
|
+
expect(out).toHaveLength(1);
|
|
220
|
+
expect(out[0].category).toBe("click-swap");
|
|
221
|
+
expect(out[0].tag).toBe("button");
|
|
222
|
+
expect(out[0].attrs).toContain("hx-get");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("counts each element exactly once even when multiple hx-* attrs match", () => {
|
|
226
|
+
// 4 hx-* attributes on the same form — should produce ONE
|
|
227
|
+
// occurrence, not four.
|
|
228
|
+
const file = `
|
|
229
|
+
<form hx-post="/x" hx-target="#a" hx-swap="innerHTML" hx-trigger="submit">
|
|
230
|
+
<input />
|
|
231
|
+
</form>
|
|
232
|
+
`;
|
|
233
|
+
const out = analyzeFile("F.tsx", file);
|
|
234
|
+
expect(out).toHaveLength(1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("detects multiple distinct elements in the same file", () => {
|
|
238
|
+
const file = `
|
|
239
|
+
<>
|
|
240
|
+
<button hx-on:click={() => {}}>A</button>
|
|
241
|
+
<form hx-post="/x" hx-target="#r" hx-swap="innerHTML">
|
|
242
|
+
<input name="q" />
|
|
243
|
+
</form>
|
|
244
|
+
<a hx-boost="true" href="/p">P</a>
|
|
245
|
+
</>
|
|
246
|
+
`;
|
|
247
|
+
const out = analyzeFile("Multi.tsx", file);
|
|
248
|
+
expect(out).toHaveLength(3);
|
|
249
|
+
const cats = out.map((o) => o.category).sort();
|
|
250
|
+
expect(cats).toEqual(["boost", "event-handler", "form-swap"]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("does not miscount braces inside template literals or attribute expressions", () => {
|
|
254
|
+
const file = `
|
|
255
|
+
<button
|
|
256
|
+
hx-target={\`#\${id}\`}
|
|
257
|
+
hx-get={useSection({ props: { x: { y: 1 } } })}
|
|
258
|
+
>
|
|
259
|
+
go
|
|
260
|
+
</button>
|
|
261
|
+
`;
|
|
262
|
+
const out = analyzeFile("Tricky.tsx", file);
|
|
263
|
+
expect(out).toHaveLength(1);
|
|
264
|
+
expect(out[0].category).toBe("click-swap");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("returns an empty array when no hx-* attributes are present", () => {
|
|
268
|
+
const file = `
|
|
269
|
+
<button onClick={() => {}}>plain react</button>
|
|
270
|
+
`;
|
|
271
|
+
expect(analyzeFile("React.tsx", file)).toEqual([]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("captures the tag name of components, not just intrinsic tags", () => {
|
|
275
|
+
const file = `
|
|
276
|
+
<MyComponent hx-on:click={() => {}}>x</MyComponent>
|
|
277
|
+
`;
|
|
278
|
+
const out = analyzeFile("C.tsx", file);
|
|
279
|
+
expect(out).toHaveLength(1);
|
|
280
|
+
expect(out[0].tag).toBe("MyComponent");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("reports a 1-indexed line number for the opening tag", () => {
|
|
284
|
+
const file = "// header\n// line 2\n<button hx-on:click={() => {}}>x</button>\n";
|
|
285
|
+
const out = analyzeFile("L.tsx", file);
|
|
286
|
+
expect(out[0].line).toBe(3);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
/* ------------------------------------------------------------------ */
|
|
291
|
+
/* analyzeHtmx() — full inventory tests */
|
|
292
|
+
/* ------------------------------------------------------------------ */
|
|
293
|
+
|
|
294
|
+
describe("analyzeHtmx (inventory)", () => {
|
|
295
|
+
it("aggregates counts across files and produces samples", () => {
|
|
296
|
+
const fs = makeFs({
|
|
297
|
+
"/site/components/A.tsx":
|
|
298
|
+
'<button hx-on:click={() => {}}>a1</button>\n' +
|
|
299
|
+
'<button hx-on:click={() => {}}>a2</button>\n',
|
|
300
|
+
"/site/components/B.tsx":
|
|
301
|
+
'<form hx-post="/x" hx-target="#r" hx-swap="innerHTML"><input/></form>\n',
|
|
302
|
+
"/site/components/C.tsx":
|
|
303
|
+
'<a hx-boost="true" href="/p">p</a>\n',
|
|
304
|
+
"/site/Plain.ts": "export const x = 1;\n",
|
|
305
|
+
});
|
|
306
|
+
const inv = analyzeHtmx(SITE, fs);
|
|
307
|
+
expect(inv.totalFiles).toBe(3);
|
|
308
|
+
expect(inv.totalOccurrences).toBe(4);
|
|
309
|
+
expect(inv.byCategory["event-handler"]).toBe(2);
|
|
310
|
+
expect(inv.byCategory["form-swap"]).toBe(1);
|
|
311
|
+
expect(inv.byCategory.boost).toBe(1);
|
|
312
|
+
expect(inv.samples["event-handler"]).toHaveLength(2);
|
|
313
|
+
expect(inv.samples["form-swap"]).toHaveLength(1);
|
|
314
|
+
expect(inv.samples.boost).toHaveLength(1);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("orders files by total descending so the biggest offenders come first", () => {
|
|
318
|
+
const fs = makeFs({
|
|
319
|
+
"/site/Small.tsx": '<button hx-on:click={() => {}}>x</button>\n',
|
|
320
|
+
"/site/Big.tsx":
|
|
321
|
+
'<button hx-on:click={() => {}}>1</button>\n' +
|
|
322
|
+
'<button hx-on:click={() => {}}>2</button>\n' +
|
|
323
|
+
'<button hx-on:click={() => {}}>3</button>\n',
|
|
324
|
+
"/site/Mid.tsx":
|
|
325
|
+
'<button hx-on:click={() => {}}>a</button>\n' +
|
|
326
|
+
'<button hx-on:click={() => {}}>b</button>\n',
|
|
327
|
+
});
|
|
328
|
+
const inv = analyzeHtmx(SITE, fs);
|
|
329
|
+
expect(inv.files.map((f) => f.file)).toEqual([
|
|
330
|
+
"Big.tsx",
|
|
331
|
+
"Mid.tsx",
|
|
332
|
+
"Small.tsx",
|
|
333
|
+
]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("caps samples per category at 3 even when there are many occurrences", () => {
|
|
337
|
+
const lines = Array.from(
|
|
338
|
+
{ length: 10 },
|
|
339
|
+
(_, i) => `<button hx-on:click={() => {}}>x${i}</button>`,
|
|
340
|
+
).join("\n");
|
|
341
|
+
const fs = makeFs({ "/site/Many.tsx": lines });
|
|
342
|
+
const inv = analyzeHtmx(SITE, fs);
|
|
343
|
+
expect(inv.totalOccurrences).toBe(10);
|
|
344
|
+
expect(inv.samples["event-handler"]).toHaveLength(3);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("excludes documentation directories from analysis", () => {
|
|
348
|
+
// `.cursor/` and `docs/` are common locations for htmx skill
|
|
349
|
+
// references / tutorials. They should not pollute the count.
|
|
350
|
+
const fs = makeFs({
|
|
351
|
+
"/site/components/Real.tsx":
|
|
352
|
+
'<button hx-on:click={() => {}}>x</button>\n',
|
|
353
|
+
"/site/.cursor/skills/htmx/example.tsx":
|
|
354
|
+
'<button hx-on:click={() => {}}>doc</button>\n',
|
|
355
|
+
"/site/docs/example.md": "irrelevant",
|
|
356
|
+
});
|
|
357
|
+
const inv = analyzeHtmx(SITE, fs);
|
|
358
|
+
expect(inv.totalOccurrences).toBe(1);
|
|
359
|
+
expect(inv.files.map((f) => f.file)).toEqual(["components/Real.tsx"]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("returns zero counts on a clean repo", () => {
|
|
363
|
+
const fs = makeFs({
|
|
364
|
+
"/site/A.tsx": '<button onClick={() => {}}>x</button>\n',
|
|
365
|
+
});
|
|
366
|
+
const inv = analyzeHtmx(SITE, fs);
|
|
367
|
+
expect(inv.totalOccurrences).toBe(0);
|
|
368
|
+
expect(inv.totalFiles).toBe(0);
|
|
369
|
+
const allCats = Object.values(inv.byCategory) as number[];
|
|
370
|
+
expect(allCats.every((c) => c === 0)).toBe(true);
|
|
371
|
+
});
|
|
372
|
+
});
|