bimba-cli 0.7.9 → 0.7.10

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 (4) hide show
  1. package/README.md +2 -2
  2. package/index.js +28 -16
  3. package/package.json +2 -2
  4. package/serve.js +316 -159
package/README.md CHANGED
@@ -82,7 +82,7 @@ Static files are resolved relative to the HTML file's directory first, then from
82
82
 
83
83
  To compile and bundle your source code from .imba to .js:
84
84
  ```bash
85
- bunx bimba src/index.imba --outdir public/js --minify
85
+ bunx bimba src/index.imba --outdir public/js
86
86
  ```
87
87
 
88
88
  With watch:
@@ -100,7 +100,7 @@ bunx bimba src/index.imba --outdir public/js --watch --clearcache
100
100
 
101
101
  `--clearcache` — delete the cache directory on exit (Ctrl+C). Works only in watch mode.
102
102
 
103
- `--minify` — minify the output JS. Enabled by default in bundle mode.
103
+ `--no-minify` — disable minification. Bundle mode minifies by default.
104
104
 
105
105
  `--sourcemap <inline|external|none>` — how to include source maps in the output (default: `none`).
106
106
 
package/index.js CHANGED
@@ -14,7 +14,7 @@ let entrypoint = ''
14
14
 
15
15
  try {
16
16
  const { values, positionals } = parseArgs({
17
- args: Bun.argv,
17
+ args: Bun.argv.slice(2),
18
18
  options: {
19
19
  watch: { type: 'boolean' },
20
20
  outdir: { type: 'string' },
@@ -29,11 +29,12 @@ try {
29
29
  port: { type: 'string' },
30
30
  html: { type: 'string' },
31
31
  },
32
+ allowNegative: true,
32
33
  strict: true,
33
34
  allowPositionals: true,
34
35
  });
35
36
  flags = values;
36
- entrypoint = Bun.argv[2];
37
+ entrypoint = positionals[0] || '';
37
38
  }
38
39
  catch (error) {
39
40
  if (error instanceof Error)
@@ -43,16 +44,21 @@ catch (error) {
43
44
  process.exit(0);
44
45
  }
45
46
 
46
- // Ensure bunfig.toml exists and contains the required preload line
47
- const bunfigPath = path.join(process.cwd(), 'bunfig.toml');
48
- const preloadLine = 'preload = ["bimba-cli/plugin.js"]';
49
- if (!fs.existsSync(bunfigPath)) {
50
- fs.writeFileSync(bunfigPath, preloadLine + '\n');
51
- } else {
52
- const content = fs.readFileSync(bunfigPath, 'utf8');
53
- if (!content.includes(preloadLine)) {
54
- fs.appendFileSync(bunfigPath, (content.endsWith('\n') ? '' : '\n') + preloadLine + '\n');
47
+ function ensureBunfigPreload() {
48
+ const bunfigPath = path.join(process.cwd(), 'bunfig.toml');
49
+ const preloadLine = 'preload = ["bimba-cli/plugin.js"]';
50
+
51
+ if (!fs.existsSync(bunfigPath)) {
52
+ console.log(theme.action("note: ") + theme.filename("bunfig.toml was not found, so bimba left it unchanged."));
53
+ console.log(theme.action(" ") + `Add ${theme.flags(preloadLine)} manually if you want Bun to preload the plugin.`);
54
+ return;
55
55
  }
56
+
57
+ const content = fs.readFileSync(bunfigPath, 'utf8');
58
+ if (content.includes(preloadLine)) return;
59
+
60
+ console.log(theme.action("note: ") + theme.filename("bunfig.toml already exists, so bimba did not edit it automatically."));
61
+ console.log(theme.action(" ") + `Add ${theme.flags(preloadLine)} manually if you want Bun to preload the plugin.`);
56
62
  }
57
63
 
58
64
  // help: more on bun building params here: https://bun.sh/docs/bundler
@@ -62,7 +68,7 @@ if(flags.help) {
62
68
  console.log("For example like this: "+theme.filedir('bimba file.imba --outdir public'));
63
69
  console.log("");
64
70
  console.log(" "+theme.flags('--outdir <folder>')+" Compile imba files to the specified folder");
65
- console.log(" "+theme.flags('--minify')+" Minify compiled .js files");
71
+ console.log(" "+theme.flags('--no-minify')+" Disable minification for compiled .js files");
66
72
  console.log(" "+theme.flags('--sourcemap <inline|external|none>')+" How should sourcemap files be included in the .js");
67
73
  console.log(" "+theme.flags('--target <browser|node>')+" Target platform for both Imba compiler and Bun bundler");
68
74
  console.log(" "+theme.flags('--external <package>')+" Exclude package from bundle (repeatable, e.g. --external ws --external node-pty)");
@@ -88,6 +94,7 @@ if (flags.serve) {
88
94
  console.log("");
89
95
  process.exit(1);
90
96
  }
97
+ ensureBunfigPreload();
91
98
  serve(entrypoint, { port: parseInt(flags.port) || 5200, html: flags.html });
92
99
  }
93
100
  // no entrypoint or outdir
@@ -100,7 +107,9 @@ else if(!entrypoint || !flags.outdir) {
100
107
  }
101
108
  // build
102
109
  else {
103
- bundle();
110
+ ensureBunfigPreload();
111
+ const success = await bundle();
112
+ if (!success && !flags.watch) process.exit(1);
104
113
  watch(bundle);
105
114
  }
106
115
 
@@ -126,7 +135,7 @@ async function bundle() {
126
135
 
127
136
  if (!fs.existsSync(entrypoint)) {
128
137
  console.log(theme.failure('Error.') + ` The specified entrypoint does not exist: ${theme.filedir(entrypoint)}`);
129
- process.exit(0);
138
+ return false;
130
139
  }
131
140
 
132
141
  stats.failed = 0
@@ -150,7 +159,7 @@ async function bundle() {
150
159
  outdir: flags.outdir,
151
160
  target: buildTarget,
152
161
  sourcemap: flags.sourcemap || 'none',
153
- minify: true,
162
+ minify: flags.minify ?? true,
154
163
  splitting: flags.splitting || false,
155
164
  plugins: [imbaPlugin]
156
165
  };
@@ -182,6 +191,8 @@ async function bundle() {
182
191
  console.log(log);
183
192
  }
184
193
  }
194
+
195
+ return result.success && !stats.failed && !stats.errors;
185
196
  }
186
197
  catch(error) {
187
198
  console.log(theme.folder("──────────────────────────────────────────────────────────────────────"));
@@ -189,8 +200,9 @@ async function bundle() {
189
200
  console.log(error)
190
201
  console.log(theme.folder("──────────────────────────────────────────────────────────────────────"));
191
202
  console.log(theme.failure(" Failure ") + theme.filename(' Bun found an error in the compiled JS file'))
203
+ return false;
192
204
  }
193
205
  finally {
194
206
  bundling = false;
195
207
  };
196
- }
208
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bimba-cli",
3
- "version": "0.7.9",
3
+ "version": "0.7.10",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/HeapVoid/bimba.git"
@@ -19,4 +19,4 @@
19
19
  "devDependencies": {
20
20
  "imba": "latest"
21
21
  }
22
- }
22
+ }
package/serve.js CHANGED
@@ -1,6 +1,6 @@
1
- import { serve as bunServe } from 'bun'
1
+ import { serve as bunServe, Glob } from 'bun'
2
2
  import * as compiler from 'imba/compiler'
3
- import { watch, existsSync } from 'fs'
3
+ import { watch, existsSync, statSync } from 'fs'
4
4
  import path from 'path'
5
5
  import { theme } from './utils.js'
6
6
  import { printerr } from './plugin.js'
@@ -256,57 +256,6 @@ const _compileCache = new Map() // filepath → { mtime, result }
256
256
  const _prevJs = new Map() // filepath → compiled js — for change detection
257
257
  const _prevSlots = new Map() // filepath → previous symbol slot count
258
258
 
259
- // ─── Import dependency graph ──────────────────────────────────────────────────
260
- //
261
- // When a non-tag module (utility functions, constants, shared state) is edited,
262
- // the existing class-prototype patching does nothing for the modules that
263
- // imported it — they hold their own captured references. To make those
264
- // updates flow into the UI, we track who imports whom and, on every change,
265
- // re-broadcast updates for the transitive importer set. The client's existing
266
- // HMR queue then re-imports each in turn; their top-level code reruns, picks
267
- // up the new symbols, and any tag re-registrations patch instances in place.
268
- //
269
- // Keys are absolute, normalized paths (path.resolve). Edges are added during
270
- // compilation by scanning the produced JS for relative .imba imports.
271
-
272
- const _imports = new Map() // absFile → Set<absFile> (what it imports)
273
- const _importers = new Map() // absFile → Set<absFile> (who imports it)
274
-
275
- function extractImports(js, fromAbs) {
276
- const dir = path.dirname(fromAbs)
277
- const out = new Set()
278
- const re = /(?:^|[\s;])(?:import|from)\s*['"]([^'"]+)['"]/g
279
- let m
280
- while ((m = re.exec(js))) {
281
- const spec = m[1]
282
- if (!spec.startsWith('.') && !spec.startsWith('/')) continue
283
- if (!spec.endsWith('.imba')) continue
284
- const resolved = spec.startsWith('/')
285
- ? path.resolve('.' + spec)
286
- : path.resolve(dir, spec)
287
- out.add(resolved)
288
- }
289
- return out
290
- }
291
-
292
- function updateImportGraph(fromAbs, newDeps) {
293
- const old = _imports.get(fromAbs)
294
- if (old) {
295
- for (const d of old) {
296
- if (newDeps.has(d)) continue
297
- const set = _importers.get(d)
298
- if (set) { set.delete(fromAbs); if (!set.size) _importers.delete(d) }
299
- }
300
- }
301
- for (const d of newDeps) {
302
- let set = _importers.get(d)
303
- if (!set) { set = new Set(); _importers.set(d, set) }
304
- set.add(fromAbs)
305
- }
306
- _imports.set(fromAbs, newDeps)
307
- }
308
-
309
-
310
259
  // Imba compiles tag render-cache slots as anonymous local Symbols at module top
311
260
  // level: `var $4 = Symbol(), $11 = Symbol(), ...; let c$0 = Symbol();`. Each
312
261
  // re-import of the file creates fresh Symbol objects, so old slot data on live
@@ -373,7 +322,6 @@ async function compileFile(filepath) {
373
322
  const prev = _prevSlots.get(abs)
374
323
  result.slots = (prev === undefined || prev === slotCount) ? 'stable' : 'shifted'
375
324
  _prevSlots.set(abs, slotCount)
376
- updateImportGraph(abs, extractImports(js, abs))
377
325
  }
378
326
 
379
327
  // Bake errors as an own property so caching/spreading preserves them.
@@ -408,69 +356,306 @@ async function readPkgJson(pkgDir) {
408
356
  }
409
357
  }
