@cfdez11/vex 0.4.0 → 0.6.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.
@@ -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.4.0",
3
+ "version": "0.6.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,8 @@
22
22
  "license": "MIT",
23
23
  "dependencies": {
24
24
  "dom-serializer": "^2.0.0",
25
+ "dotenv": "^16.0.0",
26
+ "esbuild": "^0.25.0",
25
27
  "express": "^5.2.1",
26
28
  "htmlparser2": "^10.0.0"
27
29
  }
@@ -1,3 +1,4 @@
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";
package/server/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import fs from "fs/promises";
1
+ import "dotenv/config";
2
2
  import express from "express";
3
3
  import path from "path";
4
4
  import { pathToFileURL } from "url";
5
5
  import { handlePageRequest, revalidatePath } from "./utils/router.js";
6
- import { initializeDirectories, CLIENT_DIR, SRC_DIR } from "./utils/files.js";
6
+ import { initializeDirectories, CLIENT_DIR } from "./utils/files.js";
7
7
 
8
8
  await initializeDirectories();
9
9
 
@@ -16,7 +16,7 @@ if (process.env.NODE_ENV === "production") {
16
16
  serverRoutes = routes;
17
17
  console.log("Routes loaded.");
18
18
  } catch {
19
- console.error("ERROR: No build found. Run 'pnpm build' before starting in production.");
19
+ console.error("ERROR: No build found. Run 'vex build' before starting in production.");
20
20
  process.exit(1);
21
21
  }
