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 +300 -0
- package/README.md +4 -2
- package/index.js +28 -16
- package/package.json +2 -2
- package/serve.js +337 -181
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
|
|
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` —
|
|
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 =
|
|
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
|
-
|
|
47
|
-
const bunfigPath = path.join(process.cwd(), 'bunfig.toml');
|
|
48
|
-
const preloadLine = 'preload = ["bimba-cli/plugin.js"]';
|
|
49
|
-
|
|
50
|
-
fs.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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')+"
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
440
|
-
|
|
375
|
+
|
|
376
|
+
const [name, ...rest] = specifier.split('/')
|
|
377
|
+
return { name, subpath: rest.join('/') }
|
|
441
378
|
}
|
|
442
379
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
455
|
-
|
|
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
|
-
|
|
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,
|
|
468
|
-
|
|
469
|
-
html = html
|
|
470
|
-
|
|
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
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
676
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
},
|