bimba-cli 0.7.8 → 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.
package/INTERNALS.md ADDED
@@ -0,0 +1,300 @@
1
+ # Bimba Internals: How Imba Rendering Works and How Bimba HMR Hooks Into It
2
+
3
+ Technical reference for debugging and extending bimba's dev server (`serve.js`).
4
+
5
+ ---
6
+
7
+ ## 1. How Imba Compiles Tags to JS
8
+
9
+ Imba source:
10
+ ```imba
11
+ tag my-popup
12
+ name = ''
13
+
14
+ def mount
15
+ name = 'hello'
16
+
17
+ <self @click.self=(emit('close'))>
18
+ <div.dialog>
19
+ <span.title> "Settings"
20
+ if condition
21
+ <img src=url>
22
+ else
23
+ <span.placeholder> "?"
24
+ <button @click=save> "Save"
25
+ ```
26
+
27
+ Compiled JS (simplified):
28
+ ```js
29
+ import { Component, defineTag, createElement, createComponent, ... } from 'imba';
30
+ const $beforeReconcile$ = Symbol.for('#beforeReconcile');
31
+ const $afterVisit$ = Symbol.for('#afterVisit');
32
+ const $placeChild$ = Symbol.for('#placeChild');
33
+ const $$up$ = Symbol.for('##up');
34
+
35
+ // Anonymous Symbols — one per DOM slot in the render tree.
36
+ // These are the RENDER CACHE KEYS.
37
+ var $7 = Symbol(), $11 = Symbol(), $13 = Symbol(), $19 = Symbol(), $24 = Symbol(), ...;
38
+ let c$0 = Symbol(); // class identity symbol
39
+
40
+ class MyPopupComponent extends Component {
41
+ [__init__$]($$ = null) {
42
+ super[__init__$](...arguments);
43
+ this.name = ($$ && $$.name !== undefined) ? $$.name : '';
44
+ }
45
+
46
+ mount() { this.name = 'hello'; }
47
+
48
+ render() {
49
+ var $4, $5, $6, $8 = this._ns_ || '', $9, $18, ...;
50
+ $4 = this; // $4 = the element itself
51
+ $4[$beforeReconcile$]();
52
+
53
+ // ── "First render" check ──
54
+ // $7 is a Symbol. this[$7] is stored on the INSTANCE.
55
+ // First render: this[$7] is undefined → $5=0 (CREATE mode)
56
+ // Re-render: this[$7] === 1 → $5=1 (REUSE mode)
57
+ ($5=$6=1, $4[$7] === 1) || ($5=$6=0, $4[$7] = 1);
58
+
59
+ // ── Static children: created only on first render ($5=0) ──
60
+ $5 || ($4.on$('click', {self: true, ...}));
61
+ $5 || ($9 = createElement('div', $4, `dialog ${$8}`, null));
62
+ // ↑ createElement appends $9 as child of $4
63
+
64
+ // ── Cached children: checked on every render ──
65
+ ($10 = $4[$11]) || ($4[$11] = $10 = createElement('span', $9, ...));
66
+ // ↑ try cache ↑ miss → create and cache
67
+
68
+ // ── Conditional blocks ──
69
+ $18 = null;
70
+ if (this.condition) {
71
+ ($20=$21=1, $18=$4[$19]) || ($20=$21=0, $4[$19]=$18=createElement('img',...));
72
+ } else {
73
+ ($25=$26=1, $18=$4[$24]) || ($25=$26=0, $4[$24]=$18=createElement('span',...));
74
+ }
75
+ // placeChild manages which branch is in the DOM
76
+ ($4[$30] = $16[$placeChild$]($18, 0, $4[$30]));
77
+
78
+ $4[$afterReconcile$]($6);
79
+ return $4;
80
+ }
81
+
82
+ // Static block runs at class definition time
83
+ static {
84
+ register$(this, c$0, 'my-popup', 2); // → calls customElements.define
85
+ defineTag('my-popup', this, {cssns: 'z1abc_xy', cssid: 'z1abc-xy'});
86
+ }
87
+ }
88
+
89
+ // CSS is registered as a global stylesheet
90
+ imba_styles.register('z1abc', "...");
91
+ ```
92
+
93
+ ### Key points
94
+
95
+ | Concept | Details |
96
+ |---------|---------|
97
+ | **Render cache** | Each DOM node is cached on the element instance under an anonymous `Symbol()` key. `this[$sym] \|\| (this[$sym] = createElement(...))`. |
98
+ | **Create vs Reuse** | `this[$7] === 1` is the master flag. `$5=0` = first render (create all), `$5=1` = re-render (reuse cached). |
99
+ | **Static children** | Guarded by `$5 \|\| (...)` — created only on first render, never recreated. |
100
+ | **Conditional children** | Each branch has its own cache slot (`$19` for `if`, `$24` for `else`). `$placeChild$` swaps them in/out. |
101
+ | **CSS namespace** | `_ns_` on prototype (e.g. `"z1abc_xy "`). Used as className prefix. Hash changes when CSS content changes. |
102
+ | **Tag registration** | `register$` → `customElements.define()`. `defineTag` → sets `_ns_`, `cssid`, registers in Imba's internal tag registry (`J[name]`, `xh[name]`). |
103
+ | **Lifecycle** | `__init__$` (property defaults), `connectedCallback` (DOM attachment), `mount` (post-connect, user code), `render` (DOM creation/update). |
104
+
105
+ ### Imba runtime functions
106
+
107
+ | Function | What it does |
108
+ |----------|-------------|
109
+ | `createElement(tag, parent, className, text)` | `document.createElement` + `parent[appendChild$](el)`. For plain HTML elements. |
110
+ | `createComponent(name, parent, className, text)` | Same but for custom elements. If `name` is a string, uses `document.createElement(name)`. |
111
+ | `imba_styles.register(id, css)` | Injects/updates a `<style>` element in `<head>`. Idempotent by `id`. |
112
+ | `defineTag(name, klass, opts)` | Registers tag in Imba's internal registry. Sets `_ns_`, `cssid`, `flags$ns` on prototype. |
113
+ | `register$(klass, symbol, name, flags)` | Sets up class metadata (`__meta__$`), calls `customElements.define`. |
114
+ | `imba.commit()` | Schedules a render tick via `requestAnimationFrame`. All scheduled components re-render. |
115
+ | `$beforeReconcile$` | Called at start of render. Clears internal child tracking state. |
116
+ | `$afterReconcile$` | Called at end of render. Finalizes child list. |
117
+ | `$placeChild$(child, type, prev)` | Manages conditional/dynamic child placement. Inserts/removes/replaces nodes. |
118
+ | `$afterVisit$(flag)` | Post-render hook on a component child. Triggers its own render if needed. |
119
+
120
+ ---
121
+
122
+ ## 2. The Problem Bimba Solves
123
+
124
+ Browsers have no built-in HMR for custom elements:
125
+ - `customElements.define(name, class)` can only be called ONCE per tag name
126
+ - Re-importing a module creates fresh `Symbol()` instances — old cache keys become orphans
127
+ - Without intervention, re-importing causes full duplication of DOM children
128
+
129
+ ---
130
+
131
+ ## 3. How Bimba's HMR Works
132
+
133
+ ### 3.1 Symbol Stabilization (server-side)
134
+
135
+ **Problem:** Each `var $7 = Symbol()` creates a unique symbol. Re-importing the module creates a NEW `$7` symbol. Existing elements have DOM cached under the OLD `$7`. The new render method looks up `this[NEW_$7]` — not found → creates duplicate DOM.
136
+
137
+ **Solution:** Rewrite `Symbol()` calls to use a persistent global cache:
138
+
139
+ ```
140
+ $7 = Symbol()
141
+
142
+ $7 = (__bsyms__["$7"] ||= Symbol())
143
+ ```
144
+
145
+ Where `__bsyms__` is keyed by absolute file path:
146
+ ```js
147
+ const __bsyms__ = ((globalThis.__bimba_syms ||= {})["/abs/path/to/file.imba"] ||= {});
148
+ ```
149
+
150
+ First load: creates symbols, stores in cache.
151
+ HMR reload: reuses same symbols from cache → render finds cached DOM → REUSE mode.
152
+
153
+ **Critical:** The file path key MUST be normalized (absolute via `path.resolve`). Different string representations of the same file (e.g., `./src/foo.imba` vs `src/foo.imba`) produce different cache keys → different symbols → duplication. This was the root cause of the v0.7.8 fix.
154
+
155
+ ### 3.2 Slot Stability Detection
156
+
157
+ If the user adds/removes template elements, the number of `Symbol()` declarations changes. Variable names shift (`$7` now means a different DOM slot). Even with stable symbols, the SEMANTICS change.
158
+
159
+ Detection: count `Symbol()` calls per file. Compare to previous compilation:
160
+ - Same count → `slots: 'stable'` → safe for in-place HMR
161
+ - Different count → `slots: 'shifted'` → must do destructive HMR
162
+
163
+ ### 3.3 Prototype Patching (browser-side)
164
+
165
+ `customElements.define` is hooked:
166
+
167
+ ```
168
+ First call (page load): register normally, save class in _classes map
169
+ Repeat calls (HMR): _patchClass(originalClass, newClass)
170
+ ```
171
+
172
+ `_patchClass` copies ALL own property descriptors (string + symbol keys) from the new class prototype to the original class prototype, skipping `constructor`. Also copies static properties (skipping `length`, `name`, `prototype`, `caller`, `arguments`).
173
+
174
+ Effect: all existing element instances immediately get new methods via the prototype chain. No need to recreate elements.
175
+
176
+ ### 3.4 CSS Namespace Sync
177
+
178
+ When CSS changes, Imba generates a new hash → new `_ns_` (e.g., `"z1abc_xy "` → `"z9def_gh "`). The issue:
179
+
180
+ 1. `register$` → `customElements.define` → bimba's hook → `_patchClass` runs
181
+ 2. `defineTag` runs AFTER `register$` — sets `_ns_` on the NEW class prototype
182
+ 3. But `_patchClass` already ran, so the OLD prototype still has the old `_ns_`
183
+
184
+ Solution: after `import()` completes, sync `_ns_` manually:
185
+ ```js
186
+ oldCls.prototype._ns_ = newCls.prototype._ns_;
187
+ ```
188
+
189
+ Then patch `className` on ALL custom elements in the DOM, replacing old hash parts with new ones.
190
+
191
+ ### 3.5 Always-Destructive HMR
192
+
193
+ > **History:** Earlier versions (≤0.7.8) had two paths — "stable" (in-place
194
+ > prototype patching + `imba.commit()`) and "shifted" (destructive wipe +
195
+ > re-render). The stable path was meant to preserve DOM state (inputs, focus,
196
+ > popups) when only CSS or logic changed without adding/removing template
197
+ > elements. However, it fundamentally didn't work: imba's reconciliation uses
198
+ > slot-tracking symbols (`this[$sym] === 1`) to skip re-creating elements on
199
+ > re-render. Even when `_patchClass` installs a new `render()` method, calling
200
+ > `render()` (or `imba.commit()`) does nothing — the slot check says "already
201
+ > created" and skips `createElement`. Static text, attributes, and other
202
+ > arguments baked into `createElement` calls never update.
203
+ >
204
+ > Since 0.7.9, bimba always takes the destructive path.
205
+
206
+ The `slots` field is still computed and broadcast (for potential future use),
207
+ but the client ignores it. Every HMR update does:
208
+
209
+ 1. `_patchClass` updates prototype (during import)
210
+ 2. `_ns_` is synced
211
+ 3. For each instance of each affected tag:
212
+ - Save instance properties (`Object.keys(el)`)
213
+ - Call `disconnectedCallback` on all descendant custom elements
214
+ - Delete all anonymous Symbol properties (render cache) — skip `Symbol.for(...)` ones
215
+ - `innerHTML = ''` — wipe DOM
216
+ - Restore instance properties
217
+ - `el.render()` — rebuild DOM from scratch with new render method
218
+ - `el.connectedCallback()`, `el.mount()` — re-initialize
219
+ 4. `imba.commit()` for final sync
220
+
221
+ **Trade-off:** Input focus, scroll position, and popup state are lost on every
222
+ edit. This is acceptable because correctness beats convenience — a "stable"
223
+ update that silently ignores the change is far more confusing than losing
224
+ transient UI state.
225
+
226
+ ### 3.6 Body-level Deduplication
227
+
228
+ Some modules call `imba.mount(<app-root>)` at top level. Re-importing the module would create a second root element. After each HMR import, bimba checks for new body children with the same tag name as existing ones and removes duplicates.
229
+
230
+ ---
231
+
232
+ ## 4. Server Architecture
233
+
234
+ ```
235
+ serve.js
236
+ ├── HMR Client (injected as <script> into HTML)
237
+ │ ├── customElements.define hook
238
+ │ ├── _patchClass / _copyDescriptors
239
+ │ ├── _doUpdate (stable/shifted paths)
240
+ │ ├── WebSocket connection
241
+ │ └── Error overlay
242
+
243
+ ├── Symbol Stabilization
244
+ │ ├── stabilizeSymbols(js, absPath)
245
+ │ └── Slot count tracking (_prevSlots)
246
+
247
+ ├── Compiler
248
+ │ ├── compileFile(filepath) — compile + stabilize + cache
249
+ │ ├── _compileCache (abs path → {mtime, result})
250
+ │ └── _prevJs (abs path → js string, for change detection)
251
+
252
+ ├── Import Graph
253
+ │ ├── extractImports(js, absPath) — scan for .imba imports
254
+ │ ├── updateImportGraph(from, deps) — maintain bidirectional graph
255
+ │ └── _imports / _importers maps
256
+
257
+ ├── File Watcher
258
+ │ └── watch(srcDir) → compile → broadcast update via WebSocket
259
+
260
+ ├── HTTP Server
261
+ │ ├── / → HTML with injected import map + HMR client
262
+ │ ├── *.imba → compile on demand → serve as JS
263
+ │ ├── *.css → wrap as JS module (style injection)
264
+ │ ├── /node_modules/* → resolve entry, compile .imba, wrap CJS
265
+ │ └── Static files (htmlDir, then root)
266
+
267
+ ├── Import Map (minimal, browser-side)
268
+ │ └── bare specifier → /node_modules/pkg/ prefix mapping
269
+
270
+ └── Node Modules Resolution (server-side)
271
+ ├── resolveEntry(pkg.json) — exports/module/browser/main
272
+ ├── wrapCJS(code) — detect CJS, wrap as ESM
273
+ └── Extension fallback (.imba → .js → .mjs)
274
+ ```
275
+
276
+ ---
277
+
278
+ ## 5. Common Pitfalls and Debugging
279
+
280
+ ### Symptom: Elements duplicate on first edit, not on second
281
+ **Cause:** Symbol cache key mismatch between initial load and HMR. Check that `stabilizeSymbols` receives the same file path from both the HTTP handler and the file watcher. Must use `path.resolve()` for normalization.
282
+
283
+ ### Symptom: CSS changes don't apply after HMR
284
+ **Cause:** `_ns_` not synced. Check the `_nsPatches` logic — `defineTag` sets `_ns_` AFTER `register$`, so `_patchClass` misses it. The post-import sync block must handle this.
285
+
286
+ ### Symptom: Methods don't update after HMR
287
+ **Cause:** `_patchClass` might not be running. Check that `customElements.get(name)` returns the existing class. Verify the hook on `customElements.define` is active.
288
+
289
+ ### Symptom: State lost on edit (inputs clear, popups close)
290
+ **Cause:** Taking the shifted path when stable would suffice. Check slot count — adding a comment or whitespace shouldn't change `Symbol()` count. If it does, the stabilization regex might be too broad/narrow.
291
+
292
+ ### Symptom: 500 errors on node_modules subpaths
293
+ **Cause:** `serveResolved` trying `.imba` extension without existence check. The `.imba` path calls `compileFile()` which throws on non-existent files.
294
+
295
+ ### Debugging approach
296
+ Add to HMR client `_doUpdate`:
297
+ ```js
298
+ console.log('[bimba]', file, 'slots=' + slots, 'tags:', collected);
299
+ ```
300
+ Check `globalThis.__bimba_syms` in browser console — keys should be absolute paths, values should be objects with `$7`, `$11`, etc. If you see two entries for the same file with different path formats, that's the symbol mismatch bug.
package/README.md CHANGED
@@ -56,6 +56,8 @@ Duplicate root elements (caused by `imba.mount()` running again on re-import) ar
56
56
 
