gofront 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +174 -403
  2. package/package.json +16 -19
  3. package/src/{codegen → backend/js}/expressions.js +265 -77
  4. package/src/{codegen → backend/js}/index.js +120 -89
  5. package/src/{codegen → backend/js}/runtime.js +102 -0
  6. package/src/{codegen → backend/js}/source-map.js +32 -0
  7. package/src/{codegen → backend/js}/statements.js +139 -64
  8. package/src/backend/js/stdlib/bits.js +32 -0
  9. package/src/{codegen → backend/js}/stdlib/errors.js +2 -2
  10. package/src/{codegen → backend/js}/stdlib/fmt.js +26 -8
  11. package/src/{codegen → backend/js}/stdlib/index.js +6 -0
  12. package/src/{codegen → backend/js}/stdlib/maps.js +1 -1
  13. package/src/{codegen → backend/js}/stdlib/path.js +2 -2
  14. package/src/backend/js/stdlib/shared.js +17 -0
  15. package/src/{codegen → backend/js}/stdlib/slices.js +1 -1
  16. package/src/{codegen → backend/js}/stdlib/sort.js +1 -1
  17. package/src/backend/js/stdlib/strconv.js +58 -0
  18. package/src/{codegen → backend/js}/stdlib/strings.js +4 -1
  19. package/src/{codegen → backend/js}/stdlib/testing.js +2 -2
  20. package/src/{codegen → backend/js}/stdlib/time.js +3 -3
  21. package/src/{codegen → backend/js}/templ.js +3 -3
  22. package/src/backend/wasm/boundary.js +1798 -0
  23. package/src/backend/wasm/emit-builtins.js +1103 -0
  24. package/src/backend/wasm/emit-exprs.js +1908 -0
  25. package/src/backend/wasm/emit-maps.js +1863 -0
  26. package/src/backend/wasm/emit-shared.js +519 -0
  27. package/src/backend/wasm/emit-stdlib.js +3006 -0
  28. package/src/backend/wasm/emit-stmts.js +2472 -0
  29. package/src/backend/wasm/emit.js +1376 -0
  30. package/src/backend/wasm/encode.js +937 -0
  31. package/src/backend/wasm/glue.js +144 -0
  32. package/src/backend/wasm/index.js +2790 -0
  33. package/src/backend/wasm/monomorph.js +711 -0
  34. package/src/backend/wasm/optimize.js +227 -0
  35. package/src/backend/wasm/types.js +544 -0
  36. package/src/backend/wasm/wat.js +302 -0
  37. package/src/cli-core.js +323 -75
  38. package/src/compiler.js +447 -14
  39. package/src/dev-server.js +56 -15
  40. package/src/index.js +99 -120
  41. package/src/lexer.js +18 -0
  42. package/src/lower/boxing.js +35 -0
  43. package/src/lower/captures.js +172 -0
  44. package/src/lower/embedding.js +30 -0
  45. package/src/lower/escape.js +164 -0
  46. package/src/lower/functions.js +302 -0
  47. package/src/lower/globals.js +316 -0
  48. package/src/lower/index.js +118 -0
  49. package/src/lower/ownership.js +137 -0
  50. package/src/lower/range.js +67 -0
  51. package/src/lower/walk.js +25 -0
  52. package/src/minifier.js +2 -0
  53. package/src/parser/index.js +8 -3
  54. package/src/resolver.js +3 -1
  55. package/src/test-runner.js +94 -20
  56. package/src/typechecker/assignability.js +42 -1
  57. package/src/typechecker/expressions.js +182 -3
  58. package/src/typechecker/index.js +261 -5
  59. package/src/typechecker/resolve.js +35 -6
  60. package/src/typechecker/stdlib/core.js +34 -19
  61. package/src/typechecker/stdlib/extended.js +18 -3
  62. package/src/typechecker/stdlib/shared.js +53 -0
  63. package/src/typechecker/stdlib.js +2 -0
  64. package/src/typechecker/types.js +259 -6
  65. package/src/vendor.js +2 -2
  66. package/src/codegen/stdlib/strconv.js +0 -33
  67. /package/src/{codegen → backend/js}/stdlib/builder.js +0 -0
  68. /package/src/{codegen → backend/js}/stdlib/bytes.js +0 -0
  69. /package/src/{codegen → backend/js}/stdlib/gom.js +0 -0
  70. /package/src/{codegen → backend/js}/stdlib/html.js +0 -0
  71. /package/src/{codegen → backend/js}/stdlib/io.js +0 -0
  72. /package/src/{codegen → backend/js}/stdlib/math.js +0 -0
  73. /package/src/{codegen → backend/js}/stdlib/os.js +0 -0
  74. /package/src/{codegen → backend/js}/stdlib/rand.js +0 -0
  75. /package/src/{codegen → backend/js}/stdlib/regexp.js +0 -0
  76. /package/src/{codegen → backend/js}/stdlib/unicode.js +0 -0
  77. /package/src/{codegen → backend/js}/stdlib/utf8.js +0 -0
