@decocms/start 0.43.0 → 1.1.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.
@@ -23,8 +23,6 @@ jobs:
23
23
  node-version: 22
24
24
  registry-url: https://registry.npmjs.org
25
25
 
26
- - run: npm install -g npm@latest
27
-
28
26
  - run: npm install
29
27
 
30
28
  - name: Release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.43.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -29,6 +29,7 @@
29
29
  "./sdk/instrumentedFetch": "./src/sdk/instrumentedFetch.ts",
30
30
  "./sdk/otel": "./src/sdk/otel.ts",
31
31
  "./sdk/workerEntry": "./src/sdk/workerEntry.ts",
32
+ "./sdk/abTesting": "./src/sdk/abTesting.ts",
32
33
  "./sdk/redirects": "./src/sdk/redirects.ts",
33
34
  "./sdk/sitemap": "./src/sdk/sitemap.ts",
34
35
  "./sdk/useDevice": "./src/sdk/useDevice.ts",
@@ -44,11 +45,13 @@
44
45
  "./sdk/mergeCacheControl": "./src/sdk/mergeCacheControl.ts",
45
46
  "./sdk/requestContext": "./src/sdk/requestContext.ts",
46
47
  "./sdk/createInvoke": "./src/sdk/createInvoke.ts",
48
+ "./sdk/router": "./src/sdk/router.ts",
47
49
  "./matchers/posthog": "./src/matchers/posthog.ts",
48
50
  "./apps/autoconfig": "./src/apps/autoconfig.ts",
49
51
  "./sdk/setupApps": "./src/sdk/setupApps.ts",
50
52
  "./matchers/builtins": "./src/matchers/builtins.ts",
51
53
  "./types/widgets": "./src/types/widgets.ts",
54
+ "./setup": "./src/setup.ts",
52
55
  "./routes": "./src/routes/index.ts",
53
56
  "./scripts/generate-blocks": "./scripts/generate-blocks.ts",
54
57
  "./scripts/generate-schema": "./scripts/generate-schema.ts",
