thrunium 1.2.0-rc.1 → 1.2.0-rc.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
@@ -35,7 +35,7 @@ The engine is also on npm as `thrunium` (Node 24 or newer):
35
35
 
36
36
  ```bash
37
37
  npx thrunium init my-game # new TypeScript project; add --js for JavaScript
38
- cd my-game && pnpm install && pnpm dev
38
+ cd my-game && npm run dev # init installs for you; use pnpm dev, yarn dev or bun run dev
39
39
  ```
40
40
 
41
41
  ```ts
@@ -57,10 +57,17 @@ Commands (`thrunium --help`, `--version`):
57
57
  |---|---|
58
58
  | `thrunium dev` | `--port <n>` (default 3000, tries the next 10 ports if busy), `--host <name>` (default `localhost`), `--no-open` |
59
59
  | `thrunium build` | `--out <dir>` (default `dist`), `--engine cdn\|bundle` |
60
- | `thrunium init [dir]` | `--js` (TypeScript by default); the folder must be empty |
60
+ | `thrunium init [dir]` | `--js` (TypeScript by default), `--pm npm\|pnpm\|yarn\|bun` (default: the one that ran it, else npm), `--no-install`; the folder must be empty. Installs by default; if that fails the project is kept and the command is printed |
61
+
62
+ A project needs no `index.html`: without one, `dev` and `build` generate the page
63
+ from `game.json` (`entry`, else the first of `game.ts`, `game.js`, `src/game.ts`,
64
+ `src/game.js`; the title is `name`, else the folder name). A hand-written
65
+ `index.html` is used as written, and having both it and `entry` is an error.
66
+ `import './style.css'` from game code is bundled and linked, with `url()` fonts and
67
+ images; remote `@import` stays remote.
61
68
 
62
69
  `dev` never uses the CDN. For a project with a module entry
63
- (`<script type="module" src=...>` importing `thrunium`), `build` has two engine
70
+ (a generated page, or `<script type="module" src=...>` importing `thrunium`), `build` has two engine
64
71
  modes. `cdn` (the default) adds a script tag for
65
72
  `https://cdn.thrungames.com/thrunium/v<version>/thrunium.min.js` with a `sha384`
66
73
  integrity hash, pinned to the installed version. It fails, and leaves the output
