@decocms/blocks-cli 7.46.1 → 7.47.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.46.1",
3
+ "version": "7.47.0",
4
4
  "type": "module",
5
5
  "description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "lint:unused": "knip"
33
33
  },
34
34
  "dependencies": {
35
- "@decocms/blocks": "7.46.1",
35
+ "@decocms/blocks": "7.47.0",
36
36
  "ts-morph": "^27.0.0",
37
37
  "tsx": "^4.22.5"
38
38
  },
@@ -0,0 +1,63 @@
1
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { reconcileIconCasing } from "./phase-transform";
6
+ import { createContext, type MigrationContext } from "./types";
7
+
8
+ let dir: string;
9
+ let ctx: MigrationContext;
10
+
11
+ beforeEach(() => {
12
+ dir = mkdtempSync(join(tmpdir(), "icon-casing-"));
13
+ mkdirSync(join(dir, "src", "components", "ui"), { recursive: true });
14
+ mkdirSync(join(dir, "src", "sections"), { recursive: true });
15
+ writeFileSync(
16
+ join(dir, "src", "components", "ui", "Icon.tsx"),
17
+ `export type AvailableIcons =\n | "Close"\n | "Menu"\n | "ChevronDown";\n`,
18
+ );
19
+ ctx = createContext(dir);
20
+ });
21
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
22
+
23
+ describe("reconcileIconCasing", () => {
24
+ it("case-corrects <Icon id> to match the declared AvailableIcons", () => {
25
+ const f = join(dir, "src", "sections", "Drawer.tsx");
26
+ writeFileSync(f, `<Icon id="close" /> <Icon id="menu" /> <Icon id="Close" />`);
27
+ reconcileIconCasing(ctx);
28
+ const out = readFileSync(f, "utf8");
29
+ expect(out).toContain(`id="Close"`);
30
+ expect(out).toContain(`id="Menu"`);
31
+ expect(out).not.toContain(`id="close"`);
32
+ expect(out).not.toContain(`id="menu"`);
33
+ });
34
+
35
+ it("flags a used icon that is NOT in AvailableIcons (no blind rename)", () => {
36
+ const f = join(dir, "src", "sections", "Search.tsx");
37
+ writeFileSync(f, `<Icon id="search" />`);
38
+ reconcileIconCasing(ctx);
39
+ // left as-is
40
+ expect(readFileSync(f, "utf8")).toContain(`id="search"`);
41
+ // flagged for manual review, once
42
+ const flags = ctx.manualReviewItems.filter((m) => m.reason.includes(`id="search"`));
43
+ expect(flags).toHaveLength(1);
44
+ expect(flags[0]!.file).toBe("src/sections/Search.tsx");
45
+ });
46
+
47
+ it("only touches <Icon id>, not other id= attributes", () => {
48
+ const f = join(dir, "src", "sections", "Nav.tsx");
49
+ writeFileSync(f, `<div id="close-button"><Icon id="close" /></div>`);
50
+ reconcileIconCasing(ctx);
51
+ const out = readFileSync(f, "utf8");
52
+ expect(out).toContain(`<div id="close-button">`); // untouched
53
+ expect(out).toContain(`<Icon id="Close" />`); // corrected
54
+ });
55
+
56
+ it("no-op when the site declares no AvailableIcons", () => {
57
+ rmSync(join(dir, "src", "components", "ui", "Icon.tsx"));
58
+ const f = join(dir, "src", "sections", "X.tsx");
59
+ writeFileSync(f, `<Icon id="close" />`);
60
+ reconcileIconCasing(ctx);
61
+ expect(readFileSync(f, "utf8")).toContain(`id="close"`); // unchanged
62
+ });
63
+ });
@@ -666,8 +666,16 @@ export function analyze(ctx: MigrationContext): void {
666
666
  console.log(` Font: ${ctx.fontFamily}`);
667
667
  }
668
668
 
669
- // Scan all files
669
+ // Scan all files. The root scan skips `src/` (SKIP_DIRS) — for a MODERN
670
+ // layout the code lives there, so scan `src/` separately with baseDir=src so
671
+ // its files categorize as if they were at root (`src/sections/X` → `sections/X`
672
+ // → section → targetPath `src/sections/X`). The root scan still handles config
673
+ // + static/ at the repo root.
670
674
  scanDir(ctx.sourceDir, ctx.sourceDir, ctx.files);
675
+ if (ctx.layout === "modern") {
676
+ const srcDir = path.join(ctx.sourceDir, "src");
677
+ if (fs.existsSync(srcDir)) scanDir(srcDir, srcDir, ctx.files);
678
+ }
671
679
 
672
680
  // Summary
