gofront 1.1.0 → 1.2.1

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
@@ -395,6 +395,7 @@ gofront <input> --minify minify output (built-in minifier)
395
395
  gofront <input> --minify --mangle minify and rename local identifiers
396
396
  gofront <file.go> --ast dump AST (debug)
397
397
  gofront <file.go> --tokens dump tokens (debug)
398
+ gofront test [dir] [--dom] run unit tests (-v verbose, -run <regex>)
398
399
  gofront prep [dir] copy static assets and bundle vendor dependencies
399
400
  gofront init [dir] scaffold a new project
400
401
  gofront --version / -v print version
@@ -403,6 +404,64 @@ gofront --help / -h print this help
403
404
 
404
405
  ---
405
406
 
407
+ ## Unit testing (`gofront test`)
408
+
409
+ GoFront provides native, Go-idiomatic unit testing out of the box:
410
+
411
+ - Files ending in `*_test.go` are automatically excluded from production builds (`gofront .`, `gofront -o app.js`) and compiled only when running tests.
412
+ - Built-in `testing` standard library package with `*testing.T`.
413
+ - Discover and execute `func TestXxx(t *testing.T)` functions with standard Go-style output.
414
+
415
+ ### Writing a test
416
+
417
+ ```go
418
+ package store
419
+
420
+ import "testing"
421
+
422
+ func TestAddTodo(t *testing.T) {
423
+ store := NewTodoStore()
424
+ store.Add("Buy milk")
425
+
426
+ if store.Count() != 1 {
427
+ t.Errorf("expected 1 item, got %d", store.Count())
428
+ }
429
+ }
430
+ ```
431
+
432
+ Subtests and skips are fully supported:
433
+
434
+ ```go
435
+ func TestFilter(t *testing.T) {
436
+ t.Run("active", func(t *testing.T) {
437
+ // subtest logic
438
+ })
439
+ t.Run("completed", func(t *testing.T) {
440
+ if testing.Short() {
441
+ t.Skip("skipping in short mode")
442
+ }
443
+ })
444
+ }
445
+ ```
446
+
447
+ ### Running tests
448
+
449
+ ```sh
450
+ gofront test # run tests in current directory
451
+ gofront test ./src # run tests in specific directory
452
+ gofront test -v # verbose output (RUN, PASS/FAIL, logs)
453
+ gofront test -run "Todo" # run only tests matching regex pattern
454
+ gofront test --dom # initialize JSDOM for DOM/gom/templ tests
455
+ ```
456
+
457
+ `--dom` requires `jsdom`. It is resolved from the project directory first and
458
+ then from GoFront's own installation, so a globally installed `gofront` works
459
+ without a project-local copy.
460
+
461
+ Only `TestXxx` functions declared in `*_test.go` files are discovered, matching Go.
462
+
463
+ ---
464
+
406
465
  ## Static assets & vendor bundling
407
466
 
408
467
  GoFront includes native tooling to manage static assets and package external npm dependencies for the browser without requiring external wrappers or complex build tools.
@@ -432,6 +491,27 @@ gofront prep
432
491
 
433
492
  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
493
 
494
+ Each bundled package is exposed on `window` under its full name, its unscoped name,
495
+ and a sanitised identifier (e.g. `fuse.js` → `window["fuse.js"]`, `window.fuse_js`).
496
+ To expose additional global names, add a `vendor.globals` mapping:
497
+
498
+ ```json
499
+ {
500
+ "vendor": {
501
+ "dest": ["app/vendor.js", "public/vendor.js"],
502
+ "minify": true,
503
+ "globals": {
504
+ "fuse.js": ["Fuse"],
505
+ "prismjs": ["Prism"],
506
+ "@emailjs/browser": ["emailjs"]
507
+ }
508
+ }
509
+ }
510
+ ```
511
+
512
+ `dest` accepts a string or an array (the bundle is written once and copied to each
513
+ extra destination). `gofront prep --minify` overrides `minify` from the command line.
514
+
435
515
  ---
436
516
 
437
517
  ## Multi-file packages
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gofront",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "author": "Luuk van Venrooij",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,8 +36,11 @@
36
36
  "build:gom": "node src/index.js example/gom/src -o example/gom/app.js",
37
37
  "build:templ": "node src/index.js example/templ/src -o example/templ/app.js",
38
38
  "build:all": "npm run build:simple && npm run build:reactive && npm run build:gom && npm run build:templ",
39
- "test:all": "npm run test:unit && npm run test:e2e",
39
+ "test": "npm run test:unit",
40
+ "test:all": "npm run test:unit && npm run test:examples && npm run test:examples:dom && npm run test:e2e",
40
41
  "test:unit": "node test/unit/run.js",
42
+ "test:examples": "node src/index.js test example/simple/src && node src/index.js test example/simple/src/utils && node src/index.js test example/reactive/src && node src/index.js test example/reactive/src/utils && node src/index.js test example/gom/src && node src/index.js test example/gom/src/utils && node src/index.js test example/templ/src && node src/index.js test example/templ/src/utils",
43
+ "test:examples:dom": "node src/index.js test --dom example/simple/src && node src/index.js test --dom example/gom/src && node src/index.js test --dom example/templ/src",
41
44
  "test:coverage": "c8 --include='src/**' --reporter=text --reporter=lcov node test/unit/run.js",
42
45
  "test:e2e": "npm run build:all && playwright test",
43
46
  "format": "biome format --write .",
@@ -16,6 +16,7 @@ import {
16
16
  join,
17
17
  relative,
18
18
  resolve,
19
+ sep,
19
20
  } from "node:path";
20
21
 
