bimba-cli 0.7.11 → 0.7.13

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 (3) hide show
  1. package/README.md +5 -5
  2. package/package.json +1 -1
  3. package/serve.js +82 -90
package/README.md CHANGED
@@ -37,9 +37,9 @@ bunx bimba src/index.imba --serve --port 5200 --html public/index.html
37
37
  **How it works:**
38
38
  - Serves your HTML file and compiles `.imba` files on demand (no bundling step)
39
39
  - Watches `src/` for changes and pushes updates over WebSocket
40
- - Injects an importmap built from your `package.json` dependencies (supports `exports`, `module`, `browser`, and `main` fields)
40
+ - Rewrites bare package imports in served JS modules to `__bimba_vendor__/*` URLs
41
41
  - CSS files imported from JS (e.g. `import 'some-lib/styles.css'`) are automatically wrapped as JS modules that inject `<style>` tags
42
- - npm packages with ESM entry points are served from `node_modules` locally no esm.sh proxy needed
42
+ - npm packages are bundled on demand by Bun (`target: "browser"`), so Bun owns `exports`, `browser`, CommonJS interop, and nested dependency resolution
43
43
  - Injects an HMR client that swaps component prototypes without a full page reload
44
44
 
45
45
  **HMR internals:**
@@ -58,7 +58,7 @@ Duplicate root elements (caused by `imba.mount()` running again on re-import) ar
58
58
 
59
59
  For a deep dive into how Imba compiles tags, how the render cache works, and how bimba hooks into it — see [INTERNALS.md](INTERNALS.md).
60
60
 
61
- **HTML setup:** add a `data-entrypoint` attribute to the script tag that loads your bundle. The dev server will replace it with your `.imba` entrypoint and inject the importmap above it:
61
+ **HTML setup:** add a `data-entrypoint` attribute to the script tag that loads your bundle. The dev server will replace it with your `.imba` entrypoint and remove existing import maps, since package imports are rewritten in served modules instead:
62
62
 