package/dist/cli.mjs CHANGED
@@ -152,8 +152,27 @@ function findModuleEntries(html) {
152
152
  function hasEngineTag(html) {
153
153
  return scriptTags(html).some((t) => ENGINE_SRC.test(t.attrs.get("src") ?? ""));
154
154
  }
155
+ function escapeHtml(text) {
156
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
157
+ }
158
+ function generatePage(title, entry) {
159
+ return `<!DOCTYPE html>
160
+ <html lang="en">
161
+ <head>
162
+ <meta charset="utf-8">
163
+ <meta name="viewport" content="width=device-width, initial-scale=1">
164
+ <title>${escapeHtml(title)}</title>
165
+ </head>
166
+ <body style="margin: 0; padding: 0;">
167
+ <script type="module" src="${escapeHtml(entry)}"></script>
168
+ </body>
169
+ </html>
170
+ `;
171
+ }
155
172
  function transformHtml(html, opts) {
156
173
  let out = html;
174
+ const hasHead = /<\/head\s*>/i.test(html);
175
+ const styleLinks = opts.styles.map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join("");
157
176
  const tags = localModuleTags(html);
158
177
  for (let i = tags.length - 1; i >= 0; i--) {
159
178
  const tag = tags[i];
@@ -163,9 +182,16 @@ function transformHtml(html, opts) {
163
182
  if (mapped !== void 0) {
164
183
  text = text.replace(/(\ssrc\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/i, (_m, pre) => `${pre}"${mapped}"`);
165
184
  }
166
- if (i === 0 && opts.engineTag !== null) text = opts.engineTag + text;
185
+ if (i === 0) {
186
+ if (opts.engineTag !== null) text = opts.engineTag + text;
187
+ if (!hasHead) text = styleLinks + text;
188
+ }
167
189
  out = out.slice(0, tag.index) + text + out.slice(tag.index + tag.text.length);
168
190
  }
191
+ if (hasHead && styleLinks !== "") {
192
+ const at = /<\/head\s*>/i.exec(out).index;
193
+ out = out.slice(0, at) + styleLinks + out.slice(at);
194
+ }
169
195
  if (opts.inject !== null) {
170
196
  const at = [/<\/body\s*>/i, /<\/html\s*>/i].map((re) => re.exec(out)?.index).find((i) => i !== void 0);
171
197
  out = at === void 0 ? out + opts.inject : out.slice(0, at) + opts.inject + out.slice(at);
@@ -183,8 +209,8 @@ var init_html = __esm({
183
209
  });
184
210
 
185
211
  // cli/project.ts
186
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
187
- import { join as join2 } from "node:path";
212
+ import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "node:fs";
213
+ import { basename, isAbsolute, join as join2, normalize, resolve } from "node:path";
188
214
  function resolveEngineMode(root, flag) {
189
215
  if (flag) return flag;
190
216
  const manifest = join2(root, "package.json");
@@ -200,25 +226,91 @@ function resolveEngineMode(root, flag) {
200
226
  if (value === "cdn" || value === "bundle") return value;
201
227
  throw new UserError(`Invalid "thrunium.engine" in package.json: ${JSON.stringify(value)}. Use "cdn" or "bundle".`);
202
228
  }
203
- function loadProject(root, engineFlag) {
229
+ function isFile(path) {
230
+ return existsSync2(path) && statSync(path).isFile();
231
+ }
232
+ function readGameJson(root) {
233
+ const file = join2(root, "game.json");
234
+ if (!existsSync2(file)) return null;
235
+ let json;
236
+ try {
237
+ json = JSON.parse(readFileSync2(file, "utf8"));
238
+ } catch {
239
+ throw new UserError("game.json is not valid JSON.");
240
+ }
241
+ if (typeof json !== "object" || json === null || Array.isArray(json)) {
242
+ throw new UserError("game.json must contain a JSON object.");
243
+ }
244
+ const { name, entry } = json;
245
+ if (name !== void 0 && typeof name !== "string") throw new UserError('"name" in game.json must be a string.');
246
+ if (entry !== void 0 && typeof entry !== "string") throw new UserError('"entry" in game.json must be a string.');
247
+ return { name, entry };
248
+ }
249
+ function validateEntry(root, entry) {
250
+ const inside = !isAbsolute(entry) && !normalize(entry).split(/[\\/]/).includes("..");
251
+ if (!inside) throw new UserError(`"entry" in game.json must be a relative path inside the project: ${entry}`);
252
+ if (!ENTRY_EXTENSIONS.test(entry)) {
253
+ throw new UserError(`"entry" in game.json must end in .ts, .js, .mts or .mjs: ${entry}`);
254
+ }
255
+ if (!isFile(join2(root, entry))) throw new UserError(`"entry" in game.json points to ${entry}, which does not exist.`);
256
+ }
257
+ function hasEntryKey(file) {
258
+ try {
259
+ const json = JSON.parse(readFileSync2(file, "utf8"));
260
+ return typeof json === "object" && json !== null && !Array.isArray(json) && "entry" in json;
261
+ } catch {
262
+ return false;
263
+ }
264
+ }
265
+ function resolvePage(root) {
204
266
  const indexPath = join2(root, "index.html");
205
- if (!existsSync2(indexPath)) throw new UserError(`No index.html found in ${root}.`);
206
- const html = readFileSync2(indexPath, "utf8");
267
+ if (existsSync2(indexPath)) {
268
+ if (hasEntryKey(join2(root, "game.json"))) {
269
+ throw new UserError('game.json has "entry", but index.html also exists and is used instead. Remove one of them.');
270
+ }
271
+ return { html: readFileSync2(indexPath, "utf8"), htmlSource: "file" };
272
+ }
273
+ const game = readGameJson(root);
274
+ let entry = game?.entry;
275
+ if (entry !== void 0) validateEntry(root, entry);
276
+ else entry = DEFAULT_ENTRIES.find((candidate) => isFile(join2(root, candidate)));
277
+ if (entry === void 0) {
278
+ throw new UserError(
279
+ `No game found: add game.ts or game.js (or set "entry" in game.json), or an index.html. Looked for: ${DEFAULT_ENTRIES.join(", ")}.`
280
+ );
281
+ }
282
+ return { html: generatePage(game?.name ?? basename(resolve(root)), entry), htmlSource: "generated" };
283
+ }
284
+ function loadProject(root, engineFlag) {
285
+ const { html, htmlSource } = resolvePage(root);
207
286
  const entries = findModuleEntries(html);
208
287
  for (const entry of entries) {
209
- if (!existsSync2(join2(root, entry))) throw new UserError(`Entry file ${entry} (from index.html) does not exist.`);
288
+ if (!existsSync2(join2(root, entry))) {
289
+ throw new UserError(`Entry file ${entry} (from ${htmlSource === "file" ? "index.html" : "game.json"}) does not exist.`);
290
+ }
210
291
  }
211
292
  const usesModules = entries.length > 0;
212
293
  if (usesModules && hasEngineTag(html)) {
213
294
  throw new UserError("Remove the Thrunium <script> tag from index.html; thrunium adds it for you.");
214
295
  }
215
- return { root, html, entries, usesModules, engineMode: resolveEngineMode(root, engineFlag) };
296
+ return { root, html, htmlSource, entries, usesModules, engineMode: resolveEngineMode(root, engineFlag) };
216
297
  }
217
298
  function thruniumPlugin() {
218
299
  return {
219
300
  name: "thrunium",
220
301
  setup(build2) {
221
302
  build2.onResolve({ filter: /^thrunium$/ }, () => ({ path: globalShimFile() }));
303
+ const assetFilter = new RegExp(`(${ASSET_EXTENSIONS.map((e) => e.replace(".", "\\.")).join("|")})$`, "i");
304
+ build2.onResolve({ filter: assetFilter }, (args) => {
305
+ if (args.kind === "url-token" || args.kind === "import-rule") return void 0;
306
+ return {
307
+ errors: [
308
+ {
309
+ text: `Cannot import ${args.path} from game code. Load the file by path instead, for example with game.assets.`
310
+ }
311
+ ]
312
+ };
313
+ });
222
314
  }
223
315
  };
224
316
  }
@@ -229,15 +321,22 @@ function baseBuildOptions(root) {
229
321
  format: "esm",
230
322
  target: "es2017",
231
323
  plugins: [thruniumPlugin()],
324
+ // CSS imported from game code is bundled; fonts and images it references
325
+ // are emitted as files (hashed names in build) rather than inlined.
326
+ loader: Object.fromEntries(ASSET_EXTENSIONS.map((ext) => [ext, "file"])),
232
327
  logLevel: "silent"
233
328
  };
234
329
  }
330
+ var DEFAULT_ENTRIES, ENTRY_EXTENSIONS, ASSET_EXTENSIONS;
235
331
  var init_project = __esm({
236
332
  "cli/project.ts"() {
237
333
  "use strict";
238
334
  init_engine();
239
335
  init_errors();
240
336
  init_html();
337
+ DEFAULT_ENTRIES = ["game.ts", "game.js", "src/game.ts", "src/game.js"];
338
+ ENTRY_EXTENSIONS = /\.(ts|js|mts|mjs)$/;
339
+ ASSET_EXTENSIONS = [".woff", ".woff2", ".ttf", ".otf", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
241
340
  }
242
341
  });
243
342
 
@@ -282,11 +381,11 @@ __export(dev_exports, {
282
381
  startDev: () => startDev
283
382
  });
284
383
  import { spawn } from "node:child_process";
285
- import { existsSync as existsSync3, readdirSync, realpathSync, statSync, watch } from "node:fs";
384
+ import { existsSync as existsSync3, readdirSync, realpathSync, statSync as statSync2, watch } from "node:fs";
286
385
  import { readFile } from "node:fs/promises";
287
386
  import { createServer } from "node:http";
288
387
  import { isIP } from "node:net";
289
- import { basename, extname, isAbsolute, join as join3, normalize, relative, resolve, sep } from "node:path";
388
+ import { basename as basename2, extname, isAbsolute as isAbsolute2, join as join3, normalize as normalize2, relative, resolve as resolve2, sep } from "node:path";
290
389
  import { parseArgs } from "node:util";
291
390
  import { context as esbuildContext, formatMessages } from "esbuild";
292
391
  function openBrowser(url) {
@@ -322,7 +421,7 @@ async function listen(server, port, host) {
322
421
  throw new UserError(`Ports ${port}-${last} are all in use. Pass a different --port.`);
323
422
  }
324
423
  async function startDev(opts) {
325
- const root = resolve(opts.root);
424
+ const root = resolve2(opts.root);
326
425
  const host = opts.host ?? "localhost";
327
426
  const outDir = join3(root, "dist");
328
427
  const realRoot = realpathSync(root);
@@ -355,7 +454,7 @@ data: ${JSON.stringify(data)}
355
454
  entries = newEntries;
356
455
  buildError = null;
357
456
  bundles.clear();
358
- owned = new Set(entries.map((e) => resolve(root, e)));
457
+ owned = new Set(entries.map((e) => resolve2(root, e)));
359
458
  if (entries.length === 0) return;
360
459
  let first = true;
361
460
  let firstDone;
@@ -383,12 +482,12 @@ data: ${JSON.stringify(data)}
383
482
  } else {
384
483
  buildError = null;
385
484
  const next = /* @__PURE__ */ new Map();
386
- for (const f of result.outputFiles ?? []) next.set(f.path.slice(virtualOut.length + 1), f.contents);
485
+ for (const f of result.outputFiles ?? []) next.set(f.path.slice(virtualOut.length + 1).split(sep).join("/"), f.contents);
387
486
  const changed = next.size !== bundles.size || [...next].some(([k, v]) => !Buffer.from(v).equals(bundles.get(k) ?? new Uint8Array()));
388
487
  bundles.clear();
389
488
  for (const [k, v] of next) bundles.set(k, v);
390
- owned = new Set(Object.keys(result.metafile?.inputs ?? {}).map((p) => resolve(root, p)));
391
- for (const e of entries) owned.add(resolve(root, e));
489
+ owned = new Set(Object.keys(result.metafile?.inputs ?? {}).map((p) => resolve2(root, p)));
490
+ for (const e of entries) owned.add(resolve2(root, e));
392
491
  if (!wasFirst && (changed || lastError !== null)) broadcastReload();
393
492
  }
394
493
  if (wasFirst) firstDone();
@@ -440,11 +539,13 @@ data: ${JSON.stringify(data)}
440
539
  }
441
540
  if (pathname === "/" || pathname === "/index.html") {
442
541
  const project = loadProject(root);
443
- const entryMap = new Map(project.entries.map((e, i) => [e, `/__thrunium/bundle/${i}.js`]));
542
+ const entryMap = new Map(project.entries.map((e, i) => [e, `${BUNDLE_PREFIX}${i}.js`]));
543
+ const styles = project.entries.flatMap((_, i) => bundles.has(`${i}.css`) ? [`${BUNDLE_PREFIX}${i}.css`] : []);
444
544
  const html = transformHtml(project.html, {
445
545
  engineTag: project.usesModules ? ENGINE_TAG : null,
446
546
  entryMap,
447
- inject: RELOAD_CLIENT
547
+ inject: RELOAD_CLIENT,
548
+ styles
448
549
  });
449
550
  res.writeHead(200, { ...noCache, "content-type": MIME[".html"] }).end(html);
450
551
  return;
@@ -453,25 +554,26 @@ data: ${JSON.stringify(data)}
453
554
  res.writeHead(200, { ...noCache, "content-type": MIME[".js"] }).end(await readFile(engineFile("thrunium.js")));
454
555
  return;
455
556
  }
456
- const bundle = BUNDLE_URL.exec(pathname);
457
- if (bundle) {
458
- const contents = bundles.get(`${bundle[1]}.js`);
557
+ if (pathname.startsWith(BUNDLE_PREFIX)) {
558
+ const name = pathname.slice(BUNDLE_PREFIX.length);
559
+ const contents = bundles.get(name);
459
560
  if (!contents) {
460
561
  res.writeHead(404).end();
461
562
  return;
462
563
  }
463
- res.writeHead(200, { ...noCache, "content-type": MIME[".js"] }).end(contents);
564
+ const type = MIME[extname(name).toLowerCase()] ?? "application/octet-stream";
565
+ res.writeHead(200, { ...noCache, "content-type": type }).end(contents);
464
566
  return;
465
567
  }
466
- const file = normalize(join3(root, pathname));
568
+ const file = normalize2(join3(root, pathname));
467
569
  const rel = relative(root, file);
468
570
  if (/[\\\0]/.test(pathname) || // backslashes act as separators on Windows; NUL is never valid
469
- rel.split(/[\\/]/).some((part) => part.startsWith(".")) || rel === "" || rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel) || !existsSync3(file) || !statSync(file).isFile()) {
571
+ rel.split(/[\\/]/).some((part) => part.startsWith(".")) || rel === "" || rel === ".." || rel.startsWith(".." + sep) || isAbsolute2(rel) || !existsSync3(file) || !statSync2(file).isFile()) {
470
572
  res.writeHead(404).end();
471
573
  return;
472
574
  }
473
575
  const realRel = relative(realRoot, realpathSync(file));
474
- if (realRel === ".." || realRel.startsWith(".." + sep) || isAbsolute(realRel)) {
576
+ if (realRel === ".." || realRel.startsWith(".." + sep) || isAbsolute2(realRel)) {
475
577
  res.writeHead(404).end();
476
578
  return;
477
579
  }
@@ -483,7 +585,7 @@ data: ${JSON.stringify(data)}
483
585
  for (const dir of [outDir, outTmp]) if (abs === dir || abs.startsWith(dir + sep)) return true;
484
586
  if (owned.has(abs)) return true;
485
587
  try {
486
- return statSync(abs).isDirectory();
588
+ return statSync2(abs).isDirectory();
487
589
  } catch {
488
590
  return false;
489
591
  }
@@ -548,8 +650,8 @@ data: ${JSON.stringify(data)}
548
650
  const w = watch(dir, { recursive: true }, (_event, filename) => {
549
651
  if (!filename) return schedule();
550
652
  const name = filename.toString();
551
- if (name === basename(dir) && !existsSync3(join3(dir, name))) return;
552
- if (!ignored(resolve(dir, name))) schedule();
653
+ if (name === basename2(dir) && !existsSync3(join3(dir, name))) return;
654
+ if (!ignored(resolve2(dir, name))) schedule();
553
655
  });
554
656
  w.on("error", onWatchError(dir));
555
657
  watchers.set(dir, w);
@@ -559,9 +661,10 @@ data: ${JSON.stringify(data)}
559
661
  };
560
662
  const rootWatcher = watch(root, (event, filename) => {
561
663
  if (event === "rename") rescan();
562
- const abs = filename ? resolve(root, filename.toString()) : root;
563
- if (filename && ignored(abs)) return;
564
- if (!filename || abs === join3(root, "index.html")) indexChanged = true;
664
+ const abs = filename ? resolve2(root, filename.toString()) : root;
665
+ const pageInput = !filename || abs === join3(root, "index.html") || abs === join3(root, "game.json");
666
+ if (!pageInput && ignored(abs)) return;
667
+ if (pageInput) indexChanged = true;
565
668
  schedule();
566
669
  });
567
670
  rootWatcher.on("error", onWatchError(root));
@@ -638,7 +741,7 @@ ${USAGE}`);
638
741
  });
639
742
  await server.close();
640
743
  }
641
- var USAGE, MIME, ENGINE_TAG, BUNDLE_URL, WATCH_DEBOUNCE_MS, PORT_ATTEMPTS;
744
+ var USAGE, MIME, ENGINE_TAG, BUNDLE_PREFIX, WATCH_DEBOUNCE_MS, PORT_ATTEMPTS;
642
745
  var init_dev = __esm({
643
746
  "cli/dev.ts"() {
644
747
  "use strict";
@@ -669,10 +772,15 @@ Options:
669
772
  ".ogg": "audio/ogg",
670
773
  ".css": "text/css",
671
774
  ".ts": "text/javascript",
672
- ".wasm": "application/wasm"
775
+ ".wasm": "application/wasm",
776
+ ".woff": "font/woff",
777
+ ".woff2": "font/woff2",
778
+ ".ttf": "font/ttf",
779
+ ".otf": "font/otf",
780
+ ".webp": "image/webp"
673
781
  };
674
782
  ENGINE_TAG = '<script src="/__thrunium/engine/thrunium.js"></script>';
675
- BUNDLE_URL = /^\/__thrunium\/bundle\/(\d+)\.js$/;
783
+ BUNDLE_PREFIX = "/__thrunium/bundle/";
676
784
  WATCH_DEBOUNCE_MS = 50;
677
785
  PORT_ATTEMPTS = 11;
678
786
  }
@@ -684,8 +792,8 @@ __export(build_exports, {
684
792
  build: () => build,
685
793
  runBuild: () => runBuild
686
794
  });
687
- import { existsSync as existsSync4, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync3, renameSync, rmSync, statSync as statSync2, writeFileSync, copyFileSync } from "node:fs";
688
- import { dirname as dirname2, isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
795
+ import { existsSync as existsSync4, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync3, renameSync, rmSync, statSync as statSync3, writeFileSync, copyFileSync } from "node:fs";
796
+ import { dirname as dirname2, isAbsolute as isAbsolute3, join as join4, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path";
689
797
  import { parseArgs as parseArgs2 } from "node:util";
690
798
  import { build as esbuild, formatMessages as formatMessages2 } from "esbuild";
691
799
  function listFiles(root, dir, skipDirs) {
@@ -699,10 +807,10 @@ function listFiles(root, dir, skipDirs) {
699
807
  return files;
700
808
  }
701
809
  async function runBuild(opts) {
702
- const root = resolve2(opts.root);
703
- const outDir = resolve2(root, opts.outDir ?? "dist");
810
+ const root = resolve3(opts.root);
811
+ const outDir = resolve3(root, opts.outDir ?? "dist");
704
812
  const rel = relative2(root, outDir);
705
- if (rel === "" || rel === ".." || rel.startsWith(".." + sep2) || isAbsolute2(rel)) {
813
+ if (rel === "" || rel === ".." || rel.startsWith(".." + sep2) || isAbsolute3(rel)) {
706
814
  throw new UserError(`The output folder must be inside the project and not the project itself: ${outDir}`);
707
815
  }
708
816
  const project = loadProject(root, opts.engine);
@@ -717,6 +825,7 @@ async function runBuild(opts) {
717
825
  const written = /* @__PURE__ */ new Map();
718
826
  const consumed = /* @__PURE__ */ new Set();
719
827
  const entryMap = /* @__PURE__ */ new Map();
828
+ const styles = [];
720
829
  if (project.usesModules) {
721
830
  const outAbs = join4(root, ".thrunium-out");
722
831
  const result = await esbuild({
@@ -726,6 +835,8 @@ async function runBuild(opts) {
726
835
  sourcemap: "linked",
727
836
  outdir: outAbs,
728
837
  entryNames: "assets/[name]-[hash]",
838
+ assetNames: "assets/[name]-[hash]",
839
+ // fonts/images referenced by CSS or code
729
840
  metafile: true,
730
841
  write: false
731
842
  }).catch(async (err) => {
@@ -736,9 +847,16 @@ async function runBuild(opts) {
736
847
  });
737
848
  for (const f of result.outputFiles) written.set(relative2(outAbs, f.path).split(sep2).join("/"), f.contents);
738
849
  for (const [file, meta] of Object.entries(result.metafile.outputs)) {
739
- if (meta.entryPoint) entryMap.set(meta.entryPoint, relative2(outAbs, resolve2(root, file)).split(sep2).join("/"));
850
+ if (meta.entryPoint) entryMap.set(meta.entryPoint, relative2(outAbs, resolve3(root, file)).split(sep2).join("/"));
851
+ }
852
+ for (const entry of project.entries) {
853
+ const output = Object.entries(result.metafile.outputs).find(([, meta]) => meta.entryPoint === entry);
854
+ const cssBundle = output?.[1].cssBundle;
855
+ if (cssBundle) styles.push(relative2(outAbs, resolve3(root, cssBundle)).split(sep2).join("/"));
856
+ }
857
+ for (const input of Object.keys(result.metafile.inputs)) {
858
+ if (!ASSET_EXTENSIONS.some((ext) => input.toLowerCase().endsWith(ext))) consumed.add(resolve3(root, input));
740
859
  }
741
- for (const input of Object.keys(result.metafile.inputs)) consumed.add(resolve2(root, input));
742
860
  }
743
861
  let engineTag = null;
744
862
  if (project.usesModules) {
@@ -752,13 +870,13 @@ async function runBuild(opts) {
752
870
  }
753
871
  }
754
872
  const tmp = outDir + ".tmp";
755
- written.set("index.html", transformHtml(project.html, { engineTag, entryMap, inject: null }));
873
+ written.set("index.html", transformHtml(project.html, { engineTag, entryMap, inject: null, styles }));
756
874
  rmSync(tmp, { recursive: true, force: true });
757
875
  try {
758
876
  const skipDirs = /* @__PURE__ */ new Set([outDir, tmp]);
759
877
  for (const file of listFiles(root, root, skipDirs)) {
760
878
  const key = file.split(sep2).join("/");
761
- if (written.has(key) || consumed.has(resolve2(root, file))) continue;
879
+ if (written.has(key) || consumed.has(resolve3(root, file))) continue;
762
880
  const dest = join4(tmp, file);
763
881
  mkdirSync(dirname2(dest), { recursive: true });
764
882
  copyFileSync(join4(root, file), dest);
@@ -775,7 +893,7 @@ async function runBuild(opts) {
775
893
  throw err;
776
894
  }
777
895
  const files = listAll(outDir, outDir).sort();
778
- for (const f of files) console.log(` ${f} ${formatSize(statSync2(join4(outDir, f)).size)}`);
896
+ for (const f of files) console.log(` ${f} ${formatSize(statSync3(join4(outDir, f)).size)}`);
779
897
  return { outDir, files };
780
898
  }
781
899
  function listAll(base, dir) {
@@ -834,37 +952,82 @@ Options:
834
952
  // cli/init.ts
835
953
  var init_exports = {};
836
954
  __export(init_exports, {
955
+ detectPackageManager: () => detectPackageManager,
956
+ devCommand: () => devCommand,
837
957
  init: () => init,
838
- runInit: () => runInit
958
+ installCommand: () => installCommand,
959
+ runInit: () => runInit,
960
+ spawnInstall: () => spawnInstall
839
961
  });
840
- import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync4, renameSync as renameSync2, statSync as statSync3, writeFileSync as writeFileSync2 } from "node:fs";
841
- import { basename as basename2, join as join5, resolve as resolve3 } from "node:path";
962
+ import { spawn as spawn2 } from "node:child_process";
963
+ import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync4, renameSync as renameSync2, statSync as statSync4, writeFileSync as writeFileSync2 } from "node:fs";
964
+ import { basename as basename3, join as join5, resolve as resolve4 } from "node:path";
842
965
  import { parseArgs as parseArgs3 } from "node:util";
843
- function runInit(opts) {
844
- const dir = resolve3(opts.dir);
966
+ function detectPackageManager(userAgent) {
967
+ const [name, version] = (userAgent?.split(" ")[0] ?? "").split("/");
968
+ if (!PACKAGE_MANAGERS.includes(name)) return { name: "npm", version: null };
969
+ return { name, version: version && version !== "?" ? version : null };
970
+ }
971
+ function installCommand(pm) {
972
+ return { cmd: pm, args: pm === "yarn" ? [] : ["install"] };
973
+ }
974
+ function devCommand(pm) {
975
+ return pm === "npm" || pm === "bun" ? `${pm} run dev` : `${pm} dev`;
976
+ }
977
+ function spawnInstall(cmd, args, cwd) {
978
+ return new Promise((done) => {
979
+ const child = process.platform === "win32" ? spawn2([cmd, ...args].join(" "), { cwd, stdio: "inherit", shell: true }) : spawn2(cmd, args, { cwd, stdio: "inherit" });
980
+ child.on("error", () => done(false));
981
+ child.on("close", (code) => done(code === 0));
982
+ });
983
+ }
984
+ async function runInit(opts) {
985
+ const dir = resolve4(opts.dir);
845
986
  if (existsSync5(dir)) {
846
- if (!statSync3(dir).isDirectory() || readdirSync3(dir).length > 0) {
987
+ if (!statSync4(dir).isDirectory() || readdirSync3(dir).length > 0) {
847
988
  throw new UserError(`${dir} already exists and is not an empty folder.`);
848
989
  }
849
990
  } else {
850
991
  mkdirSync2(dir, { recursive: true });
851
992
  }
852
993
  cpSync(join5(packageRoot(), "templates", opts.template), dir, { recursive: true });
994
+ if (opts.pm.name === "pnpm") cpSync(join5(packageRoot(), "templates", "pnpm"), dir, { recursive: true });
853
995
  renameSync2(join5(dir, "gitignore"), join5(dir, ".gitignore"));
854
- const name = basename2(dir).toLowerCase().replace(/[^a-z0-9-]/g, "-");
996
+ const name = basename3(dir).toLowerCase().replace(/[^a-z0-9-]/g, "-");
855
997
  for (const file of TEXT_FILES) {
856
998
  const path = join5(dir, file);
857
999
  const text = readFileSync4(path, "utf8").replace(/\{\{name\}\}/g, name).replace(/\{\{version\}\}/g, engineVersion());
858
1000
  writeFileSync2(path, text);
859
1001
  }
1002
+ const pinPnpm = opts.pm.name === "pnpm" && opts.pm.version;
1003
+ if (pinPnpm || opts.pm.name === "npm") {
1004
+ const path = join5(dir, "package.json");
1005
+ const pkg = JSON.parse(readFileSync4(path, "utf8"));
1006
+ if (pinPnpm) pkg.packageManager = `pnpm@${opts.pm.version}`;
1007
+ if (opts.pm.name === "npm") pkg.allowScripts = { esbuild: true };
1008
+ writeFileSync2(path, `${JSON.stringify(pkg, null, 2)}
1009
+ `);
1010
+ }
1011
+ if (!opts.install) return;
1012
+ const { cmd, args } = installCommand(opts.pm.name);
1013
+ const ok = await (opts.runInstall ?? spawnInstall)(cmd, args, dir);
1014
+ if (!ok) {
1015
+ throw new UserError(`The project was created, but "${[cmd, ...args].join(" ")}" failed. Run it in the project folder to finish.`);
1016
+ }
860
1017
  }
861
1018
  async function init(args) {
862
1019
  let parsed;
863
1020
  try {
864
1021
  parsed = parseArgs3({
865
1022
  args,
866
- options: { js: { type: "boolean" }, help: { type: "boolean", short: "h" } },
867
- allowPositionals: true
1023
+ options: {
1024
+ js: { type: "boolean" },
1025
+ pm: { type: "string" },
1026
+ install: { type: "boolean", default: true },
1027
+ help: { type: "boolean", short: "h" }
1028
+ },
1029
+ allowPositionals: true,
1030
+ allowNegative: true
868
1031
  });
869
1032
  } catch (err) {
870
1033
  throw new UserError(`${err instanceof Error ? err.message : String(err)}
@@ -880,14 +1043,22 @@ ${USAGE3}`);
880
1043
 
881
1044
  ${USAGE3}`);
882
1045
  const dir = positionals[0] ?? ".";
883
- runInit({ dir, template: values.js ? "js" : "ts" });
884
- console.log(`Created ${dir === "." ? "the project" : dir}. Next:
1046
+ let pm = detectPackageManager(process.env.npm_config_user_agent);
1047
+ if (values.pm !== void 0) {
1048
+ if (!PACKAGE_MANAGERS.includes(values.pm)) {
1049
+ throw new UserError(`Unknown package manager "${values.pm}". Use one of: ${PACKAGE_MANAGERS.join(", ")}.
885
1050
 
886
- ${dir === "." ? "" : `cd ${dir} && `}pnpm install && pnpm dev
1051
+ ${USAGE3}`);
1052
+ }
1053
+ pm = values.pm === pm.name ? pm : { name: values.pm, version: null };
1054
+ }
1055
+ await runInit({ dir, template: values.js ? "js" : "ts", pm, install: values.install });
1056
+ console.log(`Created ${dir === "." ? "the project" : dir}. Next:
887
1057
 
888
- (npm works too: npm install && npm run dev)`);
1058
+ ${dir === "." ? "" : ` cd ${dir}
1059
+ `} ${devCommand(pm.name)}`);
889
1060
  }
890
- var USAGE3, TEXT_FILES;
1061
+ var USAGE3, PACKAGE_MANAGERS, TEXT_FILES;
891
1062
  var init_init = __esm({
892
1063
  "cli/init.ts"() {
893
1064
  "use strict";
@@ -895,12 +1066,17 @@ var init_init = __esm({
895
1066
  init_errors();
896
1067
  USAGE3 = `Usage: thrunium init [dir] [options]
897
1068
 
898
- Creates a new game project in <dir> (default: the current folder, which must be empty).
1069
+ Creates a new game project in <dir> (default: the current folder, which must be empty)
1070
+ and installs its dependencies.
899
1071
 
900
1072
  Options:
901
- --js Use JavaScript instead of TypeScript
902
- --help, -h Show this help
1073
+ --js Use JavaScript instead of TypeScript
1074
+ --pm <name> Package manager: npm, pnpm, yarn or bun
1075
+ (default: the one that ran this command, else npm)
1076
+ --no-install Do not install dependencies
1077
+ --help, -h Show this help
903
1078
  `;
1079
+ PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
904
1080
  TEXT_FILES = ["package.json", "game.json"];
905
1081
  }
906
1082
  });