@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.
- package/.github/workflows/release.yml +0 -2
- package/package.json +6 -1
- package/scripts/generate-invoke.ts +51 -26
- package/scripts/generate-loaders.ts +133 -0
- package/scripts/generate-sections.ts +219 -0
- package/src/cms/applySectionConventions.ts +109 -0
- package/src/cms/index.ts +3 -0
- package/src/cms/sectionMixins.ts +76 -0
- package/src/hooks/DecoRootLayout.tsx +111 -0
- package/src/hooks/NavigationProgress.tsx +21 -0
- package/src/hooks/StableOutlet.tsx +30 -0
- package/src/hooks/index.ts +3 -0
- package/src/sdk/abTesting.ts +398 -0
- package/src/sdk/router.ts +92 -0
- package/src/sdk/workerEntry.ts +87 -1
- package/src/setup.ts +192 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/start",
|
|
3
|
-
"version": "
|
|
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
|
-
//
|
|
176
|
+
// Recursively unwrap AsExpression chains (e.g. `expr as unknown as Type`)
|
|
177
177
|
let createInvokeFnCall = callExpr;
|
|
178
|
-
|
|
179
|
-
createInvokeFnCall =
|
|
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
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 "
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
338
|
-
|
|
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 (
|
|
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
|
|
366
|
+
// Generate the vtexActions object (for composability with site-specific actions)
|
|
351
367
|
out += `
|
|
352
368
|
// ---------------------------------------------------------------------------
|
|
353
|
-
//
|
|
369
|
+
// Typed VTEX actions map — merge with site-specific actions in your invoke.ts
|
|
354
370
|
// ---------------------------------------------------------------------------
|
|
355
371
|
|
|
356
|
-
export const
|
|
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 += `
|
|
378
|
+
out += ` ${action.name}: ${varName} as unknown as (ctx: { data: ${action.inputType} }) => Promise<${action.returnType}>,\n`;
|
|
365
379
|
} else {
|
|
366
|
-
out += `
|
|
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";
|