@decocms/start 1.0.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/package.json +1 -1
- 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/package.json
CHANGED
|
@@ -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";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section Loader Mixins
|
|
3
|
+
*
|
|
4
|
+
* Reusable section loader factories for common patterns like device detection,
|
|
5
|
+
* mobile flag injection, and search param extraction. Eliminates repetitive
|
|
6
|
+
* `(props, req) => ({ ...props, device: detectDevice(...) })` boilerplate.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { withDevice, withMobile, compose } from "@decocms/start/cms";
|
|
11
|
+
*
|
|
12
|
+
* registerSectionLoaders({
|
|
13
|
+
* "site/sections/Product/ProductShelf.tsx": withDevice(),
|
|
14
|
+
* "site/sections/Images/Carousel.tsx": withMobile(),
|
|
15
|
+
* "site/sections/Header/Header.tsx": compose(withDevice(), withSearchParam()),
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import { detectDevice } from "../sdk/useDevice";
|
|
20
|
+
import type { SectionLoaderFn } from "./sectionLoaders";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Injects `device: "mobile" | "desktop" | "tablet"` from the request User-Agent.
|
|
24
|
+
*/
|
|
25
|
+
export function withDevice(): SectionLoaderFn {
|
|
26
|
+
return (props, req) => ({
|
|
27
|
+
...props,
|
|
28
|
+
device: detectDevice(req.headers.get("user-agent") ?? ""),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Injects `isMobile: boolean` (true for mobile and tablet) from the request User-Agent.
|
|
34
|
+
*/
|
|
35
|
+
export function withMobile(): SectionLoaderFn {
|
|
36
|
+
return (props, req) => {
|
|
37
|
+
const d = detectDevice(req.headers.get("user-agent") ?? "");
|
|
38
|
+
return { ...props, isMobile: d === "mobile" || d === "tablet" };
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const REGEX_QUERY_VALUE = /[?&]q=([^&]*)/;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Injects `currentSearchParam: string | undefined` extracted from the `?q=` URL parameter.
|
|
46
|
+
*/
|
|
47
|
+
export function withSearchParam(): SectionLoaderFn {
|
|
48
|
+
return (props, req) => {
|
|
49
|
+
const match = req.url.match(REGEX_QUERY_VALUE);
|
|
50
|
+
return {
|
|
51
|
+
...props,
|
|
52
|
+
currentSearchParam: match ? decodeURIComponent(match[1]) : undefined,
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Composes multiple section loader mixins into a single loader.
|
|
59
|
+
* Each mixin's result is merged left-to-right (later mixins override earlier ones).
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* compose(withDevice(), withSearchParam())
|
|
64
|
+
* // Equivalent to: (props, req) => ({ ...props, device: ..., currentSearchParam: ... })
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn {
|
|
68
|
+
return async (props, req) => {
|
|
69
|
+
let result = { ...props };
|
|
70
|
+
for (const mixin of mixins) {
|
|
71
|
+
const partial = await mixin(result, req);
|
|
72
|
+
result = { ...result, ...partial };
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
};
|
|
76
|
+
}
|