@cfdez11/vex 0.8.2 → 0.9.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.
Files changed (71) hide show
  1. package/dist/bin/vex.js +3 -0
  2. package/dist/client/services/cache.js +1 -0
  3. package/dist/client/services/hmr-client.js +1 -0
  4. package/dist/client/services/html.js +1 -0
  5. package/dist/client/services/hydrate-client-components.js +1 -0
  6. package/dist/client/services/hydrate.js +1 -0
  7. package/dist/client/services/index.js +1 -0
  8. package/dist/client/services/navigation/create-layouts.js +1 -0
  9. package/dist/client/services/navigation/create-navigation.js +1 -0
  10. package/dist/client/services/navigation/index.js +1 -0
  11. package/dist/client/services/navigation/link-interceptor.js +1 -0
  12. package/dist/client/services/navigation/metadata.js +1 -0
  13. package/dist/client/services/navigation/navigate.js +1 -0
  14. package/dist/client/services/navigation/prefetch.js +1 -0
  15. package/dist/client/services/navigation/render-page.js +1 -0
  16. package/dist/client/services/navigation/render-ssr.js +1 -0
  17. package/dist/client/services/navigation/router.js +1 -0
  18. package/dist/client/services/navigation/use-query-params.js +1 -0
  19. package/dist/client/services/navigation/use-route-params.js +1 -0
  20. package/dist/client/services/navigation.js +1 -0
  21. package/dist/client/services/reactive.js +1 -0
  22. package/dist/server/build-static.js +6 -0
  23. package/dist/server/index.js +4 -0
  24. package/dist/server/prebuild.js +1 -0
  25. package/dist/server/utils/cache.js +1 -0
  26. package/dist/server/utils/component-processor.js +68 -0
  27. package/dist/server/utils/data-cache.js +1 -0
  28. package/dist/server/utils/esbuild-plugin.js +1 -0
  29. package/dist/server/utils/files.js +28 -0
  30. package/dist/server/utils/hmr.js +1 -0
  31. package/dist/server/utils/router.js +11 -0
  32. package/dist/server/utils/streaming.js +1 -0
  33. package/dist/server/utils/template.js +1 -0
  34. package/package.json +8 -7
  35. package/bin/vex.js +0 -69
  36. package/client/favicon.ico +0 -0
  37. package/client/services/cache.js +0 -55
  38. package/client/services/hmr-client.js +0 -22
  39. package/client/services/html.js +0 -377
  40. package/client/services/hydrate-client-components.js +0 -97
  41. package/client/services/hydrate.js +0 -25
  42. package/client/services/index.js +0 -9
  43. package/client/services/navigation/create-layouts.js +0 -172
  44. package/client/services/navigation/create-navigation.js +0 -103
  45. package/client/services/navigation/index.js +0 -8
  46. package/client/services/navigation/link-interceptor.js +0 -39
  47. package/client/services/navigation/metadata.js +0 -23
  48. package/client/services/navigation/navigate.js +0 -64
  49. package/client/services/navigation/prefetch.js +0 -43
  50. package/client/services/navigation/render-page.js +0 -45
  51. package/client/services/navigation/render-ssr.js +0 -157
  52. package/client/services/navigation/router.js +0 -48
  53. package/client/services/navigation/use-query-params.js +0 -225
  54. package/client/services/navigation/use-route-params.js +0 -76
  55. package/client/services/navigation.js +0 -6
  56. package/client/services/reactive.js +0 -247
  57. package/server/build-static.js +0 -138
  58. package/server/index.js +0 -135
  59. package/server/prebuild.js +0 -13
  60. package/server/utils/cache.js +0 -89
  61. package/server/utils/component-processor.js +0 -1631
  62. package/server/utils/data-cache.js +0 -62
  63. package/server/utils/delay.js +0 -1
  64. package/server/utils/esbuild-plugin.js +0 -110
  65. package/server/utils/files.js +0 -845
  66. package/server/utils/hmr.js +0 -21
  67. package/server/utils/router.js +0 -375
  68. package/server/utils/streaming.js +0 -324
  69. package/server/utils/template.js +0 -274
  70. /package/{client → dist/client}/app.webmanifest +0 -0
  71. /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
- }