@decocms/blocks-cli 8.0.0 → 8.1.0-next.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.
Files changed (100) hide show
  1. package/package.json +5 -3
  2. package/scripts/analyze-traces.mjs +1 -0
  3. package/scripts/cdn-rules.test.ts +105 -0
  4. package/scripts/cdn-rules.ts +192 -0
  5. package/scripts/deco-migrate-cli.ts +6 -7
  6. package/scripts/fast-deploy-kv.test.ts +172 -0
  7. package/scripts/generate-blocks.test.ts +11 -0
  8. package/scripts/generate-blocks.ts +0 -10
  9. package/scripts/generate-invoke.test.ts +2 -2
  10. package/scripts/generate-invoke.ts +9 -21
  11. package/scripts/generate-loaders.test.ts +2 -2
  12. package/scripts/generate-loaders.ts +0 -10
  13. package/scripts/generate-schema-loader-aliases.test.ts +186 -0
  14. package/scripts/generate-schema-workspaces.test.ts +180 -0
  15. package/scripts/generate-schema.test.ts +68 -29
  16. package/scripts/generate-schema.ts +101 -89
  17. package/scripts/generate-sections.test.ts +104 -35
  18. package/scripts/generate-sections.ts +57 -11
  19. package/scripts/generate-workspaces.test.ts +179 -0
  20. package/scripts/generate.test.ts +1 -2
  21. package/scripts/generate.ts +43 -45
  22. package/scripts/lib/blocks-dedupe.test.ts +1 -1
  23. package/scripts/lib/cf-kv-rest.ts +49 -1
  24. package/scripts/lib/installedPackages.ts +42 -0
  25. package/scripts/lib/invokeSource.ts +18 -0
  26. package/scripts/lib/kv-snapshot.ts +44 -0
  27. package/scripts/lib/read-decofile.ts +13 -2
  28. package/scripts/lib/upgrade-6-to-7.ts +1 -1
  29. package/scripts/lib/wrangler-config.test.ts +49 -0
  30. package/scripts/lib/wrangler-config.ts +31 -0
  31. package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
  32. package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
  33. package/scripts/migrate/config.ts +2 -2
  34. package/scripts/migrate/delete-sets.ts +75 -0
  35. package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
  36. package/scripts/migrate/icon-casing.test.ts +63 -0
  37. package/scripts/migrate/phase-analyze.test.ts +66 -4
  38. package/scripts/migrate/phase-analyze.ts +79 -60
  39. package/scripts/migrate/phase-cleanup.test.ts +3 -1
  40. package/scripts/migrate/phase-cleanup.ts +1 -1
  41. package/scripts/migrate/phase-compile.test.ts +3 -1
  42. package/scripts/migrate/phase-report.test.ts +2 -0
  43. package/scripts/migrate/phase-report.ts +3 -4
  44. package/scripts/migrate/phase-scaffold.ts +150 -45
  45. package/scripts/migrate/phase-transform.ts +107 -2
  46. package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
  47. package/scripts/migrate/phase-verify.test.ts +3 -1
  48. package/scripts/migrate/phase-verify.ts +87 -33
  49. package/scripts/migrate/post-cleanup/rules.ts +29 -11
  50. package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
  51. package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
  52. package/scripts/migrate/source-layout.test.ts +43 -0
  53. package/scripts/migrate/source-layout.ts +32 -5
  54. package/scripts/migrate/templates/app-css.test.ts +24 -0
  55. package/scripts/migrate/templates/app-css.ts +27 -0
  56. package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
  57. package/scripts/migrate/templates/ci-yml.ts +182 -0
  58. package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
  59. package/scripts/migrate/templates/hooks.test.ts +3 -1
  60. package/scripts/migrate/templates/lib-utils.ts +3 -3
  61. package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
  62. package/scripts/migrate/templates/package-json.ts +10 -0
  63. package/scripts/migrate/templates/parity-yml.ts +136 -0
  64. package/scripts/migrate/templates/perf-yml.ts +449 -0
  65. package/scripts/migrate/templates/playwright-yml.ts +127 -0
  66. package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
  67. package/scripts/migrate/templates/routes.test.ts +31 -0
  68. package/scripts/migrate/templates/routes.ts +19 -9
  69. package/scripts/migrate/templates/section-loaders.ts +3 -3
  70. package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
  71. package/scripts/migrate/templates/server-entry.ts +19 -14
  72. package/scripts/migrate/templates/setup.ts +11 -3
  73. package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
  74. package/scripts/migrate/templates/ui-components.ts +3 -0
  75. package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
  76. package/scripts/migrate/transforms/ctx-compat.ts +93 -1
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
  78. package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
  79. package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
  80. package/scripts/migrate/transforms/jsx.ts +10 -0
  81. package/scripts/migrate/transforms/picture.test.ts +56 -0
  82. package/scripts/migrate/transforms/picture.ts +95 -0
  83. package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
  84. package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
  85. package/scripts/migrate/transforms/timer-types.test.ts +30 -0
  86. package/scripts/migrate/transforms/timer-types.ts +48 -0
  87. package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
  88. package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
  89. package/scripts/migrate/types.ts +13 -1
  90. package/scripts/migrate-blocks-to-kv.ts +16 -2
  91. package/scripts/migrate-to-cf-observability.test.ts +5 -5
  92. package/scripts/migrate-to-cf-observability.ts +1 -1
  93. package/scripts/migrate.ts +244 -6
  94. package/scripts/reconcile.test.ts +162 -0
  95. package/scripts/reconcile.ts +303 -0
  96. package/scripts/sync-blocks-bot.test.ts +276 -0
  97. package/scripts/sync-blocks-bot.ts +567 -0
  98. package/scripts/sync-blocks-to-kv.ts +20 -2
  99. package/scripts/upgrade-6-to-7.ts +0 -0
  100. package/scripts/lib/legacyArtifact.ts +0 -20
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Scaffolds the E2E harness and its config/smoke companions:
3
+ * .github/workflows/playwright.yml — GitHub Actions workflow (chromium + webkit)
4
+ * playwright.config.ts — self-contained config (no support helpers)
5
+ * tests/e2e/smoke.spec.ts — harness smoke, no app boot, no network
6
+ *
7
+ * Design rationale:
8
+ * - The smoke proves the Playwright harness runs in CI (chromium + webkit)
9
+ * without the app booting or hitting the network — it renders inline HTML
10
+ * via page.setContent. Real storefront specs (page.goto("/")) need a VCR
11
+ * layer so CI does not hit the commerce API; add a `webServer` block to the
12
+ * config and specs that navigate the app once that harness exists.
13
+ * - webkit is included because the storefront audience is majority iOS Safari.
14
+ * - The workflow needs `@playwright/test` + the `test:e2e` script, both in the
15
+ * scaffolded package.json.
16
+ *
17
+ * @param bunVersion Bun version for setup-bun (= CANONICAL_BUN_VERSION).
18
+ */
19
+ export function generatePlaywrightFiles(bunVersion: string): Record<string, string> {
20
+ const bun = bunVersion.replace(/^bun@/, "");
21
+ return {
22
+ ".github/workflows/playwright.yml": generatePlaywrightYml(bun),
23
+ "playwright.config.ts": PLAYWRIGHT_CONFIG,
24
+ "tests/e2e/smoke.spec.ts": SMOKE_SPEC,
25
+ };
26
+ }
27
+
28
+ function generatePlaywrightYml(bunVersion: string): string {
29
+ return `name: Playwright
30
+
31
+ # Functional E2E (chromium + webkit). Today only a harness smoke that renders
32
+ # inline HTML — no app boot, no network — so it runs on a plain ubuntu runner.
33
+ # When specs navigate the real app (via VCR), pin the container and add a
34
+ # webServer block to playwright.config.ts.
35
+
36
+ on:
37
+ pull_request:
38
+ push:
39
+ branches: [main]
40
+
41
+ permissions:
42
+ contents: read
43
+
44
+ concurrency:
45
+ group: playwright-\${{ github.workflow }}-\${{ github.ref }}
46
+ cancel-in-progress: true
47
+
48
+ jobs:
49
+ e2e:
50
+ runs-on: ubuntu-latest
51
+ steps:
52
+ - uses: actions/checkout@v4
53
+
54
+ - uses: oven-sh/setup-bun@v2
55
+ with:
56
+ bun-version: "${bunVersion}"
57
+
58
+ - name: bun install --frozen-lockfile
59
+ run: bun install --frozen-lockfile
60
+
61
+ - name: Install browsers (chromium + webkit)
62
+ run: bunx playwright install --with-deps chromium webkit
63
+
64
+ - name: E2E
65
+ run: bun run test:e2e
66
+
67
+ - name: Upload HTML report
68
+ if: \${{ !cancelled() }}
69
+ uses: actions/upload-artifact@v4
70
+ with:
71
+ name: playwright-report
72
+ path: playwright-report/
73
+ retention-days: 14
74
+ `;
75
+ }
76
+
77
+ const PLAYWRIGHT_CONFIG = `/**
78
+ * Playwright config — E2E base.
79
+ *
80
+ * Today only a harness smoke (tests/e2e/smoke.spec.ts) that renders inline HTML,
81
+ * proving the harness runs in CI without the app booting or hitting the network.
82
+ * Real app E2E (page.goto("/")) needs a VCR layer so CI does not call the
83
+ * commerce API — add a \`webServer\` block (e.g. { command: "bun run build && bun
84
+ * run preview", url: "http://localhost:4173" }) and the navigating specs then.
85
+ *
86
+ * chromium + webkit (webkit because the storefront audience is majority iOS
87
+ * Safari). 1 worker, 0 retries on purpose: retry masks flake instead of exposing it.
88
+ */
89
+
90
+ import { defineConfig, devices } from "@playwright/test";
91
+
92
+ export default defineConfig({
93
+ testDir: "./tests/e2e",
94
+ fullyParallel: true,
95
+ forbidOnly: !!process.env.CI,
96
+ retries: 0,
97
+ workers: 1,
98
+ reporter: [["html", { open: "never" }], ["list"]],
99
+ use: {
100
+ trace: "retain-on-failure",
101
+ screenshot: "only-on-failure",
102
+ },
103
+ projects: [
104
+ { name: "chromium", use: { ...devices["Desktop Chrome"] } },
105
+ { name: "webkit", use: { ...devices["Desktop Safari"] } },
106
+ ],
107
+ });
108
+ `;
109
+
110
+ const SMOKE_SPEC = `/**
111
+ * Smoke E2E — proves the Playwright harness runs (chromium + webkit) in CI
112
+ * without the app booting or hitting the network. Does not test the real app;
113
+ * the storefront E2E (page.goto("/")) comes with the VCR + cassettes harness.
114
+ */
115
+
116
+ import { expect, test } from "@playwright/test";
117
+
118
+ test("harness renders inline content", async ({ page }) => {
119
+ await page.setContent(
120
+ '<title>e2e smoke</title><h1>ok</h1><div data-testid="marker">ready</div>',
121
+ );
122
+
123
+ await expect(page).toHaveTitle("e2e smoke");
124
+ await expect(page.getByRole("heading", { name: "ok" })).toBeVisible();
125
+ await expect(page.getByTestId("marker")).toHaveText("ready");
126
+ });
127
+ `;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Scaffolds `.github/workflows/react-doctor.yml` — flags React
3
+ * security/perf/correctness/a11y/bundle-size/architecture issues on PRs.
4
+ *
5
+ * Advisory by construction (the action's default: comments, never fails the
6
+ * build). Separate workflow from ci.yml on purpose — zero coupling with the
7
+ * blocking gates. `fetch-depth: 0` gives the merge-base so it reports only what
8
+ * the PR introduces.
9
+ *
10
+ * Fully generic — no per-site parameters. Docs: https://www.react.doctor/ci
11
+ */
12
+ export function generateReactDoctorYml(): string {
13
+ return `name: React Doctor
14
+
15
+ # Flags React security/perf/correctness/a11y/bundle-size/architecture issues.
16
+ # Advisory by construction (comments, never fails the check). To make it a hard
17
+ # gate, add \`with: { blocking: error }\` to the action step below.
18
+
19
+ on:
20
+ pull_request:
21
+ types: [opened, synchronize, reopened, ready_for_review]
22
+ push:
23
+ branches: [main]
24
+
25
+ permissions:
26
+ contents: read
27
+ pull-requests: write
28
+ issues: write
29
+ statuses: write
30
+
31
+ concurrency:
32
+ group: react-doctor-\${{ github.event.pull_request.number || github.ref }}
33
+ cancel-in-progress: true
34
+
35
+ jobs:
36
+ react-doctor:
37
+ runs-on: ubuntu-latest
38
+ steps:
39
+ # fetch-depth: 0 gives the merge-base so it reports only PR-introduced findings.
40
+ - uses: actions/checkout@v4
41
+ with:
42
+ fetch-depth: 0
43
+
44
+ - uses: millionco/react-doctor@v2
45
+ `;
46
+ }
@@ -57,3 +57,34 @@ describe("scaffolded deco admin routes use HMR-safe factories", () => {
57
57
  });