package/src/cli-core.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  sep,
24
24
  } from "node:path";
25
25
  import { copyAssets } from "./asset-manager.js";
26
+ import { isGoFrontWasm } from "./backend/wasm/index.js";
26
27
  import { colors, log } from "./colors.js";
27
28
  import { compileDir, compileSingleFile } from "./compiler.js";
28
29
  import { createDevServer } from "./dev-server.js";
@@ -42,11 +43,26 @@ export function runCompile(inputPath, isDir, options) {
42
43
  outputFile = null,
43
44
  dumpTokens = false,
44
45
  dumpAst = false,
46
+ emitWat = false,
47
+ wasmOpt = false,
48
+ forceTarget = null,
49
+ targetOverrides = null,
50
+ rootDir = null,
45
51
  } = options ?? {};
52
+ const targetOpts = {};
53
+ if (forceTarget) targetOpts.forceTarget = forceTarget;
54
+ if (targetOverrides) targetOpts.targetOverrides = targetOverrides;
55
+ if (rootDir) targetOpts.rootDir = rootDir;
46
56
 
47
57
  if (isDir) {
48
58
  const outputDir = outputFile ? dirname(resolve(outputFile)) : resolve(".");
49
- return compileDir(inputPath, { sourceMap, outputDir });
59
+ return compileDir(inputPath, {
60
+ sourceMap,
61
+ outputDir,
62
+ emitWat,
63
+ wasmOpt,
64
+ ...targetOpts,
65
+ });
50
66
  }
51
67
 
52
68
  return compileSingleFile(inputPath, {
@@ -54,9 +70,116 @@ export function runCompile(inputPath, isDir, options) {
54
70
  outputFile,
55
71
  dumpTokens,
56
72
  dumpAst,
73
+ emitWat,
74
+ wasmOpt,
75
+ ...targetOpts,
57
76
  });
58
77
  }
59
78
 
