gofront 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -387,13 +387,15 @@ gofront <dir> compile all *.go in directory → stdout
387
387
  gofront <input> -o out.js write output to file (prints elapsed compile time e.g. "15ms")
388
388
  gofront <input> --check type-check only
389
389
  gofront <input> --watch watch for changes and recompile
390
- gofront <input> -o out.js --serve watch + serve with live reload (default port 3000)
390
+ gofront <input> -o out.js --serve watch + serve with live reload & SPA fallback (default port 3000)
391
391
  gofront <input> -o out.js --serve --port 8080 use a custom port
392
+ gofront <input> -o out.js --copy-assets compile and synchronize static assets from package.json assetCopy
392
393
  gofront <input> --source-map append inline source map (single file or directory; multi-file packages emit per-file mappings)
393
394
  gofront <input> --minify minify output (built-in minifier)
394
395
  gofront <input> --minify --mangle minify and rename local identifiers
395
396
  gofront <file.go> --ast dump AST (debug)
396
397
  gofront <file.go> --tokens dump tokens (debug)
398
+ gofront prep [dir] copy static assets and bundle vendor dependencies
397
399
  gofront init [dir] scaffold a new project
398
400
  gofront --version / -v print version
399
401
  gofront --help / -h print this help
@@ -401,6 +403,37 @@ gofront --help / -h print this help
401
403
 
402
404
  ---
403
405
 
406
+ ## Static assets & vendor bundling
407
+
408
+ GoFront includes native tooling to manage static assets and package external npm dependencies for the browser without requiring external wrappers or complex build tools.
409
+
410
+ ### Asset copying (`assetCopy`)
411
+
412
+ Specify static assets (fonts, stylesheets, images) in `package.json` (or `gofront.json`):
413
+
414
+ ```json
415
+ {
416
+ "assetCopy": [
417
+ { "source": "node_modules/@fontsource/raleway/files", "dest": "app/fonts" },
418
+ { "source": "node_modules/prismjs/themes", "dest": "app/css/prism-themes" }
419
+ ]
420
+ }
421
+ ```
422
+
423
+ Run `gofront prep` to copy them, or pass `--copy-assets` to automatically synchronize assets on build.
424
+
425
+ ### Vendor bundling (`gofront prep`)
426
+
427
+ Package external npm dependencies for the browser:
428
+
429
+ ```sh
430
+ gofront prep
431
+ ```
432
+
433
+ GoFront inspects your `dependencies`, dynamically uses `rolldown` or `esbuild` from your `devDependencies`, and bundles them into an ES module (defaulting to `app/vendor.js` or `vendor.js`).
434
+
435
+ ---
436
+
404
437
  ## Multi-file packages
405
438
 
406
439
  All `.go` files in a directory share the same namespace and are compiled as one unit.
@@ -625,7 +658,7 @@ you know exactly what to expect.
625
658
 
626
659
  ## Roadmap
627
660
 
628
- See [`docs/ROADMAP.md`](docs/ROADMAP.md) for the full roadmap and release history.
661
+ See [`docs/roadmap.md`](docs/roadmap.md) for the full roadmap and release history.
629
662
  Design documents for planned features are organised by release under `docs/v*/`
630
663
  (e.g. [`docs/v0.0.8/`](docs/v0.0.8/), [`docs/v0.0.9/`](docs/v0.0.9/)).
631
664
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gofront",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "author": "Luuk van Venrooij",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,14 +42,14 @@
42
42
  "test:e2e": "npm run build:all && playwright test",
43
43
  "format": "biome format --write .",
44
44
  "check": "biome check . && sentrux check && sentrux gate && node src/index.js example/simple/src --check && node src/index.js example/reactive/src --check && node src/index.js example/gom/src --check && node src/index.js example/templ/src --check",
45
- "prepare": "husky"
45
+ "prepare": "lefthook install"
46
46
  },
47
47
  "devDependencies": {
48
- "@biomejs/biome": "^2.4.12",
49
- "@playwright/test": "^1.59.1",
50
- "c8": "^11.0.0",
51
- "husky": "^9.1.7",
52
- "jsdom": "^29.0.2",
48
+ "@biomejs/biome": "^2.5.14",
49
+ "@playwright/test": "^1.63.0",
50
+ "c8": "^12.0.0",
51
+ "jsdom": "^30.1.0",
52
+ "lefthook": "^2.1.14",
53
53
  "serve": "^14.2.6"
54
54
  }
