@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.
@@ -0,0 +1,425 @@
1
+ /**
2
+ * HTMX surface analyzer.
3
+ *
4
+ * For sites with significant HTMX usage (the deco-cx Fresh stack
5
+ * embraced htmx as the interactivity model), the migration to TanStack
6
+ * Start has to either rewrite or eliminate every `hx-*` attribute.
7
+ * Per D2 in the migration tooling policy we don't ship a runtime —
8
+ * everything gets rewritten to React. This analyzer is the first step:
9
+ * it inventories the `hx-*` surface so the engineer (or the next
10
+ * codemod wave) knows exactly what shapes are out there before
11
+ * starting the rewrite.
12
+ *
13
+ * The output is a structured `HtmxInventory`: per-category counts,
14
+ * per-file counts, and three sample call sites per category. The
15
+ * report is the source of truth for the rewrite recipes documented
16
+ * in `references/htmx-rewrite.md` (Wave 13-B).
17
+ *
18
+ * Categorization is heuristic — we group elements by their **attribute
19
+ * cluster** rather than by individual attribute, because the rewrite
20
+ * recipe depends on the cluster (e.g. `hx-post + hx-target + hx-swap`
21
+ * on a `<form>` is a different rewrite than `hx-get + hx-target` on a
22
+ * `<button>`). False positives are recoverable: a human reads the
23
+ * report.
24
+ *
25
+ * Wave 13-A. Closes the analysis half of `htmx-foundations`; the
26
+ * codemods proper land in Wave 14.
27
+ */
28
+
29
+ import type { FsAdapter } from "../post-cleanup/types";
30
+
31
+ const SRC_GLOB_EXCLUDES = [
32
+ "node_modules",
33
+ "dist",
34
+ ".wrangler",
35
+ ".vite",
36
+ ".tanstack",
37
+ "build",
38
+ ".cursor",
39
+ ".agents",
40
+ "docs",
41
+ ];
42
+
43
+ /* ------------------------------------------------------------------ */
44
+ /* Public types */
45
+ /* ------------------------------------------------------------------ */
46
+
47
+ export type HtmxCategory =
48
+ | "event-handler"
49
+ | "form-swap"
50
+ | "click-swap"
51
+ | "auto-fetch"
52
+ | "oob-swap"
53
+ | "boost"
54
+ | "unmatched";
55
+
56
+ export interface HtmxOccurrence {
57
+ category: HtmxCategory;
58
+ file: string;
59
+ /** 1-indexed line of the opening tag's start. */
60
+ line: number;
61
+ /** Tag name (e.g. "button", "form", "input", "MyComponent"). */
62
+ tag: string;
63
+ /** Set of canonical hx-* attribute names found on this element. */
64
+ attrs: string[];
65
+ }
66
+
67
+ export interface HtmxFileSummary {
68
+ file: string;
69
+ total: number;
70
+ byCategory: Record<HtmxCategory, number>;
71
+ }
72
+
73
+ export interface HtmxInventory {
74
+ totalFiles: number;
75
+ totalOccurrences: number;
76
+ byCategory: Record<HtmxCategory, number>;
77
+ files: HtmxFileSummary[];
78
+ /** Up to 3 sample occurrences per category (ordered by file path). */
79
+ samples: Record<HtmxCategory, HtmxOccurrence[]>;
80
+ }
81
+
82
+ /* ------------------------------------------------------------------ */
83
+ /* Entry point */
84
+ /* ------------------------------------------------------------------ */
85
+
86
+ /**
87
+ * Analyze every `*.{ts,tsx}` file under `siteDir` and return a
88
+ * structured inventory of htmx usage. Pure over the injected
89
+ * `FsAdapter`, so callers (CLI, tests, migration phase wiring) can
90
+ * substitute in-memory file systems.
91
+ */
92
+ export function analyzeHtmx(siteDir: string, fs: FsAdapter): HtmxInventory {
93
+ const files = fs.glob(siteDir, "**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
94
+ const occurrences: HtmxOccurrence[] = [];
95
+ const fileSummaries: HtmxFileSummary[] = [];
96
+
97
+ for (const abs of files) {
98
+ const rel = abs.startsWith(`${siteDir}/`)
99
+ ? abs.slice(siteDir.length + 1)
100
+ : abs;
101
+ const content = fs.readText(abs);
102
+ const fileOccurrences = analyzeFile(rel, content);
103
+ if (fileOccurrences.length === 0) continue;
104
+ const summary: HtmxFileSummary = {
105
+ file: rel,
106
+ total: fileOccurrences.length,
107
+ byCategory: emptyCategoryRecord(),
108
+ };
109
+ for (const occ of fileOccurrences) {
110
+ summary.byCategory[occ.category]++;
111
+ }
112
+ fileSummaries.push(summary);
113
+ occurrences.push(...fileOccurrences);
114
+ }
115
+
116
+ const byCategory = emptyCategoryRecord();
117
+ for (const occ of occurrences) byCategory[occ.category]++;
118
+
119
+ const samples = emptyCategorySamples();
120
+ for (const occ of occurrences) {
121
+ const bucket = samples[occ.category];
122
+ if (bucket.length < 3) bucket.push(occ);
123
+ }
124
+
125
+ // Order files by total descending so the "biggest offenders" land
126
+ // at the top of the report — that's the order an engineer wants to
127
+ // chip away at the surface in.
128
+ fileSummaries.sort((a, b) => b.total - a.total || a.file.localeCompare(b.file));
129
+
130
+ return {
131
+ totalFiles: fileSummaries.length,
132
+ totalOccurrences: occurrences.length,
133
+ byCategory,
134
+ files: fileSummaries,
135
+ samples,
136
+ };
137
+ }
138
+
139
+ function emptyCategoryRecord(): Record<HtmxCategory, number> {
140
+ return {
141
+ "event-handler": 0,
142
+ "form-swap": 0,
143
+ "click-swap": 0,
144
+ "auto-fetch": 0,
145
+ "oob-swap": 0,
146
+ boost: 0,
147
+ unmatched: 0,
148
+ };
149
+ }
150
+
151
+ function emptyCategorySamples(): Record<HtmxCategory, HtmxOccurrence[]> {
152
+ return {
153
+ "event-handler": [],
154
+ "form-swap": [],
155
+ "click-swap": [],
156
+ "auto-fetch": [],
157
+ "oob-swap": [],
158
+ boost: [],
159
+ unmatched: [],
160
+ };
161
+ }
162
+
163
+ /* ------------------------------------------------------------------ */
164
+ /* Per-file analysis */
165
+ /* ------------------------------------------------------------------ */
166
+
167
+ /**
168
+ * Parse a single source file and extract one occurrence per JSX
169
+ * opening-tag that carries any `hx-*` attribute. Exported so tests
170
+ * can exercise the parser without needing an FsAdapter.
171
+ */
172
+ export function analyzeFile(file: string, content: string): HtmxOccurrence[] {
173
+ const occurrences: HtmxOccurrence[] = [];
174
+ const seenTagStarts = new Set<number>();
175
+
176
+ // Anchored on the attribute name only (not the value), so JSX
177
+ // expression slots in the value (`hx-post={useSection({...})}`)
178
+ // don't trip the regex up.
179
+ const HX_ATTR_RE = /\bhx-([a-z]+(?:-[a-z]+)*(?::[A-Za-z]+)?)\b/g;
180
+
181
+ for (const m of content.matchAll(HX_ATTR_RE)) {
182
+ const attrIdx = m.index;
183
+ if (attrIdx === undefined) continue;
184
+ const tagOpen = findEnclosingTagOpen(content, attrIdx);
185
+ if (tagOpen < 0) continue;
186
+ if (seenTagStarts.has(tagOpen)) continue;
187
+ const tagClose = findOpeningTagClose(content, tagOpen);
188
+ if (tagClose < 0) continue;
189
+ seenTagStarts.add(tagOpen);
190
+
191
+ const tagSpan = content.slice(tagOpen, tagClose + 1);
192
+ const tag = extractTagName(tagSpan);
193
+ const attrs = collectHxAttrs(tagSpan);
194
+ const line = lineNumberAt(content, tagOpen);
195
+ const category = classify(tag, attrs);
196
+
197
+ occurrences.push({
198
+ category,
199
+ file,
200
+ line,
201
+ tag,
202
+ attrs,
203
+ });
204
+ }
205
+
206
+ return occurrences;
207
+ }
208
+
209
+ /**
210
+ * Walk backwards from `attrIdx` to the most recent `<` that starts an
211
+ * opening tag (`<TagName`). Returns the index of that `<`, or -1 if
212
+ * no plausible tag start is found before the start of the file.
213
+ *
214
+ * "Plausible tag start" = `<` followed by a letter or `_`, where the
215
+ * `<` is not part of a `</` (closing tag), `<<` (operator), or
216
+ * preceded by an operand that would make it a comparison.
217
+ */
218
+ function findEnclosingTagOpen(content: string, attrIdx: number): number {
219
+ for (let i = attrIdx - 1; i >= 0; i--) {
220
+ if (content[i] !== "<") continue;
221
+ // Closing tag — `</foo>` — wouldn't carry attributes.
222
+ if (content[i + 1] === "/") continue;
223
+ // Comparison / shift operator — left side is a non-tag char.
224
+ const next = content[i + 1];
225
+ if (!/[A-Za-z_]/.test(next ?? "")) continue;
226
+ // Be slightly defensive: if the character right before `<` is a
227
+ // closing-paren or another `>`, we're definitely in JSX. If it's
228
+ // an alpha char or `]`, this could be `a < b` — but `a < b` is
229
+ // followed by an *expression*, not a tag-name + space + `hx-…`,
230
+ // so the regex hit at attrIdx would be far away. Don't bother
231
+ // disambiguating; just return the candidate.
232
+ return i;
233
+ }
234
+ return -1;
235
+ }
236
+
237
+ /**
238
+ * From the `<` at `tagOpen`, walk forward to find the index of the
239
+ * `>` that closes the *opening tag* (not the close tag of the same
240
+ * element). Skips strings (single-, double-, backtick-quoted) and
241
+ * tracks balanced `{...}` expression slots so JSX expressions in
242
+ * attributes (`hx-post={useSection({...})}`) don't mislead us.
243
+ */
244
+ function findOpeningTagClose(content: string, tagOpen: number): number {
245
+ let i = tagOpen + 1;
246
+ const n = content.length;
247
+ while (i < n) {
248
+ const ch = content[i];
249
+ if (ch === '"' || ch === "'") {
250
+ i = skipStringQuote(content, i, ch);
251
+ continue;
252
+ }
253
+ if (ch === "`") {
254
+ i = skipTemplateLiteral(content, i);
255
+ continue;
256
+ }
257
+ if (ch === "{") {
258
+ i = skipBraceBalanced(content, i);
259
+ continue;
260
+ }
261
+ if (ch === "/" && content[i + 1] === ">") return i + 1;
262
+ if (ch === ">") return i;
263
+ i++;
264
+ }
265
+ return -1;
266
+ }
267
+
268
+ function skipStringQuote(content: string, start: number, quote: string): number {
269
+ let i = start + 1;
270
+ const n = content.length;
271
+ while (i < n) {
272
+ if (content[i] === "\\") {
273
+ i += 2;
274
+ continue;
275
+ }
276
+ if (content[i] === quote) return i + 1;
277
+ i++;
278
+ }
279
+ return n;
280
+ }
281
+
282
+ function skipTemplateLiteral(content: string, start: number): number {
283
+ let i = start + 1;
284
+ const n = content.length;
285
+ while (i < n) {
286
+ if (content[i] === "\\") {
287
+ i += 2;
288
+ continue;
289
+ }
290
+ if (content[i] === "`") return i + 1;
291
+ if (content[i] === "$" && content[i + 1] === "{") {
292
+ i = skipBraceBalanced(content, i + 1);
293
+ continue;
294
+ }
295
+ i++;
296
+ }
297
+ return n;
298
+ }
299
+
300
+ function skipBraceBalanced(content: string, openIdx: number): number {
301
+ let i = openIdx + 1;
302
+ let depth = 1;
303
+ const n = content.length;
304
+ while (i < n && depth > 0) {
305
+ const ch = content[i];
306
+ if (ch === '"' || ch === "'") {
307
+ i = skipStringQuote(content, i, ch);
308
+ continue;
309
+ }
310
+ if (ch === "`") {
311
+ i = skipTemplateLiteral(content, i);
312
+ continue;
313
+ }
314
+ if (ch === "{") {
315
+ depth++;
316
+ i++;
317
+ continue;
318
+ }
319
+ if (ch === "}") {
320
+ depth--;
321
+ i++;
322
+ continue;
323
+ }
324
+ i++;
325
+ }
326
+ return i;
327
+ }
328
+
329
+ function extractTagName(tagSpan: string): string {
330
+ // tagSpan starts with `<`. The tag name is /[A-Za-z_][\w.-]*/ until
331
+ // whitespace, `/`, or `>`.
332
+ const m = /^<([A-Za-z_][\w.-]*)/.exec(tagSpan);
333
+ return m?.[1] ?? "";
334
+ }
335
+
336
+ function collectHxAttrs(tagSpan: string): string[] {
337
+ // Strip JSX expression slots so `hx-post={someFn({hx-thing: 1})}`
338
+ // — which doesn't actually exist in JSX, but defensive — doesn't
339
+ // inflate attr counts. We match the same name shape as the entry
340
+ // regex.
341
+ const seen = new Set<string>();
342
+ const re = /\bhx-([a-z]+(?:-[a-z]+)*(?::[A-Za-z]+)?)\b/g;
343
+ for (const m of tagSpan.matchAll(re)) {
344
+ seen.add(`hx-${m[1]}`);
345
+ }
346
+ return [...seen].sort();
347
+ }
348
+
349
+ function lineNumberAt(content: string, idx: number): number {
350
+ let line = 1;
351
+ for (let i = 0; i < idx && i < content.length; i++) {
352
+ if (content[i] === "\n") line++;
353
+ }
354
+ return line;
355
+ }
356
+
357
+ /* ------------------------------------------------------------------ */
358
+ /* Classification */
359
+ /* ------------------------------------------------------------------ */
360
+
361
+ /**
362
+ * Bucket an element with attribute set `attrs` into one of the
363
+ * `HtmxCategory` values. Order is intentional: the more specific
364
+ * shapes are checked first.
365
+ *
366
+ * - **boost**: `hx-boost="true"` is a top-level switch — handled
367
+ * first so it doesn't conflate with click-swap/form-swap shapes.
368
+ * - **oob-swap**: `hx-swap-oob` / `hx-select-oob` is a structural
369
+ * pattern that almost never has a clean React equivalent.
370
+ * - **auto-fetch**: a fetch attribute paired with a `hx-trigger` that
371
+ * isn't user-driven (`keyup`, `intersect`, `revealed`, `load`,
372
+ * `every:`).
373
+ * - **form-swap**: `hx-post` (with or without `hx-target`/`hx-swap`)
374
+ * on a `<form>`, OR with an explicit `hx-trigger="submit"`.
375
+ * - **click-swap**: `hx-get` (or `hx-post`) on anything else with
376
+ * `hx-target` — the dominant button-driven pattern.
377
+ * - **event-handler**: `hx-on:*` or `hx-on:click` etc with no
378
+ * fetch attr — pure client-side handler that happens to be wired
379
+ * via htmx for historical consistency.
380
+ * - **unmatched**: anything that didn't fit cleanly. Reported as a
381
+ * manual-review bucket.
382
+ */
383
+ export function classify(tag: string, attrs: string[]): HtmxCategory {
384
+ const has = (name: string) => attrs.includes(name);
385
+ const hasAny = (re: RegExp) => attrs.some((a) => re.test(a));
386
+
387
+ if (has("hx-boost")) return "boost";
388
+ if (has("hx-swap-oob") || has("hx-select-oob")) return "oob-swap";
389
+
390
+ const hasFetch = hasAny(/^hx-(get|post|put|patch|delete)$/);
391
+ // htmx supports both colon (`hx-on:click`) and dash (`hx-on-click`)
392
+ // syntax for event handlers; HTML's spec doesn't allow `:` in
393
+ // attribute names so htmx 2.x canonicalised the dash form. Match
394
+ // both. The dash form is followed by an event name (`click`,
395
+ // `htmx-config-request`, etc.), the colon form by `:event`.
396
+ const hasOn = hasAny(/^hx-on(?:[:-]|$)/);
397
+ const hasTarget = has("hx-target");
398
+ const triggerIsAutoLike =
399
+ // We don't have the value of the trigger here — just whether
400
+ // the attribute exists. The dominant non-form pattern *with*
401
+ // hx-trigger is "keyup changed delay:Xms" or "intersect".
402
+ // Without value access, we infer "auto-fetch" by elimination:
403
+ // fetch + hx-trigger + non-form. Form-submit explicit triggers
404
+ // are caught by the form-swap branch via `tag === "form"`.
405
+ has("hx-trigger");
406
+
407
+ if (hasFetch) {
408
+ // `<form>` element — form-swap (regardless of trigger).
409
+ if (tag === "form") return "form-swap";
410
+ // `<input>`, `<textarea>` etc carrying a fetch attribute —
411
+ // they fire on input event, treat as auto-fetch.
412
+ if (tag === "input" || tag === "textarea" || tag === "select") {
413
+ return "auto-fetch";
414
+ }
415
+ // Non-form / non-input element with fetch + auto-trigger →
416
+ // auto-fetch (could be `<div hx-trigger="intersect" hx-get>`).
417
+ if (triggerIsAutoLike && !hasTarget) return "auto-fetch";
418
+ // Default for the dominant button-driven pattern.
419
+ return "click-swap";
420
+ }
421
+
422
+ if (hasOn) return "event-handler";
423
+
424
+ return "unmatched";
425
+ }