@decocms/blocks-cli 8.0.0 → 8.1.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/package.json +5 -3
  2. package/scripts/analyze-traces.mjs +1 -0
  3. package/scripts/cdn-rules.test.ts +105 -0
  4. package/scripts/cdn-rules.ts +192 -0
  5. package/scripts/deco-migrate-cli.ts +6 -7
  6. package/scripts/fast-deploy-kv.test.ts +172 -0
  7. package/scripts/generate-blocks.test.ts +11 -0
  8. package/scripts/generate-blocks.ts +0 -10
  9. package/scripts/generate-invoke.test.ts +2 -2
  10. package/scripts/generate-invoke.ts +9 -21
  11. package/scripts/generate-loaders.test.ts +2 -2
  12. package/scripts/generate-loaders.ts +0 -10
  13. package/scripts/generate-schema-loader-aliases.test.ts +186 -0
  14. package/scripts/generate-schema-workspaces.test.ts +180 -0
  15. package/scripts/generate-schema.test.ts +68 -29
  16. package/scripts/generate-schema.ts +101 -89
  17. package/scripts/generate-sections.test.ts +104 -35
  18. package/scripts/generate-sections.ts +57 -11
  19. package/scripts/generate-workspaces.test.ts +179 -0
  20. package/scripts/generate.test.ts +1 -2
  21. package/scripts/generate.ts +43 -45
  22. package/scripts/lib/blocks-dedupe.test.ts +1 -1
  23. package/scripts/lib/cf-kv-rest.ts +49 -1
  24. package/scripts/lib/installedPackages.ts +42 -0
  25. package/scripts/lib/invokeSource.ts +18 -0
  26. package/scripts/lib/kv-snapshot.ts +44 -0
  27. package/scripts/lib/read-decofile.ts +13 -2
  28. package/scripts/lib/upgrade-6-to-7.ts +1 -1
  29. package/scripts/lib/wrangler-config.test.ts +49 -0
  30. package/scripts/lib/wrangler-config.ts +31 -0
  31. package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
  32. package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
  33. package/scripts/migrate/config.ts +2 -2
  34. package/scripts/migrate/delete-sets.ts +75 -0
  35. package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
  36. package/scripts/migrate/icon-casing.test.ts +63 -0
  37. package/scripts/migrate/phase-analyze.test.ts +66 -4
  38. package/scripts/migrate/phase-analyze.ts +79 -60
  39. package/scripts/migrate/phase-cleanup.test.ts +3 -1
  40. package/scripts/migrate/phase-cleanup.ts +1 -1
  41. package/scripts/migrate/phase-compile.test.ts +3 -1
  42. package/scripts/migrate/phase-report.test.ts +2 -0
  43. package/scripts/migrate/phase-report.ts +3 -4
  44. package/scripts/migrate/phase-scaffold.ts +150 -45
  45. package/scripts/migrate/phase-transform.ts +107 -2
  46. package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
  47. package/scripts/migrate/phase-verify.test.ts +3 -1
  48. package/scripts/migrate/phase-verify.ts +87 -33
  49. package/scripts/migrate/post-cleanup/rules.ts +29 -11
  50. package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
  51. package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
  52. package/scripts/migrate/source-layout.test.ts +43 -0
  53. package/scripts/migrate/source-layout.ts +32 -5
  54. package/scripts/migrate/templates/app-css.test.ts +24 -0
  55. package/scripts/migrate/templates/app-css.ts +27 -0
  56. package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
  57. package/scripts/migrate/templates/ci-yml.ts +182 -0
  58. package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
  59. package/scripts/migrate/templates/hooks.test.ts +3 -1
  60. package/scripts/migrate/templates/lib-utils.ts +3 -3
  61. package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
  62. package/scripts/migrate/templates/package-json.ts +10 -0
  63. package/scripts/migrate/templates/parity-yml.ts +136 -0
  64. package/scripts/migrate/templates/perf-yml.ts +449 -0
  65. package/scripts/migrate/templates/playwright-yml.ts +127 -0
  66. package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
  67. package/scripts/migrate/templates/routes.test.ts +31 -0
  68. package/scripts/migrate/templates/routes.ts +19 -9
  69. package/scripts/migrate/templates/section-loaders.ts +3 -3
  70. package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
  71. package/scripts/migrate/templates/server-entry.ts +19 -14
  72. package/scripts/migrate/templates/setup.ts +11 -3
  73. package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
  74. package/scripts/migrate/templates/ui-components.ts +3 -0
  75. package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
  76. package/scripts/migrate/transforms/ctx-compat.ts +93 -1
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
  78. package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
  79. package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
  80. package/scripts/migrate/transforms/jsx.ts +10 -0
  81. package/scripts/migrate/transforms/picture.test.ts +56 -0
  82. package/scripts/migrate/transforms/picture.ts +95 -0
  83. package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
  84. package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
  85. package/scripts/migrate/transforms/timer-types.test.ts +30 -0
  86. package/scripts/migrate/transforms/timer-types.ts +48 -0
  87. package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
  88. package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
  89. package/scripts/migrate/types.ts +13 -1
  90. package/scripts/migrate-blocks-to-kv.ts +16 -2
  91. package/scripts/migrate-to-cf-observability.test.ts +5 -5
  92. package/scripts/migrate-to-cf-observability.ts +1 -1
  93. package/scripts/migrate.ts +244 -6
  94. package/scripts/reconcile.test.ts +162 -0
  95. package/scripts/reconcile.ts +303 -0
  96. package/scripts/sync-blocks-bot.test.ts +276 -0
  97. package/scripts/sync-blocks-bot.ts +567 -0
  98. package/scripts/sync-blocks-to-kv.ts +20 -2
  99. package/scripts/upgrade-6-to-7.ts +0 -0
  100. package/scripts/lib/legacyArtifact.ts +0 -20