55
55
  }
@@ -0,0 +1,125 @@
1
+ // Native static asset manager for GoFront projects.
2
+ // Zero-dependency file and directory copier using node:fs.
3
+
4
+ import {
5
+ copyFileSync,
6
+ cpSync,
7
+ existsSync,
8
+ mkdirSync,
9
+ readFileSync,
10
+ statSync,
11
+ } from "node:fs";
12
+ import {
13
+ basename,
14
+ dirname,
15
+ isAbsolute,
16
+ join,
17
+ relative,
18
+ resolve,
19
+ } from "node:path";
20
+
21
+ export function loadAssetConfig(projectDir) {
22
+ const gofrontJsonPath = join(projectDir, "gofront.json");
23
+ if (existsSync(gofrontJsonPath)) {
24
+ try {
25
+ const parsed = JSON.parse(readFileSync(gofrontJsonPath, "utf8"));
26
+ if (Array.isArray(parsed.assetCopy)) return parsed.assetCopy;
27
+ } catch {}
28
+ }
29
+
30
+ const pkgJsonPath = join(projectDir, "package.json");
31
+ if (existsSync(pkgJsonPath)) {
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
34
+ if (Array.isArray(parsed.assetCopy)) return parsed.assetCopy;
35
+ } catch {}
36
+ }
37
+
38
+ return [];
39
+ }
40
+
41
+ function copyFileAsset(srcPath, destPath, dest) {
42
+ let targetFile = destPath;
43
+ if (
44
+ dest.endsWith("/") ||
45
+ (existsSync(destPath) && statSync(destPath).isDirectory())
46
+ ) {
47
+ targetFile = join(destPath, basename(srcPath));
48
+ }
49
+ mkdirSync(dirname(targetFile), { recursive: true });
50
+ copyFileSync(srcPath, targetFile);
51
+ return targetFile;
52
+ }
53
+
54
+ function copyAssetEntry(entry, projectRoot) {
55
+ if (!entry || typeof entry !== "object") return null;
56
+ const { source, dest } = entry;
57
+ if (!source || !dest) return null;
58
+
59
+ const destPath = resolve(projectRoot, dest);
60
+ const relDest = relative(projectRoot, destPath);
61
+ if (relDest.startsWith("..") || isAbsolute(relDest)) {
62
+ throw new Error(
63
+ `gofront: asset destination '${dest}' is outside project directory`,
64
+ );
65
+ }
66
+
67
+ const srcPath = resolve(projectRoot, source);
68
+ if (!existsSync(srcPath)) {
69
+ console.warn(`gofront: asset source '${source}' not found — skipping`);
70
+ return {
71
+ status: "skipped",
72
+ item: { source, dest, status: "skipped", reason: "source not found" },
73
+ };
74
+ }
75
+
76
+ try {
77
+ const stat = statSync(srcPath);
78
+ if (stat.isDirectory()) {
79
+ mkdirSync(destPath, { recursive: true });
80
+ cpSync(srcPath, destPath, { recursive: true, force: true });
81
+ return {
82
+ status: "copied",
83
+ item: { source, dest, type: "directory", status: "copied" },
84
+ };
85
+ }
86
+ const targetFile = copyFileAsset(srcPath, destPath, dest);
87
+ return {
88
+ status: "copied",
89
+ item: { source, dest: targetFile, type: "file", status: "copied" },
90
+ };
91
+ } catch (err) {
92
+ console.warn(`gofront: failed to copy asset '${source}': ${err.message}`);
93
+ return {
94
+ status: "failed",
95
+ item: { source, dest, status: "failed", reason: err.message },
96
+ };
97
+ }
98
+ }
99
+
100
+ export function copyAssets(projectDir = ".", config = null) {
101
+ const projectRoot = resolve(projectDir);
102
+ let entries;
103
+
104
+ if (Array.isArray(config)) {
105
+ entries = config;
106
+ } else if (config && Array.isArray(config.assetCopy)) {
107
+ entries = config.assetCopy;
108
+ } else {
109
+ entries = loadAssetConfig(projectRoot);
110
+ }
111
+
112
+ const items = [];
113
+ let copied = 0;
114
+ let skipped = 0;
115
+
116
+ for (const entry of entries) {
117
+ const result = copyAssetEntry(entry, projectRoot);
118
+ if (!result) continue;
119
+ if (result.status === "copied") copied++;
120
+ else skipped++;
121
+ items.push(result.item);
122
+ }
123
+
124
+ return { copied, skipped, items };
125
+ }
package/src/cli-core.js CHANGED
@@ -3,8 +3,10 @@
3
3
 
