@cfdez11/vex 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,7 +128,7 @@ Optional file at the project root.
128
128
  ```html
129
129
  <!-- pages/example/page.vex -->
130
130
  <script server>
131
- import UserCard from "components/user-card.vex";
131
+ import UserCard from "@/components/user-card.vex";
132
132
 
133
133
  const metadata = { title: "My Page", description: "Page description" };
134
134
 
@@ -138,7 +138,7 @@ Optional file at the project root.
138
138
  </script>
139
139
 
140
140
  <script client>
141
- import Counter from "components/counter.vex";
141
+ import Counter from "@/components/counter.vex";
142
142
  </script>
143
143
 
144
144
  <template>
@@ -204,11 +204,11 @@ Import them in any page or component:
204
204
 
205
205
  ```html
206
206
  <script server>
207
- import UserCard from "components/user-card.vex";
207
+ import UserCard from "@/components/user-card.vex";
208
208
  </script>
209
209
 
210
210
  <script client>
211
- import Counter from "components/counter.vex";
211
+ import Counter from "@/components/counter.vex";
212
212
  </script>
213
213
 
214
214
  <template>
@@ -361,8 +361,8 @@ Streams a fallback immediately while a slow component loads:
361
361
 
362
362
  ```html
363
363
  <script server>
364
- import SlowCard from "components/slow-card.vex";
365
- import SkeletonCard from "components/skeleton-card.vex";
364
+ import SlowCard from "@/components/slow-card.vex";
365
+ import SkeletonCard from "@/components/skeleton-card.vex";
366
366
  </script>
367
367
 
368
368
  <template>
@@ -553,12 +553,23 @@ Reference the stylesheet in `root.html`:
553
553
 
554
554
  ## 🔧 Framework API
555
555
 
556
- ### Imports
556
+ ### Import conventions
557
557
 
558
- | Import | Context | Description |
559
- |--------|---------|-------------|
560
- | `vex/reactive` | `<script client>` | Reactivity engine (`reactive`, `computed`, `effect`, `watch`) |
561
- | `vex/navigation` | `<script client>` | Router utilities (`useRouteParams`, `useQueryParams`) |
558
+ | Pattern | Example | Behaviour |
559
+ |---------|---------|-----------|
560
+ | `vex/*` | `import { reactive } from "vex/reactive"` | Framework singleton shared instance across all components |
561
+ | `@/*` | `import store from "@/utils/store.js"` | Project alias for your source root also a singleton |
562
+ | `./` / `../` | `import { fn } from "./helpers.js"` | Relative user file — also a singleton |
563
+ | npm bare specifier | `import { format } from "date-fns"` | Bundled inline by esbuild |
564
+
565
+ All user JS files (`@/` and relative) are pre-bundled at startup: npm packages are inlined, while `vex/*`, `@/*`, and relative imports stay external. The browser's ES module cache guarantees every import of the same file returns the same instance — enabling shared reactive state across components without a dedicated store library.
566
+
567
+ ### Client script imports
568
+
569
+ | Import | Description |
570
+ |--------|-------------|
571
+ | `vex/reactive` | Reactivity engine (`reactive`, `computed`, `effect`, `watch`) |
572
+ | `vex/navigation` | Router utilities (`useRouteParams`, `useQueryParams`) |
562
573
 
563
574
  ### Server script hooks
564
575
 
@@ -78,12 +78,12 @@
78
78
  // Start observing the document for new nodes
79
79
  observer.observe(document, { childList: true, subtree: true });
80
80
 
81
- // Hydrate existing components on DOMContentLoaded or immediately if already interactive
81
+ // Hydrate existing components on DOMContentLoaded or immediately if already interactive.
82
+ // The observer is intentionally NOT disconnected here — it must stay active to catch
83
+ // components inserted after DOMContentLoaded (nested CSR components, Suspense streaming,
84
+ // SPA navigations). The `data-hydrated` guard in hydrateMarker prevents double-hydration.
82
85
  if (document.readyState === "loading") {
83
- document.addEventListener("DOMContentLoaded", () => {
84
- hydrateComponents();
85
- observer.disconnect();
86
- });
86
+ document.addEventListener("DOMContentLoaded", () => hydrateComponents());
87
87
  } else {
88
88
  hydrateComponents();
89
89
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfdez11/vex",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "A vanilla JavaScript meta-framework with file-based routing, SSR/CSR/SSG/ISR and Vue-like reactivity",
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -22,6 +22,7 @@
22
22
  "license": "MIT",
23
23
  "dependencies": {
24
24
  "dom-serializer": "^2.0.0",
25
+ "dotenv": "^16.0.0",
25
26
  "esbuild": "^0.25.0",
26
27
  "express": "^5.2.1",
27
28
  "htmlparser2": "^10.0.0"
@@ -1,14 +1,14 @@
1
+ import "dotenv/config";
1
2
  import fs from "fs/promises";
2
3
  import path from "path";
3
4
  import { build } from "./utils/component-processor.js";
4
5
  import {
5
6
  initializeDirectories,
6
7
  CLIENT_DIR,
7
- SRC_DIR,
8
8
  PROJECT_ROOT,
9
9
  getRootTemplate,
10
- WATCH_IGNORE,
11
10
  generateComponentId,
11
+ USER_GENERATED_DIR,
12
12
  } from "./utils/files.js";
13
13
 
14
14
  const GENERATED_DIR = path.join(PROJECT_ROOT, ".vexjs");
@@ -67,9 +67,15 @@ await fs.cp(
67
67
  { recursive: true }
68
68
  );
69
69
 
70
- // Step 7: Copy user JS files with import rewriting → dist/_vexjs/user/
71
- console.log("📦 Processing user JS files...");
72
- await copyUserJsFiles(SRC_DIR, path.join(DIST_DIR, "_vexjs", "user"));
70
+ // Step 7: Copy pre-bundled user JS files → dist/_vexjs/user/
71
+ // build() already ran esbuild on every user .js file → USER_GENERATED_DIR.
72
+ // npm packages are bundled inline; vex/*, @/*, relative imports stay external.
73
+ console.log("📦 Copying pre-bundled user JS files...");
74
+ try {
75
+ await fs.cp(USER_GENERATED_DIR, path.join(DIST_DIR, "_vexjs", "user"), { recursive: true });
76
+ } catch {
77
+ // no user JS files — that's fine
78
+ }
73
79
 
74
80
  // Step 8: Copy public/ → dist/ (static assets, CSS)
75
81
  console.log("📦 Copying public assets...");
@@ -116,87 +122,4 @@ console.log("✅ Static build complete! Output: dist/");
116
122
  console.log("\nTo serve locally: npx serve dist");
117
123
  console.log("Static host note: configure your host to serve dist/index.html for all 404s (SPA fallback).");
118
124
 
119
- // ─── Helpers ─────────────────────────────────────────────────────────────────
120
-
121
- /**
122
- * Recursively walks SRC_DIR, rewrites imports in every .js file,
123
- * and writes results to destDir preserving the relative path structure.
124
- *
125
- * Skips directories listed in WATCH_IGNORE (node_modules, dist, .vexjs, etc.).
126
- *
127
- * @param {string} srcDir Absolute path to user source root (SRC_DIR)
128
- * @param {string} destDir Absolute path to dist/_vexjs/user/
129
- */
130
- async function copyUserJsFiles(srcDir, destDir) {
131
- let entries;
132
- try {
133
- entries = await fs.readdir(srcDir, { withFileTypes: true });
134
- } catch {
135
- return;
136
- }
137
-
138
- for (const entry of entries) {
139
- if (WATCH_IGNORE.has(entry.name)) continue;
140
-
141
- const fullSrc = path.join(srcDir, entry.name);
142
- const relToSrcDir = path.relative(SRC_DIR, fullSrc).replace(/\\/g, "/");
143
- const fullDest = path.join(destDir, relToSrcDir);
144
-
145
- if (entry.isDirectory()) {
146
- await copyUserJsFiles(fullSrc, destDir);
147
- } else if (entry.name.endsWith(".js")) {
148
- let content;
149
- try {
150
- content = await fs.readFile(fullSrc, "utf-8");
151
- } catch {
152
- continue;
153
- }
154
125
 
155
- content = rewriteUserImports(content, fullSrc, srcDir);
156
-
157
- await fs.mkdir(path.dirname(fullDest), { recursive: true });
158
- await fs.writeFile(fullDest, content, "utf-8");
159
- }
160
- }
161
- }
162
-
163
- /**
164
- * Rewrites import paths in a user JS file so they work in the browser.
165
- * Mirrors the runtime rewriting done by the /_vexjs/user/* Express handler.
166
- *
167
- * - `vex/` and `.app/` → `/_vexjs/services/`
168
- * - `@/` (project alias) → `/_vexjs/user/`
169
- * - relative `./` or `../` → `/_vexjs/user/`
170
- * - external bare specifiers (e.g. npm packages) → left as-is
171
- *
172
- * @param {string} content File source
173
- * @param {string} filePath Absolute path of the file being rewritten
174
- * @param {string} srcDir Absolute SRC_DIR root
175
- * @returns {string} Rewritten source
176
- */
177
- function rewriteUserImports(content, filePath, srcDir) {
178
- return content.replace(
179
- /^(\s*import\s+[^'"]*from\s+)['"]([^'"]+)['"]/gm,
180
- (match, prefix, modulePath) => {
181
- if (modulePath.startsWith("vex/") || modulePath.startsWith(".app/")) {
182
- let mod = modulePath.replace(/^vex\//, "").replace(/^\.app\//, "");
183
- if (!path.extname(mod)) mod += ".js";
184
- return `${prefix}'/_vexjs/services/${mod}'`;
185
- }
186
- if (modulePath.startsWith("@/") || modulePath === "@") {
187
- let resolved = path.resolve(srcDir, modulePath.replace(/^@\//, "").replace(/^@$/, ""));
188
- if (!path.extname(resolved)) resolved += ".js";
189
- const rel = path.relative(srcDir, resolved).replace(/\\/g, "/");
190
- return `${prefix}'/_vexjs/user/${rel}'`;
191
- }
192
- if (modulePath.startsWith("./") || modulePath.startsWith("../")) {
193
- const fileDir = path.dirname(filePath);
194
- let resolved = path.resolve(fileDir, modulePath);
195
- if (!path.extname(resolved)) resolved += ".js";
196
- const rel = path.relative(srcDir, resolved).replace(/\\/g, "/");
197
- return `${prefix}'/_vexjs/user/${rel}'`;
198
- }
199
- return match;
200
- }
201
- );
202
- }
package/server/index.js CHANGED
@@ -1,8 +1,9 @@
1
+ import "dotenv/config";
1
2
  import express from "express";
2
3
  import path from "path";
3
4
  import { pathToFileURL } from "url";
4
5
  import { handlePageRequest, revalidatePath } from "./utils/router.js";
5
- import { initializeDirectories, CLIENT_DIR } from "./utils/files.js";
6
+ import { initializeDirectories, CLIENT_DIR, USER_GENERATED_DIR } from "./utils/files.js";
6
7
 
7
8
  await initializeDirectories();
8
9
 
@@ -71,6 +72,22 @@ app.use(
71
72
  );
72
73
 
73
74
 
75
+ // Serve pre-bundled user JS utility files at /_vexjs/user/
76
+ // These are @/ and relative imports in <script client> blocks. esbuild bundles
77
+ // each file with npm packages inlined; vex/*, @/*, and relative user imports
78
+ // stay external so every component shares the same singleton module instance
79
+ // via the browser's ES module cache.
80
+ app.use(
81
+ "/_vexjs/user",
82
+ express.static(USER_GENERATED_DIR, {
83
+ setHeaders(res, filePath) {
84
+ if (filePath.endsWith(".js")) {
85
+ res.setHeader("Content-Type", "application/javascript");
86
+ }
87
+ },
88
+ })
89
+ );
90
+
74
91
  // Serve user's public directory at /
75
92
  app.use("/", express.static(path.join(process.cwd(), "public")));
76
93
 
@@ -115,7 +132,7 @@ app.use(async (req, res) => {
115
132
  res.status(404).send("Page not found");
116
133
  });
117
134
 
118
- const PORT = process.env.PORT || 3001;
135
+ const PORT = process.env.VEX_PORT || process.env.PORT || 3001;
119
136
  app.listen(PORT, () => {
120
137
  console.log(`Server running on port ${PORT}`);
121
138
  });
@@ -1,3 +1,4 @@
1
+ import "dotenv/config";
1
2
  import { build } from "./utils/component-processor.js";
2
3
  import { initializeDirectories } from "./utils/files.js";
3
4
 
@@ -1,8 +1,9 @@
1
1
  import { watch } from "fs";
2
+ import fs from "fs/promises";
2
3
  import path from "path";
3
4
  import esbuild from "esbuild";
4
5
  import { compileTemplateToHTML } from "./template.js";
5
- 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 } from "./files.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";
6
7
  import { renderComponents } from "./streaming.js";
7
8
  import { getRevalidateSeconds } from "./cache.js";
8
9
  import { withCache } from "./data-cache.js";
@@ -109,15 +110,12 @@ if (process.env.NODE_ENV !== "production") {
109
110
  // 3. Notify connected browsers to reload
110
111
  hmrEmitter.emit("reload", filename);
111
112
  } else if (filename.endsWith(".js")) {
112
- // User utility .js file changed. Because esbuild inlines user files into
113
- // each component bundle that imports them, a change to any utility requires
114
- // re-bundling all components — we cannot know which bundles include this
115
- // file without tracking the full import graph. Rebuilding all components
116
- // is fast enough with esbuild (sub-millisecond per file).
113
+ // Rebuild the changed user JS file so npm imports are re-bundled.
114
+ const fullPath = path.join(SRC_DIR, filename);
117
115
  try {
118
- await generateComponentsAndFillCache();
116
+ await buildUserFile(fullPath);
119
117
  } catch (e) {
120
- console.error(`[HMR] Rebuild failed after ${filename} change:`, e.message);
118
+ console.error(`[HMR] Failed to rebuild user file ${filename}:`, e.message);
121
119
  }
122
120
  hmrEmitter.emit("reload", filename);
123
121
  }
@@ -177,7 +175,7 @@ const getScriptImports = async (script, isClientSide = false, filePath = null) =
177
175
  while ((match = importRegex.exec(script)) !== null) {
178
176
  const [importStatement, defaultImport, namedImports, modulePath] = match;
179
177
 
180
- const { path, fileUrl } = await getImportData(modulePath);
178
+ const { path, fileUrl } = await getImportData(modulePath, filePath);
181
179
 
182
180
  if (path.endsWith(".vex")) {
183
181
  // Recursively process HTML component
@@ -989,7 +987,7 @@ async function generateServerComponentHTML(componentPath) {
989
987
  * and runtime interpolations (e.g., `${variable}`).
990
988
  *
991
989
  * @param {string} componentName - The logical name of the component.
992
- * @param {string} originalPath - The original file path of the component.
990
+ * @param {string} componentAbsPath - The absolute file path of the component (resolved by getImportData).
993
991
  * @param {Record<string, any>} [props={}] - An object of props to pass to the component.
994
992
  * Values can be literals or template
995
993
  * interpolations (`${…}`) for dynamic evaluation.
@@ -997,10 +995,13 @@ async function generateServerComponentHTML(componentPath) {
997
995
  * @returns {Promise<string>} A promise that resolves to a string containing
998
996
  * the `<template>` HTML for hydration.
999
997
  */
1000
- export async function processClientComponent(componentName, originalPath, props = {}) {
998
+ export async function processClientComponent(componentName, componentAbsPath, props = {}) {
1001
999
  const targetId = `client-${componentName}-${Date.now()}`;
1002
1000
 
1003
- const componentImport = generateComponentId(originalPath)
1001
+ // componentAbsPath is the absolute resolved path — generateComponentId strips ROOT_DIR
1002
+ // internally, so this produces the same hash as the bundle filename written by
1003
+ // generateComponentAndFillCache (which also calls generateComponentId with the abs path).
1004
+ const componentImport = generateComponentId(componentAbsPath);
1004
1005
  const propsJson = serializeClientComponentProps(props);
1005
1006
  const html = `<template id="${targetId}" data-client:component="${componentImport}" data-client:props='${propsJson}'></template>`;
1006
1007
 
@@ -1554,6 +1555,59 @@ export async function generateRoutes() {
1554
1555
  return { serverRoutes };
1555
1556
  }
1556
1557
 
1558
+ /**
1559
+ * Bundles a single user JS file with esbuild so npm bare-specifier imports
1560
+ * are resolved and inlined, while vex/*, @/*, and relative user imports stay
1561
+ * external (singletons served at /_vexjs/user/*).
1562
+ *
1563
+ * Output is written to USER_GENERATED_DIR preserving the SRC_DIR-relative path.
1564
+ *
1565
+ * @param {string} filePath - Absolute path to the user .js file.
1566
+ */
1567
+ async function buildUserFile(filePath) {
1568
+ const rel = path.relative(SRC_DIR, filePath).replace(/\\/g, "/");
1569
+ const outfile = path.join(USER_GENERATED_DIR, rel);
1570
+ await esbuild.build({
1571
+ entryPoints: [filePath],
1572
+ bundle: true,
1573
+ format: "esm",
1574
+ outfile,
1575
+ plugins: [createVexAliasPlugin()],
1576
+ });
1577
+ }
1578
+
1579
+ /**
1580
+ * Recursively finds all .js files in SRC_DIR (excluding WATCH_IGNORE dirs)
1581
+ * and prebundles each one via buildUserFile.
1582
+ *
1583
+ * Called during build() so that user utility files are ready before the server
1584
+ * starts serving /_vexjs/user/* from the pre-built static output.
1585
+ */
1586
+ async function buildUserFiles() {
1587
+ const collect = async (dir) => {
1588
+ let entries;
1589
+ try {
1590
+ entries = await fs.readdir(dir, { withFileTypes: true });
1591
+ } catch {
1592
+ return;
1593
+ }
1594
+ await Promise.all(entries.map(async (entry) => {
1595
+ if (WATCH_IGNORE.has(entry.name)) return;
1596
+ const full = path.join(dir, entry.name);
1597
+ if (entry.isDirectory()) {
1598
+ await collect(full);
1599
+ } else if (entry.name.endsWith(".js")) {
1600
+ try {
1601
+ await buildUserFile(full);
1602
+ } catch (e) {
1603
+ console.error(`[build] Failed to bundle user file ${full}:`, e.message);
1604
+ }
1605
+ }
1606
+ }));
1607
+ };
1608
+ await collect(SRC_DIR);
1609
+ }
1610
+
1557
1611
  /**
1558
1612
  * Single-pass build entry point.
1559
1613
  *
@@ -1570,5 +1624,6 @@ export async function generateRoutes() {
1570
1624
  */
1571
1625
  export async function build() {
1572
1626
  await generateComponentsAndFillCache();
1627
+ await buildUserFiles();
1573
1628
  return generateRoutes();
1574
1629
  }
@@ -1,5 +1,5 @@
1
1
  import path from "path";
2
- import { SRC_DIR } from "./files.js";
2
+ import { SRC_DIR, PROJECT_ROOT } from "./files.js";
3
3
 
4
4
  /**
5
5
  * Creates the VexJS esbuild alias plugin.
@@ -17,9 +17,8 @@ import { SRC_DIR } from "./files.js";
17
17
  *
18
18
  * ─── Three categories of imports ────────────────────────────────────────────
19
19
  *
20
- * 1. Framework singletons (vex/* and .app/*)
20
+ * 1. Framework singletons (vex/*)
21
21
  * Examples: `import { reactive } from 'vex/reactive'`
22
- * `import { html } from '.app/html'`
23
22
  *
24
23
  * These are framework runtime files served statically at /_vexjs/services/.
25
24
  * They MUST be marked external so every component shares the same instance
@@ -33,16 +32,20 @@ import { SRC_DIR } from "./files.js";
33
32
  * 2. Project alias (@/*)
34
33
  * Example: `import { counter } from '@/utils/counter'`
35
34
  *
36
- * @/ is a shorthand for the project SRC_DIR root. These are user JS utilities
37
- * that should be bundled into the component (not served separately). esbuild
38
- * receives the absolute filesystem path so it can read and inline the file.
35
+ * @/ is a shorthand for the project SRC_DIR root. These files are served as
36
+ * singleton modules at /_vexjs/user/ the browser's ES module cache ensures
37
+ * all components share the same instance (same reactive state, same store).
39
38
  *
40
39
  * 3. Relative imports (./ and ../)
41
40
  * Example: `import { fn } from './helpers'`
42
41
  *
43
- * These are resolved automatically by esbuild using the `resolveDir` option
44
- * set on the stdin entry (the directory of the .vex file being compiled).
45
- * No custom hook is needed for these.
42
+ * Treated the same as @/ marked external and served at /_vexjs/user/.
43
+ * This gives the same singleton guarantee as @/ imports: two components that
44
+ * import the same file via different relative paths both resolve to the same
45
+ * URL, so the browser module cache returns the same instance.
46
+ *
47
+ * Note: .vex component imports are stripped from clientImports before
48
+ * reaching esbuild, so this hook only fires for .js user utility files.
46
49
  *
47
50
  * 4. npm packages (bare specifiers like 'lodash', 'date-fns')
48
51
  * Also resolved automatically by esbuild via node_modules lookup.
@@ -63,23 +66,44 @@ export function createVexAliasPlugin() {
63
66
  return { path: `/_vexjs/services/${mod}`, external: true };
64
67
  });
65
68
 
66
- // ── Category 1b: .app/* ───────────────────────────────────────────────
67
- // Legacy alias for framework services. Same treatment as vex/*.
68
- // Matches: '.app/reactive', '.app/html', etc.
69
- build.onResolve({ filter: /^\.app\// }, (args) => {
70
- let mod = args.path.replace(/^\.app\//, "");
69
+ // ── Category 2: @/ project alias ─────────────────────────────────────
70
+ // Matches: '@/utils/counter', '@/store/ui-state', etc.
71
+ //
72
+ // These are user JS utilities that must behave as singletons — all
73
+ // components on a page must share the SAME module instance (same reactive
74
+ // state, same store). If esbuild inlined them, each component bundle would
75
+ // get its own copy and reactive state would not propagate across components.
76
+ //
77
+ // Solution: mark as external and rewrite to the browser-accessible URL
78
+ // /_vexjs/user/<path>.js. The dev server serves those files on-the-fly with
79
+ // import rewriting; the static build pre-copies them to dist/_vexjs/user/.
80
+ // The browser's ES module cache ensures a single instance is shared.
81
+ build.onResolve({ filter: /^@\// }, (args) => {
82
+ let mod = args.path.slice(2); // strip leading @/
71
83
  if (!path.extname(mod)) mod += ".js";
72
- return { path: `/_vexjs/services/${mod}`, external: true };
84
+ return { path: `/_vexjs/user/${mod}`, external: true };
73
85
  });
74
86
 
75
- // ── Category 2: @/ project alias ─────────────────────────────────────
76
- // Matches: '@/utils/counter', '@/lib/api', etc.
77
- // Resolved to an absolute filesystem path so esbuild can read and bundle
78
- // the file inline. No .js extension auto-appended here esbuild does it.
79
- build.onResolve({ filter: /^@\// }, (args) => {
80
- let resolved = path.resolve(SRC_DIR, args.path.slice(2));
87
+ // ── Category 3: relative imports (./ and ../) ─────────────────────────
88
+ // Matches: './helpers', '../utils/format', etc.
89
+ //
90
+ // User JS files imported relatively are also served as singleton modules
91
+ // at /_vexjs/user/<resolved-path>.js. This mirrors Vue + Vite: every source
92
+ // file gets its own URL, and the browser module cache ensures the same file
93
+ // is always the same instance regardless of how it was imported.
94
+ //
95
+ // Files outside SRC_DIR (e.g. node_modules reached via ../../) fall through
96
+ // to esbuild's default resolver and are bundled inline as usual.
97
+ build.onResolve({ filter: /^\.\.?\// }, (args) => {
98
+ let resolved = path.resolve(args.resolveDir, args.path);
81
99
  if (!path.extname(resolved)) resolved += ".js";
82
- return { path: resolved };
100
+
101
+ // Only intercept .js user files — anything else (CSS, JSON, non-user) falls through
102
+ if (!resolved.endsWith(".js")) return;
103
+ if (!resolved.startsWith(SRC_DIR) && !resolved.startsWith(PROJECT_ROOT)) return;
104
+
105
+ const rel = path.relative(SRC_DIR, resolved).replace(/\\/g, "/");
106
+ return { path: `/_vexjs/user/${rel}`, external: true };
83
107
  });
84
108
  },
85
109
  };
@@ -111,6 +111,7 @@ export const CLIENT_SERVICES_DIR = path.join(CLIENT_DIR, "services");
111
111
  const GENERATED_DIR = path.join(PROJECT_ROOT, ".vexjs");
112
112
  const CACHE_DIR = path.join(GENERATED_DIR, "_cache");
113
113
  export const CLIENT_COMPONENTS_DIR = path.join(GENERATED_DIR, "_components");
114
+ export const USER_GENERATED_DIR = path.join(GENERATED_DIR, "user");
114
115
  const SERVER_UTILS_DIR = path.join(GENERATED_DIR);
115
116
  const ROOT_HTML_USER = path.join(PROJECT_ROOT, "root.html");
116
117
  const ROOT_HTML_DEFAULT = path.join(FRAMEWORK_DIR, "server", "root.html");
@@ -137,6 +138,7 @@ export async function initializeDirectories() {
137
138
  fs.mkdir(GENERATED_DIR, { recursive: true }),
138
139
  fs.mkdir(CACHE_DIR, { recursive: true }),
139
140
  fs.mkdir(CLIENT_COMPONENTS_DIR, { recursive: true }),
141
+ fs.mkdir(USER_GENERATED_DIR, { recursive: true }),
140
142
  fs.mkdir(path.join(GENERATED_DIR, "services"), { recursive: true }),
141
143
  ]);
142
144
 
@@ -165,12 +167,10 @@ export async function initializeDirectories() {
165
167
  *
166
168
  * @example
167
169
  * const result = adjustClientModulePath(
168
- * '.app/reactive.js',
169
- * "import userController from '.app/reactive.js';"
170
+ * 'vex/reactive',
171
+ * "import { reactive } from 'vex/reactive';"
170
172
  * );
171
- * console.log(result.path); // '/.app/client/services/reactive.js'
172
- * console.log(result.importStatement);
173
- * // "import userController from '/.app/client/services/reactive.js';"
173
+ * console.log(result.path); // '/_vexjs/services/reactive.js'
174
174
  */
175
175
  export function adjustClientModulePath(modulePath, importStatement, componentFilePath = null) {
176
176
  if (modulePath.startsWith("/_vexjs/")) {
@@ -204,8 +204,8 @@ export function adjustClientModulePath(modulePath, importStatement, componentFil
204
204
  return { path: adjustedPath, importStatement: adjustedImportStatement };
205
205
  }
206
206
 
207
- // Framework imports (vex/ and .app/)
208
- let relative = modulePath.replace(/^vex\//, "").replace(/^\.app\//, "");
207
+ // Framework imports (vex/)
208
+ let relative = modulePath.replace(/^vex\//, "");
209
209
  let adjustedPath = `/_vexjs/services/${relative}`;
210
210
 
211
211
  // Auto-resolve directory → index.js, bare name → .js
@@ -811,16 +811,19 @@ export async function saveClientComponentModule(componentName, jsModuleCode) {
811
811
  * importPath: string
812
812
  * }}
813
813
  */
814
- export async function getImportData(importPath) {
814
+ export async function getImportData(importPath, callerFilePath = null) {
815
815
  let resolvedPath;
816
816
  if (importPath.startsWith("vex/server/")) {
817
817
  resolvedPath = path.resolve(FRAMEWORK_DIR, importPath.replace("vex/server/", "server/"));
818
818
  } else if (importPath.startsWith("vex/")) {
819
819
  resolvedPath = path.resolve(FRAMEWORK_DIR, "client/services", importPath.replace("vex/", ""));
820
- } else if (importPath.startsWith(".app/server/")) {
821
- resolvedPath = path.resolve(FRAMEWORK_DIR, importPath.replace(".app/server/", "server/"));
822
- } else if (importPath.startsWith(".app/")) {
823
- resolvedPath = path.resolve(FRAMEWORK_DIR, importPath.replace(".app/", ""));
820
+ } else if (importPath.startsWith("@/") || importPath === "@") {
821
+ resolvedPath = path.resolve(SRC_DIR, importPath.replace(/^@\//, "").replace(/^@$/, ""));
822
+ } else if ((importPath.startsWith("./") || importPath.startsWith("../")) && callerFilePath) {
823
+ // Relative import — resolve against the caller component's directory, not ROOT_DIR.
824
+ // Without this, `import Foo from './foo.vex'` inside a nested component would be
825
+ // resolved from the project root instead of from the file that contains the import.
826
+ resolvedPath = path.resolve(path.dirname(callerFilePath), importPath);
824
827
  } else {
825
828
  resolvedPath = path.resolve(ROOT_DIR, importPath);
826
829
  }
@@ -832,4 +835,5 @@ export async function getImportData(importPath) {
832
835
 
833
836
  const fileUrl = pathToFileURL(resolvedPath).href;
834
837
  return { path: resolvedPath, fileUrl, importPath };
835
- }
838
+ }
839
+
@@ -128,7 +128,7 @@ async function renderClientComponents(html, clientComponents) {
128
128
  let processedHtml = html;
129
129
  const allScripts = [];
130
130
 
131
- for (const [componentName, { originalPath }] of clientComponents.entries()) {
131
+ for (const [componentName, { path: componentAbsPath }] of clientComponents.entries()) {
132
132
  const escapedName = componentName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
133
133
 
134
134
  const componentRegex = new RegExp(
@@ -156,7 +156,7 @@ async function renderClientComponents(html, clientComponents) {
156
156
  for (let i = replacements.length - 1; i >= 0; i--) {
157
157
  const { start, end, attrs } = replacements[i];
158
158
 
159
- const htmlComponent = await processClientComponent(componentName, originalPath, attrs);
159
+ const htmlComponent = await processClientComponent(componentName, componentAbsPath, attrs);
160
160
 
161
161
  processedHtml =
162
162
  processedHtml.slice(0, start) +