bimba-cli 0.6.1 → 0.7.1

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 +9 -3
  2. package/package.json +1 -1
  3. package/serve.js +180 -13
package/README.md CHANGED
@@ -37,7 +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
40
+ - Injects an importmap built from your `package.json` dependencies (supports `exports`, `module`, `browser`, and `main` fields)
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
41
43
  - Injects an HMR client that swaps component prototypes without a full page reload
42
44
 
43
45
  **HMR internals:**
@@ -48,10 +50,12 @@ Since Imba custom elements can't be registered twice (`customElements.define` th
48
50
 
49
51
  After patching, bimba clears each element's Imba render cache (anonymous `Symbol` keys pointing to DOM nodes) and sets `innerHTML = ''`, so the new render method starts from a clean slate. Then `imba.commit()` triggers a re-render of all mounted components.
50
52
 
51
- CSS is handled automatically: Imba's runtime calls `imba_styles.register()` during module execution, which updates the `<style>` tag in place — no extra DOM work needed.
53
+ CSS is handled automatically: Imba's runtime calls `imba_styles.register()` during module execution, which updates the `<style>` tag in place — no extra DOM work needed. CSS files from npm packages (e.g. `import 'pkg/styles.css'`) are served as JS modules that inject and update `<style>` tags.
52
54
 
53
55
  Duplicate root elements (caused by `imba.mount()` running again on re-import) are removed by a dedup pass over `document.body.children` before any other HMR logic runs.
54
56
 
57
+ **Smart HMR:** bimba detects whether a change affects the template structure (adding/removing elements) or just CSS/logic. CSS-only and logic-only changes patch prototypes in place without wiping innerHTML — preserving input focus, scroll position, and open popups. Template-structural changes do a full wipe-and-rerender to ensure correctness.
58
+
55
59
  **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:
56
60
 
57
61
  ```html
@@ -66,7 +70,9 @@ Duplicate root elements (caused by `imba.mount()` running again on re-import) ar
66
70
 
67
71
  `--html <path>` — path to your HTML file (auto-detected from `./index.html`, `./public/index.html`, `./src/index.html` if omitted)
68
72
 
69
- Static files are resolved relative to the HTML file's directory first, then from the project root (for `node_modules`, `src`, etc.).
73
+ 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.
74
+
75
+ **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.
70
76
 
71
77
  ---
72
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bimba-cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/HeapVoid/bimba.git"
package/serve.js CHANGED
@@ -28,7 +28,6 @@ const hmrClient = `
28
28
  const _newClasses = new Map(); // tagName → latest class from HMR import
29
29
  const _oldNs = new Map(); // tagName → previous _ns_ (saved before _patchClass wipes it)
30
30
  let _collector = null; // when set, captures tag names defined during one HMR import
31
- let _stableSlots = false; // when true, _patchClass skips Symbol-keyed props (CSS-only HMR)
32
31
 
