@cfdez11/vex 0.2.1 → 0.4.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
 
@@ -89,6 +89,14 @@ export function reactive(obj) {
89
89
  get(target, prop) {
90
90
  // Handle primitive value conversion (for template literals, etc.)
91
91
  if (target.__isPrimitive && prop === Symbol.toPrimitive) {
92
+ // Track "value" dependency so effects using ${counter} re-run on change
93
+ if (activeEffect) {
94
+ if (!depsMap.has("value")) depsMap.set("value", new Set());
95
+ const depSet = depsMap.get("value");
96
+ depSet.add(activeEffect);
97
+ if (!activeEffect.deps) activeEffect.deps = [];
98
+ activeEffect.deps.push(depSet);
99
+ }
92
100
  return () => target.value;
93
101
  }
94
102
 
@@ -180,11 +188,19 @@ export function computed(getter) {
180
188
  value = getter();
181
189
  });
182
190
 
183
- return {
184
- get value() {
185
- return value;
191
+ return new Proxy({}, {
192
+ get(_, prop) {
193
+ if (prop === Symbol.toPrimitive) {
194
+ return () => value;
195
+ }
196
+ if (prop === "value") {
197
+ return value;
198
+ }
199
+ // Delegate any other access (e.g. .map, .length) to the underlying value
200
+ const v = value?.[prop];
201
+ return typeof v === "function" ? v.bind(value) : v;
186
202
  },
187
- };
203
+ });
188
204
  }
189
205
 
190
206
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfdez11/vex",
3
- "version": "0.2.1",
3
+ "version": "0.4.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",
@@ -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,8 +1,9 @@
1
+ import fs from "fs/promises";
1
2
  import express from "express";
2
3
  import path from "path";
3
4
  import { pathToFileURL } from "url";
4
5
  import { handlePageRequest, revalidatePath } from "./utils/router.js";
5
- import { initializeDirectories, CLIENT_DIR } from "./utils/files.js";
6
+ import { initializeDirectories, CLIENT_DIR, SRC_DIR } from "./utils/files.js";
6
7
 
7
8
  await initializeDirectories();
8
9
 
@@ -63,6 +64,49 @@ app.use(
63
64
  })
64
65
  );
65
66
 
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
+
66
110
  // Serve user's public directory at /
67
111
  app.use("/", express.static(path.join(process.cwd(), "public")));
68
112
 
@@ -1,7 +1,7 @@
1
1
  import { watch } from "fs";
2
2
  import path from "path";
3
3
  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 } from "./files.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
5
  import { renderComponents } from "./streaming.js";
6
6
  import { getRevalidateSeconds } from "./cache.js";
7
7
  import { withCache } from "./data-cache.js";
