@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,723 @@
1
+ import fs from "fs/promises";
2
+ import { watch, existsSync, statSync } from "fs";
3
+ import path from "path";
4
+ import crypto from "crypto";
5
+ import { fileURLToPath, pathToFileURL } from "url";
6
+
7
+ /**
8
+ * Absolute path of the current file.
9
+ * Used to resolve project root in ESM context.
10
+ * @private
11
+ */
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ /**
14
+ * Directory name of the current module.
15
+ * @private
16
+ */
17
+ const __dirname = path.dirname(__filename);
18
+ // Framework's own directory (packages/vexjs/ — 3 levels up from server/utils/)
19
+ const FRAMEWORK_DIR = path.resolve(__dirname, "..", "..");
20
+ // User's project root (where they run the server)
21
+ const PROJECT_ROOT = process.cwd();
22
+ const ROOT_DIR = PROJECT_ROOT;
23
+
24
+ export const PAGES_DIR = path.resolve(PROJECT_ROOT, "pages");
25
+ export const SERVER_APP_DIR = path.join(FRAMEWORK_DIR, "server");
26
+ export const CLIENT_DIR = path.join(FRAMEWORK_DIR, "client");
27
+ export const CLIENT_SERVICES_DIR = path.join(CLIENT_DIR, "services");
28
+ // Generated files go to PROJECT_ROOT/.vexjs/
29
+ const GENERATED_DIR = path.join(PROJECT_ROOT, ".vexjs");
30
+ const CACHE_DIR = path.join(GENERATED_DIR, "_cache");
31
+ export const CLIENT_COMPONENTS_DIR = path.join(GENERATED_DIR, "_components");
32
+ const SERVER_UTILS_DIR = path.join(GENERATED_DIR);
33
+ const ROOT_HTML_USER = path.join(PROJECT_ROOT, "root.html");
34
+ const ROOT_HTML_DEFAULT = path.join(FRAMEWORK_DIR, "server", "root.html");
35
+ export const ROOT_HTML_DIR = ROOT_HTML_USER;
36
+
37
+ /**
38
+ * Ensures all required application directories exist.
39
+ *
40
+ * This function initializes:
41
+ * - Client application directory
42
+ * - Server application directory
43
+ * - Server-side HTML cache directory
44
+ *
45
+ * Directories are created recursively and safely if they already exist.
46
+ *
47
+ * @async
48
+ * @private
49
+ * @returns {Promise<boolean|undefined>}
50
+ * Resolves `true` when directories are created successfully.
51
+ */
52
+ export async function initializeDirectories() {
53
+ try {
54
+ await Promise.all([
55
+ fs.mkdir(GENERATED_DIR, { recursive: true }),
56
+ fs.mkdir(CACHE_DIR, { recursive: true }),
57
+ fs.mkdir(CLIENT_COMPONENTS_DIR, { recursive: true }),
58
+ fs.mkdir(path.join(GENERATED_DIR, "services"), { recursive: true }),
59
+ ]);
60
+
61
+ return true;
62
+ } catch (err) {
63
+ console.error("Failed to create cache directory:", err);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Adjusts a client module path and its corresponding import statement.
69
+ *
70
+ * This function modifies the module path if it resides within the server directory,
71
+ * converting it to the corresponding path in the client services directory.
72
+ * If the module path is already within the client services directory, it returns it unchanged.
73
+ *
74
+ * @param {string} modulePath - The original module path to adjust.
75
+ * @param {string} importStatement - The import statement string that references the module.
76
+ * @returns {{
77
+ * path: string,
78
+ * importStatement: string
79
+ * }}
80
+ * An object containing:
81
+ * - `path`: The adjusted module path suitable for client usage.
82
+ * - `importStatement`: The updated import statement reflecting the adjusted path.
83
+ *
84
+ * @example
85
+ * const result = adjustClientModulePath(
86
+ * '.app/reactive.js',
87
+ * "import userController from '.app/reactive.js';"
88
+ * );
89
+ * console.log(result.path); // '/.app/client/services/reactive.js'
90
+ * console.log(result.importStatement);
91
+ * // "import userController from '/.app/client/services/reactive.js';"
92
+ */
93
+ export function adjustClientModulePath(modulePath, importStatement) {
94
+ if (modulePath.startsWith("/_vexjs/")) {
95
+ return { path: modulePath, importStatement };
96
+ }
97
+
98
+ let relative = modulePath.replace(/^vex\//, "").replace(/^\.app\//, "");
99
+ let adjustedPath = `/_vexjs/services/${relative}`;
100
+
101
+ // Auto-resolve directory → index.js
102
+ const fsPath = path.join(CLIENT_SERVICES_DIR, relative);
103
+ if (existsSync(fsPath) && statSync(fsPath).isDirectory()) {
104
+ adjustedPath += "/index.js";
105
+ }
106
+
107
+ const adjustedImportStatement = importStatement.replace(modulePath, adjustedPath);
108
+ return { path: adjustedPath, importStatement: adjustedImportStatement };
109
+ }
110
+
111
+ /**
112
+ * Gets relative path from one directory to another
113
+ * @param {string} from
114
+ * @param {string} to
115
+ * @returns {string}
116
+ */
117
+ export function getRelativePath(from, to) {
118
+ return path.relative(from, to);
119
+ }
120
+
121
+ /**
122
+ * Gets directory name from a file path
123
+ * @param {string} filePath
124
+ * @returns {string}
125
+ */
126
+ function getDirectoryName(filePath) {
127
+ return path.dirname(filePath);
128
+ }
129
+
130
+ /**
131
+ * Retrieves layout file paths for a given page.
132
+ *
133
+ * Layouts are determined by traversing up the directory tree
134
+ * from the page's location to the pages root, collecting any
135
+ * `layout.html` files found along the way.
136
+ *
137
+ * @async
138
+ * @param {string} pagePath
139
+ * @returns {Promise<string[]>}
140
+ */
141
+ /**
142
+ *
143
+ * `getLayoutPaths` calls `fs.access` on every ancestor directory of `pagePath`
144
+ * to discover which `layout.html` files exist. The result is deterministic for
145
+ * a given page path — the filesystem structure does not change between requests.
146
+ *
147
+ * Key: absolute page file path
148
+ * Value: array of absolute layout.html paths (innermost → outermost)
149
+ *
150
+ * In production entries live forever (deploy is immutable).
151
+ * In dev the watcher below clears the whole cache whenever any layout.html is
152
+ * created, modified, or deleted, so the next request re-discovers the correct set.
153
+ */
154
+ const layoutPathsCache = new Map();
155
+
156
+ if (process.env.NODE_ENV !== "production") {
157
+ // Watch the entire pages tree. When a layout.html changes, the set of layouts
158
+ // that exist may have changed — evict all cached entries to be safe.
159
+ watch(PAGES_DIR, { recursive: true }, (_, filename) => {
160
+ if (filename === "layout.vex" || filename?.endsWith(`${path.sep}layout.vex`)) {
161
+ layoutPathsCache.clear();
162
+ }
163
+ });
164
+ }
165
+
166
+ async function _getLayoutPaths(pagePath) {
167
+ const layouts = [];
168
+ const relativePath = getRelativePath(PAGES_DIR, pagePath);
169
+ const pathSegments = getDirectoryName(relativePath).split(path.sep);
170
+
171
+ // Always start with base layout
172
+ const baseLayout = path.join(PAGES_DIR, 'layout.vex');
173
+ if (await fileExists(baseLayout)) {
174
+ layouts.push(baseLayout);
175
+ }
176
+
177
+ // Add nested layouts based on directory structure
178
+ let currentPath = PAGES_DIR;
179
+ for (const segment of pathSegments) {
180
+ if (segment === '.' || segment === '..') continue;
181
+
182
+ currentPath = path.join(currentPath, segment);
183
+ const layoutPath = path.join(currentPath, 'layout.vex');
184
+
185
+ if (await fileExists(layoutPath)) {
186
+ layouts.push(layoutPath);
187
+ }
188
+ }
189
+
190
+ return layouts;
191
+ }
192
+
193
+ /**
194
+ * Cached wrapper around `_getLayoutPaths`.
195
+ *
196
+ * Returns the cached layout list on repeated calls for the same page, avoiding
197
+ * repeated `fs.access` probes on every SSR request.
198
+ *
199
+ * @param {string} pagePath - Absolute path to the page file.
200
+ * @returns {Promise<string[]>}
201
+ */
202
+ export async function getLayoutPaths(pagePath) {
203
+ if (layoutPathsCache.has(pagePath)) return layoutPathsCache.get(pagePath);
204
+ const result = await _getLayoutPaths(pagePath);
205
+ layoutPathsCache.set(pagePath, result);
206
+ return result;
207
+ }
208
+
209
+ /**
210
+ * Normalizes file content before persisting it to disk.
211
+ *
212
+ * - Converts Windows line endings to Unix
213
+ * - Collapses multiple whitespace characters
214
+ * - Trims leading and trailing whitespace
215
+ *
216
+ * Used mainly for generated artifacts (HTML, JS).
217
+ *
218
+ * @param {string} content
219
+ * Raw file content.
220
+ *
221
+ * @returns {string}
222
+ * Normalized content.
223
+ */
224
+ function formatFileContent(content) {
225
+ return content
226
+ .trim();
227
+ }
228
+
229
+ /**
230
+ * Writes formatted content to disk.
231
+ *
232
+ * Automatically normalizes content before writing.
233
+ *
234
+ * @async
235
+ * @param {string} filePath
236
+ * Absolute path to the output file.
237
+ *
238
+ * @param {string} content
239
+ * File content to write.
240
+ *
241
+ * @returns {Promise<void>}
242
+ */
243
+ export async function writeFile(filePath, content) {
244
+ const formattedContent = formatFileContent(content);
245
+ return fs.writeFile(filePath, formattedContent, 'utf-8');
246
+ }
247
+
248
+ /**
249
+ * Reads a UTF-8 encoded file from disk.
250
+ *
251
+ * @async
252
+ * @param {string} filePath
253
+ * Absolute path to the file.
254
+ *
255
+ * @returns {Promise<string>}
256
+ * File contents.
257
+ */
258
+ export function readFile(filePath) {
259
+ return fs.readFile(filePath, 'utf-8');
260
+ }
261
+
262
+
263
+ /**
264
+ * Checks whether a file exists and is accessible.
265
+ *
266
+ * @async
267
+ * @param {string} filePath
268
+ * Absolute path to the file.
269
+ *
270
+ * @returns {Promise<boolean>}
271
+ * True if the file exists, false otherwise.
272
+ */
273
+ export async function fileExists(filePath) {
274
+ try {
275
+ await fs.access(filePath);
276
+ return true;
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Generates a stable, filesystem-safe component identifier
284
+ * from a relative component path.
285
+ *
286
+ * This name is used to:
287
+ * - Create client-side JS module filenames
288
+ * - Reference client components during hydration
289
+ *
290
+ * @param {string} componentPath
291
+ * Relative path to the component from project root.
292
+ *
293
+ * @returns {string}
294
+ * Autogenerated component name prefixed with `_`.
295
+ */
296
+ function getAutogeneratedComponentName(componentPath) {
297
+ const componentName = componentPath
298
+ .replace(ROOT_DIR + path.sep, '')
299
+ .split(path.sep)
300
+ .filter(Boolean)
301
+ .join('_')
302
+ .replaceAll('.vex', '')
303
+ .replaceAll(path.sep, '_')
304
+ .replaceAll('-', '_')
305
+ .replaceAll(':', '');
306
+
307
+ return `_${componentName}`;
308
+ }
309
+
310
+ /**
311
+ * Generates a unique and deterministic ID for a component or page path.
312
+ *
313
+ * This is useful for naming client component chunks, cached HTML files, or
314
+ * any scenario where a stable, filesystem-safe identifier is needed.
315
+ *
316
+ * The generated ID combines a sanitized base name with a fixed-length SHA-256 hash
317
+ * derived from the relative component path, ensuring uniqueness even across
318
+ * similarly named components in different directories.
319
+ *
320
+ * @param {string} componentPath - Absolute or project-root-relative path of the component.
321
+ * @param {Object} [options] - Optional configuration.
322
+ * @param {number} [options.length=8] - Number of characters from the hash to include in the ID.
323
+ * @param {boolean} [options.prefix=true] - Whether to include the base component name as a prefix.
324
+ *
325
+ * @returns {string} A deterministic, unique, and filesystem-safe component ID.
326
+ *
327
+ * @example
328
+ * generateComponentId("/src/components/Header.jsx");
329
+ * // "_Header_3f1b2a4c"
330
+ *
331
+ * @example
332
+ * generateComponentId("/src/components/Footer.jsx", { length: 12, prefix: false });
333
+ * // "7a9b1c2d5e6f"
334
+ */
335
+ export function generateComponentId(componentPath, options = {}) {
336
+ const { length = 8, prefix = true } = options;
337
+
338
+ const relativePath = componentPath.replace(ROOT_DIR + path.sep, '');
339
+
340
+ const hash = crypto.createHash("sha256").update(relativePath).digest("hex").slice(0, length);
341
+
342
+ const baseName = getAutogeneratedComponentName(componentPath).replace(/^_/, '');
343
+
344
+ return prefix ? `_${baseName}_${hash}` : hash;
345
+ }
346
+
347
+ /**
348
+ * Resolves the absolute path to a page's main HTML file.
349
+ *
350
+ * @param {string} pageName
351
+ * Page directory name.
352
+ *
353
+ * @returns {string}
354
+ * Absolute path to `page.html`.
355
+ */
356
+ export const getPagePath = (pageName) =>
357
+ path.resolve(PAGES_DIR, pageName, "page.vex");
358
+
359
+ /**
360
+ * Retrieves the root HTML template.
361
+ *
362
+ * @async
363
+ * @returns {Promise<string>}
364
+ * Root HTML content.
365
+ */
366
+ export const getRootTemplate = async () => {
367
+ try {
368
+ await fs.access(ROOT_HTML_USER);
369
+ return await fs.readFile(ROOT_HTML_USER, "utf-8");
370
+ } catch {
371
+ return await fs.readFile(ROOT_HTML_DEFAULT, "utf-8");
372
+ }
373
+ };
374
+
375
+ /**
376
+ * Recursively scans a directory and returns all files found.
377
+ *
378
+ * Each file entry includes:
379
+ * - Absolute path
380
+ * - Relative project path
381
+ * - File name
382
+ *
383
+ * @async
384
+ * @param {string} dir
385
+ * Directory to scan.
386
+ *
387
+ * @returns {Promise<Array<{
388
+ * fullpath: string,
389
+ * name: string,
390
+ * path: string
391
+ * }>>}
392
+ */
393
+ export async function readDirectoryRecursive(dir) {
394
+ const entries = await fs.readdir(dir, { withFileTypes: true });
395
+ const files = [];
396
+
397
+ for (const entry of entries) {
398
+ const fullpath = path.join(dir, entry.name);
399
+
400
+ if (entry.isDirectory()) {
401
+ files.push(...await readDirectoryRecursive(fullpath));
402
+ } else {
403
+ files.push({
404
+ path: fullpath.replace(ROOT_DIR, ''),
405
+ fullpath,
406
+ name: entry.name,
407
+ });
408
+ }
409
+ }
410
+
411
+ return files;
412
+ }
413
+
414
+ /**
415
+ * Derives a component or page name from its filesystem path.
416
+ *
417
+ * Handles:
418
+ * - Pages inside `/pages`
419
+ * - Nested routes
420
+ * - Standalone components
421
+ *
422
+ * @param {string} fullFilepath
423
+ * Absolute file path.
424
+ *
425
+ * @param {string} fileName
426
+ * File name.
427
+ *
428
+ * @returns {string}
429
+ * Derived component name.
430
+ */
431
+
432
+ export const getComponentNameFromPath = (fullFilepath, fileName) => {
433
+ const filePath = fullFilepath.replace(ROOT_DIR + path.sep, "");
434
+ const isPage = filePath.startsWith(path.join("pages", path.sep));
435
+ if (isPage) {
436
+ const segments = filePath.split(path.sep);
437
+ if (segments.length === 2) {
438
+ return segments[0].replace(".vex", "");
439
+ } else {
440
+ return segments[segments.length - 2].replace(".vex", "");
441
+ }
442
+ }
443
+ return fileName.replace(".vex", "");
444
+ };
445
+
446
+ /**
447
+ * Retrieves cached HTML for a component or page from disk.
448
+ *
449
+ * Supports Incremental Static Regeneration (ISR) by returning cached HTML
450
+ * and its metadata. The `isStale` flag can be used to determine if the HTML
451
+ * should be regenerated.
452
+ *
453
+ * @async
454
+ * @param {Object} options
455
+ * @param {string} options.componentPath - Unique identifier or path of the component/page.
456
+ * @returns {Promise<{
457
+ * html: string | null,
458
+ * meta: { generatedAt: number, isStale: boolean } | null
459
+ * }>}
460
+ * - `html`: The cached HTML content, or null if it does not exist.
461
+ * - `meta`: Metadata object containing:
462
+ * - `generatedAt`: Timestamp (ms) of when the HTML was generated.
463
+ * - `isStale`: Boolean indicating if the cache has been manually invalidated.
464
+ */
465
+ export async function getComponentHtmlDisk({ componentPath }) {
466
+ const filePath = path.join(CACHE_DIR, generateComponentId(componentPath) + ".html");
467
+ const metaPath = filePath + ".meta.json";
468
+
469
+ const [existsHtml, existsMeta] = await Promise.all([fileExists(filePath), fileExists(metaPath)]);
470
+
471
+ if (!existsMeta || !existsHtml) {
472
+ return { html: null, meta: null };
473
+ }
474
+
475
+ const [html, meta] = await Promise.all([
476
+ fs.readFile(filePath, "utf-8"),
477
+ fs.readFile(metaPath, "utf-8")
478
+ ]).then(([htmlContent, metaContent]) => [htmlContent, JSON.parse(metaContent)]);
479
+
480
+ return { html, meta };
481
+ }
482
+
483
+ /**
484
+ * Persists server-rendered HTML to disk along with metadata.
485
+ *
486
+ * Metadata includes:
487
+ * - `generatedAt`: timestamp of generation
488
+ * - `isStale`: initially false
489
+ *
490
+ * @async
491
+ * @param {Object} options
492
+ * @param {string} options.componentPath - Unique identifier or path of the component/page.
493
+ * @param {string} options.html - The HTML content to save.
494
+ * @returns {Promise<void>} Resolves when the HTML and metadata have been successfully saved.
495
+ */
496
+ export async function saveComponentHtmlDisk({ componentPath, html }) {
497
+ const filePath = path.join(CACHE_DIR, generateComponentId(componentPath) + ".html");
498
+ const metaPath = filePath + ".meta.json";
499
+
500
+ const meta = {
501
+ generatedAt: Date.now(),
502
+ isStale: false,
503
+ path: componentPath,
504
+ };
505
+
506
+ await Promise.all([
507
+ writeFile(filePath, html, "utf-8"),
508
+ writeFile(metaPath, JSON.stringify(meta), "utf-8"),
509
+ ]);
510
+ }
511
+
512
+ /**
513
+ * Marks a cached component/page as stale without regenerating it.
514
+ *
515
+ * Useful for manual revalidation of ISR pages.
516
+ *
517
+ * @async
518
+ * @param {Object} options
519
+ * @param {string} options.componentPath - Unique identifier or path of the component/page to mark as stale.
520
+ * @returns {Promise<void>} Resolves when the cache metadata has been updated.
521
+ */
522
+ export async function markComponentHtmlStale({ componentPath }) {
523
+ const filePath = path.join(CACHE_DIR, generateComponentId(componentPath) + ".html");
524
+ const metaPath = filePath + ".meta.json";
525
+
526
+
527
+ if (!(await fileExists(metaPath))) return;
528
+
529
+ const meta = JSON.parse(await fs.readFile(metaPath, "utf-8"));
530
+ meta.isStale = true;
531
+
532
+ await writeFile(metaPath, JSON.stringify(meta), "utf-8");
533
+ }
534
+
535
+ /**
536
+ * Writes the server-side routes definition file.
537
+ *
538
+ * This file is consumed at runtime by the server router.
539
+ *
540
+ * @async
541
+ * @param {string[]} serverRoutes
542
+ * Serialized server route objects.
543
+ *
544
+ * @returns {Promise<void>}
545
+ */
546
+
547
+ /**
548
+ * Writes the server-side route registry to `_routes.js`.
549
+ *
550
+ * @param {Array<{
551
+ * path: string,
552
+ * serverPath: string,
553
+ * isNotFound: boolean,
554
+ * meta: { ssr: boolean, requiresAuth: boolean, revalidate: number | string }
555
+ * }>} serverRoutes - Plain route objects.
556
+ */
557
+ export async function saveServerRoutesFile(serverRoutes) {
558
+ await writeFile(
559
+ path.join(GENERATED_DIR, "_routes.js"),
560
+ `// Auto-generated by prebuild — do not edit manually.\nexport const routes = ${JSON.stringify(serverRoutes, null, 2)};\n`
561
+ );
562
+ }
563
+
564
+ /**
565
+ * Writes the client-side routes definition file.
566
+ *
567
+ * Includes:
568
+ * - Route definitions
569
+ *
570
+ * @async
571
+ * @param {string[]} clientRoutes
572
+ * Serialized client route objects.
573
+ * *
574
+ * @returns {Promise<void>}
575
+ */
576
+ export async function saveClientRoutesFile(clientRoutes) {
577
+ const commentsClient = `
578
+ /**
579
+ * @typedef {Object} RouteMeta
580
+ * @property {boolean} ssr
581
+ * @property {boolean} requiresAuth
582
+ * @property {number} revalidateSeconds
583
+ */
584
+
585
+ /**
586
+ * @typedef {Object} Route
587
+ * @property {string} path
588
+ * @property {string} serverPath
589
+ * @property {boolean} isNotFound
590
+ * @property {(marker: HTMLElement) => Promise<{ render: (marker: string) => void, metadata: any}>} [component]
591
+ * @property {RouteMeta} meta
592
+ * @property {Array<{ name: string, importPath: string }>} [layouts]
593
+ */
594
+ `;
595
+ const clientFileCode = `
596
+ import { loadRouteComponent } from './cache.js';
597
+
598
+ ${commentsClient}
599
+ export const routes = [
600
+ ${clientRoutes.join(",\n")}
601
+ ];
602
+ `;
603
+
604
+ await writeFile(
605
+ path.join(GENERATED_DIR, "services", "_routes.js"),
606
+ clientFileCode
607
+ );
608
+ }
609
+
610
+ /**
611
+ * Converts a page file path into a public-facing route path.
612
+ *
613
+ * Keeps dynamic segments in `[param]` format.
614
+ *
615
+ * @param {string} filePath
616
+ * Absolute page file path.
617
+ *
618
+ * @returns {string}
619
+ * Public route path.
620
+ */
621
+
622
+ export function getOriginalRoutePath(filePath) {
623
+ let route = filePath.replace(PAGES_DIR, '').replace('/page.vex', '');
624
+ if (!route.startsWith('/')) route = '/' + route;
625
+ return route;
626
+ }
627
+
628
+ /**
629
+ * Retrieves all page files (`page.html`) in the pages directory.
630
+ * Optionally includes layout files (`layout.html`).
631
+ *
632
+ * @param {Object} [options]
633
+ * @param {boolean} [options.layouts=false]
634
+ * Whether to include layout files in the results.
635
+ *
636
+ * @async
637
+ * @returns {Promise<Array<{ fullpath: string, path: string }>>}
638
+ */
639
+ export async function getPageFiles({ layouts = false } = {}) {
640
+ const pageFiles = await readDirectoryRecursive(PAGES_DIR);
641
+ const htmlFiles = pageFiles.filter((file) =>
642
+ file.fullpath.endsWith("page.vex") || (layouts && file.name === "layout.vex")
643
+ );
644
+
645
+ return htmlFiles;
646
+ }
647
+
648
+ /**
649
+ * Converts a page file path into a server routing path.
650
+ *
651
+ * Dynamic segments `[param]` are converted to `:param`
652
+ * for Express-style routing.
653
+ *
654
+ * @param {string} filePath
655
+ * Absolute page file path.
656
+ *
657
+ * @returns {string}
658
+ * Server route path.
659
+ */
660
+ export function getRoutePath(filePath) {
661
+ let route = filePath.replace(PAGES_DIR, '').replace('/page.vex', '');
662
+ route = route.replace(/\[([^\]]+)\]/g, ':$1'); // [param] -> :param
663
+
664
+ if (!route.startsWith('/')) {
665
+ route = '/' + route;
666
+ }
667
+
668
+ return route;
669
+ }
670
+
671
+ /**
672
+ * Writes a client component JS module to disk.
673
+ *
674
+ * @async
675
+ * @param {string} componentName
676
+ * Autogenerated component name.
677
+ *
678
+ * @param {string} jsModuleCode
679
+ * JavaScript module source.
680
+ *
681
+ * @returns {Promise<void>}
682
+ */
683
+ export async function saveClientComponentModule(componentName, jsModuleCode) {
684
+ const outputPath = path.join(CLIENT_COMPONENTS_DIR, `${componentName}.js`);
685
+
686
+ await writeFile(outputPath, jsModuleCode, "utf-8");
687
+ }
688
+
689
+ /**
690
+ * Resolves an import path relative to the project root
691
+ * and returns filesystem and file URL representations.
692
+ *
693
+ * @param {string} importPath
694
+ * Import path as declared in source code.
695
+ *
696
+ * @returns {{
697
+ * path: string,
698
+ * fileUrl: string,
699
+ * importPath: string
700
+ * }}
701
+ */
702
+ export async function getImportData(importPath) {
703
+ let resolvedPath;
704
+ if (importPath.startsWith("vex/server/")) {
705
+ resolvedPath = path.resolve(FRAMEWORK_DIR, importPath.replace("vex/server/", "server/"));
706
+ } else if (importPath.startsWith("vex/")) {
707
+ resolvedPath = path.resolve(FRAMEWORK_DIR, "client/services", importPath.replace("vex/", ""));
708
+ } else if (importPath.startsWith(".app/server/")) {
709
+ resolvedPath = path.resolve(FRAMEWORK_DIR, importPath.replace(".app/server/", "server/"));
710
+ } else if (importPath.startsWith(".app/")) {
711
+ resolvedPath = path.resolve(FRAMEWORK_DIR, importPath.replace(".app/", ""));
712
+ } else {
713
+ resolvedPath = path.resolve(ROOT_DIR, importPath);
714
+ }
715
+
716
+ // Auto-resolve directory → index.js
717
+ if (existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) {
718
+ resolvedPath = path.join(resolvedPath, "index.js");
719
+ }
720
+
721
+ const fileUrl = pathToFileURL(resolvedPath).href;
722
+ return { path: resolvedPath, fileUrl, importPath };
723
+ }