@zenithbuild/cli 0.4.11 → 0.5.0-beta.2.3

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.
@@ -0,0 +1,382 @@
1
+ // ---------------------------------------------------------------------------
2
+ // resolve-components.js — Compile-time component expansion
3
+ // ---------------------------------------------------------------------------
4
+ // Zenith components are structural macros. This module expands PascalCase
5
+ // component tags into their template HTML at build time, so the compiler
6
+ // only ever sees standard HTML.
7
+ //
8
+ // Pipeline:
9
+ // buildComponentRegistry() → expandComponents() → expanded source string
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
13
+ import { basename, extname, join } from 'node:path';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Registry: Map<PascalCaseName, absolutePath>
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Walk `srcDir/components/` recursively. Return Map<PascalName, absPath>.
21
+ * Errors on duplicate component names within the registry.
22
+ *
23
+ * Also scans `srcDir/layouts/` for layout components (Document Mode).
24
+ *
25
+ * @param {string} srcDir — absolute path to the project's `src/` directory
26
+ * @returns {Map<string, string>}
27
+ */
28
+ export function buildComponentRegistry(srcDir) {
29
+ /** @type {Map<string, string>} */
30
+ const registry = new Map();
31
+
32
+ const scanDirs = ['components', 'layouts', 'globals'];
33
+ for (const sub of scanDirs) {
34
+ const dir = join(srcDir, sub);
35
+ try {
36
+ statSync(dir);
37
+ } catch {
38
+ continue; // Directory doesn't exist, skip
39
+ }
40
+ walkDir(dir, registry);
41
+ }
42
+
43
+ return registry;
44
+ }
45
+
46
+ /**
47
+ * @param {string} dir
48
+ * @param {Map<string, string>} registry
49
+ */
50
+ function walkDir(dir, registry) {
51
+ let entries;
52
+ try {
53
+ entries = readdirSync(dir);
54
+ } catch {
55
+ return;
56
+ }
57
+ entries.sort();
58
+
59
+ for (const name of entries) {
60
+ const fullPath = join(dir, name);
61
+ const info = statSync(fullPath);
62
+ if (info.isDirectory()) {
63
+ walkDir(fullPath, registry);
64
+ continue;
65
+ }
66
+ if (extname(name) !== '.zen') continue;
67
+
68
+ const componentName = basename(name, '.zen');
69
+ // Only register PascalCase names (first char uppercase)
70
+ if (!/^[A-Z]/.test(componentName)) continue;
71
+
72
+ if (registry.has(componentName)) {
73
+ throw new Error(
74
+ `Duplicate component name "${componentName}":\n` +
75
+ ` 1) ${registry.get(componentName)}\n` +
76
+ ` 2) ${fullPath}\n` +
77
+ `Rename one to resolve the conflict.`
78
+ );
79
+ }
80
+ registry.set(componentName, fullPath);
81
+ }
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Template extraction
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Strip all <script ...>...</script> and <style ...>...</style> blocks
90
+ * from a .zen source. Return template-only markup.
91
+ *
92
+ * @param {string} zenSource
93
+ * @returns {string}
94
+ */
95
+ export function extractTemplate(zenSource) {
96
+ // Remove <script ...>...</script> blocks (greedy matching for nested content)
97
+ let template = zenSource;
98
+
99
+ // Strip script blocks (handles <script>, <script lang="ts">, etc.)
100
+ template = stripBlock(template, 'script');
101
+ // Strip style blocks
102
+ template = stripBlock(template, 'style');
103
+
104
+ return template.trim();
105
+ }
106
+
107
+ /**
108
+ * Strip a matched pair of <tag ...>...</tag> from source.
109
+ * Handles multiple occurrences and attributes on the opening tag.
110
+ *
111
+ * @param {string} source
112
+ * @param {string} tag
113
+ * @returns {string}
114
+ */
115
+ function stripBlock(source, tag) {
116
+ // Use a regex that matches <tag ...>...</tag> including multiline content
117
+ // We need a non-greedy approach for nested scenarios, but script/style
118
+ // blocks cannot be nested in HTML, so we can match the first closing tag.
119
+ const re = new RegExp(
120
+ `<${tag}(?:\\s[^>]*)?>` + // opening tag with optional attributes
121
+ `[\\s\\S]*?` + // content (non-greedy)
122
+ `</${tag}>`, // closing tag
123
+ 'gi'
124
+ );
125
+ return source.replace(re, '');
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Document Mode detection
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Returns true if the template contains <!doctype or <html,
134
+ * indicating it's a Document Mode component (layout wrapper).
135
+ *
136
+ * @param {string} template
137
+ * @returns {boolean}
138
+ */
139
+ export function isDocumentMode(template) {
140
+ const lower = template.toLowerCase();
141
+ return lower.includes('<!doctype') || lower.includes('<html');
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Component expansion
146
+ // ---------------------------------------------------------------------------
147
+
148
+ const OPEN_COMPONENT_TAG_RE = /<([A-Z][a-zA-Z0-9]*)(\s[^<>]*?)?\s*(\/?)>/g;
149
+
150
+ /**
151
+ * Recursively expand PascalCase component tags in `source`.
152
+ *
153
+ * @param {string} source — page or component template source
154
+ * @param {Map<string, string>} registry — component name → .zen file path
155
+ * @param {string} sourceFile — source file path (for error messages)
156
+ * @param {Set<string>} [visited] — cycle detection set
157
+ * @returns {{ expandedSource: string, usedComponents: string[] }}
158
+ */
159
+ export function expandComponents(source, registry, sourceFile, visited) {
160
+ if (visited && visited.size > 0) {
161
+ throw new Error('expandComponents() does not accept a pre-populated visited set');
162
+ }
163
+
164
+ const usedComponents = [];
165
+ const expandedSource = expandSource(source, registry, sourceFile, [], usedComponents);
166
+ return {
167
+ expandedSource,
168
+ usedComponents: [...new Set(usedComponents)],
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Expand component tags recursively.
174
+ *
175
+ * @param {string} source
176
+ * @param {Map<string, string>} registry
177
+ * @param {string} sourceFile
178
+ * @param {string[]} chain
179
+ * @param {string[]} usedComponents
180
+ * @returns {string}
181
+ */
182
+ function expandSource(source, registry, sourceFile, chain, usedComponents) {
183
+ let output = source;
184
+ let iterations = 0;
185
+ const MAX_ITERATIONS = 10_000;
186
+
187
+ while (iterations < MAX_ITERATIONS) {
188
+ iterations += 1;
189
+ const tag = findNextKnownTag(output, registry, 0);
190
+ if (!tag) {
191
+ return output;
192
+ }
193
+
194
+ let children = '';
195
+ let replaceEnd = tag.end;
196
+
197
+ if (!tag.selfClosing) {
198
+ const close = findMatchingClose(output, tag.name, tag.end);
199
+ if (!close) {
200
+ throw new Error(
201
+ `Unclosed component tag <${tag.name}> in ${sourceFile} at offset ${tag.start}`
202
+ );
203
+ }
204
+ children = expandSource(
205
+ output.slice(tag.end, close.contentEnd),
206
+ registry,
207
+ sourceFile,
208
+ chain,
209
+ usedComponents
210
+ );
211
+ replaceEnd = close.tagEnd;
212
+ }
213
+
214
+ const replacement = expandTag(
215
+ tag.name,
216
+ children,
217
+ registry,
218
+ sourceFile,
219
+ chain,
220
+ usedComponents
221
+ );
222
+
223
+ output = output.slice(0, tag.start) + replacement + output.slice(replaceEnd);
224
+ }
225
+
226
+ throw new Error(
227
+ `Component expansion exceeded ${MAX_ITERATIONS} replacements in ${sourceFile}.`
228
+ );
229
+ }
230
+
231
+ /**
232
+ * Find the next component opening tag that exists in the registry.
233
+ *
234
+ * @param {string} source
235
+ * @param {Map<string, string>} registry
236
+ * @param {number} startIndex
237
+ * @returns {{ name: string, start: number, end: number, selfClosing: boolean } | null}
238
+ */
239
+ function findNextKnownTag(source, registry, startIndex) {
240
+ OPEN_COMPONENT_TAG_RE.lastIndex = startIndex;
241
+
242
+ let match;
243
+ while ((match = OPEN_COMPONENT_TAG_RE.exec(source)) !== null) {
244
+ const name = match[1];
245
+ if (!registry.has(name)) {
246
+ continue;
247
+ }
248
+ return {
249
+ name,
250
+ start: match.index,
251
+ end: OPEN_COMPONENT_TAG_RE.lastIndex,
252
+ selfClosing: match[3] === '/',
253
+ };
254
+ }
255
+
256
+ return null;
257
+ }
258
+
259
+ /**
260
+ * Find the matching </Name> for an opening tag, accounting for nested
261
+ * tags with the same name.
262
+ *
263
+ * @param {string} source — full source
264
+ * @param {string} tagName — tag name to match
265
+ * @param {number} startAfterOpen — position after the opening tag's `>`
266
+ * @returns {{ contentEnd: number, tagEnd: number } | null}
267
+ */
268
+ function findMatchingClose(source, tagName, startAfterOpen) {
269
+ let depth = 1;
270
+ const escapedName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
271
+ const tagRe = new RegExp(`<(/?)${escapedName}(?:\\s[^<>]*?)?\\s*(/?)>`, 'g');
272
+ tagRe.lastIndex = startAfterOpen;
273
+
274
+ let match;
275
+ while ((match = tagRe.exec(source)) !== null) {
276
+ const isClose = match[1] === '/';
277
+ const isSelfClose = match[2] === '/';
278
+
279
+ if (isSelfClose && !isClose) {
280
+ // Self-closing <Name />, doesn't affect depth.
281
+ continue;
282
+ }
283
+
284
+ if (isClose) {
285
+ depth--;
286
+ if (depth === 0) {
287
+ return {
288
+ contentEnd: match.index,
289
+ tagEnd: match.index + match[0].length,
290
+ };
291
+ }
292
+ } else {
293
+ depth++;
294
+ }
295
+ }
296
+
297
+ return null;
298
+ }
299
+
300
+ /**
301
+ * Expand a single component tag into its template HTML.
302
+ *
303
+ * @param {string} name — component name
304
+ * @param {string} children — children content (inner HTML of the tag)
305
+ * @param {Map<string, string>} registry
306
+ * @param {string} sourceFile
307
+ * @param {string[]} chain
308
+ * @param {string[]} usedComponents
309
+ * @returns {string}
310
+ */
311
+ function expandTag(name, children, registry, sourceFile, chain, usedComponents) {
312
+
313
+ const compPath = registry.get(name);
314
+ if (!compPath) {
315
+ throw new Error(`Unknown component "${name}" referenced in ${sourceFile}`);
316
+ }
317
+
318
+ // Cycle detection
319
+ if (chain.includes(name)) {
320
+ const cycle = [...chain, name].join(' -> ');
321
+ throw new Error(
322
+ `Circular component dependency detected: ${cycle}\n` +
323
+ `File: ${sourceFile}`
324
+ );
325
+ }
326
+
327
+ const compSource = readFileSync(compPath, 'utf8');
328
+ let template = extractTemplate(compSource);
329
+
330
+ // Check Document Mode
331
+ const docMode = isDocumentMode(template);
332
+
333
+ if (docMode) {
334
+ // Document Mode: must contain exactly one <slot />
335
+ const slotCount = countSlots(template);
336
+ if (slotCount !== 1) {
337
+ throw new Error(
338
+ `Document Mode component "${name}" must contain exactly one <slot />, found ${slotCount}.\n` +
339
+ `File: ${compPath}`
340
+ );
341
+ }
342
+ // Replace <slot /> with children
343
+ template = replaceSlot(template, children);
344
+ } else {
345
+ // Standard component
346
+ const slotCount = countSlots(template);
347
+ if (children.trim().length > 0 && slotCount === 0) {
348
+ throw new Error(
349
+ `Component "${name}" has children but its template has no <slot />.\n` +
350
+ `Either add <slot /> to ${compPath} or make the tag self-closing.`
351
+ );
352
+ }
353
+ if (slotCount > 0) {
354
+ template = replaceSlot(template, children || '');
355
+ }
356
+ }
357
+
358
+ usedComponents.push(name);
359
+
360
+ return expandSource(template, registry, compPath, [...chain, name], usedComponents);
361
+ }
362
+
363
+ /**
364
+ * Count occurrences of <slot /> or <slot></slot> in template.
365
+ * @param {string} template
366
+ * @returns {number}
367
+ */
368
+ function countSlots(template) {
369
+ const matches = template.match(/<slot\s*>\s*<\/slot>|<slot\s*\/>|<slot\s*>/gi);
370
+ return matches ? matches.length : 0;
371
+ }
372
+
373
+ /**
374
+ * Replace <slot />, <slot/>, or <slot></slot> with replacement content.
375
+ * @param {string} template
376
+ * @param {string} content
377
+ * @returns {string}
378
+ */
379
+ function replaceSlot(template, content) {
380
+ // Replace first occurrence of <slot /> or <slot></slot> or <slot>
381
+ return template.replace(/<slot\s*>\s*<\/slot>|<slot\s*\/>|<slot\s*>/i, content);
382
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Deterministic route precedence:
3
+ * static segment > param segment > catch-all segment.
4
+ * Tie-breakers: segment count (more specific first), then lexicographic path.
5
+ *
6
+ * @param {string} a
7
+ * @param {string} b
8
+ * @returns {number}
9
+ */
10
+ export function compareRouteSpecificity(a, b) {
11
+ if (a === '/' && b !== '/') return -1;
12
+ if (b === '/' && a !== '/') return 1;
13
+
14
+ const aSegs = splitPath(a);
15
+ const bSegs = splitPath(b);
16
+ const aClass = routeClass(aSegs);
17
+ const bClass = routeClass(bSegs);
18
+ if (aClass !== bClass) {
19
+ return bClass - aClass;
20
+ }
21
+
22
+ const max = Math.min(aSegs.length, bSegs.length);
23
+ for (let i = 0; i < max; i++) {
24
+ const aWeight = segmentWeight(aSegs[i]);
25
+ const bWeight = segmentWeight(bSegs[i]);
26
+ if (aWeight !== bWeight) {
27
+ return bWeight - aWeight;
28
+ }
29
+ }
30
+
31
+ if (aSegs.length !== bSegs.length) {
32
+ return bSegs.length - aSegs.length;
33
+ }
34
+
35
+ return a.localeCompare(b);
36
+ }
37
+
38
+ /**
39
+ * @param {string} pathname
40
+ * @param {Array<{ path: string }>} routes
41
+ * @returns {{ entry: { path: string }, params: Record<string, string> } | null}
42
+ */
43
+ export function matchRoute(pathname, routes) {
44
+ const target = splitPath(pathname);
45
+ const ordered = [...routes].sort((a, b) => compareRouteSpecificity(a.path, b.path));
46
+ for (const entry of ordered) {
47
+ const pattern = splitPath(entry.path);
48
+ const params = Object.create(null);
49
+ let patternIndex = 0;
50
+ let valueIndex = 0;
51
+ let matched = true;
52
+
53
+ while (patternIndex < pattern.length) {
54
+ const segment = pattern[patternIndex];
55
+ if (segment.startsWith('*')) {
56
+ const optionalCatchAll = segment.endsWith('?');
57
+ const key = optionalCatchAll ? segment.slice(1, -1) : segment.slice(1);
58
+ if (patternIndex !== pattern.length - 1) {
59
+ matched = false;
60
+ break;
61
+ }
62
+ const rest = target.slice(valueIndex);
63
+ const rootRequiredCatchAll = !optionalCatchAll && pattern.length === 1;
64
+ if (rest.length === 0 && !optionalCatchAll && !rootRequiredCatchAll) {
65
+ matched = false;
66
+ break;
67
+ }
68
+ params[key] = normalizeCatchAll(rest);
69
+ valueIndex = target.length;
70
+ patternIndex = pattern.length;
71
+ break;
72
+ }
73
+
74
+ if (valueIndex >= target.length) {
75
+ matched = false;
76
+ break;
77
+ }
78
+
79
+ const value = target[valueIndex];
80
+ if (segment.startsWith(':')) {
81
+ params[segment.slice(1)] = value;
82
+ } else if (segment !== value) {
83
+ matched = false;
84
+ break;
85
+ }
86
+
87
+ patternIndex += 1;
88
+ valueIndex += 1;
89
+ }
90
+
91
+ if (!matched) {
92
+ continue;
93
+ }
94
+
95
+ if (valueIndex !== target.length || patternIndex !== pattern.length) {
96
+ continue;
97
+ }
98
+
99
+ return { entry, params: { ...params } };
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Resolve an incoming request URL against a manifest route list.
107
+ *
108
+ * @param {string | URL} reqUrl
109
+ * @param {Array<{ path: string }>} manifest
110
+ * @returns {{ matched: boolean, route: { path: string } | null, params: Record<string, string> }}
111
+ */
112
+ export function resolveRequestRoute(reqUrl, manifest) {
113
+ const url = reqUrl instanceof URL ? reqUrl : new URL(String(reqUrl), 'http://localhost');
114
+ const matched = matchRoute(url.pathname, manifest);
115
+ if (!matched) {
116
+ return { matched: false, route: null, params: {} };
117
+ }
118
+ return {
119
+ matched: true,
120
+ route: matched.entry,
121
+ params: matched.params
122
+ };
123
+ }
124
+
125
+ /**
126
+ * @param {string[]} segments
127
+ * @returns {number}
128
+ */
129
+ function routeClass(segments) {
130
+ let hasParam = false;
131
+ let hasCatchAll = false;
132
+ for (const segment of segments) {
133
+ if (segment.startsWith('*')) {
134
+ hasCatchAll = true;
135
+ } else if (segment.startsWith(':')) {
136
+ hasParam = true;
137
+ }
138
+ }
139
+ if (!hasParam && !hasCatchAll) return 3;
140
+ if (hasCatchAll) return 1;
141
+ return 2;
142
+ }
143
+
144
+ /**
145
+ * @param {string | undefined} segment
146
+ * @returns {number}
147
+ */
148
+ function segmentWeight(segment) {
149
+ if (!segment) return 0;
150
+ if (segment.startsWith('*')) return 1;
151
+ if (segment.startsWith(':')) return 2;
152
+ return 3;
153
+ }
154
+
155
+ /**
156
+ * @param {string} pathname
157
+ * @returns {string[]}
158
+ */
159
+ function splitPath(pathname) {
160
+ return pathname.split('/').filter(Boolean);
161
+ }
162
+
163
+ /**
164
+ * @param {string[]} segments
165
+ * @returns {string}
166
+ */
167
+ function normalizeCatchAll(segments) {
168
+ return segments.filter(Boolean).join('/');
169
+ }
@@ -0,0 +1,146 @@
1
+ // server-contract.js — Zenith CLI V0
2
+ // ---------------------------------------------------------------------------
3
+ // Shared validation and payload resolution logic for <script server> blocks.
4
+
5
+ const NEW_KEYS = new Set(['data', 'load', 'prerender']);
6
+ const LEGACY_KEYS = new Set(['ssr_data', 'props', 'ssr', 'prerender']);
7
+ const ALLOWED_KEYS = new Set(['data', 'load', 'prerender', 'ssr_data', 'props', 'ssr']);
8
+
9
+ export function validateServerExports({ exports, filePath }) {
10
+ const exportKeys = Object.keys(exports);
11
+ const illegalKeys = exportKeys.filter(k => !ALLOWED_KEYS.has(k));
12
+
13
+ if (illegalKeys.length > 0) {
14
+ throw new Error(`[Zenith] ${filePath}: illegal export(s): ${illegalKeys.join(', ')}`);
15
+ }
16
+
17
+ const hasData = 'data' in exports;
18
+ const hasLoad = 'load' in exports;
19
+
20
+ const hasNew = hasData || hasLoad;
21
+ const hasLegacy = ('ssr_data' in exports) || ('props' in exports) || ('ssr' in exports);
22
+
23
+ if (hasData && hasLoad) {
24
+ throw new Error(`[Zenith] ${filePath}: cannot export both "data" and "load". Choose one.`);
25
+ }
26
+
27
+ if (hasNew && hasLegacy) {
28
+ throw new Error(
29
+ `[Zenith] ${filePath}: cannot mix new ("data"/"load") with legacy ("ssr_data"/"props"/"ssr") exports.`
30
+ );
31
+ }
32
+
33
+ if ('prerender' in exports && typeof exports.prerender !== 'boolean') {
34
+ throw new Error(`[Zenith] ${filePath}: "prerender" must be a boolean.`);
35
+ }
36
+
37
+ if (hasLoad && typeof exports.load !== 'function') {
38
+ throw new Error(`[Zenith] ${filePath}: "load" must be a function.`);
39
+ }
40
+ if (hasLoad) {
41
+ if (exports.load.length !== 1) {
42
+ throw new Error(`[Zenith] ${filePath}: "load(ctx)" must take exactly 1 argument.`);
43
+ }
44
+ const fnStr = exports.load.toString();
45
+ const paramsMatch = fnStr.match(/^[^{=]+\(([^)]*)\)/);
46
+ if (paramsMatch && paramsMatch[1].includes('...')) {
47
+ throw new Error(`[Zenith] ${filePath}: "load(ctx)" must not contain rest parameters.`);
48
+ }
49
+ }
50
+ }
51
+
52
+ export function assertJsonSerializable(value, where = 'payload') {
53
+ const seen = new Set();
54
+
55
+ function walk(v, path) {
56
+ const t = typeof v;
57
+
58
+ if (v === null) return;
59
+ if (t === 'string' || t === 'number' || t === 'boolean') return;
60
+
61
+ if (t === 'bigint' || t === 'function' || t === 'symbol') {
62
+ throw new Error(`[Zenith] ${where}: non-serializable ${t} at ${path}`);
63
+ }
64
+
65
+ if (t === 'undefined') {
66
+ throw new Error(`[Zenith] ${where}: undefined is not allowed at ${path}`);
67
+ }
68
+
69
+ if (v instanceof Date) {
70
+ throw new Error(`[Zenith] ${where}: Date is not allowed at ${path} (convert to ISO string)`);
71
+ }
72
+
73
+ if (v instanceof Map || v instanceof Set) {
74
+ throw new Error(`[Zenith] ${where}: Map/Set not allowed at ${path}`);
75
+ }
76
+
77
+ if (t === 'object') {
78
+ if (seen.has(v)) throw new Error(`[Zenith] ${where}: circular reference at ${path}`);
79
+ seen.add(v);
80
+
81
+ if (Array.isArray(v)) {
82
+ if (path === '$') {
83
+ throw new Error(`[Zenith] ${where}: top-level payload must be a plain object, not an array at ${path}`);
84
+ }
85
+ for (let i = 0; i < v.length; i++) walk(v[i], `${path}[${i}]`);
86
+ return;
87
+ }
88
+
89
+ const proto = Object.getPrototypeOf(v);
90
+ const isPlainObject = proto === null ||
91
+ proto === Object.prototype ||
92
+ (proto && proto.constructor && proto.constructor.name === 'Object');
93
+
94
+ if (!isPlainObject) {
95
+ throw new Error(`[Zenith] ${where}: non-plain object at ${path}`);
96
+ }
97
+
98
+ for (const k of Object.keys(v)) {
99
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') {
100
+ throw new Error(`[Zenith] ${where}: forbidden prototype pollution key "${k}" at ${path}.${k}`);
101
+ }
102
+ walk(v[k], `${path}.${k}`);
103
+ }
104
+ return;
105
+ }
106
+
107
+ throw new Error(`[Zenith] ${where}: unsupported type at ${path}`);
108
+ }
109
+
110
+ walk(value, '$');
111
+ }
112
+
113
+ export async function resolveServerPayload({ exports, ctx, filePath }) {
114
+ validateServerExports({ exports, filePath });
115
+
116
+ let payload;
117
+ if ('load' in exports) {
118
+ payload = await exports.load(ctx);
119
+ assertJsonSerializable(payload, `${filePath}: load(ctx) return`);
120
+ return payload;
121
+ }
122
+ if ('data' in exports) {
123
+ payload = exports.data;
124
+ assertJsonSerializable(payload, `${filePath}: data export`);
125
+ return payload;
126
+ }
127
+
128
+ // legacy fallback
129
+ if ('ssr_data' in exports) {
130
+ payload = exports.ssr_data;
131
+ assertJsonSerializable(payload, `${filePath}: ssr_data export`);
132
+ return payload;
133
+ }
134
+ if ('props' in exports) {
135
+ payload = exports.props;
136
+ assertJsonSerializable(payload, `${filePath}: props export`);
137
+ return payload;
138
+ }
139
+ if ('ssr' in exports) {
140
+ payload = exports.ssr;
141
+ assertJsonSerializable(payload, `${filePath}: ssr export`);
142
+ return payload;
143
+ }
144
+
145
+ return {};
146
+ }