@decocms/blocks-cli 7.36.0 → 7.37.1

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.36.0",
3
+ "version": "7.37.1",
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.36.0",
34
+ "@decocms/blocks": "7.37.1",
35
35
  "ts-morph": "^27.0.0",
36
36
  "tsx": "^4.22.5"
37
37
  },
@@ -429,8 +429,7 @@ async function main() {
429
429
  console.log(`\n ${bold("Next steps:")}`);
430
430
  console.log(` cd ${outputDir}`);
431
431
  console.log(` npm install`);
432
- console.log(` npm run generate:blocks`);
433
- console.log(` npm run generate:schema`);
432
+ console.log(` npm run generate`);
434
433
  console.log(` npx tsr generate`);
435
434
  console.log(` npm run dev`);
436
435
  }
@@ -1,7 +1,5 @@
1
1
  import * as cp from "node:child_process";
2
- import * as fs from "node:fs";
3
2
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
- import * as os from "node:os";
5
3
  import { tmpdir } from "node:os";
6
4
  import path from "node:path";
7
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -105,66 +103,3 @@ describe("readBlockDelta", () => {
105
103
  expect(delta).toEqual({});
106
104
  });
107
105
  });
108
-
109
- describe("generate-blocks legacy artifact sync", () => {
110
- let tmpDir: string;
111
-
112
- beforeEach(() => {
113
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-blocks-legacy-"));
114
- // Minimal .deco/blocks/ dir with one block so output is non-empty.
115
- fs.mkdirSync(path.join(tmpDir, ".deco", "blocks"), { recursive: true });
116
- fs.writeFileSync(
117
- path.join(tmpDir, ".deco", "blocks", "pages-Home.json"),
118
- JSON.stringify({ path: "/" }),
119
- );
120
- });
121
-
122
- afterEach(() => {
123
- fs.rmSync(tmpDir, { recursive: true, force: true });
124
- });
125
-
126
- it("syncs .ts and .json to the old path when legacy file is detected", () => {
127
- const oldDir = path.join(tmpDir, "src", "server", "cms");
128
- fs.mkdirSync(oldDir, { recursive: true });
129
- fs.writeFileSync(path.join(oldDir, "blocks.gen.ts"), "// stale\n");
130
-
131
- const { code, stderr } = runGenerator([], { cwd: tmpDir });
132
- expect(code).toBe(0);
133
-
134
- expect(stderr).toContain("src/server/cms/blocks.gen.ts");
135
- expect(stderr).toContain(".deco/blocks.gen.ts");
136
- expect(stderr).toContain("Update importers to use the new path");
137
-
138
- // New path must exist.
139
- expect(fs.existsSync(path.join(tmpDir, ".deco", "blocks.gen.ts"))).toBe(true);
140
- expect(fs.existsSync(path.join(tmpDir, ".deco", "blocks.gen.json"))).toBe(true);
141
-
142
- // Old .ts must be synced (stub content, not the stale placeholder).
143
- const oldTs = fs.readFileSync(path.join(oldDir, "blocks.gen.ts"), "utf-8");
144
- expect(oldTs).not.toBe("// stale\n");
145
-
146
- // Old .json sibling must be synced — the Vite plugin reads it by suffix.
147
- const oldJson = path.join(oldDir, "blocks.gen.json");
148
- expect(fs.existsSync(oldJson)).toBe(true);
149
- const oldContent = JSON.parse(fs.readFileSync(oldJson, "utf-8"));
150
- const newContent = JSON.parse(
151
- fs.readFileSync(path.join(tmpDir, ".deco", "blocks.gen.json"), "utf-8"),
152
- );
153
- expect(oldContent).toEqual(newContent);
154
- }, 30_000);
155
-
156
- it("does not warn and does not sync when an explicit --out-file is passed", () => {
157
- const oldDir = path.join(tmpDir, "src", "server", "cms");
158
- fs.mkdirSync(oldDir, { recursive: true });
159
- fs.writeFileSync(path.join(oldDir, "blocks.gen.ts"), "// stale\n");
160
-
161
- const explicitOut = path.join(tmpDir, "custom", "blocks.gen.ts");
162
- const { code, stderr } = runGenerator(["--out-file", explicitOut], { cwd: tmpDir });
163
- expect(code).toBe(0);
164
-
165
- expect(stderr).not.toContain("Generator default output moved");
166
- expect(fs.existsSync(explicitOut)).toBe(true);
167
- // Old file must be untouched.
168
- expect(fs.readFileSync(path.join(oldDir, "blocks.gen.ts"), "utf-8")).toBe("// stale\n");
169
- }, 30_000);
170
- });
@@ -27,10 +27,6 @@
27
27
  * --out-file by swapping its extension, so passing --out-file
28
28
  * moves both artifacts together)
29
29
  *
30
- * If no `--out-file` is passed and the OLD default (src/server/cms/blocks.gen.ts)
31
- * still exists on disk, a one-line legacy warning is printed to stderr and the
32
- * NEW default is written anyway — see lib/legacyArtifact.ts.
33
- *
34
30
  * Programmatic:
35
31
  * import { generateBlocks } from "@decocms/blocks-cli/generate-blocks";
36
32
  * await generateBlocks({ blocksDir, outFile });
@@ -49,7 +45,6 @@ import {
49
45
  singleDecodeBlockName,
50
46
  } from "./lib/blocks-dedupe";
51
47
  import { buildCsvRedirectBlocks } from "./lib/csv-redirects";
52
- import { syncLegacyArtifact, warnLegacyArtifact } from "./lib/legacyArtifact";
53
48
 
