gofront 1.2.0 → 1.2.2

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,16 +387,13 @@ 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 & SPA fallback (default port 3000)
390
+ gofront <input> -o out.js --serve watch + serve with live reload (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
393
392
  gofront <input> --source-map append inline source map (single file or directory; multi-file packages emit per-file mappings)
394
393
  gofront <input> --minify minify output (built-in minifier)
395
394
  gofront <input> --minify --mangle minify and rename local identifiers
396
395
  gofront <file.go> --ast dump AST (debug)
397
396
  gofront <file.go> --tokens dump tokens (debug)
398
- gofront test [dir] [--dom] run unit tests (-v verbose, -run <regex>)
399
- gofront prep [dir] copy static assets and bundle vendor dependencies
400
397
  gofront init [dir] scaffold a new project
401
398
  gofront --version / -v print version
402
399
  gofront --help / -h print this help
@@ -404,89 +401,6 @@ gofront --help / -h print this help
404
401
 
405
402
  ---
406
403
 
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
- ---
458
-
459
- ## Static assets & vendor bundling
460
-
461
- GoFront includes native tooling to manage static assets and package external npm dependencies for the browser without requiring external wrappers or complex build tools.
462
-
463
- ### Asset copying (`assetCopy`)
464
-
465
- Specify static assets (fonts, stylesheets, images) in `package.json` (or `gofront.json`):
466
-
467
- ```json
468
- {
469
- "assetCopy": [
470
- { "source": "node_modules/@fontsource/raleway/files", "dest": "app/fonts" },
471
- { "source": "node_modules/prismjs/themes", "dest": "app/css/prism-themes" }
472
- ]
473
- }
474
- ```
475
-
476
- Run `gofront prep` to copy them, or pass `--copy-assets` to automatically synchronize assets on build.
477
-
478
- ### Vendor bundling (`gofront prep`)
479
-
480
- Package external npm dependencies for the browser:
481
-
482
- ```sh
483
- gofront prep
484
- ```
485
-
486
- 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`).
487
-
488
- ---
489
-
490
404
  ## Multi-file packages
491
405
 
492
406
  All `.go` files in a directory share the same namespace and are compiled as one unit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gofront",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "author": "Luuk van Venrooij",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
@@ -74,7 +74,52 @@ export async function handlePrep(targetDir, options = {}) {
74
74
  return { assets, vendor };
75
75
  }
76
76
 
77
- export async function handleTest(targetDir, options = {}) {
78
- const resolvedTarget = resolve(targetDir);
79
- return runTests(resolvedTarget, options);
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);
80
125
  }
@@ -333,10 +333,23 @@ export class CodeGen {
333
333
  if (fields.length === 0) {
334
334
  this.line("constructor() {}");
335
335
  } else {
336
- const params = fields.map((f) => `${f.name} = ${f.zero}`).join(", ");
336
+ // A field named like a type used in a default (e.g. `Project Project`)
337
+ // would shadow that class inside the parameter scope, so alias it.
338
+ const binding = (f) => {
339
+ const re = new RegExp(`\\b${f.name}\\b`);
340
+ return fields.some((o) => re.test(o.zero)) ? `${f.name}$` : f.name;
341
+ };
342
+ const params = fields
343
+ .map((f) => {
344
+ const b = binding(f);
345
+ return b === f.name
346
+ ? `${f.name} = ${f.zero}`
347
+ : `${f.name}: ${b} = ${f.zero}`;
348
+ })
349
+ .join(", ");
337
350
  this.line(`constructor({ ${params} } = {}) {`);
338
351
  this.indented(() => {
339
- for (const f of fields) this.line(`this.${f.name} = ${f.name};`);
352
+ for (const f of fields) this.line(`this.${f.name} = ${binding(f)};`);
340
353
  });
341
354
  this.line("}");
342
355
  }
@@ -448,7 +448,10 @@ export const statementGenMethods = {
448
448
  },
449
449
 
450
450
  _genRangeIterExpr(iterType, iteree, lhs) {
451
- if (this._isMapRangeType(iterType)) return `Object.entries(${iteree})`;
451
+ if (this._isMapRangeType(iterType)) {
452
+ if (lhs.length === 1) return `Object.keys(${iteree})`;
453
+ return `Object.entries(${iteree})`;
454
+ }
452
455
  if (this._isStringRangeType(iterType)) {
453
456
  if (lhs.length === 1) return `Array.from(${iteree}).keys()`;
454
457
  return `Array.from(${iteree}, (__c, __i) => [__i, __c.codePointAt(0)])`;
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
@@ -22,10 +22,13 @@ 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
24
  import {
25
+ formatPrepSummary,
25
26
  handleInit,
26
27
  handlePrep,
27
28
  handleTest,
28
29
  maybeMinify,
30
+ parsePrepArgs,
31
+ parseTestArgs,
29
32
  runCompile,
30
33
  } from "./cli-core.js";
31
34
  import { createDevServer, liveReloadClient } from "./dev-server.js";
@@ -69,29 +72,11 @@ Usage:
69
72
  // ── prep / vendor subcommand ──────────────────────────────────
70
73
 
71
74
  if (args[0] === "prep" || args[0] === "vendor") {
72
- const hasMinify = args.includes("--minify");
73
- const filteredArgs = args.slice(1).filter((a) => !a.startsWith("--"));
74
- const targetArg = filteredArgs[0] ?? ".";
75
- const targetDir = resolve(targetArg);
75
+ const { targetDir, vendorConfig } = parsePrepArgs(args.slice(1));
76
76
  try {
77
- const vendorConfig = hasMinify ? { minify: true } : {};
78
- const { assets, vendor } = await handlePrep(targetDir, {
79
- vendorConfig,
80
- });
81
- if (assets.copied > 0 || assets.skipped > 0) {
82
- console.error(
83
- `gofront: copied ${assets.copied} assets (${assets.skipped} skipped)`,
84
- );
85
- }
86
- if (vendor.bundled?.length > 0) {
87
- const destStr = Array.isArray(vendor.dest)
88
- ? vendor.dest.join(", ")
89
- : vendor.dest;
90
- const minStr = vendor.minify ? " (minified)" : "";
91
- console.error(
92
- `gofront: bundled ${vendor.bundled.length} vendor dependencies → ${destStr}${minStr}`,
93
- );
94
- }
77
+ const result = await handlePrep(resolve(targetDir), { vendorConfig });
78
+ for (const line of formatPrepSummary(result))
79
+ console.error(`gofront: ${line}`);
95
80
  } catch (e) {
96
81
  console.error(`gofront: ${e.message}`);
97
82
  process.exit(1);
@@ -121,35 +106,9 @@ if (args[0] === "init") {
121
106
  // ── test subcommand ───────────────────────────────────────────
122
107
 
123
108
  if (args[0] === "test") {
124
- const flags = args.slice(1);
125
- let targetDir = ".";
126
- let runFilter = null;
127
- const nonFlagArgs = [];
128
- for (let i = 0; i < flags.length; i++) {
129
- const arg = flags[i];
130
- if (arg === "-run" || arg === "--run") {
131
- runFilter = flags[++i] ?? null;
132
- continue;
133
- }
134
- if (arg.startsWith("-run=") || arg.startsWith("--run=")) {
135
- runFilter = arg.slice(arg.indexOf("=") + 1);
136
- continue;
137
- }
138
- if (arg.startsWith("-")) continue;
139
- nonFlagArgs.push(arg);
140
- }
141
- if (nonFlagArgs.length > 0) {
142
- targetDir = nonFlagArgs[0];
143
- }
144
- const verbose = flags.includes("-v");
145
- const dom = flags.includes("--dom");
146
-
109
+ const { targetDir, ...testOptions } = parseTestArgs(args.slice(1));
147
110
  try {
148
- const result = await handleTest(targetDir, {
149
- verbose,
150
- dom,
151
- run: runFilter,
152
- });
111
+ const result = await handleTest(targetDir, testOptions);
153
112
  process.exit(result.exitCode);
154
113
  } catch (e) {
155
114
  console.error(`gofront: ${e.message}`);
package/src/minifier.js CHANGED
@@ -545,10 +545,18 @@ function _applyRenaming(tokens, renameMap) {
545
545
 
546
546
  function mangle(code) {
547
547
  const tokens = tokenize(code);
548
- const nameGen = shortNameGeneratorFn();
549
- const renameMap = new Map();
550
548
  const { declaredLocals } = _collectLocalDecls(tokens);
551
549
  _filterRenameCandidates(declaredLocals);
550
+
551
+ const occupied = new Set(MANGLE_GLOBALS);
552
+ for (const tok of tokens) {
553
+ if (tok.type === "ident" && !declaredLocals.has(tok.value)) {
554
+ occupied.add(tok.value);
555
+ }
556
+ }
557
+
558
+ const nameGen = shortNameGeneratorFn(occupied);
559
+ const renameMap = new Map();
552
560
  for (const name of declaredLocals) renameMap.set(name, nameGen());
553
561
  return _applyRenaming(tokens, renameMap);
554
562
  }
@@ -606,23 +614,27 @@ function collectParams(tokens, openIdx, locals) {
606
614
  }
607
615
  }
608
616
 
609
- function* shortNameGenerator() {
617
+ function* shortNameGenerator(occupied = new Set()) {
610
618
  const chars = "abcdefghijklmnopqrstuvwxyz";
611
619
  let index = 0;
612
620
  while (true) {
621
+ let name;
613
622
  if (index < 26) {
614
- yield chars[index++];
623
+ name = chars[index++];
615
624
  } else {
616
625
  const base = index - 26;
617
- yield chars[base % 26] + (Math.floor(base / 26) + 1);
626
+ name = chars[base % 26] + (Math.floor(base / 26) + 1);
618
627
  index++;
619
628
  }
629
+ if (!occupied.has(name)) {
630
+ yield name;
631
+ }
620
632
  }
621
633
  }
622
634
 
623
635
  // Make it callable (not an iterator interface)
624
- function shortNameGeneratorFn() {
625
- const gen = shortNameGenerator();
636
+ function shortNameGeneratorFn(occupied = new Set()) {
637
+ const gen = shortNameGenerator(occupied);
626
638
  return () => gen.next().value;
627
639
  }
628
640
 
@@ -4,6 +4,7 @@
4
4
  import { spawn } from "node:child_process";
5
5
  import { createRequire } from "node:module";
6
6
  import { basename, join, resolve } from "node:path";
7
+ import { pathToFileURL } from "node:url";
7
8
  import { compilePackageTests, gwFilesIn } from "./compiler.js";
8
9
 
9
10
  export function isTestFunc(decl) {
@@ -17,9 +18,12 @@ export function isTestFunc(decl) {
17
18
  return false;
18
19
  }
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.
20
23
  export function discoverTests(programs) {
21
24
  const testNames = [];
22
25
  for (const p of programs) {
26
+ if (p._filename && !p._filename.endsWith("_test.go")) continue;
23
27
  for (const d of p.decls) {
24
28
  if (isTestFunc(d) && !testNames.includes(d.name)) {
25
29
  testNames.push(d.name);
@@ -34,11 +38,16 @@ export function generateTestHarness(bundleJs, testNames, options = {}) {
34
38
  const verbose = Boolean(options.verbose);
35
39
  const dom = Boolean(options.dom);
36
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";
37
46
 
38
47
  return `
39
48
  ${
40
49
  dom
41
- ? `import { JSDOM } from "jsdom";
50
+ ? `import { JSDOM } from ${JSON.stringify(jsdomSpec)};
42
51
  const __dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
43
52
  url: "http://localhost/",
44
53
  pretendToBeVisual: true,
@@ -92,37 +101,38 @@ async function __runGoFrontSuite() {
92
101
 
93
102
  let totalFailures = 0;
94
103
  let totalSkipped = 0;
95
- const suiteStart = typeof performance !== "undefined" ? performance.now() : Date.now();
104
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
105
+ const suiteStart = now();
96
106
 
97
- globalThis.__onSubtestStart = (name) => {
98
- if (verbose) {
99
- console.log("=== RUN " + name);
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)");
100
121
  }
101
122
  };
102
123
 
103
- globalThis.__onSubtestEnd = (subT) => {
104
- const elapsed = (((typeof performance !== "undefined" ? performance.now() : Date.now()) - subT._start) / 1000).toFixed(2);
105
- if (subT.skipped) {
106
- console.log("--- SKIP: " + subT._name + " (" + elapsed + "s)");
107
- for (const log of subT._logs) {
108
- console.log(" " + log);
109
- }
110
- } else if (subT.failed) {
111
- if (!verbose) {
112
- console.log("=== RUN " + subT._name);
113
- }
114
- for (const log of subT._logs) {
115
- console.log(" " + log);
116
- }
117
- console.log("--- FAIL: " + subT._name + " (" + elapsed + "s)");
118
- } else if (verbose) {
119
- for (const log of subT._logs) {
120
- console.log(" " + log);
121
- }
122
- console.log("--- PASS: " + subT._name + " (" + elapsed + "s)");
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)));
124
129
  };
125
130
 
131
+ globalThis.__onSubtestStart = (name) => {
132
+ if (verbose) console.log("=== RUN " + name);
133
+ };
134
+ globalThis.__onSubtestEnd = report;
135
+
126
136
  const __testRegistry = {
127
137
  ${testNames.map((n) => ` ${JSON.stringify(n)}: typeof ${n} !== "undefined" ? ${n} : null`).join(",\n")}
128
138
  };
@@ -136,54 +146,21 @@ ${testNames.map((n) => ` ${JSON.stringify(n)}: typeof ${n} !== "undefined" ?
136
146
  }
137
147
 
138
148
  const t = new __GoFront_T(name);
139
- if (verbose) {
140
- console.log("=== RUN " + name);
141
- }
149
+ if (verbose) console.log("=== RUN " + name);
142
150
 
143
151
  try {
144
152
  const res = fn(t);
145
- if (res && typeof res.then === "function") {
146
- await res;
147
- }
153
+ if (res && typeof res.then === "function") await res;
148
154
  } catch (e) {
149
- if (e instanceof __GoFront_FailNow) {
150
- // expected on FailNow
151
- } else if (e instanceof __GoFront_SkipNow) {
152
- // expected on SkipNow
153
- } else {
154
- t.Fail();
155
- t.Log("panic: " + (e?.message ?? String(e)));
156
- }
155
+ recordTestError(t, e);
157
156
  }
158
157
 
159
- const elapsed = (((typeof performance !== "undefined" ? performance.now() : Date.now()) - t._start) / 1000).toFixed(2);
160
-
161
- if (t.skipped) {
162
- totalSkipped++;
163
- console.log("--- SKIP: " + name + " (" + elapsed + "s)");
164
- for (const log of t._logs) {
165
- console.log(" " + log);
166
- }
167
- } else if (t.failed) {
168
- totalFailures++;
169
- if (!verbose) {
170
- console.log("=== RUN " + name);
171
- }
172
- for (const log of t._logs) {
173
- console.log(" " + log);
174
- }
175
- console.log("--- FAIL: " + name + " (" + elapsed + "s)");
176
- } else {
177
- if (verbose) {
178
- for (const log of t._logs) {
179
- console.log(" " + log);
180
- }
181
- console.log("--- PASS: " + name + " (" + elapsed + "s)");
182
- }
183
- }
158
+ if (t.skipped) totalSkipped++;
159
+ else if (t.failed) totalFailures++;
160
+ report(t);
184
161
  }
185
162
 
186
- const suiteElapsed = (((typeof performance !== "undefined" ? performance.now() : Date.now()) - suiteStart) / 1000).toFixed(3);
163
+ const suiteElapsed = ((now() - suiteStart) / 1000).toFixed(3);
187
164
 
188
165
  if (totalFailures > 0) {
189
166
  console.log("FAIL");
@@ -200,6 +177,16 @@ __runGoFrontSuite();
200
177
  `;
201
178
  }
202
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
+
203
190
  function validateTestOptions(options, resolvedDir) {
204
191
  if (options.run) {
205
192
  try {
@@ -209,26 +196,16 @@ function validateTestOptions(options, resolvedDir) {
209
196
  }
210
197
  }
211
198
 
199
+ let jsdomPath = null;
212
200
  if (options.dom) {
213
- let jsdomFound = false;
214
- try {
215
- const req = createRequire(join(resolvedDir, "dummy.js"));
216
- req.resolve("jsdom");
217
- jsdomFound = true;
218
- } catch {}
219
- if (!jsdomFound) {
220
- try {
221
- const req = createRequire(import.meta.url);
222
- req.resolve("jsdom");
223
- jsdomFound = true;
224
- } catch {}
225
- }
226
- if (!jsdomFound) {
201
+ jsdomPath = resolveJsdomPath(resolvedDir);
202
+ if (!jsdomPath) {
227
203
  throw new Error(
228
204
  "gofront: --dom requires 'jsdom' to be installed. Run 'npm install --save-dev jsdom' to enable DOM testing.",
229
205
  );
230
206
  }
231
207
  }
208
+ return { jsdomPath };
232
209
  }
233
210
 
234
211
  function reportEmptyTests(label, reason, options) {
@@ -290,7 +267,7 @@ function spawnTestRunner(resolvedDir, harnessJs, testCount, options) {
290
267
 
291
268
  export async function runTests(targetDir, options = {}) {
292
269
  const resolvedDir = resolve(targetDir);
293
- validateTestOptions(options, resolvedDir);
270
+ const { jsdomPath } = validateTestOptions(options, resolvedDir);
294
271
 
295
272
  const allFiles = gwFilesIn(resolvedDir, { includeTests: true });
296
273
  if (allFiles.length === 0) {
@@ -322,14 +299,11 @@ export async function runTests(targetDir, options = {}) {
322
299
  return reportEmptyTests(pkgName, "no tests to run", options);
323
300
  }
324
301
 
325
- const harnessJs = generateTestHarness(bundleJsClean(js), testNames, {
302
+ const harnessJs = generateTestHarness(js, testNames, {
326
303
  ...options,
327
304
  pkgName,
305
+ jsdomPath,
328
306
  });
329
307
 
330
308
  return spawnTestRunner(resolvedDir, harnessJs, testNames.length, options);
331
309
  }
332
-
333
- function bundleJsClean(js) {
334
- return js;
335
- }
package/src/vendor.js CHANGED
@@ -13,28 +13,17 @@ import { createRequire } from "node:module";
13
13
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
14
14
  import { pathToFileURL } from "node:url";
15
15
 
16
- export function getExportNames(pkgName) {
16
+ export function getExportNames(pkgName, globals = {}) {
17
17
  const base = pkgName.replace(/^@[^/]+\//, "");
18
18
  const clean = base.replace(/[^a-zA-Z0-9_]/g, "_");
19
19
  const names = new Set([pkgName, base, clean]);
20
-
21
- if (pkgName === "@emailjs/browser") names.add("emailjs");
22
- if (pkgName === "fuse.js") {
23
- names.add("Fuse");
24
- names.add("fuse");
25
- }
26
- if (pkgName === "prismjs") {
27
- names.add("Prism");
28
- names.add("prism");
29
- }
30
- if (pkgName === "marked") {
31
- names.add("marked");
32
- }
33
-
20
+ const extra = globals[pkgName];
21
+ for (const name of Array.isArray(extra) ? extra : extra ? [extra] : [])
22
+ names.add(name);
34
23
  return [...names];
35
24
  }
36
25
 
37
- export function generateVendorEntry(packages) {
26
+ export function generateVendorEntry(packages, globals = {}) {
38
27
  const imports = [];
39
28
  const assignments = [];
40
29
  const exports = [];
@@ -43,7 +32,7 @@ export function generateVendorEntry(packages) {
43
32
  const id = `_dep_${idx}`;
44
33
  imports.push(`import * as ${id} from ${JSON.stringify(pkg)};`);
45
34
 
46
- const names = getExportNames(pkg);
35
+ const names = getExportNames(pkg, globals);
47
36
  for (const name of names) {
48
37
  assignments.push(
49
38
  ` window[${JSON.stringify(name)}] = ${id}.default || ${id};`,
@@ -91,6 +80,27 @@ export function findBundler(projectDir) {
91
80
  return null;
92
81
  }
93
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
+
94
104
  export function loadVendorConfig(projectDir) {
95
105
  const pkgPath = join(projectDir, "package.json");
96
106
  let pkg = {};
@@ -114,6 +124,7 @@ export function loadVendorConfig(projectDir) {
114
124
  let dest = null;
115
125
  let packages = dependencies;
116
126
  let minify = false;
127
+ let globals = {};
117
128
 
118
129
  if (typeof vendorConfig === "string") {
119
130
  dest = vendorConfig;
@@ -121,13 +132,14 @@ export function loadVendorConfig(projectDir) {
121
132
  if (vendorConfig.dest) dest = vendorConfig.dest;
122
133
  if (Array.isArray(vendorConfig.packages)) packages = vendorConfig.packages;
123
134
  if (typeof vendorConfig.minify === "boolean") minify = vendorConfig.minify;
135
+ globals = normalizeGlobals(vendorConfig.globals);
124
136
  }
125
137
 
126
138
  if (!dest) {
127
139
  dest = existsSync(join(projectDir, "app")) ? "app/vendor.js" : "vendor.js";
128
140
  }
129
141
 
130
- return { dest, packages, minify };
142
+ return { dest, packages, minify, globals };
131
143
  }
132
144
 
133
145
  export function resolveDestinationPaths(projectRoot, dest) {
@@ -201,6 +213,7 @@ export async function bundleVendor(projectDir = ".", options = {}) {
201
213
  const packages = options.packages ?? config.packages;
202
214
  const dest = options.dest ?? config.dest;
203
215
  const minify = options.minify ?? config.minify ?? false;
216
+ const globals = { ...config.globals, ...normalizeGlobals(options.globals) };
204
217
 
205
218
  if (!packages || packages.length === 0) {
206
219
  return {
@@ -247,7 +260,7 @@ export async function bundleVendor(projectDir = ".", options = {}) {
247
260
  };
248
261
  }
249
262
 
250
- const entryCode = generateVendorEntry(packages);
263
+ const entryCode = generateVendorEntry(packages, globals);
251
264
  const entryFile = join(
252
265
  projectRoot,
253
266
  `.gofront-vendor-entry-${Date.now()}.mjs`,