bimba-cli 0.7.10 → 0.7.11

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 +1 -1
  2. package/package.json +1 -1
  3. package/serve.js +98 -235
package/README.md CHANGED
@@ -74,7 +74,7 @@ For a deep dive into how Imba compiles tags, how the render cache works, and how
74
74
 
75
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.
76
76
 
77
- **npm package resolution:** The import map is built by reading each dependency's `package.json`. The resolution order is: `exports["."].import` `exports["."].default` `module` → `browser` `main`. Packages with subpath exports (e.g. `"./styles.css"`) get individual import map entries. CJS-only packages (no ESM entry) are proxied through esm.sh.
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.
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.10",
3
+ "version": "0.7.11",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/HeapVoid/bimba.git"
package/serve.js CHANGED
@@ -1,4 +1,4 @@
1
- import { serve as bunServe, Glob } from 'bun'
1
+ import { serve as bunServe } from 'bun'
2
2
  import * as compiler from 'imba/compiler'
3
3
  import { watch, existsSync, statSync } from 'fs'
4
4
  import path from 'path'
@@ -340,115 +340,16 @@ function findHtml(flagHtml) {
340
340
  return candidates.find(p => existsSync(p)) || './index.html';
341
341
  }
342
342
 
343
- // ─── Node modules resolution ─────────────────────────────────────────────────
343
+ // ─── Vendor modules ──────────────────────────────────────────────────────────
344
344
 
345
- const _pkgJsonCache = new Map() // pkg root dir parsed package.json
345
+ const _vendorCache = new Map() // entrypoint { mtime, code }
346
346
 
347
- async function readPkgJson(pkgDir) {
348
- const cached = _pkgJsonCache.get(pkgDir)
349
- if (cached) return cached
350
- try {
351
- const json = JSON.parse(await Bun.file(path.join(pkgDir, 'package.json')).text())
352
- _pkgJsonCache.set(pkgDir, json)
353
- return json
354
- } catch(_) {
355
- return null
356
- }
357
- }
358
-
359
- function toPosix(filepath) {
360
- return filepath.replaceAll('\\', '/')
361
- }
362
-
363
- function toBrowserPath(filepath) {
364
- return '/' + toPosix(path.relative(process.cwd(), path.resolve(filepath)))
365
- }
366
-
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('/'),
373
- }
374
- }
375
-
376
- const [name, ...rest] = specifier.split('/')
377
- return { name, subpath: rest.join('/') }
378
- }
379
-
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
347
+ function vendorUrl(specifier) {
348
+ return '/__bimba_vendor__/' + specifier
407
349
  }
408
350
 
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
351
+ function vendorPrefixUrl(specifier) {
352
+ return vendorUrl(specifier) + '/'
452
353
  }
453
354
 
454
355
  function resolveFileCandidate(filepath) {
@@ -477,110 +378,77 @@ function resolveFileCandidate(filepath) {
477
378
  return null
478
379
  }
479
380
 
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
381
+ function vendorSpecifierFromPath(pathname) {
382
+ const prefix = '/__bimba_vendor__/'
383
+ if (!pathname.startsWith(prefix)) return null
384
+ const specifier = decodeURIComponent(pathname.slice(prefix.length))
385
+ return specifier || null
493
386
  }
494
387
 
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
- }
388
+ async function bundleVendor(entrypoint) {
389
+ try {
390
+ const stat = path.isAbsolute(entrypoint) && existsSync(entrypoint) ? statSync(entrypoint) : null
391
+ const mtime = stat?.mtimeMs || 0
392
+ const cached = _vendorCache.get(entrypoint)
393
+ if (cached && cached.mtime === mtime) return cached
394
+
395
+ const result = await Bun.build({
396
+ entrypoints: [entrypoint],
397
+ target: 'browser',
398
+ format: 'esm',
399
+ write: false,
400
+ minify: false,
401
+ sourcemap: 'none',
402
+ packages: 'bundle',
403
+ })
404
+
405
+ if (!result.success) {
406
+ return { mtime, errors: result.logs.map(log => String(log)) }
407
+ }
504
408
 
505
- function exportSpecifiers(name, exportsField) {
506
- if (!exportsField || typeof exportsField === 'string' || Array.isArray(exportsField) || isConditionMap(exportsField)) {
507
- return []
409
+ const output = result.outputs.find(output => output.path.endsWith('.js')) || result.outputs[0]
410
+ if (!output) return { mtime, errors: ['Bun.build did not return a JavaScript output'] }
411
+
412
+ let code = await output.text()
413
+ const css = (await Promise.all(
414
+ result.outputs
415
+ .filter(output => output.path.endsWith('.css'))
416
+ .map(output => output.text())
417
+ )).join('\n')
418
+ if (css) {
419
+ const id = JSON.stringify('vendor:' + entrypoint)
420
+ code = [
421
+ `const __bimba_vendor_css_id = ${id};`,
422
+ `let __bimba_vendor_css = document.querySelector('style[data-bimba-css=' + JSON.stringify(__bimba_vendor_css_id) + ']');`,
423
+ `if (!__bimba_vendor_css) { __bimba_vendor_css = document.createElement('style'); __bimba_vendor_css.setAttribute('data-bimba-css', __bimba_vendor_css_id); document.head.appendChild(__bimba_vendor_css); }`,
424
+ `__bimba_vendor_css.textContent = ${JSON.stringify(css)};`,
425
+ code,
426
+ ].join('\n')
427
+ }
428
+ const bundled = { mtime, code }
429
+ _vendorCache.set(entrypoint, bundled)
430
+ return bundled
431
+ } catch (error) {
432
+ return { mtime: 0, errors: [error?.message || String(error)] }
508
433
  }
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
434
  }
537
435
 
538
436
  async function buildGeneratedImportMap() {
539
437
  const imports = {}
540
- const visited = new Set()
541
- const queue = []
542
438
 
543
439
  try {
544
440
  const pkg = JSON.parse(await Bun.file('./package.json').text())
545
441
  for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
546
- queue.push(...Object.keys(pkg[field] || {}))
547
- }
548
- } catch(_) { /* no package.json */ }
549
-
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)
442
+ for (const name of Object.keys(pkg[field] || {})) {
443
+ imports[name] ||= vendorUrl(name)
444
+ imports[name + '/'] ||= vendorPrefixUrl(name)
572
445
  }
573
- } else {
574
- imports[name + '/'] = '/' + toPosix(path.join('node_modules', name)) + '/'
575
446
  }