54
49
  const TS_STUB = [
55
50
  "// Auto-generated — thin wrapper around blocks.gen.json.",
@@ -321,24 +316,10 @@ if (isMainModule()) {
321
316
  };
322
317
 
323
318
  const blocksDir = path.resolve(process.cwd(), arg("blocks-dir", ".deco/blocks"));
324
- const OUT_FILE_EXPLICIT = args.includes("--out-file");
325
319
  const NEW_DEFAULT_OUT_FILE = ".deco/blocks.gen.ts";
326
- const OLD_DEFAULT_OUT_FILE = "src/server/cms/blocks.gen.ts";
327
320
  const outFile = path.resolve(process.cwd(), arg("out-file", NEW_DEFAULT_OUT_FILE));
328
- const oldFilePath = path.resolve(process.cwd(), OLD_DEFAULT_OUT_FILE);
329
- const hasLegacy = !OUT_FILE_EXPLICIT && fs.existsSync(oldFilePath);
330
- if (hasLegacy) {
331
- warnLegacyArtifact(OLD_DEFAULT_OUT_FILE, NEW_DEFAULT_OUT_FILE);
332
- }
333
321
 
334
- generateBlocks({ blocksDir, outFile }).then((result) => {
335
- if (hasLegacy) {
336
- syncLegacyArtifact(oldFilePath, result.outFile);
337
- // The .ts is a Vite stub — the plugin reads the .json sibling by suffix.
338
- // Sync the JSON too so the Vite interceptor at the old path finds it.
339
- syncLegacyArtifact(oldFilePath.replace(/\.ts$/, ".json"), result.jsonFile);
340
- }
341
- }).catch((err) => {
322
+ generateBlocks({ blocksDir, outFile }).catch((err) => {
342
323
  console.error(err);
343
324
  process.exit(1);
344
325
  });
@@ -62,58 +62,3 @@ describe("generate-loaders — loader vs action emit", () => {
62
62
  expect(out).not.toContain('createLoaderEntry("site/actions/addToCart"');
63
63
  });
64
64
  });
65
-
66
- describe("generate-loaders legacy artifact sync", () => {
67
- let tmpDir: string;
68
-
69
- beforeEach(() => {
70
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-loaders-legacy-"));
71
- const write = (rel: string, content: string) => {
72
- const abs = path.join(tmpDir, rel);
73
- fs.mkdirSync(path.dirname(abs), { recursive: true });
74
- fs.writeFileSync(abs, content);
75
- };
76
- write("src/loaders/related.ts", "export default async () => [];\n");
77
- });
78
-
79
- afterEach(() => {
80
- fs.rmSync(tmpDir, { recursive: true, force: true });
81
- });
82
-
83
- it("syncs to the old path when legacy file is detected", () => {
84
- const oldDir = path.join(tmpDir, "src", "server", "cms");
85
- fs.mkdirSync(oldDir, { recursive: true });
86
- fs.writeFileSync(path.join(oldDir, "loaders.gen.ts"), "// stale\n");
87
-
88
- const { code, stderr } = run(tmpDir);
89
- expect(code).toBe(0);
90
-
91
- expect(stderr).toContain("src/server/cms/loaders.gen.ts");
92
- expect(stderr).toContain(".deco/loaders.gen.ts");
93
- expect(stderr).toContain("Update importers to use the new path");
94
-
95
- // New path must exist.
96
- expect(fs.existsSync(path.join(tmpDir, ".deco", "loaders.gen.ts"))).toBe(true);
97
-
98
- // Old file must be synced as a re-export shim (not the stale placeholder,
99
- // and not a verbatim copy — a byte copy would carry `.deco/`-relative
100
- // import paths that break at the deeper legacy location).
101
- const oldContent = fs.readFileSync(path.join(oldDir, "loaders.gen.ts"), "utf-8");
102
- expect(oldContent).not.toBe("// stale\n");
103
- expect(oldContent).toContain('export * from "../../../.deco/loaders.gen"');
104
- }, 30_000);
105
-
106
- it("does not warn and does not sync when an explicit --out-file is passed", () => {
107
- const oldDir = path.join(tmpDir, "src", "server", "cms");
108
- fs.mkdirSync(oldDir, { recursive: true });
109
- fs.writeFileSync(path.join(oldDir, "loaders.gen.ts"), "// stale\n");
110
-
111
- const explicitOut = path.join(tmpDir, "custom", "loaders.gen.ts");
112
- const { code, stderr } = run(tmpDir, ["--out-file", explicitOut]);
113
- expect(code).toBe(0);
114
-
115
- expect(stderr).not.toContain("Generator default output moved");
116
- expect(fs.existsSync(explicitOut)).toBe(true);
117
- expect(fs.readFileSync(path.join(oldDir, "loaders.gen.ts"), "utf-8")).toBe("// stale\n");
118
- }, 30_000);
119
- });
@@ -43,14 +43,9 @@
43
43
  * --exclude comma-separated list of loader keys to skip (they have custom wiring)
44
44
  * --prune-by-decofile only emit entries whose key appears as `__resolveType` in any JSON
45
45
  * --decofile-dir @deprecated alias for --prune-by-decofile
46
- *
47
- * If no `--out-file` is passed and the OLD default (src/server/cms/loaders.gen.ts)
48
- * still exists on disk, a one-line legacy warning is printed to stderr and the
49
- * NEW default is written anyway — see lib/legacyArtifact.ts.
50
46
  */
51
47
  import fs from "node:fs";
52
48
  import path from "node:path";
53
- import { syncLegacyArtifact, warnLegacyArtifact } from "./lib/legacyArtifact";
54
49
 
55
50
  const args = process.argv.slice(2);
56
51
  function arg(name: string, fallback: string): string {
@@ -60,15 +55,8 @@ function arg(name: string, fallback: string): string {
60
55
 
61
56
  const loadersDir = path.resolve(process.cwd(), arg("loaders-dir", "src/loaders"));
62
57
  const actionsDir = path.resolve(process.cwd(), arg("actions-dir", "src/actions"));
63
- const OUT_FILE_EXPLICIT = args.includes("--out-file");
64
58
  const NEW_DEFAULT_OUT_FILE = ".deco/loaders.gen.ts";
65
- const OLD_DEFAULT_OUT_FILE = "src/server/cms/loaders.gen.ts";
66
59
  const outFile = path.resolve(process.cwd(), arg("out-file", NEW_DEFAULT_OUT_FILE));
67
- const oldLoadersPath = path.resolve(process.cwd(), OLD_DEFAULT_OUT_FILE);
68
- const hasLegacy = !OUT_FILE_EXPLICIT && fs.existsSync(oldLoadersPath);
69
- if (hasLegacy) {
70
- warnLegacyArtifact(OLD_DEFAULT_OUT_FILE, NEW_DEFAULT_OUT_FILE);
71
- }
72
60
  const excludeRaw = arg("exclude", "");
73
61
  const excludeSet = new Set(excludeRaw.split(",").map((s) => s.trim()).filter(Boolean));
74
62
 
@@ -256,7 +244,6 @@ lines.push("");
256
244
 
257
245
  fs.mkdirSync(path.dirname(outFile), { recursive: true });
258
246
  fs.writeFileSync(outFile, lines.join("\n"));
259
- if (hasLegacy) syncLegacyArtifact(oldLoadersPath, outFile);
260
247
 
261
248
  const filterNote = cmsReferences
262
249
  ? ` (filtered against ${cmsReferences.size} CMS __resolveType references; pruned ${prunedCount} dead entries)`
@@ -252,10 +252,10 @@ describe("typeToJsonSchema loader block-ref matching", () => {
252
252
  });
253
253
 
254
254
  // ---------------------------------------------------------------------------
255
- // Default output path (.deco/) + legacy warning — subprocess, mirrors the
256
- // pattern in generate-sections.test.ts. generate-schema.ts IS guarded by
257
- // isMainModule(), but it's still driven as a subprocess here so the CLI's
258
- // argv-parsed OUT_REL/legacy-check top-level code runs against a real cwd.
255
+ // Default output path (.deco/) — subprocess, mirrors the pattern in
256
+ // generate-sections.test.ts. generate-schema.ts IS guarded by isMainModule(),
257
+ // but it's still driven as a subprocess here so the CLI's argv-parsed OUT_REL
258
+ // top-level code runs against a real cwd.
259
259
  // ---------------------------------------------------------------------------
260
260
 
261
261
  const SCRIPT = path.resolve(__dirname, "generate-schema.ts");
@@ -313,39 +313,9 @@ describe("generate-schema default output path (.deco/)", () => {
313
313
  expect(meta.manifest.blocks.sections).toHaveProperty("site/sections/Hero.tsx");
314
314
  }, 30_000);
315
315
 
316
- it("warns once to stderr naming both paths when the OLD default file exists and no --out is passed, but still writes the NEW default", () => {
317
- const oldDefaultDir = path.join(tmpDir, "src", "server", "admin");
318
- fs.mkdirSync(oldDefaultDir, { recursive: true });
319
- fs.writeFileSync(path.join(oldDefaultDir, "meta.gen.json"), "{}");
320
-
316
+ it("does not warn about a legacy default path", () => {
321
317
  const { code, stderr } = runGenerator(["--skip-apps"], { cwd: tmpDir });
322
318
  expect(code).toBe(0);
323
-
324
- expect(stderr).toContain("src/server/admin/meta.gen.json");
325
- expect(stderr).toContain(".deco/meta.gen.json");
326
- expect(stderr).toContain("Update importers to use the new path");
327
-
328
- const newDefault = path.join(tmpDir, ".deco", "meta.gen.json");
329
- expect(fs.existsSync(newDefault)).toBe(true);
330
-
331
- // Old file must be kept in sync so mid-migration imports still work.
332
- const oldDefault = path.join(tmpDir, "src", "server", "admin", "meta.gen.json");
333
- expect(fs.existsSync(oldDefault)).toBe(true);
334
- const oldContent = JSON.parse(fs.readFileSync(oldDefault, "utf-8"));
335
- const newContent = JSON.parse(fs.readFileSync(newDefault, "utf-8"));
336
- expect(oldContent).toEqual(newContent);
337
- }, 30_000);
338
-
339
- it("does not warn when an explicit --out is passed, even if the OLD default file exists", () => {
340
- const oldDefaultDir = path.join(tmpDir, "src", "server", "admin");
341
- fs.mkdirSync(oldDefaultDir, { recursive: true });
342
- fs.writeFileSync(path.join(oldDefaultDir, "meta.gen.json"), "{}");
343
-
344
- const explicitOut = path.join(tmpDir, "custom", "meta.gen.json");
345
- const { code, stderr } = runGenerator(["--skip-apps", "--out", explicitOut], { cwd: tmpDir });
346
- expect(code).toBe(0);
347
-
348
319
  expect(stderr).not.toContain("Generator default output moved");
349
- expect(fs.existsSync(explicitOut)).toBe(true);
350
320
  }, 30_000);
351
321
  });
@@ -33,10 +33,6 @@ import { fileURLToPath } from "node:url";
33
33
  * from disk with no runtime (FS-based Studio / Eitri stack).
34
34
  * --framework With --compose, the value written to the `framework` field
35
35
  * (default: "tanstack-start").
36
- *
37
- * If no `--out` is passed and the OLD default (src/server/admin/meta.gen.json)
38
- * still exists on disk, a one-line legacy warning is printed to stderr and the
39
- * NEW default is written anyway — see lib/legacyArtifact.ts.
40
36
  */
41
37
  import {
42
38
  type Symbol as MorphSymbol,
@@ -47,7 +43,6 @@ import {
47
43
  type Type,
48
44
  } from "ts-morph";
49
45
  import { isExcludedCodegenFile } from "./lib/codegenExclusions";
50
- import { syncLegacyArtifact, warnLegacyArtifact } from "./lib/legacyArtifact";
51
46
 
52
47
  // ---------------------------------------------------------------------------
53
48
  // CLI arg parsing
@@ -56,12 +51,9 @@ import { syncLegacyArtifact, warnLegacyArtifact } from "./lib/legacyArtifact";
56
51
  // here) so that importing this module for its pure exports
57
52
  // (definitionIdForPath, applyWidgetFormat, typeToJsonSchema — see
58
53
  // generate-schema.test.ts) never reads argv or touches the filesystem.
59
- // Without the guard, every import used to run an `fs.existsSync` check
60
- // against the *importing process's* cwd and could print a legacy-artifact
61
- // warning to stderr as an import-time side effect, unrelated to whatever
62
- // the test actually wanted to exercise. generateMeta() (below) and the
63
- // final write are themselves only reached inside `if (isMainModule())`, so
64
- // these vars only need real values in that same case.
54
+ // generateMeta() (below) and the final write are themselves only reached
55
+ // inside `if (isMainModule())`, so these vars only need real values in that
56
+ // same case.
65
57
  // ---------------------------------------------------------------------------
66
58
  const argv = process.argv.slice(2);
67
59
  function arg(name: string, fallback: string): string {
@@ -70,7 +62,6 @@ function arg(name: string, fallback: string): string {
70
62
  }
71
63
 
72
64
  const NEW_DEFAULT_OUT_REL = ".deco/meta.gen.json";
73
- const OLD_DEFAULT_OUT_REL = "src/server/admin/meta.gen.json";
74
65
 
75
66
  let SITE_NAMESPACE = "site";
76
67
  let SITE_NAME = "storefront";
@@ -90,8 +81,6 @@ let COMPOSE = false;
90
81
  // Value written to the composed meta's `framework` field (only used with
91
82
  // --compose). Defaults to composeMeta's historical "tanstack-start".
92
83
  let FRAMEWORK = "tanstack-start";
93
- let _hasLegacySchema = false;
94
- let _oldSchemaPath = "";
95
84
 
96
85
  if (isMainModule()) {
97
86
  SITE_NAMESPACE = arg("namespace", SITE_NAMESPACE);
@@ -101,16 +90,10 @@ if (isMainModule()) {
101
90
  LOADERS_REL = arg("loaders", LOADERS_REL);
102
91
  APPS_REL = arg("apps", APPS_REL);
103
92
  SKIP_APPS = argv.includes("--skip-apps");
104
- const outFileExplicit = argv.includes("--out");
105
93
  OUT_REL = arg("out", NEW_DEFAULT_OUT_REL);
106
94
  PLATFORM = arg("platform", PLATFORM);
107
95
  COMPOSE = argv.includes("--compose");
108
96
  FRAMEWORK = arg("framework", FRAMEWORK);
109
- _oldSchemaPath = path.resolve(process.cwd(), OLD_DEFAULT_OUT_REL);
110
- _hasLegacySchema = !outFileExplicit && fs.existsSync(_oldSchemaPath);
111
- if (_hasLegacySchema) {
112
- warnLegacyArtifact(OLD_DEFAULT_OUT_REL, NEW_DEFAULT_OUT_REL);
113
- }
114
97
  }
115
98
 
116
99
  // ---------------------------------------------------------------------------
@@ -1541,7 +1524,6 @@ if (isMainModule()) {
1541
1524
  const outPath = path.resolve(process.cwd(), OUT_REL);
1542
1525
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
1543
1526
  fs.writeFileSync(outPath, JSON.stringify(meta, null, 2));
1544
- if (_hasLegacySchema) syncLegacyArtifact(_oldSchemaPath, outPath);
1545
1527
 
1546
1528
  const defCount = Object.keys(meta.schema.definitions).length;
1547
1529
  const secCount = Object.keys(meta.manifest.blocks.sections || {}).length;
@@ -115,48 +115,9 @@ describe("generate-sections default output path (.deco/)", () => {
115
115
  const newDefault = path.join(tmpDir, ".deco", "sections.gen.ts");
116
116
  expect(fs.existsSync(newDefault)).toBe(true);
117
117
  expect(fs.readFileSync(newDefault, "utf-8")).toContain("site/sections/Hero.tsx");
118
- // No legacy file present, so no warning is expected.
118
+ // No legacy default path is written anymore, so no warning is expected.
119
119
  expect(stderr).not.toContain("Generator default output moved");
120
120
  }, 30_000);
121
-
122
- it("warns once to stderr naming both paths when the OLD default file exists and no --out-file is passed, but still writes the NEW default", () => {
123
- const oldDefaultDir = path.join(tmpDir, "src", "server", "cms");
124
- fs.mkdirSync(oldDefaultDir, { recursive: true });
125
- fs.writeFileSync(path.join(oldDefaultDir, "sections.gen.ts"), "// stale\n");
126
-
127
- const { code, stderr } = runGenerator([], { cwd: tmpDir });
128
- expect(code).toBe(0);
129
-
130
- expect(stderr).toContain("src/server/cms/sections.gen.ts");
131
- expect(stderr).toContain(".deco/sections.gen.ts");
132
- expect(stderr).toContain("Update importers to use the new path");
133
-
134
- const newDefault = path.join(tmpDir, ".deco", "sections.gen.ts");
135
- expect(fs.existsSync(newDefault)).toBe(true);
136
- expect(fs.readFileSync(newDefault, "utf-8")).toContain("site/sections/Hero.tsx");
137
-
138
- // Old file is kept in sync as a re-export shim (NOT a verbatim copy — a
139
- // byte copy would carry `.deco/`-relative import paths that break at the
140
- // deeper legacy location). It re-exports the new file so mid-migration
141
- // imports still resolve at any depth.
142
- const oldDefault = path.join(tmpDir, "src", "server", "cms", "sections.gen.ts");
143
- const oldContent = fs.readFileSync(oldDefault, "utf-8");
144
- expect(oldContent).toContain('export * from "../../../.deco/sections.gen"');
145
- expect(oldContent).not.toContain("site/sections/Hero.tsx");
146
- }, 30_000);
147
-
148
- it("does not warn when an explicit --out-file is passed, even if the OLD default file exists", () => {
149
- const oldDefaultDir = path.join(tmpDir, "src", "server", "cms");
150
- fs.mkdirSync(oldDefaultDir, { recursive: true });
151
- fs.writeFileSync(path.join(oldDefaultDir, "sections.gen.ts"), "// stale\n");
152
-
153
- const explicitOut = path.join(tmpDir, "custom", "sections.gen.ts");
154
- const { code, stderr } = runGenerator(["--out-file", explicitOut], { cwd: tmpDir });
155
- expect(code).toBe(0);
156
-
157
- expect(stderr).not.toContain("Generator default output moved");
158
- expect(fs.existsSync(explicitOut)).toBe(true);
159
- }, 30_000);
160
121
  });
161
122
 
162
123
  describe("generate-sections --registry", () => {
@@ -28,15 +28,10 @@
28
28
  * Built from every scanned section file, not just the ones
29
29
  * carrying convention exports. Off by default so existing
30
30
  * Vite sites regenerating sections.gen.ts in CI see zero diff.
31
- *
32
- * If no `--out-file` is passed and the OLD default (src/server/cms/sections.gen.ts)
33
- * still exists on disk, a one-line legacy warning is printed to stderr and the
34
- * NEW default is written anyway — see lib/legacyArtifact.ts.
35
31
  */
36
32
  import fs from "node:fs";
37
33
  import path from "node:path";
38
34
  import { isExcludedCodegenFile } from "./lib/codegenExclusions";
39
- import { syncLegacyArtifact, warnLegacyArtifact } from "./lib/legacyArtifact";
40
35
 
41
36
  const args = process.argv.slice(2);
42
37
  function arg(name: string, fallback: string): string {
@@ -45,15 +40,8 @@ function arg(name: string, fallback: string): string {
45
40
  }
46
41
 
47
42
  const sectionsDir = path.resolve(process.cwd(), arg("sections-dir", "src/sections"));
48
- const OUT_FILE_EXPLICIT = args.includes("--out-file");
49
43
  const NEW_DEFAULT_OUT_FILE = ".deco/sections.gen.ts";
50
- const OLD_DEFAULT_OUT_FILE = "src/server/cms/sections.gen.ts";
51
44
  const outFile = path.resolve(process.cwd(), arg("out-file", NEW_DEFAULT_OUT_FILE));
52
- const oldSectionsPath = path.resolve(process.cwd(), OLD_DEFAULT_OUT_FILE);
53
- const hasLegacy = !OUT_FILE_EXPLICIT && fs.existsSync(oldSectionsPath);
54
- if (hasLegacy) {
55
- warnLegacyArtifact(OLD_DEFAULT_OUT_FILE, NEW_DEFAULT_OUT_FILE);
56
- }
57
45
  const EMIT_REGISTRY = args.includes("--registry");
58
46
 
59
47
  interface SectionMeta {
@@ -295,7 +283,6 @@ fs.writeFileSync(
295
283
  outFile,
296
284
  lines.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n"),
297
285
  );
298
- if (hasLegacy) syncLegacyArtifact(oldSectionsPath, outFile);
299
286
 
300
287
  console.log(
301
288
  `Generated section metadata for ${entries.length} sections → ${path.relative(process.cwd(), outFile)}`,
@@ -199,7 +199,7 @@ export function report(ctx: MigrationContext): void {
199
199
  lines.push("- [ ] `src/hooks/useCart.ts` — wire to actual server functions for your platform");
200
200
  lines.push("- [ ] `src/worker-entry.ts` — verify CSP, proxy, and segment builder");
201
201
  lines.push("- [ ] See the **CSS Migration** section above for DaisyUI v4→v5 / Tailwind v3→v4 findings specific to this site");
202
- lines.push("- [ ] Run `npm run generate:blocks` and `npm run generate:schema` after migration");
202
+ lines.push("- [ ] Run `npm run generate` (unified: blocks, sections, loaders, schema) after migration");
203
203
  lines.push("");
204
204
 
205
205
  // Known Issues
@@ -250,9 +250,8 @@ export function report(ctx: MigrationContext): void {
250
250
  lines.push("# 1. Install dependencies (bun is the canonical PM)");
251
251
  lines.push("bun install");
252
252
  lines.push("");
253
- lines.push("# 2. Generate CMS blocks and schema");
254
- lines.push("bun run generate:blocks");
255
- lines.push("bun run generate:schema");
253
+ lines.push("# 2. Generate CMS artifacts (blocks, sections, loaders, schema)");
254
+ lines.push("bun run generate");
256
255
  lines.push("");
257
256
  lines.push("# 3. Generate routes");
258
257
  lines.push("bunx tsr generate");
@@ -20,6 +20,7 @@ import { generateSectionLoaders } from "./templates/section-loaders";
20
20
  import { generateCacheConfig } from "./templates/cache-config";
21
21
  import { generateSdkFiles } from "./templates/sdk-gen";
22
22
  import { generateMigrationPolicyPointerRule } from "./templates/cursor-rules";
23
+ import { generatePerfFiles } from "./templates/perf-yml";
23
24
  // `lib-utils` is imported lazily — see end of phase-cleanup. Eager
24
25
  // generation of all 11 shims left every site with dead code that had
25
26
  // to be cleaned up by hand.
@@ -87,6 +88,11 @@ export function scaffold(ctx: MigrationContext): void {
87
88
  generateLockfileCheckYml(CANONICAL_BUN_VERSION),
88
89
  );
89
90
 
91
+ // Advisory per-PR performance workflow: detects which sections changed,
92
+ // maps them to CMS page paths, runs Lighthouse against the CF preview URL
93
+ // (PR vs main), and posts a comparison comment. Gate = CLS + TBT only.
94
+ writeMultiFile(ctx, generatePerfFiles(ctx.siteName));
95
+
90
96
  // Server entry files (server.ts, worker-entry.ts, router.tsx, runtime.ts, context.ts)
91
97
  writeMultiFile(ctx, generateServerEntry(ctx));
92
98
 
@@ -20,7 +20,6 @@ const REQUIRED_FILES = [
20
20
  // Deploy / preview / sync-secrets pipelines are owned by Cloudflare
21
21
  // Workers Builds (D6.3) -- configured in the CF dashboard, not via
22
22
  // GitHub workflow files in the site repo.
23
- ".github/workflows/regen-blocks.yml",
24
23
  ".github/workflows/lockfile-check.yml",
25
24
  "knip.config.ts",
26
25
  ".prettierrc",
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Scaffolds the advisory per-PR performance workflow and its companion tools:
3
+ * .github/workflows/perf.yml — GitHub Actions workflow
4
+ * tools/perf/changed-paths.sh — maps edited sections → CMS page paths
5
+ * tools/perf/compare.mjs — compares two LHCI result dirs, emits markdown
6
+ * lighthouserc.json — LHCI collect settings (3 runs, mobile UA)
7
+ *
8
+ * Design rationale:
9
+ * - CF Workers Builds generates a per-PR preview URL (workers.dev). Both the
10
+ * PR and main previews are workers.dev — no edge cache — so the delta is
11
+ * purely code, not cache state.
12
+ * - Gate signal = CLS + TBT (layout stability / JS work; cache-insensitive).
13
+ * LCP and score are informational only (noisy on cold SSR).
14
+ * - Advisory: `continue-on-error: true`. Remove it once the site stabilises.
15
+ * - LHCI 0.15 does not write manifest.json; compare.mjs reads lhr-*.json
16
+ * directly and selects the median run by performance score.
17
+ */
18
+
19
+ /**
20
+ * @param workerName Cloudflare Worker name (= wrangler `name` field, e.g. "oficina-tanstack").
21
+ * Used in the CF check-run name and the main preview base URL.
22
+ */
23
+ export function generatePerfFiles(workerName: string): Record<string, string> {
24
+ return {
25
+ ".github/workflows/perf.yml": generatePerfYml(workerName),
26
+ "tools/perf/changed-paths.sh": CHANGED_PATHS_SH,
27
+ "tools/perf/compare.mjs": COMPARE_MJS,
28
+ "lighthouserc.json": LIGHTHOUSERC_JSON,
29
+ };
30
+ }
31
+
32
+ function generatePerfYml(workerName: string): string {
33
+ return `name: Perf
34
+
35
+ # Teste de performance por PR (advisory — comenta, não trava; vire gate removendo
36
+ # o \`continue-on-error\` do job quando estabilizar, igual react-doctor/typecheck).
37
+ #
38
+ # Trigger: pull_request (único trigger que roda do branch do PR, não do main).
39
+ # Aguarda o check do CF Workers Builds completar via polling, então extrai a
40
+ # preview URL do output — sem token CF nem build local.
41
+ #
42
+ # Baseline: URL fixa da preview do main (https://${workerName}.deco-cx.workers.dev).
43
+ # Gate: CLS + TBT (estrutura/JS, insensíveis a cache). LCP/score: informativos.
44
+
45
+ on:
46
+ pull_request:
47
+
48
+ permissions:
49
+ contents: read
50
+ pull-requests: write
51
+ checks: read
52
+
53
+ concurrency:
54
+ group: perf-\${{ github.workflow }}-\${{ github.ref }}
55
+ cancel-in-progress: true
56
+
57
+ env:
58
+ MOBILE_UA: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
59
+ BASE_URL: "https://${workerName}.deco-cx.workers.dev"
60
+ PERF_FALLBACK_PATHS: |
61
+ /
62
+
63
+ jobs:
64
+ perf:
65
+ runs-on: ubuntu-latest
66
+ continue-on-error: true
67
+ steps:
68
+ # Aguarda o CF Workers Builds completar e extrai a preview URL do output.
69
+ # Timeout: 10 min (builds normalmente terminam em ~3 min).
70
+ - id: cf
71
+ name: Aguardar CF build e extrair preview URL
72
+ env:
73
+ GH_TOKEN: \${{ github.token }}
74
+ SHA: \${{ github.event.pull_request.head.sha }}
75
+ run: |
76
+ echo "Aguardando CF Workers Builds para \$SHA..."
77
+ for i in \$(seq 1 60); do
78
+ summary=\$(gh api \\
79
+ "repos/\$GITHUB_REPOSITORY/commits/\$SHA/check-runs" \\
80
+ --jq '.check_runs[] | select(.name == "Workers Builds: ${workerName}") | .output.summary' \\
81
+ 2>/dev/null || true)
82
+
83
+ if [ -n "\$summary" ]; then
84
+ conclusion=\$(gh api \\
85
+ "repos/\$GITHUB_REPOSITORY/commits/\$SHA/check-runs" \\
86
+ --jq '.check_runs[] | select(.name == "Workers Builds: ${workerName}") | .conclusion' \\
87
+ 2>/dev/null || true)
88
+
89
+ if [ "\$conclusion" = "success" ]; then
90
+ url=\$(printf '%s' "\$summary" | grep -oP '(?<=Preview URL: )https://\\S+' | head -1)
91
+ if [ -n "\$url" ]; then
92
+ echo "url=\$url" >> "\$GITHUB_OUTPUT"
93
+ echo "run=true" >> "\$GITHUB_OUTPUT"
94
+ echo "CF build OK — preview: \$url"
95
+ exit 0
96
+ fi
97
+ elif [ -n "\$conclusion" ] && [ "\$conclusion" != "null" ]; then
98
+ echo "CF build concluiu com: \$conclusion — pulando perf"
99
+ echo "run=false" >> "\$GITHUB_OUTPUT"
100
+ exit 0
101
+ fi
102
+ fi
103
+
104
+ echo "Tentativa \$i/60 — aguardando 10s..."
105
+ sleep 10
106
+ done
107
+
108
+ echo "Timeout aguardando CF build — pulando perf"
109
+ echo "run=false" >> "\$GITHUB_OUTPUT"
110
+
111
+ - uses: actions/checkout@v4
112
+ if: steps.cf.outputs.run == 'true'
113
+ with:
114
+ fetch-depth: 0
115
+
116
+ - uses: actions/setup-node@v4
117
+ if: steps.cf.outputs.run == 'true'
118
+ with:
119
+ node-version: "22.15.0"
120
+
121
+ - id: paths
122
+ name: Mapear seções editadas → paths
123
+ if: steps.cf.outputs.run == 'true'
124
+ env:
125
+ GH_TOKEN: \${{ github.token }}
126
+ run: |
127
+ # GitHub PR Files API = source of truth (evita problemas de squash merge)
128
+ changed=\$(gh api \\
129
+ "repos/\$GITHUB_REPOSITORY/pulls/\${{ github.event.pull_request.number }}/files" \\
130
+ --paginate --jq '.[].filename' \\
131
+ | grep -E '^src/(sections|previews)/.*\\.tsx\$' || true)
132
+ echo "--- seções alteradas neste PR ---"
133
+ printf '%s\\n' "\$changed"
134
+ chmod +x tools/perf/changed-paths.sh
135
+ CHANGED_FILES="\$changed" tools/perf/changed-paths.sh > perf-paths.txt || true
136
+ echo "--- paths a medir ---"; cat perf-paths.txt
137
+ if [ -s perf-paths.txt ]; then echo "run=true" >> "\$GITHUB_OUTPUT"
138
+ else echo "run=false" >> "\$GITHUB_OUTPUT"; fi
139
+
140
+ - name: Warm previews
141
+ if: steps.cf.outputs.run == 'true' && steps.paths.outputs.run == 'true'
142
+ env:
143
+ PR_URL: \${{ steps.cf.outputs.url }}
144
+ run: |
145
+ # --max-time 30: garante que curl não trava num SSR lento
146
+ while IFS= read -r p; do
147
+ [ -n "\$p" ] || continue
148
+ for origin in "\$PR_URL" "\$BASE_URL"; do
149
+ for _ in 1 2; do
150
+ curl -s -o /dev/null --max-time 30 -A "\$MOBILE_UA" \\
151
+ -w "warm %{http_code} \${origin}\${p} ttfb=%{time_starttransfer}s\\n" \\
152
+ "\${origin}\${p}" || true
153
+ done
154
+ done
155
+ done < perf-paths.txt
156
+
157
+ - name: Lighthouse (PR + base)
158
+ if: steps.cf.outputs.run == 'true' && steps.paths.outputs.run == 'true'
159
+ env:
160
+ PR_URL: \${{ steps.cf.outputs.url }}
161
+ run: |
162
+ collect() {
163
+ local origin="\$1" out="\$2" args=()
164
+ while IFS= read -r p; do [ -n "\$p" ] && args+=(--url="\${origin}\${p}"); done < perf-paths.txt
165
+ rm -rf .lighthouseci "\$out"
166
+ npx @lhci/cli collect --config=lighthouserc.json "\${args[@]}"
167
+ mv .lighthouseci "\$out"
168
+ echo "--- \$out contents ---"; ls -la "\$out" || true
169
+ }
170
+ collect "\$PR_URL" .lhci-pr
171
+ collect "\$BASE_URL" .lhci-base
172
+
173
+ - name: Montar comentário
174
+ if: steps.cf.outputs.run == 'true' && steps.paths.outputs.run == 'true'
175
+ run: |
176
+ node tools/perf/compare.mjs .lhci-base .lhci-pr | tee perf-comment.md >> "\$GITHUB_STEP_SUMMARY"
177
+
178
+ - name: Comentar no PR
179
+ if: steps.cf.outputs.run == 'true' && steps.paths.outputs.run == 'true'
180
+ uses: marocchino/sticky-pull-request-comment@v2
181
+ with:
182
+ header: perf
183
+ path: perf-comment.md
184
+ `;
185
+ }
186
+
187
+ const CHANGED_PATHS_SH = `#!/usr/bin/env bash
188
+ set -euo pipefail
189
+
190
+ # Map the sections changed in this PR to the CMS page path(s) that render them,
191
+ # so the perf workflow only measures pages the PR can actually affect.
192
+ #
193
+ # How: each edited src/sections/**/*.tsx (or its previews/ twin) becomes a
194
+ # resolveType ("site/sections/<subpath>"); we grep the checked-in decofile
195
+ # (.deco/blocks/*.json) for that string and emit the \`path\` of every PAGE block
196
+ # that references it. Reusable/instance blocks (path == null) and param/wildcard
197
+ # paths (/*, /:slug/p, /conta/*) are skipped — they can't be measured as-is.
198
+ #
199
+ # Outcomes:
200
+ # - No section/preview files changed → print PERF_FALLBACK_PATHS (home sanity).
201
+ # - Sections changed, page(s) mapped → print the mapped path(s).
202
+ # - Sections changed, none mapped → print PERF_FALLBACK_PATHS + log why.
203
+ #
204
+ # Output: one URL path per line on stdout. Diagnostics go to stderr.
205
+ #
206
+ # Env:
207
+ # CHANGED_FILES newline-separated list of changed section/preview files
208
+ # (from GitHub PR Files API — preferred, avoids squash-merge issues)
209
+ # BASE_REF git ref/sha to diff against (fallback if CHANGED_FILES unset)
210
+ # PERF_FALLBACK_PATHS newline-separated fallback paths (default: "/")
211
+ # PERF_MAX_PATHS max paths to emit (default: 3). Sections used on many
212
+ # pages (Header/Footer) would otherwise produce 100+ paths;
213
+ # any page is representative for a shared section.
214
+
215
+ ROOT_DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")/../.." && pwd)"
216
+ cd "\$ROOT_DIR"
217
+
218
+ BASE_REF="\${BASE_REF:-origin/main}"
219
+ BLOCKS_DIR=".deco/blocks"
220
+
221
+ log() { printf '%s\\n' "\$*" >&2; }
222
+
223
+ emit_fallback() {
224
+ log "perf: sections changed but no page mapped — using PERF_FALLBACK_PATHS"
225
+ printf '%s\\n' "\${PERF_FALLBACK_PATHS:-/}" | sed 's/[[:space:]]*\$//' | sed '/^\$/d'
226
+ }
227
+
228
+ # Prefer CHANGED_FILES (set by the workflow from the GitHub PR Files API) to
229
+ # avoid squash-merge ancestry issues where git log A..B shows all historical commits.
230
+ # Fall back to git log only when running locally without CHANGED_FILES.
231
+ changed=()
232
+ if [ -n "\${CHANGED_FILES:-}" ]; then
233
+ while IFS= read -r line; do
234
+ [ -n "\$line" ] && changed+=("\$line")
235
+ done < <(printf '%s\\n' "\$CHANGED_FILES" | grep -E '\\.tsx\$' | sort -u || true)
236
+ else
237
+ while IFS= read -r line; do
238
+ [ -n "\$line" ] && changed+=("\$line")
239
+ done < <(git log --name-only --format="" "\$BASE_REF"..HEAD -- src/sections src/previews 2>/dev/null | grep -E '\\.tsx\$' | sort -u || true)
240
+ fi
241
+
242
+ # No sections changed → measure home as a baseline sanity check.
243
+ if [ "\${#changed[@]}" -eq 0 ]; then
244
+ log "perf: nenhuma seção alterada — usando PERF_FALLBACK_PATHS como sanity"
245
+ emit_fallback
246
+ exit 0
247
+ fi
248
+
249
+ # Each changed file → the resolveType the decofile references it by.
250
+ resolvetypes=()
251
+ for f in "\${changed[@]}"; do
252
+ case "\$f" in
253
+ src/sections/*) resolvetypes+=("site/\${f#src/}") ;;
254
+ src/previews/*) resolvetypes+=("site/sections/\${f#src/previews/}") ;;
255
+ esac
256
+ done
257
+ [ "\${#resolvetypes[@]}" -eq 0 ] && exit 0
258
+
259
+ # Grep the decofile for each resolveType; collect page paths (non-null).
260
+ paths=()
261
+ for rt in "\${resolvetypes[@]}"; do
262
+ while IFS= read -r block; do
263
+ [ -n "\$block" ] || continue
264
+ while IFS= read -r p; do
265
+ [ -n "\$p" ] && paths+=("\$p")
266
+ done < <(jq -r 'select(.path != null and (.path | type == "string")) | .path' "\$block" 2>/dev/null || true)
267
+ done < <(grep -Fl "\$rt" "\$BLOCKS_DIR"/*.json 2>/dev/null || true)
268
+ done
269
+
270
+ # Dedupe + drop param/wildcard paths (need a concrete slug; can't measure as-is).
271
+ clean=()
272
+ if [ "\${#paths[@]}" -gt 0 ]; then
273
+ while IFS= read -r line; do
274
+ [ -n "\$line" ] && clean+=("\$line")
275
+ done < <(printf '%s\\n' "\${paths[@]}" | grep -vE '[:*]' | sort -u)
276
+ fi
277
+
278
+ if [ "\${#clean[@]}" -eq 0 ]; then
279
+ emit_fallback
280
+ exit 0
281
+ fi
282
+
283
+ MAX="\${PERF_MAX_PATHS:-3}"
284
+ total="\${#clean[@]}"
285
+ if [ "\$total" -gt "\$MAX" ]; then
286
+ log "perf: \${total} pages mapped — capped at \${MAX} (ponytail: PERF_MAX_PATHS). Dropped: \${clean[*]:\$MAX}"
287
+ clean=("\${clean[@]:0:\$MAX}")
288
+ fi
289
+
290
+ log "perf: measuring \${#clean[@]} page(s) mapped from \${#changed[@]} changed file(s)"
291
+ printf '%s\\n' "\${clean[@]}"
292
+ `;
293
+
294
+ const COMPARE_MJS = `#!/usr/bin/env node
295
+ // Compare two Lighthouse CI result dirs (base = main preview, pr = PR preview)
296
+ // and emit a markdown table. Both previews run on *.workers.dev with no edge
297
+ // cache, same runner/region, same mobile UA — so the delta is the PR's code
298
+ // effect, not cache warm/cold.
299
+ //
300
+ // Gate signal = CLS + TBT (page structure / JS work — cache-insensitive).
301
+ // LCP / score are shown but informative only (noisy on cold workers.dev SSR).
302
+ //
303
+ // LHCI 0.15 does not write manifest.json; we read lhr-*.json directly.
304
+ //
305
+ // Usage: node compare.mjs <baseDir> <prDir> → writes markdown to stdout
306
+ // node compare.mjs --selftest → runs assertions, exits 0/1
307
+
308
+ import { readFileSync, readdirSync } from "node:fs";
309
+ import { join } from "node:path";
310
+
311
+ const TBT_ABS = 50; // ms — deltas below this are noise
312
+ const TBT_REL = 0.15; // 15% relative change
313
+ const CLS_ABS = 0.02; // absolute CLS delta that matters
314
+ const CLS_FLOOR = 0.1; // only flag CLS regression when PR is above "good"
315
+
316
+ function verdict(base, pr) {
317
+ const tbtUp = pr.tbt - base.tbt;
318
+ const tbtBad = tbtUp > TBT_ABS && tbtUp > base.tbt * TBT_REL;
319
+ const tbtGood = -tbtUp > TBT_ABS && -tbtUp > base.tbt * TBT_REL;
320
+ const clsUp = pr.cls - base.cls;
321
+ const clsBad = clsUp > CLS_ABS && pr.cls > CLS_FLOOR;
322
+ const clsGood = -clsUp > CLS_ABS && base.cls > CLS_FLOOR;
323
+ if (tbtBad || clsBad) return { icon: "🔴", gate: true };
324
+ if (tbtGood || clsGood) return { icon: "🟢", gate: false };
325
+ return { icon: "⚪", gate: false };
326
+ }
327
+
328
+ function loadDir(dir) {
329
+ const files = readdirSync(dir).filter((f) => f.startsWith("lhr-") && f.endsWith(".json"));
330
+ const byUrl = {};
331
+ for (const f of files) {
332
+ const lhr = JSON.parse(readFileSync(join(dir, f), "utf8"));
333
+ const url = lhr.finalUrl || lhr.requestedUrl;
334
+ if (!byUrl[url]) byUrl[url] = [];
335
+ byUrl[url].push(lhr);
336
+ }
337
+ const byPath = {};
338
+ for (const [url, runs] of Object.entries(byUrl)) {
339
+ runs.sort((a, b) => a.categories.performance.score - b.categories.performance.score);
340
+ const lhr = runs[Math.floor(runs.length / 2)];
341
+ const a = lhr.audits;
342
+ byPath[new URL(url).pathname] = {
343
+ score: Math.round((lhr.categories.performance.score ?? 0) * 100),
344
+ lcp: a["largest-contentful-paint"].numericValue,
345
+ cls: a["cumulative-layout-shift"].numericValue,
346
+ tbt: a["total-blocking-time"].numericValue,
347
+ };
348
+ }
349
+ return byPath;
350
+ }
351
+
352
+ const ms = (v) => \`\${Math.round(v)}ms\`;
353
+ const cls = (v) => v.toFixed(3);
354
+ function delta(base, pr, fmt) {
355
+ const d = pr - base;
356
+ const arrow = d < 0 ? "↓" : d > 0 ? "↑" : "";
357
+ return \`\${d > 0 ? "+" : ""}\${fmt(d)} \${arrow}\`.trim();
358
+ }
359
+
360
+ function render(base, pr) {
361
+ const paths = [...new Set([...Object.keys(base), ...Object.keys(pr)])].sort();
362
+ let anyGate = false;
363
+ const lines = [
364
+ "### ⚡ Perf — preview do PR × preview do main",
365
+ "",
366
+ "Ambas as previews rodam em \`*.workers.dev\` (sem edge cache), mesmo runner e UA mobile — o delta é efeito do código, não de cache quente/frio.",
367
+ "",
368
+ "**Gate:** CLS + TBT (estrutura / JS). LCP e score são informativos (ruído esperado em SSR frio).",
369
+ "",
370
+ "| Página | | CLS (base→PR) | TBT (base→PR) | LCP | Score |",
371
+ "|---|:--:|---|---|---|---|",
372
+ ];
373
+ for (const p of paths) {
374
+ const b = base[p];
375
+ const r = pr[p];
376
+ if (!b || !r) {
377
+ lines.push(\`| \\\`\${p}\\\` | ⚠️ | \${b ? "faltou PR" : "faltou base"} | | | |\`);
378
+ continue;
379
+ }
380
+ const v = verdict(b, r);
381
+ anyGate = anyGate || v.gate;
382
+ lines.push(
383
+ \`| \\\`\${p}\\\` | \${v.icon} | \${cls(b.cls)}→\${cls(r.cls)} (\${delta(b.cls, r.cls, cls)}) | \${ms(b.tbt)}→\${ms(r.tbt)} (\${delta(b.tbt, r.tbt, ms)}) | \${ms(b.lcp)}→\${ms(r.lcp)} | \${b.score}→\${r.score} |\`,
384
+ );
385
+ }
386
+ lines.push("");
387
+ lines.push(
388
+ anyGate
389
+ ? "🔴 **Regressão de CLS/TBT detectada** (advisory — não trava o merge)."
390
+ : "🟢 Sem regressão de CLS/TBT.",
391
+ );
392
+ return { md: lines.join("\\n"), anyGate };
393
+ }
394
+
395
+ function selftest() {
396
+ const ok = (c, m) => { if (!c) throw new Error("FAIL: " + m); };
397
+ ok(verdict({ tbt: 100, cls: 0.01 }, { tbt: 200, cls: 0.01 }).gate, "tbt +100ms should gate");
398
+ ok(!verdict({ tbt: 100, cls: 0.01 }, { tbt: 130, cls: 0.01 }).gate, "tbt +30ms is noise");
399
+ ok(verdict({ tbt: 100, cls: 0.11 }, { tbt: 100, cls: 0.2 }).gate, "cls 0.11→0.20 should gate");
400
+ ok(!verdict({ tbt: 100, cls: 0.02 }, { tbt: 100, cls: 0.08 }).gate, "cls under 0.1 floor no gate");
401
+ ok(verdict({ tbt: 300, cls: 0.01 }, { tbt: 100, cls: 0.01 }).icon === "🟢", "tbt drop is green");
402
+ ok(verdict({ tbt: 100, cls: 0.01 }, { tbt: 105, cls: 0.01 }).icon === "⚪", "tiny change is neutral");
403
+ console.log("compare.mjs selftest: OK");
404
+ }
405
+
406
+ if (process.argv[2] === "--selftest") { selftest(); process.exit(0); }
407
+
408
+ const [baseDir, prDir] = process.argv.slice(2);
409
+ if (!baseDir || !prDir) { console.error("usage: compare.mjs <baseDir> <prDir> | --selftest"); process.exit(2); }
410
+ process.stdout.write(render(loadDir(baseDir), loadDir(prDir)).md + "\\n");
411
+ `;
412
+
413
+ const LIGHTHOUSERC_JSON = `{
414
+ "//": "Lighthouse CI config for the per-PR perf workflow (.github/workflows/perf.yml). Mobile UA matches worker-entry.ts buildSegment (MOBILE_RE) so warmup and measurement hit the same device cache key.",
415
+ "ci": {
416
+ "collect": {
417
+ "numberOfRuns": 3,
418
+ "settings": {
419
+ "onlyCategories": ["performance"],
420
+ "emulatedUserAgent": "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
421
+ }
422
+ }
423
+ }
424
+ }
425
+ `;
@@ -1,64 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
-
4
- /**
5
- * Generator default output paths flipped from `src/server/{cms,admin}/` to
6
- * `.deco/` (framework artifacts live in the framework's folder, not mixed
7
- * into app source). Sites that never pass an explicit `--out`/`--out-file`
8
- * flag pick up the new default silently — except when the OLD default file
9
- * is still sitting on disk, which almost always means something (an
10
- * importer, a `.gitignore` entry, a stale CI cache check) still points at
11
- * it. In that case we warn once, to stderr, and then write to the NEW
12
- * default anyway: the artifact is regenerated code, so there's no reason to
13
- * block the run — the warning is just a nudge to go clean up the stale file
14
- * and its importers.
15
- *
16
- * An explicit flag means the caller made a deliberate choice about where
17
- * output goes; it gets no warning and no guard.
18
- */
19
- export function warnLegacyArtifact(oldPath: string, newPath: string): void {
20
- console.warn(
21
- `[deco] Generator default output moved: ${oldPath} -> ${newPath}. Update importers to use the new path, then delete the old file.`,
22
- );
23
- }
24
-
25
- /**
26
- * Keeps the legacy `oldPath` in sync with the freshly-generated `newPath` so
27
- * sites mid-migration whose imports still point at the old location keep
28
- * working without a full migration first. Call this after generation
29
- * completes, only when the old file was detected.
30
- *
31
- * For `.ts`/`.tsx` artifacts a verbatim copy would be broken: the generated
32
- * file's import specifiers are relative to `newPath`'s directory (`.deco/`,
33
- * one level deep), but `oldPath` lives deeper (`src/server/cms/`), so every
34
- * `../src/...` specifier would resolve to a nonexistent path and crash the
35
- * dev server on boot. Instead we write a re-export shim pointing at `newPath`
36
- * — one specifier, correct at any depth, and self-documenting the deprecation.
37
- * The generated `.gen.ts` files only have named exports, so `export *` forwards
38
- * everything (including type exports). Non-TS artifacts (e.g. `.json` schema)
39
- * have no import paths, so a byte copy is still correct.
40
- */
41
- export function syncLegacyArtifact(oldPath: string, newPath: string): void {
42
- fs.mkdirSync(path.dirname(oldPath), { recursive: true });
43
-
44
- if (!/\.tsx?$/.test(oldPath)) {
45
- fs.copyFileSync(newPath, oldPath);
46
- return;
47
- }
48
-
49
- const newRel = path.relative(process.cwd(), newPath).replace(/\\/g, "/");
50
- let specifier = path
51
- .relative(path.dirname(oldPath), newPath)
52
- .replace(/\\/g, "/")
53
- .replace(/\.tsx?$/, "");
54
- if (!specifier.startsWith(".")) specifier = `./${specifier}`;
55
-
56
- fs.writeFileSync(
57
- oldPath,
58
- `// AUTO-GENERATED — DO NOT EDIT.\n` +
59
- `// Deprecated path: the generator now writes to "${newRel}".\n` +
60
- `// This file only re-exports it so mid-migration imports keep working.\n` +
61
- `// Update your imports to the new path, then delete this file.\n` +
62
- `export * from "${specifier}";\n`,
63
- );
64
- }