@decocms/blocks-cli 7.39.0 → 7.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.39.0",
3
+ "version": "7.41.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": {
@@ -31,7 +31,7 @@
31
31
  "lint:unused": "knip"
32
32
  },
33
33
  "dependencies": {
34
- "@decocms/blocks": "7.39.0",
34
+ "@decocms/blocks": "7.41.0",
35
35
  "ts-morph": "^27.0.0",
36
36
  "tsx": "^4.22.5"
37
37
  },
@@ -336,6 +336,89 @@ describe("generate-sections neverDefer convention", () => {
336
336
  }, 60_000);
337
337
  });
338
338
 
339
+ describe("generate-sections renderJson convention", () => {
340
+ let tmpDir: string;
341
+ let sectionsDir: string;
342
+ let outFile: string;
343
+
344
+ beforeEach(() => {
345
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-sections-renderjson-"));
346
+ sectionsDir = path.join(tmpDir, "sections");
347
+ outFile = path.join(tmpDir, "out", "sections.gen.ts");
348
+ fs.mkdirSync(sectionsDir, { recursive: true });
349
+ });
350
+
351
+ afterEach(() => {
352
+ fs.rmSync(tmpDir, { recursive: true, force: true });
353
+ });
354
+
355
+ it("emits `renderJson: false` into sectionMeta and a `renderJsons` map for projection fns; the output typechecks + imports", () => {
356
+ // `= false` opt-out: web-only section dropped from ?renderJson.
357
+ fs.writeFileSync(
358
+ path.join(sectionsDir, "Theme.tsx"),
359
+ "export const renderJson = false;\nexport default function Theme() { return null; }\n",
360
+ );
361
+ // projection function: trims props for the mobile app.
362
+ fs.writeFileSync(
363
+ path.join(sectionsDir, "ProductDetails.tsx"),
364
+ [
365
+ "export const renderJson = (props: Record<string, unknown>) => {",
366
+ " const { storeConfig: _s, ...rest } = props;",
367
+ " return rest;",
368
+ "};",
369
+ "export default function ProductDetails() { return null; }",
370
+ "",
371
+ ].join("\n"),
372
+ );
373
+
374
+ const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]);
375
+ expect(code).toBe(0);
376
+
377
+ const generated = fs.readFileSync(outFile, "utf-8");
378
+
379
+ // The `= false` section carries the literal false flag; the fn section
380
+ // carries hasRenderJson (its actual fn lives in the renderJsons map).
381
+ expect(generated).toMatch(/"site\/sections\/Theme\.tsx":\s*\{[^}]*renderJson:\s*false/);
382
+ expect(generated).toMatch(/"site\/sections\/ProductDetails\.tsx":\s*\{[^}]*hasRenderJson:\s*true/);
383
+ // The emitted interface declares both (excess-property guard, cf. neverDefer).
384
+ expect(generated).toContain("renderJson?: false;");
385
+ expect(generated).toContain("hasRenderJson?: boolean;");
386
+ // The fn is imported and wired into renderJsons; the `= false` one is NOT.
387
+ expect(generated).toContain("import { renderJson as _rj0 }");
388
+ expect(generated).toMatch(/export const renderJsons: Record<string, any> = \{[\s\S]*ProductDetails\.tsx": _rj0/);
389
+ expect(generated).not.toMatch(/renderJsons: Record<string, any> = \{[\s\S]*Theme\.tsx/);
390
+
391
+ // Typecheck the output (the real failure mode for a bad emit is TS2353, not
392
+ // a runtime error) and confirm it imports with a working projection fn.
393
+ // --jsx: the generated file imports a projection fn from a `.tsx` section,
394
+ // so tsc needs jsx set (a real site's tsconfig always does; the standalone
395
+ // invocation must too, or it fails with TS6142).
396
+ const tscResult = cp.spawnSync(
397
+ "npx",
398
+ ["tsc", "--noEmit", "--strict", "--skipLibCheck", "--jsx", "react-jsx", outFile],
399
+ { encoding: "utf8" },
400
+ );
401
+ expect(tscResult.status, tscResult.stdout + tscResult.stderr).toBe(0);
402
+
403
+ const checkerFile = path.join(tmpDir, "check-import.mjs");
404
+ fs.writeFileSync(
405
+ checkerFile,
406
+ [
407
+ `const m = await import(${JSON.stringify(pathToFileURL(outFile).href)});`,
408
+ `if (m.sectionMeta?.["site/sections/Theme.tsx"]?.renderJson !== false) {`,
409
+ ` throw new Error("Theme renderJson:false missing from sectionMeta");`,
410
+ `}`,
411
+ `const fn = m.renderJsons?.["site/sections/ProductDetails.tsx"];`,
412
+ `if (typeof fn !== "function") throw new Error("ProductDetails renderJson fn missing");`,
413
+ `const out = fn({ storeConfig: 1, page: 2 });`,
414
+ `if (out.storeConfig !== undefined || out.page !== 2) throw new Error("projection fn wrong");`,
415
+ ].join("\n"),
416
+ );
417
+ const importResult = cp.spawnSync("npx", ["tsx", checkerFile], { encoding: "utf8" });
418
+ expect(importResult.status, importResult.stderr).toBe(0);
419
+ }, 60_000);
420
+ });
421
+
339
422
  describe("generate-sections output hygiene (non-registry)", () => {
340
423
  let tmpDir: string;
341
424
  let sectionsDir: string;
@@ -54,9 +54,19 @@ interface SectionMeta {
54
54
  clientOnly?: boolean;
55
55
  seo?: boolean;
56
56
  hasLoadingFallback?: boolean;
57
+ renderJson?: false;
58
+ hasRenderJson?: boolean;
57
59
  }
58
60
 
59
61
  const EXPORT_CONST_RE = /export\s+const\s+(eager|neverDefer|deferred|cache|layout|sync|clientOnly|seo)\s*=\s*(.+?)(?:;|\n)/g;
62
+ // renderJson (?renderJson mobile path). `= false` drops the section; a function
63
+ // (`export const renderJson = (props) => ...` / `export function renderJson`)
64
+ // projects its props. Kept separate from EXPORT_CONST_RE because the value is a
65
+ // literal `false` OR a function, not the `= true` booleans that regex handles.
66
+ const RENDER_JSON_FALSE_RE = /export\s+const\s+renderJson\s*=\s*false\b/;
67
+ const RENDER_JSON_FN_INLINE_RE =
68
+ /export\s+(?:function\s+renderJson\b|const\s+renderJson\s*=\s*(?!false\b)\S)/;
69
+ const RENDER_JSON_REEXPORT_RE = /export\s*\{[^}]*\brenderJson\b[^}]*\}/;
60
70
  // Detects `export function LoadingFallback(...)`, `export const LoadingFallback = ...`, etc.
61
71
  const LOADING_FALLBACK_INLINE_RE = /export\s+(?:function|const|let|var)\s+LoadingFallback\b/;
62
72
  // Detects re-exports like:
@@ -96,6 +106,14 @@ function extractMeta(content: string): SectionMeta | null {
96
106
  found = true;
97
107
  }
98
108
 
109
+ if (RENDER_JSON_FALSE_RE.test(content)) {
110
+ meta.renderJson = false;
111
+ found = true;
112
+ } else if (RENDER_JSON_FN_INLINE_RE.test(content) || RENDER_JSON_REEXPORT_RE.test(content)) {
113
+ meta.hasRenderJson = true;
114
+ found = true;
115
+ }
116
+
99
117
  return found ? meta : null;
100
118
  }
