@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 +2 -2
- package/scripts/generate-sections.test.ts +83 -0
- package/scripts/generate-sections.ts +54 -0
- package/scripts/migrate/phase-scaffold.ts +62 -48
- package/scripts/migrate/phase-verify.ts +40 -32
- package/scripts/migrate/templates/ci-workflows.test.ts +18 -1
- package/scripts/migrate/templates/parity-yml.ts +136 -0
- package/scripts/migrate/templates/setup.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.
|
|
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.
|
|
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
|
-
|
|
4
|
-
|
|
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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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 {
|
|
27
|
+
import { generateSdkFiles } from "./templates/sdk-gen";
|
|
28
|
+
import { generateSectionLoaders } from "./templates/section-loaders";
|
|
16
29
|
import { generateServerEntry } from "./templates/server-entry";
|
|
17
|
-
import {
|
|
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 {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
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(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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 {
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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")
|
|
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")
|
|
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
|
|
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(
|
|
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
|
|