bimba-cli 0.5.0 → 0.5.2
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/README.md +12 -8
- package/index.js +1 -11
- package/package.json +1 -1
- package/serve.js +256 -241
- package/.claude/agents/kfc/spec-design.md +0 -158
- package/.claude/agents/kfc/spec-impl.md +0 -39
- package/.claude/agents/kfc/spec-judge.md +0 -125
- package/.claude/agents/kfc/spec-requirements.md +0 -123
- package/.claude/agents/kfc/spec-system-prompt-loader.md +0 -38
- package/.claude/agents/kfc/spec-tasks.md +0 -183
- package/.claude/agents/kfc/spec-test.md +0 -108
- package/.claude/settings/kfc-settings.json +0 -24
- package/.claude/system-prompts/spec-workflow-starter.md +0 -306
package/serve.js
CHANGED
|
@@ -5,50 +5,146 @@ import path from 'path'
|
|
|
5
5
|
import { theme } from './utils.js'
|
|
6
6
|
import { printerr } from './plugin.js'
|
|
7
7
|
|
|
8
|
-
// HMR
|
|
9
|
-
|
|
8
|
+
// ─── HMR Client (injected into browser) ──────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const hmrClient = `
|
|
10
11
|
<script>
|
|
12
|
+
(function() {
|
|
13
|
+
// ── Custom element registry with prototype patching ────────────────────────
|
|
14
|
+
//
|
|
15
|
+
// On initial page load: tags are not registered yet → call original define,
|
|
16
|
+
// store the class in _classes map.
|
|
17
|
+
//
|
|
18
|
+
// On hot reload: the re-imported module calls customElements.define() again.
|
|
19
|
+
// The tag is already registered (browser ignores duplicate defines).
|
|
20
|
+
// Instead of ignoring the new class, we patch the prototype of the original
|
|
21
|
+
// class with all new methods. This means:
|
|
22
|
+
// - Existing element instances immediately get new render/methods
|
|
23
|
+
// - Instance properties (el.active, el.count, etc.) are preserved
|
|
24
|
+
// - CSS is auto-updated by imba_styles.register() during module execution
|
|
25
|
+
//
|
|
26
|
+
const _origDefine = customElements.define.bind(customElements);
|
|
27
|
+
const _classes = new Map(); // tagName → first-registered constructor
|
|
28
|
+
let _hotTags = []; // tags defined during the current hot import
|
|
29
|
+
|
|
30
|
+
customElements.define = function(name, cls, opts) {
|
|
31
|
+
_hotTags.push(name);
|
|
32
|
+
const existing = customElements.get(name);
|
|
33
|
+
if (!existing) {
|
|
34
|
+
_origDefine(name, cls, opts);
|
|
35
|
+
_classes.set(name, cls);
|
|
36
|
+
} else {
|
|
37
|
+
const target = _classes.get(name);
|
|
38
|
+
if (target) _patchClass(target, cls);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const _skipStatics = new Set(['length', 'name', 'prototype', 'caller', 'arguments']);
|
|
43
|
+
|
|
44
|
+
// Copy all own property descriptors from source to target, skipping keys
|
|
45
|
+
// that match the shouldSkip predicate. Handles both string and symbol keys.
|
|
46
|
+
function _copyDescriptors(target, source, shouldSkip) {
|
|
47
|
+
for (const key of Object.getOwnPropertyNames(source)) {
|
|
48
|
+
if (shouldSkip(key)) continue;
|
|
49
|
+
const d = Object.getOwnPropertyDescriptor(source, key);
|
|
50
|
+
if (d) try { Object.defineProperty(target, key, d); } catch(_) {}
|
|
51
|
+
}
|
|
52
|
+
for (const key of Object.getOwnPropertySymbols(source)) {
|
|
53
|
+
const d = Object.getOwnPropertyDescriptor(source, key);
|
|
54
|
+
if (d) try { Object.defineProperty(target, key, d); } catch(_) {}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function _patchClass(target, source) {
|
|
59
|
+
_copyDescriptors(target.prototype, source.prototype, k => k === 'constructor');
|
|
60
|
+
_copyDescriptors(target, source, k => _skipStatics.has(k));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── HMR update handler ─────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function _applyUpdate(file) {
|
|
66
|
+
clearError();
|
|
67
|
+
_hotTags = [];
|
|
68
|
+
|
|
69
|
+
import('/' + file + '?t=' + Date.now()).then(() => {
|
|
70
|
+
const updatedTags = _hotTags.slice();
|
|
71
|
+
_hotTags = [];
|
|
72
|
+
|
|
73
|
+
// Always remove duplicate root elements. Re-importing a module with a
|
|
74
|
+
// fresh ?t= query causes top-level code (e.g. imba.mount()) to run again,
|
|
75
|
+
// which can append a second copy of the root tag to body.
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
[...document.body.children].forEach(el => {
|
|
78
|
+
const tag = el.tagName.toLowerCase();
|
|
79
|
+
if (seen.has(tag)) el.remove();
|
|
80
|
+
else seen.add(tag);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// JS changed: find all instances of the updated tag types, reset their
|
|
84
|
+
// render output (innerHTML), then let imba re-render from current state.
|
|
85
|
+
//
|
|
86
|
+
// innerHTML = '' removes rendered DOM children but does NOT touch instance
|
|
87
|
+
// properties — so el.active, el.selectedTab etc. survive.
|
|
88
|
+
//
|
|
89
|
+
// We also clear any symbol keys that point to DOM Nodes (Imba's render
|
|
90
|
+
// cache) so the new render method (which uses new module-scoped symbols)
|
|
91
|
+
// starts clean and doesn't leave orphaned nodes.
|
|
92
|
+
|
|
93
|
+
const toReset = new Set();
|
|
94
|
+
|
|
95
|
+
updatedTags.forEach(tagName => {
|
|
96
|
+
document.querySelectorAll(tagName).forEach(el => {
|
|
97
|
+
toReset.add(el);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
toReset.forEach(el => {
|
|
102
|
+
// Clear Imba render cache (symbol → Node mappings)
|
|
103
|
+
Object.getOwnPropertySymbols(el).forEach(s => {
|
|
104
|
+
try { if (el[s] instanceof Node) el[s] = undefined; } catch(_) {}
|
|
105
|
+
});
|
|
106
|
+
el.innerHTML = '';
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (toReset.size > 0 && typeof imba !== 'undefined') {
|
|
110
|
+
imba.commit();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── WebSocket connection ───────────────────────────────────────────────────
|
|
116
|
+
|
|
11
117
|
let _connected = false;
|
|
12
|
-
let _styleEl = null;
|
|
13
118
|
|
|
14
119
|
function connect() {
|
|
15
120
|
const ws = new WebSocket('ws://' + location.host + '/__hmr__');
|
|
121
|
+
|
|
16
122
|
ws.onopen = () => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
_connected = true;
|
|
21
|
-
}
|
|
123
|
+
// If we reconnect after a disconnect, reload to get fresh state
|
|
124
|
+
if (_connected) location.reload();
|
|
125
|
+
else _connected = true;
|
|
22
126
|
};
|
|
127
|
+
|
|
23
128
|
ws.onmessage = (e) => {
|
|
24
|
-
const
|
|
25
|
-
if
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
document.head.appendChild(_styleEl);
|
|
30
|
-
}
|
|
31
|
-
_styleEl.textContent = data.css;
|
|
32
|
-
} else if (data.type === 'reload') {
|
|
33
|
-
location.reload();
|
|
34
|
-
} else if (data.type === 'error') {
|
|
35
|
-
showError(data.file, data.errors);
|
|
36
|
-
} else if (data.type === 'clear-error') {
|
|
37
|
-
clearError();
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
ws.onclose = () => {
|
|
41
|
-
setTimeout(connect, 1000);
|
|
129
|
+
const msg = JSON.parse(e.data);
|
|
130
|
+
if (msg.type === 'update') _applyUpdate(msg.file);
|
|
131
|
+
else if (msg.type === 'reload') location.reload();
|
|
132
|
+
else if (msg.type === 'error') showError(msg.file, msg.errors);
|
|
133
|
+
else if (msg.type === 'clear-error') clearError();
|
|
42
134
|
};
|
|
135
|
+
|
|
136
|
+
ws.onclose = () => setTimeout(connect, 1000);
|
|
43
137
|
}
|
|
44
138
|
|
|
139
|
+
// ── Error overlay ──────────────────────────────────────────────────────────
|
|
140
|
+
|
|
45
141
|
function showError(file, errors) {
|
|
46
142
|
let overlay = document.getElementById('__bimba_error__');
|
|
47
143
|
if (!overlay) {
|
|
48
144
|
overlay = document.createElement('div');
|
|
49
145
|
overlay.id = '__bimba_error__';
|
|
50
146
|
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;font-family:monospace;padding:24px;box-sizing:border-box';
|
|
51
|
-
overlay.addEventListener('click',
|
|
147
|
+
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
|
52
148
|
document.body.appendChild(overlay);
|
|
53
149
|
}
|
|
54
150
|
overlay.innerHTML = \`
|
|
@@ -73,161 +169,49 @@ const hmrClientCssOnly = `
|
|
|
73
169
|
}
|
|
74
170
|
|
|
75
171
|
connect();
|
|
172
|
+
})();
|
|
76
173
|
</script>`
|
|
77
174
|
|
|
78
|
-
//
|
|
79
|
-
const hmrClientFull = `
|
|
80
|
-
<script>
|
|
81
|
-
const _originalDefine = customElements.define.bind(customElements);
|
|
82
|
-
const _registry = new Map();
|
|
83
|
-
const _updated = new Set();
|
|
84
|
-
|
|
85
|
-
customElements.define = function(name, cls, opts) {
|
|
86
|
-
const existing = _registry.get(name);
|
|
87
|
-
if (existing) {
|
|
88
|
-
Object.getOwnPropertyNames(cls.prototype).forEach(key => {
|
|
89
|
-
if (key === 'constructor') return;
|
|
90
|
-
try { Object.defineProperty(existing.prototype, key, Object.getOwnPropertyDescriptor(cls.prototype, key)); } catch(e) {}
|
|
91
|
-
});
|
|
92
|
-
Object.getOwnPropertyNames(cls).forEach(key => {
|
|
93
|
-
if (['length','name','prototype','arguments','caller'].includes(key)) return;
|
|
94
|
-
try { Object.defineProperty(existing, key, Object.getOwnPropertyDescriptor(cls, key)); } catch(e) {}
|
|
95
|
-
});
|
|
96
|
-
_updated.add(name);
|
|
97
|
-
} else {
|
|
98
|
-
_registry.set(name, cls);
|
|
99
|
-
_originalDefine(name, cls, opts);
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
function resetElement(el) {
|
|
104
|
-
Object.getOwnPropertySymbols(el).forEach(s => {
|
|
105
|
-
try { if (el[s] instanceof Node) el[s] = undefined; } catch(e) {}
|
|
106
|
-
});
|
|
107
|
-
el.innerHTML = '';
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
let _connected = false;
|
|
111
|
-
let _overlay = null;
|
|
112
|
-
|
|
113
|
-
function showError(file, errors) {
|
|
114
|
-
if (!_overlay) {
|
|
115
|
-
_overlay = document.createElement('div');
|
|
116
|
-
_overlay.id = '__bimba_error__';
|
|
117
|
-
const s = _overlay.style;
|
|
118
|
-
s.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;font-family:monospace;padding:24px;box-sizing:border-box';
|
|
119
|
-
_overlay.addEventListener('click', (e) => { if (e.target === _overlay) clearError(); });
|
|
120
|
-
document.body.appendChild(_overlay);
|
|
121
|
-
}
|
|
122
|
-
_overlay.innerHTML = \`
|
|
123
|
-
<div style="background:#1a1a1a;border:1px solid #ff4444;border-radius:8px;max-width:860px;width:100%;max-height:90vh;overflow:auto;box-shadow:0 0 40px rgba(255,68,68,.3)">
|
|
124
|
-
<div style="background:#ff4444;color:#fff;padding:10px 16px;font-size:13px;font-weight:600;display:flex;justify-content:space-between;align-items:center">
|
|
125
|
-
<span>Compile error — \${file}</span>
|
|
126
|
-
<span onclick="document.getElementById('__bimba_error__').remove();document.getElementById('__bimba_error__')&&(window.__bimba_overlay__=null)" style="cursor:pointer;opacity:.7;font-size:16px">✕</span>
|
|
127
|
-
</div>
|
|
128
|
-
\${errors.map(err => \`
|
|
129
|
-
<div style="padding:16px;border-bottom:1px solid #333">
|
|
130
|
-
<div style="color:#ff8080;font-size:13px;margin-bottom:10px">\${err.message}\${err.line ? \` <span style="color:#888">line \${err.line}</span>\` : ''}</div>
|
|
131
|
-
\${err.snippet ? \`<pre style="margin:0;padding:10px;background:#111;border-radius:4px;font-size:12px;line-height:1.6;color:#ccc;overflow-x:auto;white-space:pre">\${err.snippet.replace(/</g,'<')}</pre>\` : ''}
|
|
132
|
-
</div>
|
|
133
|
-
\`).join('')}
|
|
134
|
-
</div>
|
|
135
|
-
\`;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function clearError() {
|
|
139
|
-
if (_overlay) { _overlay.remove(); _overlay = null; }
|
|
140
|
-
}
|
|
175
|
+
// ─── Server-side compile cache ────────────────────────────────────────────────
|
|
141
176
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
ws.onopen = () => {
|
|
145
|
-
if (_connected) {
|
|
146
|
-
location.reload();
|
|
147
|
-
} else {
|
|
148
|
-
_connected = true;
|
|
149
|
-
}
|
|
150
|
-
};
|
|
151
|
-
ws.onmessage = (e) => {
|
|
152
|
-
const data = JSON.parse(e.data);
|
|
153
|
-
if (data.type === 'update') {
|
|
154
|
-
clearError();
|
|
155
|
-
_updated.clear();
|
|
156
|
-
import('/' + data.file + '?t=' + Date.now()).then(() => {
|
|
157
|
-
const updatedClasses = [..._updated].map(n => _registry.get(n)).filter(Boolean);
|
|
158
|
-
const found = [];
|
|
159
|
-
if (updatedClasses.length) {
|
|
160
|
-
document.querySelectorAll('*').forEach(el => {
|
|
161
|
-
for (const cls of updatedClasses) {
|
|
162
|
-
if (el instanceof cls) { found.push(el); break; }
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
found.forEach(resetElement);
|
|
167
|
-
_updated.clear();
|
|
168
|
-
imba.commit();
|
|
169
|
-
});
|
|
170
|
-
} else if (data.type === 'reload') {
|
|
171
|
-
location.reload();
|
|
172
|
-
} else if (data.type === 'error') {
|
|
173
|
-
showError(data.file, data.errors);
|
|
174
|
-
} else if (data.type === 'clear-error') {
|
|
175
|
-
clearError();
|
|
176
|
-
}
|
|
177
|
-
};
|
|
178
|
-
ws.onclose = () => {
|
|
179
|
-
setTimeout(connect, 1000);
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
connect();
|
|
184
|
-
</script>`
|
|
185
|
-
|
|
186
|
-
const _compileCache = new Map()
|
|
187
|
-
// Store previous versions to detect what changed
|
|
188
|
-
const _versionHistory = new Map()
|
|
177
|
+
const _compileCache = new Map() // filepath → { mtime, result }
|
|
178
|
+
const _prevJs = new Map() // filepath → compiled js — for change detection
|
|
189
179
|
|
|
190
180
|
async function compileFile(filepath) {
|
|
191
181
|
const file = Bun.file(filepath)
|
|
192
182
|
const stat = await file.stat()
|
|
193
183
|
const mtime = stat.mtime.getTime()
|
|
184
|
+
|
|
194
185
|
const cached = _compileCache.get(filepath)
|
|
195
|
-
if (cached && cached.mtime === mtime) return { ...cached.result,
|
|
186
|
+
if (cached && cached.mtime === mtime) return { ...cached.result, changeType: 'cached' }
|
|
187
|
+
|
|
196
188
|
const code = await file.text()
|
|
197
|
-
const result = compiler.compile(code, {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (cssChanged && !jsChanged) {
|
|
206
|
-
changeType = 'css-only'
|
|
207
|
-
} else if (jsChanged) {
|
|
208
|
-
changeType = 'full'
|
|
209
|
-
} else {
|
|
210
|
-
changeType = 'none'
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
_versionHistory.set(filepath, { css: result.css, js: result.js })
|
|
189
|
+
const result = compiler.compile(code, {
|
|
190
|
+
sourcePath: filepath,
|
|
191
|
+
platform: 'browser',
|
|
192
|
+
sourcemap: 'inline',
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
const changeType = _prevJs.get(filepath) === result.js ? 'none' : 'full'
|
|
196
|
+
_prevJs.set(filepath, result.js)
|
|
215
197
|
_compileCache.set(filepath, { mtime, result })
|
|
216
198
|
return { ...result, changeType }
|
|
217
199
|
}
|
|
218
200
|
|
|
201
|
+
// ─── HTML helpers ─────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
219
203
|
function findHtml(flagHtml) {
|
|
220
204
|
if (flagHtml) return flagHtml;
|
|
221
205
|
const candidates = ['./index.html', './public/index.html', './src/index.html'];
|
|
222
206
|
return candidates.find(p => existsSync(p)) || './index.html';
|
|
223
207
|
}
|
|
224
208
|
|
|
225
|
-
// Build
|
|
209
|
+
// Build an ES import map from package.json dependencies.
|
|
226
210
|
// Packages with an .imba entry point are served locally; others via esm.sh.
|
|
227
211
|
async function buildImportMap() {
|
|
228
212
|
const imports = {
|
|
229
|
-
|
|
230
|
-
|
|
213
|
+
'imba/runtime': 'https://esm.sh/imba/runtime',
|
|
214
|
+
'imba': 'https://esm.sh/imba',
|
|
231
215
|
};
|
|
232
216
|
try {
|
|
233
217
|
const pkg = JSON.parse(await Bun.file('./package.json').text());
|
|
@@ -236,50 +220,47 @@ async function buildImportMap() {
|
|
|
236
220
|
try {
|
|
237
221
|
const depPkg = JSON.parse(await Bun.file(`./node_modules/${name}/package.json`).text());
|
|
238
222
|
const entry = depPkg.module || depPkg.main;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
} catch(e) {
|
|
223
|
+
imports[name] = (entry && entry.endsWith('.imba'))
|
|
224
|
+
? `/node_modules/${name}/${entry}`
|
|
225
|
+
: `https://esm.sh/${name}`;
|
|
226
|
+
} catch(_) {
|
|
245
227
|
imports[name] = `https://esm.sh/${name}`;
|
|
246
228
|
}
|
|
247
229
|
}
|
|
248
|
-
} catch(
|
|
230
|
+
} catch(_) { /* no package.json */ }
|
|
249
231
|
|
|
250
|
-
|
|
251
|
-
return `\t\t<script type="importmap">\n\t\t\t${json}\n\t\t</script>`;
|
|
232
|
+
return `\t\t<script type="importmap">\n\t\t\t${JSON.stringify({ imports }, null, '\t\t\t\t')}\n\t\t</script>`;
|
|
252
233
|
}
|
|
253
234
|
|
|
254
|
-
//
|
|
255
|
-
// -
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
function transformHtml(html, entrypoint, importMapTag, hmrMode) {
|
|
235
|
+
// Rewrite production HTML for the dev server:
|
|
236
|
+
// strips existing importmap + data-entrypoint script, injects importmap +
|
|
237
|
+
// entrypoint module + HMR client before </head>.
|
|
238
|
+
function transformHtml(html, entrypoint, importMapTag) {
|
|
259
239
|
html = html.replace(/<script\s+type=["']importmap["'][^>]*>[\s\S]*?<\/script>/gi, '');
|
|
260
240
|
html = html.replace(/<script([^>]*)\bdata-entrypoint\b([^>]*)><\/script>/gi, '');
|
|
261
|
-
|
|
262
241
|
const entryUrl = '/' + entrypoint.replace(/^\.\//, '').replaceAll('\\', '/');
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
html = html.replace('</head>', `${importMapTag}\n${entryScript}\n${hmrClient}\n\t</head>`);
|
|
242
|
+
html = html.replace('</head>',
|
|
243
|
+
`${importMapTag}\n\t\t<script type='module' src='${entryUrl}'></script>\n${hmrClient}\n\t</head>`
|
|
244
|
+
);
|
|
268
245
|
return html;
|
|
269
246
|
}
|
|
270
247
|
|
|
248
|
+
// ─── Dev server ───────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
271
250
|
export function serve(entrypoint, flags) {
|
|
272
|
-
const port
|
|
251
|
+
const port = flags.port || 5200
|
|
273
252
|
const htmlPath = findHtml(flags.html)
|
|
274
|
-
const htmlDir
|
|
275
|
-
const srcDir
|
|
276
|
-
const sockets
|
|
253
|
+
const htmlDir = path.dirname(htmlPath)
|
|
254
|
+
const srcDir = path.dirname(entrypoint)
|
|
255
|
+
const sockets = new Set()
|
|
277
256
|
let importMapTag = null
|
|
278
|
-
|
|
257
|
+
|
|
258
|
+
// ── Status line (prints current compile result, fades out on success) ──────
|
|
279
259
|
|
|
280
260
|
let _fadeTimers = []
|
|
281
261
|
let _fadeId = 0
|
|
282
262
|
let _statusSaved = false
|
|
263
|
+
const _isTTY = process.stdout.isTTY
|
|
283
264
|
|
|
284
265
|
function cancelFade() {
|
|
285
266
|
_fadeTimers.forEach(t => clearTimeout(t))
|
|
@@ -287,6 +268,22 @@ export function serve(entrypoint, flags) {
|
|
|
287
268
|
}
|
|
288
269
|
|
|
289
270
|
function printStatus(file, state, errors) {
|
|
271
|
+
// non-TTY (pipes, Claude Code bash, CI): plain newline-terminated output,
|
|
272
|
+
// no ANSI cursor tricks, no fade-out — so logs stay readable.
|
|
273
|
+
if (!_isTTY) {
|
|
274
|
+
const now = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
275
|
+
const tag = state === 'ok' ? 'ok' : 'fail'
|
|
276
|
+
process.stdout.write(` ${now} ${file} ${tag}\n`)
|
|
277
|
+
if (errors?.length) {
|
|
278
|
+
for (const err of errors) {
|
|
279
|
+
const msg = err.message || String(err)
|
|
280
|
+
const line = err.range?.start?.line
|
|
281
|
+
process.stdout.write(` ${msg}${line ? ` (line ${line})` : ''}\n`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
290
287
|
cancelFade()
|
|
291
288
|
if (_statusSaved) {
|
|
292
289
|
process.stdout.write('\x1b[u\x1b[J')
|
|
@@ -294,8 +291,10 @@ export function serve(entrypoint, flags) {
|
|
|
294
291
|
}
|
|
295
292
|
const now = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
296
293
|
const status = state === 'ok' ? theme.success(' ok ') : theme.failure(' fail ')
|
|
294
|
+
|
|
297
295
|
process.stdout.write('\x1b[s')
|
|
298
296
|
_statusSaved = true
|
|
297
|
+
|
|
299
298
|
if (errors?.length) {
|
|
300
299
|
process.stdout.write(` ${theme.folder(now)} ${theme.filename(file)} ${status}\n`)
|
|
301
300
|
for (const err of errors) {
|
|
@@ -304,34 +303,62 @@ export function serve(entrypoint, flags) {
|
|
|
304
303
|
} else {
|
|
305
304
|
const myId = ++_fadeId
|
|
306
305
|
const plainLine = ` ${now} ${file} ok `
|
|
307
|
-
const
|
|
308
|
-
const startDelay = 5000
|
|
309
|
-
const charDelay = 22
|
|
310
|
-
|
|
306
|
+
const total = plainLine.length
|
|
311
307
|
process.stdout.write(` ${theme.folder(now)} ${theme.filename(file)} ${status}`)
|
|
312
|
-
|
|
313
|
-
for (let i = 1; i <= totalLen; i++) {
|
|
308
|
+
for (let i = 1; i <= total; i++) {
|
|
314
309
|
_fadeTimers.push(setTimeout(() => {
|
|
315
310
|
if (_fadeId !== myId) return
|
|
316
311
|
process.stdout.write('\x1b[1D \x1b[1D')
|
|
317
|
-
if (i ===
|
|
318
|
-
|
|
319
|
-
}
|
|
320
|
-
}, startDelay + i * charDelay))
|
|
312
|
+
if (i === total) _statusSaved = false
|
|
313
|
+
}, 5000 + i * 22))
|
|
321
314
|
}
|
|
322
315
|
}
|
|
323
316
|
}
|
|
324
317
|
|
|
318
|
+
// ── File watcher ───────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
function broadcast(payload) {
|
|
321
|
+
const msg = JSON.stringify(payload)
|
|
322
|
+
for (const socket of sockets) socket.send(msg)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
325
|
const _debounce = new Map()
|
|
326
|
-
|
|
326
|
+
|
|
327
|
+
watch(srcDir, { recursive: true }, async (_event, filename) => {
|
|
327
328
|
if (!filename || !filename.endsWith('.imba')) return
|
|
328
329
|
if (_debounce.has(filename)) return
|
|
329
330
|
_debounce.set(filename, setTimeout(() => _debounce.delete(filename), 50))
|
|
331
|
+
|
|
332
|
+
const filepath = path.join(srcDir, filename)
|
|
330
333
|
const rel = path.join(path.relative('.', srcDir), filename).replaceAll('\\', '/')
|
|
331
|
-
|
|
332
|
-
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const out = await compileFile(filepath)
|
|
337
|
+
|
|
338
|
+
if (out.errors?.length) {
|
|
339
|
+
printStatus(rel, 'fail', out.errors)
|
|
340
|
+
broadcast({ type: 'error', file: rel, errors: out.errors.map(e => ({
|
|
341
|
+
message: e.message,
|
|
342
|
+
line: e.range?.start?.line,
|
|
343
|
+
snippet: e.toSnippet(),
|
|
344
|
+
})) })
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// No change at all — skip
|
|
349
|
+
if (out.changeType === 'none' || out.changeType === 'cached') return
|
|
350
|
+
|
|
351
|
+
printStatus(rel, 'ok')
|
|
352
|
+
broadcast({ type: 'clear-error' })
|
|
353
|
+
broadcast({ type: 'update', file: rel })
|
|
354
|
+
} catch(e) {
|
|
355
|
+
printStatus(rel, 'fail', [{ message: e.message }])
|
|
356
|
+
broadcast({ type: 'error', file: rel, errors: [{ message: e.message, snippet: e.stack || e.message }] })
|
|
357
|
+
}
|
|
333
358
|
})
|
|
334
359
|
|
|
360
|
+
// ── HTTP + WebSocket server ────────────────────────────────────────────────
|
|
361
|
+
|
|
335
362
|
bunServe({
|
|
336
363
|
port,
|
|
337
364
|
development: true,
|
|
@@ -340,84 +367,72 @@ export function serve(entrypoint, flags) {
|
|
|
340
367
|
const url = new URL(req.url)
|
|
341
368
|
const pathname = url.pathname
|
|
342
369
|
|
|
370
|
+
// WebSocket upgrade for HMR
|
|
343
371
|
if (pathname === '/__hmr__') {
|
|
344
372
|
if (server.upgrade(req)) return undefined
|
|
345
373
|
}
|
|
346
374
|
|
|
375
|
+
// HTML: index or any .html file
|
|
347
376
|
if (pathname === '/' || pathname.endsWith('.html')) {
|
|
348
377
|
const htmlFile = pathname === '/' ? htmlPath : '.' + pathname
|
|
349
378
|
let html = await Bun.file(htmlFile).text()
|
|
350
379
|
if (!importMapTag) importMapTag = await buildImportMap()
|
|
351
|
-
|
|
352
|
-
|
|
380
|
+
return new Response(transformHtml(html, entrypoint, importMapTag), {
|
|
381
|
+
headers: { 'Content-Type': 'text/html' },
|
|
382
|
+
})
|
|
353
383
|
}
|
|
354
384
|
|
|
385
|
+
// Imba files: compile on demand and serve as JS
|
|
355
386
|
if (pathname.endsWith('.imba')) {
|
|
387
|
+
const filepath = '.' + pathname
|
|
356
388
|
try {
|
|
357
|
-
const out = await compileFile(
|
|
358
|
-
const file = pathname.replace(/^\//, '')
|
|
389
|
+
const out = await compileFile(filepath)
|
|
359
390
|
if (out.errors?.length) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
391
|
+
const file = pathname.replace(/^\//, '')
|
|
392
|
+
printStatus(file, 'fail', out.errors)
|
|
393
|
+
broadcast({ type: 'error', file, errors: out.errors.map(e => ({
|
|
394
|
+
message: e.message,
|
|
395
|
+
line: e.range?.start?.line,
|
|
396
|
+
snippet: e.toSnippet(),
|
|
397
|
+
})) })
|
|
365
398
|
return new Response(out.errors.map(e => e.message).join('\n'), { status: 500 })
|
|
366
399
|
}
|
|
367
|
-
if (!out.cached) {
|
|
368
|
-
printStatus(file, 'ok')
|
|
369
|
-
for (const socket of sockets) socket.send(JSON.stringify({ type: 'clear-error' }))
|
|
370
|
-
|
|
371
|
-
// Send appropriate update type based on HMR mode and what changed
|
|
372
|
-
if (hmrMode === 'full') {
|
|
373
|
-
// Full mode: send 'update' for prototype swapping (CSS is included in JS via runtime)
|
|
374
|
-
for (const socket of sockets) socket.send(JSON.stringify({ type: 'update', file }))
|
|
375
|
-
} else {
|
|
376
|
-
// CSS-only mode
|
|
377
|
-
if (out.changeType === 'css-only') {
|
|
378
|
-
// Only CSS changed: inject styles without reload
|
|
379
|
-
for (const socket of sockets) socket.send(JSON.stringify({ type: 'css-update', file, css: out.css }))
|
|
380
|
-
} else if (out.changeType === 'full') {
|
|
381
|
-
// JS changed in CSS-only mode: full page reload
|
|
382
|
-
for (const socket of sockets) socket.send(JSON.stringify({ type: 'reload' }))
|
|
383
|
-
}
|
|
384
|
-
// 'none' change type: do nothing, already up-to-date
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
400
|
return new Response(out.js, { headers: { 'Content-Type': 'application/javascript' } })
|
|
388
|
-
} catch
|
|
401
|
+
} catch(e) {
|
|
389
402
|
const file = pathname.replace(/^\//, '')
|
|
390
403
|
printStatus(file, 'fail', [{ message: e.message }])
|
|
391
|
-
|
|
392
|
-
for (const socket of sockets) socket.send(payload)
|
|
404
|
+
broadcast({ type: 'error', file, errors: [{ message: e.message, snippet: e.stack || e.message }] })
|
|
393
405
|
return new Response(e.message, { status: 500 })
|
|
394
406
|
}
|
|
395
407
|
}
|
|
396
408
|
|
|
397
|
-
// Static files: check htmlDir first (assets relative to HTML), then root
|
|
398
|
-
const
|
|
399
|
-
if (await
|
|
400
|
-
const
|
|
401
|
-
if (await
|
|
409
|
+
// Static files: check htmlDir first (for assets relative to HTML), then root
|
|
410
|
+
const inHtmlDir = Bun.file(path.join(htmlDir, pathname))
|
|
411
|
+
if (await inHtmlDir.exists()) return new Response(inHtmlDir)
|
|
412
|
+
const inRoot = Bun.file('.' + pathname)
|
|
413
|
+
if (await inRoot.exists()) return new Response(inRoot)
|
|
402
414
|
|
|
403
|
-
// SPA fallback
|
|
415
|
+
// SPA fallback for extension-less paths
|
|
404
416
|
const lastSegment = pathname.split('/').pop()
|
|
405
417
|
if (!lastSegment.includes('.')) {
|
|
406
418
|
let html = await Bun.file(htmlPath).text()
|
|
407
419
|
if (!importMapTag) importMapTag = await buildImportMap()
|
|
408
|
-
|
|
409
|
-
|
|
420
|
+
return new Response(transformHtml(html, entrypoint, importMapTag), {
|
|
421
|
+
headers: { 'Content-Type': 'text/html' },
|
|
422
|
+
})
|
|
410
423
|
}
|
|
424
|
+
|
|
411
425
|
return new Response('Not Found', { status: 404 })
|
|
412
426
|
},
|
|
413
427
|
|
|
414
428
|
websocket: {
|
|
415
|
-
open:
|
|
416
|
-
close:
|
|
417
|
-
|
|
429
|
+
open: ws => sockets.add(ws),
|
|
430
|
+
close: ws => sockets.delete(ws),
|
|
431
|
+
message: () => {},
|
|
432
|
+
},
|
|
418
433
|
})
|
|
419
434
|
|
|
420
435
|
console.log(theme.folder('──────────────────────────────────────────────────────────────────────'))
|
|
421
|
-
console.log(theme.start(
|
|
436
|
+
console.log(theme.start('Dev server running at ') + theme.success(`http://localhost:${port}`))
|
|
422
437
|
console.log(theme.folder('──────────────────────────────────────────────────────────────────────'))
|
|
423
438
|
}
|