101
119
 
@@ -168,6 +186,8 @@ const lines: string[] = [
168
186
  "// export const clientOnly = true → skip SSR (client-only rendering)",
169
187
  "// export const seo = true → SEO section (provides page head data)",
170
188
  "// export function LoadingFallback → skeleton shown while section loads",
189
+ "// export const renderJson = false → drop section from ?renderJson (mobile)",
190
+ "// export const renderJson = (p)=> → project props for ?renderJson (mobile)",
171
191
  "",
172
192
  ];
173
193
 
@@ -187,6 +207,16 @@ for (let i = 0; i < nonSyncFallbacks.length; i++) {
187
207
  lines.push(`import { LoadingFallback as _fb${i} } from "${importPath}";`);
188
208
  }
189
209
 
210
+ // renderJson projection-function imports — sections whose renderJson is a
211
+ // function (not `= false`) and aren't sync-imported.
212
+ const renderJsonEntries = entries.filter((e) => e.meta.hasRenderJson);
213
+ const nonSyncRenderJsons = renderJsonEntries.filter((e) => !e.meta.sync);
214
+ for (let i = 0; i < nonSyncRenderJsons.length; i++) {
215
+ const e = nonSyncRenderJsons[i];
216
+ const importPath = relativeImportPath(outFile, e.filePath);
217
+ lines.push(`import { renderJson as _rj${i} } from "${importPath}";`);
218
+ }
219
+
190
220
  lines.push("");
191
221
 
192
222
  // Metadata map
@@ -205,6 +235,8 @@ lines.push(" sync?: boolean;");
205
235
  lines.push(" clientOnly?: boolean;");
206
236
  lines.push(" seo?: boolean;");
207
237
  lines.push(" hasLoadingFallback?: boolean;");
238
+ lines.push(" renderJson?: false;");
239
+ lines.push(" hasRenderJson?: boolean;");
208
240
  lines.push("}");
209
241
  lines.push("");
210
242
  lines.push("export const sectionMeta: Record<string, SectionMetaEntry> = {");
@@ -248,6 +280,28 @@ if (allFallbacks.length > 0) {
248
280
  }
249
281
  lines.push("");
250
282
 