57
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
58
 
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
+
59
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:
60
62
 
61
63
  ```html
@@ -80,7 +82,7 @@ Static files are resolved relative to the HTML file's directory first, then from
80
82
 
81
83
  To compile and bundle your source code from .imba to .js:
82
84
  ```bash
83
- bunx bimba src/index.imba --outdir public/js --minify
85
+ bunx bimba src/index.imba --outdir public/js
84
86
  ```
85
87
 
86
88
  With watch:
@@ -98,7 +100,7 @@ bunx bimba src/index.imba --outdir public/js --watch --clearcache
98
100
 
99
101
  `--clearcache` — delete the cache directory on exit (Ctrl+C). Works only in watch mode.
100
102
 
101
- `--minify` — minify the output JS. Enabled by default in bundle mode.
103
+ `--no-minify` — disable minification. Bundle mode minifies by default.
102
104
 
103
105
  `--sourcemap <inline|external|none>` — how to include source maps in the output (default: `none`).
104
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.8",
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'
@@ -137,28 +137,27 @@ const hmrClient = `
137
137
  }
138
138
 
139
139
  // Destructive HMR: wipe inner DOM and re-render each collected tag.
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.
144
- if (slots !== 'stable') {
145
- for (const tag of collected) {
146
- const els = document.querySelectorAll(tag);
147
- els.forEach(el => {
148
- const state = {};
149
- for (const k of Object.keys(el)) state[k] = el[k];
150
- _disconnectDescendants(el);
151
- for (const sym of Object.getOwnPropertySymbols(el)) {
152
- if (Symbol.keyFor(sym) !== undefined) continue;
153
- try { delete el[sym]; } catch(_) {}
154
- }
155
- el.innerHTML = '';
156
- Object.assign(el, state);
157
- try { el.render && el.render(); } catch(e) { console.error('[bimba] render error:', e); }
158
- try { el.connectedCallback && el.connectedCallback(); } catch(_) {}
159
- try { el.mount && el.mount(); } catch(_) {}
160
- });
161
- }
140
+ // Always destructive regardless of slots value. Imba's reconciliation
141
+ // uses slot-tracking symbols (this[$sym] === 1) to skip re-creating
142
+ // elements on re-render. Even "stable" edits (static text, attributes)
143
+ // won't apply unless we clear those symbols and force a fresh render.
144
+ // _patchClass already ran above, so the new render() method is in place.
145
+ for (const tag of collected) {
146
+ const els = document.querySelectorAll(tag);
147
+ els.forEach(el => {
148
+ const state = {};
149
+ for (const k of Object.keys(el)) state[k] = el[k];
150
+ _disconnectDescendants(el);
151
+ for (const sym of Object.getOwnPropertySymbols(el)) {
152
+ if (Symbol.keyFor(sym) !== undefined) continue;
153
+ try { delete el[sym]; } catch(_) {}
154
+ }
155
+ el.innerHTML = '';
156
+ Object.assign(el, state);
157
+ try { el.render && el.render(); } catch(e) { console.error('[bimba] render error:', e); }
158
+ try { el.connectedCallback && el.connectedCallback(); } catch(_) {}
159
+ try { el.mount && el.mount(); } catch(_) {}
160
+ });
162
161
  }
163
162
 
164
163
  if (typeof imba !== 'undefined') imba.commit();
@@ -257,57 +256,6 @@ const _compileCache = new Map() // filepath → { mtime, result }
257
256
  const _prevJs = new Map() // filepath → compiled js — for change detection
258
257
  const _prevSlots = new Map() // filepath → previous symbol slot count
259
258
 
260
- // ─── Import dependency graph ──────────────────────────────────────────────────
261
- //
262
- // When a non-tag module (utility functions, constants, shared state) is edited,
263
- // the existing class-prototype patching does nothing for the modules that
264
- // imported it — they hold their own captured references. To make those
265
- // updates flow into the UI, we track who imports whom and, on every change,
266
- // re-broadcast updates for the transitive importer set. The client's existing
267
- // HMR queue then re-imports each in turn; their top-level code reruns, picks
268
- // up the new symbols, and any tag re-registrations patch instances in place.
269
- //
270
- // Keys are absolute, normalized paths (path.resolve). Edges are added during
271
- // compilation by scanning the produced JS for relative .imba imports.
272
-
273
- const _imports = new Map() // absFile → Set<absFile> (what it imports)
274
- const _importers = new Map() // absFile → Set<absFile> (who imports it)
275
-
276
- function extractImports(js, fromAbs) {
277
- const dir = path.dirname(fromAbs)
278
- const out = new Set()
279
- const re = /(?:^|[\s;])(?:import|from)\s*['"]([^'"]+)['"]/g
280
- let m
281
- while ((m = re.exec(js))) {
282
- const spec = m[1]
283
- if (!spec.startsWith('.') && !spec.startsWith('/')) continue
284
- if (!spec.endsWith('.imba')) continue
285
- const resolved = spec.startsWith('/')
286
- ? path.resolve('.' + spec)
287
- : path.resolve(dir, spec)
288
- out.add(resolved)
289
- }
290
- return out
291
- }
292
-
293
- function updateImportGraph(fromAbs, newDeps) {
294
- const old = _imports.get(fromAbs)
295
- if (old) {
296
- for (const d of old) {
297
- if (newDeps.has(d)) continue
298
- const set = _importers.get(d)
299
- if (set) { set.delete(fromAbs); if (!set.size) _importers.delete(d) }
300
- }
301
- }
302
- for (const d of newDeps) {
303
- let set = _importers.get(d)
304
- if (!set) { set = new Set(); _importers.set(d, set) }
305
- set.add(fromAbs)
306
- }
307
- _imports.set(fromAbs, newDeps)
308
- }
309
-
310
-
311
259
  // Imba compiles tag render-cache slots as anonymous local Symbols at module top
312
260
  // level: `var $4 = Symbol(), $11 = Symbol(), ...; let c$0 = Symbol();`. Each
313
261
  // re-import of the file creates fresh Symbol objects, so old slot data on live
@@ -374,7 +322,6 @@ async function compileFile(filepath) {
374
322
  const prev = _prevSlots.get(abs)
375
323
  result.slots = (prev === undefined || prev === slotCount) ? 'stable' : 'shifted'
376
324
  _prevSlots.set(abs, slotCount)
377
- updateImportGraph(abs, extractImports(js, abs))
378
325
  }
379
326
 
380
327
  // Bake errors as an own property so caching/spreading preserves them.
@@ -409,69 +356,306 @@ async function readPkgJson(pkgDir) {
409
356
  }
410
357
  }
