@cfdez11/vex 0.2.1 → 0.3.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.
@@ -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.3.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",
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