@@ -27,16 +27,14 @@ import { fileURLToPath } from "node:url";
27
27
  * --skip-apps Skip app schema generation
28
28
  * --out Output file (default: ".deco/meta.gen.json")
29
29
  * --platform Platform name (default: "cloudflare")
30
- * --compose Run composeMeta() before writing so the output is
31
- * SELF-CONTAINED (bakes in Page, matchers, __SECTION_REF__,
32
- * Resolvable). For consumers that read meta.gen.json straight
33
- * from disk with no runtime (FS-based Studio / Eitri stack).
34
- * --framework With --compose, the value written to the `framework` field
30
+ * --framework Value written to the composed meta's `framework` field
35
31
  * (default: "tanstack-start").
36
32
  *
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.
33
+ * The output is ALWAYS run through composeMeta() before writing, so
34
+ * meta.gen.json is self-contained (bakes in Page, matchers, __SECTION_REF__,
35
+ * Resolvable). This matters for consumers that read the file straight from
36
+ * disk with no runtime (FS-based Studio / Eitri stack); runtime readers
37
+ * re-compose idempotently (see composeMeta's `framework` sentinel).
40
38
  */
41
39
  import {
42
40
  type Symbol as MorphSymbol,
@@ -47,7 +45,7 @@ import {
47
45
  type Type,
48
46
  } from "ts-morph";
49
47
  import { isExcludedCodegenFile } from "./lib/codegenExclusions";
50
- import { warnLegacyArtifact } from "./lib/legacyArtifact";
48
+ import { resolvePackageDirectory } from "./lib/installedPackages";
51
49
 
52
50
  // ---------------------------------------------------------------------------
53
51
  // CLI arg parsing
@@ -56,12 +54,9 @@ import { warnLegacyArtifact } from "./lib/legacyArtifact";
56
54
  // here) so that importing this module for its pure exports
57
55
  // (definitionIdForPath, applyWidgetFormat, typeToJsonSchema — see
58
56
  // 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.
57
+ // generateMeta() (below) and the final write are themselves only reached
58
+ // inside `if (isMainModule())`, so these vars only need real values in that
59
+ // same case.
65
60
  // ---------------------------------------------------------------------------
66
61
  const argv = process.argv.slice(2);
67
62
  function arg(name: string, fallback: string): string {
@@ -70,7 +65,6 @@ function arg(name: string, fallback: string): string {
70
65
  }
71
66
 
72
67
  const NEW_DEFAULT_OUT_REL = ".deco/meta.gen.json";
73
- const OLD_DEFAULT_OUT_REL = "src/server/admin/meta.gen.json";
74
68
 
75
69
  let SITE_NAMESPACE = "site";
76
70
  let SITE_NAME = "storefront";
@@ -81,14 +75,12 @@ let APPS_REL = "src/apps";
81
75
  let SKIP_APPS = false;
82
76
  let OUT_REL = NEW_DEFAULT_OUT_REL;
83
77
  let PLATFORM = "cloudflare";
84
- // When true, run composeMeta() over the generated site meta before writing, so
85
- // the output file is SELF-CONTAINED — it carries the framework block types
86
- // (Page, matchers, __SECTION_REF__, Resolvable) that composeMeta otherwise
87
- // injects at runtime. Required by consumers that read meta.gen.json straight
88
- // from the filesystem with no runtime (e.g. the FS-based Studio / Eitri stack).
89
- let COMPOSE = false;
90
- // Value written to the composed meta's `framework` field (only used with
91
- // --compose). Defaults to composeMeta's historical "tanstack-start".
78
+ // Value written to the composed meta's `framework` field. Defaults to
79
+ // composeMeta's historical "tanstack-start". composeMeta ALWAYS runs before
80
+ // writing (see the write block below), so the output file is SELF-CONTAINED —
81
+ // it carries the framework block types (Page, matchers, __SECTION_REF__,
82
+ // Resolvable). Required by consumers that read meta.gen.json straight from the
83
+ // filesystem with no runtime (e.g. the FS-based Studio / Eitri stack).
92
84
  let FRAMEWORK = "tanstack-start";
93
85
 
94
86
  if (isMainModule()) {
@@ -99,14 +91,9 @@ if (isMainModule()) {
99
91
  LOADERS_REL = arg("loaders", LOADERS_REL);
100
92
  APPS_REL = arg("apps", APPS_REL);
101
93
  SKIP_APPS = argv.includes("--skip-apps");
102
- const outFileExplicit = argv.includes("--out");
103
94
  OUT_REL = arg("out", NEW_DEFAULT_OUT_REL);
104
95
  PLATFORM = arg("platform", PLATFORM);
105
- COMPOSE = argv.includes("--compose");
106
96
  FRAMEWORK = arg("framework", FRAMEWORK);
107
- if (!outFileExplicit && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_REL))) {
108
- warnLegacyArtifact(OLD_DEFAULT_OUT_REL, NEW_DEFAULT_OUT_REL);
109
- }
110
97
  }
111
98
 
112
99
  // ---------------------------------------------------------------------------
@@ -319,6 +306,33 @@ const SECTION_REF_DEF_KEY = "__SECTION_REF__";
319
306
  // Well-known definition key for Resolvable (saved blocks picker)
320
307
  const RESOLVABLE_KEY = "Resolvable";
321
308
 
309
+ /**
310
+ * Whether a prop annotated `Section` / `Section[]` is the framework's opaque
311
+ * Section type (a "pick any section" reference) rather than a user-defined type
312
+ * that merely happens to be named `Section`.
313
+ *
314
+ * The framework's `Section` is opaque — `export type Section = any` in the
315
+ * scaffolded `~/types/deco.ts` — so it resolves to `any` (or `unknown`). A
316
+ * component that declares its own local `type Section = { label; items }`
317
+ * (e.g. a footer column list) resolves to a concrete object with properties;
318
+ * that is user data and must render as an inline editable object, NOT a picker.
319
+ *
320
+ * Discriminates by the resolved shape after stripping `| null | undefined` and
321
+ * the array wrapper: only the opaque type is a section reference.
322
+ */
323
+ function isOpaqueSectionType(propType: Type): boolean {
324
+ let type = propType;
325
+ if (type.isUnion()) {
326
+ const nonNull = type.getUnionTypes().filter((u) => !u.isNull() && !u.isUndefined());
327
+ if (nonNull.length === 1) type = nonNull[0];
328
+ }
329
+ if (type.isArray()) {
330
+ const el = type.getArrayElementType();
331
+ if (el) type = el;
332
+ }
333
+ return type.isAny() || type.isUnknown();
334
+ }
335
+
322
336
  // Only truly React-internal props that are never user-defined.
323
337
  // Do NOT include "children", "type", "props", or "key" — those are commonly
324
338
  // used as legitimate property names in data interfaces (e.g. SelectedFacet
@@ -356,6 +370,37 @@ interface GenerationContext {
356
370
  outputTypeToLoaderKeys: Map<string, string[]>;
357
371
  }
358
372
 
373
+ function namedLoaderType(type: Type | undefined): string | null {
374
+ if (!type) return null;
375
+ const name = type.getSymbol()?.getName();
376
+ if (
377
+ name &&
378
+ name !== "__type" &&
379
+ name !== "__object" &&
380
+ name !== "Array" &&
381
+ name !== "ReadonlyArray"
382
+ ) {
383
+ return name;
384
+ }
385
+ // Mapped/object aliases have a synthetic symbol but retain their own name.
386
+ // Bare Omit<T, K>/Generic<T> must not share a bucket across instantiations.
387
+ const alias = type.getAliasSymbol();
388
+ return alias && type.getAliasTypeArguments().length === 0 ? alias.getName() : null;
389
+ }
390
+
391
+ /** Same bounded name lookup for loader outputs and section props; no shape matching. */
392
+ function loaderTypeName(type: Type): string | null {
393
+ if (type.isUnion()) {
394
+ const nonNull = type.getUnionTypes().filter((t) => !t.isNull() && !t.isUndefined());
395
+ if (nonNull.length === 1) type = nonNull[0];
396
+ }
397
+ if (type.isArray()) {
398
+ const name = namedLoaderType(type.getArrayElementType());
399
+ return name ? `${name}[]` : null;
400
+ }
401
+ return namedLoaderType(type);
402
+ }
403
+
359
404
  /**
360
405
  * Extract the return type name of a loader's default export.
361
406
  * Unwraps Promise<T> and T | null wrappers.
@@ -370,25 +415,7 @@ function extractLoaderOutputTypeName(sourceFile: SourceFile): string | null {
370
415
  const args = ret.getTypeArguments();
371
416
  if (args.length) ret = args[0];
372
417
  }
373
- if (ret.isUnion()) {
374
- const nonNull = ret.getUnionTypes().filter((t) => !t.isNull() && !t.isUndefined());
375
- if (nonNull.length === 1) ret = nonNull[0];
376
- }
377
- // Unwrap array element type — Product[] → "Product[]" (keyed as array).
378
- // When the element type has no resolvable name (primitives like `string[]`,
379
- // or opaque types like `VNode[]`), there is NO meaningful output-type key.
380
- // Returning the generic `Array` symbol here would bucket the loader under
381
- // "Array", which then collides with EVERY array-typed section prop
382
- // (`Collection[]`, `Tab[]`, …) and wrongly turns them into loader pickers.
383
- if (ret.isArray()) {
384
- const elType = ret.getArrayElementType();
385
- const elName = elType?.getSymbol()?.getName() ?? elType?.getAliasSymbol()?.getName() ?? null;
386
- return elName ? `${elName}[]` : null;
387
- }
388
- // Reject anonymous object returns (`{ … }` → symbol name "__type"): they are
389
- // not a nameable output type and would over-match anonymous-object props.
390
- const name = ret.getSymbol()?.getName() ?? ret.getAliasSymbol()?.getName() ?? null;
391
- return name && name !== "__type" ? name : null;
418
+ return loaderTypeName(ret);
392
419
  }
393
420
 
394
421
  export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?: GenerationContext): any {
@@ -573,9 +600,16 @@ export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?:
573
600
  if (tn) typeHint = tn.getText();
574
601
  }
575
602
 
576
- // Section type → section picker reference (resolved by composeMeta)
603
+ // Section type → section picker reference (resolved by composeMeta).
604
+ // Guard on the resolved shape: the name `Section` is not reserved, so a
605
+ // component may declare its own local `type Section = { label; items }`
606
+ // (e.g. a footer column list). Only the framework's opaque Section type
607
+ // becomes a picker; a concretely-shaped local type falls through to the
608
+ // normal inline-object handling below (`isOpaqueSectionType`).
577
609
  const baseHint = typeHint.replace(/\s*\|\s*(null|undefined)/g, "").trim();
578
- if (baseHint === "Section" || baseHint === "Section[]" || baseHint === "Section[] | null") {
610
+ const isSectionName =
611
+ baseHint === "Section" || baseHint === "Section[]" || baseHint === "Section[] | null";
612
+ if (isSectionName && isOpaqueSectionType(propType)) {
579
613
  const isArray = baseHint.includes("[]");
580
614
  const sectionSchema: any = isArray
581
615
  ? {
@@ -599,32 +633,10 @@ export function typeToJsonSchema(type: Type, visited = new Set<string>(), ctx?:
599
633
  // Loader output type → block-ref: emit anyOf [Resolvable, ...matchingLoaders]
600
634
  // baseHint strips "| null | undefined" so "ProductListingPage | null" → "ProductListingPage"
601
635
  if (ctx?.outputTypeToLoaderKeys) {
602
- const typeSym = propType.getSymbol() ?? propType.getAliasSymbol();
603
- // An array type's symbol name is always the generic "Array", which is
604
- // never a meaningful loader output key — fall back to the AST type
605
- // text (`baseHint`, e.g. "Collection[]") instead. The element-based
606
- // `${elName}[]` lookup below handles arrays precisely.
607
- const symName = typeSym?.getName();
608
- const outputTypeName = symName && symName !== "Array" ? symName : baseHint;
609
- let matchingLoaders =
610
- ctx.outputTypeToLoaderKeys.get(outputTypeName) ??
636
+ const outputTypeName = loaderTypeName(propType);
637
+ const matchingLoaders =
638
+ (outputTypeName ? ctx.outputTypeToLoaderKeys.get(outputTypeName) : undefined) ??
611
639
  (outputTypeName !== baseHint ? ctx.outputTypeToLoaderKeys.get(baseHint) : undefined);
612
- // If no match yet and the prop type is an array, try element type name + "[]"
613
- if (!matchingLoaders?.length) {
614
- let arrayElementType = propType.isArray() ? propType.getArrayElementType() : null;
615
- if (!arrayElementType && propType.isUnion()) {
616
- const nonNull = propType.getUnionTypes().filter((t) => !t.isNull() && !t.isUndefined());
617
- if (nonNull.length === 1 && nonNull[0].isArray()) {
618
- arrayElementType = nonNull[0].getArrayElementType();
619
- }
620
- }
621
- if (arrayElementType) {
622
- const elName = arrayElementType.getSymbol()?.getName() ?? arrayElementType.getAliasSymbol()?.getName();
623
- if (elName) {
624
- matchingLoaders = ctx.outputTypeToLoaderKeys.get(`${elName}[]`);
625
- }
626
- }
627
- }
628
640
  if (matchingLoaders?.length) {
629
641
  const blockRefSchema: any = {
630
642
  anyOf: [
@@ -1051,8 +1063,8 @@ function generateMeta(): MetaResponse {
1051
1063
  // ---------------------------------------------------------------------------
1052
1064
 
1053
1065
  /** Absolute path to the installed @decocms/apps-<namespace> package, if present. */
1054
- function getAppPkgDir(namespace: string): string {
1055
- return path.resolve(root, `node_modules/@decocms/apps-${namespace}`);
1066
+ function getAppPkgDir(namespace: string): string | null {
1067
+ return resolvePackageDirectory(root, `@decocms/apps-${namespace}`);
1056
1068
  }
1057
1069
 
1058
1070
  /** Detect installed app namespaces from src/apps/ bridge files. */
@@ -1082,6 +1094,7 @@ function generateMeta(): MetaResponse {
1082
1094
 
1083
1095
  for (const namespace of installed) {
1084
1096
  const pkgDir = getAppPkgDir(namespace);
1097
+ if (!pkgDir) continue;
1085
1098
  const loadersDir = path.join(pkgDir, "src", "loaders");
1086
1099
  if (!fs.existsSync(loadersDir)) continue;
1087
1100
 
@@ -1522,18 +1535,17 @@ function isMainModule(): boolean {
1522
1535
  }
1523
1536
 
1524
1537
  if (isMainModule()) {
1525
- // Wrapped in an async IIFE so --compose can dynamically import composeMeta
1526
- // ONLY when requested — the default path (and any test importing this
1527
- // module's pure exports) never pulls in the @decocms/blocks/cms barrel.
1538
+ // Wrapped in an async IIFE so the composeMeta dynamic import happens only
1539
+ // when this module is actually run (any test importing this module's pure
1540
+ // exports never reaches here, so it never pulls in the @decocms/blocks/cms
1541
+ // barrel). composeMeta always runs, making the written file self-contained.
1528
1542
  void (async () => {
1529
- let meta = generateMeta();
1530
- if (COMPOSE) {
1531
- const { composeMeta } = await import("@decocms/blocks/cms");
1532
- // composeMeta returns @decocms/blocks' MetaResponse (platform optional);
1533
- // this file's local MetaResponse requires platform. It's always present
1534
- // (composeMeta spreads siteMeta, which set it), so the cast is safe.
1535
- meta = composeMeta(meta, { framework: FRAMEWORK }) as MetaResponse;
1536
- }
1543
+ const rawMeta = generateMeta();
1544
+ const { composeMeta } = await import("@decocms/blocks/cms");
1545
+ // composeMeta returns @decocms/blocks' MetaResponse (platform optional);
1546
+ // this file's local MetaResponse requires platform. It's always present
1547
+ // (composeMeta spreads siteMeta, which set it), so the cast is safe.
1548
+ const meta = composeMeta(rawMeta, { framework: FRAMEWORK }) as MetaResponse;
1537
1549
  const outPath = path.resolve(process.cwd(), OUT_REL);
1538
1550
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
1539
1551
  fs.writeFileSync(outPath, JSON.stringify(meta, null, 2));
@@ -1543,7 +1555,7 @@ if (isMainModule()) {
1543
1555
  const ldrCount = Object.keys(meta.manifest.blocks.loaders || {}).length;
1544
1556
  const appCount = Object.keys(meta.manifest.blocks.apps || {}).length;
1545
1557
  console.log(
1546
- `\nGenerated schema${COMPOSE ? " (self-contained)" : ""}: ${defCount} definitions, ${secCount} sections, ${ldrCount} loaders, ${appCount} apps → ${path.relative(process.cwd(), outPath)}`,
1558
+ `\nGenerated schema (self-contained): ${defCount} definitions, ${secCount} sections, ${ldrCount} loaders, ${appCount} apps → ${path.relative(process.cwd(), outPath)}`,
1547
1559
  );
1548
1560
  })();
1549
1561
  }
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Verifies the operationally important behavior: a co-located test/spec/
11
11
  * stories/gen file sitting next to a real section must never be walked into
12
- * sectionMeta (the fila incident this generator's `walkDir` reproduced:
12
+ * sectionMeta (a production-site incident this generator's `walkDir` reproduced:
13
13
  * `sections.test.ts` became a bogus section in a site's generated output).
14
14
  */
15
15
  import * as cp from "node:child_process";
@@ -72,6 +72,22 @@ describe("generate-sections walkDir exclusions", () => {
72
72
  expect(generated).not.toContain("Hero.stories.tsx");
73
73
  expect(generated).not.toContain("sections.gen.ts");
74
74
  }, 30_000);
75
+
76
+ it("extracts `export const deferred = true` into sectionMeta", () => {
77
+ fs.writeFileSync(
78
+ path.join(sectionsDir, "HeavyPLP.tsx"),
79
+ "export const deferred = true;\nexport default function HeavyPLP() { return null; }\n",
80
+ );
81
+
82
+ const { code } = runGenerator(["--sections-dir", sectionsDir, "--out-file", outFile]);
83
+ expect(code).toBe(0);
84
+
85
+ const generated = fs.readFileSync(outFile, "utf-8");
86
+ // The per-section deferral flag must survive the regex scan → sectionMeta,
87
+ // or applySectionConventions can never register it as always-defer.
88
+ expect(generated).toMatch(/"site\/sections\/HeavyPLP\.tsx":\s*\{[^}]*deferred:\s*true/);
89
+ expect(generated).toContain("deferred?: boolean;");
90
+ }, 30_000);
75
91
  });
76
92
 
77
93
  describe("generate-sections default output path (.deco/)", () => {
@@ -99,39 +115,9 @@ describe("generate-sections default output path (.deco/)", () => {
99
115
  const newDefault = path.join(tmpDir, ".deco", "sections.gen.ts");
100
116
  expect(fs.existsSync(newDefault)).toBe(true);
101
117
  expect(fs.readFileSync(newDefault, "utf-8")).toContain("site/sections/Hero.tsx");
102
- // No legacy file present, so no warning is expected.
118
+ // No legacy default path is written anymore, so no warning is expected.
103
119
  expect(stderr).not.toContain("Generator default output moved");
104
120
  }, 30_000);
105
-
106
- 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", () => {
107
- const oldDefaultDir = path.join(tmpDir, "src", "server", "cms");
108
- fs.mkdirSync(oldDefaultDir, { recursive: true });
109
- fs.writeFileSync(path.join(oldDefaultDir, "sections.gen.ts"), "// stale\n");
110
-
111
- const { code, stderr } = runGenerator([], { cwd: tmpDir });
112
- expect(code).toBe(0);
113
-
114
- expect(stderr).toContain("src/server/cms/sections.gen.ts");
115
- expect(stderr).toContain(".deco/sections.gen.ts");
116
- expect(stderr).toContain("Move the file and update its importers");
117
-
118
- const newDefault = path.join(tmpDir, ".deco", "sections.gen.ts");
119
- expect(fs.existsSync(newDefault)).toBe(true);
120
- expect(fs.readFileSync(newDefault, "utf-8")).toContain("site/sections/Hero.tsx");
121
- }, 30_000);
122
-
123
- it("does not warn when an explicit --out-file is passed, even if the OLD default file exists", () => {
124
- const oldDefaultDir = path.join(tmpDir, "src", "server", "cms");
125
- fs.mkdirSync(oldDefaultDir, { recursive: true });
126
- fs.writeFileSync(path.join(oldDefaultDir, "sections.gen.ts"), "// stale\n");
127
-
128
- const explicitOut = path.join(tmpDir, "custom", "sections.gen.ts");
129
- const { code, stderr } = runGenerator(["--out-file", explicitOut], { cwd: tmpDir });
130
- expect(code).toBe(0);
131
-
132
- expect(stderr).not.toContain("Generator default output moved");
133
- expect(fs.existsSync(explicitOut)).toBe(true);
134
- }, 30_000);
135
121
  });
136
122
 
137
123
  describe("generate-sections --registry", () => {
@@ -287,8 +273,8 @@ describe("generate-sections neverDefer convention", () => {
287
273
  // SectionMetaEntry interface the SAME file declares omitted the field —
288
274
  // so every generated file containing a neverDefer section failed the
289
275
  // site's typecheck (TS2353 excess property), and sites hand-patched the
290
- // interface only to have the next regeneration wipe the patch (miess's
291
- // .deco/sections.gen.ts carried exactly that TODO). The emitted interface
276
+ // interface only to have the next regeneration wipe the patch (a production
277
+ // site's .deco/sections.gen.ts carried exactly that TODO). The emitted interface
292
278
  // must match SectionMetaEntry in @decocms/blocks/cms.
293
279
  let tmpDir: string;
294
280
  let sectionsDir: string;
@@ -306,7 +292,7 @@ describe("generate-sections neverDefer convention", () => {
306
292
  });
307
293
 
308
294
  it("declares neverDefer on the emitted SectionMetaEntry interface and the generated file typechecks + imports", () => {
309
- // Mirrors miess's src/sections/Product/SearchResult.tsx.
295
+ // Mirrors a production site's src/sections/Product/SearchResult.tsx.
310
296
  fs.writeFileSync(
311
297
  path.join(sectionsDir, "SearchResult.tsx"),
312
298
  "export const neverDefer = true;\nexport default function SearchResult() { return null; }\n",
@@ -350,6 +336,89 @@ describe("generate-sections neverDefer convention", () => {
350
336
  }, 60_000);
351
337
  });
352
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
+
353
422
  describe("generate-sections output hygiene (non-registry)", () => {
354
423
  let tmpDir: string;
355
424
  let sectionsDir: string;
@@ -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 { warnLegacyArtifact } from "./lib/legacyArtifact";
40
35
 
41
36
  const args = process.argv.slice(2);
42
37
  function arg(name: string, fallback: string): string {
@@ -45,27 +40,33 @@ 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
- if (!OUT_FILE_EXPLICIT && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_FILE))) {
53
- warnLegacyArtifact(OLD_DEFAULT_OUT_FILE, NEW_DEFAULT_OUT_FILE);
54
- }
55
45
  const EMIT_REGISTRY = args.includes("--registry");
56
46
 
57
47
  interface SectionMeta {
58
48
  eager?: boolean;
59
49
  neverDefer?: boolean;
50
+ deferred?: boolean;
60
51
  cache?: string;
61
52
  layout?: boolean;
62
53
  sync?: boolean;
63
54
  clientOnly?: boolean;
64
55
  seo?: boolean;
65
56
  hasLoadingFallback?: boolean;
57
+ renderJson?: false;
58
+ hasRenderJson?: boolean;
66
59
  }
67
60
 
68
- const EXPORT_CONST_RE = /export\s+const\s+(eager|neverDefer|cache|layout|sync|clientOnly|seo)\s*=\s*(.+?)(?:;|\n)/g;
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[^}]*\}/;
69
70
  // Detects `export function LoadingFallback(...)`, `export const LoadingFallback = ...`, etc.
70
71
  const LOADING_FALLBACK_INLINE_RE = /export\s+(?:function|const|let|var)\s+LoadingFallback\b/;
71
72
  // Detects re-exports like:
@@ -105,6 +106,14 @@ function extractMeta(content: string): SectionMeta | null {
105
106
  found = true;
106
107
  }
107
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
+
108
117
  return found ? meta : null;
109
118
  }
110
119
 
@@ -177,6 +186,8 @@ const lines: string[] = [
177
186
  "// export const clientOnly = true → skip SSR (client-only rendering)",
178
187
  "// export const seo = true → SEO section (provides page head data)",
179
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)",
180
191
  "",
181
192
  ];
182
193
 
@@ -196,6 +207,16 @@ for (let i = 0; i < nonSyncFallbacks.length; i++) {
196
207
  lines.push(`import { LoadingFallback as _fb${i} } from "${importPath}";`);
197
208
  }
198
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
+
199
220
  lines.push("");
200
221
 
201
222
  // Metadata map
@@ -207,12 +228,15 @@ lines.push("");
207
228
  lines.push("export interface SectionMetaEntry {");
208
229
  lines.push(" eager?: boolean;");
209
230
  lines.push(" neverDefer?: boolean;");
231
+ lines.push(" deferred?: boolean;");
210
232
  lines.push(" cache?: string;");
211
233
  lines.push(" layout?: boolean;");
212
234
  lines.push(" sync?: boolean;");
213
235
  lines.push(" clientOnly?: boolean;");
214
236
  lines.push(" seo?: boolean;");
215
237
  lines.push(" hasLoadingFallback?: boolean;");
238
+ lines.push(" renderJson?: false;");
239
+ lines.push(" hasRenderJson?: boolean;");
216
240
  lines.push("}");
217
241
  lines.push("");
218
242
  lines.push("export const sectionMeta: Record<string, SectionMetaEntry> = {");
@@ -256,6 +280,28 @@ if (allFallbacks.length > 0) {
256
280
  }
257
281
  lines.push("");
258
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
+
259
305
  // Lazy section-import registry — opt-in via --registry, built from every
260
306
  // scanned section file (not just convention-carrying `entries`).
261
307
  if (EMIT_REGISTRY) {