33
32
  customElements.define = function(name, cls, opts) {
34
33
  if (_collector) _collector.push(name);
@@ -42,9 +41,8 @@ const hmrClient = `
42
41
  if (target) {
43
42
  // Save old _ns_ before _patchClass overwrites prototype descriptors
44
43
  if (target.prototype._ns_) _oldNs.set(name, target.prototype._ns_);
45
- // When CSS-only (stable slots), skip patching render/methods
46
- // new render() has new Symbol closures that would cause duplicate DOM.
47
- if (!_stableSlots) _patchClass(target, cls);
44
+ // Always patch: even CSS-only changes may update methods.
45
+ _patchClass(target, cls);
48
46
  }
49
47
  }
50
48
  };
@@ -109,12 +107,10 @@ const hmrClient = `
109
107
  const collected = [];
110
108
  const prev = _collector;
111
109
  _collector = collected;
112
- _stableSlots = (slots === 'stable');
113
110
  try {
114
111
  await import('/' + file + '?t=' + Date.now());
115
112
  } finally {
116
113
  _collector = prev;
117
- _stableSlots = false;
118
114
  }
119
115
 
120
116
  // Sync _ns_ (CSS namespace) from the new classes. imba_defineTag sets
@@ -141,8 +137,10 @@ const hmrClient = `
141
137
  }
142
138
 
143
139
  // Destructive HMR: wipe inner DOM and re-render each collected tag.
144
- // Skip when slots === 'stable' (CSS-only change) — no template diff,
145
- // so wiping innerHTML would destroy DOM state (inputs, focus) for nothing.
140
+ // Skip when slots === 'stable' — template structure unchanged (CSS-only
141
+ // or logic-only edit that didn't add/remove elements), so wiping innerHTML
142
+ // would destroy DOM state (inputs, focus, popups) for nothing.
143
+ // _patchClass already ran above, so methods are up-to-date either way.
146
144
  if (slots !== 'stable') {
147
145
  for (const tag of collected) {
148
146
  const els = document.querySelectorAll(tag);
@@ -410,6 +408,121 @@ function findHtml(flagHtml) {
410
408
  return candidates.find(p => existsSync(p)) || './index.html';
411
409
  }
412
410
 
411
+ // ─── Node modules resolution ─────────────────────────────────────────────────
412
+ //
413
+ // Packages with conditional exports (e.g. highlight.js) map subpaths like
414
+ // "./lib/languages/*" to different files for `require` vs `import`. The browser
415
+ // import map uses a simple trailing-slash prefix mapping, so
416
+ // `highlight.js/lib/languages/javascript` → `/node_modules/highlight.js/lib/languages/javascript`.
417
+ // But that's the CJS file — the browser needs the ESM one under `es/`.
418
+ //
419
+ // We solve this at the HTTP level: when serving a JS file from node_modules,
420
+ // check if the file is CJS and the package has an ESM alternative via its
421
+ // `exports` field. If so, rewrite to the ESM path.
422
+
423
+ const _pkgJsonCache = new Map() // pkg root dir → parsed package.json
424
+
425
+ async function readPkgJson(pkgDir) {
426
+ const cached = _pkgJsonCache.get(pkgDir)
427
+ if (cached) return cached
428
+ try {
429
+ const json = JSON.parse(await Bun.file(path.join(pkgDir, 'package.json')).text())
430
+ _pkgJsonCache.set(pkgDir, json)
431
+ return json
432
+ } catch(_) {
433
+ return null
434
+ }
435
+ }
436
+
437
+ // Given a request path inside node_modules (e.g. "node_modules/highlight.js/lib/languages/javascript"),
438
+ // try to resolve via conditional exports to find the ESM version.
439
+ // Returns the rewritten filesystem path, or null if no rewrite needed.
440
+ async function resolveNodeModuleESM(filepath) {
441
+ // Only rewrite JS files (or extensionless → .js)
442
+ const parts = filepath.split('/')
443
+ const nmIdx = parts.indexOf('node_modules')
444
+ if (nmIdx === -1) return null
445
+
446
+ const pkgName = parts[nmIdx + 1].startsWith('@')
447
+ ? parts[nmIdx + 1] + '/' + parts[nmIdx + 2]
448
+ : parts[nmIdx + 1]
449
+ const pkgDir = parts.slice(0, nmIdx + 1 + (pkgName.includes('/') ? 2 : 1)).join('/')
450
+ const depPkg = await readPkgJson(pkgDir)
451
+ if (!depPkg?.exports || typeof depPkg.exports === 'string') return null
452
+
453
+ // Build the subpath relative to pkg root: "./lib/languages/javascript"
454
+ const pkgParts = pkgName.includes('/') ? 2 : 1
455
+ const subParts = parts.slice(nmIdx + 1 + pkgParts)
456
+ const subpath = './' + subParts.join('/')
457
+
458
+ const exp = depPkg.exports
459
+
460
+ // Try exact match first: exports["./lib/core"]
461
+ if (exp[subpath]) {
462
+ const target = exp[subpath]
463
+ const esm = typeof target === 'string' ? target : (target?.import || target?.default)
464
+ if (esm) {
465
+ const resolved = path.join(pkgDir, esm)
466
+ if (existsSync(resolved)) return resolved
467
+ }
468
+ }
469
+
470
+ // Try with .js extension: exports["./lib/core"] for request "./lib/core"
471
+ const subpathJs = subpath + '.js'
472
+ if (exp[subpathJs]) {
473
+ const target = exp[subpathJs]
474
+ const esm = typeof target === 'string' ? target : (target?.import || target?.default)
475
+ if (esm) {
476
+ const resolved = path.join(pkgDir, esm)
477
+ if (existsSync(resolved)) return resolved
478
+ }
479
+ }
480
+
481
+ // Try wildcard match: exports["./lib/languages/*"]
482
+ for (const [pattern, target] of Object.entries(exp)) {
483
+ if (!pattern.includes('*')) continue
484
+ const prefix = pattern.slice(0, pattern.indexOf('*'))
485
+ const suffix = pattern.slice(pattern.indexOf('*') + 1)
486
+
487
+ // Check both with and without .js extension
488
+ for (const sp of [subpath, subpathJs]) {
489
+ if (!sp.startsWith(prefix)) continue
490
+ if (suffix && !sp.endsWith(suffix)) continue
491
+ const stem = sp.slice(prefix.length, suffix ? -suffix.length || undefined : undefined)
492
+
493
+ const esm = typeof target === 'string' ? target : (target?.import || target?.default)
494
+ if (!esm || !esm.includes('*')) continue
495
+
496
+ const resolved = path.join(pkgDir, esm.replace('*', stem))
497
+ if (existsSync(resolved)) return resolved
498
+ }
499
+ }
500
+
501
+ return null
502
+ }
503
+
504
+ // Resolve the ESM entry point for an npm package.
505
+ // Priority: exports["."].import → exports["."].default → module → main
506
+ function resolveEntry(depPkg) {
507
+ const exp = depPkg.exports;
508
+ if (exp) {
509
+ // exports: "./index.js" (string shorthand)
510
+ if (typeof exp === 'string') return exp;
511
+ // exports: { ".": { "import": "...", "default": "..." } }
512
+ const dot = exp['.'];
513
+ if (dot) {
514
+ if (typeof dot === 'string') return dot;
515
+ if (dot.import) return dot.import;
516
+ if (dot.default) return dot.default;
517
+ }
518
+ // exports: { "import": "...", "default": "..." } (no "." wrapper)
519
+ if (exp.import) return exp.import;
520
+ if (exp.default) return exp.default;
521
+ }
522
+ // Fallback to legacy fields
523
+ return depPkg.module || depPkg.browser || depPkg.main;
524
+ }
525
+
413
526
  // Build an ES import map from package.json dependencies.
414
527
  // Packages with an .imba entry point are served locally; others via esm.sh.
415
528
  async function buildImportMap() {
@@ -423,12 +536,23 @@ async function buildImportMap() {
423
536
  if (name === 'imba') continue;
424
537
  try {
425
538
  const depPkg = JSON.parse(await Bun.file(`./node_modules/${name}/package.json`).text());
426
- const entry = depPkg.module || depPkg.main;
427
- imports[name] = (entry && entry.endsWith('.imba'))
428
- ? `/node_modules/${name}/${entry}`
429
- : `https://esm.sh/${name}`;
539
+ const entry = resolveEntry(depPkg);
540
+ if (entry && !entry.startsWith('http')) {
541
+ // Local package — serve from node_modules
542
+ imports[name] = `/node_modules/${name}/${entry}`;
543
+ // Always add trailing-slash mapping for deep/subpath imports
544
+ // (e.g. highlight.js/lib/languages/javascript). The browser
545
+ // doesn't enforce Node's `exports` restrictions, it just needs
546
+ // a URL prefix to resolve bare specifiers like "pkg/sub/path".
547
+ imports[name + '/'] = `/node_modules/${name}/`;
548
+ } else {
549
+ // No resolvable entry — use esm.sh to auto-wrap
550
+ imports[name] = `https://esm.sh/${name}`;
551
+ imports[name + '/'] = `https://esm.sh/${name}/`;
552
+ }
430
553
  } catch(_) {
431
554
  imports[name] = `https://esm.sh/${name}`;
555
+ imports[name + '/'] = `https://esm.sh/${name}/`;
432
556
  }