447
+ } catch(_) { /* no package.json */ }
576
448
 
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'
449
+ imports['imba'] ||= vendorUrl('imba')
450
+ imports['imba/'] ||= vendorPrefixUrl('imba')
451
+ imports['imba/runtime'] ||= vendorUrl('imba/runtime')
584
452
 
585
453
  return { imports }
586
454
  }
@@ -632,15 +500,6 @@ function renderImportMapTag(importMap) {
632
500
  return `\t\t<script type="importmap">\n\t\t\t${JSON.stringify(importMap, null, '\t\t\t\t')}\n\t\t</script>`
633
501
  }
634
502
 
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 };`
642
- }
643
-
644
503
  // Rewrite production HTML for the dev server:
645
504
  // strips existing importmap + data-entrypoint script, injects merged importmap +
646
505
  // entrypoint module + HMR client before </head>.
@@ -786,16 +645,26 @@ export function serve(entrypoint, flags) {
786
645
  if (server.upgrade(req)) return undefined
787
646
  }
788
647
 
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
- })
648
+ if (pathname.startsWith('/__bimba_vendor__/')) {
649
+ const specifier = vendorSpecifierFromPath(pathname)
650
+ const bundled = specifier ? await bundleVendor(specifier) : null
651
+ if (bundled?.code) {
652
+ return new Response(bundled.code, { headers: { 'Content-Type': 'application/javascript' } })
797
653
  }
798
654
 
655
+ return new Response((bundled?.errors || [`Could not bundle vendor module: ${specifier}`]).join('\n'), { status: 500 })
656
+ }
657
+
658
+ // HTML: index or any .html file
659
+ if (pathname === '/' || pathname.endsWith('.html')) {
660
+ const htmlFile = pathname === '/' ? htmlPath : '.' + pathname
661
+ let html = await Bun.file(htmlFile).text()
662
+ if (!generatedImportMap) generatedImportMap = await buildGeneratedImportMap()
663
+ return new Response(transformHtml(html, entrypoint, generatedImportMap), {
664
+ headers: { 'Content-Type': 'text/html' },
665
+ })
666
+ }
667
+
799
668
  // Imba files: compile on demand and serve as JS
800
669
  if (pathname.endsWith('.imba')) {
801
670
  const filepath = '.' + pathname
@@ -838,31 +707,25 @@ export function serve(entrypoint, flags) {
838
707
  }
839
708
  }
840
709
 
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')) {
849
- const f = Bun.file(filePath)
850
- if (!(await f.exists())) return null
851
- const out = await compileFile(filePath)
852
- if (out.errors?.length) return new Response(out.errors.map(e => e.message).join('\n'), { status: 500 })
853
- return new Response(out.js, { headers: { 'Content-Type': 'application/javascript' } })
854
- }
855
- const f = Bun.file(filePath)
856
- if (!(await f.exists())) return null
857
- const code = await f.text()
858
- const wrapped = wrapCJS(code)
859
- return new Response(wrapped || code, { headers: { 'Content-Type': 'application/javascript' } })
860
- }
710
+ // Direct node_modules URLs (from user import maps or explicit imports)
711
+ // are bundled through Bun too, so browser/cjs/exports handling stays
712
+ // in one place.
713
+ if (pathname.startsWith('/node_modules/')) {
714
+ const resolved = resolveFileCandidate('.' + pathname)
715
+ if (resolved?.endsWith('.imba')) {
716
+ const out = await compileFile(resolved)
717
+ if (out.errors?.length) return new Response(out.errors.map(e => e.message).join('\n'), { status: 500 })
718
+ return new Response(out.js, { headers: { 'Content-Type': 'application/javascript' } })
719
+ }
861
720
 
862
- const resolved = await resolveNodeModulesRequest(pathname)
863
- const resp = resolved ? await serveResolved(resolved) : null
864
- if (resp) return resp
721
+ if (resolved) {
722
+ const bundled = await bundleVendor(path.resolve(resolved))
723
+ if (bundled?.code) {
724
+ return new Response(bundled.code, { headers: { 'Content-Type': 'application/javascript' } })
725
+ }
726
+ return new Response((bundled?.errors || [`Could not bundle ${pathname}`]).join('\n'), { status: 500 })
865
727
  }
728
+ }
866
729
 
867
730
  // Static files: check htmlDir first (for assets relative to HTML), then root
868
731
  const inHtmlDir = Bun.file(path.join(htmlDir, pathname))