@@ -90,6 +93,7 @@
90
93
  "peerDependencies": {
91
94
  "@microlabs/otel-cf-workers": ">=1.0.0-rc.0",
92
95
  "@opentelemetry/api": ">=1.9.0",
96
+ "@tanstack/react-query": ">=5.0.0",
93
97
  "@tanstack/react-start": ">=1.0.0",
94
98
  "@tanstack/store": ">=0.7.0",
95
99
  "react": "^19.0.0",
@@ -110,6 +114,7 @@
110
114
  "@opentelemetry/api": "^1.9.1",
111
115
  "@semantic-release/exec": "^7.1.0",
112
116
  "@semantic-release/git": "^10.0.1",
117
+ "@tanstack/react-query": "^5.96.0",
113
118
  "@tanstack/store": "^0.9.1",
114
119
  "@types/react": "^19.0.0",
115
120
  "@types/react-dom": "^19.0.0",
@@ -173,10 +173,10 @@ for (const prop of actionsObj.getProperties()) {
173
173
  let inputType = "any";
174
174
  let callBody = "";
175
175
 
176
- // Navigate through potential type assertion (as ...)
176
+ // Recursively unwrap AsExpression chains (e.g. `expr as unknown as Type`)
177
177
  let createInvokeFnCall = callExpr;
178
- if (callExpr.getKind() === SyntaxKind.AsExpression) {
179
- createInvokeFnCall = callExpr.asKindOrThrow(SyntaxKind.AsExpression).getExpression();
178
+ while (createInvokeFnCall.getKind() === SyntaxKind.AsExpression) {
179
+ createInvokeFnCall = createInvokeFnCall.asKindOrThrow(SyntaxKind.AsExpression).getExpression();
180
180
  }
181
181
 
182
182
  // Now we have createInvokeFn(...) call
@@ -215,15 +215,18 @@ for (const prop of actionsObj.getProperties()) {
215
215
  }
216
216
  }
217
217
 
218
- // Extract the return type from the "as" assertion if present
218
+ // Extract the return type from the outermost "as" assertion.
219
+ // For `expr as unknown as (ctx: ...) => Promise<T>`, the outermost
220
+ // AsExpression has the function type with Promise<T>.
219
221
  let returnType = "any";
220
222
  if (callExpr.getKind() === SyntaxKind.AsExpression) {
221
223
  const asExpr = callExpr.asKindOrThrow(SyntaxKind.AsExpression);
222
224
  const typeText = asExpr.getTypeNode()?.getText() || "";
223
- // Extract Promise<X> from (ctx: {...}) => Promise<X>
224
- const promiseMatch = typeText.match(/Promise<(.+)>$/s);
225
- if (promiseMatch) {
226
- returnType = promiseMatch[1].trim();
225
+ if (typeText !== "unknown") {
226
+ const promiseMatch = typeText.match(/Promise<(.+)>$/s);
227
+ if (promiseMatch) {
228
+ returnType = promiseMatch[1].trim();
229
+ }
227
230
  }
228
231
  }
229
232
 
@@ -277,12 +280,25 @@ for (const action of actions) {
277
280
  }
278
281
  }
279
282
 
283
+ // Count how many actually parsed vs. stubbed
284
+ const parsed = actions.filter((a) => a.callBody && a.importedFn).length;
285
+ const stubbed = actions.length - parsed;
286
+ if (stubbed > 0) {
287
+ console.warn(`⚠ ${stubbed} action(s) could not be parsed — generated as stubs:`);
288
+ for (const a of actions) {
289
+ if (!a.callBody || !a.importedFn) console.warn(` - ${a.name}`);
290
+ }
291
+ }
292
+
280
293
  // Build output
281
294
  let out = `// Auto-generated by @decocms/start/scripts/generate-invoke.ts
282
295
  // Do not edit manually. Re-run the generator to update.
283
296
  //
284
297
  // Each server function is a top-level const so TanStack Start's compiler
285
298
  // can transform createServerFn().handler() into RPC stubs on the client.
299
+ //
300
+ // Site-specific extensions: import { vtexActions } from this file and merge
301
+ // with your own actions in a separate invoke.ts.
286
302
  import { createServerFn } from "@tanstack/react-start";
287
303
  `;
288
304
 
@@ -318,56 +334,65 @@ for (const action of actions) {
318
334
  const varName = `$${action.name}`;
319
335
 
320
336
  if (action.callBody && action.importedFn) {
321
- // Replace "input" references with "ctx.data" in the call body
337
+ // Replace "input" references with "data" in the call body.
338
+ // The handler receives `{ data }` destructured from the validated input.
322
339
  let body = action.callBody;
323
- // The callBody looks like: functionName(input.foo, input.bar)
324
- // We need it to be: functionName(ctx.data.foo, ctx.data.bar)
325
- body = body.replace(/\binput\./g, "ctx.data.");
326
- // Handle cases like functionName(input) without dot
327
- body = body.replace(/\binput\b(?!\.)/g, "ctx.data");
340
+ body = body.replace(/\binput\./g, "data.");
341
+ body = body.replace(/\binput\b(?!\.)/g, "data");
328
342
 
329
343
  if (action.unwrap) {
330
344
  out += `\nconst ${varName} = createServerFn({ method: "POST" })
331
- .handler(async (ctx: { data: ${action.inputType} }) => {
345
+ .inputValidator((data: ${action.inputType}) => data)
346
+ .handler(async ({ data }): Promise<any> => {
332
347
  const result = await ${body};
333
348
  return unwrapResult(result);
334
349
  });\n`;
335
350
  } else {
336
351
  out += `\nconst ${varName} = createServerFn({ method: "POST" })
337
- .handler(async (ctx: { data: ${action.inputType} }) => {
338
- return await ${body};
352
+ .inputValidator((data: ${action.inputType}) => data)
353
+ .handler(async ({ data }): Promise<any> => {
354
+ return ${body};
339
355
  });\n`;
340
356
  }
341
357
  } else {
342
358
  // Fallback: couldn't parse — generate a stub
343
359
  out += `\n// TODO: could not auto-generate ${action.name} — add manually\nconst ${varName} = createServerFn({ method: "POST" })
344
- .handler(async (ctx: { data: any }) => {
360
+ .handler(async () => {
345
361
  throw new Error("${action.name}: not implemented — regenerate invoke");
346
362
  });\n`;
347
363
  }
348
364
  }
349
365
 
350
- // Generate the invoke object
366
+ // Generate the vtexActions object (for composability with site-specific actions)
351
367
  out += `
352
368
  // ---------------------------------------------------------------------------
353
- // Public invoke objectsame DX as @decocms/apps/vtex/invoke
369
+ // Typed VTEX actions map merge with site-specific actions in your invoke.ts
354
370
  // ---------------------------------------------------------------------------
355
371
 
356
- export const invoke = {
357
- vtex: {
358
- actions: {
372
+ export const vtexActions = {
359
373
  `;
360
374
 
361
375
  for (const action of actions) {
362
376
  const varName = `$${action.name}`;
363
377
  if (action.returnType !== "any") {
364
- out += ` ${action.name}: ${varName} as unknown as (ctx: { data: ${action.inputType} }) => Promise<${action.returnType}>,\n`;
378
+ out += ` ${action.name}: ${varName} as unknown as (ctx: { data: ${action.inputType} }) => Promise<${action.returnType}>,\n`;
365
379
  } else {
366
- out += ` ${action.name}: ${varName},\n`;
380
+ out += ` ${action.name}: ${varName},\n`;
367
381
  }
368
382
  }
369
383
 
370
- out += ` },
384
+ out += `} as const;
385
+
386
+ // Re-export OrderForm type (commonly imported from invoke by site components)
387
+ export type { OrderForm } from "@decocms/apps/vtex/types";
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // Default invoke object — import this if you don't need site extensions
391
+ // ---------------------------------------------------------------------------
392
+
393
+ export const invoke = {
394
+ vtex: {
395
+ actions: vtexActions,
371
396
  },
372
397
  } as const;
373
398
  `;
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Scans site loader and action files and generates a registry map
4
+ * for COMMERCE_LOADERS pass-through entries.
5
+ *
6
+ * Each loader/action file that exports a default function gets a generated
7
+ * entry like:
8
+ * "site/loaders/SAP/getUser": async (props) => {
9
+ * const mod = await import("../../loaders/SAP/getUser");
10
+ * return mod.default(props);
11
+ * },
12
+ *
13
+ * Both keyed with and without `.ts` suffix for CMS block compatibility.
14
+ *
15
+ * Files listed in --exclude are skipped (they need custom wiring in setup.ts).
16
+ *
17
+ * Usage (from site root):
18
+ * npx tsx node_modules/@decocms/start/scripts/generate-loaders.ts
19
+ *
20
+ * CLI:
21
+ * --loaders-dir override loaders input (default: src/loaders)
22
+ * --actions-dir override actions input (default: src/actions)
23
+ * --out-file override output (default: src/server/cms/loaders.gen.ts)
24
+ * --exclude comma-separated list of loader keys to skip (they have custom wiring)
25
+ */
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+
29
+ const args = process.argv.slice(2);
30
+ function arg(name: string, fallback: string): string {
31
+ const idx = args.indexOf(`--${name}`);
32
+ return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback;
33
+ }
34
+
35
+ const loadersDir = path.resolve(process.cwd(), arg("loaders-dir", "src/loaders"));
36
+ const actionsDir = path.resolve(process.cwd(), arg("actions-dir", "src/actions"));
37
+ const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/loaders.gen.ts"));
38
+ const excludeRaw = arg("exclude", "");
39
+ const excludeSet = new Set(excludeRaw.split(",").map((s) => s.trim()).filter(Boolean));
40
+
41
+ function walkDir(dir: string): string[] {
42
+ const results: string[] = [];
43
+ if (!fs.existsSync(dir)) return results;
44
+
45
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
46
+ const fullPath = path.join(dir, entry.name);
47
+ if (entry.isDirectory()) {
48
+ results.push(...walkDir(fullPath));
49
+ } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
50
+ results.push(fullPath);
51
+ }
52
+ }
53
+ return results;
54
+ }
55
+
56
+ function fileToKey(filePath: string, baseDir: string, prefix: string): string {
57
+ const rel = path.relative(baseDir, filePath).replace(/\\/g, "/").replace(/\.tsx?$/, "");
58
+ return `${prefix}/${rel}`;
59
+ }
60
+
61
+ function relativeImportPath(from: string, to: string): string {
62
+ let rel = path.relative(path.dirname(from), to).replace(/\\/g, "/");
63
+ if (!rel.startsWith(".")) rel = `./${rel}`;
64
+ return rel.replace(/\.tsx?$/, "");
65
+ }
66
+
67
+ function hasDefaultExport(content: string): boolean {
68
+ return /export\s+default\b/.test(content) || /export\s*\{[^}]*\bdefault\b/.test(content);
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+
73
+ interface LoaderEntry {
74
+ key: string;
75
+ importPath: string;
76
+ }
77
+
78
+ const entries: LoaderEntry[] = [];
79
+
80
+ for (const filePath of walkDir(loadersDir)) {
81
+ const content = fs.readFileSync(filePath, "utf-8");
82
+ if (!hasDefaultExport(content)) continue;
83
+ const key = fileToKey(filePath, loadersDir, "site/loaders");
84
+ if (excludeSet.has(key) || excludeSet.has(`${key}.ts`)) continue;
85
+ entries.push({
86
+ key,
87
+ importPath: relativeImportPath(outFile, filePath),
88
+ });
89
+ }
90
+
91
+ for (const filePath of walkDir(actionsDir)) {
92
+ const content = fs.readFileSync(filePath, "utf-8");
93
+ if (!hasDefaultExport(content)) continue;
94
+ const key = fileToKey(filePath, actionsDir, "site/actions");
95
+ if (excludeSet.has(key) || excludeSet.has(`${key}.ts`)) continue;
96
+ entries.push({
97
+ key,
98
+ importPath: relativeImportPath(outFile, filePath),
99
+ });
100
+ }
101
+
102
+ entries.sort((a, b) => a.key.localeCompare(b.key));
103
+
104
+ const lines: string[] = [
105
+ "// Auto-generated by @decocms/start/scripts/generate-loaders.ts",
106
+ "// Do not edit manually. Run `npm run generate:loaders` to update.",
107
+ "//",
108
+ "// Pass-through loader/action entries for COMMERCE_LOADERS.",
109
+ "// Custom-wired entries should be excluded via --exclude and added manually in setup.ts.",
110
+ "",
111
+ "export const siteLoaders: Record<string, (props: any) => Promise<any>> = {",
112
+ ];
113
+
114
+ for (const entry of entries) {
115
+ lines.push(` "${entry.key}": async (props: any) => {`);
116
+ lines.push(` const mod = await import("${entry.importPath}");`);
117
+ lines.push(" return mod.default(props);");
118
+ lines.push(" },");
119
+ lines.push(` "${entry.key}.ts": async (props: any) => {`);
120
+ lines.push(` const mod = await import("${entry.importPath}");`);
121
+ lines.push(" return mod.default(props);");
122
+ lines.push(" },");
123
+ }
124
+
125
+ lines.push("};");
126
+ lines.push("");
127
+
128
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
129
+ fs.writeFileSync(outFile, lines.join("\n"));
130
+
131
+ console.log(
132
+ `Generated ${entries.length} loader entries (${entries.length * 2} with .ts aliases) → ${path.relative(process.cwd(), outFile)}`,
133
+ );
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Scans site section files and extracts convention-based metadata:
4
+ * - export const eager = true → alwaysEager
5
+ * - export const cache = "listing" → registerCacheableSections
6
+ * - export const layout = true → registerLayoutSections
7
+ * - export const sync = true → registerSectionsSync (bundled, not lazy)
8
+ * - export const clientOnly = true → registerSection with clientOnly
9
+ * - export const seo = true → registerSeoSections
10
+ * - export function LoadingFallback → registerSection with loadingFallback
11
+ *
12
+ * Emits sections.gen.ts with metadata + sync imports for sections marked sync=true.
13
+ *
14
+ * Usage (from site root):
15
+ * npx tsx node_modules/@decocms/start/scripts/generate-sections.ts
16
+ *
17
+ * CLI:
18
+ * --sections-dir override input (default: src/sections)
19
+ * --out-file override output (default: src/server/cms/sections.gen.ts)
20
+ */
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+
24
+ const args = process.argv.slice(2);
25
+ function arg(name: string, fallback: string): string {
26
+ const idx = args.indexOf(`--${name}`);
27
+ return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback;
28
+ }
29
+
30
+ const sectionsDir = path.resolve(process.cwd(), arg("sections-dir", "src/sections"));
31
+ const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/sections.gen.ts"));
32
+
33
+ interface SectionMeta {
34
+ eager?: boolean;
35
+ cache?: string;
36
+ layout?: boolean;
37
+ sync?: boolean;
38
+ clientOnly?: boolean;
39
+ seo?: boolean;
40
+ hasLoadingFallback?: boolean;
41
+ }
42
+
43
+ const EXPORT_CONST_RE = /export\s+const\s+(eager|cache|layout|sync|clientOnly|seo)\s*=\s*(.+?)(?:;|\n)/g;
44
+ const LOADING_FALLBACK_RE = /export\s+(?:function|const)\s+LoadingFallback\b/;
45
+
46
+ function extractMeta(content: string): SectionMeta | null {
47
+ const meta: SectionMeta = {};
48
+ let found = false;
49
+
50
+ for (const match of content.matchAll(EXPORT_CONST_RE)) {
51
+ const key = match[1] as keyof SectionMeta;
52
+ const rawValue = match[2].trim().replace(/['"]/g, "");
53
+ found = true;
54
+
55
+ if (key === "cache") {
56
+ meta.cache = rawValue;
57
+ } else if (rawValue === "true") {
58
+ (meta as any)[key] = true;
59
+ }
60
+ }
61
+
62
+ if (LOADING_FALLBACK_RE.test(content)) {
63
+ meta.hasLoadingFallback = true;
64
+ found = true;
65
+ }
66
+
67
+ return found ? meta : null;
68
+ }
69
+
70
+ function walkDir(dir: string, base: string = dir): string[] {
71
+ const results: string[] = [];
72
+ if (!fs.existsSync(dir)) return results;
73
+
74
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
75
+ const fullPath = path.join(dir, entry.name);
76
+ if (entry.isDirectory()) {
77
+ results.push(...walkDir(fullPath, base));
78
+ } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) {
79
+ results.push(fullPath);
80
+ }
81
+ }
82
+ return results;
83
+ }
84
+
85
+ function fileToSectionKey(filePath: string, _sectionsDir: string): string {
86
+ const rel = path.relative(_sectionsDir, filePath).replace(/\\/g, "/");
87
+ return `site/sections/${rel}`;
88
+ }
89
+
90
+ function relativeImportPath(from: string, to: string): string {
91
+ let rel = path.relative(path.dirname(from), to).replace(/\\/g, "/");
92
+ if (!rel.startsWith(".")) rel = `./${rel}`;
93
+ return rel.replace(/\.tsx?$/, "");
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+
98
+ if (!fs.existsSync(sectionsDir)) {
99
+ console.warn(`Sections directory not found: ${sectionsDir} — generating empty output.`);
100
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
101
+ fs.writeFileSync(outFile, [
102
+ "// Auto-generated — no sections found.",
103
+ "export const sectionMeta = {};",
104
+ "",
105
+ ].join("\n"));
106
+ process.exit(0);
107
+ }
108
+
109
+ const sectionFiles = walkDir(sectionsDir);
110
+ const entries: Array<{ key: string; meta: SectionMeta; filePath: string }> = [];
111
+
112
+ for (const filePath of sectionFiles) {
113
+ const content = fs.readFileSync(filePath, "utf-8");
114
+ const meta = extractMeta(content);
115
+ if (!meta) continue;
116
+ const key = fileToSectionKey(filePath, sectionsDir);
117
+ entries.push({ key, meta, filePath });
118
+ }
119
+
120
+ const syncEntries = entries.filter((e) => e.meta.sync);
121
+ const fallbackEntries = entries.filter((e) => e.meta.hasLoadingFallback);
122
+
123
+ const lines: string[] = [
124
+ "// Auto-generated by @decocms/start/scripts/generate-sections.ts",
125
+ "// Do not edit manually. Add convention exports to your section files instead.",
126
+ "//",
127
+ "// Supported conventions:",
128
+ "// export const eager = true → always SSR'd (never deferred)",
129
+ "// export const cache = \"listing\" → SWR-cached section loader results",
130
+ "// export const layout = true → cached as layout (Header, Footer, Theme)",
131
+ "// export const sync = true → bundled synchronously (not lazy-loaded)",
132
+ "// export const clientOnly = true → skip SSR (client-only rendering)",
133
+ "// export const seo = true → SEO section (provides page head data)",
134
+ "// export function LoadingFallback → skeleton shown while section loads",
135
+ "",
136
+ ];
137
+
138
+ // Sync imports — sections marked sync=true get static imports for registerSectionsSync
139
+ for (let i = 0; i < syncEntries.length; i++) {
140
+ const e = syncEntries[i];
141
+ const importPath = relativeImportPath(outFile, e.filePath);
142
+ const varName = `_sync${i}`;
143
+ lines.push(`import * as ${varName} from "${importPath}";`);
144
+ }
145
+
146
+ // LoadingFallback imports — sections with LoadingFallback that aren't sync-imported
147
+ const nonSyncFallbacks = fallbackEntries.filter((e) => !e.meta.sync);
148
+ for (let i = 0; i < nonSyncFallbacks.length; i++) {
149
+ const e = nonSyncFallbacks[i];
150
+ const importPath = relativeImportPath(outFile, e.filePath);
151
+ lines.push(`import { LoadingFallback as _fb${i} } from "${importPath}";`);
152
+ }
153
+
154
+ lines.push("");
155
+
156
+ // Metadata map
157
+ lines.push("export interface SectionMetaEntry {");
158
+ lines.push(" eager?: boolean;");
159
+ lines.push(" cache?: string;");
160
+ lines.push(" layout?: boolean;");
161
+ lines.push(" sync?: boolean;");
162
+ lines.push(" clientOnly?: boolean;");
163
+ lines.push(" seo?: boolean;");
164
+ lines.push(" hasLoadingFallback?: boolean;");
165
+ lines.push("}");
166
+ lines.push("");
167
+ lines.push("export const sectionMeta: Record<string, SectionMetaEntry> = {");
168
+ for (const e of entries) {
169
+ const props = Object.entries(e.meta)
170
+ .map(([k, v]) => `${k}: ${typeof v === "string" ? `"${v}"` : v}`)
171
+ .join(", ");
172
+ lines.push(` "${e.key}": { ${props} },`);
173
+ }
174
+ lines.push("};");
175
+ lines.push("");
176
+
177
+ // Sync components map
178
+ if (syncEntries.length > 0) {
179
+ lines.push("export const syncComponents: Record<string, any> = {");
180
+ for (let i = 0; i < syncEntries.length; i++) {
181
+ lines.push(` "${syncEntries[i].key}": _sync${i},`);
182
+ }
183
+ lines.push("};");
184
+ } else {
185
+ lines.push("export const syncComponents: Record<string, any> = {};");
186
+ }
187
+ lines.push("");
188
+
189
+ // LoadingFallback components map
190
+ const allFallbacks = entries.filter((e) => e.meta.hasLoadingFallback);
191
+ if (allFallbacks.length > 0) {
192
+ lines.push("export const loadingFallbacks: Record<string, React.ComponentType<any>> = {");
193
+ for (const e of allFallbacks) {
194
+ if (e.meta.sync) {
195
+ const syncIdx = syncEntries.indexOf(e);
196
+ lines.push(` "${e.key}": _sync${syncIdx}.LoadingFallback,`);
197
+ } else {
198
+ const fbIdx = nonSyncFallbacks.indexOf(e);
199
+ lines.push(` "${e.key}": _fb${fbIdx},`);
200
+ }
201
+ }
202
+ lines.push("};");
203
+ } else {
204
+ lines.push("export const loadingFallbacks: Record<string, React.ComponentType<any>> = {};");
205
+ }
206
+ lines.push("");
207
+
208
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
209
+ fs.writeFileSync(outFile, lines.join("\n"));
210
+
211
+ console.log(
212
+ `Generated section metadata for ${entries.length} sections → ${path.relative(process.cwd(), outFile)}`,
213
+ );
214
+ console.log(
215
+ ` ${syncEntries.length} sync, ${allFallbacks.length} with LoadingFallback, ` +
216
+ `${entries.filter((e) => e.meta.eager).length} eager, ` +
217
+ `${entries.filter((e) => e.meta.layout).length} layout, ` +
218
+ `${entries.filter((e) => e.meta.cache).length} cached`,
219
+ );
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Applies convention-based section metadata generated by generate-sections.ts.
3
+ *
4
+ * Reads the generated sectionMeta/syncComponents/loadingFallbacks and calls
5
+ * the appropriate registration functions — replacing manual calls to
6
+ * registerSectionsSync, setAsyncRenderingConfig, registerCacheableSections,
7
+ * registerLayoutSections, registerSeoSections, and registerSection.
8
+ */
9
+ import {
10
+ registerSection,
11
+ registerSectionsSync,
12
+ } from "./registry";
13
+ import {
14
+ registerCacheableSections,
15
+ registerLayoutSections,
16
+ } from "./sectionLoaders";
17
+ import {
18
+ registerSeoSections,
19
+ setAsyncRenderingConfig,
20
+ getAsyncRenderingConfig,
21
+ } from "./resolve";
22
+
23
+ export interface SectionMetaEntry {
24
+ eager?: boolean;
25
+ cache?: string;
26
+ layout?: boolean;
27
+ sync?: boolean;
28
+ clientOnly?: boolean;
29
+ seo?: boolean;
30
+ hasLoadingFallback?: boolean;
31
+ }
32
+
33
+ export interface ApplySectionConventionsInput {
34
+ /** Section metadata map from sections.gen.ts */
35
+ meta: Record<string, SectionMetaEntry>;
36
+ /** Sync-imported section modules from sections.gen.ts */
37
+ syncComponents?: Record<string, any>;
38
+ /** LoadingFallback components from sections.gen.ts */
39
+ loadingFallbacks?: Record<string, React.ComponentType<any>>;
40
+ /** Lazy section loaders from import.meta.glob (used for clientOnly/loadingFallback registration) */
41
+ sectionGlob?: Record<string, () => Promise<any>>;
42
+ }
43
+
44
+ export function applySectionConventions(input: ApplySectionConventionsInput): void {
45
+ const { meta, syncComponents, loadingFallbacks, sectionGlob } = input;
46
+
47
+ const eagerSections: string[] = [];
48
+ const layoutSections: string[] = [];
49
+ const seoSections: string[] = [];
50
+ const cacheableSections: Record<string, string> = {};
51
+
52
+ for (const [key, entry] of Object.entries(meta)) {
53
+ if (entry.eager) eagerSections.push(key);
54
+ if (entry.layout) layoutSections.push(key);
55
+ if (entry.seo) seoSections.push(key);
56
+ if (entry.cache) cacheableSections[key] = entry.cache;
57
+
58
+ if (entry.clientOnly && sectionGlob) {
59
+ const globKey = sectionGlobKey(key, sectionGlob);
60
+ if (globKey) {
61
+ registerSection(key, sectionGlob[globKey] as any, { clientOnly: true });
62
+ }
63
+ }
64
+
65
+ if (entry.hasLoadingFallback && loadingFallbacks?.[key] && sectionGlob) {
66
+ const globKey = sectionGlobKey(key, sectionGlob);
67
+ if (globKey) {
68
+ registerSection(key, sectionGlob[globKey] as any, {
69
+ loadingFallback: loadingFallbacks[key],
70
+ });
71
+ }
72
+ }
73
+ }
74
+
75
+ if (syncComponents && Object.keys(syncComponents).length > 0) {
76
+ registerSectionsSync(syncComponents);
77
+ }
78
+
79
+ if (eagerSections.length > 0) {
80
+ const existing = getAsyncRenderingConfig();
81
+ setAsyncRenderingConfig({
82
+ ...existing,
83
+ alwaysEager: [...(existing.alwaysEager ?? []), ...eagerSections],
84
+ });
85
+ }
86
+
87
+ if (layoutSections.length > 0) {
88
+ registerLayoutSections(layoutSections);
89
+ }
90
+
91
+ if (seoSections.length > 0) {
92
+ registerSeoSections(seoSections);
93
+ }
94
+
95
+ if (Object.keys(cacheableSections).length > 0) {
96
+ registerCacheableSections(cacheableSections);
97
+ }
98
+ }
99
+
100
+ function sectionGlobKey(
101
+ sectionKey: string,
102
+ glob: Record<string, () => Promise<any>>,
103
+ ): string | null {
104
+ const relative = sectionKey.replace("site/sections/", "./sections/");
105
+ if (glob[relative]) return relative;
106
+ const withDot = sectionKey.replace("site/", "./");
107
+ if (glob[withDot]) return withDot;
108
+ return null;
109
+ }
package/src/cms/index.ts CHANGED
@@ -70,3 +70,6 @@ export {
70
70
  runSectionLoaders,
71
71
  runSingleSectionLoader,
72
72
  } from "./sectionLoaders";
73
+ export { compose, withDevice, withMobile, withSearchParam } from "./sectionMixins";
74
+ export type { ApplySectionConventionsInput, SectionMetaEntry } from "./applySectionConventions";
75
+ export { applySectionConventions } from "./applySectionConventions";