@cfdez11/vex 0.1.0

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