bimba-cli 0.7.10 → 0.7.12

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 +117 -234
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.12",
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
- import { serve as bunServe, Glob } from 'bun'
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'
@@ -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('/') }
347
+ function vendorUrl(specifier) {
348
+ return '/__bimba_vendor__/' + specifier
378
349
  }
379
350
 
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
351
+ function vendorPrefixUrl(specifier) {
352
+ return vendorUrl(specifier) + '/'
452
353
  }
453
354
 
454
355
  function resolveFileCandidate(filepath) {
@@ -477,110 +378,97 @@ 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
- }
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 }))
388
+ function vendorEntrypoint(entrypoint) {
389
+ if (path.isAbsolute(entrypoint)) return entrypoint
390
+
391
+ const dir = path.join(process.cwd(), 'node_modules', '.cache', 'bimba', 'vendor-entry')
392
+ mkdirSync(dir, { recursive: true })
393
+
394
+ const name = encodeURIComponent(entrypoint).replace(/%/g, '_')
395
+ const file = path.join(dir, name + '.js')
396
+ const specifier = JSON.stringify(entrypoint)
397
+ const code = [
398
+ `export * from ${specifier};`,
399
+ `import * as mod from ${specifier};`,
400
+ `export default (mod.default ?? mod);`,
401
+ '',
402
+ ].join('\n')
403
+ writeFileSync(file, code)
404
+ return file
513
405
  }
514
406
 
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
407
+ async function bundleVendor(entrypoint) {
408
+ try {
409
+ const stat = path.isAbsolute(entrypoint) && existsSync(entrypoint) ? statSync(entrypoint) : null
410
+ const mtime = stat?.mtimeMs || 0
411
+ const cached = _vendorCache.get(entrypoint)
412
+ if (cached && cached.mtime === mtime) return cached
413
+
414
+ const buildEntrypoint = vendorEntrypoint(entrypoint)
415
+ const result = await Bun.build({
416
+ entrypoints: [buildEntrypoint],
417
+ target: 'browser',
418
+ format: 'esm',
419
+ write: false,
420
+ minify: false,
421
+ sourcemap: 'none',
422
+ packages: 'bundle',
423
+ })
424
+
425
+ if (!result.success) {
426
+ return { mtime, errors: result.logs.map(log => String(log)) }
427
+ }
529
428
 
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))
429
+ const output = result.outputs.find(output => output.path.endsWith('.js')) || result.outputs[0]
430
+ if (!output) return { mtime, errors: ['Bun.build did not return a JavaScript output'] }
431
+
432
+ let code = await output.text()
433
+ const css = (await Promise.all(
434
+ result.outputs
435
+ .filter(output => output.path.endsWith('.css'))
436
+ .map(output => output.text())
437
+ )).join('\n')
438
+ if (css) {
439
+ const id = JSON.stringify('vendor:' + entrypoint)
440
+ code = [
441
+ `const __bimba_vendor_css_id = ${id};`,
442
+ `let __bimba_vendor_css = document.querySelector('style[data-bimba-css=' + JSON.stringify(__bimba_vendor_css_id) + ']');`,
443
+ `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); }`,
444
+ `__bimba_vendor_css.textContent = ${JSON.stringify(css)};`,
445
+ code,
446
+ ].join('\n')
447
+ }
448
+ const bundled = { mtime, code }
449
+ _vendorCache.set(entrypoint, bundled)
450
+ return bundled
451
+ } catch (error) {
452
+ return { mtime: 0, errors: [error?.message || String(error)] }
533
453
  }
534
-
535
- return mappings
536
454
  }
537
455
 
538
456
  async function buildGeneratedImportMap() {
539
457
  const imports = {}
540
- const visited = new Set()
541
- const queue = []
542
458
 
543
459
  try {
544
460
  const pkg = JSON.parse(await Bun.file('./package.json').text())
545
461
  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)
462
+ for (const name of Object.keys(pkg[field] || {})) {
463
+ imports[name] ||= vendorUrl(name)
464
+ imports[name + '/'] ||= vendorPrefixUrl(name)
572
465
  }
573
- } else {
574
- imports[name + '/'] = '/' + toPosix(path.join('node_modules', name)) + '/'
575
466
  }