4
4
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
+ import { copyAssets } from "./asset-manager.js";
6
7
  import { compileDir, compileSingleFile } from "./compiler.js";
7
8
  import { minify } from "./minifier.js";
9
+ import { bundleVendor } from "./vendor.js";
8
10
 
9
11
  export function runCompile(inputPath, isDir, options) {
10
12
  const {
@@ -63,3 +65,10 @@ export function handleInit(targetDir) {
63
65
 
64
66
  return { mainPath };
65
67
  }
68
+
69
+ export async function handlePrep(targetDir, options = {}) {
70
+ const resolvedTarget = resolve(targetDir);
71
+ const assets = copyAssets(resolvedTarget, options.assetConfig);
72
+ const vendor = await bundleVendor(resolvedTarget, options.vendorConfig);
73
+ return { assets, vendor };
74
+ }
package/src/dev-server.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // Minimal dev server for gofront --serve watch mode.
2
2
  // Serves static files and pushes a reload event via SSE after each build.
3
3
 
4
- import { existsSync, readFileSync } from "node:fs";
4
+ import { existsSync, readFileSync, statSync } from "node:fs";
5
5
  import { createServer } from "node:http";
6
- import { extname, join } from "node:path";
6
+ import { extname, join, resolve } from "node:path";
7
7
 
8
- const MIME = {
8
+ export const MIME = {
9
9
  ".html": "text/html; charset=utf-8",
10
10
  ".js": "application/javascript; charset=utf-8",
11
11
  ".css": "text/css; charset=utf-8",
@@ -22,42 +22,97 @@ const MIME = {
22
22
  // Injected at the bottom of compiled JS in serve mode.
23
23
  export const liveReloadClient = `(function(){var es=new EventSource('/_gofront/events');es.addEventListener('reload',function(){location.reload();});})();`;
24
24
 
25
- export function createDevServer(serveDir, port) {
26
- const clients = new Set();
25
+ export function handleDevRequest(req, res, serveDir, clients = new Set()) {
26
+ // SSE endpoint — browser connects here to receive reload events
27
+ if (req.url === "/_gofront/events") {
28
+ res.writeHead(200, {
29
+ "Content-Type": "text/event-stream",
30
+ "Cache-Control": "no-cache",
31
+ Connection: "keep-alive",
32
+ });
33
+ res.write(": connected\n\n");
34
+ clients.add(res);
35
+ req.on?.("close", () => clients.delete(res));
36
+ return;
37
+ }
27
38
 
28
- const server = createServer((req, res) => {
29
- // SSE endpoint — browser connects here to receive reload events
30
- if (req.url === "/_gofront/events") {
31
- res.writeHead(200, {
32
- "Content-Type": "text/event-stream",
33
- "Cache-Control": "no-cache",
34
- Connection: "keep-alive",
35
- });
36
- res.write(": connected\n\n");
37
- clients.add(res);
38
- req.on("close", () => clients.delete(res));
39
- return;
40
- }
39
+ let urlPath = req.url.split("?")[0].split("#")[0];
40
+ try {
41
+ urlPath = decodeURIComponent(urlPath);
42
+ } catch {
43
+ // Ignore malformed URL encoding and use raw path
44
+ }
45
+
46
+ if (urlPath === "/" || urlPath === "") urlPath = "/index.html";
41
47
 
42
- let urlPath = req.url.split("?")[0];
43
- if (urlPath === "/" || urlPath === "") urlPath = "/index.html";
48
+ const resolvedServe = resolve(serveDir);
49
+ const filePath = resolve(serveDir, `.${urlPath}`);
50
+ if (!filePath.startsWith(resolvedServe)) {
51
+ res.writeHead(403, { "Content-Type": "text/plain" });
52
+ res.end("Forbidden");
53
+ return;
54
+ }
55
+
56
+ if (!existsSync(filePath)) {
57
+ // SPA fallback: clean paths with no file extension fall back to index.html
58
+ const ext = extname(urlPath);
59
+ if (!ext) {
60
+ const indexPath = join(serveDir, "index.html");
61
+ if (existsSync(indexPath)) {
62
+ try {
63
+ const data = readFileSync(indexPath);
64
+ res.writeHead(200, { "Content-Type": MIME[".html"] });
65
+ res.end(data);
66
+ return;
67
+ } catch {
68
+ res.writeHead(500, { "Content-Type": "text/plain" });
69
+ res.end("Server error");
70
+ return;
71
+ }
72
+ }
73
+ }
74
+ res.writeHead(404, { "Content-Type": "text/plain" });
75
+ res.end("Not found");
76
+ return;
77
+ }
44
78
 
45
- const filePath = join(serveDir, urlPath);
46
- if (!existsSync(filePath)) {
79
+ try {
80
+ const stat = statSync(filePath);
81
+ if (stat.isDirectory()) {
82
+ const dirIndex = join(filePath, "index.html");
83
+ if (existsSync(dirIndex)) {
84
+ const data = readFileSync(dirIndex);
85
+ res.writeHead(200, { "Content-Type": MIME[".html"] });
86
+ res.end(data);
87
+ return;
88
+ }
89
+ const rootIndex = join(serveDir, "index.html");
90
+ if (existsSync(rootIndex)) {
91
+ const data = readFileSync(rootIndex);
92
+ res.writeHead(200, { "Content-Type": MIME[".html"] });
93
+ res.end(data);
94
+ return;
95
+ }
47
96
  res.writeHead(404, { "Content-Type": "text/plain" });
48
97
  res.end("Not found");
49
98
  return;
50
99
  }
51
100
 
52
- try {
53
- const data = readFileSync(filePath);
54
- const mime = MIME[extname(filePath)] ?? "application/octet-stream";
55
- res.writeHead(200, { "Content-Type": mime });
56
- res.end(data);
57
- } catch {
58
- res.writeHead(500, { "Content-Type": "text/plain" });
59
- res.end("Server error");
60
- }
101
+ const data = readFileSync(filePath);
102
+ const mime = MIME[extname(filePath)] ?? "application/octet-stream";
103
+ res.writeHead(200, { "Content-Type": mime });
104
+ res.end(data);
105
+ } catch {
106
+ res.writeHead(500, { "Content-Type": "text/plain" });
107
+ res.end("Server error");
108
+ }
109
+ }
110
+
111
+ export function createDevServer(serveDir, port = 3000) {
112
+ const clients = new Set();
113
+
114
+ const server = createServer((req, res) => {
115
+ handleDevRequest(req, res, serveDir, clients);
61
116
  });
62
117
 
63
118
  server.on("error", (err) => {
@@ -70,8 +125,12 @@ export function createDevServer(serveDir, port) {
70
125
  }
71
126
  process.exit(1);
72
127
  });
128
+
73
129
  server.listen(port, () => {
74
- console.error(`gofront: dev server → http://localhost:${port}`);
130
+ const actualPort = server.address()?.port ?? port;
131
+ if (port !== 0) {
132
+ console.error(`gofront: dev server → http://localhost:${actualPort}`);
133
+ }
75
134
  });
76
135
 
77
136
  function notify() {
@@ -80,5 +139,9 @@ export function createDevServer(serveDir, port) {
80
139
  }
81
140
  }
82
141
 
83
- return { notify };
142
+ return {
143
+ notify,
144
+ server,
145
+ close: () => new Promise((res) => server.close(res)),
146
+ };
84
147
  }
package/src/index.js CHANGED
@@ -18,9 +18,10 @@ import { createRequire } from "node:module";
18
18
  const _require = createRequire(import.meta.url);
19
19
  const { version } = _require("../package.json");
20
20
 
21
- import { statSync, watch, writeFileSync } from "node:fs";
21
+ import { mkdirSync, statSync, watch, writeFileSync } from "node:fs";
22
22
  import { dirname, resolve } from "node:path";
23
- import { handleInit, maybeMinify, runCompile } from "./cli-core.js";
23
+ import { copyAssets } from "./asset-manager.js";
24
+ import { handleInit, handlePrep, maybeMinify, runCompile } from "./cli-core.js";
24
25
  import { createDevServer, liveReloadClient } from "./dev-server.js";
25
26
 
26
27
  // ── Parse CLI args ───────────────────────────────────────────
@@ -44,11 +45,13 @@ Usage:
44
45
  gofront <input> --watch Watch for changes and recompile
45
46
  gofront <input> -o out.js --serve Watch + serve with live reload (default port 3000)
46
47
  gofront <input> -o out.js --serve --port 8080 Use a custom port
48
+ gofront <input> -o out.js --copy-assets Compile and synchronize static assets
47
49
  gofront <input> --source-map Append inline source map to output
48
50
  gofront <input> --minify Minify output
49
51
  gofront <input> --minify --mangle Minify and mangle identifiers
50
52
  gofront <file.go> --ast Dump AST (debug)
51
53
  gofront <file.go> --tokens Dump tokens (debug)
54
+ gofront prep [dir] Copy static assets and bundle vendor dependencies
52
55
  gofront init [dir] Scaffold a new GoFront project
53
56
  gofront --version Print version
54
57
  `.trim(),
@@ -56,6 +59,30 @@ Usage:
56
59
  process.exit(0);
57
60
  }
58
61
 
62
+ // ── prep / vendor subcommand ──────────────────────────────────
63
+
64
+ if (args[0] === "prep" || args[0] === "vendor") {
65
+ const targetArg = args[1] ?? ".";
66
+ const targetDir = resolve(targetArg);
67
+ try {
68
+ const { assets, vendor } = await handlePrep(targetDir);
69
+ if (assets.copied > 0 || assets.skipped > 0) {
70
+ console.error(
71
+ `gofront: copied ${assets.copied} assets (${assets.skipped} skipped)`,
72
+ );
73
+ }
74
+ if (vendor.bundled?.length > 0) {
75
+ console.error(
76
+ `gofront: bundled ${vendor.bundled.length} vendor dependencies → ${vendor.dest}`,
77
+ );
78
+ }
79
+ } catch (e) {
80
+ console.error(`gofront: ${e.message}`);
81
+ process.exit(1);
82
+ }
83
+ process.exit(0);
84
+ }
85
+
59
86
  // ── init subcommand ──────────────────────────────────────────
60
87
 
61
88
  if (args[0] === "init") {
@@ -84,6 +111,7 @@ const dumpTokens = args.includes("--tokens");
84
111
  const sourceMap = args.includes("--source-map");
85
112
  const serveMode = args.includes("--serve");
86
113
  const watchMode = args.includes("--watch") || serveMode;
114
+ const copyAssetsFlag = args.includes("--copy-assets");
87
115
  const minifyOutput = args.includes("--minify");
88
116
  const mangleOutput = args.includes("--mangle");
89
117
  const portFlag = args.indexOf("--port");
@@ -148,6 +176,7 @@ if (!watchMode) {
148
176
 
149
177
  if (outputFile) {
150
178
  try {
179
+ mkdirSync(dirname(resolve(outputFile)), { recursive: true });
151
180
  writeFileSync(outputFile, `${js}\n`);
152
181
  console.error(`gofront: wrote ${outputFile} (${elapsedMs}ms)`);
153
182
  } catch (e) {
@@ -157,6 +186,19 @@ if (!watchMode) {
157
186
  } else {
158
187
  console.log(js);
159
188
  }
189
+
190
+ if (copyAssetsFlag) {
191
+ try {
192
+ const projectDir = resolve(".");
193
+ const { copied, skipped } = copyAssets(projectDir);
194
+ if (copied > 0 || skipped > 0) {
195
+ console.error(`gofront: copied ${copied} assets (${skipped} skipped)`);
196
+ }
197
+ } catch (e) {
198
+ console.error(`gofront: asset copy failed: ${e.message}`);
199
+ }
200
+ }
201
+
160
202
  process.exit(0);
161
203
  }
162
204
 
@@ -210,6 +252,15 @@ function buildOnce(changedFile = null) {
210
252
  `[${timestamp()}] gofront: OK (${elapsedMs}ms${changeNote})`,
211
253
  );
212
254
  }
255
+
256
+ if (copyAssetsFlag) {
257
+ try {
258
+ copyAssets(resolve("."));
259
+ } catch (e) {
260
+ console.error(`gofront: asset copy failed: ${e.message}`);
261
+ }
262
+ }
263
+
213
264
  devServer?.notify();
214
265
  } catch (e) {
215
266
  console.error(`[${timestamp()}] gofront: ERROR`);
package/src/vendor.js ADDED
@@ -0,0 +1,228 @@
1
+ // Vendor dependency bundler for GoFront projects.
2
+ // Dynamically loads a bundler (rolldown or esbuild) from consumer devDependencies.
3
+
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { createRequire } from "node:module";
12
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+
15
+ export function getExportNames(pkgName) {
16
+ const base = pkgName.replace(/^@[^/]+\//, "");
17
+ const clean = base.replace(/[^a-zA-Z0-9_]/g, "_");
18
+ const names = new Set([pkgName, base, clean]);
19
+
20
+ if (pkgName === "@emailjs/browser") names.add("emailjs");
21
+ if (pkgName === "fuse.js") {
22
+ names.add("Fuse");
23
+ names.add("fuse");
24
+ }
25
+ if (pkgName === "prismjs") {
26
+ names.add("Prism");
27
+ names.add("prism");
28
+ }
29
+ if (pkgName === "marked") {
30
+ names.add("marked");
31
+ }
32
+
33
+ return [...names];
34
+ }
35
+
36
+ export function generateVendorEntry(packages) {
37
+ const imports = [];
38
+ const assignments = [];
39
+ const exports = [];
40
+
41
+ packages.forEach((pkg, idx) => {
42
+ const id = `_dep_${idx}`;
43
+ imports.push(`import * as ${id} from ${JSON.stringify(pkg)};`);
44
+
45
+ const names = getExportNames(pkg);
46
+ for (const name of names) {
47
+ assignments.push(
48
+ ` window[${JSON.stringify(name)}] = ${id}.default || ${id};`,
49
+ );
50
+ }
51
+
52
+ const cleanId = pkg
53
+ .replace(/^@[^/]+\//, "")
54
+ .replace(/[^a-zA-Z0-9_$]/g, "_");
55
+ exports.push(` ${id} as ${cleanId}`);
56
+ });
57
+
58
+ return `// Auto-generated GoFront vendor entry point
59
+ ${imports.join("\n")}
60
+
61
+ if (typeof window !== "undefined") {
62
+ ${assignments.join("\n")}
63
+ }
64
+
65
+ export {
66
+ ${exports.join(",\n")}
67
+ };
68
+ `;
69
+ }
70
+
71
+ export function findBundler(projectDir) {
72
+ const pkgPath = join(projectDir, "package.json");
73
+ let req;
74
+ if (existsSync(pkgPath)) {
75
+ req = createRequire(pkgPath);
76
+ } else {
77
+ req = createRequire(join(projectDir, "dummy.js"));
78
+ }
79
+
80
+ try {
81
+ const path = req.resolve("rolldown");
82
+ return { name: "rolldown", path };
83
+ } catch {}
84
+
85
+ try {
86
+ const path = req.resolve("esbuild");
87
+ return { name: "esbuild", path };
88
+ } catch {}
89
+
90
+ return null;
91
+ }
92
+
93
+ export function loadVendorConfig(projectDir) {
94
+ const pkgPath = join(projectDir, "package.json");
95
+ let pkg = {};
96
+ if (existsSync(pkgPath)) {
97
+ try {
98
+ pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
99
+ } catch {}
100
+ }
101
+
102
+ const gfjPath = join(projectDir, "gofront.json");
103
+ let gfj = {};
104
+ if (existsSync(gfjPath)) {
105
+ try {
106
+ gfj = JSON.parse(readFileSync(gfjPath, "utf8"));
107
+ } catch {}
108
+ }
109
+
110
+ const vendorConfig = gfj.vendor ?? pkg.vendor ?? null;
111
+ const dependencies = Object.keys(pkg.dependencies ?? {});
112
+
113
+ let dest = null;
114
+ let packages = dependencies;
115
+
116
+ if (typeof vendorConfig === "string") {
117
+ dest = vendorConfig;
118
+ } else if (vendorConfig && typeof vendorConfig === "object") {
119
+ if (vendorConfig.dest) dest = vendorConfig.dest;
120
+ if (Array.isArray(vendorConfig.packages)) packages = vendorConfig.packages;
121
+ }
122
+
123
+ if (!dest) {
124
+ dest = existsSync(join(projectDir, "app")) ? "app/vendor.js" : "vendor.js";
125
+ }
126
+
127
+ return { dest, packages };
128
+ }
129
+
130
+ export async function bundleVendor(projectDir = ".", options = {}) {
131
+ const projectRoot = resolve(projectDir);
132
+ const config = loadVendorConfig(projectRoot);
133
+
134
+ const packages = options.packages ?? config.packages;
135
+ const dest = options.dest ?? config.dest;
136
+
137
+ if (!packages || packages.length === 0) {
138
+ return {
139
+ bundled: [],
140
+ skipped: 0,
141
+ dest,
142
+ message: "no external dependencies found",
143
+ };
144
+ }
145
+
146
+ const destPath = resolve(projectRoot, dest);
147
+ const relDest = relative(projectRoot, destPath);
148
+ if (relDest.startsWith("..") || isAbsolute(relDest)) {
149
+ throw new Error(
150
+ `gofront: vendor destination '${dest}' is outside project directory`,
151
+ );
152
+ }
153
+
154
+ const bundlerInfo = options.bundler ?? findBundler(projectRoot);
155
+ if (!bundlerInfo) {
156
+ console.warn(
157
+ "gofront: no bundler found (rolldown or esbuild). Run 'npm install --save-dev rolldown' to enable vendor bundling.",
158
+ );
159
+ return {
160
+ bundled: [],
161
+ skipped: packages.length,
162
+ dest,
163
+ bundler: null,
164
+ reason: "no bundler installed",
165
+ };
166
+ }
167
+
168
+ mkdirSync(dirname(destPath), { recursive: true });
169
+
170
+ // Allow custom bundler execution function (useful for tests and plugins)
171
+ if (typeof bundlerInfo.bundle === "function") {
172
+ await bundlerInfo.bundle({
173
+ projectRoot,
174
+ packages,
175
+ dest: destPath,
176
+ });
177
+ return { bundled: packages, bundler: bundlerInfo.name ?? "custom", dest };
178
+ }
179
+
180
+ const entryCode = generateVendorEntry(packages);
181
+ const entryFile = join(
182
+ projectRoot,
183
+ `.gofront-vendor-entry-${Date.now()}.mjs`,
184
+ );
185
+ writeFileSync(entryFile, entryCode);
186
+
187
+ try {
188
+ if (bundlerInfo.name === "rolldown") {
189
+ const rolldownModule =
190
+ bundlerInfo.instance ??
191
+ (await import(pathToFileURL(bundlerInfo.path).href));
192
+ const rolldownFn =
193
+ rolldownModule.rolldown ||
194
+ rolldownModule.default?.rolldown ||
195
+ rolldownModule.default;
196
+
197
+ const bundle = await rolldownFn({
198
+ input: entryFile,
199
+ cwd: projectRoot,
200
+ });
201
+ await bundle.write({
202
+ file: destPath,
203
+ format: "esm",
204
+ });
205
+ await bundle.close();
206
+ } else if (bundlerInfo.name === "esbuild") {
207
+ const esbuildModule =
208
+ bundlerInfo.instance ??
209
+ (await import(pathToFileURL(bundlerInfo.path).href));
210
+ const buildFn =
211
+ esbuildModule.build || esbuildModule.default?.build || esbuildModule;
212
+
213
+ await buildFn({
214
+ entryPoints: [entryFile],
215
+ bundle: true,
216
+ format: "esm",
217
+ outfile: destPath,
218
+ absWorkingDir: projectRoot,
219
+ });
220
+ }
221
+ } finally {
222
+ if (existsSync(entryFile)) {
223
+ rmSync(entryFile, { force: true });
224
+ }
225
+ }
226
+
227
+ return { bundled: packages, bundler: bundlerInfo.name, dest };
228
+ }