@decocms/blocks-cli 7.39.0 → 7.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.39.0",
3
+ "version": "7.40.0",
4
4
  "type": "module",
5
5
  "description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "lint:unused": "knip"
32
32
  },
33
33
  "dependencies": {
34
- "@decocms/blocks": "7.39.0",
34
+ "@decocms/blocks": "7.40.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) {
@@ -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