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