@cfdez11/vex 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfdez11/vex",
3
- "version": "0.4.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
  }
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