@cfdez11/vex 0.3.0 → 0.5.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
@@ -686,6 +686,12 @@ sequenceDiagram
686
686
  - [x] `vex.config.json` — configurable `srcDir` and `watchIgnore`
687
687
  - [x] Published to npm as `@cfdez11/vex`
688
688
  - [x] VS Code extension with syntax highlighting and go-to-definition
689
+ - [ ] Devtools
690
+ - [ ] Typescript in framework
691
+ - [ ] Allow typescript to devs
692
+ - [ ] Improve extension (hightlight, redirects, etc)
693
+ - [ ] Create theme syntax
694
+ - [ ] Create docs page
689
695
  - [ ] Authentication middleware
690
696
  - [ ] CDN cache integration
691
697
  - [ ] Fix Suspense marker replacement with multi-root templates
package/bin/vex.js CHANGED
@@ -9,7 +9,25 @@ const serverDir = path.resolve(__dirname, "..", "server");
9
9
 
10
10
  const [command] = process.argv.slice(2);
11
11
 
12
+ /**
13
+ * Available CLI commands.
14
+ *
15
+ * Each entry is a factory function that calls `spawn()` to launch a child
16
+ * process and returns the ChildProcess handle.
17
+ *
18
+ * `spawn(command, args, options)` forks a new OS process running `command`
19
+ * with the given `args`. It is non-blocking: the parent (this CLI) keeps
20
+ * running while the child executes. The returned ChildProcess emits an
21
+ * "exit" event when the child terminates, which we use to forward its exit
22
+ * code so the shell sees the correct status (e.g. for CI).
23
+ *
24
+ * `stdio: "inherit"` wires the child's stdin/stdout/stderr directly to the
25
+ * terminal that launched the CLI. Without it the child's output would be
26
+ * captured internally and never displayed. "inherit" is equivalent to
27
+ * passing [process.stdin, process.stdout, process.stderr].
28
+ */
12
29
  const commands = {
30
+ /** Start the dev server with Node's built-in file watcher (--watch restarts on .js changes). */
13
31
  dev: () =>
14
32
  spawn(
15
33
  "node",
@@ -17,6 +35,7 @@ const commands = {
17
35
  { stdio: "inherit" }
18
36
  ),
19
37
 
38
+ /** Run the prebuild: scan pages/, generate component bundles and route registries. */
20
39
  build: () =>
21
40
  spawn(
22
41
  "node",
@@ -24,16 +43,25 @@ const commands = {
24
43
  { stdio: "inherit" }
25
44
  ),
26
45
 
46
+ /** Start the production server. Sets NODE_ENV=production to disable HMR and file watchers. */
27
47
  start: () =>
28
48
  spawn(
29
49
  "node",
30
50
  [path.join(serverDir, "index.js")],
31
51
  { stdio: "inherit", env: { ...process.env, NODE_ENV: "production" } }
32
52
  ),
53
+
54
+ /** Run the static build: prebuild + copy assets to dist/ for deployment without a server. */
55
+ "build:static": () =>
56
+ spawn(
57
+ "node",
58
+ [path.join(serverDir, "build-static.js")],
59
+ { stdio: "inherit" }
60
+ ),
33
61
  };
34
62
 
35
63
  if (!commands[command]) {
36
- console.error(`Unknown command: "${command}"\nAvailable: dev, build, start`);
64
+ console.error(`Unknown command: "${command}"\nAvailable: dev, build, build:static, start`);
37
65
  process.exit(1);
38
66
  }
39
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfdez11/vex",
3
- "version": "0.3.0",
3
+ "version": "0.5.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
+ "esbuild": "^0.25.0",
25
26
  "express": "^5.2.1",
26
27
  "htmlparser2": "^10.0.0"
27
28
  }
@@ -0,0 +1,202 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { build } from "./utils/component-processor.js";
4
+ import {
5
+ initializeDirectories,
6
+ CLIENT_DIR,
7
+ SRC_DIR,
8
+ PROJECT_ROOT,
9
+ getRootTemplate,
10
+ WATCH_IGNORE,
11
+ generateComponentId,
12
+ } from "./utils/files.js";
13
+
14
+ const GENERATED_DIR = path.join(PROJECT_ROOT, ".vexjs");
15
+ const DIST_DIR = path.join(PROJECT_ROOT, "dist");
16
+
17
+ console.log("🔨 Starting static build...");
18
+
19
+ // Step 1: Prebuild (components + routes)
20
+ console.log("📁 Initializing directories...");
21
+ await initializeDirectories();
22
+
23
+ console.log("⚙️ Generating components and routes...");
24
+ const { serverRoutes } = await build();
25
+
26
+ // Step 2: Create dist/ structure (clean start)
27
+ console.log("🗂️ Creating dist/ structure...");
28
+ await fs.rm(DIST_DIR, { recursive: true, force: true });
29
+ await fs.mkdir(path.join(DIST_DIR, "_vexjs", "_components"), { recursive: true });
30
+ await fs.mkdir(path.join(DIST_DIR, "_vexjs", "user"), { recursive: true });
31
+
32
+ // Step 3: Generate dist/index.html shell
33
+ console.log("📄 Generating index.html shell...");
34
+ const rootTemplate = await getRootTemplate();
35
+ let shell = rootTemplate
36
+ .replace(/\{\{metadata\.title\}\}/g, "App")
37
+ .replace(/\{\{metadata\.description\}\}/g, "")
38
+ .replace(/\{\{props\.children\}\}/g, "");
39
+
40
+ const frameworkScripts = [
41
+ `<script type="module" src="/_vexjs/services/index.js"></script>`,
42
+ `<script src="/_vexjs/services/hydrate-client-components.js"></script>`,
43
+ `<script src="/_vexjs/services/hydrate.js" id="hydrate-script"></script>`,
44
+ ].join("\n ");
45
+
46
+ shell = shell.replace("</head>", ` ${frameworkScripts}\n</head>`);
47
+ await fs.writeFile(path.join(DIST_DIR, "index.html"), shell, "utf-8");
48
+
49
+ // Step 4: Copy framework client files → dist/_vexjs/
50
+ console.log("📦 Copying framework client files...");
51
+ await fs.cp(CLIENT_DIR, path.join(DIST_DIR, "_vexjs"), { recursive: true });
52
+
53
+ // Step 5: Copy generated component bundles → dist/_vexjs/_components/
54
+ console.log("📦 Copying component bundles...");
55
+ await fs.cp(
56
+ path.join(GENERATED_DIR, "_components"),
57
+ path.join(DIST_DIR, "_vexjs", "_components"),
58
+ { recursive: true }
59
+ );
60
+
61
+ // Step 6: Copy generated services (includes _routes.js) → dist/_vexjs/services/
62
+ // This overwrites the framework-level services dir copy with the generated routes
63
+ console.log("📦 Copying generated services...");
64
+ await fs.cp(
65
+ path.join(GENERATED_DIR, "services"),
66
+ path.join(DIST_DIR, "_vexjs", "services"),
67
+ { recursive: true }
68
+ );
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"));
73
+
74
+ // Step 8: Copy public/ → dist/ (static assets, CSS)
75
+ console.log("📦 Copying public assets...");
76
+ const publicDir = path.join(PROJECT_ROOT, "public");
77
+ try {
78
+ await fs.cp(publicDir, DIST_DIR, { recursive: true });
79
+ } catch {
80
+ // no public/ directory — that's fine
81
+ }
82
+
83
+ // Step 9: Copy pre-rendered HTML for SSG routes (revalidate: 'never')
84
+ const CACHE_DIR = path.join(GENERATED_DIR, "_cache");
85
+ const ssgRoutes = serverRoutes.filter(
86
+ (r) => r.meta.revalidate === "never" || r.meta.revalidate === false
87
+ );
88
+ if (ssgRoutes.length > 0) {
89
+ console.log("📄 Copying pre-rendered SSG pages...");
90
+ for (const route of ssgRoutes) {
91
+ const cacheFile = path.join(CACHE_DIR, `${generateComponentId(route.serverPath)}.html`);
92
+ try {
93
+ const html = await fs.readFile(cacheFile, "utf-8");
94
+ const routeSegment = route.serverPath === "/" ? "" : route.serverPath;
95
+ const destPath = path.join(DIST_DIR, routeSegment, "index.html");
96
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
97
+ await fs.writeFile(destPath, html, "utf-8");
98
+ console.log(` ✓ ${route.serverPath}`);
99
+ } catch {
100
+ console.warn(` ✗ ${route.serverPath} (no cached HTML found)`);
101
+ }
102
+ }
103
+ }
104
+
105
+ // Step 10: Report SSR-only routes that were skipped
106
+ const ssrOnlyRoutes = serverRoutes.filter((r) => r.meta.ssr);
107
+ if (ssrOnlyRoutes.length > 0) {
108
+ console.warn("\n⚠️ The following routes require a server and were NOT included in the static build:");
109
+ for (const r of ssrOnlyRoutes) {
110
+ console.warn(` ${r.path} (SSR)`);
111
+ }
112
+ console.warn(" These routes will show a 404 in the static build.\n");
113
+ }
114
+
115
+ console.log("✅ Static build complete! Output: dist/");
116
+ console.log("\nTo serve locally: npx serve dist");
117
+ console.log("Static host note: configure your host to serve dist/index.html for all 404s (SPA fallback).");
118
+
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
+
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,9 +1,8 @@
1
- import fs from "fs/promises";
2
1
  import express from "express";
3
2
  import path from "path";
4
3
  import { pathToFileURL } from "url";
5
4
  import { handlePageRequest, revalidatePath } from "./utils/router.js";
6
- import { initializeDirectories, CLIENT_DIR, SRC_DIR } from "./utils/files.js";
5
+ import { initializeDirectories, CLIENT_DIR } from "./utils/files.js";
7
6
 
8
7
  await initializeDirectories();
9
8
 
@@ -16,7 +15,7 @@ if (process.env.NODE_ENV === "production") {
16
15
  serverRoutes = routes;
17
16
  console.log("Routes loaded.");
18
17
  } catch {
19
- console.error("ERROR: No build found. Run 'pnpm build' before starting in production.");
18
+ console.error("ERROR: No build found. Run 'vex build' before starting in production.");
20
19
  process.exit(1);
21
20
  }
22
21
  } else {
@@ -28,7 +27,9 @@ if (process.env.NODE_ENV === "production") {
28
27
 
29
28
  const app = express();
30
29
 
31
- // Serve generated client components at /_vexjs/_components/ (before broader /_vexjs mount)
30
+ // Serve generated client component bundles at /_vexjs/_components/
31
+ // Must be registered before the broader /_vexjs static mount below so that
32
+ // .vexjs/_components/ takes priority over anything in CLIENT_DIR/_components/.
32
33
  app.use(
33
34
  "/_vexjs/_components",
34
35
  express.static(path.join(process.cwd(), ".vexjs", "_components"), {
@@ -40,7 +41,9 @@ app.use(
40
41
  })
41
42
  );
42
43
 
43
- // Serve generated services (e.g. _routes.js) at /_vexjs/services/ (before broader /_vexjs mount)
44
+ // Serve generated services (e.g. _routes.js) at /_vexjs/services/
45
+ // Also before the broader /_vexjs mount so the generated _routes.js
46
+ // overrides any placeholder that might exist in the framework source.
44
47
  app.use(
45
48
  "/_vexjs/services",
46
49
  express.static(path.join(process.cwd(), ".vexjs", "services"), {
@@ -52,7 +55,10 @@ app.use(
52
55
  })
53
56
  );
54
57
 
55
- // Serve framework client files at /_vexjs/
58
+ // Serve framework client runtime files at /_vexjs/
59
+ // (reactive.js, html.js, hydrate.js, navigation/, etc.)
60
+ // User imports like `vex/reactive` are marked external by esbuild and resolved
61
+ // here at runtime — a single shared instance per page load.
56
62
  app.use(
57
63
  "/_vexjs",
58
64
  express.static(CLIENT_DIR, {
@@ -64,48 +70,6 @@ app.use(
64
70
  })
65
71
  );
66
72
 
67
- // Serve user JS utility files at /_vexjs/user/* with import rewriting
68
- app.get("/_vexjs/user/*splat", async (req, res) => {
69
- const splat = req.params.splat;
70
- const relPath = Array.isArray(splat) ? splat.join("/") : splat;
71
- const filePath = path.resolve(path.join(SRC_DIR, relPath));
72
- // Prevent path traversal outside SRC_DIR
73
- if (!filePath.startsWith(SRC_DIR + path.sep) && filePath !== SRC_DIR) {
74
- return res.status(403).send("Forbidden");
75
- }
76
- try {
77
- let content = await fs.readFile(filePath, "utf-8");
78
- // Rewrite imports to browser-accessible paths
79
- content = content.replace(
80
- /^(\s*import\s+[^'"]*from\s+)['"]([^'"]+)['"]/gm,
81
- (match, prefix, modulePath) => {
82
- if (modulePath.startsWith("vex/") || modulePath.startsWith(".app/")) {
83
- let mod = modulePath.replace(/^vex\//, "").replace(/^\.app\//, "");
84
- if (!path.extname(mod)) mod += ".js";
85
- return `${prefix}'/_vexjs/services/${mod}'`;
86
- }
87
- if (modulePath.startsWith("@/") || modulePath === "@") {
88
- let resolved = path.resolve(SRC_DIR, modulePath.replace(/^@\//, "").replace(/^@$/, ""));
89
- if (!path.extname(resolved)) resolved += ".js";
90
- const rel = path.relative(SRC_DIR, resolved).replace(/\\/g, "/");
91
- return `${prefix}'/_vexjs/user/${rel}'`;
92
- }
93
- if (modulePath.startsWith("./") || modulePath.startsWith("../")) {
94
- const fileDir = path.dirname(filePath);
95
- let resolved = path.resolve(fileDir, modulePath);
96
- if (!path.extname(resolved)) resolved += ".js";
97
- const rel = path.relative(SRC_DIR, resolved).replace(/\\/g, "/");
98
- return `${prefix}'/_vexjs/user/${rel}'`;
99
- }
100
- return match;
101
- }
102
- );
103
- res.setHeader("Content-Type", "application/javascript");
104
- res.send(content);
105
- } catch {
106
- res.status(404).send("Not found");
107
- }
108
- });
109
73
 
110
74
  // Serve user's public directory at /
111
75
  app.use("/", express.static(path.join(process.cwd(), "public")));
@@ -1,10 +1,12 @@
1
1
  import { watch } from "fs";
2
2
  import path from "path";
3
+ import esbuild from "esbuild";
3
4
  import { compileTemplateToHTML } from "./template.js";
4
- 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 } from "./files.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";
5
6
  import { renderComponents } from "./streaming.js";
6
7
  import { getRevalidateSeconds } from "./cache.js";
7
8
  import { withCache } from "./data-cache.js";
9
+ import { createVexAliasPlugin } from "./esbuild-plugin.js";
8
10
 
9
11
  /**
10
12
  * Throws a structured redirect error that propagates out of getData and is
@@ -107,7 +109,16 @@ if (process.env.NODE_ENV !== "production") {
107
109
  // 3. Notify connected browsers to reload
108
110
  hmrEmitter.emit("reload", filename);
109
111
  } else if (filename.endsWith(".js")) {
110
- // User utility file changed reload browsers (served dynamically, no bundle to regenerate)
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).
117
+ try {
118
+ await generateComponentsAndFillCache();
119
+ } catch (e) {
120
+ console.error(`[HMR] Rebuild failed after ${filename} change:`, e.message);
121
+ }
111
122
  hmrEmitter.emit("reload", filename);
112
123
  }
113
124
  });
@@ -674,9 +685,22 @@ function convertVueToHtmlTagged(template, clientCode = "") {
674
685
 
675
686
  let result = template.trim();
676
687
 
688
+ // Self-closing x-for="item in items" → ${items.value.map(item => html`<Component ... />`)}
689
+ result = result.replace(
690
+ /<([\w-]+)([^>]*)\s+x-for="(\w+)\s+in\s+([^"]+)(?:\.value)?"([^>]*)\/>/g,
691
+ (_, tag, beforeAttrs, iterVar, arrayVar, afterAttrs) => {
692
+ const cleanExpr = arrayVar.trim();
693
+ const isSimpleVar = /^\w+$/.test(cleanExpr);
694
+ const arrayAccess = isSimpleVar && reactiveVars.has(cleanExpr)
695
+ ? `${cleanExpr}.value`
696
+ : cleanExpr;
697
+ return `\${${arrayAccess}.map(${iterVar} => html\`<${tag}${beforeAttrs}${afterAttrs} />\`)}`;
698
+ }
699
+ );
700
+
677
701
  // x-for="item in items" → ${items.value.map(item => html`...`)}
678
702
  result = result.replace(
679
- /<(\w+)([^>]*)\s+x-for="(\w+)\s+in\s+([^"]+)(?:\.value)?"([^>]*)>([\s\S]*?)<\/\1>/g,
703
+ /<([\w-]+)([^>]*)\s+x-for="(\w+)\s+in\s+([^"]+)(?:\.value)?"([^>]*)>([\s\S]*?)<\/\1>/g,
680
704
  (_, tag, beforeAttrs, iterVar, arrayVar, afterAttrs, content) => {
681
705
  const cleanExpr = arrayVar.trim();
682
706
  const isSimpleVar = /^\w+$/.test(cleanExpr);
@@ -727,98 +751,41 @@ function convertVueToHtmlTagged(template, clientCode = "") {
727
751
 
728
752
 
729
753
  /**
730
- * Normalizes and deduplicates client-side ES module imports,
731
- * ensuring required framework imports are present.
754
+ * Generates and bundles a client-side JS module for a hydrated component using esbuild.
755
+ *
756
+ * Previously this function assembled the output by hand: it collected import statements,
757
+ * deduped them with getClientCodeImports, and concatenated everything into a JS string.
758
+ * That approach had two fundamental limitations:
759
+ * 1. npm package imports (bare specifiers like 'lodash') were left unresolved in the
760
+ * output — the browser has no module resolver and would throw at runtime.
761
+ * 2. Transitive user utility files (@/utils/foo imported by @/utils/bar) were not
762
+ * bundled; they were served on-the-fly at runtime by the /_vexjs/user/* handler,
763
+ * adding an extra network round-trip per utility file on page load.
764
+ *
765
+ * With esbuild the entry source is passed via stdin and esbuild takes care of:
766
+ * - Resolving and inlining @/ user imports and their transitive dependencies
767
+ * - Resolving and bundling npm packages from node_modules
768
+ * - Deduplicating shared modules across the bundle
769
+ * - Writing the final ESM output directly to the destination file
770
+ *
771
+ * Framework singletons (vex/*, .app/*) are intentionally NOT bundled. They are
772
+ * marked external by the vex-aliases plugin so the browser resolves them at runtime
773
+ * from /_vexjs/services/, ensuring a single shared instance per page. Bundling them
774
+ * would give each component its own copy of reactive.js, breaking shared state.
732
775
  *
733
776
  * @async
734
- * @param {Record<string, {
735
- * fileUrl: string,
736
- * originalPath: string,
737
- * importStatement: string
738
- * }>} clientImports
739
- *
740
- * @param {Record<string, string[]>} [requiredImports]
741
- *
742
- * @returns {Promise<string[]>}
743
- * Clean import statements.
744
- */
745
- async function getClientCodeImports(
746
- clientImports,
747
- requiredImports = {
748
- "/_vexjs/services/reactive.js": ["effect"],
749
- "/_vexjs/services/html.js": ["html"],
750
- }
751
- ) {
752
-
753
- // Create a unique set of import statements to avoid duplicates
754
- const cleanImportsSet = new Set(
755
- Object.values(clientImports).map((importData) => importData.importStatement)
756
- );
757
- const cleanImports = Array.from(cleanImportsSet);
758
-
759
- for (const [modulePath, requiredModules] of Object.entries(requiredImports)) {
760
- const importIndex = cleanImports.findIndex((imp) =>
761
- new RegExp(`from\\s+['"]${modulePath}['"]`).test(imp)
762
- );
763
-
764
- if (importIndex === -1) {
765
- cleanImports.push(
766
- `import { ${requiredModules.join(", ")} } from '${modulePath}';`
767
- );
768
- } else {
769
- // if import exists, ensure it includes all required symbols
770
- const existingImport = cleanImports[importIndex];
771
- const importMatch = existingImport.match(/\{([^}]+)\}/);
772
-
773
- if (importMatch) {
774
- const importedModules = importMatch[1].split(",").map((s) => s.trim());
775
- // Determine which required modules are missing
776
- const missingModules = requiredModules.filter(
777
- (s) => !importedModules.includes(s)
778
- );
779
- if (missingModules.length > 0) {
780
- // Add missing symbols and reconstruct the import statement
781
- importedModules.push(...missingModules);
782
- cleanImports[importIndex] = existingImport.replace(
783
- /\{[^}]+\}/,
784
- `{ ${importedModules.join(", ")} }`
785
- );
786
- }
787
- } else {
788
- // If no named imports, convert to named imports
789
- cleanImports[importIndex] = `import { ${requiredModules.join(
790
- ", "
791
- )} } from '${modulePath}';`;
792
- }
793
- }
794
- }
795
-
796
- // Return the final list of import statements
797
- return cleanImports;
798
- }
799
-
800
- /**
801
- * Generates a client-side JS module for a hydrated component.
802
- *
803
- * The module:
804
- * - Includes required imports
805
- * - Injects default props
806
- * - Exports metadata
807
- * - Exposes a hydration entry point
808
- *
809
- * @async
810
- * @param {string} clientCode
811
- * @param {string} template
812
- * @param {object} metadata
813
- * @param {Record<string, {
814
- * fileUrl: string,
815
- * originalPath: string,
816
- * importStatement: string
817
- * }>} clientImports
818
- *
777
+ * @param {{
778
+ * clientCode: string,
779
+ * template: string,
780
+ * metadata: object,
781
+ * clientImports: Record<string, { originalImportStatement: string }>,
782
+ * clientComponents: Map<string, any>,
783
+ * componentFilePath: string,
784
+ * componentName: string,
785
+ * }} params
819
786
  *
820
- * @returns {Promise<string|null>}
821
- * Generated JS module code or null if no client code exists.
787
+ * @returns {Promise<null>}
788
+ * Always returns null esbuild writes the bundle directly to disk.
822
789
  */
823
790
  export async function generateClientComponentModule({
824
791
  clientCode,
@@ -826,56 +793,96 @@ export async function generateClientComponentModule({
826
793
  metadata,
827
794
  clientImports,
828
795
  clientComponents,
796
+ componentFilePath,
797
+ componentName,
829
798
  }) {
799
+ if (!clientCode && !template) return null;
830
800
 
831
- // Extract default props from xprops
801
+ // ── 1. Resolve default props from xprops() ─────────────────────────────────
832
802
  const defaults = extractVPropsDefaults(clientCode);
833
-
834
803
  const clientCodeWithProps = addComputedProps(clientCode, defaults);
835
804
 
836
- // Remove xprops declaration and imports from client code
805
+ // ── 2. Build the function body: remove xprops declaration and import lines ──
806
+ // Imports are hoisted to module level in the entry source (step 4).
837
807
  const cleanClientCode = clientCodeWithProps
838
808
  .replace(/const\s+props\s*=\s*xprops\s*\([\s\S]*?\)\s*;?/g, "")
839
809
  .replace(/^\s*import\s+.*$/gm, "")
840
810
  .trim();
841
811
 
842
- // Convert template
812
+ // ── 3. Convert Vue-like template syntax to html`` tagged template ───────────
843
813
  const convertedTemplate = convertVueToHtmlTagged(template, clientCodeWithProps);
814
+ const { html: processedHtml } = await renderComponents({ html: convertedTemplate, clientComponents });
815
+
816
+ // ── 4. Collect module-level imports for the esbuild entry source ────────────
817
+ // Use originalImportStatement (the specifier as written by the developer, before
818
+ // any path rewriting). esbuild receives the original specifiers and the alias
819
+ // plugin translates them at bundle time — no pre-rewriting needed here.
820
+ const importLines = new Set(
821
+ Object.values(clientImports)
822
+ .map((ci) => ci.originalImportStatement)
823
+ .filter(Boolean)
824
+ );
844
825
 
845
- const { html: processedHtml } = await renderComponents({
846
- html: convertedTemplate,
847
- clientComponents,
848
- });
849
-
850
- const cleanImports = await getClientCodeImports(clientImports);
851
-
852
- const clientComponentModule = `
853
- ${cleanImports.join("\n")}
854
-
855
- export const metadata = ${JSON.stringify(metadata)}
856
-
857
- export function hydrateClientComponent(marker, incomingProps = {}) {
858
- ${cleanClientCode}
859
-
860
- let root = null;
861
- function render() {
862
- const node = html\`${processedHtml}\`;
863
- if (!root) {
864
- root = node;
865
- marker.replaceWith(node);
866
- } else {
867
- root.replaceWith(node);
868
- root = node;
869
- }
870
- }
871
-
872
- effect(() => render());
873
-
874
- return root;
826
+ // Ensure effect and html are always available in the component body.
827
+ // If the developer already imported them the alias plugin's deduplication
828
+ // in esbuild's module graph handles the overlap — no duplicate at runtime.
829
+ const hasEffect = [...importLines].some((l) => /\beffect\b/.test(l));
830
+ const hasHtml = [...importLines].some((l) => /\bhtml\b/.test(l));
831
+ if (!hasEffect) importLines.add("import { effect } from 'vex/reactive';");
832
+ if (!hasHtml) importLines.add("import { html } from 'vex/html';");
833
+
834
+ // ── 5. Assemble the esbuild entry source ────────────────────────────────────
835
+ // This is a valid ESM module that esbuild will bundle. Imports at the top,
836
+ // hydrateClientComponent exported as a named function.
837
+ const entrySource = `
838
+ ${[...importLines].join("\n")}
839
+
840
+ export const metadata = ${JSON.stringify(metadata)};
841
+
842
+ export function hydrateClientComponent(marker, incomingProps = {}) {
843
+ ${cleanClientCode}
844
+
845
+ let root = null;
846
+ function render() {
847
+ const node = html\`${processedHtml}\`;
848
+ if (!root) {
849
+ root = node;
850
+ marker.replaceWith(node);
851
+ } else {
852
+ root.replaceWith(node);
853
+ root = node;
875
854
  }
876
- `;
855
+ }
856
+
857
+ effect(() => render());
858
+ return root;
859
+ }
860
+ `.trim();
861
+
862
+ // ── 6. Bundle with esbuild ──────────────────────────────────────────────────
863
+ // stdin mode: esbuild receives the generated source as a virtual file.
864
+ // resolveDir tells esbuild which directory to use when resolving relative
865
+ // imports — it must be the .vex source file's directory so that './utils/foo'
866
+ // resolves relative to where the developer wrote the import, not relative to
867
+ // the framework's internal directories.
868
+ const outfile = path.join(CLIENT_COMPONENTS_DIR, `${componentName}.js`);
869
+
870
+ await esbuild.build({
871
+ stdin: {
872
+ contents: entrySource,
873
+ resolveDir: componentFilePath ? path.dirname(componentFilePath) : CLIENT_COMPONENTS_DIR,
874
+ },
875
+ bundle: true,
876
+ outfile,
877
+ format: "esm",
878
+ platform: "browser",
879
+ plugins: [createVexAliasPlugin()],
880
+ // Silence esbuild's default stdout logging — the framework has its own output
881
+ logLevel: "silent",
882
+ });
877
883
 
878
- return clientComponentModule.trim();
884
+ // esbuild wrote directly to outfile — no string to return
885
+ return null;
879
886
  }
880
887
 
881
888
  /**
@@ -994,12 +1001,38 @@ export async function processClientComponent(componentName, originalPath, props
994
1001
  const targetId = `client-${componentName}-${Date.now()}`;
995
1002
 
996
1003
  const componentImport = generateComponentId(originalPath)
997
- const propsJson = JSON.stringify(props);
1004
+ const propsJson = serializeClientComponentProps(props);
998
1005
  const html = `<template id="${targetId}" data-client:component="${componentImport}" data-client:props='${propsJson}'></template>`;
999
1006
 
1000
1007
  return html;
1001
1008
  }
1002
1009
 
1010
+ function isTemplateExpression(value) {
1011
+ return typeof value === "string" && /^\$\{[\s\S]+\}$/.test(value.trim());
1012
+ }
1013
+
1014
+ function serializeRuntimePropValue(value) {
1015
+ if (!isTemplateExpression(value)) {
1016
+ return JSON.stringify(value);
1017
+ }
1018
+
1019
+ return value.trim().slice(2, -1).trim();
1020
+ }
1021
+
1022
+ function serializeClientComponentProps(props = {}) {
1023
+ const hasDynamicValues = Object.values(props).some(isTemplateExpression);
1024
+
1025
+ if (!hasDynamicValues) {
1026
+ return JSON.stringify(props);
1027
+ }
1028
+
1029
+ const serializedEntries = Object.entries(props).map(([key, value]) => {
1030
+ return `${JSON.stringify(key)}: ${serializeRuntimePropValue(value)}`;
1031
+ });
1032
+
1033
+ return `\${JSON.stringify({ ${serializedEntries.join(", ")} })}`;
1034
+ }
1035
+
1003
1036
  /**
1004
1037
  * Extract xprops object literal from client code
1005
1038
  * @param {string} clientCode
@@ -1142,22 +1175,24 @@ function fillRoute(route, params) {
1142
1175
  });
1143
1176
  }
1144
1177
  /**
1145
- *
1146
- * Generates js module and save it in public directory.
1147
- *
1178
+ * Generates and saves the client-side JS bundle for a component.
1179
+ *
1180
+ * Delegates to generateClientComponentModule, which uses esbuild to bundle
1181
+ * the component's <script client> code into a self-contained ESM file written
1182
+ * directly to .vexjs/_components/<componentName>.js.
1183
+ *
1184
+ * componentFilePath is required so esbuild can resolve relative imports
1185
+ * (./utils/foo) from the correct base directory.
1186
+ *
1148
1187
  * @param {{
1149
- * metadata: object,
1150
- * clientCode: string,
1151
- * template: string,
1152
- * clientImports: Record<string, {
1153
- * fileUrl: string,
1154
- * originalPath: string,
1155
- * importStatement: string
1156
- * }>,
1157
- * clientComponents: Record<string, any>,
1158
- * componentName: string,
1159
- * }}
1160
- *
1188
+ * metadata: object,
1189
+ * clientCode: string,
1190
+ * template: string,
1191
+ * clientImports: Record<string, { originalImportStatement: string }>,
1192
+ * clientComponents: Map<string, any>,
1193
+ * componentName: string,
1194
+ * componentFilePath: string,
1195
+ * }} params
1161
1196
  * @returns {Promise<void>}
1162
1197
  */
1163
1198
  async function saveClientComponent({
@@ -1167,18 +1202,17 @@ async function saveClientComponent({
1167
1202
  clientImports,
1168
1203
  clientComponents,
1169
1204
  componentName,
1205
+ componentFilePath,
1170
1206
  }) {
1171
- const jsModuleCode = await generateClientComponentModule({
1207
+ await generateClientComponentModule({
1172
1208
  metadata,
1173
1209
  clientCode,
1174
1210
  template,
1175
1211
  clientImports,
1176
1212
  clientComponents,
1213
+ componentFilePath,
1214
+ componentName,
1177
1215
  });
1178
-
1179
- if (jsModuleCode) {
1180
- await saveClientComponentModule(componentName, jsModuleCode)
1181
- }
1182
1216
  }
1183
1217
 
1184
1218
  /**x
@@ -1250,6 +1284,7 @@ async function generateComponentAndFillCache(filePath) {
1250
1284
  clientImports,
1251
1285
  clientComponents,
1252
1286
  componentName: generateComponentId(cacheKey),
1287
+ componentFilePath: filePath,
1253
1288
  }))
1254
1289
  }
1255
1290
  }
@@ -1263,6 +1298,7 @@ async function generateComponentAndFillCache(filePath) {
1263
1298
  clientImports,
1264
1299
  clientComponents,
1265
1300
  componentName: generateComponentId(urlPath),
1301
+ componentFilePath: filePath,
1266
1302
  }))
1267
1303
  }
1268
1304
 
@@ -0,0 +1,86 @@
1
+ import path from "path";
2
+ import { SRC_DIR } from "./files.js";
3
+
4
+ /**
5
+ * Creates the VexJS esbuild alias plugin.
6
+ *
7
+ * esbuild resolves imports by looking at the specifier string (e.g. "vex/reactive",
8
+ * "./utils/counter", "lodash"). By default it only understands relative paths and
9
+ * node_modules. This plugin teaches esbuild about the three VexJS-specific import
10
+ * conventions so it can correctly bundle every <script client> block.
11
+ *
12
+ * The plugin intercepts imports at bundle time via onResolve hooks — each hook
13
+ * matches a filter regex against the import specifier and returns either:
14
+ * - { path, external: true } → esbuild leaves the import as-is in the output.
15
+ * The browser resolves it at runtime from the URL.
16
+ * - { path } → esbuild reads and inlines the file into the bundle.
17
+ *
18
+ * ─── Three categories of imports ────────────────────────────────────────────
19
+ *
20
+ * 1. Framework singletons (vex/* and .app/*)
21
+ * Examples: `import { reactive } from 'vex/reactive'`
22
+ * `import { html } from '.app/html'`
23
+ *
24
+ * These are framework runtime files served statically at /_vexjs/services/.
25
+ * They MUST be marked external so every component shares the same instance
26
+ * at runtime. If esbuild inlined them, each component bundle would get its
27
+ * own copy of reactive.js — reactive state would not be shared across
28
+ * components on the same page and the entire reactivity system would break.
29
+ *
30
+ * The path is rewritten from the short alias to the browser-accessible URL:
31
+ * vex/reactive → /_vexjs/services/reactive.js (external)
32
+ *
33
+ * 2. Project alias (@/*)
34
+ * Example: `import { counter } from '@/utils/counter'`
35
+ *
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.
39
+ *
40
+ * 3. Relative imports (./ and ../)
41
+ * Example: `import { fn } from './helpers'`
42
+ *
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.
46
+ *
47
+ * 4. npm packages (bare specifiers like 'lodash', 'date-fns')
48
+ * Also resolved automatically by esbuild via node_modules lookup.
49
+ * No custom hook is needed.
50
+ *
51
+ * @returns {import('esbuild').Plugin}
52
+ */
53
+ export function createVexAliasPlugin() {
54
+ return {
55
+ name: "vex-aliases",
56
+ setup(build) {
57
+ // ── Category 1a: vex/* ────────────────────────────────────────────────
58
+ // Matches: 'vex/reactive', 'vex/html', 'vex/navigation', etc.
59
+ // Rewrites to the browser URL and marks external so esbuild skips bundling.
60
+ build.onResolve({ filter: /^vex\// }, (args) => {
61
+ let mod = args.path.replace(/^vex\//, "");
62
+ if (!path.extname(mod)) mod += ".js";
63
+ return { path: `/_vexjs/services/${mod}`, external: true };
64
+ });
65
+
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\//, "");
71
+ if (!path.extname(mod)) mod += ".js";
72
+ return { path: `/_vexjs/services/${mod}`, external: true };
73
+ });
74
+
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));
81
+ if (!path.extname(resolved)) resolved += ".js";
82
+ return { path: resolved };
83
+ });
84
+ },
85
+ };
86
+ }
@@ -30,19 +30,20 @@ import {
30
30
  */
31
31
  function parseAttributes(rawAttrs) {
32
32
  const attrs = {};
33
- const regex = /:(\w+)=['"]([^'"]+)['"]|@(\w+)=['"]([^'"]+)['"]|(\w+)=['"]([^'"]+)['"]/g;
33
+ const regex =
34
+ /:([\w-]+)=(?:"([^"]*)"|'([^']*)')|@([\w-]+)=(?:"([^"]*)"|'([^']*)')|([\w:-]+)=(?:"([^"]*)"|'([^']*)')/g;
34
35
  let match;
35
36
 
36
37
  while ((match = regex.exec(rawAttrs)) !== null) {
37
38
  if (match[1]) {
38
39
  // Dynamic prop :prop
39
- attrs[match[1]] = match[2];
40
- } else if (match[3]) {
40
+ attrs[match[1]] = match[2] ?? match[3] ?? "";
41
+ } else if (match[4]) {
41
42
  // Event handler @event
42
- attrs[match[3]] = match[4];
43
- } else if (match[5]) {
43
+ attrs[match[4]] = match[5] ?? match[6] ?? "";
44
+ } else if (match[7]) {
44
45
  // Static prop
45
- attrs[match[5]] = match[6];
46
+ attrs[match[7]] = match[8] ?? match[9] ?? "";
46
47
  }
47
48
  }
48
49