467
+ } catch(_) { /* no package.json */ }
576
468
 
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'
469
+ imports['imba'] ||= vendorUrl('imba')
470
+ imports['imba/'] ||= vendorPrefixUrl('imba')
471
+ imports['imba/runtime'] ||= vendorUrl('imba/runtime')
584
472
 
585
473
  return { imports }
586
474
  }
@@ -632,15 +520,6 @@ function renderImportMapTag(importMap) {
632
520
  return `\t\t<script type="importmap">\n\t\t\t${JSON.stringify(importMap, null, '\t\t\t\t')}\n\t\t</script>`
633
521
  }
634
522
 
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
523
  // Rewrite production HTML for the dev server:
645
524
  // strips existing importmap + data-entrypoint script, injects merged importmap +
646
525
  // entrypoint module + HMR client before </head>.
@@ -786,16 +665,26 @@ export function serve(entrypoint, flags) {
786
665
  if (server.upgrade(req)) return undefined
787
666
  }
788
667
 
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
- })
668
+ if (pathname.startsWith('/__bimba_vendor__/')) {
669
+ const specifier = vendorSpecifierFromPath(pathname)
670
+ const bundled = specifier ? await bundleVendor(specifier) : null
671
+ if (bundled?.code) {
672
+ return new Response(bundled.code, { headers: { 'Content-Type': 'application/javascript' } })
797
673
  }
798
674
 
675
+ return new Response((bundled?.errors || [`Could not bundle vendor module: ${specifier}`]).join('\n'), { status: 500 })
676
+ }
677
+
678
+ // HTML: index or any .html file
679
+ if (pathname === '/' || pathname.endsWith('.html')) {
680
+ const htmlFile = pathname === '/' ? htmlPath : '.' + pathname
681
+ let html = await Bun.file(htmlFile).text()
682
+ if (!generatedImportMap) generatedImportMap = await buildGeneratedImportMap()
683
+ return new Response(transformHtml(html, entrypoint, generatedImportMap), {
684
+ headers: { 'Content-Type': 'text/html' },
685
+ })
686
+ }
687
+
799
688
  // Imba files: compile on demand and serve as JS
800
689
  if (pathname.endsWith('.imba')) {
801
690
  const filepath = '.' + pathname
@@ -838,31 +727,25 @@ export function serve(entrypoint, flags) {
838
727
  }
839
728
  }
840
729
 
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
- }
730
+ // Direct node_modules URLs (from user import maps or explicit imports)
731
+ // are bundled through Bun too, so browser/cjs/exports handling stays
732
+ // in one place.
733
+ if (pathname.startsWith('/node_modules/')) {
734
+ const resolved = resolveFileCandidate('.' + pathname)
735
+ if (resolved?.endsWith('.imba')) {
736
+ const out = await compileFile(resolved)
737
+ if (out.errors?.length) return new Response(out.errors.map(e => e.message).join('\n'), { status: 500 })
738
+ return new Response(out.js, { headers: { 'Content-Type': 'application/javascript' } })
739
+ }
861
740
 
862
- const resolved = await resolveNodeModulesRequest(pathname)
863
- const resp = resolved ? await serveResolved(resolved) : null
864
- if (resp) return resp
741
+ if (resolved) {
742
+ const bundled = await bundleVendor(path.resolve(resolved))
743
+ if (bundled?.code) {
744
+ return new Response(bundled.code, { headers: { 'Content-Type': 'application/javascript' } })
745
+ }
746
+ return new Response((bundled?.errors || [`Could not bundle ${pathname}`]).join('\n'), { status: 500 })
865
747
  }
748
+ }
866
749
 
867
750
  // Static files: check htmlDir first (for assets relative to HTML), then root
868
751
  const inHtmlDir = Bun.file(path.join(htmlDir, pathname))