673
681
  const byAction = { transform: 0, delete: 0, move: 0, scaffold: 0, "manual-review": 0 };
@@ -387,7 +387,12 @@ export default debounce;
387
387
 
388
388
  function generateSignalShim(): string {
389
389
  return `import { useState, useRef, useEffect, useMemo, useCallback, useSyncExternalStore } from "react";
390
- export { signal, type ReactiveSignal } from "@decocms/blocks/sdk/signal";
390
+ // Import locally (so \`ReactiveSignal\` is in scope for useSignalValue below) AND
391
+ // re-export for consumers. A bare \`export { … } from\` re-export does NOT bind the
392
+ // name locally — using it as a type then fails with "Cannot find name".
393
+ import { signal, type ReactiveSignal } from "@decocms/blocks/sdk/signal";
394
+ export { signal };
395
+ export type { ReactiveSignal };
391
396
 
392
397
  /** Run a function immediately. Kept for legacy module-level side effects. */
393
398
  export function effect(fn: () => void | (() => void)): () => void {
@@ -5,12 +5,15 @@ import type { MigrationContext, TransformResult, SectionMeta } from "./types";
5
5
  import { log, logPhase } from "./types";
6
6
  import { transformImports } from "./transforms/imports";
7
7
  import { transformJsx } from "./transforms/jsx";
8
+ import { transformPicture } from "./transforms/picture";
9
+ import { transformTimerTypes } from "./transforms/timer-types";
8
10
  import { transformFreshApis } from "./transforms/fresh-apis";
9
11
  import { transformCtxCompat } from "./transforms/ctx-compat";
10
12
  import { transformDenoIsms } from "./transforms/deno-isms";
11
13
  import { transformTailwind } from "./transforms/tailwind";
12
14
  import { transformDeadCode } from "./transforms/dead-code";
13
15
  import { transformHtmxOnEvents } from "./transforms/htmx-on-events";
16
+ import { transformUseScriptHandlers } from "./transforms/use-script-handlers";
14
17
  import { createSectionConventionsTransform } from "./transforms/section-conventions";
15
18
 
16
19
  /** Map of section path → metadata, populated per-run */
@@ -67,10 +70,13 @@ function applyTransforms(content: string, filePath: string, ctx?: MigrationConte
67
70
  { name: "imports", fn: (c) => transformImports(c, ctx?.islandWrapperTargets) },
68
71
  { name: "jsx", fn: transformJsx },
69
72
  { name: "htmx-on-events", fn: transformHtmxOnEvents },
73
+ { name: "use-script-handlers", fn: transformUseScriptHandlers },
70
74
  { name: "fresh-apis", fn: transformFreshApis },
71
75
  { name: "ctx-compat", fn: transformCtxCompat },
72
76
  { name: "dead-code", fn: (c) => transformDeadCode(c, ctx?.platform) },
73
77
  { name: "deno-isms", fn: transformDenoIsms },
78
+ { name: "timer-types", fn: transformTimerTypes },
79
+ { name: "picture", fn: transformPicture },
74
80
  { name: "tailwind", fn: transformTailwind },
75
81
  ];
76
82
 
@@ -264,6 +270,74 @@ export function transform(ctx: MigrationContext): void {
264
270
  // the actual file and rewrite the import.
265
271
  if (!ctx.dryRun) {
266
272
  fixIslandImports(ctx);
273
+ reconcileIconCasing(ctx);
274
+ }
275
+ }
276
+
277
+ /**
278
+ * `<Icon id="close">` → `<Icon id="Close">`. Deco-fresh (Deno) didn't strictly
279
+ * typecheck, so sites often used lowercase icon ids that don't match their own
280
+ * PascalCase `AvailableIcons` union — the stricter TanStack typecheck then fails
281
+ * ("Type '\"close\"' is not assignable to type 'AvailableIcons'"). This
282
+ * case-corrects any `<Icon id="X">` whose name matches an AvailableIcons entry
283
+ * case-insensitively. Names with NO match (a genuinely missing icon) are left
284
+ * as-is and flagged for manual review — adding them to the type + sprite is a
285
+ * design decision, not a rename.
286
+ */
287
+ export function reconcileIconCasing(ctx: MigrationContext): void {
288
+ const srcDir = path.join(ctx.sourceDir, "src");
289
+ if (!fs.existsSync(srcDir)) return;
290
+
291
+ const walk = (dir: string, visit: (file: string, content: string) => void) => {
292
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
293
+ if (entry.isDirectory()) {
294
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
295
+ walk(path.join(dir, entry.name), visit);
296
+ } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) {
297
+ visit(path.join(dir, entry.name), fs.readFileSync(path.join(dir, entry.name), "utf-8"));
298
+ }
299
+ }
300
+ };
301
+
302
+ // 1. Canonical icon names from the declared `AvailableIcons` union (if any).
303
+ const canonical = new Map<string, string>(); // lowercased → canonical casing
304
+ walk(srcDir, (_file, content) => {
305
+ const m = content.match(/export type AvailableIcons\s*=\s*([\s\S]*?);/);
306
+ if (m) for (const n of m[1].matchAll(/["']([A-Za-z0-9_-]+)["']/g)) canonical.set(n[1].toLowerCase(), n[1]);
307
+ });
308
+ if (canonical.size === 0) return;
309
+
310
+ // 2. Case-correct `<Icon id="X">` usages; flag unmatched names once per file.
311
+ const iconIdRe = /(<Icon\b[^>]*?\bid=["'])([A-Za-z0-9_-]+)(["'])/g;
312
+ let corrected = 0;
313
+ walk(srcDir, (file, content) => {
314
+ let modified = false;
315
+ const flagged = new Set<string>();
316
+ const next = content.replace(iconIdRe, (whole, pre, name, post) => {
317
+ const canon = canonical.get(name.toLowerCase());
318
+ if (canon) {
319
+ if (canon !== name) {
320
+ modified = true;
321
+ corrected++;
322
+ return `${pre}${canon}${post}`;
323
+ }
324
+ return whole;
325
+ }
326
+ if (!flagged.has(name)) {
327
+ flagged.add(name);
328
+ ctx.manualReviewItems.push({
329
+ file: path.relative(ctx.sourceDir, file).replace(/\\/g, "/"),
330
+ reason: `<Icon id="${name}"> is not in AvailableIcons — add it to the type in Icon.tsx and the sprite, or the icon renders blank.`,
331
+ severity: "warning",
332
+ });
333
+ }
334
+ return whole;
335
+ });
336
+ if (modified) fs.writeFileSync(file, next, "utf-8");
337
+ });
338
+
339
+ if (corrected > 0) {
340
+ console.log(` Icon casing: corrected ${corrected} <Icon id> to match AvailableIcons`);
267
341
  }
268
342
  }
269
343
 
@@ -19,6 +19,26 @@ function makeFs(paths: string[]): FsLike {
19
19
  };
20
20
  }
21
21
 
22
+ /**
23
+ * FsLike where each dir path is tagged as populated (has files) or empty.
24
+ * `dirsWithFiles` exist AND report `hasFiles: true`; `emptyDirs` exist but
25
+ * report `hasFiles: false` (leftover stubs).
26
+ */
27
+ function makeFsWithFiles(dirsWithFiles: string[], emptyDirs: string[] = []): FsLike {
28
+ const norm = (p: string) => p.replace(/\\/g, "/");
29
+ const populated = new Set(dirsWithFiles.map(norm));
30
+ const empty = new Set(emptyDirs.map(norm));
31
+ return {
32
+ existsSync(p) {
33
+ const n = norm(p);
34
+ return populated.has(n) || empty.has(n);
35
+ },
36
+ hasFiles(p) {
37
+ return populated.has(norm(p));
38
+ },
39
+ };
40
+ }
41
+
22
42
  const SITE = "/site";
23
43
 
24
44
  describe("detectSourceLayout — classic layout", () => {
@@ -64,6 +84,29 @@ describe("detectSourceLayout — mixed layout", () => {
64
84
  });
65
85
  });
66
86
 
87
+ describe("detectSourceLayout — empty leftover dirs (hasFiles)", () => {
88
+ it("ignores an EMPTY root sections/ next to a populated src/sections/ → modern", () => {
89
+ // The real portal-davinci case: leftover empty `sections/` + real `src/sections/`.
90
+ const fs = makeFsWithFiles(["/site/src/sections"], ["/site/sections"]);
91
+ expect(detectSourceLayout(SITE, fs)).toBe("modern");
92
+ });
93
+
94
+ it("ignores an EMPTY src/sections/ next to a populated root sections/ → classic", () => {
95
+ const fs = makeFsWithFiles(["/site/sections"], ["/site/src/sections"]);
96
+ expect(detectSourceLayout(SITE, fs)).toBe("classic");
97
+ });
98
+
99
+ it("still flags genuinely mixed (both populated) as mixed", () => {
100
+ const fs = makeFsWithFiles(["/site/sections", "/site/src/sections"]);
101
+ expect(detectSourceLayout(SITE, fs)).toBe("mixed");
102
+ });
103
+
104
+ it("empty dirs on both sides → empty", () => {
105
+ const fs = makeFsWithFiles([], ["/site/sections", "/site/src/sections"]);
106
+ expect(detectSourceLayout(SITE, fs)).toBe("empty");
107
+ });
108
+ });
109
+
67
110
  describe("detectSourceLayout — empty layout", () => {
68
111
  it("returns empty when neither root nor src/ has recognised dirs", () => {
69
112
  const fs = makeFs(["/site/package.json", "/site/README.md"]);
@@ -31,19 +31,46 @@ const RECOGNISED_DIRS = ["sections", "islands", "components", "loaders", "action
31
31
 
32
32
  export interface FsLike {
33
33
  existsSync(p: string): boolean;
34
+ /**
35
+ * True when `p` is a directory containing at least one FILE (recursively).
36
+ * An empty leftover dir (e.g. a `sections/` stub next to a populated
37
+ * `src/sections/`) must NOT count as a layout, or it triggers a false
38
+ * "mixed layout" abort. Optional so existing `{ existsSync }` stubs keep
39
+ * working — when absent, mere existence counts (previous behaviour).
40
+ */
41
+ hasFiles?(p: string): boolean;
34
42
  }
35
43
 
36
- const realFs: FsLike = { existsSync: fs.existsSync };
44
+ /** Recursively: does this dir hold at least one file? Empty dirs → false. */
45
+ function realHasFiles(p: string): boolean {
46
+ let entries: fs.Dirent[];
47
+ try {
48
+ entries = fs.readdirSync(p, { withFileTypes: true });
49
+ } catch {
50
+ return false;
51
+ }
52
+ for (const e of entries) {
53
+ if (e.isFile()) return true;
54
+ if (e.isDirectory() && realHasFiles(path.join(p, e.name))) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ const realFs: FsLike = { existsSync: fs.existsSync, hasFiles: realHasFiles };
37
60
 
38
61
  /**
39
62
  * Classify the source directory's layout. Pure function — accepts
40
63
  * a `FsLike` so unit tests can stub the disk without mocking node:fs.
41
64
  */
42
65
  export function detectSourceLayout(sourceDir: string, fsAdapter: FsLike = realFs): SourceLayout {
43
- const hasRootDir = RECOGNISED_DIRS.some((d) => fsAdapter.existsSync(path.join(sourceDir, d)));
44
- const hasSrcDir = RECOGNISED_DIRS.some((d) =>
45
- fsAdapter.existsSync(path.join(sourceDir, "src", d)),
46
- );
66
+ // A recognised dir only "counts" if it exists AND holds files — an empty
67
+ // leftover `sections/` next to a populated `src/sections/` is not a real
68
+ // second layout and must not trigger a "mixed" abort. Falls back to mere
69
+ // existence when the adapter doesn't implement hasFiles (old test stubs).
70
+ const counts = (p: string): boolean =>
71
+ fsAdapter.existsSync(p) && (fsAdapter.hasFiles ? fsAdapter.hasFiles(p) : true);
72
+ const hasRootDir = RECOGNISED_DIRS.some((d) => counts(path.join(sourceDir, d)));
73
+ const hasSrcDir = RECOGNISED_DIRS.some((d) => counts(path.join(sourceDir, "src", d)));
47
74
 
48
75
  if (hasRootDir && hasSrcDir) return "mixed";
49
76
  if (hasSrcDir) return "modern";
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { transformJsx } from "./jsx";
3
+
4
+ describe("transformJsx — class in type annotations", () => {
5
+ it("renames an INLINE `class?:` type property (mid-line)", () => {
6
+ const src = `function P({ height, className: _c }: { height: string; class?: string }) {}`;
7
+ const out = transformJsx(src).content;
8
+ expect(out).toContain("className?: string");
9
+ expect(out).not.toContain("class?: string");
10
+ // the destructuring rename is preserved
11
+ expect(out).toContain("className: _c");
12
+ });
13
+
14
+ it("still renames a line-start `class?:` in an interface", () => {
15
+ const out = transformJsx(`interface Props {\n class?: string;\n}`).content;
16
+ expect(out).toContain("className?: string");
17
+ });
18
+
19
+ it("renames `class?:` inside a union member", () => {
20
+ const out = transformJsx(`type T = { a: 1 } | { class?: number };`).content;
21
+ expect(out).toContain("className?: number");
22
+ });
23
+
24
+ it("does NOT touch a runtime `class:` value (no `?`)", () => {
25
+ const out = transformJsx(`const x = { class: "btn" };`).content;
26
+ expect(out).toContain(`class: "btn"`);
27
+ });
28
+ });
@@ -195,6 +195,16 @@ export function transformJsx(content: string): TransformResult {
195
195
  notes.push("Replaced 'class' in interface definitions with 'className'");
196
196
  }
197
197
 
198
+ // `class?:` MID-LINE, inside an INLINE type literal
199
+ // (e.g. `{ height: string; class?: string }`). The `?` marks it as a type
200
+ // property — runtime objects never use `?:` — so this can't hit a real
201
+ // `class:` value. The line-start rule above misses the inline form.
202
+ if (/[{;,|(]\s*class\?\s*:/.test(result)) {
203
+ result = result.replace(/([{;,|(]\s*)class(\?\s*:)/g, "$1className$2");
204
+ changed = true;
205
+ notes.push("Replaced inline 'class?:' type property with 'className?:'");
206
+ }
207
+
198
208
  // Remove `alt` prop from non-img elements (<a>, <iframe>, <div>, etc.)
199
209
  // In React, `alt` is only valid on <img>, <input type="image">, <area>
200
210
  const altOnNonImgRegex = /(<(?:a|iframe|div|span|button|section)\s[^>]*?)\s+alt=(?:\{[^}]*\}|"[^"]*"|'[^']*')/g;
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { transformPicture } from "./picture";
3
+
4
+ const CLASSIC = `<Picture preload className="grid-pos">
5
+ <Source src={image.mobile} width={360} height={120} media="(max-width: 767px)" />
6
+ <Source src={image.desktop} width={1440} height={200} media="(min-width: 767px)" />
7
+ <img className="w-full" src={image.desktop} alt={image.alt ?? title} />
8
+ </Picture>`;
9
+
10
+ describe("transformPicture", () => {
11
+ it("converts <Source>+<img> children into sources[] + top-level props", () => {
12
+ const { content, changed, notes } = transformPicture(CLASSIC);
13
+ expect(changed).toBe(true);
14
+ expect(content).toContain("sources={[");
15
+ // both sources, values unwrapped into an object literal
16
+ expect(content).toContain(
17
+ `{ src: image.mobile, width: 360, height: 120, media: "(max-width: 767px)" }`,
18
+ );
19
+ expect(content).toContain(`{ src: image.desktop, width: 1440, height: 200, media:`);
20
+ // required-for-typecheck props lifted from the <img> fallback
21
+ expect(content).toContain("src={image.desktop}");
22
+ expect(content).toContain("width={1440}");
23
+ expect(content).toContain("alt={image.alt ?? title}");
24
+ // self-closing, children gone
25
+ expect(content).not.toContain("<Source");
26
+ expect(content).not.toContain("</Picture>");
27
+ expect(notes.some((n) => n.includes("sources[]"))).toBe(true);
28
+ });
29
+
30
+ it("does not emit a duplicate className (Picture's own wins)", () => {
31
+ const { content } = transformPicture(CLASSIC);
32
+ expect((content.match(/className=/g) ?? []).length).toBe(1);
33
+ expect(content).toContain(`className="grid-pos"`);
34
+ expect(content).not.toContain(`"w-full"`);
35
+ });
36
+
37
+ it("lifts the <img> className when the Picture declares none", () => {
38
+ const input = `<Picture preload><Source src={a} width={10} media="m" /><img className="w" src={a} alt={t} /></Picture>`;
39
+ expect(transformPicture(input).content).toContain(`className="w"`);
40
+ });
41
+
42
+ it("leaves a prop-based Picture (no <Source> children) untouched", () => {
43
+ const already = `<Picture sources={x} src={y} width={10} />`;
44
+ const { content, changed } = transformPicture(already);
45
+ expect(changed).toBe(false);
46
+ expect(content).toBe(already);
47
+ });
48
+
49
+ it("handles a Picture with no <img> fallback (top props from last Source)", () => {
50
+ const input = `<Picture><Source src={a} width={800} media="all" /></Picture>`;
51
+ const { content } = transformPicture(input);
52
+ expect(content).toContain("sources={[{ src: a, width: 800, media: \"all\" }]}");
53
+ expect(content).toContain("src={a}");
54
+ expect(content).toContain("width={800}");
55
+ });
56
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Picture API migration: children → props.
3
+ *
4
+ * Deco-fresh's `<Picture>` wrapped `<Source>` + an `<img>` fallback as CHILDREN.
5
+ * The new `@decocms/blocks` `Picture` is prop-based — `PictureProps` requires a
6
+ * `sources: PictureSourceProps[]` array plus `src`/`width` (from ImageProps).
7
+ * The children still render (`children ?? sources`) so a naive keep-children
8
+ * approach both fails the typecheck AND double-renders (children + the bottom
9
+ * `<Image>`). This transform converts the classic shape:
10
+ *
11
+ * <Picture ATTRS>
12
+ * <Source src={a} width={360} media="..." />
13
+ * <Source src={b} width={1440} media="..." />
14
+ * <img src={b} alt={x} className="..." />
15
+ * </Picture>
16
+ *
17
+ * into the prop form:
18
+ *
19
+ * <Picture ATTRS
20
+ * sources={[{ src: a, width: 360, media: "..." }, { src: b, width: 1440, media: "..." }]}
21
+ * src={b} width={1440} alt={x} className="..." />
22
+ *
23
+ * Only Pictures with `<Source>` children are touched; a Picture already using
24
+ * `sources={...}` props is left as-is.
25
+ */
26
+
27
+ import type { TransformResult } from "../types";
28
+
29
+ /** Parse a JSX tag's attribute string into name → raw value (`{expr}` / `"str"` / boolean). */
30
+ function parseAttrs(attrStr: string): Record<string, string> {
31
+ const attrs: Record<string, string> = {};
32
+ const re = /([A-Za-z_][\w-]*)(?:\s*=\s*(\{[^{}]*\}|"[^"]*"|'[^']*'))?/g;
33
+ let m: RegExpExecArray | null;
34
+ while ((m = re.exec(attrStr)) !== null) {
35
+ if (m[1]) attrs[m[1]] = m[2] ?? "";
36
+ }
37
+ return attrs;
38
+ }
39
+
40
+ /** Unwrap a raw JSX value for an object-literal field: `{image.x}` → `image.x`, `"s"` → `"s"`. */
41
+ function unwrap(v: string): string {
42
+ return v.startsWith("{") && v.endsWith("}") ? v.slice(1, -1) : v;
43
+ }
44
+
45
+ export function transformPicture(content: string): TransformResult {
46
+ const notes: string[] = [];
47
+ let changed = false;
48
+
49
+ const result = content.replace(
50
+ /<Picture(\s[^>]*?)?>([\s\S]*?)<\/Picture>/g,
51
+ (whole, attrStr: string | undefined, inner: string) => {
52
+ const sourceMatches = [...inner.matchAll(/<Source\s+([^/>]*?)\/>/g)];
53
+ if (sourceMatches.length === 0) return whole; // not the children pattern
54
+
55
+ const sources = sourceMatches.map((mm) => parseAttrs(mm[1]));
56
+ const imgMatch = inner.match(/<img\s+([^/>]*?)\/>/);
57
+ const img = imgMatch ? parseAttrs(imgMatch[1]) : {};
58
+ const last = sources[sources.length - 1]!;
59
+
60
+ // sources={[{ src, width, height, media, sizes }, ...]}
61
+ const srcArr = sources
62
+ .map((s) => {
63
+ const fields = (["src", "width", "height", "media", "sizes"] as const)
64
+ .filter((k) => s[k] !== undefined)
65
+ .map((k) => `${k}: ${unwrap(s[k]!)}`);
66
+ return `{ ${fields.join(", ")} }`;
67
+ })
68
+ .join(", ");
69
+
70
+ // The required-for-typecheck props (src/width/height/alt) come from the
71
+ // <img> fallback, else the last <Source>.
72
+ const pick = (k: string): string | undefined => img[k] ?? last[k];
73
+ const top: string[] = [];
74
+ for (const k of ["src", "width", "height", "alt"] as const) {
75
+ const v = pick(k);
76
+ if (v !== undefined && v !== "") top.push(`${k}=${v}`);
77
+ }
78
+ // className/loading: only lift from the <img> if the <Picture> doesn't
79
+ // already declare it — otherwise we'd emit a duplicate attribute (React
80
+ // keeps the last, silently dropping the Picture's own classes).
81
+ const pictureAttrs = parseAttrs(attrStr ?? "");
82
+ for (const k of ["className", "loading"] as const) {
83
+ if (img[k] !== undefined && img[k] !== "" && pictureAttrs[k] === undefined) {
84
+ top.push(`${k}=${img[k]}`);
85
+ }
86
+ }
87
+
88
+ changed = true;
89
+ notes.push(`Picture: ${sources.length} <Source> children → sources[] props`);
90
+ return `<Picture${attrStr ?? ""} sources={[${srcArr}]} ${top.join(" ")} />`;
91
+ },
92
+ );
93
+
94
+ return { content: result, changed, notes };
95
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { transformTimerTypes } from "./timer-types";
3
+
4
+ describe("transformTimerTypes", () => {
5
+ it("retypes a `number` handle assigned setTimeout", () => {
6
+ const r = transformTimerTypes(`let hideTimeout: number;\nhideTimeout = setTimeout(() => {}, 100);`);
7
+ expect(r.changed).toBe(true);
8
+ expect(r.content).toContain("let hideTimeout: ReturnType<typeof setTimeout>");
9
+ });
10
+
11
+ it("retypes a setInterval handle", () => {
12
+ const r = transformTimerTypes(`let poll: number;\npoll = setInterval(fn, 1000);`);
13
+ expect(r.content).toContain("let poll: ReturnType<typeof setInterval>");
14
+ });
15
+
16
+ it("leaves an unrelated numeric variable alone", () => {
17
+ const r = transformTimerTypes(`let count: number;\ncount = 5;\nlet t: number;\nt = setTimeout(f, 1);`);
18
+ expect(r.content).toContain("let count: number");
19
+ expect(r.content).toContain("let t: ReturnType<typeof setTimeout>");
20
+ });
21
+
22
+ it("handles the window.setTimeout form", () => {
23
+ const r = transformTimerTypes(`let x: number;\nx = window.setTimeout(f, 1);`);
24
+ expect(r.content).toContain("let x: ReturnType<typeof setTimeout>");
25
+ });
26
+
27
+ it("is a no-op when the handle is never assigned a timer", () => {
28
+ expect(transformTimerTypes(`let y: number;`).changed).toBe(false);
29
+ });
30
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `let x: number = setTimeout(...)` typing fix.
3
+ *
4
+ * Deco-fresh (Deno DOM types) had `setTimeout`/`setInterval` return `number`, so
5
+ * sites typed their handles `let hideTimeout: number`. Under Node/TanStack types
6
+ * they return `NodeJS.Timeout`, so the assignment fails ("Type 'Timeout' is not
7
+ * assignable to type 'number'"). Rather than `window.setTimeout` (unsafe in
8
+ * server `.ts`), retype the DECLARATION to `ReturnType<typeof setTimeout>` — the
9
+ * portable handle type, correct in both browser and worker/node.
10
+ *
11
+ * Only a `let|const|var NAME: number` declaration whose NAME is assigned a
12
+ * `setTimeout(` / `setInterval(` elsewhere in the file is retyped; a plain
13
+ * numeric variable is never touched.
14
+ */
15
+
16
+ import type { TransformResult } from "../types";
17
+
18
+ export function transformTimerTypes(content: string): TransformResult {
19
+ const notes: string[] = [];
20
+ let changed = false;
21
+ let result = content;
22
+
23
+ for (const kind of ["setTimeout", "setInterval"] as const) {
24
+ // Names assigned this timer: `NAME = setTimeout(` (and `= window.setTimeout(`).
25
+ const assigned = new Set<string>();
26
+ for (const m of content.matchAll(
27
+ new RegExp(`\\b([A-Za-z_$][\\w$]*)\\s*=\\s*(?:window\\.)?${kind}\\s*\\(`, "g"),
28
+ )) {
29
+ assigned.add(m[1]!);
30
+ }
31
+
32
+ for (const name of assigned) {
33
+ // Retype only the declaration's `: number` (allowing `| undefined`/`| null`).
34
+ const declRe = new RegExp(
35
+ `\\b(let|const|var)(\\s+${name}\\s*:\\s*)number\\b`,
36
+ "g",
37
+ );
38
+ const next = result.replace(declRe, `$1$2ReturnType<typeof ${kind}>`);
39
+ if (next !== result) {
40
+ result = next;
41
+ changed = true;
42
+ notes.push(`Timer type: ${name}: number → ReturnType<typeof ${kind}>`);
43
+ }
44
+ }
45
+ }
46
+
47
+ return { content: result, changed, notes };
48
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { transformUseScriptHandlers } from "./use-script-handlers";
3
+
4
+ describe("transformUseScriptHandlers", () => {
5
+ it("rewrites an identifier callback + args to an arrow", () => {
6
+ const r = transformUseScriptHandlers(`<button onClick={useScript(onClick, -1)} />`);
7
+ expect(r.changed).toBe(true);
8
+ expect(r.content).toContain(`onClick={() => onClick(-1)}`);
9
+ });
10
+
11
+ it("handles a no-arg identifier callback", () => {
12
+ const r = transformUseScriptHandlers(`<button onClick={useScript(handleClick)} />`);
13
+ expect(r.content).toContain(`onClick={() => handleClick()}`);
14
+ });
15
+
16
+ it("handles multiple args and any onEvent name", () => {
17
+ const r = transformUseScriptHandlers(`<a onMouseEnter={useScript(prefetch, id, "x")} />`);
18
+ expect(r.content).toContain(`onMouseEnter={() => prefetch(id, "x")}`);
19
+ });
20
+
21
+ it("leaves an INLINE arrow untouched (per-site closure → manual)", () => {
22
+ const src = `<button onClick={useScript(({ x }) => { doThing(x); })} />`;
23
+ const r = transformUseScriptHandlers(src);
24
+ expect(r.changed).toBe(false);
25
+ expect(r.content).toBe(src);
26
+ });
27
+
28
+ it("leaves an arg with nested parens untouched (safety)", () => {
29
+ const src = `<button onClick={useScript(fn, foo(x))} />`;
30
+ expect(transformUseScriptHandlers(src).changed).toBe(false);
31
+ });
32
+
33
+ it("does not touch a non-handler useScript call", () => {
34
+ const src = `const s = useScript(fn, 1);`;
35
+ expect(transformUseScriptHandlers(src).changed).toBe(false);
36
+ });
37
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `useScript` in React event handlers → a plain arrow.
3
+ *
4
+ * Deco-fresh's `useScript(fn, ...args)` serialized `fn` to a client-side script
5
+ * STRING (hydration-free interactivity), so `onClick={useScript(onClick, -1)}`
6
+ * passed a string to `onClick`. React wants a FUNCTION and the stricter TanStack
7
+ * typecheck fails ("Type 'string' is not assignable to MouseEventHandler").
8
+ * Hydrated React just calls the function directly:
9
+ *
10
+ * onClick={useScript(onClick, -1)} → onClick={() => onClick(-1)}
11
+ *
12
+ * Only the SIMPLE, unambiguous shape is rewritten — an identifier callback plus
13
+ * literal args (no nested parens/braces). An INLINE arrow
14
+ * (`useScript(({x}) => {…})`) depends on how the closure was serialized and is a
15
+ * per-site call, so it's left for the `htmx-residue` audit + manual rewrite.
16
+ */
17
+
18
+ import type { TransformResult } from "../types";
19
+
20
+ export function transformUseScriptHandlers(content: string): TransformResult {
21
+ const notes: string[] = [];
22
+ let changed = false;
23
+
24
+ // onEvent={useScript(IDENT[, simple args])} — IDENT is a bare identifier /
25
+ // member expr; args contain no nested parens or braces (keeps it safe).
26
+ const re = /\b(on[A-Z]\w+)=\{useScript\(\s*([A-Za-z_$][\w$.]*)\s*((?:,[^{}()]*)?)\)\}/g;
27
+
28
+ const result = content.replace(re, (_whole, event: string, fn: string, argsRaw: string) => {
29
+ const args = argsRaw.replace(/^\s*,\s*/, "").trim();
30
+ changed = true;
31
+ notes.push(`useScript handler: ${event}={useScript(${fn}…)} → () => ${fn}(…)`);
32
+ return `${event}={() => ${fn}(${args})}`;
33
+ });
34
+
35
+ return { content: result, changed, notes };
36
+ }
@@ -132,6 +132,12 @@ export interface LoaderInfo {
132
132
 
133
133
  export interface MigrationContext {
134
134
  sourceDir: string;
135
+ /**
136
+ * Source directory layout: `"classic"` (sections/ at root) or `"modern"`
137
+ * (src/sections/). Drives whether the analyzer also scans `src/`. Set in
138
+ * migrate.ts after detection; defaults to `"classic"`.
139
+ */
140
+ layout: import("./source-layout").SourceLayout;
135
141
  siteName: string;
136
142
  platform: Platform;
137
143
  /** VTEX account name (e.g. "casaevideonewio") — extracted from source code */
@@ -219,6 +225,7 @@ export function createContext(
219
225
  ): MigrationContext {
220
226
  return {
221
227
  sourceDir,
228
+ layout: "classic",
222
229
  siteName: "",
223
230
  platform: "custom",
224
231
  vtexAccount: null,
@@ -157,18 +157,21 @@ async function main() {
157
157
  config: siteConfig,
158
158
  });
159
159
 
160
- // Phase 0: Source-layout detection (early-abort for unsupported layouts).
161
- // The analyzer assumes a classic root layout (sections/, islands/, ...);
162
- // running it on a modern src/ layout silently yields a near-empty
163
- // migration. Detect-and-abort here so the user gets an actionable error
164
- // before we touch any files.
160
+ // Phase 0: Source-layout detection. `classic` (root sections/) and `modern`
161
+ // (src/sections/) are both supported — the analyzer scans src/ natively for
162
+ // modern layouts. Only genuinely ambiguous (`mixed`) or unrecognizable
163
+ // (`empty`) layouts abort with an actionable error before we touch files.
165
164
  const layout = detectSourceLayout(sourceDir);
166
- if (layout !== "classic") {
165
+ ctx.layout = layout;
166
+ if (layout === "mixed" || layout === "empty") {
167
167
  console.error(red(`Error: ${layout} source layout`));
168
168
  console.error("");
169
169
  console.error(explainNonClassicLayout(layout, sourceDir));
170
170
  process.exit(2);
171
171
  }
172
+ if (layout === "modern") {
173
+ console.log(` Source layout: modern (src/) — scanning src/ natively`);
174
+ }
172
175
 
173
176
  try {
174
177
  // Phase 1: Analyze source