@cfdez11/vex 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/bin/vex.js +29 -1
- package/package.json +2 -1
- package/server/build-static.js +202 -0
- package/server/index.js +12 -48
- package/server/utils/component-processor.js +185 -149
- package/server/utils/esbuild-plugin.js +86 -0
- package/server/utils/streaming.js +7 -6
package/README.md
CHANGED
|
@@ -686,6 +686,12 @@ sequenceDiagram
|
|
|
686
686
|
- [x] `vex.config.json` — configurable `srcDir` and `watchIgnore`
|
|
687
687
|
- [x] Published to npm as `@cfdez11/vex`
|
|
688
688
|
- [x] VS Code extension with syntax highlighting and go-to-definition
|
|
689
|
+
- [ ] Devtools
|
|
690
|
+
- [ ] Typescript in framework
|
|
691
|
+
- [ ] Allow typescript to devs
|
|
692
|
+
- [ ] Improve extension (hightlight, redirects, etc)
|
|
693
|
+
- [ ] Create theme syntax
|
|
694
|
+
- [ ] Create docs page
|
|
689
695
|
- [ ] Authentication middleware
|
|
690
696
|
- [ ] CDN cache integration
|
|
691
697
|
- [ ] Fix Suspense marker replacement with multi-root templates
|
package/bin/vex.js
CHANGED
|
@@ -9,7 +9,25 @@ const serverDir = path.resolve(__dirname, "..", "server");
|
|
|
9
9
|
|
|
10
10
|
const [command] = process.argv.slice(2);
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Available CLI commands.
|
|
14
|
+
*
|
|
15
|
+
* Each entry is a factory function that calls `spawn()` to launch a child
|
|
16
|
+
* process and returns the ChildProcess handle.
|
|
17
|
+
*
|
|
18
|
+
* `spawn(command, args, options)` forks a new OS process running `command`
|
|
19
|
+
* with the given `args`. It is non-blocking: the parent (this CLI) keeps
|
|
20
|
+
* running while the child executes. The returned ChildProcess emits an
|
|
21
|
+
* "exit" event when the child terminates, which we use to forward its exit
|
|
22
|
+
* code so the shell sees the correct status (e.g. for CI).
|
|
23
|
+
*
|
|
24
|
+
* `stdio: "inherit"` wires the child's stdin/stdout/stderr directly to the
|
|
25
|
+
* terminal that launched the CLI. Without it the child's output would be
|
|
26
|
+
* captured internally and never displayed. "inherit" is equivalent to
|
|
27
|
+
* passing [process.stdin, process.stdout, process.stderr].
|
|
28
|
+
*/
|
|
12
29
|
const commands = {
|
|
30
|
+
/** Start the dev server with Node's built-in file watcher (--watch restarts on .js changes). */
|
|
13
31
|
dev: () =>
|
|
14
32
|
spawn(
|
|
15
33
|
"node",
|
|
@@ -17,6 +35,7 @@ const commands = {
|
|
|
17
35
|
{ stdio: "inherit" }
|
|
18
36
|
),
|
|
19
37
|
|
|
38
|
+
/** Run the prebuild: scan pages/, generate component bundles and route registries. */
|
|
20
39
|
build: () =>
|
|
21
40
|
spawn(
|
|
22
41
|
"node",
|
|
@@ -24,16 +43,25 @@ const commands = {
|
|
|
24
43
|
{ stdio: "inherit" }
|
|
25
44
|
),
|
|
26
45
|
|
|
46
|
+
/** Start the production server. Sets NODE_ENV=production to disable HMR and file watchers. */
|
|
27
47
|
start: () =>
|
|
28
48
|
spawn(
|
|
29
49
|
"node",
|
|
30
50
|
[path.join(serverDir, "index.js")],
|
|
31
51
|
{ stdio: "inherit", env: { ...process.env, NODE_ENV: "production" } }
|
|
32
52
|
),
|
|
53
|
+
|
|
54
|
+
/** Run the static build: prebuild + copy assets to dist/ for deployment without a server. */
|
|
55
|
+
"build:static": () =>
|
|
56
|
+
spawn(
|
|
57
|
+
"node",
|
|
58
|
+
[path.join(serverDir, "build-static.js")],
|
|
59
|
+
{ stdio: "inherit" }
|
|
60
|
+
),
|
|
33
61
|
};
|
|
34
62
|
|
|
35
63
|
if (!commands[command]) {
|
|
36
|
-
console.error(`Unknown command: "${command}"\nAvailable: dev, build, start`);
|
|
64
|
+
console.error(`Unknown command: "${command}"\nAvailable: dev, build, build:static, start`);
|
|
37
65
|
process.exit(1);
|
|
38
66
|
}
|
|
39
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cfdez11/vex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A vanilla JavaScript meta-framework with file-based routing, SSR/CSR/SSG/ISR and Vue-like reactivity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./server/index.js",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"dom-serializer": "^2.0.0",
|
|
25
|
+
"esbuild": "^0.25.0",
|
|
25
26
|
"express": "^5.2.1",
|
|
26
27
|
"htmlparser2": "^10.0.0"
|
|
27
28
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { build } from "./utils/component-processor.js";
|
|
4
|
+
import {
|
|
5
|
+
initializeDirectories,
|
|
6
|
+
CLIENT_DIR,
|
|
7
|
+
SRC_DIR,
|
|
8
|
+
PROJECT_ROOT,
|
|
9
|
+
getRootTemplate,
|
|
10
|
+
WATCH_IGNORE,
|
|
11
|
+
generateComponentId,
|
|
12
|
+
} from "./utils/files.js";
|
|
13
|
+
|
|
14
|
+
const GENERATED_DIR = path.join(PROJECT_ROOT, ".vexjs");
|
|
15
|
+
const DIST_DIR = path.join(PROJECT_ROOT, "dist");
|
|
16
|
+
|
|
17
|
+
console.log("🔨 Starting static build...");
|
|
18
|
+
|
|
19
|
+
// Step 1: Prebuild (components + routes)
|
|
20
|
+
console.log("📁 Initializing directories...");
|
|
21
|
+
await initializeDirectories();
|
|
22
|
+
|
|
23
|
+
console.log("⚙️ Generating components and routes...");
|
|
24
|
+
const { serverRoutes } = await build();
|
|
25
|
+
|
|
26
|
+
// Step 2: Create dist/ structure (clean start)
|
|
27
|
+
console.log("🗂️ Creating dist/ structure...");
|
|
28
|
+
await fs.rm(DIST_DIR, { recursive: true, force: true });
|
|
29
|
+
await fs.mkdir(path.join(DIST_DIR, "_vexjs", "_components"), { recursive: true });
|
|
30
|
+
await fs.mkdir(path.join(DIST_DIR, "_vexjs", "user"), { recursive: true });
|
|
31
|
+
|
|
32
|
+
// Step 3: Generate dist/index.html shell
|
|
33
|
+
console.log("📄 Generating index.html shell...");
|
|
34
|
+
const rootTemplate = await getRootTemplate();
|
|
35
|
+
let shell = rootTemplate
|
|
36
|
+
.replace(/\{\{metadata\.title\}\}/g, "App")
|
|
37
|
+
.replace(/\{\{metadata\.description\}\}/g, "")
|
|
38
|
+
.replace(/\{\{props\.children\}\}/g, "");
|
|
39
|
+
|
|
40
|
+
const frameworkScripts = [
|
|
41
|
+
`<script type="module" src="/_vexjs/services/index.js"></script>`,
|
|
42
|
+
`<script src="/_vexjs/services/hydrate-client-components.js"></script>`,
|
|
43
|
+
`<script src="/_vexjs/services/hydrate.js" id="hydrate-script"></script>`,
|
|
44
|
+
].join("\n ");
|
|
45
|
+
|
|
46
|
+
shell = shell.replace("</head>", ` ${frameworkScripts}\n</head>`);
|
|
47
|
+
await fs.writeFile(path.join(DIST_DIR, "index.html"), shell, "utf-8");
|
|
48
|
+
|
|
49
|
+
// Step 4: Copy framework client files → dist/_vexjs/
|
|
50
|
+
console.log("📦 Copying framework client files...");
|
|
51
|
+
await fs.cp(CLIENT_DIR, path.join(DIST_DIR, "_vexjs"), { recursive: true });
|
|
52
|
+
|
|
53
|
+
// Step 5: Copy generated component bundles → dist/_vexjs/_components/
|
|
54
|
+
console.log("📦 Copying component bundles...");
|
|
55
|
+
await fs.cp(
|
|
56
|
+
path.join(GENERATED_DIR, "_components"),
|
|
57
|
+
path.join(DIST_DIR, "_vexjs", "_components"),
|
|
58
|
+
{ recursive: true }
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Step 6: Copy generated services (includes _routes.js) → dist/_vexjs/services/
|
|
62
|
+
// This overwrites the framework-level services dir copy with the generated routes
|
|
63
|
+
console.log("📦 Copying generated services...");
|
|
64
|
+
await fs.cp(
|
|
65
|
+
path.join(GENERATED_DIR, "services"),
|
|
66
|
+
path.join(DIST_DIR, "_vexjs", "services"),
|
|
67
|
+
{ recursive: true }
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Step 7: Copy user JS files with import rewriting → dist/_vexjs/user/
|
|
71
|
+
console.log("📦 Processing user JS files...");
|
|
72
|
+
await copyUserJsFiles(SRC_DIR, path.join(DIST_DIR, "_vexjs", "user"));
|
|
73
|
+
|
|
74
|
+
// Step 8: Copy public/ → dist/ (static assets, CSS)
|
|
75
|
+
console.log("📦 Copying public assets...");
|
|
76
|
+
const publicDir = path.join(PROJECT_ROOT, "public");
|
|
77
|
+
try {
|
|
78
|
+
await fs.cp(publicDir, DIST_DIR, { recursive: true });
|
|
79
|
+
} catch {
|
|
80
|
+
// no public/ directory — that's fine
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Step 9: Copy pre-rendered HTML for SSG routes (revalidate: 'never')
|
|
84
|
+
const CACHE_DIR = path.join(GENERATED_DIR, "_cache");
|
|
85
|
+
const ssgRoutes = serverRoutes.filter(
|
|
86
|
+
(r) => r.meta.revalidate === "never" || r.meta.revalidate === false
|
|
87
|
+
);
|
|
88
|
+
if (ssgRoutes.length > 0) {
|
|
89
|
+
console.log("📄 Copying pre-rendered SSG pages...");
|
|
90
|
+
for (const route of ssgRoutes) {
|
|
91
|
+
const cacheFile = path.join(CACHE_DIR, `${generateComponentId(route.serverPath)}.html`);
|
|
92
|
+
try {
|
|
93
|
+
const html = await fs.readFile(cacheFile, "utf-8");
|
|
94
|
+
const routeSegment = route.serverPath === "/" ? "" : route.serverPath;
|
|
95
|
+
const destPath = path.join(DIST_DIR, routeSegment, "index.html");
|
|
96
|
+
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
|
97
|
+
await fs.writeFile(destPath, html, "utf-8");
|
|
98
|
+
console.log(` ✓ ${route.serverPath}`);
|
|
99
|
+
} catch {
|
|
100
|
+
console.warn(` ✗ ${route.serverPath} (no cached HTML found)`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Step 10: Report SSR-only routes that were skipped
|
|
106
|
+
const ssrOnlyRoutes = serverRoutes.filter((r) => r.meta.ssr);
|
|
107
|
+
if (ssrOnlyRoutes.length > 0) {
|
|
108
|
+
console.warn("\n⚠️ The following routes require a server and were NOT included in the static build:");
|
|
109
|
+
for (const r of ssrOnlyRoutes) {
|
|
110
|
+
console.warn(` ${r.path} (SSR)`);
|
|
111
|
+
}
|
|
112
|
+
console.warn(" These routes will show a 404 in the static build.\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.log("✅ Static build complete! Output: dist/");
|
|
116
|
+
console.log("\nTo serve locally: npx serve dist");
|
|
117
|
+
console.log("Static host note: configure your host to serve dist/index.html for all 404s (SPA fallback).");
|
|
118
|
+
|
|
119
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Recursively walks SRC_DIR, rewrites imports in every .js file,
|
|
123
|
+
* and writes results to destDir preserving the relative path structure.
|
|
124
|
+
*
|
|
125
|
+
* Skips directories listed in WATCH_IGNORE (node_modules, dist, .vexjs, etc.).
|
|
126
|
+
*
|
|
127
|
+
* @param {string} srcDir Absolute path to user source root (SRC_DIR)
|
|
128
|
+
* @param {string} destDir Absolute path to dist/_vexjs/user/
|
|
129
|
+
*/
|
|
130
|
+
async function copyUserJsFiles(srcDir, destDir) {
|
|
131
|
+
let entries;
|
|
132
|
+
try {
|
|
133
|
+
entries = await fs.readdir(srcDir, { withFileTypes: true });
|
|
134
|
+
} catch {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
if (WATCH_IGNORE.has(entry.name)) continue;
|
|
140
|
+
|
|
141
|
+
const fullSrc = path.join(srcDir, entry.name);
|
|
142
|
+
const relToSrcDir = path.relative(SRC_DIR, fullSrc).replace(/\\/g, "/");
|
|
143
|
+
const fullDest = path.join(destDir, relToSrcDir);
|
|
144
|
+
|
|
145
|
+
if (entry.isDirectory()) {
|
|
146
|
+
await copyUserJsFiles(fullSrc, destDir);
|
|
147
|
+
} else if (entry.name.endsWith(".js")) {
|
|
148
|
+
let content;
|
|
149
|
+
try {
|
|
150
|
+
content = await fs.readFile(fullSrc, "utf-8");
|
|
151
|
+
} catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
content = rewriteUserImports(content, fullSrc, srcDir);
|
|
156
|
+
|
|
157
|
+
await fs.mkdir(path.dirname(fullDest), { recursive: true });
|
|
158
|
+
await fs.writeFile(fullDest, content, "utf-8");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Rewrites import paths in a user JS file so they work in the browser.
|
|
165
|
+
* Mirrors the runtime rewriting done by the /_vexjs/user/* Express handler.
|
|
166
|
+
*
|
|
167
|
+
* - `vex/` and `.app/` → `/_vexjs/services/`
|
|
168
|
+
* - `@/` (project alias) → `/_vexjs/user/`
|
|
169
|
+
* - relative `./` or `../` → `/_vexjs/user/`
|
|
170
|
+
* - external bare specifiers (e.g. npm packages) → left as-is
|
|
171
|
+
*
|
|
172
|
+
* @param {string} content File source
|
|
173
|
+
* @param {string} filePath Absolute path of the file being rewritten
|
|
174
|
+
* @param {string} srcDir Absolute SRC_DIR root
|
|
175
|
+
* @returns {string} Rewritten source
|
|
176
|
+
*/
|
|
177
|
+
function rewriteUserImports(content, filePath, srcDir) {
|
|
178
|
+
return content.replace(
|
|
179
|
+
/^(\s*import\s+[^'"]*from\s+)['"]([^'"]+)['"]/gm,
|
|
180
|
+
(match, prefix, modulePath) => {
|
|
181
|
+
if (modulePath.startsWith("vex/") || modulePath.startsWith(".app/")) {
|
|
182
|
+
let mod = modulePath.replace(/^vex\//, "").replace(/^\.app\//, "");
|
|
183
|
+
if (!path.extname(mod)) mod += ".js";
|
|
184
|
+
return `${prefix}'/_vexjs/services/${mod}'`;
|
|
185
|
+
}
|
|
186
|
+
if (modulePath.startsWith("@/") || modulePath === "@") {
|
|
187
|
+
let resolved = path.resolve(srcDir, modulePath.replace(/^@\//, "").replace(/^@$/, ""));
|
|
188
|
+
if (!path.extname(resolved)) resolved += ".js";
|
|
189
|
+
const rel = path.relative(srcDir, resolved).replace(/\\/g, "/");
|
|
190
|
+
return `${prefix}'/_vexjs/user/${rel}'`;
|
|
191
|
+
}
|
|
192
|
+
if (modulePath.startsWith("./") || modulePath.startsWith("../")) {
|
|
193
|
+
const fileDir = path.dirname(filePath);
|
|
194
|
+
let resolved = path.resolve(fileDir, modulePath);
|
|
195
|
+
if (!path.extname(resolved)) resolved += ".js";
|
|
196
|
+
const rel = path.relative(srcDir, resolved).replace(/\\/g, "/");
|
|
197
|
+
return `${prefix}'/_vexjs/user/${rel}'`;
|
|
198
|
+
}
|
|
199
|
+
return match;
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
}
|
package/server/index.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import fs from "fs/promises";
|
|
2
1
|
import express from "express";
|
|
3
2
|
import path from "path";
|
|
4
3
|
import { pathToFileURL } from "url";
|
|
5
4
|
import { handlePageRequest, revalidatePath } from "./utils/router.js";
|
|
6
|
-
import { initializeDirectories, CLIENT_DIR
|
|
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 '
|
|
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
|
|
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/
|
|
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
|
|
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
|
-
*
|
|
731
|
-
*
|
|
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 {
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
* }
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
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<
|
|
821
|
-
*
|
|
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
|
-
//
|
|
801
|
+
// ── 1. Resolve default props from xprops() ─────────────────────────────────
|
|
832
802
|
const defaults = extractVPropsDefaults(clientCode);
|
|
833
|
-
|
|
834
803
|
const clientCodeWithProps = addComputedProps(clientCode, defaults);
|
|
835
804
|
|
|
836
|
-
//
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
|
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 =
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1150
|
-
*
|
|
1151
|
-
*
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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[
|
|
40
|
+
attrs[match[1]] = match[2] ?? match[3] ?? "";
|
|
41
|
+
} else if (match[4]) {
|
|
41
42
|
// Event handler @event
|
|
42
|
-
attrs[match[
|
|
43
|
-
} else if (match[
|
|
43
|
+
attrs[match[4]] = match[5] ?? match[6] ?? "";
|
|
44
|
+
} else if (match[7]) {
|
|
44
45
|
// Static prop
|
|
45
|
-
attrs[match[
|
|
46
|
+
attrs[match[7]] = match[8] ?? match[9] ?? "";
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|