63
63
  ```html
64
64
  <script type='module' src="./js/index.js" data-entrypoint></script>
@@ -72,9 +72,9 @@ For a deep dive into how Imba compiles tags, how the render cache works, and how
72
72
 
73
73
  `--html <path>` — path to your HTML file (auto-detected from `./index.html`, `./public/index.html`, `./src/index.html` if omitted)
74
74
 
75
- Static files are resolved relative to the HTML file's directory first, then from the project root (for `node_modules`, `src`, etc.). Extensionless imports (common in `node_modules`) are resolved by trying `.js` and `.mjs` extensions automatically.
75
+ Static files are resolved relative to the HTML file's directory first, then from the project root (for `node_modules`, `src`, etc.). Extensionless imports are resolved by trying `.imba`, `.js`, and `.mjs` extensions automatically.
76
76
 
77
- **npm package resolution:** The dev server maps package imports to `__bimba_vendor__/*` URLs and bundles those modules on demand with Bun (`target: "browser"`). Imba source files still compile separately for HMR, while Bun owns dependency resolution, `exports`, `browser` fields, nested `node_modules`, and CommonJS interop.
77
+ **npm package resolution:** The dev server scans each served JS module and rewrites bare imports such as `imba/runtime`, `@scope/pkg`, and `pkg/subpath` to `__bimba_vendor__/*` URLs. Those vendor URLs are bundled on demand with Bun (`target: "browser"`). Imba source files still compile separately for HMR, while Bun owns dependency resolution, `exports`, `browser` fields, nested `node_modules`, and CommonJS interop.
78
78
 
79
79
  ---
80
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bimba-cli",
3
- "version": "0.7.11",
3
+ "version": "0.7.13",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/HeapVoid/bimba.git"
package/serve.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { serve as bunServe } from 'bun'
2
2
  import * as compiler from 'imba/compiler'
3
- import { watch, existsSync, statSync } from 'fs'
3
+ import { mkdirSync, watch, existsSync, statSync, writeFileSync } from 'fs'
4
4
  import path from 'path'
5
5
  import { theme } from './utils.js'
6
6
  import { printerr } from './plugin.js'
@@ -255,6 +255,7 @@ const hmrClient = `
255
255
  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
+ const _importScanner = new Bun.Transpiler({ loader: 'js' })
258
259
 
259
260
  // Imba compiles tag render-cache slots as anonymous local Symbols at module top
260
261
  // level: `var $4 = Symbol(), $11 = Symbol(), ...; let c$0 = Symbol();`. Each
@@ -299,6 +300,40 @@ function _normalizeResult(result, extras) {
299
300
  }
300
301
  }
301
302
 
303
+ function isBareSpecifier(specifier) {
304
+ if (!specifier) return false
305
+ if (specifier.startsWith('.') || specifier.startsWith('/')) return false
306
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(specifier)) return false
307
+ if (specifier.startsWith('#')) return false
308
+ return true
309
+ }
310
+
311
+ function escapeRegExp(value) {
312
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
313
+ }
314
+
315
+ function rewriteBareImports(js) {
316
+ let imports = []
317
+ try {
318
+ imports = _importScanner.scanImports(js)
319
+ } catch (_) {
320
+ return js
321
+ }
322
+
323
+ for (const specifier of new Set(imports.map(item => item.path).filter(isBareSpecifier))) {
324
+ const target = vendorUrl(specifier)
325
+ const escaped = escapeRegExp(specifier)
326
+
327
+ const staticPattern = new RegExp(`((?:import|export)\\s+(?:[^'"]*?\\s+from\\s*)?)(['"])${escaped}\\2`, 'g')
328
+ js = js.replace(staticPattern, (_match, prefix, quote) => `${prefix}${quote}${target}${quote}`)
329
+
330
+ const dynamicPattern = new RegExp(`(\\bimport\\s*\\(\\s*)(['"])${escaped}\\2`, 'g')
331
+ js = js.replace(dynamicPattern, (_match, prefix, quote) => `${prefix}${quote}${target}${quote}`)
332
+ }
333
+
334
+ return js
335
+ }
336
+
302
337
  async function compileFile(filepath) {
303
338
  const abs = path.resolve(filepath)
304
339
  const file = Bun.file(filepath)
@@ -318,7 +353,7 @@ async function compileFile(filepath) {
318
353
  const errors = result.errors || []
319
354
  if (!errors.length && result.js) {
320
355
  const { js, slotCount } = stabilizeSymbols(result.js, abs)
321
- result.js = js
356
+ result.js = rewriteBareImports(js)
322
357
  const prev = _prevSlots.get(abs)
323
358
  result.slots = (prev === undefined || prev === slotCount) ? 'stable' : 'shifted'
324
359
  _prevSlots.set(abs, slotCount)
@@ -345,11 +380,7 @@ function findHtml(flagHtml) {
345
380
  const _vendorCache = new Map() // entrypoint → { mtime, code }
346
381
 
347
382
  function vendorUrl(specifier) {
348
- return '/__bimba_vendor__/' + specifier
349
- }
350
-
351
- function vendorPrefixUrl(specifier) {
352
- return vendorUrl(specifier) + '/'
383
+ return '/__bimba_vendor__/' + encodeURIComponent(specifier)
353
384
  }
354
385
 
355
386
  function resolveFileCandidate(filepath) {
@@ -385,6 +416,25 @@ function vendorSpecifierFromPath(pathname) {
385
416
  return specifier || null
386
417
  }
387
418
 
419
+ function vendorEntrypoint(entrypoint) {
420
+ if (path.isAbsolute(entrypoint)) return entrypoint
421
+
422
+ const dir = path.join(process.cwd(), 'node_modules', '.cache', 'bimba', 'vendor-entry')
423
+ mkdirSync(dir, { recursive: true })
424
+
425
+ const name = encodeURIComponent(entrypoint).replace(/%/g, '_')
426
+ const file = path.join(dir, name + '.js')
427
+ const specifier = JSON.stringify(entrypoint)
428
+ const code = [
429
+ `export * from ${specifier};`,
430
+ `import * as mod from ${specifier};`,
431
+ `export default (mod.default ?? mod);`,
432
+ '',
433
+ ].join('\n')
434
+ writeFileSync(file, code)
435
+ return file
436
+ }
437
+
388
438
  async function bundleVendor(entrypoint) {
389
439
  try {
390
440
  const stat = path.isAbsolute(entrypoint) && existsSync(entrypoint) ? statSync(entrypoint) : null
@@ -392,8 +442,9 @@ async function bundleVendor(entrypoint) {
392
442
  const cached = _vendorCache.get(entrypoint)
393
443
  if (cached && cached.mtime === mtime) return cached
394
444
 
445
+ const buildEntrypoint = vendorEntrypoint(entrypoint)
395
446
  const result = await Bun.build({
396
- entrypoints: [entrypoint],
447
+ entrypoints: [buildEntrypoint],
397
448
  target: 'browser',
398
449
  format: 'esm',
399
450
  write: false,
@@ -433,86 +484,22 @@ async function bundleVendor(entrypoint) {
433
484
  }
434
485
  }
435
486
 
436
- async function buildGeneratedImportMap() {
437
- const imports = {}
438
-
439
- try {
440
- const pkg = JSON.parse(await Bun.file('./package.json').text())
441
- for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
442
- for (const name of Object.keys(pkg[field] || {})) {
443
- imports[name] ||= vendorUrl(name)
444
- imports[name + '/'] ||= vendorPrefixUrl(name)
445
- }
446
- }
447
- } catch(_) { /* no package.json */ }
448
-
449
- imports['imba'] ||= vendorUrl('imba')
450
- imports['imba/'] ||= vendorPrefixUrl('imba')
451
- imports['imba/runtime'] ||= vendorUrl('imba/runtime')
452
-
453
- return { imports }
454
- }
455
-
456
- function extractUserImportMap(html) {
457
- const merged = { imports: {}, scopes: {} }
458
-
459
- html = html.replace(/<script\s+type=["']importmap["'][^>]*>([\s\S]*?)<\/script>/gi, (_match, json) => {
460
- try {
461
- const parsed = JSON.parse(json)
462
- Object.assign(merged.imports, parsed.imports || {})
463
- for (const [scope, specifiers] of Object.entries(parsed.scopes || {})) {
464
- merged.scopes[scope] = { ...(merged.scopes[scope] || {}), ...specifiers }
465
- }
466
- } catch (_) {
467
- // ignore invalid import maps and keep serving the page
468
- }
469
- return ''
470
- })
471
-
472
- if (!Object.keys(merged.scopes).length) delete merged.scopes
473
- return { html, importMap: merged }
474
- }
475
-
476
- function mergeImportMaps(generated, user) {
477
- const merged = {
478
- imports: { ...(generated.imports || {}), ...(user.imports || {}) },
479
- }
480
-
481
- const scopeKeys = new Set([
482
- ...Object.keys(generated.scopes || {}),
483
- ...Object.keys(user.scopes || {}),
484
- ])
485
-
486
- if (scopeKeys.size) {
487
- merged.scopes = {}
488
- for (const key of scopeKeys) {
489
- merged.scopes[key] = {
490
- ...((generated.scopes || {})[key] || {}),
491
- ...((user.scopes || {})[key] || {}),
492
- }
493
- }
494
- }
495
-
496
- return merged
497
- }
498
-
499
- function renderImportMapTag(importMap) {
500
- return `\t\t<script type="importmap">\n\t\t\t${JSON.stringify(importMap, null, '\t\t\t\t')}\n\t\t</script>`
487
+ async function serveJavaScriptFile(filepath) {
488
+ const js = rewriteBareImports(await Bun.file(filepath).text())
489
+ return new Response(js, { headers: { 'Content-Type': 'application/javascript' } })
501
490
  }
502
491
 
503
492
  // Rewrite production HTML for the dev server:
504
- // strips existing importmap + data-entrypoint script, injects merged importmap +
493
+ // strips existing importmap + data-entrypoint script, then injects the Imba
505
494
  // entrypoint module + HMR client before </head>.
506
- function transformHtml(html, entrypoint, generatedImportMap) {
507
- const extracted = extractUserImportMap(html)
508
- html = extracted.html
495
+ function transformHtml(html, entrypoint) {
496
+ html = html.replace(/<script\s+type=["']importmap["'][^>]*>[\s\S]*?<\/script>/gi, '')
509
497
  html = html.replace(/<script([^>]*)\bdata-entrypoint\b([^>]*)><\/script>/gi, '')
510
498
 
511
499
  const entryUrl = '/' + entrypoint.replace(/^\.\//, '').replaceAll('\\', '/')
512
- const importMapTag = renderImportMapTag(mergeImportMaps(generatedImportMap, extracted.importMap))
513
500
 
514
501
  html = html.replace('</head>',
515
- `${importMapTag}\n\t\t<script type='module' src='${entryUrl}'></script>\n${hmrClient}\n\t</head>`
502
+ `\t\t<script type='module' src='${entryUrl}'></script>\n${hmrClient}\n\t</head>`
516
503
  )
517
504
  return html
518
505
  }
@@ -525,7 +512,6 @@ export function serve(entrypoint, flags) {
525
512
  const htmlDir = path.dirname(htmlPath)
526
513
  const srcDir = path.dirname(entrypoint)
527
514
  const sockets = new Set()
528
- let generatedImportMap = null
529
515
 
530
516
  // ── Status line (prints current compile result, fades out on success) ──────
531
517
 
@@ -659,8 +645,7 @@ export function serve(entrypoint, flags) {
659
645
  if (pathname === '/' || pathname.endsWith('.html')) {
660
646
  const htmlFile = pathname === '/' ? htmlPath : '.' + pathname
661
647
  let html = await Bun.file(htmlFile).text()
662
- if (!generatedImportMap) generatedImportMap = await buildGeneratedImportMap()
663
- return new Response(transformHtml(html, entrypoint, generatedImportMap), {
648
+ return new Response(transformHtml(html, entrypoint), {
664
649
  headers: { 'Content-Type': 'text/html' },
665
650
  })
666
651
  }
@@ -693,8 +678,13 @@ export function serve(entrypoint, flags) {
693
678
  // Without this, `import './styles.css'` inside an ESM package fails because
694
679
  // the browser expects a JS module response, not raw CSS.
695
680
  if (pathname.endsWith('.css')) {
696
- const cssFile = Bun.file('.' + pathname)
697
- if (await cssFile.exists()) {
681
+ const cssPath = resolveFileCandidate(path.join(htmlDir, pathname)) || resolveFileCandidate('.' + pathname)
682
+ const cssFile = cssPath ? Bun.file(cssPath) : null
683
+ if (cssFile && await cssFile.exists()) {
684
+ if (req.headers.get('sec-fetch-dest') === 'style') {
685
+ return new Response(cssFile, { headers: { 'Content-Type': 'text/css' } })
686
+ }
687
+
698
688
  const css = await cssFile.text()
699
689
  const id = JSON.stringify(pathname)
700
690
  const js = [
@@ -707,6 +697,11 @@ export function serve(entrypoint, flags) {
707
697
  }
708
698
  }
709
699
 
700
+ if (!pathname.startsWith('/node_modules/') && (pathname.endsWith('.js') || pathname.endsWith('.mjs'))) {
701
+ const jsFile = resolveFileCandidate(path.join(htmlDir, pathname)) || resolveFileCandidate('.' + pathname)
702
+ if (jsFile) return serveJavaScriptFile(jsFile)
703
+ }
704
+
710
705
  // Direct node_modules URLs (from user import maps or explicit imports)
711
706
  // are bundled through Bun too, so browser/cjs/exports handling stays
712
707
  // in one place.
@@ -743,18 +738,15 @@ export function serve(entrypoint, flags) {
743
738
  if (!out.errors?.length) return new Response(out.js, { headers: { 'Content-Type': 'application/javascript' } })
744
739
  }
745
740
  for (const ext of ['.js', '.mjs']) {
746
- const withExt = Bun.file('.' + pathname + ext)
747
- if (await withExt.exists()) return new Response(withExt, {
748
- headers: { 'Content-Type': 'application/javascript' },
749
- })
741
+ const withExt = '.' + pathname + ext
742
+ if (existsSync(withExt)) return serveJavaScriptFile(withExt)
750
743
  }
751
744
  }
752
745
 
753
746
  // SPA fallback for extension-less paths
754
747
  if (!lastSegment.includes('.')) {
755
748
  let html = await Bun.file(htmlPath).text()
756
- if (!generatedImportMap) generatedImportMap = await buildGeneratedImportMap()
757
- return new Response(transformHtml(html, entrypoint, generatedImportMap), {
749
+ return new Response(transformHtml(html, entrypoint), {
758
750
  headers: { 'Content-Type': 'text/html' },
759
751
  })
760
752
  }