433
557
  }
434
558
  } catch(_) { /* no package.json */ }
@@ -624,14 +748,57 @@ export function serve(entrypoint, flags) {
624
748
  }
625
749
  }
626
750
 
751
+ // CSS files imported from JS: wrap as a JS module that injects a <style> tag.
752
+ // Without this, `import './styles.css'` inside an ESM package fails because
753
+ // the browser expects a JS module response, not raw CSS.
754
+ if (pathname.endsWith('.css')) {
755
+ const cssFile = Bun.file('.' + pathname)
756
+ if (await cssFile.exists()) {
757
+ const css = await cssFile.text()
758
+ const id = JSON.stringify(pathname)
759
+ const js = [
760
+ `const id = ${id};`,
761
+ `let el = document.querySelector('style[data-bimba-css=' + JSON.stringify(id) + ']');`,
762
+ `if (!el) { el = document.createElement('style'); el.setAttribute('data-bimba-css', id); document.head.appendChild(el); }`,
763
+ `el.textContent = ${JSON.stringify(css)};`,
764
+ ].join('\n')
765
+ return new Response(js, { headers: { 'Content-Type': 'application/javascript' } })
766
+ }
767
+ }
768
+
769
+ // node_modules: resolve conditional exports (CJS → ESM) before serving.
770
+ // e.g. highlight.js/lib/languages/javascript → es/languages/javascript.js
771
+ if (pathname.startsWith('/node_modules/')) {
772
+ const filepath = '.' + pathname
773
+ const esmPath = await resolveNodeModuleESM(filepath)
774
+ if (esmPath) {
775
+ const file = Bun.file(esmPath)
776
+ if (await file.exists()) return new Response(file, {
777
+ headers: { 'Content-Type': 'application/javascript' },
778
+ })
779
+ }
780
+ }
781
+
627
782
  // Static files: check htmlDir first (for assets relative to HTML), then root
628
783
  const inHtmlDir = Bun.file(path.join(htmlDir, pathname))
629
784
  if (await inHtmlDir.exists()) return new Response(inHtmlDir)
630
785
  const inRoot = Bun.file('.' + pathname)
631
786
  if (await inRoot.exists()) return new Response(inRoot)
632
787
 
633
- // SPA fallback for extension-less paths
788
+ // Try .js / .mjs extension for extensionless paths (e.g. node_modules imports)
634
789
  const lastSegment = pathname.split('/').pop()
790
+ if (!lastSegment.includes('.')) {
791
+ // For node_modules, ESM resolution above already handles extensionless
792
+ // paths via wildcard exports matching (tries subpath + '.js').
793
+ for (const ext of ['.js', '.mjs']) {
794
+ const withExt = Bun.file('.' + pathname + ext)
795
+ if (await withExt.exists()) return new Response(withExt, {
796
+ headers: { 'Content-Type': 'application/javascript' },
797
+ })
798
+ }
799
+ }
800
+
801
+ // SPA fallback for extension-less paths
635
802
  if (!lastSegment.includes('.')) {
636
803
  let html = await Bun.file(htmlPath).text()
637
804
  if (!importMapTag) importMapTag = await buildImportMap()