@cfdez11/vex 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/services/hydrate-client-components.js +5 -5
- package/package.json +3 -1
- package/server/build-static.js +1 -0
- package/server/index.js +14 -49
- package/server/prebuild.js +1 -0
- package/server/utils/component-processor.js +192 -153
- package/server/utils/esbuild-plugin.js +86 -0
- package/server/utils/files.js +6 -1
- package/server/utils/streaming.js +9 -8
|
@@ -78,12 +78,12 @@
|
|
|
78
78
|
// Start observing the document for new nodes
|
|
79
79
|
observer.observe(document, { childList: true, subtree: true });
|
|
80
80
|
|
|
81
|
-
// Hydrate existing components on DOMContentLoaded or immediately if already interactive
|
|
81
|
+
// Hydrate existing components on DOMContentLoaded or immediately if already interactive.
|
|
82
|
+
// The observer is intentionally NOT disconnected here — it must stay active to catch
|
|
83
|
+
// components inserted after DOMContentLoaded (nested CSR components, Suspense streaming,
|
|
84
|
+
// SPA navigations). The `data-hydrated` guard in hydrateMarker prevents double-hydration.
|
|
82
85
|
if (document.readyState === "loading") {
|
|
83
|
-
document.addEventListener("DOMContentLoaded", () =>
|
|
84
|
-
hydrateComponents();
|
|
85
|
-
observer.disconnect();
|
|
86
|
-
});
|
|
86
|
+
document.addEventListener("DOMContentLoaded", () => hydrateComponents());
|
|
87
87
|
} else {
|
|
88
88
|
hydrateComponents();
|
|
89
89
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cfdez11/vex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "A vanilla JavaScript meta-framework with file-based routing, SSR/CSR/SSG/ISR and Vue-like reactivity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./server/index.js",
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"dom-serializer": "^2.0.0",
|
|
25
|
+
"dotenv": "^16.0.0",
|
|
26
|
+
"esbuild": "^0.25.0",
|
|
25
27
|
"express": "^5.2.1",
|
|
26
28
|
"htmlparser2": "^10.0.0"
|
|
27
29
|
}
|
package/server/build-static.js
CHANGED
package/server/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import "dotenv/config";
|
|
2
2
|
import express from "express";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { pathToFileURL } from "url";
|
|
5
5
|
import { handlePageRequest, revalidatePath } from "./utils/router.js";
|
|
6
|
-
import { initializeDirectories, CLIENT_DIR
|
|
6
|
+
import { initializeDirectories, CLIENT_DIR } from "./utils/files.js";
|
|
7
7
|
|
|
8
8
|
await initializeDirectories();
|
|
9
9
|
|
|
@@ -16,7 +16,7 @@ if (process.env.NODE_ENV === "production") {
|
|
|
16
16
|
serverRoutes = routes;
|
|
17
17
|
console.log("Routes loaded.");
|
|
18
18
|
} catch {
|
|
19
|
-
console.error("ERROR: No build found. Run '
|
|
19
|
+
console.error("ERROR: No build found. Run 'vex build' before starting in production.");
|
|
20
20
|
process.exit(1);
|
|
21
21
|
}
|
|
22
22
|
} else {
|
|
@@ -28,7 +28,9 @@ if (process.env.NODE_ENV === "production") {
|
|
|
28
28
|
|
|
29
29
|
const app = express();
|
|
30
30
|
|
|
31
|
-
// Serve generated client
|
|
31
|
+
// Serve generated client component bundles at /_vexjs/_components/
|
|
32
|
+
// Must be registered before the broader /_vexjs static mount below so that
|
|
33
|
+
// .vexjs/_components/ takes priority over anything in CLIENT_DIR/_components/.
|
|
32
34
|
app.use(
|
|
33
35
|
"/_vexjs/_components",
|
|
34
36
|
express.static(path.join(process.cwd(), ".vexjs", "_components"), {
|
|
@@ -40,7 +42,9 @@ app.use(
|
|
|
40
42
|
})
|
|
41
43
|
);
|
|
42
44
|
|
|
43
|
-
// Serve generated services (e.g. _routes.js) at /_vexjs/services/
|
|
45
|
+
// Serve generated services (e.g. _routes.js) at /_vexjs/services/
|
|
46
|
+
// Also before the broader /_vexjs mount so the generated _routes.js
|
|
47
|
+
// overrides any placeholder that might exist in the framework source.
|
|
44
48
|
app.use(
|
|
45
49
|
"/_vexjs/services",
|
|
46
50
|
express.static(path.join(process.cwd(), ".vexjs", "services"), {
|
|
@@ -52,7 +56,10 @@ app.use(
|
|
|
52
56
|
})
|
|
53
57
|
);
|
|
54
58
|
|
|
55
|
-
// Serve framework client files at /_vexjs/
|
|
59
|
+
// Serve framework client runtime files at /_vexjs/
|
|
60
|
+
// (reactive.js, html.js, hydrate.js, navigation/, etc.)
|
|
61
|
+
// User imports like `vex/reactive` are marked external by esbuild and resolved
|
|
62
|
+
// here at runtime — a single shared instance per page load.
|
|
56
63
|
app.use(
|
|
57
64
|
"/_vexjs",
|
|
58
65
|
express.static(CLIENT_DIR, {
|
|
@@ -64,48 +71,6 @@ app.use(
|
|
|
64
71
|
})
|
|
65
72
|
);
|
|
66
73
|
|
|
67
|
-
// Serve user JS utility files at /_vexjs/user/* with import rewriting
|
|
68
|
-
app.get("/_vexjs/user/*splat", async (req, res) => {
|
|
69
|
-
const splat = req.params.splat;
|
|
70
|
-
const relPath = Array.isArray(splat) ? splat.join("/") : splat;
|
|
71
|
-
const filePath = path.resolve(path.join(SRC_DIR, relPath));
|
|
72
|
-
// Prevent path traversal outside SRC_DIR
|
|
73
|
-
if (!filePath.startsWith(SRC_DIR + path.sep) && filePath !== SRC_DIR) {
|
|
74
|
-
return res.status(403).send("Forbidden");
|
|
75
|
-
}
|
|
76
|
-
try {
|
|
77
|
-
let content = await fs.readFile(filePath, "utf-8");
|
|
78
|
-
// Rewrite imports to browser-accessible paths
|
|
79
|
-
content = content.replace(
|
|
80
|
-
/^(\s*import\s+[^'"]*from\s+)['"]([^'"]+)['"]/gm,
|
|
81
|
-
(match, prefix, modulePath) => {
|
|
82
|
-
if (modulePath.startsWith("vex/") || modulePath.startsWith(".app/")) {
|
|
83
|
-
let mod = modulePath.replace(/^vex\//, "").replace(/^\.app\//, "");
|
|
84
|
-
if (!path.extname(mod)) mod += ".js";
|
|
85
|
-
return `${prefix}'/_vexjs/services/${mod}'`;
|
|
86
|
-
}
|
|
87
|
-
if (modulePath.startsWith("@/") || modulePath === "@") {
|
|
88
|
-
let resolved = path.resolve(SRC_DIR, modulePath.replace(/^@\//, "").replace(/^@$/, ""));
|
|
89
|
-
if (!path.extname(resolved)) resolved += ".js";
|
|
90
|
-
const rel = path.relative(SRC_DIR, resolved).replace(/\\/g, "/");
|
|
91
|
-
return `${prefix}'/_vexjs/user/${rel}'`;
|
|
92
|
-
}
|
|
93
|
-
if (modulePath.startsWith("./") || modulePath.startsWith("../")) {
|
|
94
|
-
const fileDir = path.dirname(filePath);
|
|
95
|
-
let resolved = path.resolve(fileDir, modulePath);
|
|
96
|
-
if (!path.extname(resolved)) resolved += ".js";
|
|
97
|
-
const rel = path.relative(SRC_DIR, resolved).replace(/\\/g, "/");
|
|
98
|
-
return `${prefix}'/_vexjs/user/${rel}'`;
|
|
99
|
-
}
|
|
100
|
-
return match;
|
|
101
|
-
}
|
|
102
|
-
);
|
|
103
|
-
res.setHeader("Content-Type", "application/javascript");
|
|
104
|
-
res.send(content);
|
|
105
|
-
} catch {
|
|
106
|
-
res.status(404).send("Not found");
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
74
|
|
|
110
75
|
// Serve user's public directory at /
|
|
111
76
|
app.use("/", express.static(path.join(process.cwd(), "public")));
|
|
@@ -151,7 +116,7 @@ app.use(async (req, res) => {
|
|
|
151
116
|
res.status(404).send("Page not found");
|
|
152
117
|
});
|
|
153
118
|
|
|
154
|
-
const PORT = process.env.PORT || 3001;
|
|
119
|
+
const PORT = process.env.VEX_PORT || process.env.PORT || 3001;
|
|
155
120
|
app.listen(PORT, () => {
|
|
156
121
|
console.log(`Server running on port ${PORT}`);
|
|
157
122
|
});
|
package/server/prebuild.js
CHANGED
|
@@ -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
|
});
|
|
@@ -166,7 +177,7 @@ const getScriptImports = async (script, isClientSide = false, filePath = null) =
|
|
|
166
177
|
while ((match = importRegex.exec(script)) !== null) {
|
|
167
178
|
const [importStatement, defaultImport, namedImports, modulePath] = match;
|
|
168
179
|
|
|
169
|
-
const { path, fileUrl } = await getImportData(modulePath);
|
|
180
|
+
const { path, fileUrl } = await getImportData(modulePath, filePath);
|
|
170
181
|
|
|
171
182
|
if (path.endsWith(".vex")) {
|
|
172
183
|
// Recursively process HTML component
|
|
@@ -674,9 +685,22 @@ function convertVueToHtmlTagged(template, clientCode = "") {
|
|
|
674
685
|
|
|
675
686
|
let result = template.trim();
|
|
676
687
|
|
|
688
|
+
// Self-closing x-for="item in items" → ${items.value.map(item => html`<Component ... />`)}
|
|
689
|
+
result = result.replace(
|
|
690
|
+
/<([\w-]+)([^>]*)\s+x-for="(\w+)\s+in\s+([^"]+)(?:\.value)?"([^>]*)\/>/g,
|
|
691
|
+
(_, tag, beforeAttrs, iterVar, arrayVar, afterAttrs) => {
|
|
692
|
+
const cleanExpr = arrayVar.trim();
|
|
693
|
+
const isSimpleVar = /^\w+$/.test(cleanExpr);
|
|
694
|
+
const arrayAccess = isSimpleVar && reactiveVars.has(cleanExpr)
|
|
695
|
+
? `${cleanExpr}.value`
|
|
696
|
+
: cleanExpr;
|
|
697
|
+
return `\${${arrayAccess}.map(${iterVar} => html\`<${tag}${beforeAttrs}${afterAttrs} />\`)}`;
|
|
698
|
+
}
|
|
699
|
+
);
|
|
700
|
+
|
|
677
701
|
// x-for="item in items" → ${items.value.map(item => html`...`)}
|
|
678
702
|
result = result.replace(
|
|
679
|
-
/<(\w+)([^>]*)\s+x-for="(\w+)\s+in\s+([^"]+)(?:\.value)?"([^>]*)>([\s\S]*?)<\/\1>/g,
|
|
703
|
+
/<([\w-]+)([^>]*)\s+x-for="(\w+)\s+in\s+([^"]+)(?:\.value)?"([^>]*)>([\s\S]*?)<\/\1>/g,
|
|
680
704
|
(_, tag, beforeAttrs, iterVar, arrayVar, afterAttrs, content) => {
|
|
681
705
|
const cleanExpr = arrayVar.trim();
|
|
682
706
|
const isSimpleVar = /^\w+$/.test(cleanExpr);
|
|
@@ -727,98 +751,41 @@ function convertVueToHtmlTagged(template, clientCode = "") {
|
|
|
727
751
|
|
|
728
752
|
|
|
729
753
|
/**
|
|
730
|
-
*
|
|
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
|
/**
|
|
@@ -982,7 +989,7 @@ async function generateServerComponentHTML(componentPath) {
|
|
|
982
989
|
* and runtime interpolations (e.g., `${variable}`).
|
|
983
990
|
*
|
|
984
991
|
* @param {string} componentName - The logical name of the component.
|
|
985
|
-
* @param {string}
|
|
992
|
+
* @param {string} componentAbsPath - The absolute file path of the component (resolved by getImportData).
|
|
986
993
|
* @param {Record<string, any>} [props={}] - An object of props to pass to the component.
|
|
987
994
|
* Values can be literals or template
|
|
988
995
|
* interpolations (`${…}`) for dynamic evaluation.
|
|
@@ -990,16 +997,45 @@ async function generateServerComponentHTML(componentPath) {
|
|
|
990
997
|
* @returns {Promise<string>} A promise that resolves to a string containing
|
|
991
998
|
* the `<template>` HTML for hydration.
|
|
992
999
|
*/
|
|
993
|
-
export async function processClientComponent(componentName,
|
|
1000
|
+
export async function processClientComponent(componentName, componentAbsPath, props = {}) {
|
|
994
1001
|
const targetId = `client-${componentName}-${Date.now()}`;
|
|
995
1002
|
|
|
996
|
-
|
|
997
|
-
|
|
1003
|
+
// componentAbsPath is the absolute resolved path — generateComponentId strips ROOT_DIR
|
|
1004
|
+
// internally, so this produces the same hash as the bundle filename written by
|
|
1005
|
+
// generateComponentAndFillCache (which also calls generateComponentId with the abs path).
|
|
1006
|
+
const componentImport = generateComponentId(componentAbsPath);
|
|
1007
|
+
const propsJson = serializeClientComponentProps(props);
|
|
998
1008
|
const html = `<template id="${targetId}" data-client:component="${componentImport}" data-client:props='${propsJson}'></template>`;
|
|
999
1009
|
|
|
1000
1010
|
return html;
|
|
1001
1011
|
}
|
|
1002
1012
|
|
|
1013
|
+
function isTemplateExpression(value) {
|
|
1014
|
+
return typeof value === "string" && /^\$\{[\s\S]+\}$/.test(value.trim());
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function serializeRuntimePropValue(value) {
|
|
1018
|
+
if (!isTemplateExpression(value)) {
|
|
1019
|
+
return JSON.stringify(value);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return value.trim().slice(2, -1).trim();
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function serializeClientComponentProps(props = {}) {
|
|
1026
|
+
const hasDynamicValues = Object.values(props).some(isTemplateExpression);
|
|
1027
|
+
|
|
1028
|
+
if (!hasDynamicValues) {
|
|
1029
|
+
return JSON.stringify(props);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const serializedEntries = Object.entries(props).map(([key, value]) => {
|
|
1033
|
+
return `${JSON.stringify(key)}: ${serializeRuntimePropValue(value)}`;
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
return `\${JSON.stringify({ ${serializedEntries.join(", ")} })}`;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1003
1039
|
/**
|
|
1004
1040
|
* Extract xprops object literal from client code
|
|
1005
1041
|
* @param {string} clientCode
|
|
@@ -1142,22 +1178,24 @@ function fillRoute(route, params) {
|
|
|
1142
1178
|
});
|
|
1143
1179
|
}
|
|
1144
1180
|
/**
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
1181
|
+
* Generates and saves the client-side JS bundle for a component.
|
|
1182
|
+
*
|
|
1183
|
+
* Delegates to generateClientComponentModule, which uses esbuild to bundle
|
|
1184
|
+
* the component's <script client> code into a self-contained ESM file written
|
|
1185
|
+
* directly to .vexjs/_components/<componentName>.js.
|
|
1186
|
+
*
|
|
1187
|
+
* componentFilePath is required so esbuild can resolve relative imports
|
|
1188
|
+
* (./utils/foo) from the correct base directory.
|
|
1189
|
+
*
|
|
1148
1190
|
* @param {{
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
1151
|
-
*
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1156
|
-
* }
|
|
1157
|
-
* clientComponents: Record<string, any>,
|
|
1158
|
-
* componentName: string,
|
|
1159
|
-
* }}
|
|
1160
|
-
*
|
|
1191
|
+
* metadata: object,
|
|
1192
|
+
* clientCode: string,
|
|
1193
|
+
* template: string,
|
|
1194
|
+
* clientImports: Record<string, { originalImportStatement: string }>,
|
|
1195
|
+
* clientComponents: Map<string, any>,
|
|
1196
|
+
* componentName: string,
|
|
1197
|
+
* componentFilePath: string,
|
|
1198
|
+
* }} params
|
|
1161
1199
|
* @returns {Promise<void>}
|
|
1162
1200
|
*/
|
|
1163
1201
|
async function saveClientComponent({
|
|
@@ -1167,18 +1205,17 @@ async function saveClientComponent({
|
|
|
1167
1205
|
clientImports,
|
|
1168
1206
|
clientComponents,
|
|
1169
1207
|
componentName,
|
|
1208
|
+
componentFilePath,
|
|
1170
1209
|
}) {
|
|
1171
|
-
|
|
1210
|
+
await generateClientComponentModule({
|
|
1172
1211
|
metadata,
|
|
1173
1212
|
clientCode,
|
|
1174
1213
|
template,
|
|
1175
1214
|
clientImports,
|
|
1176
1215
|
clientComponents,
|
|
1216
|
+
componentFilePath,
|
|
1217
|
+
componentName,
|
|
1177
1218
|
});
|
|
1178
|
-
|
|
1179
|
-
if (jsModuleCode) {
|
|
1180
|
-
await saveClientComponentModule(componentName, jsModuleCode)
|
|
1181
|
-
}
|
|
1182
1219
|
}
|
|
1183
1220
|
|
|
1184
1221
|
/**x
|
|
@@ -1250,6 +1287,7 @@ async function generateComponentAndFillCache(filePath) {
|
|
|
1250
1287
|
clientImports,
|
|
1251
1288
|
clientComponents,
|
|
1252
1289
|
componentName: generateComponentId(cacheKey),
|
|
1290
|
+
componentFilePath: filePath,
|
|
1253
1291
|
}))
|
|
1254
1292
|
}
|
|
1255
1293
|
}
|
|
@@ -1263,6 +1301,7 @@ async function generateComponentAndFillCache(filePath) {
|
|
|
1263
1301
|
clientImports,
|
|
1264
1302
|
clientComponents,
|
|
1265
1303
|
componentName: generateComponentId(urlPath),
|
|
1304
|
+
componentFilePath: filePath,
|
|
1266
1305
|
}))
|
|
1267
1306
|
}
|
|
1268
1307
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { SRC_DIR } from "./files.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates the VexJS esbuild alias plugin.
|
|
6
|
+
*
|
|
7
|
+
* esbuild resolves imports by looking at the specifier string (e.g. "vex/reactive",
|
|
8
|
+
* "./utils/counter", "lodash"). By default it only understands relative paths and
|
|
9
|
+
* node_modules. This plugin teaches esbuild about the three VexJS-specific import
|
|
10
|
+
* conventions so it can correctly bundle every <script client> block.
|
|
11
|
+
*
|
|
12
|
+
* The plugin intercepts imports at bundle time via onResolve hooks — each hook
|
|
13
|
+
* matches a filter regex against the import specifier and returns either:
|
|
14
|
+
* - { path, external: true } → esbuild leaves the import as-is in the output.
|
|
15
|
+
* The browser resolves it at runtime from the URL.
|
|
16
|
+
* - { path } → esbuild reads and inlines the file into the bundle.
|
|
17
|
+
*
|
|
18
|
+
* ─── Three categories of imports ────────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* 1. Framework singletons (vex/* and .app/*)
|
|
21
|
+
* Examples: `import { reactive } from 'vex/reactive'`
|
|
22
|
+
* `import { html } from '.app/html'`
|
|
23
|
+
*
|
|
24
|
+
* These are framework runtime files served statically at /_vexjs/services/.
|
|
25
|
+
* They MUST be marked external so every component shares the same instance
|
|
26
|
+
* at runtime. If esbuild inlined them, each component bundle would get its
|
|
27
|
+
* own copy of reactive.js — reactive state would not be shared across
|
|
28
|
+
* components on the same page and the entire reactivity system would break.
|
|
29
|
+
*
|
|
30
|
+
* The path is rewritten from the short alias to the browser-accessible URL:
|
|
31
|
+
* vex/reactive → /_vexjs/services/reactive.js (external)
|
|
32
|
+
*
|
|
33
|
+
* 2. Project alias (@/*)
|
|
34
|
+
* Example: `import { counter } from '@/utils/counter'`
|
|
35
|
+
*
|
|
36
|
+
* @/ is a shorthand for the project SRC_DIR root. These are user JS utilities
|
|
37
|
+
* that should be bundled into the component (not served separately). esbuild
|
|
38
|
+
* receives the absolute filesystem path so it can read and inline the file.
|
|
39
|
+
*
|
|
40
|
+
* 3. Relative imports (./ and ../)
|
|
41
|
+
* Example: `import { fn } from './helpers'`
|
|
42
|
+
*
|
|
43
|
+
* These are resolved automatically by esbuild using the `resolveDir` option
|
|
44
|
+
* set on the stdin entry (the directory of the .vex file being compiled).
|
|
45
|
+
* No custom hook is needed for these.
|
|
46
|
+
*
|
|
47
|
+
* 4. npm packages (bare specifiers like 'lodash', 'date-fns')
|
|
48
|
+
* Also resolved automatically by esbuild via node_modules lookup.
|
|
49
|
+
* No custom hook is needed.
|
|
50
|
+
*
|
|
51
|
+
* @returns {import('esbuild').Plugin}
|
|
52
|
+
*/
|
|
53
|
+
export function createVexAliasPlugin() {
|
|
54
|
+
return {
|
|
55
|
+
name: "vex-aliases",
|
|
56
|
+
setup(build) {
|
|
57
|
+
// ── Category 1a: vex/* ────────────────────────────────────────────────
|
|
58
|
+
// Matches: 'vex/reactive', 'vex/html', 'vex/navigation', etc.
|
|
59
|
+
// Rewrites to the browser URL and marks external so esbuild skips bundling.
|
|
60
|
+
build.onResolve({ filter: /^vex\// }, (args) => {
|
|
61
|
+
let mod = args.path.replace(/^vex\//, "");
|
|
62
|
+
if (!path.extname(mod)) mod += ".js";
|
|
63
|
+
return { path: `/_vexjs/services/${mod}`, external: true };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── Category 1b: .app/* ───────────────────────────────────────────────
|
|
67
|
+
// Legacy alias for framework services. Same treatment as vex/*.
|
|
68
|
+
// Matches: '.app/reactive', '.app/html', etc.
|
|
69
|
+
build.onResolve({ filter: /^\.app\// }, (args) => {
|
|
70
|
+
let mod = args.path.replace(/^\.app\//, "");
|
|
71
|
+
if (!path.extname(mod)) mod += ".js";
|
|
72
|
+
return { path: `/_vexjs/services/${mod}`, external: true };
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── Category 2: @/ project alias ─────────────────────────────────────
|
|
76
|
+
// Matches: '@/utils/counter', '@/lib/api', etc.
|
|
77
|
+
// Resolved to an absolute filesystem path so esbuild can read and bundle
|
|
78
|
+
// the file inline. No .js extension auto-appended here — esbuild does it.
|
|
79
|
+
build.onResolve({ filter: /^@\// }, (args) => {
|
|
80
|
+
let resolved = path.resolve(SRC_DIR, args.path.slice(2));
|
|
81
|
+
if (!path.extname(resolved)) resolved += ".js";
|
|
82
|
+
return { path: resolved };
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
package/server/utils/files.js
CHANGED
|
@@ -811,7 +811,7 @@ export async function saveClientComponentModule(componentName, jsModuleCode) {
|
|
|
811
811
|
* importPath: string
|
|
812
812
|
* }}
|
|
813
813
|
*/
|
|
814
|
-
export async function getImportData(importPath) {
|
|
814
|
+
export async function getImportData(importPath, callerFilePath = null) {
|
|
815
815
|
let resolvedPath;
|
|
816
816
|
if (importPath.startsWith("vex/server/")) {
|
|
817
817
|
resolvedPath = path.resolve(FRAMEWORK_DIR, importPath.replace("vex/server/", "server/"));
|
|
@@ -821,6 +821,11 @@ export async function getImportData(importPath) {
|
|
|
821
821
|
resolvedPath = path.resolve(FRAMEWORK_DIR, importPath.replace(".app/server/", "server/"));
|
|
822
822
|
} else if (importPath.startsWith(".app/")) {
|
|
823
823
|
resolvedPath = path.resolve(FRAMEWORK_DIR, importPath.replace(".app/", ""));
|
|
824
|
+
} else if ((importPath.startsWith("./") || importPath.startsWith("../")) && callerFilePath) {
|
|
825
|
+
// Relative import — resolve against the caller component's directory, not ROOT_DIR.
|
|
826
|
+
// Without this, `import Foo from './foo.vex'` inside a nested component would be
|
|
827
|
+
// resolved from the project root instead of from the file that contains the import.
|
|
828
|
+
resolvedPath = path.resolve(path.dirname(callerFilePath), importPath);
|
|
824
829
|
} else {
|
|
825
830
|
resolvedPath = path.resolve(ROOT_DIR, importPath);
|
|
826
831
|
}
|
|
@@ -30,19 +30,20 @@ import {
|
|
|
30
30
|
*/
|
|
31
31
|
function parseAttributes(rawAttrs) {
|
|
32
32
|
const attrs = {};
|
|
33
|
-
const regex =
|
|
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
|
|
|
@@ -127,7 +128,7 @@ async function renderClientComponents(html, clientComponents) {
|
|
|
127
128
|
let processedHtml = html;
|
|
128
129
|
const allScripts = [];
|
|
129
130
|
|
|
130
|
-
for (const [componentName, {
|
|
131
|
+
for (const [componentName, { path: componentAbsPath }] of clientComponents.entries()) {
|
|
131
132
|
const escapedName = componentName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
132
133
|
|
|
133
134
|
const componentRegex = new RegExp(
|
|
@@ -155,7 +156,7 @@ async function renderClientComponents(html, clientComponents) {
|
|
|
155
156
|
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
156
157
|
const { start, end, attrs } = replacements[i];
|
|
157
158
|
|
|
158
|
-
const htmlComponent = await processClientComponent(componentName,
|
|
159
|
+
const htmlComponent = await processClientComponent(componentName, componentAbsPath, attrs);
|
|
159
160
|
|
|
160
161
|
processedHtml =
|
|
161
162
|
processedHtml.slice(0, start) +
|