22
22
  } else {
@@ -28,7 +28,9 @@ if (process.env.NODE_ENV === "production") {
28
28
 
29
29
  const app = express();
30
30
 
31
- // Serve generated client components at /_vexjs/_components/ (before broader /_vexjs mount)
31
+ // Serve generated client component bundles at /_vexjs/_components/
32
+ // Must be registered before the broader /_vexjs static mount below so that
33
+ // .vexjs/_components/ takes priority over anything in CLIENT_DIR/_components/.
32
34
  app.use(
33
35
  "/_vexjs/_components",
34
36
  express.static(path.join(process.cwd(), ".vexjs", "_components"), {
@@ -40,7 +42,9 @@ app.use(
40
42
  })
41
43
  );
42
44
 
43
- // Serve generated services (e.g. _routes.js) at /_vexjs/services/ (before broader /_vexjs mount)
45
+ // Serve generated services (e.g. _routes.js) at /_vexjs/services/
46
+ // Also before the broader /_vexjs mount so the generated _routes.js
47
+ // overrides any placeholder that might exist in the framework source.
44
48
  app.use(
45
49
  "/_vexjs/services",
46
50
  express.static(path.join(process.cwd(), ".vexjs", "services"), {
@@ -52,7 +56,10 @@ app.use(
52
56
  })
53
57
  );
54
58
 
55
- // Serve framework client files at /_vexjs/
59
+ // Serve framework client runtime files at /_vexjs/
60
+ // (reactive.js, html.js, hydrate.js, navigation/, etc.)
61
+ // User imports like `vex/reactive` are marked external by esbuild and resolved
62
+ // here at runtime — a single shared instance per page load.
56
63
  app.use(
57
64
  "/_vexjs",
58
65
  express.static(CLIENT_DIR, {
@@ -64,48 +71,6 @@ app.use(
64
71
  })
65
72
  );
66
73
 
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
74
 
110
75
  // Serve user's public directory at /
111
76
  app.use("/", express.static(path.join(process.cwd(), "public")));
@@ -151,7 +116,7 @@ app.use(async (req, res) => {
151
116
  res.status(404).send("Page not found");
152
117
  });
153
118
 
154
- const PORT = process.env.PORT || 3001;
119
+ const PORT = process.env.VEX_PORT || process.env.PORT || 3001;
155
120
  app.listen(PORT, () => {
156
121
  console.log(`Server running on port ${PORT}`);
157
122
  });
@@ -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,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
  });
@@ -166,7 +177,7 @@ const getScriptImports = async (script, isClientSide = false, filePath = null) =
166
177
  while ((match = importRegex.exec(script)) !== null) {
167
178
  const [importStatement, defaultImport, namedImports, modulePath] = match;
168
179
 
169
- const { path, fileUrl } = await getImportData(modulePath);
180
+ const { path, fileUrl } = await getImportData(modulePath, filePath);
170
181
 
171
182
  if (path.endsWith(".vex")) {
172
183
  // Recursively process HTML component
@@ -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
  /**
@@ -982,7 +989,7 @@ async function generateServerComponentHTML(componentPath) {
982
989
  * and runtime interpolations (e.g., `${variable}`).
983
990
  *
984
991
  * @param {string} componentName - The logical name of the component.
985
- * @param {string} originalPath - The original file path of the component.
992
+ * @param {string} componentAbsPath - The absolute file path of the component (resolved by getImportData).
986
993
  * @param {Record<string, any>} [props={}] - An object of props to pass to the component.
987
994
  * Values can be literals or template
988
995
  * interpolations (`${…}`) for dynamic evaluation.
@@ -990,16 +997,45 @@ async function generateServerComponentHTML(componentPath) {
990
997
  * @returns {Promise<string>} A promise that resolves to a string containing
991
998
  * the `<template>` HTML for hydration.
992
999
  */
993
- export async function processClientComponent(componentName, originalPath, props = {}) {
1000
+ export async function processClientComponent(componentName, componentAbsPath, props = {}) {
994
1001
  const targetId = `client-${componentName}-${Date.now()}`;
995
1002
 
996
- const componentImport = generateComponentId(originalPath)
997
- const propsJson = JSON.stringify(props);
1003
+ // componentAbsPath is the absolute resolved path — generateComponentId strips ROOT_DIR
1004
+ // internally, so this produces the same hash as the bundle filename written by
1005
+ // generateComponentAndFillCache (which also calls generateComponentId with the abs path).
1006
+ const componentImport = generateComponentId(componentAbsPath);
1007
+ const propsJson = serializeClientComponentProps(props);
998
1008
  const html = `<template id="${targetId}" data-client:component="${componentImport}" data-client:props='${propsJson}'></template>`;
999
1009
 
1000
1010
  return html;
1001
1011
  }
1002
1012
 
1013
+ function isTemplateExpression(value) {
1014
+ return typeof value === "string" && /^\$\{[\s\S]+\}$/.test(value.trim());
1015
+ }
1016
+
1017
+ function serializeRuntimePropValue(value) {
1018
+ if (!isTemplateExpression(value)) {
1019
+ return JSON.stringify(value);
1020
+ }
1021
+
1022
+ return value.trim().slice(2, -1).trim();
1023
+ }
1024
+
1025
+ function serializeClientComponentProps(props = {}) {
1026
+ const hasDynamicValues = Object.values(props).some(isTemplateExpression);
1027
+
1028
+ if (!hasDynamicValues) {
1029
+ return JSON.stringify(props);
1030
+ }
1031
+
1032
+ const serializedEntries = Object.entries(props).map(([key, value]) => {
1033
+ return `${JSON.stringify(key)}: ${serializeRuntimePropValue(value)}`;
1034
+ });
1035
+
1036
+ return `\${JSON.stringify({ ${serializedEntries.join(", ")} })}`;
1037
+ }
1038
+
1003
1039
  /**
1004
1040
  * Extract xprops object literal from client code
1005
1041
  * @param {string} clientCode
@@ -1142,22 +1178,24 @@ function fillRoute(route, params) {
1142
1178
  });
1143
1179
  }
1144
1180
  /**
1145
- *
1146
- * Generates js module and save it in public directory.
1147
- *
1181
+ * Generates and saves the client-side JS bundle for a component.
1182
+ *
1183
+ * Delegates to generateClientComponentModule, which uses esbuild to bundle
1184
+ * the component's <script client> code into a self-contained ESM file written
1185
+ * directly to .vexjs/_components/<componentName>.js.
1186
+ *
1187
+ * componentFilePath is required so esbuild can resolve relative imports
1188
+ * (./utils/foo) from the correct base directory.
1189
+ *
1148
1190
  * @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
- *
1191
+ * metadata: object,
1192
+ * clientCode: string,
1193
+ * template: string,
1194
+ * clientImports: Record<string, { originalImportStatement: string }>,
1195
+ * clientComponents: Map<string, any>,
1196
+ * componentName: string,
1197
+ * componentFilePath: string,
1198
+ * }} params
1161
1199
  * @returns {Promise<void>}
1162
1200
  */
1163
1201
  async function saveClientComponent({
@@ -1167,18 +1205,17 @@ async function saveClientComponent({
1167
1205
  clientImports,
1168
1206
  clientComponents,
1169
1207
  componentName,
1208
+ componentFilePath,
1170
1209
  }) {
1171
- const jsModuleCode = await generateClientComponentModule({
1210
+ await generateClientComponentModule({
1172
1211
  metadata,
1173
1212
  clientCode,
1174
1213
  template,
1175
1214
  clientImports,
1176
1215
  clientComponents,
1216
+ componentFilePath,
1217
+ componentName,
1177
1218
  });
1178
-
1179
- if (jsModuleCode) {
1180
- await saveClientComponentModule(componentName, jsModuleCode)
1181
- }
1182
1219
  }
1183
1220
 
1184
1221
  /**x
@@ -1250,6 +1287,7 @@ async function generateComponentAndFillCache(filePath) {
1250
1287
  clientImports,
1251
1288
  clientComponents,
1252
1289
  componentName: generateComponentId(cacheKey),
1290
+ componentFilePath: filePath,
1253
1291
  }))
1254
1292
  }
1255
1293
  }
@@ -1263,6 +1301,7 @@ async function generateComponentAndFillCache(filePath) {
1263
1301
  clientImports,
1264
1302
  clientComponents,
1265
1303
  componentName: generateComponentId(urlPath),
1304
+ componentFilePath: filePath,
1266
1305
  }))
1267
1306
  }
1268
1307
 
@@ -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
+ }
@@ -811,7 +811,7 @@ 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/"));
@@ -821,6 +821,11 @@ export async function getImportData(importPath) {
821
821
  resolvedPath = path.resolve(FRAMEWORK_DIR, importPath.replace(".app/server/", "server/"));
822
822
  } else if (importPath.startsWith(".app/")) {
823
823
  resolvedPath = path.resolve(FRAMEWORK_DIR, importPath.replace(".app/", ""));
824
+ } else if ((importPath.startsWith("./") || importPath.startsWith("../")) && callerFilePath) {
825
+ // Relative import — resolve against the caller component's directory, not ROOT_DIR.
826
+ // Without this, `import Foo from './foo.vex'` inside a nested component would be
827
+ // resolved from the project root instead of from the file that contains the import.
828
+ resolvedPath = path.resolve(path.dirname(callerFilePath), importPath);
824
829
  } else {
825
830
  resolvedPath = path.resolve(ROOT_DIR, importPath);
826
831
  }
@@ -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
 
@@ -127,7 +128,7 @@ async function renderClientComponents(html, clientComponents) {
127
128
  let processedHtml = html;
128
129
  const allScripts = [];
129
130
 
130
- for (const [componentName, { originalPath }] of clientComponents.entries()) {
131
+ for (const [componentName, { path: componentAbsPath }] of clientComponents.entries()) {
131
132
  const escapedName = componentName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
132
133
 
133
134
  const componentRegex = new RegExp(
@@ -155,7 +156,7 @@ async function renderClientComponents(html, clientComponents) {
155
156
  for (let i = replacements.length - 1; i >= 0; i--) {
156
157
  const { start, end, attrs } = replacements[i];
157
158
 
158
- const htmlComponent = await processClientComponent(componentName, originalPath, attrs);
159
+ const htmlComponent = await processClientComponent(componentName, componentAbsPath, attrs);
159
160
 
160
161
  processedHtml =
161
162
  processedHtml.slice(0, start) +