58
58
  }
59
59
  });
60
+
61
+ /**
62
+ * Regression guard: the page renderers must wire `loadDeferredSectionFn` to the
63
+ * `deferredSectionLoader` shim, never the raw `loadDeferredSection` server fn.
64
+ * `loadDeferredSection` is a createServerFn with an inputValidator, so it must
65
+ * be called as `fn({ data })`; DeferredSectionWrapper calls `loadFn(bareArgs)`,
66
+ * so passing the raw fn leaves `ctx.data` undefined and the deferred section
67
+ * renders blank. The shim wraps the `{ data }` envelope.
68
+ */
69
+ describe("scaffolded page routes wire the deferred-loader shim, not the raw server fn", () => {
70
+ for (const platform of ["vtex", "custom"] as const) {
71
+ describe(`platform: ${platform}`, () => {
72
+ const files = generateRoutes(makeCtx(platform));
73
+
74
+ for (const file of ["src/routes/index.tsx", "src/routes/$.tsx"] as const) {
75
+ it(`${file} passes deferredSectionLoader from the sdk subpath`, () => {
76
+ const content = files[file];
77
+ expect(content, `${file} must be emitted`).toBeTypeOf("string");
78
+
79
+ expect(content).toContain(
80
+ 'import { deferredSectionLoader } from "@decocms/tanstack/sdk/deferredSectionLoader"',
81
+ );
82
+ expect(content).toContain("loadDeferredSectionFn={deferredSectionLoader}");
83
+
84
+ // The broken form: the raw server fn passed directly.
85
+ expect(content).not.toContain("loadDeferredSectionFn={loadDeferredSection}");
86
+ });
87
+ }
88
+ });
89
+ }
90
+ });
@@ -46,22 +46,31 @@ function generateRoot(ctx: MigrationContext, siteTitle: string, vtexAccount: str
46
46
 
47
47
  // Build preconnect list based on platform
48
48
  const preconnects: string[] = [];
49
- preconnects.push(` { rel: "preconnect", href: "https://decoims.com", crossOrigin: "anonymous" as const },`);
49
+ preconnects.push(` { rel: "preconnect", href: "https://assets.decocms.com", crossOrigin: "anonymous" as const },`);
50
50
  if (isVtex) {
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")}
@@ -107,7 +115,8 @@ function RootLayout() {
107
115
 
108
116
  function generateIndex(ctx: MigrationContext, siteTitle: string): string {
109
117
  return `import { createFileRoute } from "@tanstack/react-router";
110
- import { cmsHomeRouteConfig, DecoPageRenderer, loadDeferredSection } from "@decocms/tanstack";
118
+ import { cmsHomeRouteConfig, DecoPageRenderer } from "@decocms/tanstack";
119
+ import { deferredSectionLoader } from "@decocms/tanstack/sdk/deferredSectionLoader";
111
120
 
112
121
  // MIGRATION TODO: customize defaultTitle / defaultDescription / fallback
113
122
  // copy below for ${siteTitle}. CMS \`Site.seo\` overrides these once block
@@ -143,7 +152,7 @@ function HomePage() {
143
152
  deferredPromises={data.deferredPromises}
144
153
  pagePath={data.pagePath}
145
154
  pageUrl={data.pageUrl}
146
- loadDeferredSectionFn={loadDeferredSection}
155
+ loadDeferredSectionFn={deferredSectionLoader}
147
156
  />
148
157
  );
149
158
  }
@@ -152,7 +161,8 @@ function HomePage() {
152
161
 
153
162
  function generateCatchAll(ctx: MigrationContext, siteTitle: string): string {
154
163
  return `import { createFileRoute } from "@tanstack/react-router";
155
- import { cmsRouteConfig, DecoPageRenderer, loadDeferredSection } from "@decocms/tanstack";
164
+ import { cmsRouteConfig, DecoPageRenderer } from "@decocms/tanstack";
165
+ import { deferredSectionLoader } from "@decocms/tanstack/sdk/deferredSectionLoader";
156
166
 
157
167
  // MIGRATION TODO: customize defaultTitle / defaultDescription for ${siteTitle}
158
168
  // (CMS \`Site.seo\` overrides these per-page once block resolution kicks in).
@@ -180,7 +190,7 @@ function CmsPage() {
180
190
  deferredPromises={data.deferredPromises}
181
191
  pagePath={data.pagePath}
182
192
  pageUrl={data.pageUrl}
183
- loadDeferredSectionFn={loadDeferredSection}
193
+ loadDeferredSectionFn={deferredSectionLoader}
184
194
  />
185
195
  );
186
196
  }
@@ -139,8 +139,8 @@ export function generateSectionLoaders(ctx: MigrationContext): string {
139
139
  // returned.
140
140
  //
141
141
  // The previous template chose mixins XOR own-loader and silently dropped
142
- // the section's loader when both were present — see als-tanstack
143
- // SearchContainerV2 SSR regression.
142
+ // the section's loader when both were present — see a production
143
+ // storefront's SearchContainer SSR regression.
144
144
  for (const meta of ctx.sectionMetas) {
145
145
  if (meta.isHeader || meta.isAccountSection || meta.isStatusOnly) continue;
146
146
  // Skip sections with no loader AND no device needs
@@ -418,7 +418,7 @@ export function generateSectionLoaders(ctx: MigrationContext): string {
418
418
  // ---------- Privacy cookie check ----------
419
419
  if (isVtex && hasPrivacyPolice) {
420
420
  entries.push(``);
421
- const vtexAccount = ctx.vtexAccount || "casaevideonewio";
421
+ const vtexAccount = ctx.vtexAccount || "acme";
422
422
  entries.push(` "site/sections/Account/PrivacyPolice.tsx": (props: any, req) => {`);
423
423
  entries.push(` const cookies = req.headers.get("cookie") ?? "";`);
424
424
  entries.push(` const logged = cookies.includes("VtexIdclientAutCookie_${vtexAccount}");`);
@@ -49,3 +49,22 @@ describe("VTEX invoke scaffold wires vtex.loaders + wishlist actions (#368)", ()
49
49
  expect(invokeSrc).toContain("parseVtexAuthJwt");
50
50
  });
51
51
  });
52
+
53
+ describe("scaffolds ?renderJson / ?asJson OFF by default (opt-in)", () => {
54
+ const workerKey = "src/worker-entry.ts";
55
+
56
+ it("VTEX worker-entry disables both page-as-JSON endpoints", () => {
57
+ const worker = generateServerEntry(makeVtexCtx())[workerKey];
58
+ expect(worker).toContain("renderJson: false");
59
+ expect(worker).toContain("asJson: false");
60
+ });
61
+
62
+ it("non-VTEX worker-entry disables both too", () => {
63
+ const ctx = createContext("/tmp/custom-scaffold-fixture-site");
64
+ ctx.siteName = "acme-custom";
65
+ ctx.platform = "custom";
66
+ const worker = generateServerEntry(ctx)[workerKey];
67
+ expect(worker).toContain("renderJson: false");
68
+ expect(worker).toContain("asJson: false");
69
+ });
70
+ });
@@ -86,6 +86,11 @@ ${isCommerce ? `
86
86
  const serverEntry = createServerEntry({ fetch: handler.fetch });
87
87
 
88
88
  const decoWorker = createDecoWorkerEntry(serverEntry, {
89
+ // Page-as-JSON endpoints are OFF by default — opt in when you build a mobile
90
+ // app that consumes the storefront (see the renderJson docs). ?asJson is the
91
+ // legacy raw path; ?renderJson is the lean per-section one.
92
+ renderJson: false,
93
+ asJson: false,
89
94
  admin: {
90
95
  handleMeta,
91
96
  handleDecofileRead,
@@ -93,14 +98,10 @@ const decoWorker = createDecoWorkerEntry(serverEntry, {
93
98
  handleRender,
94
99
  corsHeaders,
95
100
  },
96
- // Region splits the cache so a RJ-cached response isn't served to SP visitors
97
- // when pages use the website/matchers/location.ts matcher. Without this, the
98
- // first geo-resolved response leaks across regions.
99
- buildSegment: (request) => {
100
- const cf = (request as unknown as { cf?: { regionCode?: string } }).cf;
101
- const regionCode = request.headers.get("cf-region-code") ?? cf?.regionCode ?? "";
102
- return regionCode ? { regionId: regionCode } : {};
103
- },
101
+ // No geo in buildSegment: createDecoWorkerEntry's geoCacheKey: "auto" already
102
+ // backfills the region into the segment — but only when the decofile has a
103
+ // website/matchers/location.ts block. Setting it here unconditionally split
104
+ // every page's cache per state (~27 copies per URL in Brazil) for nothing.
104
105
  });
105
106
 
106
107
  export default instrumentWorker(decoWorker, {
@@ -161,19 +162,23 @@ const serverEntry = createServerEntry({ fetch: handler.fetch });
161
162
  const MOBILE_RE = /mobile|android|iphone/i;
162
163
 
163
164
  const decoWorker = createDecoWorkerEntry(serverEntry, {
165
+ // Page-as-JSON endpoints are OFF by default — opt in when you build a mobile
166
+ // app that consumes the storefront (see the renderJson docs). ?asJson is the
167
+ // legacy raw path; ?renderJson is the lean per-section one.
168
+ renderJson: false,
169
+ asJson: false,
164
170
  csp: CSP_DIRECTIVES,
165
171
  buildSegment: (request) => {
166
172
  const vtx = extractVtexContext(request);
167
- const cf = (request as unknown as { cf?: { regionCode?: string } }).cf;
168
- const geoRegion = request.headers.get("cf-region-code") ?? cf?.regionCode ?? "";
169
173
  return {
170
174
  device: MOBILE_RE.test(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop",
171
175
  loggedIn: vtx.isLoggedIn,
172
176
  salesChannel: vtx.salesChannel,
173
- // Prefer VTEX regionalization regionId when present; otherwise fall back
174
- // to Cloudflare geo so the website/matchers/location.ts matcher gets a
175
- // properly segmented cache.
176
- regionId: (vtx as any).regionId ?? (geoRegion || undefined),
177
+ // VTEX regionalization only. Don't fall back to Cloudflare geo here:
178
+ // geoCacheKey: "auto" adds the region only when a
179
+ // website/matchers/location.ts block exists; doing it unconditionally
180
+ // splits every page's cache per state.
181
+ regionId: (vtx as any).regionId,
177
182
  };
178
183
  },
179
184
  admin: {
@@ -11,7 +11,7 @@ function discoverFonts(ctx: MigrationContext): string[] {
11
11
  const staticFonts = path.join(ctx.sourceDir, "static", "fonts");
12
12
  if (fs.existsSync(staticFonts)) return scanFontDir(staticFonts);
13
13
 
14
- // Check static-*/fonts/ (multi-brand sites like casaevideo)
14
+ // Check static-*/fonts/ (multi-brand sites)
15
15
  try {
16
16
  const entries = fs.readdirSync(ctx.sourceDir, { withFileTypes: true });
17
17
  for (const e of entries) {
@@ -103,8 +103,8 @@ import { createInstrumentedFetch } from "@decocms/blocks/sdk/instrumentedFetch";
103
103
  import { initVtexFromBlocks, setVtexFetch } from "@decocms/apps-vtex";` : ""}${hasLocationMatcher ? `
104
104
  import { registerLocationMatcher } from "./matchers/location";` : ""}
105
105
  import { blocks as generatedBlocks } from "../.deco/blocks.gen";
106
- import { sectionMeta, syncComponents, loadingFallbacks } from "../.deco/sections.gen";
107
- import { PreviewProviders } from "@decocms/tanstack";
106
+ import { sectionMeta, syncComponents, loadingFallbacks, renderJsons } from "../.deco/sections.gen";
107
+ import { PreviewProviders, setupTanstackFastDeploy } from "@decocms/tanstack";
108
108
  // @ts-ignore Vite ?url import
109
109
  import appCss from "./styles/app.css?url";
110
110
 
@@ -131,6 +131,13 @@ createSiteSetup({
131
131
  return null;
132
132
  },
133
133
  });
134
+
135
+ // -- Fast Deploy --
136
+ // Hands the KV binding resolver to @decocms/blocks-admin, which cannot import
137
+ // @decocms/tanstack itself (wrong direction in the package graph). Without this
138
+ // call a Studio publish silently no-ops instead of writing through to KV.
139
+ // Inert unless the worker has DECO_FAST_DEPLOY=1 + a DECO_KV binding.
140
+ setupTanstackFastDeploy();
134
141
  ${isVtex ? `
135
142
  // -- VTEX wiring --
136
143
  setVtexFetch(createInstrumentedFetch("vtex"));
@@ -140,6 +147,7 @@ applySectionConventions({
140
147
  meta: sectionMeta,
141
148
  syncComponents,
142
149
  loadingFallbacks,
150
+ renderJsons,
143
151
  sectionGlob: import.meta.glob("./sections/**/*.tsx") as Record<string, () => Promise<any>>,
144
152
  });
145
153
 
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Scaffolds `.github/workflows/sync-blocks-bot.yml` — the secure content channel
3
+ * from the still-live Fresh/Deno storefront into the migrated repo.
4
+ *
5
+ * Replaces the legacy push-based sync (a workflow in the *legacy* repo holding a
6
+ * cross-repo PAT, `rsync --delete` + `git push` straight into this repo's main).
7
+ * That token is a code-write door, not a content channel: whoever controls the
8
+ * legacy repo or the token can land arbitrary files here, unreviewed.
9
+ *
10
+ * Direction is inverted — this repo pulls, on its own schedule, with its own
11
+ * `GITHUB_TOKEN`, and gates the result:
12
+ * - `sync-blocks-bot.ts` fetches `<origin>/.decofile` (public, unauthenticated)
13
+ * and writes `.deco/blocks/`, denying the `Site` block and anything holding
14
+ * encrypted credentials, and aborting on a plaintext credential;
15
+ * - a path guard fails the run if anything outside `.deco/blocks/` changed;
16
+ * - `bun run generate && bun run build` runs IN THIS JOB, before the PR. It
17
+ * has to be here: a PR opened with `GITHUB_TOKEN` does not trigger
18
+ * `pull_request` workflows, so gating on the PR's own CI would need a PAT
19
+ * and would never fire on its own.
20
+ *
21
+ * Inert until the operator sets the repo variable `SYNC_BLOCKS_ORIGIN` (same
22
+ * one-knob pattern as parity.yml's `PARITY_PROD_URL`) — the job skips cleanly.
23
+ *
24
+ * Docs: docs/sync-blocks-bot.md
25
+ *
26
+ * @param bunVersion Pinned bun version, in lockstep with package.json.
27
+ * @param cliVersion Optional `@decocms/blocks-cli` version to run the pull
28
+ * with. Omit for a freshly scaffolded site: the site's own installed copy is
29
+ * by definition in-version, so the local file path is used. Pass a version
30
+ * for a site still on an older `@decocms/*` — pinning only the sync step gets
31
+ * the script without bumping the runtime the site builds against (blocks-cli
32
+ * pins `@decocms/blocks` exactly, so bumping the devDep drags a second
33
+ * runtime version into the tree). Drop it when the site bumps.
34
+ *
35
+ * GOTCHA: the pinned form uses `npx --package=<pkg> <bin>`, NOT
36
+ * `bunx <pkg> <bin>`. `bunx pkg@ver some-bin` treats `some-bin` as an
37
+ * *argument* and runs the package's first bin instead — in this package that
38
+ * is `deco-migrate`, which rewrites the whole site and exits 0 in dry-run, so
39
+ * the job goes green having run the wrong tool. Observed on a real run. The
40
+ * `--json`-report assertion below is the second line of defence.
41
+ */
42
+ export function generateSyncBlocksBotYml(bunVersion: string, cliVersion?: string): string {
43
+ const bun = bunVersion.replace(/^bun@/, "");
44
+ const pullCmd = cliVersion
45
+ ? `npx --yes --package=@decocms/blocks-cli@${cliVersion} deco-sync-blocks-bot`
46
+ : "bunx tsx node_modules/@decocms/blocks-cli/scripts/sync-blocks-bot.ts";
47
+ return `name: sync-blocks-bot
48
+
49
+ # Puxa o conteúdo publicado na loja de produção (Fresh/Deno) para \`.deco/blocks\`
50
+ # e abre um PR. Sem token cross-repo: o repo legado não tem permissão nenhuma
51
+ # aqui — este repo busca sozinho, valida e só então mergeia.
52
+ #
53
+ # Configuração (uma vez): variável de repo \`SYNC_BLOCKS_ORIGIN\` = origin da
54
+ # loja de produção (ex.: https://www.minhaloja.com.br). Sem ela o job pula limpo.
55
+ # Para revisão humana em vez de merge automático, mude \`AUTO_MERGE\` para "false".
56
+ #
57
+ # Depois de ligar isto, APAGUE o workflow de push no repo legado e REVOGUE o
58
+ # token cross-repo — o pull não fecha aquela porta sozinho. Ver docs/sync-blocks-bot.md.
59
+
60
+ on:
61
+ schedule:
62
+ # 06:00 UTC = 03:00 BRT, fora do horário de publicação do CMS.
63
+ - cron: "0 6 * * *"
64
+ workflow_dispatch:
65
+ inputs:
66
+ origin:
67
+ description: "Origin da loja de produção (sobrepõe SYNC_BLOCKS_ORIGIN)"
68
+ required: false
69
+ prune:
70
+ description: "Apagar blocos que não existem mais em produção"
71
+ type: boolean
72
+ default: true
73
+ dry_run:
74
+ description: "Só relatório, não escreve nem abre PR"
75
+ type: boolean
76
+ default: false
77
+
78
+ permissions:
79
+ contents: write
80
+ pull-requests: write
81
+
82
+ # Publicações do CMS acontecem em rajada; uma sync por vez, sem cancelar a que
83
+ # já está no gate de build.
84
+ concurrency:
85
+ group: sync-blocks-bot
86
+ cancel-in-progress: false
87
+
88
+ env:
89
+ BUN_VERSION: "${bun}"
90
+ AUTO_MERGE: "true"
91
+ # Chaves de bloco que a sync NUNCA sobrescreve. Blocos com secret encriptado
92
+ # já são protegidos por shape pelo script (as credenciais deste repo vivem no
93
+ # bloco de app dele, que não existe nesse layout em produção).
94
+ DENY_KEYS: "Site,site"
95
+
96
+ jobs:
97
+ sync:
98
+ runs-on: ubuntu-latest
99
+ steps:
100
+ - name: Resolve origin
101
+ id: cfg
102
+ env:
103
+ INPUT_ORIGIN: \${{ github.event.inputs.origin }}
104
+ VAR_ORIGIN: \${{ vars.SYNC_BLOCKS_ORIGIN }}
105
+ run: |
106
+ origin="\${INPUT_ORIGIN:-$VAR_ORIGIN}"
107
+ if [ -z "$origin" ]; then
108
+ echo "::notice::variável de repo SYNC_BLOCKS_ORIGIN não configurada — sync-blocks-bot inerte."
109
+ echo "skip=true" >> "$GITHUB_OUTPUT"
110
+ else
111
+ echo "origin=$origin" >> "$GITHUB_OUTPUT"
112
+ fi
113
+
114
+ - uses: actions/checkout@v4
115
+ if: steps.cfg.outputs.skip != 'true'
116
+
117
+ - uses: oven-sh/setup-bun@v2
118
+ if: steps.cfg.outputs.skip != 'true'
119
+ with:
120
+ bun-version: \${{ env.BUN_VERSION }}
121
+
122
+ - name: Install
123
+ if: steps.cfg.outputs.skip != 'true'
124
+ run: bun install --frozen-lockfile
125
+
126
+ - name: Pull decofile de produção
127
+ if: steps.cfg.outputs.skip != 'true'
128
+ env:
129
+ ORIGIN: \${{ steps.cfg.outputs.origin }}
130
+ PRUNE: \${{ github.event.inputs.prune == 'false' && ' ' || '--prune' }}
131
+ DRY_RUN: \${{ github.event.inputs.dry_run == 'true' && '--dry-run' || ' ' }}
132
+ run: |
133
+ # pipefail é obrigatório: sem ele o \`| tee\` mascara o exit 1 do gate
134
+ # de plaintext secret e o job passa mesmo tendo abortado o pull.
135
+ set -o pipefail
136
+ ${pullCmd} \\
137
+ --origin "$ORIGIN" \\
138
+ --out .deco/blocks \\
139
+ --deny "$DENY_KEYS" \\
140
+ --fail-on-plaintext-secret \\
141
+ --json --github $PRUNE $DRY_RUN | tee /tmp/sync-blocks-report.json
142
+ # Prova de que foi ESTE script que rodou, e que ele viu conteúdo. Sem
143
+ # isso, uma invocação errada que resolva para outro bin do pacote
144
+ # termina 0 e o job fica verde tendo rodado a ferramenta errada.
145
+ grep -q '"remoteBlocks"' /tmp/sync-blocks-report.json || {
146
+ echo "::error::o passo de pull não produziu o relatório esperado — comando errado?"
147
+ exit 1
148
+ }
149
+
150
+ - name: Guard — só .deco/blocks pode mudar
151
+ id: guard
152
+ if: steps.cfg.outputs.skip != 'true' && github.event.inputs.dry_run != 'true'
153
+ run: |
154
+ {
155
+ git -c core.quotepath=false diff --name-only HEAD
156
+ git -c core.quotepath=false ls-files --others --exclude-standard
157
+ } | sort -u > /tmp/sync-blocks-changed.txt
158
+ offending="$(grep -v '^\\.deco/blocks/' /tmp/sync-blocks-changed.txt || true)"
159
+ if [ -n "$offending" ]; then
160
+ echo "::error::a sync mexeu fora de .deco/blocks — abortando:"
161
+ echo "$offending"
162
+ exit 1
163
+ fi
164
+ if [ ! -s /tmp/sync-blocks-changed.txt ]; then
165
+ echo "::notice::conteúdo já está em dia, nada a sincronizar."
166
+ echo "changed=false" >> "$GITHUB_OUTPUT"
167
+ else
168
+ echo "changed=true" >> "$GITHUB_OUTPUT"
169
+ echo "$(wc -l < /tmp/sync-blocks-changed.txt) arquivo(s) de bloco alterado(s)"
170
+ fi
171
+
172
+ # Gate de verdade. Roda AQUI porque PR aberto com GITHUB_TOKEN não dispara
173
+ # o workflow de \`pull_request\` — gatear no CI do PR exigiria um PAT.
174
+ - name: Validar (generate + build)
175
+ if: steps.guard.outputs.changed == 'true'
176
+ run: bun run generate && bun run build
177
+
178
+ - name: Abrir PR (e mergear se AUTO_MERGE)
179
+ if: steps.guard.outputs.changed == 'true'
180
+ env:
181
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
182
+ ORIGIN: \${{ steps.cfg.outputs.origin }}
183
+ run: |
184
+ branch="sync-blocks/$(date -u +%Y-%m-%dT%H%M%SZ)"
185
+ git config user.name "github-actions[bot]"
186
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
187
+ git checkout -b "$branch"
188
+ git add .deco/blocks
189
+ git commit -m "chore(content): sync .deco/blocks de $ORIGIN"
190
+ git push origin "$branch"
191
+ url="$(gh pr create \\
192
+ --title "chore(content): sync .deco/blocks de produção" \\
193
+ --body "Conteúdo puxado de \\\`$ORIGIN/.decofile\\\` pelo workflow \\\`sync-blocks-bot\\\`. Só \\\`.deco/blocks/**\\\` mudou (guard) e \\\`generate + build\\\` passou antes deste PR existir." \\
194
+ --head "$branch")"
195
+ echo "PR: $url"
196
+ if [ "$AUTO_MERGE" = "true" ]; then
197
+ gh pr merge "$url" --squash --delete-branch || \\
198
+ echo "::notice::merge automático bloqueado (branch protection?) — PR aberto para revisão: $url"
199
+ fi
200
+ `;
201
+ }
@@ -10,9 +10,12 @@ export function generateUiComponents(_ctx: MigrationContext): Record<string, str
10
10
  getSrcSet,
11
11
  registerImageCdnDomain,
12
12
  getImageCdnDomain,
13
+ registerImageQuality,
14
+ getImageQuality,
13
15
  FACTORS,
14
16
  type ImageProps,
15
17
  type FitOptions,
18
+ type ImageQuality,
16
19
  } from "@decocms/blocks/hooks";
17
20
  `;
18
21