@decocms/blocks-cli 7.46.1 → 7.48.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 +2 -2
- package/scripts/migrate/icon-casing.test.ts +63 -0
- package/scripts/migrate/phase-analyze.ts +9 -1
- package/scripts/migrate/phase-scaffold.ts +6 -1
- package/scripts/migrate/phase-transform.ts +74 -0
- package/scripts/migrate/source-layout.test.ts +43 -0
- package/scripts/migrate/source-layout.ts +32 -5
- package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
- package/scripts/migrate/transforms/jsx.ts +10 -0
- package/scripts/migrate/transforms/picture.test.ts +56 -0
- package/scripts/migrate/transforms/picture.ts +95 -0
- package/scripts/migrate/transforms/timer-types.test.ts +30 -0
- package/scripts/migrate/transforms/timer-types.ts +48 -0
- package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
- package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
- package/scripts/migrate/types.ts +7 -0
- package/scripts/migrate.ts +9 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.48.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.
|
|
35
|
+
"@decocms/blocks": "7.48.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|
package/scripts/migrate/types.ts
CHANGED
|
@@ -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,
|
package/scripts/migrate.ts
CHANGED
|
@@ -157,18 +157,21 @@ async function main() {
|
|
|
157
157
|
config: siteConfig,
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
-
// Phase 0: Source-layout detection (
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
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
|
-
|
|
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
|