410
358
 
411
- // Wrap a CommonJS file as an ESM module so the browser can import it.
412
- // Detects CJS by checking for `module.exports` or top-level `exports.` usage.
413
- function wrapCJS(code) {
414
- if (!code.includes('module.exports') && !code.includes('exports.')) return null
415
- // Already has ESM syntax — don't wrap (dual-format files)
416
- if (/^\s*(export\s|import\s)/m.test(code)) return null
417
- return `var module = { exports: {} }, exports = module.exports;\n${code}\nexport default module.exports;\nexport { module };`
359
+ function toPosix(filepath) {
360
+ return filepath.replaceAll('\\', '/')
361
+ }
362
+
363
+ function toBrowserPath(filepath) {
364
+ return '/' + toPosix(path.relative(process.cwd(), path.resolve(filepath)))
418
365
  }
419
366
 
420
- // Resolve the ESM entry point for an npm package.
421
- // Priority: exports["."].import → exports["."].default → module → main
422
- function resolveEntry(depPkg) {
423
- const exp = depPkg.exports;
424
- if (exp) {
425
- // exports: "./index.js" (string shorthand)
426
- if (typeof exp === 'string') return exp;
427
- // exports: { ".": { "import": "...", "default": "..." } }
428
- const dot = exp['.'];
429
- if (dot) {
430
- if (typeof dot === 'string') return dot;
431
- if (dot.import) return dot.import;
432
- if (dot.default) return dot.default;
367
+ function parsePackageSpecifier(specifier) {
368
+ if (specifier.startsWith('@')) {
369
+ const parts = specifier.split('/')
370
+ return {
371
+ name: parts.slice(0, 2).join('/'),
372
+ subpath: parts.slice(2).join('/'),
433
373
  }
434
- // exports: { "import": "...", "default": "..." } (no "." wrapper)
435
- if (exp.import) return exp.import;
436
- if (exp.default) return exp.default;
437
374
  }
438
- // Fallback to legacy fields
439
- return depPkg.module || depPkg.browser || depPkg.main;
375
+
376
+ const [name, ...rest] = specifier.split('/')
377
+ return { name, subpath: rest.join('/') }
440
378
  }
441
379
 
442
- // Build an ES import map from package.json dependencies.
443
- // The import map is intentionally simple it just maps bare specifiers
444
- // to /node_modules/ URLs. All the smart resolution (conditional exports,
445
- // CJS→ESM, entry points, extensions) happens on the server side.
446
- async function buildImportMap() {
447
- const imports = {
448
- 'imba/runtime': 'https://esm.sh/imba/runtime',
449
- 'imba': 'https://esm.sh/imba',
450
- };
380
+ function isConditionMap(value) {
381
+ return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every(key => !key.startsWith('.'))
382
+ }
383
+
384
+ function pickExportTarget(value) {
385
+ if (!value) return null
386
+ if (typeof value === 'string') return value
387
+ if (Array.isArray(value)) {
388
+ for (const item of value) {
389
+ const resolved = pickExportTarget(item)
390
+ if (resolved) return resolved
391
+ }
392
+ return null
393
+ }
394
+ if (!isConditionMap(value)) return null
395
+
396
+ for (const key of ['browser', 'import', 'default', 'module']) {
397
+ const resolved = pickExportTarget(value[key])
398
+ if (resolved) return resolved
399
+ }
400
+
401
+ for (const nested of Object.values(value)) {
402
+ const resolved = pickExportTarget(nested)
403
+ if (resolved) return resolved
404
+ }
405
+
406
+ return null
407
+ }
408
+
409
+ function resolveExportTarget(exportsField, subpath = '') {
410
+ if (!exportsField) return null
411
+ const exportKey = subpath ? `./${subpath}` : '.'
412
+
413
+ if (typeof exportsField === 'string' || Array.isArray(exportsField) || isConditionMap(exportsField)) {
414
+ return exportKey === '.' ? pickExportTarget(exportsField) : null
415
+ }
416
+
417
+ const exact = exportsField[exportKey]
418
+ if (exact) return pickExportTarget(exact)
419
+
420
+ let bestMatch = null
421
+ for (const [key, value] of Object.entries(exportsField)) {
422
+ if (!key.startsWith('./') || !key.includes('*')) continue
423
+
424
+ const [prefix, suffix] = key.split('*')
425
+ if (!exportKey.startsWith(prefix) || !exportKey.endsWith(suffix)) continue
426
+
427
+ const matched = exportKey.slice(prefix.length, exportKey.length - suffix.length)
428
+ const target = pickExportTarget(value)
429
+ if (!target) continue
430
+
431
+ const score = prefix.length + suffix.length
432
+ if (!bestMatch || score > bestMatch.score) {
433
+ bestMatch = { matched, target, score }
434
+ }
435
+ }
436
+
437
+ return bestMatch ? bestMatch.target.replaceAll('*', bestMatch.matched) : null
438
+ }
439
+
440
+ function resolvePackageTarget(depPkg, subpath = '') {
441
+ if (subpath) {
442
+ const exported = resolveExportTarget(depPkg.exports, subpath)
443
+ if (exported) return exported
444
+ if (depPkg.exports) return null
445
+ return './' + subpath
446
+ }
447
+
448
+ const exported = resolveExportTarget(depPkg.exports)
449
+ if (exported) return exported
450
+ if (typeof depPkg.browser === 'string') return depPkg.browser
451
+ return depPkg.module || depPkg.main || null
452
+ }
453
+
454
+ function resolveFileCandidate(filepath) {
455
+ const candidates = [
456
+ filepath,
457
+ filepath + '.js',
458
+ filepath + '.mjs',
459
+ filepath + '.cjs',
460
+ filepath + '.imba',
461
+ filepath + '.css',
462
+ path.join(filepath, 'index.js'),
463
+ path.join(filepath, 'index.mjs'),
464
+ path.join(filepath, 'index.cjs'),
465
+ path.join(filepath, 'index.imba'),
466
+ ]
467
+
468
+ for (const candidate of candidates) {
469
+ if (!existsSync(candidate)) continue
470
+ try {
471
+ if (statSync(candidate).isFile()) return candidate
472
+ } catch (_) {
473
+ // ignore vanished files and continue resolving
474
+ }
475
+ }
476
+
477
+ return null
478
+ }
479
+
480
+ async function resolvePackageFile(specifier) {
481
+ const { name, subpath } = parsePackageSpecifier(specifier)
482
+ if (!name) return null
483
+
484
+ const pkgDir = path.join(process.cwd(), 'node_modules', name)
485
+ const depPkg = await readPkgJson(pkgDir)
486
+ if (!depPkg) return null
487
+
488
+ const target = resolvePackageTarget(depPkg, subpath)
489
+ if (!target) return null
490
+
491
+ const candidate = resolveFileCandidate(path.resolve(pkgDir, target))
492
+ return candidate ? path.resolve(candidate) : null
493
+ }
494
+
495
+ async function resolveNodeModulesRequest(pathname) {
496
+ const specifier = pathname.replace(/^\/node_modules\//, '')
497
+ if (!specifier) return null
498
+
499
+ const direct = resolveFileCandidate(path.join(process.cwd(), 'node_modules', specifier))
500
+ if (direct) return path.resolve(direct)
501
+
502
+ return resolvePackageFile(specifier)
503
+ }
504
+
505
+ function exportSpecifiers(name, exportsField) {
506
+ if (!exportsField || typeof exportsField === 'string' || Array.isArray(exportsField) || isConditionMap(exportsField)) {
507
+ return []
508
+ }
509
+
510
+ return Object.entries(exportsField)
511
+ .filter(([key]) => key.startsWith('./') && key !== '.')
512
+ .map(([key, value]) => ({ key, value }))
513
+ }
514
+
515
+ async function expandPatternExport(name, pkgDir, key, value) {
516
+ const target = pickExportTarget(value)
517
+ if (!target || !key.includes('*') || !target.includes('*')) return {}
518
+
519
+ const specPattern = key.slice(2)
520
+ const [specPrefix, specSuffix] = specPattern.split('*')
521
+ const normalizedTarget = target.replace(/^\.\//, '')
522
+ const [targetPrefix, targetSuffix] = normalizedTarget.split('*')
523
+ const glob = new Glob(normalizedTarget)
524
+ const mappings = {}
525
+
526
+ for await (const file of glob.scan(pkgDir)) {
527
+ const rel = toPosix(file)
528
+ if (!rel.startsWith(targetPrefix) || !rel.endsWith(targetSuffix)) continue
529
+
530
+ const matched = rel.slice(targetPrefix.length, rel.length - targetSuffix.length)
531
+ const specifier = `${name}/${specPrefix}${matched}${specSuffix}`.replace(/\/+/g, '/')
532
+ mappings[specifier] = '/' + toPosix(path.join('node_modules', name, rel))
533
+ }
534
+
535
+ return mappings
536
+ }
537
+
538
+ async function buildGeneratedImportMap() {
539
+ const imports = {}
540
+ const visited = new Set()
541
+ const queue = []
542
+
451
543
  try {
452
- const pkg = JSON.parse(await Bun.file('./package.json').text());
453
- for (const [name] of Object.entries(pkg.dependencies || {})) {
454
- if (name === 'imba') continue;
455
- imports[name] = `/node_modules/${name}/`;
456
- imports[name + '/'] = `/node_modules/${name}/`;
544
+ const pkg = JSON.parse(await Bun.file('./package.json').text())
545
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
546
+ queue.push(...Object.keys(pkg[field] || {}))
457
547
  }
458
548
  } catch(_) { /* no package.json */ }
459
549
 
460
- return `\t\t<script type="importmap">\n\t\t\t${JSON.stringify({ imports }, null, '\t\t\t\t')}\n\t\t</script>`;
550
+ while (queue.length) {
551
+ const name = queue.shift()
552
+ if (!name || visited.has(name)) continue
553
+ visited.add(name)
554
+
555
+ const pkgDir = path.join(process.cwd(), 'node_modules', name)
556
+ const depPkg = await readPkgJson(pkgDir)
557
+ if (!depPkg) continue
558
+
559
+ const entryFile = await resolvePackageFile(name)
560
+ if (entryFile) imports[name] = toBrowserPath(entryFile)
561
+
562
+ if (depPkg.exports) {
563
+ for (const { key, value } of exportSpecifiers(name, depPkg.exports)) {
564
+ if (key.includes('*')) {
565
+ Object.assign(imports, await expandPatternExport(name, pkgDir, key, value))
566
+ continue
567
+ }
568
+
569
+ const specifier = `${name}/${key.slice(2)}`
570
+ const file = await resolvePackageFile(specifier)
571
+ if (file) imports[specifier] = toBrowserPath(file)
572
+ }
573
+ } else {
574
+ imports[name + '/'] = '/' + toPosix(path.join('node_modules', name)) + '/'
575
+ }
576
+
577
+ for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies']) {
578
+ queue.push(...Object.keys(depPkg[field] || {}))
579
+ }
580
+ }
581
+
582
+ if (!imports['imba']) imports['imba'] = 'https://esm.sh/imba'
583
+ if (!imports['imba/runtime']) imports['imba/runtime'] = 'https://esm.sh/imba/runtime'
584
+
585
+ return { imports }
586
+ }
587
+
588
+ function extractUserImportMap(html) {
589
+ const merged = { imports: {}, scopes: {} }
590
+
591
+ html = html.replace(/<script\s+type=["']importmap["'][^>]*>([\s\S]*?)<\/script>/gi, (_match, json) => {
592
+ try {
593
+ const parsed = JSON.parse(json)
594
+ Object.assign(merged.imports, parsed.imports || {})
595
+ for (const [scope, specifiers] of Object.entries(parsed.scopes || {})) {
596
+ merged.scopes[scope] = { ...(merged.scopes[scope] || {}), ...specifiers }
597
+ }
598
+ } catch (_) {
599
+ // ignore invalid import maps and keep serving the page
600
+ }
601
+ return ''
602
+ })
603
+
604
+ if (!Object.keys(merged.scopes).length) delete merged.scopes
605
+ return { html, importMap: merged }
606
+ }
607
+
608
+ function mergeImportMaps(generated, user) {
609
+ const merged = {
610
+ imports: { ...(generated.imports || {}), ...(user.imports || {}) },
611
+ }
612
+
613
+ const scopeKeys = new Set([
614
+ ...Object.keys(generated.scopes || {}),
615
+ ...Object.keys(user.scopes || {}),
616
+ ])
617
+
618
+ if (scopeKeys.size) {
619
+ merged.scopes = {}
620
+ for (const key of scopeKeys) {
621
+ merged.scopes[key] = {
622
+ ...((generated.scopes || {})[key] || {}),
623
+ ...((user.scopes || {})[key] || {}),
624
+ }
625
+ }
626
+ }
627
+
628
+ return merged
629
+ }
630
+
631
+ function renderImportMapTag(importMap) {
632
+ return `\t\t<script type="importmap">\n\t\t\t${JSON.stringify(importMap, null, '\t\t\t\t')}\n\t\t</script>`
633
+ }
634
+
635
+ // Wrap a CommonJS file as an ESM module so the browser can import it.
636
+ // Detects CJS by checking for `module.exports` or top-level `exports.` usage.
637
+ function wrapCJS(code) {
638
+ if (!code.includes('module.exports') && !code.includes('exports.')) return null
639
+ // Already has ESM syntax — don't wrap (dual-format files)
640
+ if (/^\s*(export\s|import\s)/m.test(code)) return null
641
+ return `var module = { exports: {} }, exports = module.exports;\n${code}\nexport default module.exports;\nexport { module };`
461
642
  }
462
643
 
463
644
  // Rewrite production HTML for the dev server:
464
- // strips existing importmap + data-entrypoint script, injects importmap +
645
+ // strips existing importmap + data-entrypoint script, injects merged importmap +
465
646
  // entrypoint module + HMR client before </head>.
466
- function transformHtml(html, entrypoint, importMapTag) {
467
- html = html.replace(/<script\s+type=["']importmap["'][^>]*>[\s\S]*?<\/script>/gi, '');
468
- html = html.replace(/<script([^>]*)\bdata-entrypoint\b([^>]*)><\/script>/gi, '');
469
- const entryUrl = '/' + entrypoint.replace(/^\.\//, '').replaceAll('\\', '/');
647
+ function transformHtml(html, entrypoint, generatedImportMap) {
648
+ const extracted = extractUserImportMap(html)
649
+ html = extracted.html
650
+ html = html.replace(/<script([^>]*)\bdata-entrypoint\b([^>]*)><\/script>/gi, '')
651
+
652
+ const entryUrl = '/' + entrypoint.replace(/^\.\//, '').replaceAll('\\', '/')
653
+ const importMapTag = renderImportMapTag(mergeImportMaps(generatedImportMap, extracted.importMap))
654
+
470
655
  html = html.replace('</head>',
471
656
  `${importMapTag}\n\t\t<script type='module' src='${entryUrl}'></script>\n${hmrClient}\n\t</head>`
472
- );
473
- return html;
657
+ )
658
+ return html
474
659
  }
475
660
 
476
661
  // ─── Dev server ───────────────────────────────────────────────────────────────
@@ -481,7 +666,7 @@ export function serve(entrypoint, flags) {
481
666
  const htmlDir = path.dirname(htmlPath)
482
667
  const srcDir = path.dirname(entrypoint)
483
668
  const sockets = new Set()
484
- let importMapTag = null
669
+ let generatedImportMap = null
485
670
 
486
671
  // ── Status line (prints current compile result, fades out on success) ──────
487
672
 
@@ -601,15 +786,15 @@ export function serve(entrypoint, flags) {
601
786
  if (server.upgrade(req)) return undefined
602
787
  }
603
788
 
604
- // HTML: index or any .html file
605
- if (pathname === '/' || pathname.endsWith('.html')) {
606
- const htmlFile = pathname === '/' ? htmlPath : '.' + pathname
607
- let html = await Bun.file(htmlFile).text()
608
- if (!importMapTag) importMapTag = await buildImportMap()
609
- return new Response(transformHtml(html, entrypoint, importMapTag), {
610
- headers: { 'Content-Type': 'text/html' },
611
- })
612
- }
789
+ // HTML: index or any .html file
790
+ if (pathname === '/' || pathname.endsWith('.html')) {
791
+ const htmlFile = pathname === '/' ? htmlPath : '.' + pathname
792
+ let html = await Bun.file(htmlFile).text()
793
+ if (!generatedImportMap) generatedImportMap = await buildGeneratedImportMap()
794
+ return new Response(transformHtml(html, entrypoint, generatedImportMap), {
795
+ headers: { 'Content-Type': 'text/html' },
796
+ })
797
+ }
613
798
 
614
799
  // Imba files: compile on demand and serve as JS
615
800
  if (pathname.endsWith('.imba')) {
@@ -653,15 +838,14 @@ export function serve(entrypoint, flags) {
653
838
  }
654
839
  }
655
840
 
656
- // node_modules: entry point resolution, .imba compilation, CJS→ESM wrapping.
657
- // The import map just maps bare specifiers to /node_modules/pkg/ URLs.
658
- // All smart resolution happens here at request time.
659
- if (pathname.startsWith('/node_modules/')) {
660
- const filepath = '.' + pathname
661
-
662
- // Serve a resolved file: compile .imba, wrap CJS as ESM, pass ESM through
663
- const serveResolved = async (filePath) => {
664
- if (filePath.endsWith('.imba')) {
841
+ // node_modules: entry point resolution, .imba compilation, CJS→ESM wrapping.
842
+ // The import map points to concrete module URLs when possible, but we
843
+ // still resolve package-style /node_modules/pkg[/subpath] requests here
844
+ // so user-defined import maps and manual URLs keep working.
845
+ if (pathname.startsWith('/node_modules/')) {
846
+ // Serve a resolved file: compile .imba, wrap CJS as ESM, pass ESM through
847
+ const serveResolved = async (filePath) => {
848
+ if (filePath.endsWith('.imba')) {
665
849
  const f = Bun.file(filePath)
666
850
  if (!(await f.exists())) return null
667
851
  const out = await compileFile(filePath)
@@ -671,41 +855,14 @@ export function serve(entrypoint, flags) {
671
855
  const f = Bun.file(filePath)
672
856
  if (!(await f.exists())) return null
673
857
  const code = await f.text()
674
- const wrapped = wrapCJS(code)
675
- return new Response(wrapped || code, { headers: { 'Content-Type': 'application/javascript' } })
676
- }
677
-
678
- // Resolve entry point for root package requests (/node_modules/pkg/ or /node_modules/pkg)
679
- const parts = pathname.slice(1).split('/') // ['node_modules', 'pkg', ...]
680
- const isScoped = parts[1]?.startsWith('@')
681
- const pkgParts = isScoped ? 3 : 2 // node_modules/@scope/pkg or node_modules/pkg
682
- const subParts = parts.slice(pkgParts)
683
- const isRootRequest = subParts.length === 0 || (subParts.length === 1 && subParts[0] === '')
684
-
685
- if (isRootRequest) {
686
- const pkgDir = './' + parts.slice(0, pkgParts).join('/')
687
- const depPkg = await readPkgJson(pkgDir)
688
- if (depPkg) {
689
- const entry = resolveEntry(depPkg)
690
- if (entry) {
691
- const resp = await serveResolved(path.join(pkgDir, entry))
692
- if (resp) return resp
693
- }
858
+ const wrapped = wrapCJS(code)
859
+ return new Response(wrapped || code, { headers: { 'Content-Type': 'application/javascript' } })
694
860
  }
695
- }
696
-
697
- // Subpath: try the exact path, then with extensions
698
- const resp = await serveResolved(filepath)
699
- if (resp) return resp
700
861
 
701
- // Extensionless: try .imba, .js, .mjs
702
- if (!filepath.includes('.', filepath.lastIndexOf('/') + 1)) {
703
- for (const ext of ['.imba', '.js', '.mjs']) {
704
- const resp = await serveResolved(filepath + ext)
705
- if (resp) return resp
706
- }
862
+ const resolved = await resolveNodeModulesRequest(pathname)
863
+ const resp = resolved ? await serveResolved(resolved) : null
864
+ if (resp) return resp
707
865
  }
708
- }
709
866
 
710
867
  // Static files: check htmlDir first (for assets relative to HTML), then root
711
868
  const inHtmlDir = Bun.file(path.join(htmlDir, pathname))
@@ -731,13 +888,13 @@ export function serve(entrypoint, flags) {
731
888
  }
732
889
 
733
890
  // SPA fallback for extension-less paths
734
- if (!lastSegment.includes('.')) {
735
- let html = await Bun.file(htmlPath).text()
736
- if (!importMapTag) importMapTag = await buildImportMap()
737
- return new Response(transformHtml(html, entrypoint, importMapTag), {
738
- headers: { 'Content-Type': 'text/html' },
739
- })
740
- }
891
+ if (!lastSegment.includes('.')) {
892
+ let html = await Bun.file(htmlPath).text()
893
+ if (!generatedImportMap) generatedImportMap = await buildGeneratedImportMap()
894
+ return new Response(transformHtml(html, entrypoint, generatedImportMap), {
895
+ headers: { 'Content-Type': 'text/html' },
896
+ })
897
+ }
741
898
 
742
899
  return new Response('Not Found', { status: 404 })
743
900
  },