283
+ // renderJson projection-functions map (?renderJson mobile path). `= false`
284
+ // sections are NOT here — they carry `renderJson: false` in sectionMeta, which
285
+ // applySectionConventions reads directly. Only projection functions need a live
286
+ // reference. Typed `any` (like syncComponents) to avoid importing the RenderJson
287
+ // type into the generated file.
288
+ if (renderJsonEntries.length > 0) {
289
+ lines.push("export const renderJsons: Record<string, any> = {");
290
+ for (const e of renderJsonEntries) {
291
+ if (e.meta.sync) {
292
+ const syncIdx = syncEntries.indexOf(e);
293
+ lines.push(` "${e.key}": _sync${syncIdx}.renderJson,`);
294
+ } else {
295
+ const rjIdx = nonSyncRenderJsons.indexOf(e);
296
+ lines.push(` "${e.key}": _rj${rjIdx},`);
297
+ }
298
+ }
299
+ lines.push("};");
300
+ } else {
301
+ lines.push("export const renderJsons: Record<string, any> = {};");
302
+ }
303
+ lines.push("");
304
+
251
305
  // Lazy section-import registry — opt-in via --registry, built from every
252
306
  // scanned section file (not just convention-carrying `entries`).
253
307
  if (EMIT_REGISTRY) {
@@ -1,38 +1,39 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { MigrationContext } from "./types";
4
- import { log, logPhase } from "./types";
3
+ // `lib-utils` is imported lazily — see end of phase-cleanup. Eager
4
+ // generation of all 11 shims left every site with dead code that had
5
+ // to be cleaned up by hand.
6
+ import { extractTheme } from "./analyzers/theme-extractor";
7
+ import { generateAppCss } from "./templates/app-css";
8
+ import { generateCacheConfig } from "./templates/cache-config";
9
+ import { generateCiFiles } from "./templates/ci-yml";
10
+ import { generateCommerceInit } from "./templates/commerce-init";
11
+ import { generateCommerceLoaders } from "./templates/commerce-loaders";
12
+ import { generateMigrationPolicyPointerRule } from "./templates/cursor-rules";
13
+ import { generateHooks } from "./templates/hooks";
14
+ import { generateKnipConfig } from "./templates/knip-config";
15
+ import { generateLockfileCheckYml } from "./templates/lockfile-check-yml";
16
+ import { generateMainPushGuardYml } from "./templates/main-push-guard-yml";
5
17
  import {
6
18
  CANONICAL_BUN_VERSION,
7
19
  CANONICAL_NODE_VERSION,
8
20
  generatePackageJson,
9
21
  } from "./templates/package-json";
10
- import { generateLockfileCheckYml } from "./templates/lockfile-check-yml";
11
- import { generateTsconfig } from "./templates/tsconfig";
12
- import { generateViteConfig } from "./templates/vite-config";
13
- import { generateKnipConfig } from "./templates/knip-config";
22
+ import { generateParityYml } from "./templates/parity-yml";
23
+ import { generatePerfFiles } from "./templates/perf-yml";
24
+ import { generatePlaywrightFiles } from "./templates/playwright-yml";
25
+ import { generateReactDoctorYml } from "./templates/react-doctor-yml";
14
26
  import { generateRoutes } from "./templates/routes";
15
- import { generateSetup } from "./templates/setup";
27
+ import { generateSdkFiles } from "./templates/sdk-gen";
28
+ import { generateSectionLoaders } from "./templates/section-loaders";
16
29
  import { generateServerEntry } from "./templates/server-entry";
17
- import { generateAppCss } from "./templates/app-css";
30
+ import { generateSetup } from "./templates/setup";
31
+ import { generateTsconfig } from "./templates/tsconfig";
18
32
  import { generateTypeFiles } from "./templates/types-gen";
19
33
  import { generateUiComponents } from "./templates/ui-components";
20
- import { generateHooks } from "./templates/hooks";
21
- import { generateCommerceInit } from "./templates/commerce-init";
22
- import { generateCommerceLoaders } from "./templates/commerce-loaders";
23
- import { generateSectionLoaders } from "./templates/section-loaders";
24
- import { generateCacheConfig } from "./templates/cache-config";
25
- import { generateSdkFiles } from "./templates/sdk-gen";
26
- import { generateMigrationPolicyPointerRule } from "./templates/cursor-rules";
27
- import { generatePerfFiles } from "./templates/perf-yml";
28
- import { generateCiFiles } from "./templates/ci-yml";
29
- import { generateMainPushGuardYml } from "./templates/main-push-guard-yml";
30
- import { generatePlaywrightFiles } from "./templates/playwright-yml";
31
- import { generateReactDoctorYml } from "./templates/react-doctor-yml";
32
- // `lib-utils` is imported lazily — see end of phase-cleanup. Eager
33
- // generation of all 11 shims left every site with dead code that had
34
- // to be cleaned up by hand.
35
- import { extractTheme } from "./analyzers/theme-extractor";
34
+ import { generateViteConfig } from "./templates/vite-config";
35
+ import type { MigrationContext } from "./types";
36
+ import { log, logPhase } from "./types";
36
37
 
37
38
  function writeFile(ctx: MigrationContext, relPath: string, content: string) {
38
39
  const fullPath = path.join(ctx.sourceDir, relPath);
@@ -78,13 +79,21 @@ export function scaffold(ctx: MigrationContext): void {
78
79
  // does NOT scaffold deploy/preview/sync-secrets workflows in the site
79
80
  // repo; the operator wires the repo<->worker connection in the CF
80
81
  // dashboard once after the first push.
81
- writeFile(ctx, ".prettierrc", JSON.stringify({
82
- semi: true,
83
- singleQuote: false,
84
- trailingComma: "all" as const,
85
- printWidth: 100,
86
- tabWidth: 2,
87
- }, null, 2) + "\n");
82
+ writeFile(
83
+ ctx,
84
+ ".prettierrc",
85
+ JSON.stringify(
86
+ {
87
+ semi: true,
88
+ singleQuote: false,
89
+ trailingComma: "all" as const,
90
+ printWidth: 100,
91
+ tabWidth: 2,
92
+ },
93
+ null,
94
+ 2,
95
+ ) + "\n",
96
+ );
88
97
 
89
98
  // PR-time lockfile guardrail. Lives in the site repo (per-site, not
90
99
  // a centralised reusable workflow) because per D6.3 we are not
@@ -117,6 +126,10 @@ export function scaffold(ctx: MigrationContext): void {
117
126
  // Advisory React lint (react-doctor): comments on PRs, never fails.
118
127
  writeFile(ctx, ".github/workflows/react-doctor.yml", generateReactDoctorYml());
119
128
 
129
+ // Advisory parity validation: compares each PR preview against the original
130
+ // live storefront (@decocms/parity). Inert until PARITY_PROD_URL is set.
131
+ writeFile(ctx, ".github/workflows/parity.yml", generateParityYml(ctx.siteName));
132
+
120
133
  // Server entry files (server.ts, worker-entry.ts, router.tsx, runtime.ts, context.ts)
121
134
  writeMultiFile(ctx, generateServerEntry(ctx));
122
135
 
@@ -255,18 +268,20 @@ export { type Font as SiteThemeFont };
255
268
  }
256
269
 
257
270
  function generateWranglerConfig(ctx: MigrationContext): string {
258
- return JSON.stringify(
259
- {
260
- name: ctx.siteName,
261
- main: "src/worker-entry.ts",
262
- compatibility_date: "2025-05-01",
263
- compatibility_flags: ["nodejs_compat", "no_handle_cross_request_promise_resolution"],
264
- cache: { enabled: true },
265
- kv_namespaces: [{ binding: "SITES_KV", id: "dev-sites-kv" }],
266
- },
267
- null,
268
- 2,
269
- ) + "\n";
271
+ return (
272
+ JSON.stringify(
273
+ {
274
+ name: ctx.siteName,
275
+ main: "src/worker-entry.ts",
276
+ compatibility_date: "2025-05-01",
277
+ compatibility_flags: ["nodejs_compat", "no_handle_cross_request_promise_resolution"],
278
+ cache: { enabled: true },
279
+ kv_namespaces: [{ binding: "SITES_KV", id: "dev-sites-kv" }],
280
+ },
281
+ null,
282
+ 2,
283
+ ) + "\n"
284
+ );
270
285
  }
271
286
 
272
287
  function generateGitignore(): string {
@@ -580,10 +595,7 @@ ${entries.join("\n")}
580
595
  }
581
596
 
582
597
  function hasLocationMatcher(ctx: MigrationContext): boolean {
583
- const dirs = [
584
- path.join(ctx.sourceDir, "matchers"),
585
- path.join(ctx.sourceDir, "src", "matchers"),
586
- ];
598
+ const dirs = [path.join(ctx.sourceDir, "matchers"), path.join(ctx.sourceDir, "src", "matchers")];
587
599
  for (const dir of dirs) {
588
600
  if (fs.existsSync(dir)) {
589
601
  const files = fs.readdirSync(dir);
@@ -598,7 +610,9 @@ function hasLocationMatcher(ctx: MigrationContext): boolean {
598
610
  try {
599
611
  const content = fs.readFileSync(path.join(blocksDir, file), "utf-8");
600
612
  if (content.includes("website/matchers/location")) return true;
601
- } catch { /* skip */ }
613
+ } catch {
614
+ /* skip */
615
+ }
602
616
  }
603
617
  }
604
618
  return false;
@@ -25,6 +25,7 @@ const REQUIRED_FILES = [
25
25
  ".github/workflows/main-push-guard.yml",
26
26
  ".github/workflows/playwright.yml",
27
27
  ".github/workflows/react-doctor.yml",
28
+ ".github/workflows/parity.yml",
28
29
  "tools/gates/no-suppressions.sh",
29
30
  "playwright.config.ts",
30
31
  "knip.config.ts",
@@ -74,9 +75,7 @@ export const checks: Check[] = [
74
75
  name: "All scaffolded files exist",
75
76
  severity: "error",
76
77
  fn: (ctx) => {
77
- const missing = REQUIRED_FILES.filter(
78
- (f) => !fs.existsSync(path.join(ctx.sourceDir, f)),
79
- );
78
+ const missing = REQUIRED_FILES.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f)));
80
79
  if (missing.length > 0) {
81
80
  console.log(` Missing: ${missing.join(", ")}`);
82
81
  return false;
@@ -88,9 +87,7 @@ export const checks: Check[] = [
88
87
  name: "Old artifacts removed",
89
88
  severity: "error",
90
89
  fn: (ctx) => {
91
- const remaining = MUST_NOT_EXIST.filter(
92
- (f) => fs.existsSync(path.join(ctx.sourceDir, f)),
93
- );
90
+ const remaining = MUST_NOT_EXIST.filter((f) => fs.existsSync(path.join(ctx.sourceDir, f)));
94
91
  if (remaining.length > 0) {
95
92
  console.log(` Still exists: ${remaining.join(", ")}`);
96
93
  return false;
@@ -141,14 +138,16 @@ export const checks: Check[] = [
141
138
  },
142
139
  },
143
140
  {
144
- name: 'No class= in JSX (should be className=)',
141
+ name: "No class= in JSX (should be className=)",
145
142
  severity: "warning",
146
143
  fn: (ctx) => {
147
144
  const srcDir = path.join(ctx.sourceDir, "src");
148
145
  if (!fs.existsSync(srcDir)) return true;
149
146
  const bad = findFilesWithPattern(srcDir, /<[a-zA-Z][^>]*\sclass\s*=/);
150
147
  if (bad.length > 0) {
151
- console.log(` Still has class= in JSX: ${bad.slice(0, 5).join(", ")}${bad.length > 5 ? ` (+${bad.length - 5} more)` : ""}`);
148
+ console.log(
149
+ ` Still has class= in JSX: ${bad.slice(0, 5).join(", ")}${bad.length > 5 ? ` (+${bad.length - 5} more)` : ""}`,
150
+ );
152
151
  return false;
153
152
  }
154
153
  return true;
@@ -163,12 +162,8 @@ export const checks: Check[] = [
163
162
  console.log(" public/ directory missing");
164
163
  return false;
165
164
  }
166
- const hasSprites = fs.existsSync(
167
- path.join(publicDir, "sprites.svg"),
168
- );
169
- const hasFavicon = fs.existsSync(
170
- path.join(publicDir, "favicon.ico"),
171
- );
165
+ const hasSprites = fs.existsSync(path.join(publicDir, "sprites.svg"));
166
+ const hasFavicon = fs.existsSync(path.join(publicDir, "favicon.ico"));
172
167
  if (!hasSprites) console.log(" Missing: public/sprites.svg");
173
168
  if (!hasFavicon) console.log(" Missing: public/favicon.ico");
174
169
  return hasSprites && hasFavicon;
@@ -277,7 +272,9 @@ export const checks: Check[] = [
277
272
  const bad = findFilesWithPattern(srcDir, /(?<!<(?:img|Image)[^>]*)-z-\d+/);
278
273
  if (bad.length > 0) {
279
274
  console.log(` Negative z-index on non-image elements: ${bad.join(", ")}`);
280
- console.log(` These may be invisible due to stacking contexts. Replace with z-0 or positive z-index.`);
275
+ console.log(
276
+ ` These may be invisible due to stacking contexts. Replace with z-0 or positive z-index.`,
277
+ );
281
278
  return false;
282
279
  }
283
280
  return true;
@@ -324,7 +321,9 @@ export const checks: Check[] = [
324
321
  // Check that runtime.ts exists and has the invoke proxy
325
322
  const runtimePath = path.join(srcDir, "runtime.ts");
326
323
  if (!fs.existsSync(runtimePath)) {
327
- console.log(` Files use invoke.* but src/runtime.ts is missing: ${hasInvoke.join(", ")}`);
324
+ console.log(
325
+ ` Files use invoke.* but src/runtime.ts is missing: ${hasInvoke.join(", ")}`,
326
+ );
328
327
  return false;
329
328
  }
330
329
  }
@@ -379,7 +378,10 @@ export const checks: Check[] = [
379
378
  fn: (ctx) => {
380
379
  const srcDir = path.join(ctx.sourceDir, "src");
381
380
  if (!fs.existsSync(srcDir)) return true;
382
- const bad = findFilesWithPattern(srcDir, /\bhx-(?:get|post|put|delete|patch|trigger|target|swap|on|indicator|sync|select)\b/);
381
+ const bad = findFilesWithPattern(
382
+ srcDir,
383
+ /\bhx-(?:get|post|put|delete|patch|trigger|target|swap|on|indicator|sync|select)\b/,
384
+ );
383
385
  if (bad.length > 0) {
384
386
  console.log(` HTMX attributes found (needs manual React migration): ${bad.join(", ")}`);
385
387
  return false;
@@ -433,7 +435,9 @@ export const checks: Check[] = [
433
435
  ];
434
436
  const unique = [...new Set(bad)];
435
437
  if (unique.length > 0) {
436
- console.log(` Still references frozen @decocms/start or @decocms/apps/* specifiers: ${unique.join(", ")}`);
438
+ console.log(
439
+ ` Still references frozen @decocms/start or @decocms/apps/* specifiers: ${unique.join(", ")}`,
440
+ );
437
441
  return false;
438
442
  }
439
443
  return true;
@@ -450,9 +454,7 @@ export const checks: Check[] = [
450
454
  "src/setup/commerce-init.ts",
451
455
  "src/setup/section-loaders.ts",
452
456
  ];
453
- const missing = setupFiles.filter(
454
- (f) => !fs.existsSync(path.join(ctx.sourceDir, f)),
455
- );
457
+ const missing = setupFiles.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f)));
456
458
  if (missing.length > 0) {
457
459
  console.log(` Missing setup infrastructure: ${missing.join(", ")}`);
458
460
  return false;
@@ -468,9 +470,13 @@ export const checks: Check[] = [
468
470
  if (fs.existsSync(islandsDir)) {
469
471
  try {
470
472
  const files = fs.readdirSync(islandsDir, { recursive: true });
471
- const tsxFiles = (files as string[]).filter((f: string) => f.endsWith(".tsx") || f.endsWith(".ts"));
473
+ const tsxFiles = (files as string[]).filter(
474
+ (f: string) => f.endsWith(".tsx") || f.endsWith(".ts"),
475
+ );
472
476
  if (tsxFiles.length > 0) {
473
- console.log(` src/islands/ still has ${tsxFiles.length} files — should be moved to components/`);
477
+ console.log(
478
+ ` src/islands/ still has ${tsxFiles.length} files — should be moved to components/`,
479
+ );
474
480
  return false;
475
481
  }
476
482
  } catch {}
@@ -487,9 +493,7 @@ export const checks: Check[] = [
487
493
  "src/hooks/useUser.ts",
488
494
  "src/hooks/useWishlist.ts",
489
495
  ];
490
- const missing = hookFiles.filter(
491
- (f) => !fs.existsSync(path.join(ctx.sourceDir, f)),
492
- );
496
+ const missing = hookFiles.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f)));
493
497
  if (missing.length > 0) {
494
498
  console.log(` Missing hooks: ${missing.join(", ")}`);
495
499
  return false;
@@ -507,9 +511,7 @@ export const checks: Check[] = [
507
511
  "src/types/deco.ts",
508
512
  "src/types/commerce-app.ts",
509
513
  ];
510
- const missing = typeFiles.filter(
511
- (f) => !fs.existsSync(path.join(ctx.sourceDir, f)),
512
- );
514
+ const missing = typeFiles.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f)));
513
515
  if (missing.length > 0) {
514
516
  console.log(` Missing type files: ${missing.join(", ")}`);
515
517
  return false;
@@ -530,7 +532,8 @@ function findFilesWithPattern(
530
532
  for (const entry of entries) {
531
533
  const fullPath = path.join(dir, entry.name);
532
534
  if (entry.isDirectory()) {
533
- if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server") continue;
535
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server")
536
+ continue;
534
537
  findFilesWithPattern(fullPath, pattern, results, root);
535
538
  } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
536
539
  const content = fs.readFileSync(fullPath, "utf-8");
@@ -563,7 +566,8 @@ function findMatchesWithPattern(
563
566
  for (const entry of entries) {
564
567
  const fullPath = path.join(dir, entry.name);
565
568
  if (entry.isDirectory()) {
566
- if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server") continue;
569
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server")
570
+ continue;
567
571
  findMatchesWithPattern(fullPath, pattern, results, root);
568
572
  } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
569
573
  const content = fs.readFileSync(fullPath, "utf-8");
@@ -591,7 +595,11 @@ export function verify(ctx: MigrationContext): boolean {
591
595
 
592
596
  for (const check of checks) {
593
597
  const pass = check.fn(ctx);
594
- const icon = pass ? "\x1b[32m✓\x1b[0m" : check.severity === "error" ? "\x1b[31m✗\x1b[0m" : "\x1b[33m⚠\x1b[0m";
598
+ const icon = pass
599
+ ? "\x1b[32m✓\x1b[0m"
600
+ : check.severity === "error"
601
+ ? "\x1b[31m✗\x1b[0m"
602
+ : "\x1b[33m⚠\x1b[0m";
595
603
  console.log(` ${icon} ${check.name}`);
596
604
  if (!pass) {
597
605
  if (check.severity === "error") errors++;
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { generateCiFiles } from "./ci-yml";
3
3
  import { generateMainPushGuardYml } from "./main-push-guard-yml";
4
+ import { generateParityYml } from "./parity-yml";
4
5
  import { generatePlaywrightFiles } from "./playwright-yml";
5
6
  import { generateReactDoctorYml } from "./react-doctor-yml";
6
7
 
@@ -25,7 +26,9 @@ describe("generateCiFiles", () => {
25
26
 
26
27
  it("blocks on generate + build, keeps cleanliness gates advisory", () => {
27
28
  // generate + build have NO continue-on-error
28
- expect(ci).toMatch(/Generate artifacts[\s\S]*?run: bun run generate && bun run generate:routes/);
29
+ expect(ci).toMatch(
30
+ /Generate artifacts[\s\S]*?run: bun run generate && bun run generate:routes/,
31
+ );
29
32
  expect(ci).toMatch(/Build \(vite\)\n\s+run: bun run build\n/);
30
33
  // the four cleanliness gates are advisory
31
34
  for (const advisory of ["Typecheck", "Format check", "Knip", "no new suppression"]) {
@@ -106,3 +109,17 @@ describe("generateReactDoctorYml", () => {
106
109
  expect(yml).not.toMatch(/^\s*blocking: error/m);
107
110
  });
108
111
  });
112
+
113
+ describe("generateParityYml", () => {
114
+ const yml = generateParityYml("loja-tanstack");
115
+ it("runs parity journey advisory, gated on PARITY_PROD_URL", () => {
116
+ expect(yml).toContain("name: Parity");
117
+ expect(yml).toContain("continue-on-error: true"); // advisory, never blocks
118
+ expect(yml).toContain("vars.PARITY_PROD_URL"); // prod = original storefront
119
+ expect(yml).toContain("bunx @decocms/parity journey");
120
+ expect(yml).toContain("--junit parity-results.xml");
121
+ expect(yml).toContain("--github");
122
+ expect(yml).toContain("Workers Builds: loja-tanstack"); // worker name interpolated
123
+ expect(yml).toContain("secrets.ANTHROPIC_API_KEY"); // optional LLM key
124
+ });
125
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Scaffolds `.github/workflows/parity.yml` — validates the migrated storefront
3
+ * against the ORIGINAL live site on every PR, using `@decocms/parity`.
4
+ *
5
+ * Why day-one: a fresh migration is exactly when parity earns its keep — it
6
+ * catches the sections/SEO/journey steps that silently didn't survive the
7
+ * VTEX IO → blocks port, comparing the PR preview against the source of truth
8
+ * (the still-live original storefront).
9
+ *
10
+ * Shape (mirrors perf.yml):
11
+ * - Advisory (`continue-on-error: true`) so day-one CI is green; comments,
12
+ * never blocks. Flip to a gate by removing continue-on-error once stable.
13
+ * - cand = the CF Workers Builds preview URL, discovered by polling the
14
+ * check-run output (same block as perf.yml — no CF token, no local build).
15
+ * - prod = repo variable `PARITY_PROD_URL` (the original storefront). The one
16
+ * calibration knob the operator sets once; the job SKIPS cleanly if unset,
17
+ * so the workflow is inert until wired.
18
+ * - Runs `parity journey` (the tightest loop: purchase journey only, JUnit +
19
+ * GitHub annotations, no LLM aggregation). ANTHROPIC_API_KEY is an OPTIONAL
20
+ * secret — parity runs without it (issues severity-sorted, not LLM-ranked).
21
+ *
22
+ * Docs: https://docs.decocms.com/v2/en/parity/ci
23
+ *
24
+ * @param workerName Cloudflare Worker name (= wrangler `name`, e.g. "loja-tanstack").
25
+ * Used to match the "Workers Builds: <name>" check run.
26
+ */
27
+ export function generateParityYml(workerName: string): string {
28
+ return `name: Parity
29
+
30
+ # Valida a loja migrada contra a loja ORIGINAL (source of truth) a cada PR, com
31
+ # @decocms/parity. Advisory (comenta, não trava) — vire gate removendo o
32
+ # \`continue-on-error\` do job quando estabilizar, igual perf/react-doctor.
33
+ #
34
+ # Requer a variável de repo \`PARITY_PROD_URL\` = URL da loja original (ex.:
35
+ # https://www.minhaloja.com.br). Sem ela, o job pula limpo. \`ANTHROPIC_API_KEY\`
36
+ # é secret OPCIONAL (ranqueia issues com IA; parity roda sem ele).
37
+ #
38
+ # cand = preview URL do CF Workers Builds (mesmo polling do perf.yml).
39
+
40
+ on:
41
+ pull_request:
42
+
43
+ permissions:
44
+ contents: read
45
+ pull-requests: write
46
+ checks: read
47
+
48
+ concurrency:
49
+ group: parity-\${{ github.workflow }}-\${{ github.ref }}
50
+ cancel-in-progress: true
51
+
52
+ jobs:
53
+ parity:
54
+ runs-on: ubuntu-latest
55
+ continue-on-error: true
56
+ steps:
57
+ # Pula tudo se a URL da loja original não foi configurada.
58
+ - id: guard
59
+ name: Checar PARITY_PROD_URL
60
+ env:
61
+ PROD_URL: \${{ vars.PARITY_PROD_URL }}
62
+ run: |
63
+ if [ -z "$PROD_URL" ]; then
64
+ echo "PARITY_PROD_URL não configurada — pulando parity." >> "$GITHUB_STEP_SUMMARY"
65
+ echo "Configure em Settings → Variables → Actions para ativar." >> "$GITHUB_STEP_SUMMARY"
66
+ echo "run=false" >> "$GITHUB_OUTPUT"
67
+ else
68
+ echo "run=true" >> "$GITHUB_OUTPUT"
69
+ fi
70
+
71
+ # Aguarda o CF Workers Builds completar e extrai a preview URL do output.
72
+ # Timeout: 10 min (builds normalmente terminam em ~3 min).
73
+ - id: cf
74
+ name: Aguardar CF build e extrair preview URL
75
+ if: steps.guard.outputs.run == 'true'
76
+ env:
77
+ GH_TOKEN: \${{ github.token }}
78
+ SHA: \${{ github.event.pull_request.head.sha }}
79
+ run: |
80
+ echo "Aguardando CF Workers Builds para $SHA..."
81
+ for i in $(seq 1 60); do
82
+ summary=$(gh api \\
83
+ "repos/$GITHUB_REPOSITORY/commits/$SHA/check-runs" \\
84
+ --jq '.check_runs[] | select(.name == "Workers Builds: ${workerName}") | .output.summary' \\
85
+ 2>/dev/null || true)
86
+
87
+ if [ -n "$summary" ]; then
88
+ conclusion=$(gh api \\
89
+ "repos/$GITHUB_REPOSITORY/commits/$SHA/check-runs" \\
90
+ --jq '.check_runs[] | select(.name == "Workers Builds: ${workerName}") | .conclusion' \\
91
+ 2>/dev/null || true)
92
+
93
+ if [ "$conclusion" = "success" ]; then
94
+ url=$(printf '%s' "$summary" | grep -oP '(?<=Preview URL: )https://\\S+' | head -1)
95
+ if [ -n "$url" ]; then
96
+ echo "url=$url" >> "$GITHUB_OUTPUT"
97
+ echo "run=true" >> "$GITHUB_OUTPUT"
98
+ echo "CF build OK — preview: $url"
99
+ exit 0
100
+ fi
101
+ elif [ -n "$conclusion" ] && [ "$conclusion" != "null" ]; then
102
+ echo "CF build concluiu com: $conclusion — pulando parity"
103
+ echo "run=false" >> "$GITHUB_OUTPUT"
104
+ exit 0
105
+ fi
106
+ fi
107
+
108
+ echo "Tentativa $i/60 — aguardando 10s..."
109
+ sleep 10
110
+ done
111
+
112
+ echo "Timeout aguardando CF build — pulando parity"
113
+ echo "run=false" >> "$GITHUB_OUTPUT"
114
+
115
+ - uses: oven-sh/setup-bun@v2
116
+ if: steps.cf.outputs.run == 'true'
117
+ with:
118
+ bun-version: "1.3.5"
119
+
120
+ # parity journey dirige um browser real — precisa do binário do chromium.
121
+ - name: Instalar chromium
122
+ if: steps.cf.outputs.run == 'true'
123
+ run: bunx playwright install --with-deps chromium
124
+
125
+ - name: Rodar parity journey (prod × preview)
126
+ if: steps.cf.outputs.run == 'true'
127
+ env:
128
+ ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
129
+ run: |
130
+ bunx @decocms/parity journey \\
131
+ --prod "\${{ vars.PARITY_PROD_URL }}" \\
132
+ --cand "\${{ steps.cf.outputs.url }}" \\
133
+ --junit parity-results.xml \\
134
+ --github
135
+ `;
136
+ }
@@ -103,7 +103,7 @@ 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";
106
+ import { sectionMeta, syncComponents, loadingFallbacks, renderJsons } from "../.deco/sections.gen";
107
107
  import { PreviewProviders } from "@decocms/tanstack";
108
108
  // @ts-ignore Vite ?url import
109
109
  import appCss from "./styles/app.css?url";
@@ -140,6 +140,7 @@ applySectionConventions({
140
140
  meta: sectionMeta,
141
141
  syncComponents,
142
142
  loadingFallbacks,
143
+ renderJsons,
143
144
  sectionGlob: import.meta.glob("./sections/**/*.tsx") as Record<string, () => Promise<any>>,
144
145
  });
145
146