@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.31.5",
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.5",
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: "Module-level signal .value reads without useStore()",
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
- if (moduleSignals.size === 0) return [];
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 useStore(name.store call in the same file.
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
- // Per-signal subscription check: useStore(name.store is the canonical
1747
- // subscription pattern. A file that subscribes to a different signal
1748
- // is not considered subscribed to this one.
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 useStore() — component will not re-render when the signal changes`,
1757
- fix: `Use useStore(${name}.store, (s) => s) to subscribe, or replace with useQuery / useMutation`,
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
- // Font preloads
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
- ${preconnects.join("\n")}
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")}
@@ -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: {},