21
22
  export function loadAssetConfig(projectDir) {
@@ -58,7 +59,11 @@ function copyAssetEntry(entry, projectRoot) {
58
59
 
59
60
  const destPath = resolve(projectRoot, dest);
60
61
  const relDest = relative(projectRoot, destPath);
61
- if (relDest.startsWith("..") || isAbsolute(relDest)) {
62
+ if (
63
+ relDest === ".." ||
64
+ relDest.startsWith(`..${sep}`) ||
65
+ isAbsolute(relDest)
66
+ ) {
62
67
  throw new Error(
63
68
  `gofront: asset destination '${dest}' is outside project directory`,
64
69
  );
package/src/cli-core.js CHANGED
@@ -6,6 +6,7 @@ import { basename, dirname, join, resolve } from "node:path";
6
6
  import { copyAssets } from "./asset-manager.js";
7
7
  import { compileDir, compileSingleFile } from "./compiler.js";
8
8
  import { minify } from "./minifier.js";
9
+ import { runTests } from "./test-runner.js";
9
10
  import { bundleVendor } from "./vendor.js";
10
11
 
11
12
  export function runCompile(inputPath, isDir, options) {
@@ -72,3 +73,53 @@ export async function handlePrep(targetDir, options = {}) {
72
73
  const vendor = await bundleVendor(resolvedTarget, options.vendorConfig);
73
74
  return { assets, vendor };
74
75
  }
76
+
77
+ export function parsePrepArgs(argv) {
78
+ const positional = argv.filter((a) => !a.startsWith("-"));
79
+ return {
80
+ targetDir: positional[0] ?? ".",
81
+ vendorConfig: argv.includes("--minify") ? { minify: true } : {},
82
+ };
83
+ }
84
+
85
+ export function formatPrepSummary({ assets, vendor }) {
86
+ const lines = [];
87
+ if (assets.copied > 0 || assets.skipped > 0) {
88
+ lines.push(`copied ${assets.copied} assets (${assets.skipped} skipped)`);
89
+ }
90
+ if (vendor.bundled?.length > 0) {
91
+ const dest = Array.isArray(vendor.dest)
92
+ ? vendor.dest.join(", ")
93
+ : vendor.dest;
94
+ const min = vendor.minify ? " (minified)" : "";
95
+ lines.push(
96
+ `bundled ${vendor.bundled.length} vendor dependencies → ${dest}${min}`,
97
+ );
98
+ }
99
+ return lines;
100
+ }
101
+
102
+ export function parseTestArgs(argv) {
103
+ let run = null;
104
+ const positional = [];
105
+ for (let i = 0; i < argv.length; i++) {
106
+ const arg = argv[i];
107
+ if (arg === "-run" || arg === "--run") {
108
+ run = argv[++i] ?? null;
109
+ } else if (arg.startsWith("-run=") || arg.startsWith("--run=")) {
110
+ run = arg.slice(arg.indexOf("=") + 1);
111
+ } else if (!arg.startsWith("-")) {
112
+ positional.push(arg);
113
+ }
114
+ }
115
+ return {
116
+ targetDir: positional[0] ?? ".",
117
+ verbose: argv.includes("-v"),
118
+ dom: argv.includes("--dom"),
119
+ run,
120
+ };
121
+ }
122
+
123
+ export function handleTest(targetDir, options = {}) {
124
+ return runTests(targetDir, options);
125
+ }
@@ -345,6 +345,8 @@ export const expressionGenMethods = {
345
345
  return this._genRegexpMethodCall(expr.func.field, expr);
346
346
  if (recvName === "time.Time")
347
347
  return this._genTimeMethodCall(expr.func.field, expr);
348
+ if (recvName === "testing.T")
349
+ return this._genTestingMethodCall(expr.func.field, expr);
348
350
  return undefined;
349
351
  },
350
352
 
@@ -752,6 +754,10 @@ export const expressionGenMethods = {
752
754
  const cmp = `__equal(${l}, ${r})`;
753
755
  return expr.op === "==" ? cmp : `!${cmp}`;
754
756
  }
757
+ if (l === "null" || r === "null") {
758
+ const op = expr.op === "==" ? "==" : "!=";
759
+ return `${l} ${op} ${r}`;
760
+ }
755
761
  }
756
762
  const op = expr.op === "==" ? "===" : expr.op === "!=" ? "!==" : expr.op;
757
763
  return `${l} ${op} ${r}`;
@@ -30,6 +30,7 @@ import {
30
30
  HELPER_PATH_CLEAN,
31
31
  HELPER_S,
32
32
  HELPER_SPRINTF,
33
+ HELPER_TESTING,
33
34
  HELPER_TIME_FMT,
34
35
  HELPER_TIME_PARSE,
35
36
  } from "./runtime.js";
@@ -71,6 +72,7 @@ export class CodeGen {
71
72
  this._usesPathClean = false;
72
73
  this._usesTimeFmt = false;
73
74
  this._usesTimeParse = false;
75
+ this._usesTesting = false;
74
76
  // Iterator (range-over-func) context
75
77
  this._inIteratorBody = false;
76
78
  this._iterDepth = 0;
@@ -137,20 +139,24 @@ export class CodeGen {
137
139
  }
138
140
  }
139
141
 
140
- _callInitAndMain(initNames, program) {
142
+ _callInitAndMain(initNames, program, isTest = false) {
141
143
  for (const name of initNames) this.line(`${name}();`);
142
- if (program.decls.some((d) => d.kind === "FuncDecl" && d.name === "main"))
144
+ if (
145
+ !isTest &&
146
+ program.decls.some((d) => d.kind === "FuncDecl" && d.name === "main")
147
+ )
143
148
  this.line("main();");
144
149
  }
145
150
 
146
- generate(program) {
151
+ generate(program, options = {}) {
152
+ const isTest = options.isTest ?? false;
147
153
  const methods = this._collectDecls(program);
148
154
  this._emitJsImports();
149
155
  this._emitTypeDecls(program, methods);
150
156
  this._emitVarConstDecls(program);
151
157
  const initNames = this._emitFuncDecls(program);
152
- this._callInitAndMain(initNames, program);
153
- this._prependHelpers();
158
+ this._callInitAndMain(initNames, program, isTest);
159
+ this._prependHelpers(isTest);
154
160
  while (this.out[0] === "") this.out.shift();
155
161
  return this.out.join("\n");
156
162
  }
@@ -221,7 +227,8 @@ export class CodeGen {
221
227
  return methods;
222
228
  }
223
229
 
224
- _prependHelpers() {
230
+ _prependHelpers(isTest = false) {
231
+ const needsTesting = isTest || this._usesTesting;
225
232
  const HELPER_MAP = [
226
233
  [this._usesLen, HELPER_LEN],
227
234
  [this._usesAppend, HELPER_APPEND],
@@ -229,12 +236,13 @@ export class CodeGen {
229
236
  [this._usesEqual, HELPER_EQUAL],
230
237
  [this._usesCmul, HELPER_CMUL],
231
238
  [this._usesCdiv, HELPER_CDIV],
232
- [this._usesSprintf, HELPER_SPRINTF],
239
+ [this._usesSprintf || needsTesting, HELPER_SPRINTF],
233
240
  [this._usesError, HELPER_ERROR],
234
241
  [this._usesErrorIs, HELPER_ERROR_IS],
235
242
  [this._usesPathClean, HELPER_PATH_CLEAN],
236
243
  [this._usesTimeFmt, HELPER_TIME_FMT],
237
244
  [this._usesTimeParse, HELPER_TIME_PARSE],
245
+ [needsTesting, HELPER_TESTING],
238
246
  ];
239
247
  const helpers = HELPER_MAP.filter(([flag]) => flag).map(([, h]) => h);
240
248
  if (helpers.length > 0) this.out.unshift(...helpers, "");
@@ -242,14 +250,14 @@ export class CodeGen {
242
250
 
243
251
  // Generate a single bundle from multiple programs (same-package multi-file).
244
252
  // Annotates each decl with its source file index before merging.
245
- generateAll(programs) {
253
+ generateAll(programs, options = {}) {
246
254
  for (let i = 0; i < programs.length; i++) {
247
255
  for (const decl of programs[i].decls) {
248
256
  decl._srcFileIdx = i;
249
257
  }
250
258
  }
251
259
  const merged = { decls: programs.flatMap((p) => p.decls) };
252
- return this.generate(merged);
260
+ return this.generate(merged, options);
253
261
  }
254
262
 
255
263
  // Returns a source map JSON string for the last generate() call.
@@ -56,9 +56,19 @@ export const HELPER_SPRINTF = `var __sprintf = __sprintf || function(f, ...a) {
56
56
  case "s": return pad(String(v == null ? "<nil>" : v), width, false);
57
57
  case "d": return pad(String(Math.trunc(Number(v))), width, zero);
58
58
  case "v": {
59
- if (typeof v === "object" && v !== null && "re" in v && "im" in v) {
60
- const sign = v.im >= 0 ? "+" : "";
61
- return pad("(" + v.re + sign + v.im + "i)", width, false);
59
+ if (typeof v === "object" && v !== null) {
60
+ if ("re" in v && "im" in v) {
61
+ const sign = v.im >= 0 ? "+" : "";
62
+ return pad("(" + v.re + sign + v.im + "i)", width, false);
63
+ }
64
+ if (typeof v.Error === "function") {
65
+ return pad(String(v.Error()), width, false);
66
+ }
67
+ try {
68
+ return pad(JSON.stringify(v), width, false);
69
+ } catch {
70
+ return pad(String(v), width, false);
71
+ }
62
72
  }
63
73
  return pad(String(v == null ? "<nil>" : v), width, false);
64
74
  }
@@ -149,3 +159,108 @@ export const HELPER_TIME_PARSE = `var __timeParse = __timeParse || function(layo
149
159
  return [{ _d: new Date(0) }, "parsing time: cannot parse"];
150
160
  }
151
161
  };`;
162
+
163
+ export const HELPER_TESTING = `var __GoFront_FailNow = __GoFront_FailNow || class extends Error {
164
+ constructor() { super("FailNow"); this.name = "__GoFront_FailNow"; }
165
+ };
166
+ var __GoFront_SkipNow = __GoFront_SkipNow || class extends Error {
167
+ constructor() { super("SkipNow"); this.name = "__GoFront_SkipNow"; }
168
+ };
169
+ var __GoFront_T = __GoFront_T || class {
170
+ constructor(name = "", parent = null) {
171
+ this._name = name;
172
+ this._parent = parent;
173
+ this.failed = false;
174
+ this.skipped = false;
175
+ this._logs = [];
176
+ this._start = typeof performance !== "undefined" ? performance.now() : Date.now();
177
+ }
178
+ Name() { return this._name; }
179
+ Fail() {
180
+ this.failed = true;
181
+ if (this._parent) this._parent.Fail();
182
+ }
183
+ Failed() { return this.failed; }
184
+ FailNow() {
185
+ this.Fail();
186
+ throw new __GoFront_FailNow();
187
+ }
188
+ Log(...args) {
189
+ const msg = args.map(a => {
190
+ if (typeof a === "object" && a !== null) {
191
+ try { return JSON.stringify(a); } catch { return String(a); }
192
+ }
193
+ return String(a);
194
+ }).join(" ");
195
+ this._logs.push(msg);
196
+ }
197
+ Logf(format, ...args) {
198
+ const msg = typeof __sprintf === "function" ? __sprintf(format, ...args) : format;
199
+ this._logs.push(msg);
200
+ }
201
+ Error(...args) {
202
+ this.Fail();
203
+ this.Log(...args);
204
+ }
205
+ Errorf(format, ...args) {
206
+ this.Fail();
207
+ this.Logf(format, ...args);
208
+ }
209
+ Fatal(...args) {
210
+ this.Log(...args);
211
+ this.FailNow();
212
+ }
213
+ Fatalf(format, ...args) {
214
+ this.Logf(format, ...args);
215
+ this.FailNow();
216
+ }
217
+ Skip(...args) {
218
+ this.skipped = true;
219
+ if (args.length > 0) this.Log(...args);
220
+ throw new __GoFront_SkipNow();
221
+ }
222
+ Skipf(format, ...args) {
223
+ this.skipped = true;
224
+ this.Logf(format, ...args);
225
+ throw new __GoFront_SkipNow();
226
+ }
227
+ Skipped() { return this.skipped; }
228
+ Helper() {}
229
+ Run(name, fn) {
230
+ const subName = this._name ? \`\${this._name}/\${name}\` : name;
231
+ const subT = new __GoFront_T(subName, this);
232
+ if (typeof globalThis.__onSubtestStart === "function") globalThis.__onSubtestStart(subName);
233
+ try {
234
+ const res = fn(subT);
235
+ if (res && typeof res.then === "function") {
236
+ return res.then(() => {
237
+ if (typeof globalThis.__onSubtestEnd === "function") globalThis.__onSubtestEnd(subT);
238
+ return !subT.failed;
239
+ }).catch(e => {
240
+ if (e instanceof __GoFront_FailNow) {
241
+ // expected
242
+ } else if (e instanceof __GoFront_SkipNow) {
243
+ // expected
244
+ } else {
245
+ subT.Fail();
246
+ subT.Log("panic: " + (e?.message ?? String(e)));
247
+ }
248
+ if (typeof globalThis.__onSubtestEnd === "function") globalThis.__onSubtestEnd(subT);
249
+ return false;
250
+ });
251
+ }
252
+ } catch (e) {
253
+ if (e instanceof __GoFront_FailNow) {
254
+ // expected on FailNow
255
+ } else if (e instanceof __GoFront_SkipNow) {
256
+ // expected on Skip
257
+ } else {
258
+ subT.Fail();
259
+ subT.Log("panic: " + (e?.message ?? String(e)));
260
+ }
261
+ }
262
+ if (typeof globalThis.__onSubtestEnd === "function") globalThis.__onSubtestEnd(subT);
263
+ return !subT.failed;
264
+ }
265
+ };
266
+ var __testing_T = __GoFront_T;`;
@@ -18,6 +18,7 @@ import { slicesMethods } from "./slices.js";
18
18
  import { sortMethods } from "./sort.js";
19
19
  import { strconvMethods } from "./strconv.js";
20
20
  import { stringsMethods } from "./strings.js";
21
+ import { testingMethods } from "./testing.js";
21
22
  import { timeMethods } from "./time.js";
22
23
  import { unicodeMethods } from "./unicode.js";
23
24
  import { utf8Methods } from "./utf8.js";
@@ -45,6 +46,7 @@ const STDLIB_METHOD_MAP = {
45
46
  rand: "_genRand",
46
47
  utf8: "_genUtf8",
47
48
  path: "_genPath",
49
+ testing: "_genTesting",
48
50
  };
49
51
 
50
52
  const dispatchMethods = {
@@ -94,5 +96,6 @@ export const stdlibGenMethods = {
94
96
  ...timeMethods,
95
97
  ...unicodeMethods,
96
98
  ...utf8Methods,
99
+ ...testingMethods,
97
100
  ...dispatchMethods,
98
101
  };
@@ -0,0 +1,34 @@
1
+ // CodeGen for Go `testing` package and `*testing.T` methods.
2
+
3
+ /** @typedef {import('../index.js').CodeGen} CodeGen */
4
+
5
+ /** @type {ThisType<CodeGen>} */
6
+ export const testingMethods = {
7
+ _genTesting(fn, _a) {
8
+ switch (fn) {
9
+ case "Short":
10
+ return "false";
11
+ case "Verbose":
12
+ return "Boolean(globalThis.__gofront_verbose)";
13
+ }
14
+ return undefined;
15
+ },
16
+
17
+ _genTestingMethodCall(method, expr) {
18
+ this._usesTesting = true;
19
+ const recv = expr.func.expr;
20
+ const t = this.genExpr(recv);
21
+ const args = expr.args
22
+ .map((a) => (a._spread ? `...${this.genExpr(a)}` : this.genExpr(a)))
23
+ .join(", ");
24
+ if (
25
+ method === "Errorf" ||
26
+ method === "Fatalf" ||
27
+ method === "Logf" ||
28
+ method === "Skipf"
29
+ ) {
30
+ this._usesSprintf = true;
31
+ }
32
+ return `${t}.${method}(${args})`;
33
+ },
34
+ };
package/src/compiler.js CHANGED
@@ -62,9 +62,13 @@ function parseGoFrontFile(filePath) {
62
62
  return ast;
63
63
  }
64
64
 
65
- function gwFilesIn(dir) {
65
+ export function gwFilesIn(dir, { includeTests = false } = {}) {
66
66
  return readdirSync(dir)
67
- .filter((f) => f.endsWith(".go") || f.endsWith(".templ"))
67
+ .filter((f) => {
68
+ if (!f.endsWith(".go") && !f.endsWith(".templ")) return false;
69
+ if (!includeTests && f.endsWith("_test.go")) return false;
70
+ return true;
71
+ })
68
72
  .sort() // deterministic order
69
73
  .map((f) => join(dir, f));
70
74
  }
@@ -206,11 +210,17 @@ export function compileSingleFile(inputPath, options = {}) {
206
210
  }
207
211
 
208
212
  export function compileDir(dir, options = {}) {
209
- const files = gwFilesIn(dir);
213
+ const files = gwFilesIn(dir, { includeTests: options.includeTests ?? false });
210
214
  if (files.length === 0) throw new Error(`No .go files found in ${dir}`);
211
215
  return compileFiles(files, { ...options, fromDir: dir });
212
216
  }
213
217
 
218
+ export function compilePackageTests(dir, options = {}) {
219
+ const files = gwFilesIn(dir, { includeTests: true });
220
+ if (files.length === 0) throw new Error(`No .go files found in ${dir}`);
221
+ return compileFiles(files, { ...options, fromDir: dir, isTest: true });
222
+ }
223
+
214
224
  export function compileFiles(files, options = {}) {
215
225
  const fromDir = options.fromDir ?? dirname(resolve(files[0]));
216
226
 
@@ -262,7 +272,9 @@ export function compileFiles(files, options = {}) {
262
272
 
263
273
  // ── 4. Code generation ────────────────────────────────────────
264
274
  const codegen = new CodeGen(checker, jsImports, bundledPackages);
265
- const mainJs = codegen.generateAll(programs);
275
+ const mainJs = codegen.generateAll(programs, {
276
+ isTest: options.isTest ?? false,
277
+ });
266
278
 
267
279
  let js = preambles.length > 0 ? `${preambles.join("\n")}\n${mainJs}` : mainJs;
268
280
 
@@ -278,6 +290,7 @@ export function compileFiles(files, options = {}) {
278
290
  return {
279
291
  pkgName,
280
292
  js,
293
+ programs,
281
294
  exportedSymbols: checker.getExportedSymbols(),
282
295
  exportedTypes: checker.getExportedTypes(),
283
296
  };
package/src/dev-server.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { existsSync, readFileSync, statSync } from "node:fs";
5
5
  import { createServer } from "node:http";
6
- import { extname, join, resolve } from "node:path";
6
+ import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
 
8
8
  export const MIME = {
9
9
  ".html": "text/html; charset=utf-8",
@@ -22,6 +22,12 @@ export 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
+ // True when `target` is `root` itself or a path inside it (no `..` escape).
26
+ function isInsideDir(root, target) {
27
+ const rel = relative(root, target);
28
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
29
+ }
30
+
25
31
  export function handleDevRequest(req, res, serveDir, clients = new Set()) {
26
32
  // SSE endpoint — browser connects here to receive reload events
27
33
  if (req.url === "/_gofront/events") {
@@ -45,9 +51,8 @@ export function handleDevRequest(req, res, serveDir, clients = new Set()) {
45
51
 
46
52
  if (urlPath === "/" || urlPath === "") urlPath = "/index.html";
47
53
 
48
- const resolvedServe = resolve(serveDir);
49
54
  const filePath = resolve(serveDir, `.${urlPath}`);
50
- if (!filePath.startsWith(resolvedServe)) {
55
+ if (!isInsideDir(resolve(serveDir), filePath)) {
51
56
  res.writeHead(403, { "Content-Type": "text/plain" });
52
57
  res.end("Forbidden");
53
58
  return;
package/src/index.js CHANGED
@@ -21,7 +21,16 @@ const { version } = _require("../package.json");
21
21
  import { mkdirSync, statSync, watch, writeFileSync } from "node:fs";
22
22
  import { dirname, resolve } from "node:path";
23
23
  import { copyAssets } from "./asset-manager.js";
24
- import { handleInit, handlePrep, maybeMinify, runCompile } from "./cli-core.js";
24
+ import {
25
+ formatPrepSummary,
26
+ handleInit,
27
+ handlePrep,
28
+ handleTest,
29
+ maybeMinify,
30
+ parsePrepArgs,
31
+ parseTestArgs,
32
+ runCompile,
33
+ } from "./cli-core.js";
25
34
  import { createDevServer, liveReloadClient } from "./dev-server.js";
26
35
 
27
36
  // ── Parse CLI args ───────────────────────────────────────────
@@ -51,7 +60,8 @@ Usage:
51
60
  gofront <input> --minify --mangle Minify and mangle identifiers
52
61
  gofront <file.go> --ast Dump AST (debug)
53
62
  gofront <file.go> --tokens Dump tokens (debug)
54
- gofront prep [dir] Copy static assets and bundle vendor dependencies
63
+ gofront test [dir] [--dom] Run unit tests (-v verbose, -run <regex>)
64
+ gofront prep [dir] [--minify] Copy static assets and bundle vendor dependencies
55
65
  gofront init [dir] Scaffold a new GoFront project
56
66
  gofront --version Print version
57
67
  `.trim(),
@@ -62,20 +72,11 @@ Usage:
62
72
  // ── prep / vendor subcommand ──────────────────────────────────
63
73
 
64
74
  if (args[0] === "prep" || args[0] === "vendor") {
65
- const targetArg = args[1] ?? ".";
66
- const targetDir = resolve(targetArg);
75
+ const { targetDir, vendorConfig } = parsePrepArgs(args.slice(1));
67
76
  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
- }
77
+ const result = await handlePrep(resolve(targetDir), { vendorConfig });
78
+ for (const line of formatPrepSummary(result))
79
+ console.error(`gofront: ${line}`);
79
80
  } catch (e) {
80
81
  console.error(`gofront: ${e.message}`);
81
82
  process.exit(1);
@@ -102,6 +103,19 @@ if (args[0] === "init") {
102
103
  process.exit(0);
103
104
  }
104
105
 
106
+ // ── test subcommand ───────────────────────────────────────────
107
+
108
+ if (args[0] === "test") {
109
+ const { targetDir, ...testOptions } = parseTestArgs(args.slice(1));
110
+ try {
111
+ const result = await handleTest(targetDir, testOptions);
112
+ process.exit(result.exitCode);
113
+ } catch (e) {
114
+ console.error(`gofront: ${e.message}`);
115
+ process.exit(1);
116
+ }
117
+ }
118
+
105
119
  const inputArg = args[0];
106
120
  const outputFlag = args.indexOf("-o");
107
121
  const outputFile = outputFlag !== -1 ? args[outputFlag + 1] : null;
package/src/resolver.js CHANGED
@@ -47,6 +47,7 @@ const BUILTIN_PACKAGES = new Set([
47
47
  "regexp",
48
48
  "path",
49
49
  "path/filepath",
50
+ "testing",
50
51
  ]);
51
52
 
52
53
  // Returns true for local relative paths (start with ./ or ../)
@@ -0,0 +1,309 @@
1
+ // GoFront native test runner and harness generator.
2
+ // Handles test function discovery, test harness emission, and subprocess execution.
3
+
4
+ import { spawn } from "node:child_process";
5
+ import { createRequire } from "node:module";
6
+ import { basename, join, resolve } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { compilePackageTests, gwFilesIn } from "./compiler.js";
9
+
10
+ export function isTestFunc(decl) {
11
+ if (decl.kind !== "FuncDecl") return false;
12
+ if (!decl.name.startsWith("Test")) return false;
13
+ if (decl.params?.length !== 1) return false;
14
+ const pType = decl.params[0].type;
15
+ if (pType?.kind === "PointerType" && pType.base?.name === "testing.T")
16
+ return true;
17
+ if (pType?.kind === "TypeName" && pType.name === "testing.T") return true;
18
+ return false;
19
+ }
20
+
21
+ // Only programs from *_test.go files are scanned, matching Go. Programs
22
+ // without a `_filename` (constructed by hand) are treated as test files.
23
+ export function discoverTests(programs) {
24
+ const testNames = [];
25
+ for (const p of programs) {
26
+ if (p._filename && !p._filename.endsWith("_test.go")) continue;
27
+ for (const d of p.decls) {
28
+ if (isTestFunc(d) && !testNames.includes(d.name)) {
29
+ testNames.push(d.name);
30
+ }
31
+ }
32
+ }
33
+ return testNames;
34
+ }
35
+
36
+ export function generateTestHarness(bundleJs, testNames, options = {}) {
37
+ const runRegex = options.run ? options.run : null;
38
+ const verbose = Boolean(options.verbose);
39
+ const dom = Boolean(options.dom);
40
+ const pkgName = options.pkgName ?? "main";
41
+ // The harness runs from stdin with cwd = project dir, so a bare "jsdom"
42
+ // import only works if the project has it locally. Use the resolved path.
43
+ const jsdomSpec = options.jsdomPath
44
+ ? pathToFileURL(options.jsdomPath).href
45
+ : "jsdom";
46
+
47
+ return `
48
+ ${
49
+ dom
50
+ ? `import { JSDOM } from ${JSON.stringify(jsdomSpec)};
51
+ const __dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
52
+ url: "http://localhost/",
53
+ pretendToBeVisual: true,
54
+ });
55
+ globalThis.window = __dom.window;
56
+ globalThis.document = __dom.window.document;
57
+ try {
58
+ Object.defineProperty(globalThis, "navigator", {
59
+ value: __dom.window.navigator,
60
+ configurable: true,
61
+ writable: true,
62
+ });
63
+ } catch {
64
+ try { globalThis.navigator = __dom.window.navigator; } catch {}
65
+ }
66
+ globalThis.location = __dom.window.location;
67
+ globalThis.history = __dom.window.history;
68
+ globalThis.HTMLElement = __dom.window.HTMLElement;
69
+ globalThis.Element = __dom.window.Element;
70
+ globalThis.Node = __dom.window.Node;
71
+ globalThis.customElements = __dom.window.customElements;
72
+ `
73
+ : `globalThis.window = globalThis.window ?? null;
74
+ globalThis.document = globalThis.document ?? null;
75
+ `
76
+ }
77
+ globalThis.__gofront_verbose = ${verbose};
78
+
79
+ // ── Compiled Package Bundle ──
80
+ ${bundleJs}
81
+ // ─────────────────────────────
82
+
83
+ async function __runGoFrontSuite() {
84
+ const allTests = ${JSON.stringify(testNames)};
85
+ const runFilter = ${runRegex ? `new RegExp(${JSON.stringify(runRegex)})` : "null"};
86
+ const verbose = ${verbose};
87
+ const pkgName = ${JSON.stringify(pkgName)};
88
+
89
+ const testsToRun = runFilter
90
+ ? allTests.filter((name) => runFilter.test(name))
91
+ : allTests;
92
+
93
+ if (testsToRun.length === 0) {
94
+ if (allTests.length > 0 && runFilter) {
95
+ console.log("testing: warning: no tests to run matching " + ${JSON.stringify(runRegex)});
96
+ }
97
+ console.log("PASS");
98
+ console.log("ok \\t" + pkgName + "\\t0.000s");
99
+ process.exit(0);
100
+ }
101
+
102
+ let totalFailures = 0;
103
+ let totalSkipped = 0;
104
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
105
+ const suiteStart = now();
106
+
107
+ // Go-style streaming report. In non-verbose mode only failures and skips are
108
+ // shown, so the "=== RUN" header is emitted late for failures.
109
+ const report = (t) => {
110
+ const elapsed = ((now() - t._start) / 1000).toFixed(2);
111
+ if (t.skipped) {
112
+ console.log("--- SKIP: " + t._name + " (" + elapsed + "s)");
113
+ for (const log of t._logs) console.log(" " + log);
114
+ } else if (t.failed) {
115
+ if (!verbose) console.log("=== RUN " + t._name);
116
+ for (const log of t._logs) console.log(" " + log);
117
+ console.log("--- FAIL: " + t._name + " (" + elapsed + "s)");
118
+ } else if (verbose) {
119
+ for (const log of t._logs) console.log(" " + log);
120
+ console.log("--- PASS: " + t._name + " (" + elapsed + "s)");
121
+ }
122
+ };
123
+
124
+ // FailNow/SkipNow are control flow; anything else is a panic → failure.
125
+ const recordTestError = (t, e) => {
126
+ if (e instanceof __GoFront_FailNow || e instanceof __GoFront_SkipNow) return;
127
+ t.Fail();
128
+ t.Log("panic: " + (e?.message ?? String(e)));
129
+ };
130
+
131
+ globalThis.__onSubtestStart = (name) => {
132
+ if (verbose) console.log("=== RUN " + name);
133
+ };
134
+ globalThis.__onSubtestEnd = report;
135
+
136
+ const __testRegistry = {
137
+ ${testNames.map((n) => ` ${JSON.stringify(n)}: typeof ${n} !== "undefined" ? ${n} : null`).join(",\n")}
138
+ };
139
+
140
+ for (const name of testsToRun) {
141
+ const fn = __testRegistry[name] ?? (typeof globalThis[name] === "function" ? globalThis[name] : null);
142
+ if (typeof fn !== "function") {
143
+ console.error("testing: cannot find test function " + name);
144
+ totalFailures++;
145
+ continue;
146
+ }
147
+
148
+ const t = new __GoFront_T(name);
149
+ if (verbose) console.log("=== RUN " + name);
150
+
151
+ try {
152
+ const res = fn(t);
153
+ if (res && typeof res.then === "function") await res;
154
+ } catch (e) {
155
+ recordTestError(t, e);
156
+ }
157
+
158
+ if (t.skipped) totalSkipped++;
159
+ else if (t.failed) totalFailures++;
160
+ report(t);
161
+ }
162
+
163
+ const suiteElapsed = ((now() - suiteStart) / 1000).toFixed(3);
164
+
165
+ if (totalFailures > 0) {
166
+ console.log("FAIL");
167
+ console.log("FAIL\\t" + pkgName + "\\t" + suiteElapsed + "s");
168
+ process.exit(1);
169
+ } else {
170
+ console.log("PASS");
171
+ console.log("ok \\t" + pkgName + "\\t" + suiteElapsed + "s");
172
+ process.exit(0);
173
+ }
174
+ }
175
+
176
+ __runGoFrontSuite();
177
+ `;
178
+ }
179
+
180
+ // Project-local jsdom wins; fall back to the one next to GoFront (global/npx installs).
181
+ export function resolveJsdomPath(projectDir) {
182
+ for (const from of [join(projectDir, "dummy.js"), import.meta.url]) {
183
+ try {
184
+ return createRequire(from).resolve("jsdom");
185
+ } catch {}
186
+ }
187
+ return null;
188
+ }
189
+
190
+ function validateTestOptions(options, resolvedDir) {
191
+ if (options.run) {
192
+ try {
193
+ new RegExp(options.run);
194
+ } catch (e) {
195
+ throw new Error(`gofront: invalid regex for -run: ${e.message}`);
196
+ }
197
+ }
198
+
199
+ let jsdomPath = null;
200
+ if (options.dom) {
201
+ jsdomPath = resolveJsdomPath(resolvedDir);
202
+ if (!jsdomPath) {
203
+ throw new Error(
204
+ "gofront: --dom requires 'jsdom' to be installed. Run 'npm install --save-dev jsdom' to enable DOM testing.",
205
+ );
206
+ }
207
+ }
208
+ return { jsdomPath };
209
+ }
210
+
211
+ function reportEmptyTests(label, reason, options) {
212
+ const out = `? \t${label}\t[${reason}]\n`;
213
+ if (options.captureOutput) {
214
+ return { exitCode: 0, stdout: out, stderr: "", noTests: true };
215
+ }
216
+ const dest = options.stdout ?? process.stdout;
217
+ dest.write(out);
218
+ return { exitCode: 0, noTests: true };
219
+ }
220
+
221
+ function spawnTestRunner(resolvedDir, harnessJs, testCount, options) {
222
+ return new Promise((resolvePromise, rejectPromise) => {
223
+ const child = spawn(process.execPath, ["--input-type=module", "-"], {
224
+ cwd: resolvedDir,
225
+ env: process.env,
226
+ stdio: ["pipe", "pipe", "pipe"],
227
+ });
228
+
229
+ let stdout = "";
230
+ let stderr = "";
231
+
232
+ child.stdout.on("data", (chunk) => {
233
+ const str = chunk.toString();
234
+ if (options.captureOutput) {
235
+ stdout += str;
236
+ } else {
237
+ (options.stdout ?? process.stdout).write(str);
238
+ }
239
+ });
240
+
241
+ child.stderr.on("data", (chunk) => {
242
+ const str = chunk.toString();
243
+ if (options.captureOutput) {
244
+ stderr += str;
245
+ } else {
246
+ (options.stderr ?? process.stderr).write(str);
247
+ }
248
+ });
249
+
250
+ child.on("error", (err) => {
251
+ rejectPromise(err);
252
+ });
253
+
254
+ child.on("close", (exitCode) => {
255
+ resolvePromise({
256
+ exitCode: exitCode ?? 0,
257
+ stdout,
258
+ stderr,
259
+ testCount,
260
+ });
261
+ });
262
+
263
+ child.stdin.on("error", () => {});
264
+ child.stdin.end(harnessJs);
265
+ });
266
+ }
267
+
268
+ export async function runTests(targetDir, options = {}) {
269
+ const resolvedDir = resolve(targetDir);
270
+ const { jsdomPath } = validateTestOptions(options, resolvedDir);
271
+
272
+ const allFiles = gwFilesIn(resolvedDir, { includeTests: true });
273
+ if (allFiles.length === 0) {
274
+ throw new Error(`No .go files found in ${targetDir}`);
275
+ }
276
+
277
+ const testFiles = allFiles.filter((f) => f.endsWith("_test.go"));
278
+ if (testFiles.length === 0) {
279
+ return reportEmptyTests(basename(resolvedDir), "no test files", options);
280
+ }
281
+
282
+ let compiled;
283
+ try {
284
+ compiled = compilePackageTests(resolvedDir, options);
285
+ } catch (err) {
286
+ const out = `FAIL\t${basename(resolvedDir)} [build failed]\n${err?.message ?? String(err)}\n`;
287
+ if (options.captureOutput) {
288
+ return { exitCode: 1, stdout: "", stderr: out };
289
+ }
290
+ const dest = options.stderr ?? process.stderr;
291
+ dest.write(out);
292
+ return { exitCode: 1 };
293
+ }
294
+
295
+ const { pkgName, js, programs } = compiled;
296
+ const testNames = discoverTests(programs);
297
+
298
+ if (testNames.length === 0) {
299
+ return reportEmptyTests(pkgName, "no tests to run", options);
300
+ }
301
+
302
+ const harnessJs = generateTestHarness(js, testNames, {
303
+ ...options,
304
+ pkgName,
305
+ jsdomPath,
306
+ });
307
+
308
+ return spawnTestRunner(resolvedDir, harnessJs, testNames.length, options);
309
+ }
@@ -225,7 +225,19 @@ export class TypeChecker {
225
225
  _checkTopDeclsPass(programs) {
226
226
  for (const p of programs) {
227
227
  this._setCurrentFile(p);
228
- for (const d of p.decls) this.checkTopDecl(d, this.globals);
228
+ for (const d of p.decls) {
229
+ if (d.kind === "VarDecl" || d.kind === "ConstDecl") {
230
+ this.checkTopDecl(d, this.globals);
231
+ }
232
+ }
233
+ }
234
+ for (const p of programs) {
235
+ this._setCurrentFile(p);
236
+ for (const d of p.decls) {
237
+ if (d.kind === "FuncDecl" || d.kind === "MethodDecl") {
238
+ this.checkTopDecl(d, this.globals);
239
+ }
240
+ }
229
241
  }
230
242
  }
231
243
 
@@ -308,6 +308,64 @@ export function setupExtendedGlobals(globals, types) {
308
308
  },
309
309
  });
310
310
 
311
+ const TESTING_T = {
312
+ kind: "named",
313
+ name: "testing.T",
314
+ underlying: { kind: "struct", fields: new Map(), methods: new Map() },
315
+ };
316
+ const TESTING_PTR = { kind: "pointer", base: TESTING_T };
317
+
318
+ TESTING_T.underlying.methods = new Map([
319
+ ["Error", { kind: "func", params: [ANY], returns: [VOID], variadic: true }],
320
+ [
321
+ "Errorf",
322
+ { kind: "func", params: [STRING, ANY], returns: [VOID], variadic: true },
323
+ ],
324
+ ["Fatal", { kind: "func", params: [ANY], returns: [VOID], variadic: true }],
325
+ [
326
+ "Fatalf",
327
+ { kind: "func", params: [STRING, ANY], returns: [VOID], variadic: true },
328
+ ],
329
+ ["Fail", { kind: "func", params: [], returns: [VOID] }],
330
+ ["Failed", { kind: "func", params: [], returns: [BOOL] }],
331
+ ["FailNow", { kind: "func", params: [], returns: [VOID] }],
332
+ ["Log", { kind: "func", params: [ANY], returns: [VOID], variadic: true }],
333
+ [
334
+ "Logf",
335
+ { kind: "func", params: [STRING, ANY], returns: [VOID], variadic: true },
336
+ ],
337
+ ["Skip", { kind: "func", params: [ANY], returns: [VOID], variadic: true }],
338
+ [
339
+ "Skipf",
340
+ { kind: "func", params: [STRING, ANY], returns: [VOID], variadic: true },
341
+ ],
342
+ ["Skipped", { kind: "func", params: [], returns: [BOOL] }],
343
+ ["Helper", { kind: "func", params: [], returns: [VOID] }],
344
+ [
345
+ "Run",
346
+ {
347
+ kind: "func",
348
+ params: [
349
+ STRING,
350
+ { kind: "func", params: [TESTING_PTR], returns: [VOID] },
351
+ ],
352
+ returns: [BOOL],
353
+ },
354
+ ],
355
+ ["Name", { kind: "func", params: [], returns: [STRING] }],
356
+ ]);
357
+
358
+ globals.define("testing", {
359
+ kind: "namespace",
360
+ name: "testing",
361
+ members: {
362
+ T: TESTING_T,
363
+ Short: { kind: "func", params: [], returns: [BOOL] },
364
+ Verbose: { kind: "func", params: [], returns: [BOOL] },
365
+ },
366
+ });
367
+ types.set("testing.T", TESTING_T);
368
+
311
369
  for (const name of [
312
370
  "append",
313
371
  "len",
package/src/vendor.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // Dynamically loads a bundler (rolldown or esbuild) from consumer devDependencies.
3
3
 
4
4
  import {
5
+ copyFileSync,
5
6
  existsSync,
6
7
  mkdirSync,
7
8
  readFileSync,
@@ -12,28 +13,17 @@ import { createRequire } from "node:module";
12
13
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
13
14
  import { pathToFileURL } from "node:url";
14
15
 
15
- export function getExportNames(pkgName) {
16
+ export function getExportNames(pkgName, globals = {}) {
16
17
  const base = pkgName.replace(/^@[^/]+\//, "");
17
18
  const clean = base.replace(/[^a-zA-Z0-9_]/g, "_");
18
19
  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
-
20
+ const extra = globals[pkgName];
21
+ for (const name of Array.isArray(extra) ? extra : extra ? [extra] : [])
22
+ names.add(name);
33
23
  return [...names];
34
24
  }
35
25
 
36
- export function generateVendorEntry(packages) {
26
+ export function generateVendorEntry(packages, globals = {}) {
37
27
  const imports = [];
38
28
  const assignments = [];
39
29
  const exports = [];
@@ -42,7 +32,7 @@ export function generateVendorEntry(packages) {
42
32
  const id = `_dep_${idx}`;
43
33
  imports.push(`import * as ${id} from ${JSON.stringify(pkg)};`);
44
34
 
45
- const names = getExportNames(pkg);
35
+ const names = getExportNames(pkg, globals);
46
36
  for (const name of names) {
47
37
  assignments.push(
48
38
  ` window[${JSON.stringify(name)}] = ${id}.default || ${id};`,
@@ -90,6 +80,27 @@ export function findBundler(projectDir) {
90
80
  return null;
91
81
  }
92
82
 
83
+ // Normalises `vendor.globals` ({ pkg: string | string[] }) to { pkg: string[] }.
84
+ function normalizeGlobals(raw) {
85
+ if (raw === undefined || raw === null) return {};
86
+ if (typeof raw !== "object" || Array.isArray(raw)) {
87
+ throw new Error(
88
+ "gofront: vendor.globals must be an object mapping package names to global names",
89
+ );
90
+ }
91
+ const out = {};
92
+ for (const [pkg, value] of Object.entries(raw)) {
93
+ const list = Array.isArray(value) ? value : [value];
94
+ if (list.some((v) => typeof v !== "string")) {
95
+ throw new Error(
96
+ `gofront: vendor.globals[${JSON.stringify(pkg)}] must be a string or array of strings`,
97
+ );
98
+ }
99
+ out[pkg] = list;
100
+ }
101
+ return out;
102
+ }
103
+
93
104
  export function loadVendorConfig(projectDir) {
94
105
  const pkgPath = join(projectDir, "package.json");
95
106
  let pkg = {};
@@ -112,19 +123,87 @@ export function loadVendorConfig(projectDir) {
112
123
 
113
124
  let dest = null;
114
125
  let packages = dependencies;
126
+ let minify = false;
127
+ let globals = {};
115
128
 
116
129
  if (typeof vendorConfig === "string") {
117
130
  dest = vendorConfig;
118
131
  } else if (vendorConfig && typeof vendorConfig === "object") {
119
132
  if (vendorConfig.dest) dest = vendorConfig.dest;
120
133
  if (Array.isArray(vendorConfig.packages)) packages = vendorConfig.packages;
134
+ if (typeof vendorConfig.minify === "boolean") minify = vendorConfig.minify;
135
+ globals = normalizeGlobals(vendorConfig.globals);
121
136
  }
122
137
 
123
138
  if (!dest) {
124
139
  dest = existsSync(join(projectDir, "app")) ? "app/vendor.js" : "vendor.js";
125
140
  }
126
141
 
127
- return { dest, packages };
142
+ return { dest, packages, minify, globals };
143
+ }
144
+
145
+ export function resolveDestinationPaths(projectRoot, dest) {
146
+ const destList = Array.isArray(dest) ? dest : [dest];
147
+ if (destList.length === 0) {
148
+ throw new Error(
149
+ "gofront: at least one vendor destination must be specified",
150
+ );
151
+ }
152
+
153
+ return destList.map((d) => {
154
+ const fullPath = resolve(projectRoot, d);
155
+ const relDest = relative(projectRoot, fullPath);
156
+ if (relDest.startsWith("..") || isAbsolute(relDest)) {
157
+ throw new Error(
158
+ `gofront: vendor destination '${d}' is outside project directory`,
159
+ );
160
+ }
161
+ return fullPath;
162
+ });
163
+ }
164
+
165
+ async function runBundler(
166
+ bundlerInfo,
167
+ { projectRoot, entryFile, destPath, minify },
168
+ ) {
169
+ if (bundlerInfo.name === "rolldown") {
170
+ const rolldownModule =
171
+ bundlerInfo.instance ??
172
+ (await import(pathToFileURL(bundlerInfo.path).href));
173
+ const rolldownFn =
174
+ rolldownModule.rolldown ||
175
+ rolldownModule.default?.rolldown ||
176
+ rolldownModule.default;
177
+
178
+ const bundle = await rolldownFn({
179
+ input: entryFile,
180
+ cwd: projectRoot,
181
+ });
182
+ await bundle.write({
183
+ file: destPath,
184
+ format: "esm",
185
+ minify: Boolean(minify),
186
+ });
187
+ await bundle.close();
188
+ return;
189
+ }
190
+
191
+ if (bundlerInfo.name === "esbuild") {
192
+ const esbuildModule =
193
+ bundlerInfo.instance ??
194
+ (await import(pathToFileURL(bundlerInfo.path).href));
195
+ const buildFn =
196
+ esbuildModule.build || esbuildModule.default?.build || esbuildModule;
197
+
198
+ await buildFn({
199
+ entryPoints: [entryFile],
200
+ bundle: true,
201
+ format: "esm",
202
+ outfile: destPath,
203
+ absWorkingDir: projectRoot,
204
+ minify: Boolean(minify),
205
+ });
206
+ }
128
207
  }
129
208
 
130
209
  export async function bundleVendor(projectDir = ".", options = {}) {
@@ -133,24 +212,20 @@ export async function bundleVendor(projectDir = ".", options = {}) {
133
212
 
134
213
  const packages = options.packages ?? config.packages;
135
214
  const dest = options.dest ?? config.dest;
215
+ const minify = options.minify ?? config.minify ?? false;
216
+ const globals = { ...config.globals, ...normalizeGlobals(options.globals) };
136
217
 
137
218
  if (!packages || packages.length === 0) {
138
219
  return {
139
220
  bundled: [],
140
221
  skipped: 0,
141
222
  dest,
223
+ minify: Boolean(minify),
142
224
  message: "no external dependencies found",
143
225
  };
144
226
  }
145
227
 
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
-
228
+ const destPaths = resolveDestinationPaths(projectRoot, dest);
154
229
  const bundlerInfo = options.bundler ?? findBundler(projectRoot);
155
230
  if (!bundlerInfo) {
156
231
  console.warn(
@@ -161,23 +236,31 @@ export async function bundleVendor(projectDir = ".", options = {}) {
161
236
  skipped: packages.length,
162
237
  dest,
163
238
  bundler: null,
239
+ minify: Boolean(minify),
164
240
  reason: "no bundler installed",
165
241
  };
166
242
  }
167
243
 
168
- mkdirSync(dirname(destPath), { recursive: true });
244
+ for (const p of destPaths) {
245
+ mkdirSync(dirname(p), { recursive: true });
246
+ }
169
247
 
170
- // Allow custom bundler execution function (useful for tests and plugins)
171
248
  if (typeof bundlerInfo.bundle === "function") {
172
249
  await bundlerInfo.bundle({
173
250
  projectRoot,
174
251
  packages,
175
- dest: destPath,
252
+ dest: destPaths.length === 1 ? destPaths[0] : destPaths,
253
+ minify: Boolean(minify),
176
254
  });
177
- return { bundled: packages, bundler: bundlerInfo.name ?? "custom", dest };
255
+ return {
256
+ bundled: packages,
257
+ bundler: bundlerInfo.name ?? "custom",
258
+ dest,
259
+ minify: Boolean(minify),
260
+ };
178
261
  }
179
262
 
180
- const entryCode = generateVendorEntry(packages);
263
+ const entryCode = generateVendorEntry(packages, globals);
181
264
  const entryFile = join(
182
265
  projectRoot,
183
266
  `.gofront-vendor-entry-${Date.now()}.mjs`,
@@ -185,38 +268,15 @@ export async function bundleVendor(projectDir = ".", options = {}) {
185
268
  writeFileSync(entryFile, entryCode);
186
269
 
187
270
  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
- });
271
+ await runBundler(bundlerInfo, {
272
+ projectRoot,
273
+ entryFile,
274
+ destPath: destPaths[0],
275
+ minify,
276
+ });
277
+
278
+ for (let i = 1; i < destPaths.length; i++) {
279
+ copyFileSync(destPaths[0], destPaths[i]);
220
280
  }
221
281
  } finally {
222
282
  if (existsSync(entryFile)) {
@@ -224,5 +284,10 @@ export async function bundleVendor(projectDir = ".", options = {}) {
224
284
  }
225
285
  }
226
286
 
227
- return { bundled: packages, bundler: bundlerInfo.name, dest };
287
+ return {
288
+ bundled: packages,
289
+ bundler: bundlerInfo.name,
290
+ dest,
291
+ minify: Boolean(minify),
292
+ };
228
293
  }