@decocms/blocks-cli 7.31.5 → 7.31.7
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/phase-analyze.test.ts +63 -1
- package/scripts/migrate/phase-analyze.ts +58 -0
- package/scripts/migrate/phase-scaffold.ts +19 -1
- package/scripts/migrate/post-cleanup/rules.ts +27 -9
- package/scripts/migrate/templates/routes.ts +12 -4
- package/scripts/migrate/types.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.31.
|
|
3
|
+
"version": "7.31.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
|
|
6
6
|
"repository": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"lint:unused": "knip"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@decocms/blocks": "7.31.
|
|
34
|
+
"@decocms/blocks": "7.31.7",
|
|
35
35
|
"ts-morph": "^27.0.0",
|
|
36
36
|
"tsx": "^4.22.5"
|
|
37
37
|
},
|
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { extractPlatform } from "./phase-analyze";
|
|
5
|
+
import { extractGoogleFonts, extractPlatform } from "./phase-analyze";
|
|
6
6
|
|
|
7
7
|
// `extractPlatform` runs four strategies in order:
|
|
8
8
|
// 1. deno.json imports referencing apps/{platform}/
|
|
@@ -16,6 +16,68 @@ import { extractPlatform } from "./phase-analyze";
|
|
|
16
16
|
// list so Strategy 2 fires first and short-circuits before Strategy 3 can
|
|
17
17
|
// over-match.
|
|
18
18
|
|
|
19
|
+
describe("extractGoogleFonts", () => {
|
|
20
|
+
let tmp: string;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "extract-gfonts-"));
|
|
24
|
+
fs.mkdirSync(path.join(tmp, "routes"), { recursive: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("extracts preconnects and stylesheet from _app.tsx", () => {
|
|
32
|
+
fs.writeFileSync(
|
|
33
|
+
path.join(tmp, "routes", "_app.tsx"),
|
|
34
|
+
`<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
35
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
36
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" />`,
|
|
37
|
+
);
|
|
38
|
+
const result = extractGoogleFonts(tmp);
|
|
39
|
+
expect(result.preconnects).toEqual([
|
|
40
|
+
"https://fonts.googleapis.com",
|
|
41
|
+
"https://fonts.gstatic.com",
|
|
42
|
+
]);
|
|
43
|
+
expect(result.stylesheets).toEqual([
|
|
44
|
+
"https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap",
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("deduplicates URLs present in both _app.tsx and _layout.tsx", () => {
|
|
49
|
+
const sharedUrl = "https://fonts.googleapis.com/css2?family=Roboto&display=swap";
|
|
50
|
+
fs.writeFileSync(
|
|
51
|
+
path.join(tmp, "routes", "_app.tsx"),
|
|
52
|
+
`<link rel="stylesheet" href="${sharedUrl}" />`,
|
|
53
|
+
);
|
|
54
|
+
fs.writeFileSync(
|
|
55
|
+
path.join(tmp, "routes", "_layout.tsx"),
|
|
56
|
+
`<link rel="stylesheet" href="${sharedUrl}" />`,
|
|
57
|
+
);
|
|
58
|
+
const result = extractGoogleFonts(tmp);
|
|
59
|
+
expect(result.stylesheets).toHaveLength(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("extracts Google Fonts @import from tailwind.css", () => {
|
|
63
|
+
fs.writeFileSync(
|
|
64
|
+
path.join(tmp, "tailwind.css"),
|
|
65
|
+
`@import url('https://fonts.googleapis.com/css2?family=Poppins&display=swap');`,
|
|
66
|
+
);
|
|
67
|
+
const result = extractGoogleFonts(tmp);
|
|
68
|
+
expect(result.stylesheets).toEqual([
|
|
69
|
+
"https://fonts.googleapis.com/css2?family=Poppins&display=swap",
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns empty arrays when no Google Fonts are declared", () => {
|
|
74
|
+
fs.writeFileSync(path.join(tmp, "routes", "_app.tsx"), `<link rel="icon" href="/favicon.ico" />`);
|
|
75
|
+
const result = extractGoogleFonts(tmp);
|
|
76
|
+
expect(result.preconnects).toHaveLength(0);
|
|
77
|
+
expect(result.stylesheets).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
19
81
|
describe("extractPlatform", () => {
|
|
20
82
|
let tmp: string;
|
|
21
83
|
|
|
@@ -418,6 +418,50 @@ function scanDir(
|
|
|
418
418
|
}
|
|
419
419
|
}
|
|
420
420
|
|
|
421
|
+
export function extractGoogleFonts(
|
|
422
|
+
sourceDir: string,
|
|
423
|
+
): { preconnects: string[]; stylesheets: string[] } {
|
|
424
|
+
const preconnects: string[] = [];
|
|
425
|
+
const stylesheets: string[] = [];
|
|
426
|
+
const seen = new Set<string>();
|
|
427
|
+
|
|
428
|
+
const hrefRe = /href=["'](https:\/\/fonts\.(?:googleapis|gstatic)\.com[^"']*)["']/g;
|
|
429
|
+
|
|
430
|
+
// Read _app.tsx and _layout.tsx for <link> declarations
|
|
431
|
+
for (const candidate of ["routes/_app.tsx", "routes/_layout.tsx"]) {
|
|
432
|
+
const fp = path.join(sourceDir, candidate);
|
|
433
|
+
if (!fs.existsSync(fp)) continue;
|
|
434
|
+
const content = fs.readFileSync(fp, "utf-8");
|
|
435
|
+
for (const m of content.matchAll(hrefRe)) {
|
|
436
|
+
const href = m[1];
|
|
437
|
+
if (seen.has(href)) continue;
|
|
438
|
+
seen.add(href);
|
|
439
|
+
if (href.includes("/css")) {
|
|
440
|
+
stylesheets.push(href);
|
|
441
|
+
} else {
|
|
442
|
+
preconnects.push(href);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Also check tailwind.css for @import url(https://fonts.googleapis.com/...)
|
|
448
|
+
for (const cssCandidate of ["tailwind.css", "styles/app.css", "styles/global.css"]) {
|
|
449
|
+
const fp = path.join(sourceDir, cssCandidate);
|
|
450
|
+
if (!fs.existsSync(fp)) continue;
|
|
451
|
+
const content = fs.readFileSync(fp, "utf-8");
|
|
452
|
+
const importRe = /@import\s+url\(['"]?(https:\/\/fonts\.googleapis\.com[^'")\s]+)/g;
|
|
453
|
+
for (const m of content.matchAll(importRe)) {
|
|
454
|
+
const href = m[1];
|
|
455
|
+
if (!seen.has(href)) {
|
|
456
|
+
seen.add(href);
|
|
457
|
+
stylesheets.push(href);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return { preconnects, stylesheets };
|
|
463
|
+
}
|
|
464
|
+
|
|
421
465
|
function extractGtmId(sourceDir: string): string | null {
|
|
422
466
|
const appPath = path.join(sourceDir, "routes", "_app.tsx");
|
|
423
467
|
if (!fs.existsSync(appPath)) return null;
|
|
@@ -627,6 +671,20 @@ export function analyze(ctx: MigrationContext): void {
|
|
|
627
671
|
ctx.platform = extractPlatform(ctx.sourceDir);
|
|
628
672
|
ctx.vtexAccount = ctx.platform === "vtex" ? extractVtexAccount(ctx.sourceDir) : null;
|
|
629
673
|
ctx.gtmId = extractGtmId(ctx.sourceDir);
|
|
674
|
+
ctx.googleFonts = extractGoogleFonts(ctx.sourceDir);
|
|
675
|
+
// If no font URLs were extracted but the file mentions googleapis (likely a
|
|
676
|
+
// JSX expression like href={URL_CONST}), flag it for manual review.
|
|
677
|
+
if (ctx.googleFonts.preconnects.length === 0 && ctx.googleFonts.stylesheets.length === 0) {
|
|
678
|
+
const appPath = path.join(ctx.sourceDir, "routes", "_app.tsx");
|
|
679
|
+
if (fs.existsSync(appPath) && fs.readFileSync(appPath, "utf-8").includes("fonts.googleapis")) {
|
|
680
|
+
ctx.manualReviewItems.push({
|
|
681
|
+
file: "routes/_app.tsx",
|
|
682
|
+
reason:
|
|
683
|
+
"Google Fonts reference found but could not be extracted automatically (may use a JSX expression or variable). Add the font preconnect and stylesheet links manually to src/routes/__root.tsx.",
|
|
684
|
+
severity: "warning",
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
630
688
|
|
|
631
689
|
// Extract theme colors and font from CMS
|
|
632
690
|
const theme = extractThemeFromCms(ctx.sourceDir);
|
|
@@ -341,7 +341,7 @@ export default debounce;
|
|
|
341
341
|
}
|
|
342
342
|
|
|
343
343
|
function generateSignalShim(): string {
|
|
344
|
-
return `import { useState, useRef, useEffect, useMemo, useCallback } from "react";
|
|
344
|
+
return `import { useState, useRef, useEffect, useMemo, useCallback, useSyncExternalStore } from "react";
|
|
345
345
|
export { signal, type ReactiveSignal } from "@decocms/blocks/sdk/signal";
|
|
346
346
|
|
|
347
347
|
/** Run a function immediately. Kept for legacy module-level side effects. */
|
|
@@ -387,6 +387,24 @@ export function useComputed<T>(compute: () => T): { readonly value: T } {
|
|
|
387
387
|
export function useSignalEffect(cb: () => void | (() => void)): void {
|
|
388
388
|
useEffect(cb);
|
|
389
389
|
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Subscribe to a ReactiveSignal and re-render when it changes.
|
|
393
|
+
* Required for signals from useUI() (displayCart, displayMenu, etc.) —
|
|
394
|
+
* reading .value directly in render is a dead read in React and freezes the UI.
|
|
395
|
+
*
|
|
396
|
+
* @example
|
|
397
|
+
* const { displayCart } = useUI();
|
|
398
|
+
* const open = useSignalValue(displayCart);
|
|
399
|
+
* return <Drawer open={open} />;
|
|
400
|
+
*/
|
|
401
|
+
export function useSignalValue<T>(sig: ReactiveSignal<T>): T {
|
|
402
|
+
return useSyncExternalStore(
|
|
403
|
+
(cb) => sig.subscribe(cb),
|
|
404
|
+
() => sig.value,
|
|
405
|
+
() => sig.value,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
390
408
|
`;
|
|
391
409
|
}
|
|
392
410
|
|
|
@@ -1702,9 +1702,21 @@ const ruleVtexProxyHandlerMissing: Rule = {
|
|
|
1702
1702
|
const MODULE_SIGNAL_RE =
|
|
1703
1703
|
/^(?:export\s+)?const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:\w+\.)?signal\s*\(/m;
|
|
1704
1704
|
|
|
1705
|
+
// Well-known signals returned by useUI(). These are never declared with
|
|
1706
|
+
// `const x = signal(...)` at module scope — they come from a hook — so
|
|
1707
|
+
// MODULE_SIGNAL_RE won't catch them. The list is stable: it's the public
|
|
1708
|
+
// API surface of useUI() across all deco storefronts.
|
|
1709
|
+
const WELL_KNOWN_UI_SIGNALS = new Set([
|
|
1710
|
+
"displayCart",
|
|
1711
|
+
"displayMenu",
|
|
1712
|
+
"displayNav",
|
|
1713
|
+
"displaySearchbar",
|
|
1714
|
+
"displaySearchPopup",
|
|
1715
|
+
]);
|
|
1716
|
+
|
|
1705
1717
|
const ruleSignalValueReads: Rule = {
|
|
1706
1718
|
id: "signal-value-reads",
|
|
1707
|
-
title: "
|
|
1719
|
+
title: "Signal .value reads without subscription (freezes UI)",
|
|
1708
1720
|
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1709
1721
|
const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
|
|
1710
1722
|
|
|
@@ -1724,7 +1736,13 @@ const ruleSignalValueReads: Rule = {
|
|
|
1724
1736
|
}
|
|
1725
1737
|
}
|
|
1726
1738
|
|
|
1727
|
-
|
|
1739
|
+
// Also check well-known useUI() signals — they never appear as module-level
|
|
1740
|
+
// declarations, so MODULE_SIGNAL_RE never picks them up.
|
|
1741
|
+
for (const name of WELL_KNOWN_UI_SIGNALS) {
|
|
1742
|
+
if (!moduleSignals.has(name)) {
|
|
1743
|
+
moduleSignals.set(name, "useUI()");
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1728
1746
|
|
|
1729
1747
|
// Pre-compile per-signal regexes once — avoids N×M compilations in Pass 2.
|
|
1730
1748
|
const valueReMap = new Map<string, RegExp>(
|
|
@@ -1732,7 +1750,8 @@ const ruleSignalValueReads: Rule = {
|
|
|
1732
1750
|
);
|
|
1733
1751
|
|
|
1734
1752
|
// Pass 2: for each .tsx file, check per-signal whether .value is read
|
|
1735
|
-
// without a matching
|
|
1753
|
+
// without a matching subscription in the same file.
|
|
1754
|
+
// Accepts either useStore(name.store or useSignalValue(name as subscriptions.
|
|
1736
1755
|
const tsxFiles = tsFiles.filter((f) => f.endsWith(".tsx"));
|
|
1737
1756
|
const findings: Finding[] = [];
|
|
1738
1757
|
|
|
@@ -1743,18 +1762,17 @@ const ruleSignalValueReads: Rule = {
|
|
|
1743
1762
|
for (const [name, declaredIn] of moduleSignals) {
|
|
1744
1763
|
const valueRe = valueReMap.get(name)!;
|
|
1745
1764
|
if (!valueRe.test(content)) continue;
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
const subscribedRe = new RegExp(`\\buseStore\\s*\\(\\s*${name}\\.store\\b`);
|
|
1765
|
+
const subscribedRe = new RegExp(
|
|
1766
|
+
`\\buseStore\\s*\\(\\s*${name}\\.store\\b|\\buseSignalValue\\s*\\(\\s*${name}\\b`,
|
|
1767
|
+
);
|
|
1750
1768
|
if (subscribedRe.test(content)) continue;
|
|
1751
1769
|
|
|
1752
1770
|
findings.push({
|
|
1753
1771
|
rule: "signal-value-reads",
|
|
1754
1772
|
severity: "warning",
|
|
1755
1773
|
file: rel,
|
|
1756
|
-
message: `'${name}.value' read without
|
|
1757
|
-
fix: `
|
|
1774
|
+
message: `'${name}.value' read without subscription — component will not re-render when the signal changes`,
|
|
1775
|
+
fix: `Replace with \`const ${name}Value = useSignalValue(${name})\` (from ~/sdk/signal) or \`useStore(${name}.store)\``,
|
|
1758
1776
|
meta: { signalName: name, declaredIn },
|
|
1759
1777
|
});
|
|
1760
1778
|
}
|
|
@@ -51,17 +51,26 @@ function generateRoot(ctx: MigrationContext, siteTitle: string, vtexAccount: str
|
|
|
51
51
|
preconnects.push(` { rel: "preconnect", href: "https://${vtexAccount}.vtexassets.com", crossOrigin: "anonymous" as const },`);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
const googleFontLinks = [
|
|
55
|
+
...ctx.googleFonts.preconnects.map((href) =>
|
|
56
|
+
` { rel: "preconnect", href: "${href}"${href.includes("gstatic") ? ', crossOrigin: "anonymous" as const' : ""} },`
|
|
57
|
+
),
|
|
58
|
+
...ctx.googleFonts.stylesheets.map((href) =>
|
|
59
|
+
` { rel: "stylesheet", href: "${href}" },`
|
|
60
|
+
),
|
|
61
|
+
];
|
|
62
|
+
|
|
55
63
|
const fontPreloads = fonts.map((f) =>
|
|
56
64
|
` { rel: "preload", href: "${f}", as: "font", type: "font/woff2", crossOrigin: "anonymous" as const },`
|
|
57
65
|
);
|
|
58
66
|
|
|
59
|
-
// DNS prefetch for common third-party services
|
|
60
67
|
const dnsPrefetch: string[] = [];
|
|
61
68
|
if (isVtex) {
|
|
62
69
|
dnsPrefetch.push(` { rel: "dns-prefetch", href: "https://sp.vtex.com" },`);
|
|
63
70
|
}
|
|
64
71
|
|
|
72
|
+
const linkLines = [...preconnects, ...googleFontLinks, ...fontPreloads].filter(Boolean);
|
|
73
|
+
|
|
65
74
|
return `import { createRootRoute } from "@tanstack/react-router";
|
|
66
75
|
import { DecoRootLayout } from "@decocms/tanstack";
|
|
67
76
|
// @ts-ignore Vite ?url import
|
|
@@ -83,8 +92,7 @@ export const Route = createRootRoute({
|
|
|
83
92
|
{ property: "og:locale", content: "pt_BR" },
|
|
84
93
|
],
|
|
85
94
|
links: [
|
|
86
|
-
${
|
|
87
|
-
${fontPreloads.join("\n")}
|
|
95
|
+
${linkLines.join("\n")}
|
|
88
96
|
{ rel: "stylesheet", href: appCss },
|
|
89
97
|
{ rel: "icon", href: "/favicon.ico" },
|
|
90
98
|
${dnsPrefetch.join("\n")}
|
package/scripts/migrate/types.ts
CHANGED
|
@@ -156,6 +156,8 @@ export interface MigrationContext {
|
|
|
156
156
|
themeColors: Record<string, string>;
|
|
157
157
|
/** Font family from CMS config */
|
|
158
158
|
fontFamily: string | null;
|
|
159
|
+
/** Google Fonts URLs extracted from routes/_app.tsx or CSS @import */
|
|
160
|
+
googleFonts: { preconnects: string[]; stylesheets: string[] };
|
|
159
161
|
|
|
160
162
|
/**
|
|
161
163
|
* Statically-extracted content of the source site's `tailwind.config.ts`
|
|
@@ -226,6 +228,7 @@ export function createContext(
|
|
|
226
228
|
discoveredNpmDeps: {},
|
|
227
229
|
themeColors: {},
|
|
228
230
|
fontFamily: null,
|
|
231
|
+
googleFonts: { preconnects: [], stylesheets: [] },
|
|
229
232
|
tailwindConfig: {
|
|
230
233
|
colors: {},
|
|
231
234
|
fontFamily: {},
|