411
358
 
412
- // Wrap a CommonJS file as an ESM module so the browser can import it.
413
- // Detects CJS by checking for `module.exports` or top-level `exports.` usage.
414
- function wrapCJS(code) {
415
- if (!code.includes('module.exports') && !code.includes('exports.')) return null
416
- // Already has ESM syntax — don't wrap (dual-format files)
417
- if (/^\s*(export\s|import\s)/m.test(code)) return null
418
- 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)))
419
365
  }
420
366
 
421
- // Resolve the ESM entry point for an npm package.
422
- // Priority: exports["."].import → exports["."].default → module → main
423
- function resolveEntry(depPkg) {
424
- const exp = depPkg.exports;
425
- if (exp) {
426
- // exports: "./index.js" (string shorthand)
427
- if (typeof exp === 'string') return exp;
428
- // exports: { ".": { "import": "...", "default": "..." } }
429
- const dot = exp['.'];
430
- if (dot) {
431
- if (typeof dot === 'string') return dot;
432
- if (dot.import) return dot.import;
433
- 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('/'),
434
373
  }
435
- // exports: { "import": "...", "default": "..." } (no "." wrapper)
436
- if (exp.import) return exp.import;
437
- if (exp.default) return exp.default;
438
374
  }
439
- // Fallback to legacy fields
440
- return depPkg.module || depPkg.browser || depPkg.main;
375
+
376
+ const [name, ...rest] = specifier.split('/')
377
+ return { name, subpath: rest.join('/') }
441
378
  }
