@cfdez11/vex 0.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/README.md +1383 -0
- package/client/app.webmanifest +14 -0
- package/client/favicon.ico +0 -0
- package/client/services/cache.js +55 -0
- package/client/services/hmr-client.js +22 -0
- package/client/services/html.js +377 -0
- package/client/services/hydrate-client-components.js +97 -0
- package/client/services/hydrate.js +25 -0
- package/client/services/index.js +9 -0
- package/client/services/navigation/create-layouts.js +172 -0
- package/client/services/navigation/create-navigation.js +103 -0
- package/client/services/navigation/index.js +8 -0
- package/client/services/navigation/link-interceptor.js +39 -0
- package/client/services/navigation/metadata.js +23 -0
- package/client/services/navigation/navigate.js +64 -0
- package/client/services/navigation/prefetch.js +43 -0
- package/client/services/navigation/render-page.js +45 -0
- package/client/services/navigation/render-ssr.js +157 -0
- package/client/services/navigation/router.js +48 -0
- package/client/services/navigation/use-query-params.js +225 -0
- package/client/services/navigation/use-route-params.js +76 -0
- package/client/services/reactive.js +231 -0
- package/package.json +24 -0
- package/server/index.js +115 -0
- package/server/prebuild.js +12 -0
- package/server/root.html +15 -0
- package/server/utils/cache.js +89 -0
- package/server/utils/component-processor.js +1526 -0
- package/server/utils/data-cache.js +62 -0
- package/server/utils/delay.js +1 -0
- package/server/utils/files.js +723 -0
- package/server/utils/hmr.js +21 -0
- package/server/utils/router.js +373 -0
- package/server/utils/streaming.js +315 -0
- package/server/utils/template.js +263 -0
|
@@ -0,0 +1,1526 @@
|
|
|
1
|
+
import { watch } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { compileTemplateToHTML } from "./template.js";
|
|
4
|
+
import { getOriginalRoutePath, getPageFiles, getRoutePath, saveClientComponentModule, saveClientRoutesFile, saveComponentHtmlDisk, saveServerRoutesFile, readFile, getImportData, generateComponentId, adjustClientModulePath, PAGES_DIR, ROOT_HTML_DIR, getLayoutPaths } from "./files.js";
|
|
5
|
+
import { renderComponents } from "./streaming.js";
|
|
6
|
+
import { getRevalidateSeconds } from "./cache.js";
|
|
7
|
+
import { withCache } from "./data-cache.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Throws a structured redirect error that propagates out of getData and is
|
|
11
|
+
* caught by the router to issue an HTTP redirect response (FEAT-02).
|
|
12
|
+
*
|
|
13
|
+
* Available automatically inside every <script server> block — no import needed.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} redirectPath - The path or URL to redirect to.
|
|
16
|
+
* @param {number} [statusCode=302] - HTTP redirect status (301, 302, 307, 308).
|
|
17
|
+
* @throws {Error} Always throws — use inside getData to abort rendering.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* async function getData({ req }) {
|
|
21
|
+
* if (!VALID_CITIES.includes(req.params.city)) redirect('/not-found', 302);
|
|
22
|
+
* return { city: req.params.city };
|
|
23
|
+
* }
|
|
24
|
+
*/
|
|
25
|
+
function redirect(redirectPath, statusCode = 302) {
|
|
26
|
+
const err = new Error("REDIRECT");
|
|
27
|
+
err.redirect = { path: redirectPath, statusCode };
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* In-memory cache for parsed `.html` component files.
|
|
33
|
+
*
|
|
34
|
+
* `processHtmlFile` does three expensive things per call:
|
|
35
|
+
* 1. `fs.readFile` — disk I/O
|
|
36
|
+
* 2. Regex extraction of <script server>, <script client> and <template> blocks
|
|
37
|
+
* 3. `new AsyncFunction(...)` + execution to extract getData / getMetadata / getStaticPaths
|
|
38
|
+
*
|
|
39
|
+
* None of these results change between requests for the same file, so the output
|
|
40
|
+
* can be safely reused across the lifetime of the process — in both dev and production.
|
|
41
|
+
*
|
|
42
|
+
* In production files never change, so entries are kept forever.
|
|
43
|
+
* In dev, a file watcher (see below) deletes stale entries whenever a .html file is saved,
|
|
44
|
+
* so the next request re-parses and re-populates the cache automatically.
|
|
45
|
+
*
|
|
46
|
+
* Key: absolute file path (e.g. /project/pages/home/page.html)
|
|
47
|
+
* Value: the object returned by _processHtmlFile (getData, template, serverComponents, …)
|
|
48
|
+
*/
|
|
49
|
+
const processHtmlFileCache = new Map();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Root HTML shell read once at module load.
|
|
53
|
+
*
|
|
54
|
+
* `root.html` is a static wrapper (doctype, <head>, <body>) that never changes
|
|
55
|
+
* between requests. Reading it from disk on every SSR request is pure waste.
|
|
56
|
+
*
|
|
57
|
+
* Because this module is ESM, top-level `await` is valid and the read is
|
|
58
|
+
* completed before any request handler can call `renderLayouts`, so there is
|
|
59
|
+
* no race condition. Subsequent calls to `renderLayouts` use the already-resolved
|
|
60
|
+
* value with zero I/O.
|
|
61
|
+
*
|
|
62
|
+
* In production the value is kept for the lifetime of the process (files are
|
|
63
|
+
* immutable after deploy). In dev the watcher below refreshes it on save.
|
|
64
|
+
*/
|
|
65
|
+
let rootTemplate = await readFile(ROOT_HTML_DIR);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* In dev only: watch pages/, components/ and root.html for .html changes.
|
|
69
|
+
*
|
|
70
|
+
* - pages/ and components/: evicts the processHtmlFileCache entry for the
|
|
71
|
+
* changed file so the next request re-parses it from disk.
|
|
72
|
+
* - root.html: re-reads the file and updates `rootTemplate` so the new shell
|
|
73
|
+
* is used immediately without restarting the process.
|
|
74
|
+
*
|
|
75
|
+
* This watcher is intentionally skipped in production because:
|
|
76
|
+
* - files are immutable after deploy, so invalidation is never needed
|
|
77
|
+
* - `fs.watch` keeps the Node process alive and consumes inotify/kqueue handles
|
|
78
|
+
*/
|
|
79
|
+
if (process.env.NODE_ENV !== "production") {
|
|
80
|
+
// Lazy import — hmr.js is never loaded in production
|
|
81
|
+
const { hmrEmitter } = await import("./hmr.js");
|
|
82
|
+
|
|
83
|
+
const watchDirs = [PAGES_DIR, path.join(path.dirname(PAGES_DIR), "components")];
|
|
84
|
+
for (const dir of watchDirs) {
|
|
85
|
+
watch(dir, { recursive: true }, async (_, filename) => {
|
|
86
|
+
if (filename?.endsWith(".vex")) {
|
|
87
|
+
const fullPath = path.join(dir, filename);
|
|
88
|
+
|
|
89
|
+
// 1. Evict all in-memory caches for this file
|
|
90
|
+
processHtmlFileCache.delete(fullPath);
|
|
91
|
+
processedComponentsInBuild.delete(fullPath);
|
|
92
|
+
|
|
93
|
+
// 2. Re-generate client bundle so the browser gets fresh JS (FEAT-03 HMR)
|
|
94
|
+
try {
|
|
95
|
+
await generateComponentAndFillCache(fullPath);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.error(`[HMR] Re-generation failed for ${filename}:`, e.message);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 3. Notify connected browsers to reload
|
|
101
|
+
hmrEmitter.emit("reload", filename);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// root.html is a single file — watch it directly
|
|
107
|
+
watch(ROOT_HTML_DIR, async () => {
|
|
108
|
+
rootTemplate = await readFile(ROOT_HTML_DIR);
|
|
109
|
+
hmrEmitter.emit("reload", "root.html");
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const DEFAULT_METADATA = {
|
|
114
|
+
title: "Vanilla JS App",
|
|
115
|
+
description: "Default description",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parses an ES module script block and extracts:
|
|
120
|
+
* - Server-side imports (executed immediately)
|
|
121
|
+
* - HTML component imports
|
|
122
|
+
* - Client-side imports (without execution)
|
|
123
|
+
*
|
|
124
|
+
* When `isClientSide` is enabled, JavaScript modules are not executed,
|
|
125
|
+
* preventing side effects during server rendering.
|
|
126
|
+
*
|
|
127
|
+
* @async
|
|
128
|
+
* @param {string} script
|
|
129
|
+
* Raw script contents extracted from <script> block.
|
|
130
|
+
*
|
|
131
|
+
* @param {boolean} [isClientSide=false]
|
|
132
|
+
* Whether the script is client-only.
|
|
133
|
+
*
|
|
134
|
+
* @returns {Promise<{
|
|
135
|
+
* imports: Record<string, any>,
|
|
136
|
+
* componentRegistry: Map<string, {
|
|
137
|
+
* path: string,
|
|
138
|
+
* originalPath: string,
|
|
139
|
+
* importStatement: string
|
|
140
|
+
* }>,
|
|
141
|
+
* clientImports: Record<string, {
|
|
142
|
+
* fileUrl: string,
|
|
143
|
+
* originalPath: string,
|
|
144
|
+
* importStatement: string
|
|
145
|
+
* }>
|
|
146
|
+
* }>}
|
|
147
|
+
*/
|
|
148
|
+
const getScriptImports = async (script, isClientSide = false) => {
|
|
149
|
+
const componentRegistry = new Map();
|
|
150
|
+
const imports = {};
|
|
151
|
+
const clientImports = {};
|
|
152
|
+
const importRegex =
|
|
153
|
+
/import\s+(?:([a-zA-Z_$][\w$]*)|\{([^}]*)\})\s+from\s+['"]([^'"]+)['"]/g;
|
|
154
|
+
let match;
|
|
155
|
+
|
|
156
|
+
// Process imports
|
|
157
|
+
while ((match = importRegex.exec(script)) !== null) {
|
|
158
|
+
const [importStatement, defaultImport, namedImports, modulePath] = match;
|
|
159
|
+
|
|
160
|
+
const { path, fileUrl } = await getImportData(modulePath);
|
|
161
|
+
|
|
162
|
+
if (path.endsWith(".vex")) {
|
|
163
|
+
// Recursively process HTML component
|
|
164
|
+
if (defaultImport) {
|
|
165
|
+
componentRegistry.set(defaultImport, {
|
|
166
|
+
path: path,
|
|
167
|
+
originalPath: modulePath,
|
|
168
|
+
importStatement,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
} else if (!isClientSide) {
|
|
172
|
+
// Import JS module
|
|
173
|
+
const module = await import(fileUrl);
|
|
174
|
+
if (defaultImport) {
|
|
175
|
+
imports[defaultImport] = module.default || module[defaultImport];
|
|
176
|
+
}
|
|
177
|
+
if (namedImports) {
|
|
178
|
+
namedImports.split(",").forEach((name) => {
|
|
179
|
+
const trimmedName = name.trim();
|
|
180
|
+
imports[trimmedName] = module[trimmedName];
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
} else if (defaultImport) {
|
|
184
|
+
// client side default imports and named imports
|
|
185
|
+
const adjustedClientModule = adjustClientModulePath(modulePath, importStatement);
|
|
186
|
+
clientImports[defaultImport || namedImports] = {
|
|
187
|
+
fileUrl,
|
|
188
|
+
originalPath: adjustedClientModule.path,
|
|
189
|
+
importStatement: adjustedClientModule.importStatement,
|
|
190
|
+
};
|
|
191
|
+
} else {
|
|
192
|
+
namedImports.split(",").forEach((name) => {
|
|
193
|
+
const trimmedName = name.trim();
|
|
194
|
+
// if import module path is .app/file_name.js add .app/client/services/file_name.js
|
|
195
|
+
const adjustedClientModule = adjustClientModulePath(modulePath, importStatement);
|
|
196
|
+
clientImports[trimmedName] = {
|
|
197
|
+
fileUrl,
|
|
198
|
+
originalPath: adjustedClientModule.path,
|
|
199
|
+
importStatement: adjustedClientModule.importStatement,
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { imports, componentRegistry, clientImports };
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Raw implementation of the file parser — called only when the cache misses.
|
|
210
|
+
* Do not call directly; use the exported `processHtmlFile` wrapper instead.
|
|
211
|
+
*
|
|
212
|
+
* Parses an HTML page or component file and extracts:
|
|
213
|
+
* - Server-side logic
|
|
214
|
+
* - Client-side code
|
|
215
|
+
* - HTML template
|
|
216
|
+
* - Metadata & data-fetching hooks
|
|
217
|
+
* - Component dependency graphs
|
|
218
|
+
*
|
|
219
|
+
* Server-side scripts are executed in a sandboxed async context.
|
|
220
|
+
*
|
|
221
|
+
* @async
|
|
222
|
+
* @param {string} filePath
|
|
223
|
+
* Absolute path to the HTML file.
|
|
224
|
+
*
|
|
225
|
+
* @returns {Promise<{
|
|
226
|
+
* getStaticPaths: (() => Promise<Array<{ params: Record<string, string | number> }>>) | null,
|
|
227
|
+
* getData: (() => Promise<any>) | null,
|
|
228
|
+
* getMetadata: (() => Promise<any>) | null,
|
|
229
|
+
* template: string,
|
|
230
|
+
* clientCode: string,
|
|
231
|
+
* clientImports: Record<string, {
|
|
232
|
+
* fileUrl: string,
|
|
233
|
+
* originalPath: string,
|
|
234
|
+
* importStatement: string
|
|
235
|
+
* }>,
|
|
236
|
+
* serverComponents: Map<string, {
|
|
237
|
+
* path: string,
|
|
238
|
+
* originalPath: string,
|
|
239
|
+
* importStatement: string
|
|
240
|
+
* }>,
|
|
241
|
+
* clientComponents: Map<string, {
|
|
242
|
+
* path: string,
|
|
243
|
+
* originalPath: string,
|
|
244
|
+
* importStatement: string
|
|
245
|
+
* }>
|
|
246
|
+
* }>}
|
|
247
|
+
*/
|
|
248
|
+
async function _processHtmlFile(filePath) {
|
|
249
|
+
const content = await readFile(filePath);
|
|
250
|
+
|
|
251
|
+
const serverMatch = content.match(/<script server>([\s\S]*?)<\/script>/);
|
|
252
|
+
const clientMatch = content.match(/<script client>([\s\S]*?)<\/script>/);
|
|
253
|
+
const templateMatch = content.match(/<template>([\s\S]*?)<\/template>/);
|
|
254
|
+
|
|
255
|
+
const template = templateMatch ? templateMatch[1].trim() : "";
|
|
256
|
+
const clientCode = clientMatch ? clientMatch[1].trim() : "";
|
|
257
|
+
let serverComponents = new Map();
|
|
258
|
+
let clientComponents = new Map();
|
|
259
|
+
let clientImports = {};
|
|
260
|
+
|
|
261
|
+
let getData = null;
|
|
262
|
+
let getStaticPaths = null;
|
|
263
|
+
let getMetadata = null;
|
|
264
|
+
|
|
265
|
+
if (serverMatch) {
|
|
266
|
+
const scriptContent = serverMatch[1];
|
|
267
|
+
const { componentRegistry, imports } = await getScriptImports(
|
|
268
|
+
scriptContent
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
serverComponents = componentRegistry;
|
|
272
|
+
|
|
273
|
+
// Clean script and execute
|
|
274
|
+
const cleanedScript = scriptContent
|
|
275
|
+
.replace(
|
|
276
|
+
/import\s+(?:(?:[a-zA-Z_$][\w$]*)|\{[^}]*\})\s+from\s+['"][^'"]*['"];?\n?/g,
|
|
277
|
+
""
|
|
278
|
+
)
|
|
279
|
+
.replace(/export\s+/g, "")
|
|
280
|
+
.trim();
|
|
281
|
+
|
|
282
|
+
if (cleanedScript) {
|
|
283
|
+
const AsyncFunction = Object.getPrototypeOf(
|
|
284
|
+
async function () { }
|
|
285
|
+
).constructor;
|
|
286
|
+
// `redirect` and `withCache` are the first two params so they are in
|
|
287
|
+
// scope for the script closure — including inside getData.
|
|
288
|
+
const fn = new AsyncFunction(
|
|
289
|
+
"redirect",
|
|
290
|
+
"withCache",
|
|
291
|
+
...Object.keys(imports),
|
|
292
|
+
`
|
|
293
|
+
${cleanedScript}
|
|
294
|
+
${!cleanedScript.includes("getData") ? "const getData = null;" : ""}
|
|
295
|
+
${!cleanedScript.includes("const metadata = ") ? "const metadata = null;" : ""}
|
|
296
|
+
${!cleanedScript.includes("getMetadata") ? "const getMetadata = null;" : ""}
|
|
297
|
+
${!cleanedScript.includes("getStaticPaths") ? "const getStaticPaths = null;" : ""}
|
|
298
|
+
return { getData, metadata, getMetadata, getStaticPaths };
|
|
299
|
+
`
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const result = await fn(redirect, withCache, ...Object.values(imports));
|
|
304
|
+
getData = result.getData;
|
|
305
|
+
getStaticPaths = result.getStaticPaths;
|
|
306
|
+
getMetadata = result.metadata ? () => result.metadata : result.getMetadata;
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.error(`Error executing script in ${filePath}:`, error.message);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (clientMatch) {
|
|
314
|
+
const { componentRegistry, clientImports: newClientImports } = await getScriptImports(clientMatch[1], true);
|
|
315
|
+
clientComponents = componentRegistry;
|
|
316
|
+
clientImports = newClientImports;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
getStaticPaths,
|
|
321
|
+
getData,
|
|
322
|
+
getMetadata,
|
|
323
|
+
template,
|
|
324
|
+
clientCode,
|
|
325
|
+
serverComponents,
|
|
326
|
+
clientComponents,
|
|
327
|
+
clientImports,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Cached wrapper around `_processHtmlFile`.
|
|
333
|
+
*
|
|
334
|
+
* Returns the cached result on subsequent calls for the same file, avoiding
|
|
335
|
+
* repeated disk reads and AsyncFunction construction on every SSR request.
|
|
336
|
+
* The cache is populated on first access and evicted in dev when the file changes
|
|
337
|
+
* (see `processHtmlFileCache` watcher above).
|
|
338
|
+
*
|
|
339
|
+
* @param {string} filePath - Absolute path to the .html component file.
|
|
340
|
+
* @returns {Promise<ReturnType<_processHtmlFile>>}
|
|
341
|
+
*/
|
|
342
|
+
export async function processHtmlFile(filePath) {
|
|
343
|
+
if (processHtmlFileCache.has(filePath)) return processHtmlFileCache.get(filePath);
|
|
344
|
+
const result = await _processHtmlFile(filePath);
|
|
345
|
+
processHtmlFileCache.set(filePath, result);
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Renders an HTML file using server-side data and metadata hooks.
|
|
351
|
+
*
|
|
352
|
+
* @async
|
|
353
|
+
* @param {string} filePath
|
|
354
|
+
* Absolute path to the HTML file.
|
|
355
|
+
*
|
|
356
|
+
* @param {{
|
|
357
|
+
* req: import("http").IncomingMessage,
|
|
358
|
+
* res: import("http").ServerResponse,
|
|
359
|
+
* [key: string]: any
|
|
360
|
+
* }} [context={}]
|
|
361
|
+
*
|
|
362
|
+
* @param {object} [extraComponentData={}]
|
|
363
|
+
* Additional data to pass to the component during rendering.
|
|
364
|
+
*
|
|
365
|
+
* @returns {Promise<{
|
|
366
|
+
* html: string,
|
|
367
|
+
* metadata: object | null,
|
|
368
|
+
* clientCode: string,
|
|
369
|
+
* serverComponents: Map<string, any>,
|
|
370
|
+
* clientComponents: Map<string, any>,
|
|
371
|
+
* clientImports: Record<string, {
|
|
372
|
+
* fileUrl: string,
|
|
373
|
+
* originalPath: string,
|
|
374
|
+
* importStatement: string
|
|
375
|
+
* }>,
|
|
376
|
+
* }>}
|
|
377
|
+
*/
|
|
378
|
+
export async function renderHtmlFile(filePath, context = {}, extraComponentData = {}) {
|
|
379
|
+
const {
|
|
380
|
+
getData,
|
|
381
|
+
getMetadata,
|
|
382
|
+
template,
|
|
383
|
+
clientCode,
|
|
384
|
+
serverComponents,
|
|
385
|
+
clientComponents,
|
|
386
|
+
clientImports,
|
|
387
|
+
} = await processHtmlFile(filePath);
|
|
388
|
+
|
|
389
|
+
const componentData = getData ? await getData(context) : {};
|
|
390
|
+
const metadata = getMetadata ? await getMetadata({ req: context.req, props: componentData }) : null;
|
|
391
|
+
const html = compileTemplateToHTML(template, { ...componentData, ...extraComponentData });
|
|
392
|
+
|
|
393
|
+
return { html, metadata, clientCode, serverComponents, clientComponents, clientImports };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Generates final <script> tags for client-side execution,
|
|
398
|
+
* including scripts and component modules.
|
|
399
|
+
*
|
|
400
|
+
* @param {{
|
|
401
|
+
* clientCode: string,
|
|
402
|
+
* clientComponentsScripts?: string[],
|
|
403
|
+
* clientComponents?: Map<string, {
|
|
404
|
+
* path: string,
|
|
405
|
+
* originalPath: string,
|
|
406
|
+
* importStatement: string
|
|
407
|
+
* }>,
|
|
408
|
+
* }} params
|
|
409
|
+
*
|
|
410
|
+
* @returns {string}
|
|
411
|
+
* HTML-safe script tag string.
|
|
412
|
+
*/
|
|
413
|
+
function generateClientScriptTags({
|
|
414
|
+
clientCode,
|
|
415
|
+
clientComponentsScripts = [],
|
|
416
|
+
clientComponents = new Map(),
|
|
417
|
+
}) {
|
|
418
|
+
if (!clientCode) return "";
|
|
419
|
+
// replace component imports to point to .js files
|
|
420
|
+
for (const { importStatement } of clientComponents.values()) {
|
|
421
|
+
clientCode = clientCode.replace(`${importStatement};`, '').replace(importStatement, "");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const clientCodeWithoutComponentImports = clientCode
|
|
425
|
+
.split("\n")
|
|
426
|
+
.filter((line) => !/^\s*import\s+.*['"].*\.vex['"]/.test(line))
|
|
427
|
+
.join("\n")
|
|
428
|
+
.trim();
|
|
429
|
+
|
|
430
|
+
const scripts = `
|
|
431
|
+
${clientCodeWithoutComponentImports.trim()
|
|
432
|
+
? `<script type="module">\n${clientCodeWithoutComponentImports}\n</script>`
|
|
433
|
+
: ""
|
|
434
|
+
}
|
|
435
|
+
${clientComponentsScripts?.length ? clientComponentsScripts.join("\n") : ""}
|
|
436
|
+
`;
|
|
437
|
+
|
|
438
|
+
return scripts.trim();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Renders a page with server and client components.
|
|
443
|
+
* @param {string} pagePath
|
|
444
|
+
* @param {{
|
|
445
|
+
* req: import("http").IncomingMessage,
|
|
446
|
+
* res: import("http").ServerResponse,
|
|
447
|
+
* [key: string]: any
|
|
448
|
+
* }} [ctx={}]
|
|
449
|
+
* @param {boolean} [awaitSuspenseComponents=false]
|
|
450
|
+
* @param {object} [extraComponentData={}]
|
|
451
|
+
* @returns {Promise<{
|
|
452
|
+
* html: string,
|
|
453
|
+
* metadata: object,
|
|
454
|
+
* clientCode: string,
|
|
455
|
+
* serverComponents: Map<string, any>,
|
|
456
|
+
* clientComponents: Map<string, any>,
|
|
457
|
+
* suspenseComponents: Array<{ id: string, content: string }>,
|
|
458
|
+
* clientComponentsScripts: string[],
|
|
459
|
+
* }>
|
|
460
|
+
*/
|
|
461
|
+
async function renderPage(pagePath, ctx, awaitSuspenseComponents = false, extraComponentData = {}) {
|
|
462
|
+
const {
|
|
463
|
+
html,
|
|
464
|
+
metadata,
|
|
465
|
+
clientCode,
|
|
466
|
+
serverComponents,
|
|
467
|
+
clientComponents,
|
|
468
|
+
} = await renderHtmlFile(pagePath, ctx, extraComponentData);
|
|
469
|
+
|
|
470
|
+
const {
|
|
471
|
+
html: htmlWithComponents,
|
|
472
|
+
suspenseComponents,
|
|
473
|
+
clientComponentsScripts = [],
|
|
474
|
+
} = await renderComponents({
|
|
475
|
+
html,
|
|
476
|
+
serverComponents,
|
|
477
|
+
clientComponents,
|
|
478
|
+
awaitSuspenseComponents,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
html: htmlWithComponents,
|
|
483
|
+
metadata,
|
|
484
|
+
clientCode,
|
|
485
|
+
serverComponents,
|
|
486
|
+
clientComponents,
|
|
487
|
+
suspenseComponents,
|
|
488
|
+
clientComponentsScripts,
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Renders nested layouts for a given page.
|
|
494
|
+
* @param {string} pagePath
|
|
495
|
+
* @param {string} pageContent
|
|
496
|
+
* @param {object} [pageHead={}]
|
|
497
|
+
* @returns {Promise<string>}
|
|
498
|
+
*/
|
|
499
|
+
async function renderLayouts(pagePath, pageContent, pageHead = {}) {
|
|
500
|
+
const layoutPaths = await getLayoutPaths(pagePath);
|
|
501
|
+
|
|
502
|
+
let currentContent = pageContent;
|
|
503
|
+
let deepMetadata = pageHead.metadata || {};
|
|
504
|
+
|
|
505
|
+
// Render layouts from innermost to outermost
|
|
506
|
+
for (let i = layoutPaths.length - 1; i >= 0; i--) {
|
|
507
|
+
const layoutPath = layoutPaths[i];
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
const { html, metadata } = await renderPage(layoutPath, {}, false, {
|
|
511
|
+
props: {
|
|
512
|
+
children: currentContent
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
deepMetadata = { ...deepMetadata, ...metadata };
|
|
517
|
+
currentContent = html;
|
|
518
|
+
} catch (error) {
|
|
519
|
+
console.warn(`Error rendering ${layoutPath}, skipping`);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// wrap in root — rootTemplate is pre-loaded at module level
|
|
525
|
+
const { clientScripts, ...restPageHead } = pageHead;
|
|
526
|
+
currentContent = compileTemplateToHTML(rootTemplate, {
|
|
527
|
+
...restPageHead,
|
|
528
|
+
metadata: deepMetadata,
|
|
529
|
+
props: {
|
|
530
|
+
children: currentContent
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// Inject framework internals + page client scripts before </head>
|
|
535
|
+
// so users don't need to reference framework scripts in their root.html
|
|
536
|
+
const devMode = process.env.NODE_ENV !== "production";
|
|
537
|
+
const frameworkScripts = [
|
|
538
|
+
`<script type="module" src="/_vexjs/services/index.js"></script>`,
|
|
539
|
+
`<script src="/_vexjs/services/hydrate-client-components.js"></script>`,
|
|
540
|
+
`<script src="/_vexjs/services/hydrate.js" id="hydrate-script"></script>`,
|
|
541
|
+
devMode ? `<script src="/_vexjs/services/hmr-client.js"></script>` : "",
|
|
542
|
+
clientScripts || "",
|
|
543
|
+
].filter(Boolean).join("\n ");
|
|
544
|
+
|
|
545
|
+
currentContent = currentContent.replace("</head>", ` ${frameworkScripts}\n</head>`);
|
|
546
|
+
|
|
547
|
+
return currentContent;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Renders a page wrapped in the global layout.
|
|
552
|
+
*
|
|
553
|
+
* Supports:
|
|
554
|
+
* - Server components
|
|
555
|
+
* - Suspense streaming
|
|
556
|
+
* - Client hydration
|
|
557
|
+
*
|
|
558
|
+
* @async
|
|
559
|
+
* @param {string} pagePath
|
|
560
|
+
* Absolute path to page.html.
|
|
561
|
+
*
|
|
562
|
+
* @param {{
|
|
563
|
+
* req: import("http").IncomingMessage,
|
|
564
|
+
* res: import("http").ServerResponse,
|
|
565
|
+
* [key: string]: any
|
|
566
|
+
* }} [ctx={}]
|
|
567
|
+
*
|
|
568
|
+
* @param {boolean} [awaitSuspenseComponents=false]
|
|
569
|
+
* Whether suspense components should be rendered immediately.
|
|
570
|
+
*
|
|
571
|
+
* @returns {Promise<{
|
|
572
|
+
* html: string,
|
|
573
|
+
* pageHtml: string,
|
|
574
|
+
* metadata: object,
|
|
575
|
+
* suspenseComponents: Array<{ id: string, content: string }>,
|
|
576
|
+
* serverComponents: Map<string, any>,
|
|
577
|
+
* clientComponents: Map<string, any>
|
|
578
|
+
* }>}
|
|
579
|
+
*/
|
|
580
|
+
export async function renderPageWithLayout(pagePath, ctx = {}, awaitSuspenseComponents = false) {
|
|
581
|
+
const {
|
|
582
|
+
html: pageHtml,
|
|
583
|
+
metadata,
|
|
584
|
+
clientCode,
|
|
585
|
+
serverComponents,
|
|
586
|
+
clientComponents,
|
|
587
|
+
suspenseComponents,
|
|
588
|
+
clientComponentsScripts,
|
|
589
|
+
} = await renderPage(pagePath, ctx, awaitSuspenseComponents);
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
// Wrap in layout
|
|
593
|
+
const clientScripts = generateClientScriptTags({
|
|
594
|
+
clientCode,
|
|
595
|
+
clientComponentsScripts,
|
|
596
|
+
clientComponents,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const html = await renderLayouts(pagePath, pageHtml, {
|
|
600
|
+
clientScripts,
|
|
601
|
+
metadata: { ...DEFAULT_METADATA, ...metadata },
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
return {
|
|
605
|
+
html,
|
|
606
|
+
pageHtml,
|
|
607
|
+
metadata,
|
|
608
|
+
suspenseComponents,
|
|
609
|
+
serverComponents,
|
|
610
|
+
clientComponents,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Converts a Vue-like template syntax into an `html`` tagged template.
|
|
616
|
+
*
|
|
617
|
+
* Supports:
|
|
618
|
+
* - v-for, v-if, v-else-if, v-else, v-show
|
|
619
|
+
* - Reactive `.value` auto-detection
|
|
620
|
+
* - Property & event bindings
|
|
621
|
+
*
|
|
622
|
+
* @param {string} template
|
|
623
|
+
* Vue-like template string.
|
|
624
|
+
*
|
|
625
|
+
* @param {string} [clientCode=""]
|
|
626
|
+
* Client-side code used to detect reactive variables.
|
|
627
|
+
*
|
|
628
|
+
* @returns {string}
|
|
629
|
+
* Converted tagged-template HTML.
|
|
630
|
+
*/
|
|
631
|
+
function convertVueToHtmlTagged(template, clientCode = "") {
|
|
632
|
+
const reactiveVars = new Set();
|
|
633
|
+
const reactiveRegex =
|
|
634
|
+
/(?:const|let|var)\s+(\w+)\s*=\s*(?:reactive|computed)\(/g;
|
|
635
|
+
|
|
636
|
+
let match;
|
|
637
|
+
|
|
638
|
+
while ((match = reactiveRegex.exec(clientCode)) !== null) {
|
|
639
|
+
reactiveVars.add(match[1]);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Helper to add .value only to reactive variables
|
|
644
|
+
* Preserves member access (e.g., counter.value stays as counter.value)
|
|
645
|
+
* Preserves method calls (e.g., increment() stays as increment())
|
|
646
|
+
*/
|
|
647
|
+
const processExpression = (expr) => {
|
|
648
|
+
return expr.replace(/\b(\w+)(?!\s*[\.\(])/g, (_, varName) => {
|
|
649
|
+
return reactiveVars.has(varName) ? `${varName}.value` : varName;
|
|
650
|
+
});
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
let result = template.trim();
|
|
654
|
+
|
|
655
|
+
// x-for="item in items" → ${items.value.map(item => html`...`)}
|
|
656
|
+
result = result.replace(
|
|
657
|
+
/<(\w+)([^>]*)\s+x-for="(\w+)\s+in\s+([^"]+)(?:\.value)?"([^>]*)>([\s\S]*?)<\/\1>/g,
|
|
658
|
+
(_, tag, beforeAttrs, iterVar, arrayVar, afterAttrs, content) => {
|
|
659
|
+
const cleanExpr = arrayVar.trim();
|
|
660
|
+
const isSimpleVar = /^\w+$/.test(cleanExpr);
|
|
661
|
+
const arrayAccess = isSimpleVar && reactiveVars.has(cleanExpr)
|
|
662
|
+
? `${cleanExpr}.value`
|
|
663
|
+
: cleanExpr;
|
|
664
|
+
return `\${${arrayAccess}.map(${iterVar} => html\`<${tag}${beforeAttrs}${afterAttrs}>${content}</${tag}>\`)}`;
|
|
665
|
+
}
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
// x-show="condition" → x-show="${condition.value}" (add .value for reactive vars)
|
|
669
|
+
result = result.replace(/x-show="([^"]+)"/g, (_, condition) => {
|
|
670
|
+
return `x-show="\${${processExpression(condition)}}"`;
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// {{variable}} → ${variable.value} (for reactive vars)
|
|
674
|
+
result = result.replace(/\{\{([^}]+)\}\}/g, (_, expr) => {
|
|
675
|
+
return `\${${processExpression(expr.trim())}}`;
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// @click="handler" → @click="${handler}" (no .value for functions)
|
|
679
|
+
result = result.replace(/@(\w+)="([^"]+)"/g, (_, event, handler) => {
|
|
680
|
+
const isArrowFunction = /^\s*\(?.*?\)?\s*=>/.test(handler);
|
|
681
|
+
const isFunctionCall = /[\w$]+\s*\(.*\)/.test(handler.trim());
|
|
682
|
+
|
|
683
|
+
if (isArrowFunction) {
|
|
684
|
+
return `@${event}="\${${handler.trim()}}"`;
|
|
685
|
+
} else if (isFunctionCall) {
|
|
686
|
+
return `@${event}="\${() => ${handler.trim()}}"`;
|
|
687
|
+
} else {
|
|
688
|
+
return `@${event}="\${${handler.trim()}}"`;
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// :prop="value" → :prop="${value.value}" (for reactive vars, but skip already processed ${...})
|
|
693
|
+
result = result.replace(/:(\w+)="(?!\$\{)([^"]+)"/g, (_, attr, value) => {
|
|
694
|
+
return `:${attr}='\${${processExpression(value)}}'`;
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// x-if="condition" → x-if="${condition}"
|
|
698
|
+
result = result.replace(/x-if="([^"]*)"/g, 'x-if="${$1}"');
|
|
699
|
+
|
|
700
|
+
// x-else-if="condition" → x-else-if="${condition}"
|
|
701
|
+
result = result.replace(/x-else-if="([^"]*)"/g, 'x-else-if="${$1}"');
|
|
702
|
+
|
|
703
|
+
return result;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Normalizes and deduplicates client-side ES module imports,
|
|
709
|
+
* ensuring required framework imports are present.
|
|
710
|
+
*
|
|
711
|
+
* @async
|
|
712
|
+
* @param {Record<string, {
|
|
713
|
+
* fileUrl: string,
|
|
714
|
+
* originalPath: string,
|
|
715
|
+
* importStatement: string
|
|
716
|
+
* }>} clientImports
|
|
717
|
+
*
|
|
718
|
+
* @param {Record<string, string[]>} [requiredImports]
|
|
719
|
+
*
|
|
720
|
+
* @returns {Promise<string[]>}
|
|
721
|
+
* Clean import statements.
|
|
722
|
+
*/
|
|
723
|
+
async function getClientCodeImports(
|
|
724
|
+
clientImports,
|
|
725
|
+
requiredImports = {
|
|
726
|
+
"/_vexjs/services/reactive.js": ["effect"],
|
|
727
|
+
"/_vexjs/services/html.js": ["html"],
|
|
728
|
+
}
|
|
729
|
+
) {
|
|
730
|
+
|
|
731
|
+
// Create a unique set of import statements to avoid duplicates
|
|
732
|
+
const cleanImportsSet = new Set(
|
|
733
|
+
Object.values(clientImports).map((importData) => importData.importStatement)
|
|
734
|
+
);
|
|
735
|
+
const cleanImports = Array.from(cleanImportsSet);
|
|
736
|
+
|
|
737
|
+
for (const [modulePath, requiredModules] of Object.entries(requiredImports)) {
|
|
738
|
+
const importIndex = cleanImports.findIndex((imp) =>
|
|
739
|
+
new RegExp(`from\\s+['"]${modulePath}['"]`).test(imp)
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
if (importIndex === -1) {
|
|
743
|
+
cleanImports.push(
|
|
744
|
+
`import { ${requiredModules.join(", ")} } from '${modulePath}';`
|
|
745
|
+
);
|
|
746
|
+
} else {
|
|
747
|
+
// if import exists, ensure it includes all required symbols
|
|
748
|
+
const existingImport = cleanImports[importIndex];
|
|
749
|
+
const importMatch = existingImport.match(/\{([^}]+)\}/);
|
|
750
|
+
|
|
751
|
+
if (importMatch) {
|
|
752
|
+
const importedModules = importMatch[1].split(",").map((s) => s.trim());
|
|
753
|
+
// Determine which required modules are missing
|
|
754
|
+
const missingModules = requiredModules.filter(
|
|
755
|
+
(s) => !importedModules.includes(s)
|
|
756
|
+
);
|
|
757
|
+
if (missingModules.length > 0) {
|
|
758
|
+
// Add missing symbols and reconstruct the import statement
|
|
759
|
+
importedModules.push(...missingModules);
|
|
760
|
+
cleanImports[importIndex] = existingImport.replace(
|
|
761
|
+
/\{[^}]+\}/,
|
|
762
|
+
`{ ${importedModules.join(", ")} }`
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
} else {
|
|
766
|
+
// If no named imports, convert to named imports
|
|
767
|
+
cleanImports[importIndex] = `import { ${requiredModules.join(
|
|
768
|
+
", "
|
|
769
|
+
)} } from '${modulePath}';`;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Return the final list of import statements
|
|
775
|
+
return cleanImports;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Generates a client-side JS module for a hydrated component.
|
|
780
|
+
*
|
|
781
|
+
* The module:
|
|
782
|
+
* - Includes required imports
|
|
783
|
+
* - Injects default props
|
|
784
|
+
* - Exports metadata
|
|
785
|
+
* - Exposes a hydration entry point
|
|
786
|
+
*
|
|
787
|
+
* @async
|
|
788
|
+
* @param {string} clientCode
|
|
789
|
+
* @param {string} template
|
|
790
|
+
* @param {object} metadata
|
|
791
|
+
* @param {Record<string, {
|
|
792
|
+
* fileUrl: string,
|
|
793
|
+
* originalPath: string,
|
|
794
|
+
* importStatement: string
|
|
795
|
+
* }>} clientImports
|
|
796
|
+
*
|
|
797
|
+
*
|
|
798
|
+
* @returns {Promise<string|null>}
|
|
799
|
+
* Generated JS module code or null if no client code exists.
|
|
800
|
+
*/
|
|
801
|
+
export async function generateClientComponentModule({
|
|
802
|
+
clientCode,
|
|
803
|
+
template,
|
|
804
|
+
metadata,
|
|
805
|
+
clientImports,
|
|
806
|
+
clientComponents,
|
|
807
|
+
}) {
|
|
808
|
+
|
|
809
|
+
// Extract default props from xprops
|
|
810
|
+
const defaults = extractVPropsDefaults(clientCode);
|
|
811
|
+
|
|
812
|
+
const clientCodeWithProps = addComputedProps(clientCode, defaults);
|
|
813
|
+
|
|
814
|
+
// Remove xprops declaration and imports from client code
|
|
815
|
+
const cleanClientCode = clientCodeWithProps
|
|
816
|
+
.replace(/const\s+props\s*=\s*xprops\s*\([\s\S]*?\)\s*;?/g, "")
|
|
817
|
+
.replace(/^\s*import\s+.*$/gm, "")
|
|
818
|
+
.trim();
|
|
819
|
+
|
|
820
|
+
// Convert template
|
|
821
|
+
const convertedTemplate = convertVueToHtmlTagged(template, clientCodeWithProps);
|
|
822
|
+
|
|
823
|
+
const { html: processedHtml } = await renderComponents({
|
|
824
|
+
html: convertedTemplate,
|
|
825
|
+
clientComponents,
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
const cleanImports = await getClientCodeImports(clientImports);
|
|
829
|
+
|
|
830
|
+
const clientComponentModule = `
|
|
831
|
+
${cleanImports.join("\n")}
|
|
832
|
+
|
|
833
|
+
export const metadata = ${JSON.stringify(metadata)}
|
|
834
|
+
|
|
835
|
+
export function hydrateClientComponent(marker, incomingProps = {}) {
|
|
836
|
+
${cleanClientCode}
|
|
837
|
+
|
|
838
|
+
let root = null;
|
|
839
|
+
function render() {
|
|
840
|
+
const node = html\`${processedHtml}\`;
|
|
841
|
+
if (!root) {
|
|
842
|
+
root = node;
|
|
843
|
+
marker.replaceWith(node);
|
|
844
|
+
} else {
|
|
845
|
+
root.replaceWith(node);
|
|
846
|
+
root = node;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
effect(() => render());
|
|
851
|
+
|
|
852
|
+
return root;
|
|
853
|
+
}
|
|
854
|
+
`;
|
|
855
|
+
|
|
856
|
+
return clientComponentModule.trim();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Determines if a page can be fully client-side rendered (CSR)
|
|
861
|
+
* @param {number | string} revalidate
|
|
862
|
+
* @param {boolean} hasServerComponents
|
|
863
|
+
* @param {boolean} hasGetData
|
|
864
|
+
* @returns
|
|
865
|
+
*/
|
|
866
|
+
function getIfPageCanCSR(revalidate, hasServerComponents, hasGetData) {
|
|
867
|
+
const revalidateSeconds = getRevalidateSeconds(revalidate ?? 0);
|
|
868
|
+
const neverRevalidate = revalidateSeconds === -1;
|
|
869
|
+
const canCSR = !hasServerComponents && (neverRevalidate || !hasGetData);
|
|
870
|
+
|
|
871
|
+
return canCSR;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Generates static HTML for a server component.
|
|
876
|
+
*
|
|
877
|
+
* Supports:
|
|
878
|
+
* - getStaticPaths
|
|
879
|
+
* - ISR pre-rendering
|
|
880
|
+
*
|
|
881
|
+
* @async
|
|
882
|
+
* @param {string} componentPath
|
|
883
|
+
* Absolute path to the HTML component.
|
|
884
|
+
*
|
|
885
|
+
* @returns {Promise<Array<{
|
|
886
|
+
* canCSR: boolean,
|
|
887
|
+
* htmls: Array<{
|
|
888
|
+
* params: Record<string, string | number>,
|
|
889
|
+
* html: string, // full html with layout
|
|
890
|
+
* pageHtml: string // only page html without layout
|
|
891
|
+
* }>
|
|
892
|
+
* }>>}
|
|
893
|
+
*/
|
|
894
|
+
async function generateServerComponentHTML(componentPath) {
|
|
895
|
+
const {
|
|
896
|
+
getStaticPaths,
|
|
897
|
+
getData,
|
|
898
|
+
getMetadata,
|
|
899
|
+
serverComponents,
|
|
900
|
+
...restProcessHtmlFile
|
|
901
|
+
} = await processHtmlFile(componentPath);
|
|
902
|
+
|
|
903
|
+
const metadata = getMetadata ? await getMetadata({ req: { params: {} }, props: {} }) : null;
|
|
904
|
+
const canCSR = getIfPageCanCSR(
|
|
905
|
+
metadata?.revalidate,
|
|
906
|
+
serverComponents.size > 0,
|
|
907
|
+
typeof getData === "function"
|
|
908
|
+
);
|
|
909
|
+
|
|
910
|
+
const paths = getStaticPaths ? await getStaticPaths() : [];
|
|
911
|
+
|
|
912
|
+
const result = {
|
|
913
|
+
htmls: [],
|
|
914
|
+
canCSR,
|
|
915
|
+
metadata,
|
|
916
|
+
getStaticPaths,
|
|
917
|
+
getData,
|
|
918
|
+
getMetadata,
|
|
919
|
+
serverComponents,
|
|
920
|
+
...restProcessHtmlFile,
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
const isPage = componentPath.includes(PAGES_DIR);
|
|
924
|
+
|
|
925
|
+
if(!isPage) {
|
|
926
|
+
return result;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// If no static paths and getData exists, render once with empty params
|
|
930
|
+
if (paths.length === 0 && !!getData) {
|
|
931
|
+
const {
|
|
932
|
+
html,
|
|
933
|
+
pageHtml,
|
|
934
|
+
metadata: pageMetadata,
|
|
935
|
+
} =
|
|
936
|
+
await renderPageWithLayout(componentPath, {}, true);
|
|
937
|
+
|
|
938
|
+
result.htmls.push({ params: {}, html, pageHtml, metadata: pageMetadata });
|
|
939
|
+
|
|
940
|
+
return result;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
for (const path of paths) {
|
|
944
|
+
const { html, pageHtml, metadata } =
|
|
945
|
+
await renderPageWithLayout(componentPath, { req: path }, true);
|
|
946
|
+
|
|
947
|
+
result.htmls.push({ params: path.params, html, pageHtml, metadata });
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
return result;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Generates a client-side hydration placeholder `<template>` for a given component.
|
|
955
|
+
*
|
|
956
|
+
* This function creates a `<template>` element containing metadata that allows
|
|
957
|
+
* the client to dynamically hydrate the component. The `data-client:component`
|
|
958
|
+
* attribute stores a unique import identifier, and `data-client:props` stores
|
|
959
|
+
* the component's props as a JSON-like string, supporting both static values
|
|
960
|
+
* and runtime interpolations (e.g., `${variable}`).
|
|
961
|
+
*
|
|
962
|
+
* @param {string} componentName - The logical name of the component.
|
|
963
|
+
* @param {string} originalPath - The original file path of the component.
|
|
964
|
+
* @param {Record<string, any>} [props={}] - An object of props to pass to the component.
|
|
965
|
+
* Values can be literals or template
|
|
966
|
+
* interpolations (`${…}`) for dynamic evaluation.
|
|
967
|
+
*
|
|
968
|
+
* @returns {Promise<string>} A promise that resolves to a string containing
|
|
969
|
+
* the `<template>` HTML for hydration.
|
|
970
|
+
*/
|
|
971
|
+
export async function processClientComponent(componentName, originalPath, props = {}) {
|
|
972
|
+
const targetId = `client-${componentName}-${Date.now()}`;
|
|
973
|
+
|
|
974
|
+
const propsEntries = Object.entries(props)
|
|
975
|
+
.map(([key, value]) => {
|
|
976
|
+
// if starts with ${ remove quotes
|
|
977
|
+
if (typeof value === "string" && value.startsWith("${")) {
|
|
978
|
+
return `${key}:${value.replace(/^\$\{|\}$/g, "")}`;
|
|
979
|
+
} else {
|
|
980
|
+
return `${key}:${JSON.stringify(value)}`;
|
|
981
|
+
}
|
|
982
|
+
})
|
|
983
|
+
.join(",");
|
|
984
|
+
|
|
985
|
+
const componentImport = generateComponentId(originalPath)
|
|
986
|
+
const html = `<template id="${targetId}" data-client:component="${componentImport}" data-client:props='${propsEntries ? `\${JSON.stringify({${propsEntries}})}` : "{}"}'></template>`;
|
|
987
|
+
|
|
988
|
+
return html;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Extract xprops object literal from client code
|
|
993
|
+
* @param {string} clientCode
|
|
994
|
+
* @returns {string | null}
|
|
995
|
+
*/
|
|
996
|
+
function extractVPropsObject(clientCode) {
|
|
997
|
+
const match = clientCode.match(/xprops\s*\(\s*(\{[\s\S]*?\})\s*\)/);
|
|
998
|
+
return match ? match[1] : null;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Extract default values from xprops definition
|
|
1003
|
+
* @param {string} clientCode
|
|
1004
|
+
* @returns {object} Object with prop names and their default values
|
|
1005
|
+
*/
|
|
1006
|
+
function extractVPropsDefaults(clientCode) {
|
|
1007
|
+
const xpropsLiteral = extractVPropsObject(clientCode);
|
|
1008
|
+
if (!xpropsLiteral) return {};
|
|
1009
|
+
|
|
1010
|
+
const xpropsDef = safeObjectEval(xpropsLiteral);
|
|
1011
|
+
const defaults = {};
|
|
1012
|
+
|
|
1013
|
+
for (const key in xpropsDef) {
|
|
1014
|
+
const def = xpropsDef[key];
|
|
1015
|
+
if (def && typeof def === "object" && "default" in def) {
|
|
1016
|
+
defaults[key] = def.default;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return defaults;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Safely evaluates an object literal without executing side effects.
|
|
1025
|
+
*
|
|
1026
|
+
* @param {string} objectLiteral
|
|
1027
|
+
* @returns {object}
|
|
1028
|
+
*/
|
|
1029
|
+
function safeObjectEval(objectLiteral) {
|
|
1030
|
+
return Function(`"use strict"; return (${objectLiteral})`)();
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Applies default props from xprops definition
|
|
1035
|
+
* @param {object} xpropsDef
|
|
1036
|
+
* @param {object} componentProps
|
|
1037
|
+
* @returns {object}
|
|
1038
|
+
*/
|
|
1039
|
+
function applyDefaultProps(xpropsDefined, componentProps) {
|
|
1040
|
+
const finalProps = {};
|
|
1041
|
+
for (const key in xpropsDefined) {
|
|
1042
|
+
const def = xpropsDefined[key];
|
|
1043
|
+
if (key in componentProps) {
|
|
1044
|
+
finalProps[key] = componentProps[key];
|
|
1045
|
+
} else if ("default" in def) {
|
|
1046
|
+
finalProps[key] = def.default;
|
|
1047
|
+
} else {
|
|
1048
|
+
finalProps[key] = undefined;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
return finalProps;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Compute props used in the client code
|
|
1057
|
+
* @param {string} clientCode
|
|
1058
|
+
* @param {object} componentProps
|
|
1059
|
+
*/
|
|
1060
|
+
function computeProps(clientCode, componentProps) {
|
|
1061
|
+
const xpropsLiteral = extractVPropsObject(clientCode);
|
|
1062
|
+
|
|
1063
|
+
if (!xpropsLiteral) return componentProps;
|
|
1064
|
+
|
|
1065
|
+
const xpropsDefined = safeObjectEval(xpropsLiteral);
|
|
1066
|
+
|
|
1067
|
+
return applyDefaultProps(xpropsDefined, componentProps);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Adds computed props to client code if are defined.
|
|
1072
|
+
* Replaces xprops(...) by const props = { ... };
|
|
1073
|
+
* @param {string} clientCode
|
|
1074
|
+
* @param {object} componentProps
|
|
1075
|
+
*
|
|
1076
|
+
* @returns {string}
|
|
1077
|
+
*/
|
|
1078
|
+
function addComputedProps(clientCode, componentProps) {
|
|
1079
|
+
const xpropsRegex = /const\s+props\s*=\s*xprops\s*\([\s\S]*?\)\s*;?/;
|
|
1080
|
+
if (!xpropsRegex.test(clientCode)) return clientCode;
|
|
1081
|
+
|
|
1082
|
+
const computedProps = computeProps(clientCode, componentProps);
|
|
1083
|
+
|
|
1084
|
+
return clientCode.replace(
|
|
1085
|
+
xpropsRegex,
|
|
1086
|
+
`const props = { ...${JSON.stringify(computedProps)}, ...incomingProps };`
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
async function getMetadataAndStaticPaths(getMetadata, getStaticPaths) {
|
|
1091
|
+
const promises = [];
|
|
1092
|
+
|
|
1093
|
+
if (getMetadata) {
|
|
1094
|
+
promises.push(getMetadata({ req: { params: {} }, props: {} }));
|
|
1095
|
+
}
|
|
1096
|
+
if (getStaticPaths) {
|
|
1097
|
+
promises.push(getStaticPaths());
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const [metadata, paths] = await Promise.all(promises);
|
|
1101
|
+
|
|
1102
|
+
return {
|
|
1103
|
+
metadata: metadata || DEFAULT_METADATA,
|
|
1104
|
+
paths: paths || [],
|
|
1105
|
+
};
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Replaces route parameters with the provided values.
|
|
1110
|
+
*
|
|
1111
|
+
* This function takes a route that may contain multiple parameters in the `:param` format
|
|
1112
|
+
* and replaces them with the corresponding values from the `params` object.
|
|
1113
|
+
*
|
|
1114
|
+
* @example
|
|
1115
|
+
* // Route with multiple parameters
|
|
1116
|
+
* fillRoute("/user/:userId/post/:postId", { userId: 123, postId: 456 });
|
|
1117
|
+
* // Returns: "/user/123/post/456"
|
|
1118
|
+
*
|
|
1119
|
+
* @param {string} route - The route containing `:param` placeholders.
|
|
1120
|
+
* @param {Record<string, string|number>} params - An object with values to replace in the route.
|
|
1121
|
+
* @throws {Error} Throws an error if any parameter in the route is missing in `params`.
|
|
1122
|
+
* @returns {string} The final route with all parameters replaced.
|
|
1123
|
+
*/
|
|
1124
|
+
function fillRoute(route, params) {
|
|
1125
|
+
return route.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => {
|
|
1126
|
+
if (params[key] === undefined) {
|
|
1127
|
+
throw new Error(`Missing parameter "${key}"`);
|
|
1128
|
+
}
|
|
1129
|
+
return params[key];
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
*
|
|
1134
|
+
* Generates js module and save it in public directory.
|
|
1135
|
+
*
|
|
1136
|
+
* @param {{
|
|
1137
|
+
* metadata: object,
|
|
1138
|
+
* clientCode: string,
|
|
1139
|
+
* template: string,
|
|
1140
|
+
* clientImports: Record<string, {
|
|
1141
|
+
* fileUrl: string,
|
|
1142
|
+
* originalPath: string,
|
|
1143
|
+
* importStatement: string
|
|
1144
|
+
* }>,
|
|
1145
|
+
* clientComponents: Record<string, any>,
|
|
1146
|
+
* componentName: string,
|
|
1147
|
+
* }}
|
|
1148
|
+
*
|
|
1149
|
+
* @returns {Promise<void>}
|
|
1150
|
+
*/
|
|
1151
|
+
async function saveClientComponent({
|
|
1152
|
+
metadata,
|
|
1153
|
+
clientCode,
|
|
1154
|
+
template,
|
|
1155
|
+
clientImports,
|
|
1156
|
+
clientComponents,
|
|
1157
|
+
componentName,
|
|
1158
|
+
}) {
|
|
1159
|
+
const jsModuleCode = await generateClientComponentModule({
|
|
1160
|
+
metadata,
|
|
1161
|
+
clientCode,
|
|
1162
|
+
template,
|
|
1163
|
+
clientImports,
|
|
1164
|
+
clientComponents,
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
if (jsModuleCode) {
|
|
1168
|
+
await saveClientComponentModule(componentName, jsModuleCode)
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**x
|
|
1173
|
+
* Generates and persists either:
|
|
1174
|
+
* - Server-rendered HTML (SSG / ISR) for a component, or
|
|
1175
|
+
* - A client-side hydration module when SSR is not applicable.
|
|
1176
|
+
*
|
|
1177
|
+
* This function is executed at build-time and is responsible for:
|
|
1178
|
+
* - Executing `getStaticPaths` when present
|
|
1179
|
+
* - Rendering server components and caching their HTML output
|
|
1180
|
+
* - Generating client component JS modules when needed
|
|
1181
|
+
*
|
|
1182
|
+
* Server-rendered components take precedence over client components.
|
|
1183
|
+
*
|
|
1184
|
+
* @async
|
|
1185
|
+
* @param {string} filePath
|
|
1186
|
+
* Absolute path to the component or page HTML file.
|
|
1187
|
+
*
|
|
1188
|
+
* @returns {Promise<"Server component generated" | "Client component generated">}
|
|
1189
|
+
* Indicates which type of artifact was generated.
|
|
1190
|
+
*/
|
|
1191
|
+
/**
|
|
1192
|
+
* Tracks which component files have already been processed during the current
|
|
1193
|
+
* build run
|
|
1194
|
+
*
|
|
1195
|
+
* Shared components (e.g. `UserCard`) are imported by multiple pages, so
|
|
1196
|
+
* `generateComponentAndFillCache` could be invoked for the same file path
|
|
1197
|
+
* dozens of times — once per page that imports it. Without this guard every
|
|
1198
|
+
* invocation would re-run `generateServerComponentHTML`, write the same files
|
|
1199
|
+
* to disk multiple times, and schedule redundant recursive calls for that
|
|
1200
|
+
* component's own dependencies.
|
|
1201
|
+
*
|
|
1202
|
+
* The Set is cleared at the start of `generateComponentsAndFillCache` so that
|
|
1203
|
+
* a second build run (e.g. hot-reload) starts with a clean slate.
|
|
1204
|
+
*/
|
|
1205
|
+
const processedComponentsInBuild = new Set();
|
|
1206
|
+
|
|
1207
|
+
async function generateComponentAndFillCache(filePath) {
|
|
1208
|
+
if (processedComponentsInBuild.has(filePath)) return 'Already processed';
|
|
1209
|
+
processedComponentsInBuild.add(filePath);
|
|
1210
|
+
|
|
1211
|
+
const urlPath = getRoutePath(filePath);
|
|
1212
|
+
|
|
1213
|
+
const {
|
|
1214
|
+
template,
|
|
1215
|
+
htmls: serverHtmls,
|
|
1216
|
+
canCSR,
|
|
1217
|
+
clientImports,
|
|
1218
|
+
metadata,
|
|
1219
|
+
clientCode,
|
|
1220
|
+
clientComponents,
|
|
1221
|
+
serverComponents,
|
|
1222
|
+
} = await generateServerComponentHTML(filePath);
|
|
1223
|
+
|
|
1224
|
+
const saveServerHtmlsPromises = [];
|
|
1225
|
+
const saveClientHtmlPromises = [];
|
|
1226
|
+
const saveComponentsPromises = [];
|
|
1227
|
+
|
|
1228
|
+
if (serverHtmls.length) {
|
|
1229
|
+
for (const { params, html, pageHtml, metadata: pageMetadata } of serverHtmls) {
|
|
1230
|
+
const cacheKey = fillRoute(urlPath, params);
|
|
1231
|
+
saveServerHtmlsPromises.push(saveComponentHtmlDisk({ componentPath: cacheKey, html }));
|
|
1232
|
+
|
|
1233
|
+
if (canCSR) {
|
|
1234
|
+
saveServerHtmlsPromises.push(saveClientComponent({
|
|
1235
|
+
metadata: pageMetadata,
|
|
1236
|
+
clientCode,
|
|
1237
|
+
template: pageHtml,
|
|
1238
|
+
clientImports,
|
|
1239
|
+
clientComponents,
|
|
1240
|
+
componentName: generateComponentId(cacheKey),
|
|
1241
|
+
}))
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (canCSR && serverHtmls.length === 0) {
|
|
1247
|
+
saveClientHtmlPromises.push(saveClientComponent({
|
|
1248
|
+
metadata,
|
|
1249
|
+
clientCode,
|
|
1250
|
+
template,
|
|
1251
|
+
clientImports,
|
|
1252
|
+
clientComponents,
|
|
1253
|
+
componentName: generateComponentId(urlPath),
|
|
1254
|
+
}))
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
if(serverComponents.size > 0) {
|
|
1258
|
+
const serverComponentPaths = Array.from(serverComponents.values()).map(({ path }) => path);
|
|
1259
|
+
saveComponentsPromises.push(...serverComponentPaths.map(generateComponentAndFillCache));
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if(clientComponents.size > 0) {
|
|
1263
|
+
const clientComponentPaths = Array.from(clientComponents.values()).map(({ path }) => path);
|
|
1264
|
+
saveComponentsPromises.push(...clientComponentPaths.map(generateComponentAndFillCache));
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
await Promise.all([...saveServerHtmlsPromises, ...saveClientHtmlPromises, ...saveComponentsPromises]);
|
|
1268
|
+
|
|
1269
|
+
return 'Component generated';
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Generates all application components and fills the server HTML cache.
|
|
1274
|
+
*
|
|
1275
|
+
* This function:
|
|
1276
|
+
* - Scans all pages and reusable components
|
|
1277
|
+
* - Generates server-rendered HTML when possible
|
|
1278
|
+
* - Generates client-side component modules when required
|
|
1279
|
+
* - Persists outputs to disk for runtime usage
|
|
1280
|
+
*
|
|
1281
|
+
* Intended to be executed at build-time or during pre-render steps.
|
|
1282
|
+
*
|
|
1283
|
+
* @async
|
|
1284
|
+
* @returns {Promise<string>}
|
|
1285
|
+
* Build completion message.
|
|
1286
|
+
*/
|
|
1287
|
+
export async function generateComponentsAndFillCache() {
|
|
1288
|
+
// Reset the deduplication set so repeated build runs start clean
|
|
1289
|
+
processedComponentsInBuild.clear();
|
|
1290
|
+
|
|
1291
|
+
const pagesFiles = await getPageFiles({ layouts: true });
|
|
1292
|
+
|
|
1293
|
+
const generateComponentsPromises = pagesFiles.map((file) =>
|
|
1294
|
+
generateComponentAndFillCache(file.fullpath)
|
|
1295
|
+
);
|
|
1296
|
+
|
|
1297
|
+
await Promise.all(generateComponentsPromises);
|
|
1298
|
+
|
|
1299
|
+
return 'Components generation completed';
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* Extracts routing metadata from a page file and generates
|
|
1304
|
+
* server-side and client-side route definitions.
|
|
1305
|
+
*
|
|
1306
|
+
* Determines whether the page:
|
|
1307
|
+
* - Requires SSR
|
|
1308
|
+
* - Can be statically rendered
|
|
1309
|
+
* - Needs a client-side hydration component
|
|
1310
|
+
*
|
|
1311
|
+
* This function does NOT write files; it only prepares route descriptors.
|
|
1312
|
+
*
|
|
1313
|
+
* @async
|
|
1314
|
+
* @param {{
|
|
1315
|
+
* fullpath: string,
|
|
1316
|
+
* path: string
|
|
1317
|
+
* }} file
|
|
1318
|
+
* Page file descriptor.
|
|
1319
|
+
*
|
|
1320
|
+
* @returns {Promise<{
|
|
1321
|
+
* serverRoutes: Array<{
|
|
1322
|
+
* path: string,
|
|
1323
|
+
* serverPath: string,
|
|
1324
|
+
* isNotFound: boolean,
|
|
1325
|
+
* meta: {
|
|
1326
|
+
* ssr: boolean,
|
|
1327
|
+
* requiresAuth: boolean,
|
|
1328
|
+
* revalidate: number | string
|
|
1329
|
+
* }
|
|
1330
|
+
* }>,
|
|
1331
|
+
* clientRoutes: Array<{
|
|
1332
|
+
* path: string,
|
|
1333
|
+
* component?: Function,
|
|
1334
|
+
* meta: {
|
|
1335
|
+
* ssr: boolean,
|
|
1336
|
+
* requiresAuth: boolean,
|
|
1337
|
+
* }
|
|
1338
|
+
* }>,
|
|
1339
|
+
* }>}
|
|
1340
|
+
* Route configuration data used to generate routing files.
|
|
1341
|
+
*/
|
|
1342
|
+
async function getRouteFileData(file) {
|
|
1343
|
+
const data = {
|
|
1344
|
+
serverRoutes: [],
|
|
1345
|
+
clientRoutes: [],
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
const [ processedFileData, layoutPaths ]= await Promise.all([
|
|
1349
|
+
processHtmlFile(file.fullpath),
|
|
1350
|
+
getLayoutPaths(file.fullpath),
|
|
1351
|
+
]);
|
|
1352
|
+
|
|
1353
|
+
const { getData, getMetadata, getStaticPaths, serverComponents } = processedFileData;
|
|
1354
|
+
|
|
1355
|
+
const filePath = getOriginalRoutePath(file.fullpath);
|
|
1356
|
+
const urlPath = getRoutePath(file.fullpath);
|
|
1357
|
+
|
|
1358
|
+
const { metadata, paths } = await getMetadataAndStaticPaths(getMetadata, getStaticPaths);
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
const canCSR = getIfPageCanCSR(
|
|
1362
|
+
metadata?.revalidate,
|
|
1363
|
+
serverComponents.size > 0,
|
|
1364
|
+
typeof getData === "function"
|
|
1365
|
+
);
|
|
1366
|
+
|
|
1367
|
+
// Push a plain object — no serialisation needed.
|
|
1368
|
+
// Previously this was a hand-crafted JS string that generateRoutes() had to
|
|
1369
|
+
// eval() back into an object. Using a plain object lets saveServerRoutesFile
|
|
1370
|
+
// serialise with JSON.stringify and lets generateRoutes() return the array directly.
|
|
1371
|
+
data.serverRoutes.push({
|
|
1372
|
+
path: filePath,
|
|
1373
|
+
serverPath: urlPath,
|
|
1374
|
+
isNotFound: file.path.includes("/not-found/"),
|
|
1375
|
+
meta: {
|
|
1376
|
+
ssr: !canCSR,
|
|
1377
|
+
requiresAuth: false,
|
|
1378
|
+
revalidate: metadata?.revalidate ?? 0,
|
|
1379
|
+
},
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
if (!canCSR) {
|
|
1384
|
+
data.clientRoutes.push(`{
|
|
1385
|
+
path: "${urlPath}",
|
|
1386
|
+
meta: {
|
|
1387
|
+
ssr: true,
|
|
1388
|
+
requiresAuth: false,
|
|
1389
|
+
},
|
|
1390
|
+
}`);
|
|
1391
|
+
|
|
1392
|
+
return data;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const componentsBasePath = "/_vexjs/_components";
|
|
1396
|
+
|
|
1397
|
+
const layoutsImportData = layoutPaths.map((layoutPath) => {
|
|
1398
|
+
const urlPath = getRoutePath(layoutPath);
|
|
1399
|
+
const layoutComponentName = generateComponentId(urlPath);
|
|
1400
|
+
return ({
|
|
1401
|
+
name: layoutComponentName,
|
|
1402
|
+
importPath: `${componentsBasePath}/${layoutComponentName}.js`,
|
|
1403
|
+
})
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
// if is static page with paths, create route for each path
|
|
1407
|
+
if (paths.length > 0) {
|
|
1408
|
+
for (const pathObj of paths) {
|
|
1409
|
+
const filledPath = fillRoute(urlPath, pathObj.params);
|
|
1410
|
+
const componentName = generateComponentId(filledPath);
|
|
1411
|
+
const importPath = `${componentsBasePath}/${componentName}.js`;
|
|
1412
|
+
|
|
1413
|
+
data.clientRoutes.push(`{
|
|
1414
|
+
path: "${filledPath}",
|
|
1415
|
+
component: async () => {
|
|
1416
|
+
const mod = await loadRouteComponent("${filledPath}", () => import("${importPath}"));
|
|
1417
|
+
|
|
1418
|
+
return { hydrateClientComponent: mod.hydrateClientComponent, metadata: mod.metadata };
|
|
1419
|
+
},
|
|
1420
|
+
layouts: ${JSON.stringify(layoutsImportData)},
|
|
1421
|
+
meta: {
|
|
1422
|
+
ssr: false,
|
|
1423
|
+
requiresAuth: false,
|
|
1424
|
+
},
|
|
1425
|
+
}`);
|
|
1426
|
+
}
|
|
1427
|
+
} else {
|
|
1428
|
+
const componentName = generateComponentId(urlPath);
|
|
1429
|
+
const importPath = `${componentsBasePath}/${componentName}.js`;
|
|
1430
|
+
|
|
1431
|
+
data.clientRoutes.push(`{
|
|
1432
|
+
path: "${urlPath}",
|
|
1433
|
+
component: async () => {
|
|
1434
|
+
const mod = await loadRouteComponent("${urlPath}", () => import("${importPath}"));
|
|
1435
|
+
|
|
1436
|
+
return { hydrateClientComponent: mod.hydrateClientComponent, metadata: mod.metadata };
|
|
1437
|
+
},
|
|
1438
|
+
layouts: ${JSON.stringify(layoutsImportData)},
|
|
1439
|
+
meta: {
|
|
1440
|
+
ssr: false,
|
|
1441
|
+
requiresAuth: false,
|
|
1442
|
+
},
|
|
1443
|
+
}`);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
return data;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Generates server-side and client-side routing tables by scanning page files.
|
|
1451
|
+
*
|
|
1452
|
+
* This function:
|
|
1453
|
+
* - Analyzes each page to determine SSR or client rendering
|
|
1454
|
+
* - Produces server route definitions for request handling
|
|
1455
|
+
* - Produces client route definitions for navigation and hydration
|
|
1456
|
+
* - Writes routing artifacts to disk
|
|
1457
|
+
*
|
|
1458
|
+
* Output files:
|
|
1459
|
+
* - `server/_routes.js`
|
|
1460
|
+
* - `public/_routes.js`
|
|
1461
|
+
*
|
|
1462
|
+
* @async
|
|
1463
|
+
* @returns {Promise<{
|
|
1464
|
+
* serverRoutes: Array<{
|
|
1465
|
+
* path: string,
|
|
1466
|
+
* serverPath: string,
|
|
1467
|
+
* isNotFound: boolean,
|
|
1468
|
+
* meta: {
|
|
1469
|
+
* ssr: boolean,
|
|
1470
|
+
* requiresAuth: boolean,
|
|
1471
|
+
* revalidate: number | string
|
|
1472
|
+
* }
|
|
1473
|
+
* }>
|
|
1474
|
+
* }>}
|
|
1475
|
+
* Parsed server routes for runtime usage.
|
|
1476
|
+
*/
|
|
1477
|
+
export async function generateRoutes() {
|
|
1478
|
+
const pageFiles = await getPageFiles()
|
|
1479
|
+
|
|
1480
|
+
const serverRoutes = [];
|
|
1481
|
+
const clientRoutes = [];
|
|
1482
|
+
|
|
1483
|
+
const routeFilesPromises = pageFiles.map((pageFile) => getRouteFileData(pageFile))
|
|
1484
|
+
const routeFiles = await Promise.all(routeFilesPromises);
|
|
1485
|
+
|
|
1486
|
+
for (const routeFile of routeFiles) {
|
|
1487
|
+
const {
|
|
1488
|
+
serverRoutes: serverRoutesFile,
|
|
1489
|
+
clientRoutes: clientRoutesFile,
|
|
1490
|
+
} = routeFile;
|
|
1491
|
+
|
|
1492
|
+
if (serverRoutesFile?.length) {
|
|
1493
|
+
serverRoutes.push(...serverRoutesFile);
|
|
1494
|
+
}
|
|
1495
|
+
if (clientRoutesFile?.length) {
|
|
1496
|
+
clientRoutes.push(...clientRoutesFile);
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
await Promise.all([
|
|
1501
|
+
saveClientRoutesFile(clientRoutes),
|
|
1502
|
+
saveServerRoutesFile(serverRoutes),
|
|
1503
|
+
]);
|
|
1504
|
+
|
|
1505
|
+
// serverRoutes is already an array of plain objects — no eval() needed (BUILD-03 fix)
|
|
1506
|
+
return { serverRoutes };
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* Single-pass build entry point.
|
|
1511
|
+
*
|
|
1512
|
+
* Previously `prebuild.js` and `index.js` called `generateComponentsAndFillCache`
|
|
1513
|
+
* and `generateRoutes` as two independent steps. Both steps processed the same
|
|
1514
|
+
* page files: the first populated `processHtmlFileCache`, the second hit the
|
|
1515
|
+
* cache, but they were still two separate async chains.
|
|
1516
|
+
*
|
|
1517
|
+
* `build()` runs both steps sequentially and returns the server routes so
|
|
1518
|
+
* callers need only one import and one await.
|
|
1519
|
+
*
|
|
1520
|
+
* @async
|
|
1521
|
+
* @returns {Promise<{ serverRoutes: Array<Object> }>}
|
|
1522
|
+
*/
|
|
1523
|
+
export async function build() {
|
|
1524
|
+
await generateComponentsAndFillCache();
|
|
1525
|
+
return generateRoutes();
|
|
1526
|
+
}
|