@@ -83,8 +83,14 @@ if (process.env.NODE_ENV !== "production") {
83
83
  // Watch SRC_DIR (configured via vex.config.json `srcDir`, defaults to project root).
84
84
  // Skip any path segment that appears in WATCH_IGNORE to avoid reacting to
85
85
  // changes inside node_modules, build outputs, or other non-source directories.
86
+ // Individual file patterns can be excluded via `watchIgnoreFiles` in vex.config.json.
86
87
  watch(SRC_DIR, { recursive: true }, async (_, filename) => {
87
- if (filename?.endsWith(".vex") && !filename.split(path.sep).some(part => WATCH_IGNORE.has(part))) {
88
+ if (!filename) return;
89
+ if (filename.split(path.sep).some(part => WATCH_IGNORE.has(part))) return;
90
+ const normalizedFilename = filename.replace(/\\/g, "/");
91
+ if (WATCH_IGNORE_FILES.some(pattern => path.matchesGlob(normalizedFilename, pattern))) return;
92
+
93
+ if (filename.endsWith(".vex")) {
88
94
  const fullPath = path.join(SRC_DIR, filename);
89
95
 
90
96
  // 1. Evict all in-memory caches for this file
@@ -100,6 +106,9 @@ if (process.env.NODE_ENV !== "production") {
100
106
 
101
107
  // 3. Notify connected browsers to reload
102
108
  hmrEmitter.emit("reload", filename);
109
+ } else if (filename.endsWith(".js")) {
110
+ // User utility file changed — reload browsers (served dynamically, no bundle to regenerate)
111
+ hmrEmitter.emit("reload", filename);
103
112
  }
104
113
  });
105
114
 
@@ -145,7 +154,7 @@ const DEFAULT_METADATA = {
145
154
  * }>
146
155
  * }>}
147
156
  */
148
- const getScriptImports = async (script, isClientSide = false) => {
157
+ const getScriptImports = async (script, isClientSide = false, filePath = null) => {
149
158
  const componentRegistry = new Map();
150
159
  const imports = {};
151
160
  const clientImports = {};
@@ -182,21 +191,22 @@ const getScriptImports = async (script, isClientSide = false) => {
182
191
  }
183
192
  } else if (defaultImport) {
184
193
  // client side default imports and named imports
185
- const adjustedClientModule = adjustClientModulePath(modulePath, importStatement);
194
+ const adjustedClientModule = adjustClientModulePath(modulePath, importStatement, filePath);
186
195
  clientImports[defaultImport || namedImports] = {
187
196
  fileUrl,
188
197
  originalPath: adjustedClientModule.path,
189
198
  importStatement: adjustedClientModule.importStatement,
199
+ originalImportStatement: importStatement,
190
200
  };
191
201
  } else {
192
202
  namedImports.split(",").forEach((name) => {
193
203
  const trimmedName = name.trim();
194
- // if import module path is .app/file_name.js add .app/client/services/file_name.js
195
- const adjustedClientModule = adjustClientModulePath(modulePath, importStatement);
204
+ const adjustedClientModule = adjustClientModulePath(modulePath, importStatement, filePath);
196
205
  clientImports[trimmedName] = {
197
- fileUrl,
206
+ fileUrl,
198
207
  originalPath: adjustedClientModule.path,
199
208
  importStatement: adjustedClientModule.importStatement,
209
+ originalImportStatement: importStatement,
200
210
  };
201
211
  });
202
212
  }
@@ -311,7 +321,7 @@ async function _processHtmlFile(filePath) {
311
321
  }
312
322
 
313
323
  if (clientMatch) {
314
- const { componentRegistry, clientImports: newClientImports } = await getScriptImports(clientMatch[1], true);
324
+ const { componentRegistry, clientImports: newClientImports } = await getScriptImports(clientMatch[1], true, filePath);
315
325
  clientComponents = componentRegistry;
316
326
  clientImports = newClientImports;
317
327
  }
@@ -412,6 +422,7 @@ export async function renderHtmlFile(filePath, context = {}, extraComponentData
412
422
  */
413
423
  function generateClientScriptTags({
414
424
  clientCode,
425
+ clientImports = {},
415
426
  clientComponentsScripts = [],
416
427
  clientComponents = new Map(),
417
428
  }) {
@@ -421,6 +432,13 @@ function generateClientScriptTags({
421
432
  clientCode = clientCode.replace(`${importStatement};`, '').replace(importStatement, "");
422
433
  }
423
434
 
435
+ // Rewrite framework and user utility imports to browser-accessible paths
436
+ for (const importData of Object.values(clientImports)) {
437
+ if (importData.originalImportStatement && importData.importStatement !== importData.originalImportStatement) {
438
+ clientCode = clientCode.replace(importData.originalImportStatement, importData.importStatement);
439
+ }
440
+ }
441
+
424
442
  const clientCodeWithoutComponentImports = clientCode
425
443
  .split("\n")
426
444
  .filter((line) => !/^\s*import\s+.*['"].*\.vex['"]/.test(line))
@@ -459,11 +477,12 @@ function generateClientScriptTags({
459
477
  * }>
460
478
  */
461
479
  async function renderPage(pagePath, ctx, awaitSuspenseComponents = false, extraComponentData = {}) {
462
- const {
463
- html,
464
- metadata,
465
- clientCode,
466
- serverComponents,
480
+ const {
481
+ html,
482
+ metadata,
483
+ clientCode,
484
+ clientImports,
485
+ serverComponents,
467
486
  clientComponents,
468
487
  } = await renderHtmlFile(pagePath, ctx, extraComponentData);
469
488
 
@@ -482,6 +501,7 @@ async function renderPage(pagePath, ctx, awaitSuspenseComponents = false, extraC
482
501
  html: htmlWithComponents,
483
502
  metadata,
484
503
  clientCode,
504
+ clientImports,
485
505
  serverComponents,
486
506
  clientComponents,
487
507
  suspenseComponents,
@@ -582,6 +602,7 @@ export async function renderPageWithLayout(pagePath, ctx = {}, awaitSuspenseComp
582
602
  html: pageHtml,
583
603
  metadata,
584
604
  clientCode,
605
+ clientImports,
585
606
  serverComponents,
586
607
  clientComponents,
587
608
  suspenseComponents,
@@ -592,6 +613,7 @@ export async function renderPageWithLayout(pagePath, ctx = {}, awaitSuspenseComp
592
613
  // Wrap in layout
593
614
  const clientScripts = generateClientScriptTags({
594
615
  clientCode,
616
+ clientImports,
595
617
  clientComponentsScripts,
596
618
  clientComponents,
597
619
  });
@@ -85,10 +85,24 @@ export const WATCH_IGNORE = new Set([
85
85
  ".next", ".nuxt", ".svelte-kit", ".astro",
86
86
  // misc
87
87
  "tmp", "temp", ".cache", ".claude",
88
- // user-defined extras from vex.config.json
89
- ...(_vexConfig.watchIgnore || []),
88
+ // user-defined extras from vex.config.json.
89
+ // Simple names (no /, *, .) are treated as directory names here.
90
+ ...(_vexConfig.watchIgnore || []).filter(p => !/[\/\*\.]/.test(p)),
90
91
  ]);
91
92
 
93
+ /**
94
+ * Glob patterns derived from `watchIgnore` entries in vex.config.json that
95
+ * contain path separators, wildcards, or dots — i.e. file-level patterns.
96
+ *
97
+ * Simple directory names in the same array go to WATCH_IGNORE instead.
98
+ *
99
+ * "watchIgnore": ["utils/legacy.js", "components/wip/**", "wip"]
100
+ * → "wip" added to WATCH_IGNORE (directory name)
101
+ * → "utils/legacy.js" added to WATCH_IGNORE_FILES (glob pattern)
102
+ * → "components/wip/**" added to WATCH_IGNORE_FILES (glob pattern)
103
+ */
104
+ export const WATCH_IGNORE_FILES = (_vexConfig.watchIgnore || []).filter(p => /[\/\*\.]/.test(p));
105
+
92
106
  export const PAGES_DIR = path.resolve(SRC_DIR, "pages");
93
107
  export const SERVER_APP_DIR = path.join(FRAMEWORK_DIR, "server");
94
108
  export const CLIENT_DIR = path.join(FRAMEWORK_DIR, "client");
@@ -158,11 +172,39 @@ export async function initializeDirectories() {
158
172
  * console.log(result.importStatement);
159
173
  * // "import userController from '/.app/client/services/reactive.js';"
160
174
  */
161
- export function adjustClientModulePath(modulePath, importStatement) {
175
+ export function adjustClientModulePath(modulePath, importStatement, componentFilePath = null) {
162
176
  if (modulePath.startsWith("/_vexjs/")) {
163
177
  return { path: modulePath, importStatement };
164
178
  }
165
179
 
180
+ // User imports — relative (e.g. "../utils/context") or @ alias (e.g. "@/utils/context")
181
+ // — served via /_vexjs/user/
182
+ const isRelative = (modulePath.startsWith("./") || modulePath.startsWith("../")) && componentFilePath;
183
+ const isAtAlias = modulePath.startsWith("@/") || modulePath === "@";
184
+ if (isRelative || isAtAlias) {
185
+ let resolvedPath;
186
+ if (isAtAlias) {
187
+ resolvedPath = path.resolve(SRC_DIR, modulePath.replace(/^@\//, "").replace(/^@$/, ""));
188
+ } else {
189
+ const componentDir = path.dirname(componentFilePath);
190
+ resolvedPath = path.resolve(componentDir, modulePath);
191
+ }
192
+ if (!path.extname(resolvedPath)) {
193
+ if (existsSync(resolvedPath + ".js")) {
194
+ resolvedPath += ".js";
195
+ } else if (existsSync(path.join(resolvedPath, "index.js"))) {
196
+ resolvedPath = path.join(resolvedPath, "index.js");
197
+ } else {
198
+ resolvedPath += ".js";
199
+ }
200
+ }
201
+ const relativePath = path.relative(SRC_DIR, resolvedPath).replace(/\\/g, "/");
202
+ const adjustedPath = `/_vexjs/user/${relativePath}`;
203
+ const adjustedImportStatement = importStatement.replace(modulePath, adjustedPath);
204
+ return { path: adjustedPath, importStatement: adjustedImportStatement };
205
+ }
206
+
207
+ // Framework imports (vex/ and .app/)
166
208
  let relative = modulePath.replace(/^vex\//, "").replace(/^\.app\//, "");
167
209
  let adjustedPath = `/_vexjs/services/${relative}`;
168
210
 
@@ -77,9 +77,12 @@ function parseHTMLToNodes(html) {
77
77
  */
78
78
  function processNode(node, scope, previousRendered = false) {
79
79
  if (node.type === "text") {
80
- node.data = node.data.replace(/\{\{(.+?)\}\}/g, (_, expr) =>
81
- getDataValue(expr.trim(), scope)
82
- );
80
+ // Replace {{expr}} with its value from scope (SSR interpolation).
81
+ // The lookbehind (?<!\\) skips escaped \{{expr}}, which are then
82
+ // unescaped to literal {{expr}} by the second replace.
83
+ node.data = node.data
84
+ .replace(/(?<!\\)\{\{(.+?)\}\}/g, (_, expr) => getDataValue(expr.trim(), scope))
85
+ .replace(/\\\{\{/g, "{{");
83
86
  return node;
84
87
  }
85
88
 
@@ -88,9 +91,9 @@ function processNode(node, scope, previousRendered = false) {
88
91
 
89
92
  for (const [attrName, attrValue] of Object.entries(attrs)) {
90
93
  if (typeof attrValue === "string") {
91
- attrs[attrName] = attrValue.replace(/\{\{(.+?)\}\}/g, (_, expr) =>
92
- getDataValue(expr.trim(), scope)
93
- );
94
+ attrs[attrName] = attrValue
95
+ .replace(/(?<!\\)\{\{(.+?)\}\}/g, (_, expr) => getDataValue(expr.trim(), scope))
96
+ .replace(/\\\{\{/g, "{{");
94
97
  }
95
98
  }
96
99