442
379
 
443
- // Build an ES import map from package.json dependencies.
444
- // The import map is intentionally simple it just maps bare specifiers
445
- // to /node_modules/ URLs. All the smart resolution (conditional exports,
446
- // CJS→ESM, entry points, extensions) happens on the server side.
447
- async function buildImportMap() {
448
- const imports = {
449
- 'imba/runtime': 'https://esm.sh/imba/runtime',
450
- 'imba': 'https://esm.sh/imba',
451
- };
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
+
452
543
  try {
453
- const pkg = JSON.parse(await Bun.file('./package.json').text());
454
- for (const [name] of Object.entries(pkg.dependencies || {})) {
455
- if (name === 'imba') continue;
456
- imports[name] = `/node_modules/${name}/`;
457
- 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] || {}))
458
547
  }
459
548
  } catch(_) { /* no package.json */ }
460
549
 
461
- 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 };`
462
642
  }
463
643
 
464
644
  // Rewrite production HTML for the dev server:
465
- // strips existing importmap + data-entrypoint script, injects importmap +
645
+ // strips existing importmap + data-entrypoint script, injects merged importmap +
466
646
  // entrypoint module + HMR client before </head>.
467
- function transformHtml(html, entrypoint, importMapTag) {
468
- html = html.replace(/<script\s+type=["']importmap["'][^>]*>[\s\S]*?<\/script>/gi, '');
469
- html = html.replace(/<script([^>]*)\bdata-entrypoint\b([^>]*)><\/script>/gi, '');
470
- 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
+
471
655
  html = html.replace('</head>',
472
656
  `${importMapTag}\n\t\t<script type='module' src='${entryUrl}'></script>\n${hmrClient}\n\t</head>`
473
- );
474
- return html;
657
+ )
658
+ return html
475
659
  }
476
660
 
477
661
  // ─── Dev server ───────────────────────────────────────────────────────────────
@@ -482,7 +666,7 @@ export function serve(entrypoint, flags) {
482
666
  const htmlDir = path.dirname(htmlPath)
483
667
  const srcDir = path.dirname(entrypoint)
484
668
  const sockets = new Set()
485
- let importMapTag = null
669
+ let generatedImportMap = null
486
670
 
487
671
  // ── Status line (prints current compile result, fades out on success) ──────
488
672
 
@@ -602,15 +786,15 @@ export function serve(entrypoint, flags) {
602
786
  if (server.upgrade(req)) return undefined
603
787
  }
604
788
 
605
- // HTML: index or any .html file
606
- if (pathname === '/' || pathname.endsWith('.html')) {
607
- const htmlFile = pathname === '/' ? htmlPath : '.' + pathname
608
- let html = await Bun.file(htmlFile).text()
609
- if (!importMapTag) importMapTag = await buildImportMap()
610
- return new Response(transformHtml(html, entrypoint, importMapTag), {
611
- headers: { 'Content-Type': 'text/html' },
612
- })
613
- }
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
+ }
614
798
 
615
799
  // Imba files: compile on demand and serve as JS
616
800
  if (pathname.endsWith('.imba')) {
@@ -654,15 +838,14 @@ export function serve(entrypoint, flags) {
654
838
  }
655
839
  }
656
840
 
657
- // node_modules: entry point resolution, .imba compilation, CJS→ESM wrapping.
658
- // The import map just maps bare specifiers to /node_modules/pkg/ URLs.
659
- // All smart resolution happens here at request time.
660
- if (pathname.startsWith('/node_modules/')) {
661
- const filepath = '.' + pathname
662
-
663
- // Serve a resolved file: compile .imba, wrap CJS as ESM, pass ESM through
664
- const serveResolved = async (filePath) => {
665
- 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')) {
666
849
  const f = Bun.file(filePath)
667
850
  if (!(await f.exists())) return null
668
851
  const out = await compileFile(filePath)
@@ -672,41 +855,14 @@ export function serve(entrypoint, flags) {
672
855
  const f = Bun.file(filePath)
673
856
  if (!(await f.exists())) return null
674
857
  const code = await f.text()
675
- const wrapped = wrapCJS(code)
676
- return new Response(wrapped || code, { headers: { 'Content-Type': 'application/javascript' } })
677
- }
678
-
679
- // Resolve entry point for root package requests (/node_modules/pkg/ or /node_modules/pkg)
680
- const parts = pathname.slice(1).split('/') // ['node_modules', 'pkg', ...]
681
- const isScoped = parts[1]?.startsWith('@')
682
- const pkgParts = isScoped ? 3 : 2 // node_modules/@scope/pkg or node_modules/pkg
683
- const subParts = parts.slice(pkgParts)
684
- const isRootRequest = subParts.length === 0 || (subParts.length === 1 && subParts[0] === '')
685
-
686
- if (isRootRequest) {
687
- const pkgDir = './' + parts.slice(0, pkgParts).join('/')
688
- const depPkg = await readPkgJson(pkgDir)
689
- if (depPkg) {
690
- const entry = resolveEntry(depPkg)
691
- if (entry) {
692
- const resp = await serveResolved(path.join(pkgDir, entry))
693
- if (resp) return resp
694
- }
858
+ const wrapped = wrapCJS(code)
859
+ return new Response(wrapped || code, { headers: { 'Content-Type': 'application/javascript' } })
695
860
  }
696
- }
697
-
698
- // Subpath: try the exact path, then with extensions
699
- const resp = await serveResolved(filepath)
700
- if (resp) return resp
701
861
 
702
- // Extensionless: try .imba, .js, .mjs
703
- if (!filepath.includes('.', filepath.lastIndexOf('/') + 1)) {
704
- for (const ext of ['.imba', '.js', '.mjs']) {
705
- const resp = await serveResolved(filepath + ext)
706
- if (resp) return resp
707
- }
862
+ const resolved = await resolveNodeModulesRequest(pathname)
863
+ const resp = resolved ? await serveResolved(resolved) : null
864
+ if (resp) return resp
708
865
  }
709
- }
710
866
 
711
867
  // Static files: check htmlDir first (for assets relative to HTML), then root
712
868
  const inHtmlDir = Bun.file(path.join(htmlDir, pathname))
@@ -732,13 +888,13 @@ export function serve(entrypoint, flags) {
732
888
  }
733
889
 
734
890
  // SPA fallback for extension-less paths
735
- if (!lastSegment.includes('.')) {
736
- let html = await Bun.file(htmlPath).text()
737
- if (!importMapTag) importMapTag = await buildImportMap()
738
- return new Response(transformHtml(html, entrypoint, importMapTag), {
739
- headers: { 'Content-Type': 'text/html' },
740
- })
741
- }
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
+ }
742
898
 
743
899
  return new Response('Not Found', { status: 404 })
744
900
  },