79
+ // `--js-only` / `--target js` compile every package to JavaScript (A/B
80
+ // benchmarking, fallback builds for browsers without WasmGC).
81
+ export function parseTargetFlag(argv) {
82
+ if (argv.includes("--js-only")) return "js";
83
+ for (let i = 0; i < argv.length; i++) {
84
+ const arg = argv[i];
85
+ const value =
86
+ arg === "--target"
87
+ ? argv[i + 1]
88
+ : arg.startsWith("--target=")
89
+ ? arg.slice("--target=".length)
90
+ : null;
91
+ if (value === null || value === undefined) continue;
92
+ if (value !== "js")
93
+ throw new Error(
94
+ `unsupported --target '${value}' (only 'js' is supported; package targets come from //gofront:target directives or gofront.json "targets")`,
95
+ );
96
+ return "js";
97
+ }
98
+ return null;
99
+ }
100
+
101
+ // Per-package target overrides from `gofront.json` (`"targets": { "engine/physics": "wasm" }`).
102
+ function projectTargetOverrides(project) {
103
+ const t = project?.config?.targets;
104
+ return t && typeof t === "object" ? t : null;
105
+ }
106
+
107
+ // Override keys are relative to the project's source root, so every command
108
+ // (build, dev, check, test — including `dir/...`) must resolve them against
109
+ // the same `rootDir` or a sub-package would see itself as ".".
110
+ function projectTargetOptions(project, options = {}) {
111
+ const targetOverrides =
112
+ options.targetOverrides ?? projectTargetOverrides(project);
113
+ if (!targetOverrides) return {};
114
+ return {
115
+ targetOverrides,
116
+ rootDir: options.rootDir ?? project.srcDir,
117
+ };
118
+ }
119
+
120
+ // Removes `--target <value>` / `--target=<value>` / `--js-only` so the value
121
+ // is not mistaken for a positional argument.
122
+ function stripTargetFlag(argv) {
123
+ const out = [];
124
+ for (let i = 0; i < argv.length; i++) {
125
+ const arg = argv[i];
126
+ if (arg === "--target") {
127
+ i++;
128
+ continue;
129
+ }
130
+ if (arg.startsWith("--target=") || arg === "--js-only") continue;
131
+ out.push(arg);
132
+ }
133
+ return out;
134
+ }
135
+
136
+ // Writes the JS bundle plus, when the project links wasm packages, the
137
+ // sibling `app.wasm` (and `app.wat` when requested). Stale wasm artifacts
138
+ // are removed so a project that drops its wasm packages serves clean output —
139
+ // but only when the existing file is one GoFront produced (recognised by its
140
+ // `gofront` custom section), so a hand-placed `app.wasm` is left alone.
141
+ export function writeCompileOutput(outputFile, result, js = result.js) {
142
+ mkdirSync(dirname(outputFile), { recursive: true });
143
+ writeFileSync(outputFile, `${js}\n`);
144
+ const wasmFile = join(dirname(outputFile), "app.wasm");
145
+ const watFile = join(dirname(outputFile), "app.wat");
146
+ const wasmMapFile = join(dirname(outputFile), "app.wasm.map");
147
+ const written = [outputFile];
148
+ let ownsWasm = Boolean(result.wasm);
149
+ if (result.wasm) {
150
+ writeFileSync(wasmFile, result.wasm);
151
+ written.push(wasmFile);
152
+ if (result.wasmSourceMap) {
153
+ writeFileSync(wasmMapFile, result.wasmSourceMap);
154
+ written.push(wasmMapFile);
155
+ } else if (existsSync(wasmMapFile)) {
156
+ rmSync(wasmMapFile);
157
+ }
158
+ } else if (existsSync(wasmFile)) {
159
+ if (isGoFrontWasm(readFileSync(wasmFile))) {
160
+ rmSync(wasmFile);
161
+ ownsWasm = true;
162
+ if (existsSync(wasmMapFile)) rmSync(wasmMapFile);
163
+ }
164
+ }
165
+ if (result.wat) {
166
+ writeFileSync(watFile, result.wat);
167
+ written.push(watFile);
168
+ } else if (ownsWasm && existsSync(watFile)) {
169
+ rmSync(watFile);
170
+ }
171
+ return written;
172
+ }
173
+
174
+ export function formatWrittenDesc(written, outputFile = written?.[0] ?? "") {
175
+ if (!written || written.length <= 1) return colors.cyan(outputFile);
176
+ const extras = written
177
+ .slice(1)
178
+ .map((p) => colors.cyan(basename(p)))
179
+ .join(" + ");
180
+ return `${colors.cyan(outputFile)} + ${extras}`;
181
+ }
182
+
60
183
  export function maybeMinify(js, options) {
61
184
  const {
62
185
  minify: doMinify = false,
@@ -212,10 +335,11 @@ export function formatPrepSummary({ assets, vendor }) {
212
335
  export function parseTestArgs(argv) {
213
336
  let run = null;
214
337
  const positional = [];
215
- for (let i = 0; i < argv.length; i++) {
216
- const arg = argv[i];
338
+ const rest = stripTargetFlag(argv);
339
+ for (let i = 0; i < rest.length; i++) {
340
+ const arg = rest[i];
217
341
  if (arg === "-run" || arg === "--run") {
218
- run = argv[++i] ?? null;
342
+ run = rest[++i] ?? null;
219
343
  } else if (arg.startsWith("-run=") || arg.startsWith("--run=")) {
220
344
  run = arg.slice(arg.indexOf("=") + 1);
221
345
  } else if (!arg.startsWith("-")) {
@@ -227,16 +351,24 @@ export function parseTestArgs(argv) {
227
351
  verbose: argv.includes("-v"),
228
352
  dom: argv.includes("--dom"),
229
353
  run,
354
+ forceTarget: parseTargetFlag(argv),
230
355
  };
231
356
  }
232
357
 
233
358
  export function handleTest(targetDir = ".", options = {}) {
234
359
  const pattern = parsePackagePattern(targetDir);
235
- if (pattern) return runTestsRecursive(pattern, options);
360
+ if (pattern) {
361
+ const project = detectProject(pattern);
362
+ return runTestsRecursive(pattern, {
363
+ ...options,
364
+ ...projectTargetOptions(project, options),
365
+ });
366
+ }
236
367
 
237
368
  let testTarget = targetDir;
369
+ let project = null;
238
370
  try {
239
- const project = detectProject(targetDir);
371
+ project = detectProject(targetDir);
240
372
  const targetPath = resolve(targetDir);
241
373
  if (
242
374
  statSync(targetPath).isDirectory() &&
@@ -248,7 +380,10 @@ export function handleTest(targetDir = ".", options = {}) {
248
380
  : dirname(project.srcDir);
249
381
  }
250
382
  } catch {}
251
- return runTests(testTarget, options);
383
+ return runTests(testTarget, {
384
+ ...options,
385
+ ...projectTargetOptions(project, options),
386
+ });
252
387
  }
253
388
 
254
389
  async function runTestsRecursive(rootDir, options) {
@@ -276,7 +411,7 @@ async function runTestsRecursive(rootDir, options) {
276
411
 
277
412
  // Returns the root directory of a Go-style recursive pattern (`./...`,
278
413
  // `app/src/...`), or null when the argument is a plain path.
279
- export function parsePackagePattern(arg) {
414
+ function parsePackagePattern(arg) {
280
415
  if (arg === "...") return resolve(".");
281
416
  if (!arg.endsWith("/...")) return null;
282
417
  return resolve(arg.slice(0, -4) || ".");
@@ -286,7 +421,7 @@ const SKIPPED_DIRS = new Set(["node_modules", "dist", "public"]);
286
421
 
287
422
  // Lists every directory under root (inclusive) that contains .go or .templ
288
423
  // files, in sorted order, skipping hidden directories and build output.
289
- export function findPackageDirs(root) {
424
+ function findPackageDirs(root) {
290
425
  const out = [];
291
426
  const walk = (dir) => {
292
427
  let entries;
@@ -376,7 +511,7 @@ function resolveDevOutputFile(projectRoot, serveDir, config) {
376
511
  return join(serveDir, "app.js");
377
512
  }
378
513
 
379
- export function detectProject(dir = ".") {
514
+ function detectProject(dir = ".") {
380
515
  const projectRoot = resolve(dir);
381
516
  const config = loadProjectConfig(projectRoot);
382
517
  const srcDir = resolveSrcDir(projectRoot, config);
@@ -399,9 +534,10 @@ export function detectProject(dir = ".") {
399
534
  // ── check command ─────────────────────────────────────────────
400
535
 
401
536
  export function parseCheckArgs(argv) {
402
- const positional = argv.filter((a) => !a.startsWith("-"));
537
+ const positional = stripTargetFlag(argv).filter((a) => !a.startsWith("-"));
403
538
  return {
404
539
  targetDir: positional[0] ?? ".",
540
+ forceTarget: parseTargetFlag(argv),
405
541
  };
406
542
  }
407
543
 
@@ -412,11 +548,12 @@ export function handleCheck(targetDir = ".", options = {}) {
412
548
  if (dirs.length === 0) {
413
549
  throw new Error(`No .go files found under ${targetDir}`);
414
550
  }
551
+ const targetOpts = projectTargetOptions(detectProject(pattern), options);
415
552
  const startMs = performance.now();
416
553
  const packages = [];
417
554
  for (const dir of dirs) {
418
555
  const pkgStart = performance.now();
419
- runCompile(dir, true, { ...options });
556
+ runCompile(dir, true, { ...options, ...targetOpts });
420
557
  packages.push({
421
558
  dir,
422
559
  elapsedMs: (performance.now() - pkgStart).toFixed(0),
@@ -446,7 +583,10 @@ export function handleCheck(targetDir = ".", options = {}) {
446
583
 
447
584
  const targetIsDir = statSync(compileTarget).isDirectory();
448
585
  const startMs = performance.now();
449
- runCompile(compileTarget, targetIsDir, { ...options });
586
+ runCompile(compileTarget, targetIsDir, {
587
+ ...options,
588
+ ...projectTargetOptions(project, options),
589
+ });
450
590
  const elapsedMs = (performance.now() - startMs).toFixed(0);
451
591
  return { target: targetDir, compileTarget, elapsedMs };
452
592
  }
@@ -460,6 +600,11 @@ export function parseBuildArgs(argv) {
460
600
  const sourceMap = argv.includes("--source-map");
461
601
  const noMinify = argv.includes("--no-minify");
462
602
  const noMangle = argv.includes("--no-mangle");
603
+ const emitWat = argv.includes("--emit-wat");
604
+ const release = argv.includes("--release");
605
+ const noWasmOpt = argv.includes("--no-wasm-opt");
606
+ const wasmOpt = !noWasmOpt && (argv.includes("--wasm-opt") || release);
607
+ const forceTarget = parseTargetFlag(argv);
463
608
 
464
609
  for (let i = 0; i < argv.length; i++) {
465
610
  const arg = argv[i];
@@ -467,6 +612,8 @@ export function parseBuildArgs(argv) {
467
612
  outDir = argv[++i] ?? null;
468
613
  } else if (arg.startsWith("--out-dir=")) {
469
614
  outDir = arg.slice(arg.indexOf("=") + 1);
615
+ } else if (arg === "--target") {
616
+ i++;
470
617
  } else if (!arg.startsWith("-")) {
471
618
  positional.push(arg);
472
619
  }
@@ -477,8 +624,12 @@ export function parseBuildArgs(argv) {
477
624
  outDir,
478
625
  pwa,
479
626
  sourceMap,
627
+ emitWat,
480
628
  minify: !noMinify,
481
629
  mangle: !noMangle && !noMinify,
630
+ release,
631
+ wasmOpt,
632
+ forceTarget,
482
633
  };
483
634
  }
484
635
 
@@ -640,12 +791,19 @@ function collectOutputFiles(outDir) {
640
791
  return files;
641
792
  }
642
793
 
643
- export function formatBuildSummary({ outDir, elapsedMs, files, pwa }) {
794
+ export function formatBuildSummary({ outDir, elapsedMs, files, pwa, wasmOpt }) {
644
795
  const lines = [`build complete in ${elapsedMs}ms → ${outDir}`];
645
796
  for (const f of files) {
646
797
  const sizeKb = (f.size / 1024).toFixed(1);
647
798
  lines.push(` ${f.name} (${sizeKb} kB)`);
648
799
  }
800
+ if (wasmOpt && wasmOpt.originalSize > 0) {
801
+ const origKb = (wasmOpt.originalSize / 1024).toFixed(1);
802
+ const optKb = (wasmOpt.optimizedSize / 1024).toFixed(1);
803
+ lines.push(
804
+ ` wasm-opt: app.wasm ${origKb} kB → ${optKb} kB (-${wasmOpt.percentSaved}%) [${wasmOpt.engine}]`,
805
+ );
806
+ }
649
807
  if (pwa) {
650
808
  lines.push(
651
809
  ` pwa: generated sw.js (${pwa.assetsCount} cached assets, ${pwa.version})`,
@@ -667,6 +825,10 @@ export async function handleBuild(targetDir = ".", options = {}) {
667
825
  const compileResult = runCompile(srcDir, isDir, {
668
826
  outputFile,
669
827
  sourceMap: options.sourceMap ?? false,
828
+ emitWat: options.emitWat ?? false,
829
+ wasmOpt: options.wasmOpt ?? false,
830
+ forceTarget: options.forceTarget ?? null,
831
+ ...projectTargetOptions(project, options),
670
832
  });
671
833
 
672
834
  const doMinify = options.minify ?? true;
@@ -676,7 +838,7 @@ export async function handleBuild(targetDir = ".", options = {}) {
676
838
  mangle: doMangle,
677
839
  sourceMap: options.sourceMap ?? false,
678
840
  });
679
- writeFileSync(outputFile, `${js}\n`);
841
+ writeCompileOutput(outputFile, compileResult, js);
680
842
 
681
843
  let vendor = null;
682
844
  try {
@@ -712,16 +874,45 @@ export async function handleBuild(targetDir = ".", options = {}) {
712
874
  assets,
713
875
  vendor,
714
876
  pwa,
877
+ wasmOpt: compileResult.wasmOptInfo,
715
878
  };
716
879
  }
717
880
 
718
881
  export { generatePwa };
719
882
 
883
+ // ── legacy `gofront <input> [flags]` command ──────────────────
884
+
885
+ export function parseLegacyArgs(argv) {
886
+ const outputFlag = argv.indexOf("-o");
887
+ const portFlag = argv.indexOf("--port");
888
+ const serve = argv.includes("--serve");
889
+ const release = argv.includes("--release");
890
+ return {
891
+ inputArg: argv[0],
892
+ outputFile: outputFlag !== -1 ? (argv[outputFlag + 1] ?? null) : null,
893
+ checkOnly: argv.includes("--check"),
894
+ dumpAst: argv.includes("--ast"),
895
+ dumpTokens: argv.includes("--tokens"),
896
+ sourceMap: argv.includes("--source-map"),
897
+ forceTarget: parseTargetFlag(argv),
898
+ serve,
899
+ watch: argv.includes("--watch") || serve,
900
+ copyAssets: argv.includes("--copy-assets"),
901
+ release,
902
+ wasmOpt:
903
+ !argv.includes("--no-wasm-opt") &&
904
+ (argv.includes("--wasm-opt") || release),
905
+ minify: argv.includes("--minify") || release,
906
+ mangle: argv.includes("--mangle") || release,
907
+ port: portFlag !== -1 ? parseInt(argv[portFlag + 1], 10) : 3000,
908
+ };
909
+ }
910
+
720
911
  // ── dev command ───────────────────────────────────────────────
721
912
 
722
913
  export function parseDevArgs(argv) {
723
914
  let port = null;
724
- let outputFile = null;
915
+ let outputFile; // undefined → fall back to project.devOutputFile
725
916
  const positional = [];
726
917
  for (let i = 0; i < argv.length; i++) {
727
918
  const arg = argv[i];
@@ -730,7 +921,9 @@ export function parseDevArgs(argv) {
730
921
  } else if (arg.startsWith("--port=")) {
731
922
  port = parseInt(arg.slice(arg.indexOf("=") + 1), 10);
732
923
  } else if (arg === "-o" || arg === "--output") {
733
- outputFile = argv[++i] ?? null;
924
+ outputFile = argv[++i];
925
+ } else if (arg === "--target") {
926
+ i++;
734
927
  } else if (!arg.startsWith("-")) {
735
928
  positional.push(arg);
736
929
  }
@@ -739,30 +932,58 @@ export function parseDevArgs(argv) {
739
932
  targetDir: positional[0] ?? ".",
740
933
  port: Number.isNaN(port) ? null : port,
741
934
  outputFile,
935
+ forceTarget: parseTargetFlag(argv),
742
936
  };
743
937
  }
744
938
 
745
- function buildDevOnce(
746
- { srcDir, isDir, outputFile, sourceMap, devServer },
939
+ // Compiles once and writes the output when an output file is configured.
940
+ // Returns the build info; throws on compile errors.
941
+ function runDevBuild(
942
+ {
943
+ srcDir,
944
+ isDir,
945
+ outputFile,
946
+ sourceMap,
947
+ minify,
948
+ mangle,
949
+ forceTarget,
950
+ targetOverrides,
951
+ rootDir,
952
+ },
747
953
  changedFile = null,
748
954
  ) {
955
+ const startMs = performance.now();
956
+ const result = runCompile(srcDir, isDir, {
957
+ outputFile,
958
+ sourceMap,
959
+ forceTarget,
960
+ targetOverrides,
961
+ rootDir,
962
+ });
963
+ const js = maybeMinify(result.js, { minify, mangle, sourceMap });
964
+ const written = outputFile
965
+ ? writeCompileOutput(outputFile, result, js)
966
+ : null;
967
+ const elapsedMs = (performance.now() - startMs).toFixed(0);
968
+ return { result, js, written, outputFile, elapsedMs, changedFile };
969
+ }
970
+
971
+ function reportDevBuild({ written, outputFile, elapsedMs, changedFile }) {
972
+ const note = changedFile ? ` — ${changedFile} changed` : "";
973
+ log.ok(
974
+ `— wrote ${formatWrittenDesc(written, outputFile)} ${colors.dim(`(${elapsedMs}ms${note})`)}`,
975
+ );
976
+ }
977
+
978
+ function buildDevOnce(config, changedFile = null) {
749
979
  try {
750
- const startMs = performance.now();
751
- const result = runCompile(srcDir, isDir, {
752
- outputFile,
753
- sourceMap: sourceMap ?? true,
754
- });
755
- mkdirSync(dirname(outputFile), { recursive: true });
756
- writeFileSync(outputFile, `${result.js}\n`);
757
- const elapsedMs = (performance.now() - startMs).toFixed(0);
758
- const note = changedFile ? ` — ${changedFile} changed` : "";
759
- log.ok(
760
- `— wrote ${colors.cyan(outputFile)} ${colors.dim(`(${elapsedMs}ms${note})`)}`,
761
- );
762
- devServer.notify();
980
+ const info = runDevBuild(config, changedFile);
981
+ (config.onBuild ?? reportDevBuild)(info);
982
+ config.devServer?.notify();
763
983
  } catch (e) {
764
- log.error(e.message);
765
- devServer.notifyError(e);
984
+ if (config.onError) config.onError(e);
985
+ else log.error(e.message);
986
+ config.devServer?.notifyError(e);
766
987
  }
767
988
  }
768
989
 
@@ -805,10 +1026,12 @@ function setupDevWatchers(config) {
805
1026
  let htmlDebounce = null;
806
1027
 
807
1028
  const handleCss = (filename) => {
808
- try {
809
- copyAssets(config.project.projectRoot, config.assetConfig);
810
- } catch {}
811
- config.devServer.notifyCss(filename);
1029
+ if (config.copyAssets) {
1030
+ try {
1031
+ copyAssets(config.project.projectRoot, config.assetConfig);
1032
+ } catch {}
1033
+ }
1034
+ config.devServer?.notifyCss(filename);
812
1035
  };
813
1036
 
814
1037
  const watchers = [];
@@ -825,22 +1048,24 @@ function setupDevWatchers(config) {
825
1048
  );
826
1049
  if (srcWatcher) watchers.push(srcWatcher);
827
1050
 
828
- const serveWatcher = createServeWatcher(
829
- config.serveDir,
830
- config.watchTarget,
831
- (fn) => {
832
- clearTimeout(cssDebounce);
833
- cssDebounce = setTimeout(() => handleCss(fn), 50);
834
- },
835
- () => {
836
- clearTimeout(htmlDebounce);
837
- // Typed reload keeps a pending build error visible after the page reloads.
838
- htmlDebounce = setTimeout(
839
- () => config.devServer.notify({ type: "reload" }),
840
- 50,
841
- );
842
- },
843
- );
1051
+ const serveWatcher = config.devServer
1052
+ ? createServeWatcher(
1053
+ config.serveDir,
1054
+ config.watchTarget,
1055
+ (fn) => {
1056
+ clearTimeout(cssDebounce);
1057
+ cssDebounce = setTimeout(() => handleCss(fn), 50);
1058
+ },
1059
+ () => {
1060
+ clearTimeout(htmlDebounce);
1061
+ // Typed reload keeps a pending build error visible after the page reloads.
1062
+ htmlDebounce = setTimeout(
1063
+ () => config.devServer.notify({ type: "reload" }),
1064
+ 50,
1065
+ );
1066
+ },
1067
+ )
1068
+ : null;
844
1069
  if (serveWatcher) watchers.push(serveWatcher);
845
1070
 
846
1071
  return {
@@ -858,6 +1083,12 @@ function setupDevWatchers(config) {
858
1083
  };
859
1084
  }
860
1085
 
1086
+ // Options beyond the `dev` subcommand's own (used by the legacy `--watch` path):
1087
+ // serve: false → no dev server (watch + rebuild only)
1088
+ // outputFile: null → never write; the compiled JS is passed to `onBuild`
1089
+ // copyAssets: false → skip the initial and per-CSS-change asset copy
1090
+ // minify / mangle → applied to every build
1091
+ // onBuild / onError → replace the default log lines
861
1092
  export async function handleDev(targetDir = ".", options = {}) {
862
1093
  const project = detectProject(targetDir);
863
1094
  const port = options.port ?? project.port ?? 3000;
@@ -865,43 +1096,59 @@ export async function handleDev(targetDir = ".", options = {}) {
865
1096
  ? resolve(options.serveDir)
866
1097
  : project.serveDir;
867
1098
  const srcDir = options.srcDir ? resolve(options.srcDir) : project.srcDir;
868
- const outputFile = options.outputFile
869
- ? resolve(options.outputFile)
870
- : project.devOutputFile;
1099
+ const outputFile =
1100
+ options.outputFile === null
1101
+ ? null
1102
+ : options.outputFile
1103
+ ? resolve(options.outputFile)
1104
+ : project.devOutputFile;
1105
+ const doCopyAssets = options.copyAssets ?? true;
871
1106
 
872
- copyAssets(project.projectRoot, options.assetConfig);
1107
+ if (doCopyAssets) copyAssets(project.projectRoot, options.assetConfig);
873
1108
 
874
1109
  const isDir = statSync(srcDir).isDirectory();
875
- const devServer = createDevServer(serveDir, port, {
876
- ...options,
877
- silent: true,
878
- });
1110
+ const devServer =
1111
+ options.serve === false
1112
+ ? null
1113
+ : createDevServer(serveDir, port, {
1114
+ ...options,
1115
+ silent: options.silent ?? true,
1116
+ });
1117
+ const buildConfig = {
1118
+ srcDir,
1119
+ isDir,
1120
+ outputFile,
1121
+ sourceMap: options.sourceMap ?? true,
1122
+ minify: options.minify ?? false,
1123
+ mangle: options.mangle ?? false,
1124
+ forceTarget: options.forceTarget ?? null,
1125
+ ...projectTargetOptions(project, options),
1126
+ devServer,
1127
+ onBuild: options.onBuild,
1128
+ onError: options.onError,
1129
+ };
879
1130
  let initialError = null;
880
1131
 
1132
+ let written = null;
881
1133
  try {
882
- const result = runCompile(srcDir, isDir, {
883
- outputFile,
884
- sourceMap: options.sourceMap ?? true,
885
- });
886
- mkdirSync(dirname(outputFile), { recursive: true });
887
- writeFileSync(outputFile, `${result.js}\n`);
1134
+ const info = runDevBuild(buildConfig);
1135
+ written = info.written;
1136
+ options.onBuild?.(info);
888
1137
  } catch (err) {
889
1138
  initialError = err;
890
- devServer.notifyError(err);
1139
+ options.onError?.(err);
1140
+ devServer?.notifyError(err);
891
1141
  }
892
1142
 
893
1143
  let watcherController = null;
894
1144
  if (options.watch !== false) {
895
1145
  const watchTarget = isDir ? srcDir : dirname(srcDir);
896
1146
  watcherController = setupDevWatchers({
1147
+ ...buildConfig,
897
1148
  watchTarget,
898
- srcDir,
899
- isDir,
900
- outputFile,
901
- sourceMap: options.sourceMap,
902
- devServer,
903
1149
  project,
904
1150
  serveDir,
1151
+ copyAssets: doCopyAssets,
905
1152
  assetConfig: options.assetConfig,
906
1153
  });
907
1154
  }
@@ -914,9 +1161,10 @@ export async function handleDev(targetDir = ".", options = {}) {
914
1161
  srcDir,
915
1162
  outputFile,
916
1163
  initialError,
1164
+ written,
917
1165
  close: async () => {
918
1166
  watcherController?.close();
919
- await devServer.close();
1167
+ await devServer?.close();
920
1168
  },
921
1169
  };
922
1170
  }