@decocms/blocks-cli 7.0.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/package.json +44 -0
- package/scripts/analyze-traces.mjs +1117 -0
- package/scripts/audit-observability-config.test.ts +446 -0
- package/scripts/audit-observability-config.ts +511 -0
- package/scripts/deco-migrate-cli.ts +444 -0
- package/scripts/fast-deploy-kv.test.ts +131 -0
- package/scripts/generate-blocks.test.ts +94 -0
- package/scripts/generate-blocks.ts +274 -0
- package/scripts/generate-invoke.test.ts +195 -0
- package/scripts/generate-invoke.ts +469 -0
- package/scripts/generate-loaders.ts +217 -0
- package/scripts/generate-schema.ts +1287 -0
- package/scripts/generate-sections.ts +237 -0
- package/scripts/htmx-analyze.ts +226 -0
- package/scripts/lib/blocks-dedupe.test.ts +179 -0
- package/scripts/lib/blocks-dedupe.ts +142 -0
- package/scripts/lib/cf-kv-rest.ts +78 -0
- package/scripts/lib/jsonc.ts +122 -0
- package/scripts/lib/kv-snapshot.ts +51 -0
- package/scripts/lib/read-decofile.ts +70 -0
- package/scripts/lib/sync-helpers.ts +44 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
- package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
- package/scripts/migrate/analyzers/island-classifier.ts +96 -0
- package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
- package/scripts/migrate/analyzers/section-metadata.ts +147 -0
- package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
- package/scripts/migrate/colors.ts +46 -0
- package/scripts/migrate/config.test.ts +202 -0
- package/scripts/migrate/config.ts +186 -0
- package/scripts/migrate/phase-analyze.test.ts +63 -0
- package/scripts/migrate/phase-analyze.ts +782 -0
- package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
- package/scripts/migrate/phase-cleanup-audit.ts +105 -0
- package/scripts/migrate/phase-cleanup.test.ts +141 -0
- package/scripts/migrate/phase-cleanup.ts +1588 -0
- package/scripts/migrate/phase-compile.test.ts +193 -0
- package/scripts/migrate/phase-compile.ts +177 -0
- package/scripts/migrate/phase-report.ts +243 -0
- package/scripts/migrate/phase-scaffold.ts +593 -0
- package/scripts/migrate/phase-transform.ts +310 -0
- package/scripts/migrate/phase-verify.test.ts +127 -0
- package/scripts/migrate/phase-verify.ts +572 -0
- package/scripts/migrate/post-cleanup/rules.ts +1708 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
- package/scripts/migrate/post-cleanup/runner.ts +137 -0
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
- package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
- package/scripts/migrate/post-cleanup/types.ts +106 -0
- package/scripts/migrate/source-layout.test.ts +111 -0
- package/scripts/migrate/source-layout.ts +103 -0
- package/scripts/migrate/templates/app-css.ts +366 -0
- package/scripts/migrate/templates/cache-config.ts +26 -0
- package/scripts/migrate/templates/commerce-loaders.ts +230 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
- package/scripts/migrate/templates/cursor-rules.ts +70 -0
- package/scripts/migrate/templates/hooks.test.ts +141 -0
- package/scripts/migrate/templates/hooks.ts +152 -0
- package/scripts/migrate/templates/knip-config.ts +27 -0
- package/scripts/migrate/templates/lib-utils.test.ts +139 -0
- package/scripts/migrate/templates/lib-utils.ts +326 -0
- package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
- package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
- package/scripts/migrate/templates/package-json.ts +177 -0
- package/scripts/migrate/templates/routes.ts +237 -0
- package/scripts/migrate/templates/sdk-gen.ts +59 -0
- package/scripts/migrate/templates/section-loaders.ts +456 -0
- package/scripts/migrate/templates/server-entry.ts +561 -0
- package/scripts/migrate/templates/setup.ts +148 -0
- package/scripts/migrate/templates/tsconfig.ts +21 -0
- package/scripts/migrate/templates/types-gen.ts +174 -0
- package/scripts/migrate/templates/ui-components.ts +144 -0
- package/scripts/migrate/templates/vite-config.ts +101 -0
- package/scripts/migrate/transforms/dead-code.ts +455 -0
- package/scripts/migrate/transforms/deno-isms.ts +85 -0
- package/scripts/migrate/transforms/fresh-apis.ts +223 -0
- package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
- package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
- package/scripts/migrate/transforms/imports.ts +385 -0
- package/scripts/migrate/transforms/jsx.ts +317 -0
- package/scripts/migrate/transforms/section-conventions.ts +210 -0
- package/scripts/migrate/transforms/tailwind.ts +739 -0
- package/scripts/migrate/types.ts +244 -0
- package/scripts/migrate-blocks-to-kv.ts +104 -0
- package/scripts/migrate-post-cleanup.ts +191 -0
- package/scripts/migrate-to-cf-observability.test.ts +215 -0
- package/scripts/migrate-to-cf-observability.ts +699 -0
- package/scripts/migrate.ts +282 -0
- package/scripts/smoke-otlp-errorlog.ts +46 -0
- package/scripts/smoke-otlp-meter.ts +48 -0
- package/scripts/sync-blocks-to-kv.ts +153 -0
- package/scripts/tailwind-lint.ts +518 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { TransformResult } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fix JSX after scriptAsDataURI → useScript replacement.
|
|
5
|
+
*
|
|
6
|
+
* Transforms patterns like:
|
|
7
|
+
* <script dangerouslySetInnerHTML={{ __html: useScript(fn, { ...props, x })}
|
|
8
|
+
* defer
|
|
9
|
+
* />
|
|
10
|
+
* Into:
|
|
11
|
+
* <script dangerouslySetInnerHTML={{ __html: useScript(fn, { ...props, x }) }}
|
|
12
|
+
* />
|
|
13
|
+
*
|
|
14
|
+
* The key issue: the original `src={scriptAsDataURI(...)}` has one closing `}`,
|
|
15
|
+
* but `dangerouslySetInnerHTML={{ __html: useScript(...) }}` needs two closing `}}`.
|
|
16
|
+
* We also need to remove stray attrs like `defer` that sit between the call and `/>`.
|
|
17
|
+
*/
|
|
18
|
+
function rebalanceScriptDataUri(code: string): string {
|
|
19
|
+
const marker = "dangerouslySetInnerHTML={{ __html: useScript(";
|
|
20
|
+
let idx = code.indexOf(marker);
|
|
21
|
+
|
|
22
|
+
while (idx !== -1) {
|
|
23
|
+
const start = idx + marker.length;
|
|
24
|
+
// Find the balanced closing paren for useScript(
|
|
25
|
+
let depth = 1;
|
|
26
|
+
let i = start;
|
|
27
|
+
while (i < code.length && depth > 0) {
|
|
28
|
+
if (code[i] === "(") depth++;
|
|
29
|
+
else if (code[i] === ")") depth--;
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
// i is now right after the matching ) of useScript(...)
|
|
33
|
+
// We expect `}` next (closing the old src={...})
|
|
34
|
+
// We need to replace everything from ) to /> with `) }} />`
|
|
35
|
+
// and remove any stray attributes like `defer`, `type="module"`, etc.
|
|
36
|
+
const afterParen = code.substring(i);
|
|
37
|
+
const closingMatch = afterParen.match(/^\s*\}\s*\n?\s*([\s\S]*?)\s*\/>/);
|
|
38
|
+
if (closingMatch) {
|
|
39
|
+
const endOffset = i + closingMatch[0].length;
|
|
40
|
+
// i is already past the closing ), so just add the }} and />
|
|
41
|
+
const replacement = ` }}\n />`;
|
|
42
|
+
code = code.substring(0, i) + replacement + code.substring(endOffset);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
idx = code.indexOf(marker, idx + 1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return code;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Removes or replaces Fresh-specific APIs:
|
|
53
|
+
*
|
|
54
|
+
* - asset("/path") → "/path"
|
|
55
|
+
* - <Head>...</Head> → content extracted or removed
|
|
56
|
+
* - defineApp wrapper → unwrap
|
|
57
|
+
* - IS_BROWSER → typeof window !== "undefined"
|
|
58
|
+
* - Context.active().release?.revision() → "" (Vite handles cache busting)
|
|
59
|
+
*/
|
|
60
|
+
export function transformFreshApis(content: string): TransformResult {
|
|
61
|
+
const notes: string[] = [];
|
|
62
|
+
let changed = false;
|
|
63
|
+
let result = content;
|
|
64
|
+
|
|
65
|
+
// asset("/path") → "/path" and asset(`/path`) → `/path`
|
|
66
|
+
if (/\basset\(/.test(result)) {
|
|
67
|
+
result = result.replace(
|
|
68
|
+
/\basset\(\s*(`[^`]+`|"[^"]+"|'[^']+')\s*\)/g,
|
|
69
|
+
(_match, path) => {
|
|
70
|
+
// For template literals with revision, simplify
|
|
71
|
+
const inner = path.slice(1, -1);
|
|
72
|
+
if (inner.includes("${revision}") || inner.includes("?revision=")) {
|
|
73
|
+
// Remove cache-busting query — Vite handles it
|
|
74
|
+
const clean = inner
|
|
75
|
+
.replace(/\?revision=\$\{revision\}/, "")
|
|
76
|
+
.replace(/\$\{revision\}/, "");
|
|
77
|
+
return `"${clean}"`;
|
|
78
|
+
}
|
|
79
|
+
return path;
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
changed = true;
|
|
83
|
+
notes.push("Replaced asset() calls with direct paths");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Remove import { asset, Head } from "$fresh/runtime.ts"
|
|
87
|
+
// (the imports transform handles the specifier, but we also need to handle
|
|
88
|
+
// cases where the import line wasn't fully removed)
|
|
89
|
+
result = result.replace(
|
|
90
|
+
/^import\s+\{[^}]*\b(?:asset|Head)\b[^}]*\}\s+from\s+["']\$fresh\/runtime\.ts["'];?\s*\n?/gm,
|
|
91
|
+
"",
|
|
92
|
+
);
|
|
93
|
+
result = result.replace(
|
|
94
|
+
/^import\s+\{[^}]*\}\s+from\s+["']\$fresh\/server\.ts["'];?\s*\n?/gm,
|
|
95
|
+
"",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// IS_BROWSER → typeof window !== "undefined"
|
|
99
|
+
if (result.includes("IS_BROWSER")) {
|
|
100
|
+
result = result.replace(
|
|
101
|
+
/\bIS_BROWSER\b/g,
|
|
102
|
+
'(typeof window !== "undefined")',
|
|
103
|
+
);
|
|
104
|
+
// Remove the import
|
|
105
|
+
result = result.replace(
|
|
106
|
+
/^import\s+\{[^}]*\bIS_BROWSER\b[^}]*\}\s+from\s+["'][^"']+["'];?\s*\n?/gm,
|
|
107
|
+
"",
|
|
108
|
+
);
|
|
109
|
+
changed = true;
|
|
110
|
+
notes.push('Replaced IS_BROWSER with typeof window !== "undefined"');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Context.active().release?.revision() → "" or remove the entire await line
|
|
114
|
+
if (result.includes("Context.active()")) {
|
|
115
|
+
result = result.replace(
|
|
116
|
+
/(?:const|let)\s+\w+\s*=\s*await\s+Context\.active\(\)\.release\?\.revision\(\);?\s*\n?/g,
|
|
117
|
+
"",
|
|
118
|
+
);
|
|
119
|
+
result = result.replace(
|
|
120
|
+
/Context\.active\(\)\.release\?\.revision\(\)/g,
|
|
121
|
+
'""',
|
|
122
|
+
);
|
|
123
|
+
result = result.replace(
|
|
124
|
+
/^import\s+\{\s*Context\s*\}\s+from\s+["']@deco\/deco["'];?\s*\n?/gm,
|
|
125
|
+
"",
|
|
126
|
+
);
|
|
127
|
+
changed = true;
|
|
128
|
+
notes.push("Removed Context.active().release?.revision()");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// defineApp wrapper → unwrap to a plain function
|
|
132
|
+
// Matches: export default defineApp(async (_req, ctx) => { ... });
|
|
133
|
+
if (result.includes("defineApp")) {
|
|
134
|
+
result = result.replace(
|
|
135
|
+
/export\s+default\s+defineApp\(\s*(?:async\s+)?\([^)]*\)\s*=>\s*\{/,
|
|
136
|
+
"// NOTE: defineApp removed — this file needs manual conversion to a route\nexport default function AppLayout() {",
|
|
137
|
+
);
|
|
138
|
+
// Remove trailing ); that closed defineApp
|
|
139
|
+
// This is tricky — we'll flag for manual review instead of guessing
|
|
140
|
+
changed = true;
|
|
141
|
+
notes.push(
|
|
142
|
+
"MANUAL: defineApp wrapper partially unwrapped — verify closing brackets",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Replace <Head>...</Head> with <>...</>
|
|
147
|
+
// React 19 auto-hoists <title>, <meta>, <link> tags to document <head>.
|
|
148
|
+
if (result.includes("<Head>") || result.includes("<Head ")) {
|
|
149
|
+
// Handle self-closing <Head ... /> first so it becomes <></> (not just <>)
|
|
150
|
+
result = result.replace(/<Head\s[^>]*\/>/g, "<></>");
|
|
151
|
+
result = result.replace(/<Head\s*\/>/g, "<></>");
|
|
152
|
+
result = result.replace(/<Head>/g, "<>");
|
|
153
|
+
result = result.replace(/<Head\s[^>]*>/g, "<>");
|
|
154
|
+
result = result.replace(/<\/Head>/g, "</>");
|
|
155
|
+
changed = true;
|
|
156
|
+
notes.push("Replaced <Head> with fragment — React 19 hoists head tags automatically");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// scriptAsDataURI → useScript with dangerouslySetInnerHTML
|
|
160
|
+
// scriptAsDataURI is a Fresh pattern that returns a data: URI for <script src=...>.
|
|
161
|
+
// In React/TanStack, useScript returns a string for dangerouslySetInnerHTML.
|
|
162
|
+
//
|
|
163
|
+
// Before: <script src={scriptAsDataURI(fn, arg1, arg2)} defer />
|
|
164
|
+
// After: <script dangerouslySetInnerHTML={{ __html: useScript(fn, arg1, arg2) }} />
|
|
165
|
+
if (result.includes("scriptAsDataURI")) {
|
|
166
|
+
// Ensure useScript is imported
|
|
167
|
+
if (
|
|
168
|
+
!result.includes('"@decocms/start/sdk/useScript"') &&
|
|
169
|
+
!result.includes("'@decocms/start/sdk/useScript'")
|
|
170
|
+
) {
|
|
171
|
+
result = `import { useScript } from "@decocms/start/sdk/useScript";\n${result}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Transform src={scriptAsDataURI(...)} into dangerouslySetInnerHTML={{ __html: useScript(...) }}
|
|
175
|
+
// We need to match balanced parens to capture the full argument list.
|
|
176
|
+
result = result.replace(
|
|
177
|
+
/\bsrc=\{scriptAsDataURI\(/g,
|
|
178
|
+
"dangerouslySetInnerHTML={{ __html: useScript(",
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// Now close the pattern: find the matching )} and replace with ) }}
|
|
182
|
+
// The pattern after replacement is: dangerouslySetInnerHTML={{ __html: useScript(...)}<maybe whitespace and other attrs>
|
|
183
|
+
// We need to find the closing )} that ends the JSX expression
|
|
184
|
+
result = rebalanceScriptDataUri(result);
|
|
185
|
+
|
|
186
|
+
// Replace any remaining standalone scriptAsDataURI references
|
|
187
|
+
result = result.replace(/\bscriptAsDataURI\b/g, "useScript");
|
|
188
|
+
|
|
189
|
+
changed = true;
|
|
190
|
+
notes.push("Replaced scriptAsDataURI with useScript + dangerouslySetInnerHTML");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// allowCorsFor — not available in @decocms/start, remove usage
|
|
194
|
+
if (result.includes("allowCorsFor")) {
|
|
195
|
+
result = result.replace(
|
|
196
|
+
/^import\s+\{[^}]*\ballowCorsFor\b[^}]*\}\s+from\s+["'][^"']+["'];?\s*\n?/gm,
|
|
197
|
+
"",
|
|
198
|
+
);
|
|
199
|
+
// Remove allowCorsFor calls
|
|
200
|
+
result = result.replace(/\ballowCorsFor\b\([^)]*\);?\s*\n?/g, "");
|
|
201
|
+
changed = true;
|
|
202
|
+
notes.push("Removed allowCorsFor (not needed in TanStack)");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ctx.response.headers → not available, flag
|
|
206
|
+
if (result.includes("ctx.response")) {
|
|
207
|
+
notes.push("MANUAL: ctx.response usage found — FnContext in @decocms/start does not have response object");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// { crypto } from "@std/crypto" → use globalThis.crypto (Web Crypto API)
|
|
211
|
+
// The import is already removed by imports transform, but `crypto` references
|
|
212
|
+
// need to be prefixed with globalThis if they'd shadow the global
|
|
213
|
+
if (result.match(/^import\s+\{[^}]*\bcrypto\b/m)) {
|
|
214
|
+
// Import already removed by imports transform, so just ensure bare `crypto` works
|
|
215
|
+
// No action needed — globalThis.crypto is available in Workers + Node 20+
|
|
216
|
+
notes.push("INFO: @std/crypto replaced with globalThis.crypto (Web Crypto API)");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Clean up blank lines
|
|
220
|
+
result = result.replace(/\n{3,}/g, "\n\n");
|
|
221
|
+
|
|
222
|
+
return { content: result, changed, notes };
|
|
223
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
_internals,
|
|
4
|
+
transformHtmxOnEvents,
|
|
5
|
+
} from "./htmx-on-events";
|
|
6
|
+
|
|
7
|
+
const { STANDARD_EVENT_MAP, TODO_MARKER } = _internals;
|
|
8
|
+
|
|
9
|
+
describe("transformHtmxOnEvents — basic renames", () => {
|
|
10
|
+
it("renames hx-on:click → onClick (colon variant)", () => {
|
|
11
|
+
const src = `<button hx-on:click={() => alert("hi")}>click</button>`;
|
|
12
|
+
const r = transformHtmxOnEvents(src);
|
|
13
|
+
expect(r.changed).toBe(true);
|
|
14
|
+
expect(r.content).toBe(
|
|
15
|
+
`<button onClick={() => alert("hi")}>click</button>`,
|
|
16
|
+
);
|
|
17
|
+
expect(r.notes[0]).toContain("Renamed 1 hx-on:* attribute(s)");
|
|
18
|
+
expect(r.notes[0]).toContain("onClick=1");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("renames hx-on-click → onClick (dash variant)", () => {
|
|
22
|
+
const src = `<button hx-on-click={fn}>click</button>`;
|
|
23
|
+
const r = transformHtmxOnEvents(src);
|
|
24
|
+
expect(r.changed).toBe(true);
|
|
25
|
+
expect(r.content).toBe(`<button onClick={fn}>click</button>`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("preserves whitespace around `=`", () => {
|
|
29
|
+
const src = `<button hx-on:click = {fn}>x</button>`;
|
|
30
|
+
const r = transformHtmxOnEvents(src);
|
|
31
|
+
expect(r.content).toBe(`<button onClick = {fn}>x</button>`);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("renames every standard event in the map", () => {
|
|
35
|
+
for (const [htmxEvent, reactName] of Object.entries(STANDARD_EVENT_MAP)) {
|
|
36
|
+
const src = `<x hx-on:${htmxEvent}={fn}/>`;
|
|
37
|
+
const r = transformHtmxOnEvents(src);
|
|
38
|
+
expect(r.content, `event=${htmxEvent}`).toBe(`<x ${reactName}={fn}/>`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("renames every standard event in the map (dash variant)", () => {
|
|
43
|
+
for (const [htmxEvent, reactName] of Object.entries(STANDARD_EVENT_MAP)) {
|
|
44
|
+
const src = `<x hx-on-${htmxEvent}={fn}/>`;
|
|
45
|
+
const r = transformHtmxOnEvents(src);
|
|
46
|
+
expect(r.content, `event=${htmxEvent}`).toBe(`<x ${reactName}={fn}/>`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("handles multiple events on the same element", () => {
|
|
51
|
+
const src = `<input hx-on:change={a} hx-on:keyup={b} hx-on:focus={c}/>`;
|
|
52
|
+
const r = transformHtmxOnEvents(src);
|
|
53
|
+
expect(r.content).toBe(
|
|
54
|
+
`<input onChange={a} onKeyUp={b} onFocus={c}/>`,
|
|
55
|
+
);
|
|
56
|
+
expect(r.notes[0]).toContain("Renamed 3 hx-on:* attribute(s)");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("handles multi-line attribute values", () => {
|
|
60
|
+
const src = `<button
|
|
61
|
+
hx-on:click={() => {
|
|
62
|
+
setLoading(true);
|
|
63
|
+
doStuff();
|
|
64
|
+
}}>
|
|
65
|
+
Submit
|
|
66
|
+
</button>`;
|
|
67
|
+
const r = transformHtmxOnEvents(src);
|
|
68
|
+
expect(r.content).toContain("onClick={() => {");
|
|
69
|
+
expect(r.content).toContain("setLoading(true);");
|
|
70
|
+
expect(r.content).not.toContain("hx-on");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("handles string-valued events (rare but legal htmx)", () => {
|
|
74
|
+
const src = `<button hx-on:click="alert('hi')">x</button>`;
|
|
75
|
+
const r = transformHtmxOnEvents(src);
|
|
76
|
+
expect(r.content).toBe(`<button onClick="alert('hi')">x</button>`);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("transformHtmxOnEvents — what stays untouched", () => {
|
|
81
|
+
it("leaves htmx lifecycle events alone (htmx-config-request, htmx-before-request, ...)", () => {
|
|
82
|
+
const src = `<form hx-post="/x"
|
|
83
|
+
hx-on:htmx-before-request={a}
|
|
84
|
+
hx-on:htmx-config-request={b}
|
|
85
|
+
hx-on-htmx-after-swap={c}
|
|
86
|
+
/>`;
|
|
87
|
+
const r = transformHtmxOnEvents(src);
|
|
88
|
+
expect(r.changed).toBe(false);
|
|
89
|
+
expect(r.content).toBe(src);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("renames standard events but preserves htmx lifecycle on the same element", () => {
|
|
93
|
+
const src = `<form hx-post="/x" hx-on:submit={validate} hx-on:htmx-before-request={addCsrf}>...</form>`;
|
|
94
|
+
const r = transformHtmxOnEvents(src);
|
|
95
|
+
expect(r.changed).toBe(true);
|
|
96
|
+
expect(r.content).toBe(
|
|
97
|
+
`<form hx-post="/x" onSubmit={validate} hx-on:htmx-before-request={addCsrf}>...</form>`,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("leaves unknown/custom events alone (no React synthetic equivalent)", () => {
|
|
102
|
+
const src = `<x hx-on:my-custom-event={fn} hx-on-other-thing={fn2}/>`;
|
|
103
|
+
const r = transformHtmxOnEvents(src);
|
|
104
|
+
expect(r.changed).toBe(false);
|
|
105
|
+
expect(r.content).toBe(src);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("leaves non-event hx-* attributes alone (hx-post, hx-target, hx-swap, ...)", () => {
|
|
109
|
+
const src = `<form hx-post="/x" hx-target="#r" hx-swap="innerHTML" hx-trigger="submit"/>`;
|
|
110
|
+
const r = transformHtmxOnEvents(src);
|
|
111
|
+
expect(r.changed).toBe(false);
|
|
112
|
+
expect(r.content).toBe(src);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("leaves already-React onClick alone", () => {
|
|
116
|
+
const src = `<button onClick={fn}>x</button>`;
|
|
117
|
+
const r = transformHtmxOnEvents(src);
|
|
118
|
+
expect(r.changed).toBe(false);
|
|
119
|
+
expect(r.content).toBe(src);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("does not match attribute names that merely contain 'hx-on' as a substring", () => {
|
|
123
|
+
const src = `<x data-hx-onfoo={fn} aria-hx-on="x"/>`;
|
|
124
|
+
const r = transformHtmxOnEvents(src);
|
|
125
|
+
expect(r.changed).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns unchanged on files with no hx-on at all (fast path)", () => {
|
|
129
|
+
const src = `<button onClick={fn}>x</button>\n<form hx-post="/x"/>`;
|
|
130
|
+
const r = transformHtmxOnEvents(src);
|
|
131
|
+
expect(r.changed).toBe(false);
|
|
132
|
+
expect(r.content).toBe(src);
|
|
133
|
+
expect(r.notes).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("transformHtmxOnEvents — Fresh-isms TODO injection", () => {
|
|
138
|
+
it("injects a top-of-file MIGRATION TODO when handler references useScript()", () => {
|
|
139
|
+
const src = `import { useScript } from "site/sdk/useScript.ts";
|
|
140
|
+
|
|
141
|
+
export default function X() {
|
|
142
|
+
return <button hx-on:click={useScript(handler)}>x</button>;
|
|
143
|
+
}
|
|
144
|
+
`;
|
|
145
|
+
const r = transformHtmxOnEvents(src);
|
|
146
|
+
expect(r.changed).toBe(true);
|
|
147
|
+
expect(r.content.startsWith(TODO_MARKER)).toBe(true);
|
|
148
|
+
expect(r.content).toContain("onClick={useScript(handler)}");
|
|
149
|
+
expect(r.notes.some((n) => n.includes("Injected MIGRATION TODO"))).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("injects a top-of-file MIGRATION TODO when handler references globalThis.window.STOREFRONT", () => {
|
|
153
|
+
const src = `<button hx-on:click={() => { globalThis.window.STOREFRONT.CART.addToCart({}); }}>buy</button>`;
|
|
154
|
+
const r = transformHtmxOnEvents(src);
|
|
155
|
+
expect(r.content.startsWith(TODO_MARKER)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("injects a top-of-file MIGRATION TODO when handler references STOREFRONT.* (shorthand)", () => {
|
|
159
|
+
const src = `import { STOREFRONT } from "site/sdk";\n<button hx-on:click={() => STOREFRONT.CART.addToCart()}>x</button>`;
|
|
160
|
+
const r = transformHtmxOnEvents(src);
|
|
161
|
+
expect(r.content.startsWith(TODO_MARKER)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("does NOT inject a TODO when no Fresh-isms are detected", () => {
|
|
165
|
+
const src = `<button hx-on:click={() => setOpen(true)}>x</button>`;
|
|
166
|
+
const r = transformHtmxOnEvents(src);
|
|
167
|
+
expect(r.changed).toBe(true);
|
|
168
|
+
expect(r.content.startsWith(TODO_MARKER)).toBe(false);
|
|
169
|
+
expect(r.content).toBe(`<button onClick={() => setOpen(true)}>x</button>`);
|
|
170
|
+
expect(r.notes.some((n) => n.includes("Injected MIGRATION TODO"))).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("preserves a leading shebang and inserts the TODO after it", () => {
|
|
174
|
+
const src = `#!/usr/bin/env node\n<button hx-on:click={useScript(fn)}>x</button>\n`;
|
|
175
|
+
const r = transformHtmxOnEvents(src);
|
|
176
|
+
expect(r.content.startsWith("#!/usr/bin/env node\n")).toBe(true);
|
|
177
|
+
expect(r.content.split("\n")[1]).toBe(TODO_MARKER);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("does not inject the TODO twice when the codemod is rerun (idempotent)", () => {
|
|
181
|
+
const src = `<button hx-on:click={useScript(fn)}>x</button>`;
|
|
182
|
+
const first = transformHtmxOnEvents(src);
|
|
183
|
+
const second = transformHtmxOnEvents(first.content);
|
|
184
|
+
expect(second.changed).toBe(false);
|
|
185
|
+
const occurrences = first.content.split(TODO_MARKER).length - 1;
|
|
186
|
+
expect(occurrences).toBe(1);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("transformHtmxOnEvents — idempotency + edge cases", () => {
|
|
191
|
+
it("is idempotent on a clean rewritten file (rerunning is a no-op)", () => {
|
|
192
|
+
const src = `<button onClick={fn}>x</button>`;
|
|
193
|
+
const first = transformHtmxOnEvents(src);
|
|
194
|
+
const second = transformHtmxOnEvents(first.content);
|
|
195
|
+
expect(first.changed).toBe(false);
|
|
196
|
+
expect(second.changed).toBe(false);
|
|
197
|
+
expect(second.content).toBe(src);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("is idempotent on a file that just got rewritten (rerun produces same output)", () => {
|
|
201
|
+
const src = `<button hx-on:click={fn}>x</button>`;
|
|
202
|
+
const first = transformHtmxOnEvents(src);
|
|
203
|
+
const second = transformHtmxOnEvents(first.content);
|
|
204
|
+
expect(second.changed).toBe(false);
|
|
205
|
+
expect(second.content).toBe(first.content);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("handles JSX components (capitalized tag names) just like lowercase intrinsics", () => {
|
|
209
|
+
const src = `<Accordion.Trigger hx-on-click={toggle}>open</Accordion.Trigger>`;
|
|
210
|
+
const r = transformHtmxOnEvents(src);
|
|
211
|
+
expect(r.content).toBe(
|
|
212
|
+
`<Accordion.Trigger onClick={toggle}>open</Accordion.Trigger>`,
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("renames within a long file containing many tags", () => {
|
|
217
|
+
const src = Array.from({ length: 20 })
|
|
218
|
+
.map((_, i) => `<button key={${i}} hx-on:click={() => setCount(${i})}>${i}</button>`)
|
|
219
|
+
.join("\n");
|
|
220
|
+
const r = transformHtmxOnEvents(src);
|
|
221
|
+
expect(r.notes[0]).toContain("Renamed 20 hx-on:* attribute(s)");
|
|
222
|
+
expect(r.content.split("onClick=").length - 1).toBe(20);
|
|
223
|
+
expect(r.content.includes("hx-on")).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("transformHtmxOnEvents — als-shaped fixtures", () => {
|
|
228
|
+
it("AddToBagButton: hx-on-click + Fresh useScript → onClick + TODO", () => {
|
|
229
|
+
const src = `import { useScript } from "site/sdk/useScript.ts";
|
|
230
|
+
|
|
231
|
+
interface Props { productId: string; }
|
|
232
|
+
|
|
233
|
+
export default function AddToBagButton({ productId }: Props) {
|
|
234
|
+
const handler = (id: string) => {
|
|
235
|
+
globalThis.window.STOREFRONT.CART.addToCart({ id });
|
|
236
|
+
};
|
|
237
|
+
return (
|
|
238
|
+
<button
|
|
239
|
+
class="btn"
|
|
240
|
+
hx-on-click={useScript(handler, productId)}
|
|
241
|
+
>
|
|
242
|
+
Add to bag
|
|
243
|
+
</button>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
`;
|
|
247
|
+
const r = transformHtmxOnEvents(src);
|
|
248
|
+
expect(r.changed).toBe(true);
|
|
249
|
+
expect(r.content).toContain("onClick={useScript(handler, productId)}");
|
|
250
|
+
expect(r.content.startsWith(TODO_MARKER)).toBe(true);
|
|
251
|
+
const codeBody = r.content.split("§ Pattern 1 (event-handler).\n")[1];
|
|
252
|
+
expect(codeBody).not.toMatch(/\bhx-on\b/);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("SearchInput: keeps hx-post/hx-target/etc, renames hx-on:change", () => {
|
|
256
|
+
const src = `<input
|
|
257
|
+
type="search"
|
|
258
|
+
hx-post={searchUrl}
|
|
259
|
+
hx-target="#suggestions"
|
|
260
|
+
hx-swap="innerHTML"
|
|
261
|
+
hx-trigger="keyup changed delay:300ms"
|
|
262
|
+
hx-sync="closest form:abort"
|
|
263
|
+
hx-on:change={(e) => { e.preventDefault(); }}
|
|
264
|
+
/>`;
|
|
265
|
+
const r = transformHtmxOnEvents(src);
|
|
266
|
+
expect(r.changed).toBe(true);
|
|
267
|
+
expect(r.content).toContain(`onChange={(e) => { e.preventDefault(); }}`);
|
|
268
|
+
expect(r.content).toContain("hx-post={searchUrl}");
|
|
269
|
+
expect(r.content).toContain('hx-target="#suggestions"');
|
|
270
|
+
expect(r.content).toContain('hx-swap="innerHTML"');
|
|
271
|
+
expect(r.content).toContain('hx-sync="closest form:abort"');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("RecoveryPassword: form with mixed standard + htmx lifecycle hooks", () => {
|
|
275
|
+
const src = `<form
|
|
276
|
+
hx-post={url}
|
|
277
|
+
hx-target="#form"
|
|
278
|
+
hx-swap="outerHTML"
|
|
279
|
+
hx-trigger="submit"
|
|
280
|
+
hx-disabled-elt="find button"
|
|
281
|
+
hx-indicator="#loader"
|
|
282
|
+
hx-select="#form"
|
|
283
|
+
hx-on:submit={(e) => { /* validate */ }}
|
|
284
|
+
hx-on-htmx-before-request={(e) => addCsrf(e)}
|
|
285
|
+
>
|
|
286
|
+
…
|
|
287
|
+
</form>`;
|
|
288
|
+
const r = transformHtmxOnEvents(src);
|
|
289
|
+
expect(r.changed).toBe(true);
|
|
290
|
+
expect(r.content).toContain(`onSubmit={(e) => { /* validate */ }}`);
|
|
291
|
+
expect(r.content).toContain("hx-on-htmx-before-request={(e) => addCsrf(e)}");
|
|
292
|
+
expect(r.content).toContain("hx-post={url}");
|
|
293
|
+
expect(r.content).toContain('hx-trigger="submit"');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("Footer.tsx-style: simple onClick with no Fresh-ism — no TODO", () => {
|
|
297
|
+
const src = `<button hx-on:click={() => setOpen((p) => !p)}>menu</button>`;
|
|
298
|
+
const r = transformHtmxOnEvents(src);
|
|
299
|
+
expect(r.changed).toBe(true);
|
|
300
|
+
expect(r.content.startsWith(TODO_MARKER)).toBe(false);
|
|
301
|
+
expect(r.content).toBe(
|
|
302
|
+
`<button onClick={() => setOpen((p) => !p)}>menu</button>`,
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
});
|