@decocms/start 2.19.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
|
@@ -17,7 +17,8 @@ of which sections below actually apply to your codebase:
|
|
|
17
17
|
npx -p @decocms/start deco-post-cleanup
|
|
18
18
|
|
|
19
19
|
# Auto-apply mechanical fixes for the safe rules, then report what's left.
|
|
20
|
-
# Safe rules: dead-lib-shims, dead-runtime-shim, local-widgets-types
|
|
20
|
+
# Safe rules: dead-lib-shims, dead-runtime-shim, local-widgets-types,
|
|
21
|
+
# vtex-shim-regression (swap subset), obsolete-vite-plugins.
|
|
21
22
|
# Other rules stay detect-only — they require human judgment.
|
|
22
23
|
npx -p @decocms/start deco-post-cleanup --fix
|
|
23
24
|
|
|
@@ -29,18 +30,23 @@ npx -p @decocms/start deco-post-cleanup --json
|
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
The audit covers all 7 rules below and prints the exact file path +
|
|
32
|
-
suggested fix for each finding. With `--fix`, the
|
|
33
|
-
auto-apply
|
|
34
|
-
shadowed shims
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
suggested fix for each finding. With `--fix`, the safe rules
|
|
34
|
+
auto-apply: `rm` for dead files, regex-anchored import rewrites for
|
|
35
|
+
shadowed shims (`local-widgets-types`, `dead-runtime-shim`), the swap
|
|
36
|
+
subset of `vtex-shim-regression`, and JS-aware removal of obsolete
|
|
37
|
+
inline plugin literals from `vite.config.ts`. The output explicitly
|
|
38
|
+
tags rules that require manual work as `(0 fixed, manual)`, so you
|
|
39
|
+
always know what's left after auto-fix runs.
|
|
37
40
|
|
|
38
41
|
Real-world signal: on baggagio, `--fix` produced a byte-identical
|
|
39
42
|
diff to the manual cleanup PR a human had just made (45 files,
|
|
40
43
|
+45/-53). On casaevideo-storefront (production), the audit caught
|
|
41
44
|
six silent VTEX shim regressions that no `tsc --noEmit` run can
|
|
42
|
-
detect —
|
|
43
|
-
|
|
45
|
+
detect — `--fix` covers the swap subset of those automatically since
|
|
46
|
+
`>= 2.16.0`. On the same site's `vite.config.ts`, `--fix` removes
|
|
47
|
+
both obsolete inline plugins (`site-manual-chunks` +
|
|
48
|
+
`deco-stub-meta-gen`) cleanly — ~74 LOC / 2.5 KB gone, attached
|
|
49
|
+
comments included.
|
|
44
50
|
|
|
45
51
|
## 1. Delete unused `src/lib/*` shims
|
|
46
52
|
|
|
@@ -115,8 +121,14 @@ The framework's `decoVitePlugin()` now handles both:
|
|
|
115
121
|
old split caused circular-dep load-order crashes — every site overrode it)
|
|
116
122
|
- `meta.gen.{json,ts}` is stubbed on the client by default
|
|
117
123
|
|
|
118
|
-
Delete both inline plugins from the site's `vite.config.ts`.
|
|
119
|
-
|
|
124
|
+
Delete both inline plugins from the site's `vite.config.ts`. Since
|
|
125
|
+
`@decocms/start >= 2.19.0`, `deco-post-cleanup --fix` does this for
|
|
126
|
+
you — it walks the AST with brace-balanced parsing (template literals
|
|
127
|
+
and nested `{}` inside `config()`/`load()` bodies don't trip it up),
|
|
128
|
+
removes the literal **plus its trailing comma + attached `// ...`
|
|
129
|
+
comment block**, and is idempotent (rerunning is a no-op). Block
|
|
130
|
+
comments are left alone. Verify the production build still succeeds
|
|
131
|
+
(`vite build` in the site repo).
|
|
120
132
|
|
|
121
133
|
## 3. Drop the `runtime.ts` `invoke` shim
|
|
122
134
|
|
|
@@ -688,27 +688,83 @@ Releases shipped from Wave 6:
|
|
|
688
688
|
- `@decocms/apps@1.7.0` — adds `vtex/hooks/createUseCart` factory
|
|
689
689
|
- `@decocms/start@2.8.0` (compile phase) → `2.9.0` (template shim) → `2.10.0` (per-site config)
|
|
690
690
|
|
|
691
|
-
### Wave 12 (kicked off 2026-05-01 after D1–D5 sign-off) — Priority 1 (framework + commerce)
|
|
691
|
+
### Wave 12 (kicked off 2026-05-01 after D1–D5 sign-off) — Priority 1 (framework + commerce) — ✅ **COMPLETE**
|
|
692
692
|
|
|
693
693
|
After surfacing als-storefront as the third migration target (heavy on
|
|
694
694
|
htmx, ~120 hx-* files, prior als-tanstack attempt thrown away), the
|
|
695
|
-
"wait for 3rd site" deferrals collapse. Wave 12
|
|
696
|
-
that als + casaevideo + baggagio
|
|
697
|
-
audit `--fix` work D3 forces us into.
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
- **W12-B** apps-start:
|
|
703
|
-
|
|
704
|
-
- **
|
|
705
|
-
-
|
|
706
|
-
- **W12-
|
|
707
|
-
|
|
708
|
-
- **W12-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
695
|
+
"wait for 3rd site" deferrals collapse. Wave 12 shipped the abstractions
|
|
696
|
+
that als + casaevideo + baggagio had already justified, plus the
|
|
697
|
+
audit `--fix` work D3 forces us into. **9 PRs across `deco-start` and
|
|
698
|
+
`apps-start`, all merged.**
|
|
699
|
+
|
|
700
|
+
**Shipped PRs:**
|
|
701
|
+
|
|
702
|
+
- **W12-A + W12-B** [`apps-start#33`](https://github.com/decocms/apps-start/pull/33) — `feat(vtex/hooks): add createUseUser + createUseWishlist factories` ✅ **MERGED**.
|
|
703
|
+
Mirrors the `createUseCart` shape: invoke-driven legacy state machine, signal-shaped public API (`.value`), independent instances per call. `createUseWishlist` also exposes the `legacyAddArgsToCanonical` and `findWishlistEntry` helpers so site code can keep its old `productId`/`productGroupId` swap convention while routing through the canonical `vtex.actions.{addToWishlist, removeFromWishlist}` signature. New unit tests assert factory shape, instance independence, the arg-swap convention, and the entry-finder helper.
|
|
704
|
+
- **(Lint unblock)** [`apps-start#34`](https://github.com/decocms/apps-start/pull/34) — `chore(lint): tighten shopify storefront.graphql.gen.ts types + biome formatting` ✅ **MERGED**.
|
|
705
|
+
Cleared pre-existing `noExplicitAny` failures in `shopify/utils/storefront/storefront.graphql.gen.ts` plus formatting drift in `vtex/__tests__/client-segment-cookie.test.ts` so subsequent Wave 12 PRs in `apps-start` could land on a green CI baseline. Replaced loose `any` types with a structured `ProductFilter` shape (derived from real consumers) and `unknown` elsewhere; ran `biome check --write` to fix import order + formatting.
|
|
706
|
+
- **W12-C** [`deco-start#123`](https://github.com/decocms/deco-start/pull/123) — `feat(migrate): D3 — generated stubs throw at runtime` ✅ **MERGED**, released as `@decocms/start@2.16.0`.
|
|
707
|
+
Implements **D3** verbatim. The migration-time stubs in `lib-utils.ts` for `toProduct`, `getISCookiesFromBag`, `getSegmentFromBag`, `withSegmentCookie` no longer return identity-cast values, empty objects, or empty `Headers` — they now `throw new Error(STUB_MSG)` with a per-symbol message that names the canonical replacement, the canonical signature, and the `deco-post-cleanup --fix` invocation. Tests assert the throwing bodies + that other functional helpers (e.g. `parseCookie`, `isFilterParam`) are untouched.
|
|
708
|
+
- **W12-D + W12-E** [`deco-start#124`](https://github.com/decocms/deco-start/pull/124) — `feat(audit): vtex-shim-regression --fix for swap-able symbols` ✅ **MERGED**, released as `@decocms/start@2.17.0`.
|
|
709
|
+
Auto-fixes the swap subset of the regression rule: when every imported symbol from a `~/lib/vtex-*` shim has a `kind: "swap"` hint pointing to the same canonical module, the rule rewrites the `from "..."` clause to canonical and leaves the named-import list (including `as`-aliases) verbatim. Mixed swap + refactor surfaces, and shims that mix stubs with real impls (e.g. `isFilterParam`), are deliberately left for manual fix. 6 new tests + skill doc § 5 update.
|
|
710
|
+
- **W12-i** [`deco-start#125`](https://github.com/decocms/deco-start/pull/125) — `feat(migrate): scaffold useUser + useWishlist as factory shims (vtex)` ✅ **MERGED**.
|
|
711
|
+
Updates the migration script's `hooks.ts` template so freshly migrated VTEX sites get 3-line factory shims (`export const { useUser, resetUser } = createUseUser({ invoke })`) instead of 200-LOC singletons that were copy-pasted by the old migration. Non-VTEX sites still get the legacy stubs but with docstrings pointing at the factories for parity context. 4 new tests cover both branches and a line-count budget.
|
|
712
|
+
- **W12-F** [`deco-start#127`](https://github.com/decocms/deco-start/pull/127) — `feat(audit): obsolete-vite-plugins --fix` ✅ **MERGED**.
|
|
713
|
+
JS-aware applyFix for the rule. Walks `vite.config.ts` with a brace-counter that skips strings, template literals (including `${...}` interpolation), and line/block comments, so nested `{}` inside `config()` / `load()` / `resolveId()` bodies do not throw off boundary detection. Removes the inline literal + trailing `,\n` + the contiguous block of `// ...` comments immediately attached above. Idempotent. 7 new tests + smoke verified against real casaevideo `vite.config.ts` (162 LOC → both plugins gone, 2503 bytes / ~74 LOC removed, structurally identical to baggagio's already-clean shape, post-fix audit returns 0 findings).
|
|
714
|
+
- **W12-G** [`apps-start#35`](https://github.com/decocms/apps-start/pull/35) — `docs: add AGENTS.md cross-linking the canonical migration policy` ✅ **MERGED**.
|
|
715
|
+
Adds an AGENTS.md to `apps-start` so any agent or contributor opening that repo knows the canonical migration policy lives in `decocms/deco-start` and what D1–D5 mean specifically inside `apps-start` (especially D4: site-local apps live in the *site*, not in `apps-start`). Architecture overview + cross-link table.
|
|
716
|
+
- **W12-H** [`deco-start#126`](https://github.com/decocms/deco-start/pull/126) — `feat(migrate): scaffold migration-tooling-policy pointer rule` ✅ **MERGED**, released as `@decocms/start@2.18.0`.
|
|
717
|
+
Migration scaffold phase now writes `.cursor/rules/migration-tooling-policy.mdc` into every newly migrated site. The pointer is `alwaysApply: true`, links to the canonical rule and plan in `decocms/deco-start`, includes a one-line-per-decision D1–D5 table scoped to the site, and points at the `deco-post-cleanup --fix` / `--strict` commands. Length budget under 3 KB so it stays a pointer, not a copy. 8 new tests.
|
|
718
|
+
|
|
719
|
+
Wave 12 ships in priority-1 order; Wave 13 starts now.
|
|
720
|
+
|
|
721
|
+
### Wave 12 — discoveries
|
|
722
|
+
|
|
723
|
+
- **D3 + audit `--fix` is a closed loop, not an either/or.** The
|
|
724
|
+
symmetry that `--fix` for swap-able stubs (W12-D/E) combined with
|
|
725
|
+
throwing stubs (W12-C) produces is significant: the moment a
|
|
726
|
+
migrated site runs anything that hits a stub'd symbol, it throws
|
|
727
|
+
with an actionable error pointing at the canonical replacement; the
|
|
728
|
+
same moment, `--fix` knows what `from "..."` clause to rewrite. The
|
|
729
|
+
user no longer has a "silent regression" failure mode.
|
|
730
|
+
- **Per-symbol fix-hint table now has 5 consumers.** It's read by:
|
|
731
|
+
the rule's prose `fix:` field, the rule's `meta.fixHints`
|
|
732
|
+
structured payload, the runtime stub error message, the `--fix`
|
|
733
|
+
rewriter (selects swap candidates), and the skill doc table. Adding
|
|
734
|
+
a 5th, 6th, Nth stub means appending one entry to `STUB_FIX_HINTS`
|
|
735
|
+
— every consumer picks up the new symbol for free.
|
|
736
|
+
- **Site-level policy enforcement at scaffold time, not runtime.**
|
|
737
|
+
W12-H ships the canonical D1–D5 policy *as a pointer* into every
|
|
738
|
+
new site. Cursor sessions in those sites load the rule with
|
|
739
|
+
`alwaysApply: true`, so they know the policy without us having to
|
|
740
|
+
pull a copy of the rule into each repo. Drift-free by construction
|
|
741
|
+
— the canonical rule changes upstream and the pointer keeps
|
|
742
|
+
pointing.
|
|
743
|
+
- **Brace-balanced parsing + comment attachment makes
|
|
744
|
+
`obsolete-vite-plugins` `--fix` safe at scale.** The casaevideo
|
|
745
|
+
smoke test confirmed the approach handles real-world vite configs
|
|
746
|
+
with multi-line plugins, attached comments describing the
|
|
747
|
+
workaround, template literals containing `}`, and nested
|
|
748
|
+
`rollupOptions`. Idempotency falls out of "rule found 0 plugins
|
|
749
|
+
→ no findings → no fix actions". This is the pattern for any
|
|
750
|
+
future `--fix` that needs to surgically edit a file: extract a
|
|
751
|
+
span helper, write surface-level tests, smoke against a real
|
|
752
|
+
production file, ship.
|
|
753
|
+
- **`apps-start` had latent CI debt.** W12 surfaced pre-existing
|
|
754
|
+
`noExplicitAny` failures in `shopify/utils/storefront/storefront.graphql.gen.ts`
|
|
755
|
+
that had been failing on `main` for an unknown duration. The lint
|
|
756
|
+
unblock PR (`apps-start#34`) is the kind of "passing through" fix
|
|
757
|
+
that should land first whenever a CI gate is red. Don't paper
|
|
758
|
+
over it.
|
|
759
|
+
- **Factories migrate cleaner than templates.** Comparing
|
|
760
|
+
`apps-start#33` (factories) to `deco-start#125` (template that
|
|
761
|
+
*consumes* the factories), the factory PR is the larger artifact
|
|
762
|
+
but the template PR shipped 4 lines into each generated file.
|
|
763
|
+
Investing once in a well-shaped factory pays a 50:1 multiplier on
|
|
764
|
+
every site that runs the migration after that point. This is the
|
|
765
|
+
D4 promotion path working end-to-end: build it once at site level,
|
|
766
|
+
prove it on 2 sites, promote to `@decocms/apps`, then rewrite the
|
|
767
|
+
template to use the canonical.
|
|
712
768
|
|
|
713
769
|
### Wave 13 (htmx foundations — Priority 2 part 1) — planned
|
|
714
770
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/start",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.20.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
|
|
6
6
|
"main": "./src/index.ts",
|
|
7
7
|
"bin": {
|
|
8
8
|
"deco-migrate": "./scripts/migrate.ts",
|
|
9
|
-
"deco-post-cleanup": "./scripts/migrate-post-cleanup.ts"
|
|
9
|
+
"deco-post-cleanup": "./scripts/migrate-post-cleanup.ts",
|
|
10
|
+
"deco-htmx-analyze": "./scripts/htmx-analyze.ts"
|
|
10
11
|
},
|
|
11
12
|
"exports": {
|
|
12
13
|
".": "./src/index.ts",
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* HTMX surface analyzer — CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* Inventories the `hx-*` surface of a Deco storefront so the engineer
|
|
6
|
+
* (or the next codemod wave) knows exactly what shapes are out there
|
|
7
|
+
* before starting the rewrite to React. Per D2 in the migration
|
|
8
|
+
* tooling policy, all htmx is rewritten on migration; no runtime is
|
|
9
|
+
* shipped in `@decocms/start`.
|
|
10
|
+
*
|
|
11
|
+
* Usage (from a site directory):
|
|
12
|
+
* npx -p @decocms/start deco-htmx-analyze
|
|
13
|
+
* npx -p @decocms/start deco-htmx-analyze --source /path/to/site
|
|
14
|
+
* npx -p @decocms/start deco-htmx-analyze --json
|
|
15
|
+
*
|
|
16
|
+
* Options:
|
|
17
|
+
* --source <dir> Site directory to analyze (default: current directory)
|
|
18
|
+
* --json Emit machine-readable JSON instead of pretty text
|
|
19
|
+
* --top <n> Show top N files by occurrence count (default: 20)
|
|
20
|
+
* --help, -h Show this help
|
|
21
|
+
*
|
|
22
|
+
* Wave 13-A. Read-only. Codemods land in Wave 14.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import { realFsAdapter } from "./migrate/post-cleanup/runner";
|
|
27
|
+
import {
|
|
28
|
+
analyzeHtmx,
|
|
29
|
+
type HtmxCategory,
|
|
30
|
+
type HtmxInventory,
|
|
31
|
+
} from "./migrate/analyzers/htmx-analyze";
|
|
32
|
+
import { banner, bold, cyan, gray, green, red, yellow } from "./migrate/colors";
|
|
33
|
+
|
|
34
|
+
interface CliOpts {
|
|
35
|
+
source: string;
|
|
36
|
+
json: boolean;
|
|
37
|
+
top: number;
|
|
38
|
+
help: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseArgs(args: string[]): CliOpts {
|
|
42
|
+
let source = ".";
|
|
43
|
+
let json = false;
|
|
44
|
+
let top = 20;
|
|
45
|
+
let help = false;
|
|
46
|
+
for (let i = 0; i < args.length; i++) {
|
|
47
|
+
switch (args[i]) {
|
|
48
|
+
case "--source":
|
|
49
|
+
source = args[++i];
|
|
50
|
+
break;
|
|
51
|
+
case "--json":
|
|
52
|
+
json = true;
|
|
53
|
+
break;
|
|
54
|
+
case "--top":
|
|
55
|
+
top = Number.parseInt(args[++i] ?? "20", 10);
|
|
56
|
+
if (Number.isNaN(top) || top < 0) top = 20;
|
|
57
|
+
break;
|
|
58
|
+
case "--help":
|
|
59
|
+
case "-h":
|
|
60
|
+
help = true;
|
|
61
|
+
break;
|
|
62
|
+
default:
|
|
63
|
+
console.error(`Unknown argument: ${args[i]}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { source, json, top, help };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function printHelp(): void {
|
|
71
|
+
console.log(`deco-htmx-analyze
|
|
72
|
+
|
|
73
|
+
Inventory the htmx surface (hx-* attributes) of a Deco storefront.
|
|
74
|
+
|
|
75
|
+
Usage:
|
|
76
|
+
npx -p @decocms/start deco-htmx-analyze [options]
|
|
77
|
+
|
|
78
|
+
Options:
|
|
79
|
+
--source <dir> Site directory to analyze (default: cwd)
|
|
80
|
+
--json Emit machine-readable JSON
|
|
81
|
+
--top <n> Top N files by occurrence count (default: 20)
|
|
82
|
+
--help, -h Show this help
|
|
83
|
+
|
|
84
|
+
The output is read-only. Codemods that rewrite htmx to React are a
|
|
85
|
+
planned follow-up — see the deco-to-tanstack-migration skill for the
|
|
86
|
+
per-pattern rewrite recipes.
|
|
87
|
+
`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const CATEGORY_DESCRIPTIONS: Record<HtmxCategory, string> = {
|
|
91
|
+
"event-handler": "hx-on:* with no fetch attr — pure client-side handler",
|
|
92
|
+
"form-swap": "hx-post on a <form> with hx-target/hx-swap",
|
|
93
|
+
"click-swap": "hx-get/hx-post on a button with hx-target",
|
|
94
|
+
"auto-fetch": "hx-trigger=keyup/intersect/etc on input or auto-fired element",
|
|
95
|
+
"oob-swap": "hx-swap-oob / hx-select-oob — out-of-band patches",
|
|
96
|
+
boost: "hx-boost=true — link prefetch, already-SPA in TanStack Start",
|
|
97
|
+
unmatched: "hx-* attribute set that didn't match a known pattern",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const CATEGORY_RECIPES: Record<HtmxCategory, string> = {
|
|
101
|
+
"event-handler":
|
|
102
|
+
"replace hx-on:click={useScript(...)} with onClick={() => { ... }}",
|
|
103
|
+
"form-swap":
|
|
104
|
+
"<form onSubmit> + useMutation/server function; render result with state",
|
|
105
|
+
"click-swap":
|
|
106
|
+
"setState/setView + conditional render, or sub-route via TanStack Router",
|
|
107
|
+
"auto-fetch":
|
|
108
|
+
"debounced state + useQuery; for intersect use IntersectionObserver",
|
|
109
|
+
"oob-swap":
|
|
110
|
+
"manual: out-of-band has no 1:1; refactor to broadcast event + listener",
|
|
111
|
+
boost: "replace <a hx-boost> with TanStack Router <Link> (already SPA)",
|
|
112
|
+
unmatched: "manual review",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const CATEGORY_ORDER: HtmxCategory[] = [
|
|
116
|
+
"event-handler",
|
|
117
|
+
"click-swap",
|
|
118
|
+
"form-swap",
|
|
119
|
+
"auto-fetch",
|
|
120
|
+
"boost",
|
|
121
|
+
"oob-swap",
|
|
122
|
+
"unmatched",
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
function printText(inv: HtmxInventory, top: number): void {
|
|
126
|
+
banner("HTMX surface analysis");
|
|
127
|
+
|
|
128
|
+
if (inv.totalOccurrences === 0) {
|
|
129
|
+
console.log(green("✓ No hx-* attributes found.\n"));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
console.log(`${bold("Files with hx-* usage:")} ${inv.totalFiles}`);
|
|
134
|
+
console.log(`${bold("Total occurrences:")} ${inv.totalOccurrences}\n`);
|
|
135
|
+
|
|
136
|
+
console.log(bold("By category:"));
|
|
137
|
+
for (const cat of CATEGORY_ORDER) {
|
|
138
|
+
const count = inv.byCategory[cat];
|
|
139
|
+
if (count === 0) continue;
|
|
140
|
+
const label = `${cat.padEnd(15)}`;
|
|
141
|
+
const desc = gray(CATEGORY_DESCRIPTIONS[cat]);
|
|
142
|
+
console.log(` ${cyan(label)} ${String(count).padStart(4)} ${desc}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(`\n${bold("Migration recipes:")}`);
|
|
146
|
+
for (const cat of CATEGORY_ORDER) {
|
|
147
|
+
if (inv.byCategory[cat] === 0) continue;
|
|
148
|
+
console.log(` ${cyan(cat.padEnd(15))} ${gray(CATEGORY_RECIPES[cat])}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(`\n${bold(`Top ${top} files by occurrence:`)}`);
|
|
152
|
+
const slice = inv.files.slice(0, top);
|
|
153
|
+
const widest = Math.max(...slice.map((f) => f.file.length), 30);
|
|
154
|
+
for (const f of slice) {
|
|
155
|
+
const detail = CATEGORY_ORDER.filter((c) => f.byCategory[c] > 0)
|
|
156
|
+
.map((c) => `${c}=${f.byCategory[c]}`)
|
|
157
|
+
.join(", ");
|
|
158
|
+
console.log(
|
|
159
|
+
` ${f.file.padEnd(widest)} ${String(f.total).padStart(3)} ${gray(detail)}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (inv.files.length > top) {
|
|
164
|
+
console.log(gray(` …and ${inv.files.length - top} more file(s)`));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
console.log(`\n${bold("Sample call sites:")}`);
|
|
168
|
+
for (const cat of CATEGORY_ORDER) {
|
|
169
|
+
const samples = inv.samples[cat];
|
|
170
|
+
if (samples.length === 0) continue;
|
|
171
|
+
console.log(` ${cyan(cat)}`);
|
|
172
|
+
for (const s of samples) {
|
|
173
|
+
const attrs = s.attrs.join(", ");
|
|
174
|
+
console.log(
|
|
175
|
+
` ${gray(`${s.file}:${s.line}`)} <${s.tag}> [${attrs}]`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const hasOob = inv.byCategory["oob-swap"] > 0;
|
|
181
|
+
const hasUnmatched = inv.byCategory.unmatched > 0;
|
|
182
|
+
if (hasOob || hasUnmatched) {
|
|
183
|
+
console.log();
|
|
184
|
+
if (hasOob) {
|
|
185
|
+
console.log(
|
|
186
|
+
yellow(
|
|
187
|
+
"⚠ oob-swap occurrences require manual rewrite — no 1:1 React equivalent.",
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (hasUnmatched) {
|
|
192
|
+
console.log(
|
|
193
|
+
yellow(
|
|
194
|
+
"⚠ unmatched occurrences require manual review — see Sample call sites.",
|
|
195
|
+
),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
console.log();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function main(argv: string[]): number {
|
|
203
|
+
const opts = parseArgs(argv);
|
|
204
|
+
if (opts.help) {
|
|
205
|
+
printHelp();
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const sourceDir = path.resolve(opts.source);
|
|
210
|
+
const inv = analyzeHtmx(sourceDir, realFsAdapter);
|
|
211
|
+
|
|
212
|
+
if (opts.json) {
|
|
213
|
+
console.log(JSON.stringify(inv, null, 2));
|
|
214
|
+
} else {
|
|
215
|
+
printText(inv, opts.top);
|
|
216
|
+
}
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
process.exit(main(process.argv.slice(2)));
|
|
222
|
+
} catch (err) {
|
|
223
|
+
console.error(red(`✗ deco-htmx-analyze failed: ${(err as Error).message}`));
|
|
224
|
+
if (process.env.DEBUG) console.error((err as Error).stack);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|