spark-html-bun 0.1.2 → 0.1.3
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 +6 -2
- package/package.json +2 -2
- package/src/index.js +119 -36
package/README.md
CHANGED
|
@@ -26,8 +26,12 @@ bun dev
|
|
|
26
26
|
(built from your `package.json`), so the browser runs your ES modules directly
|
|
27
27
|
— **no bundling in dev**. Scoped component HMR rides a plain WebSocket
|
|
28
28
|
(`/__spark_hmr`) + `fs.watch`: edit a component and only its instances
|
|
29
|
-
re-mount
|
|
30
|
-
always correct
|
|
29
|
+
re-mount — fresh markup **and** fresh scoped CSS — sibling state preserved
|
|
30
|
+
(slotted / loop-managed hosts full-reload, always correct; components not on
|
|
31
|
+
the current page are a no-op — the next mount fetches them fresh). Editing a
|
|
32
|
+
`.css` file swaps the matching `<link>` in place with no reload; editing page
|
|
33
|
+
HTML or a JS module reloads. Editor save patterns (temp file + rename) are
|
|
34
|
+
debounced into a single update.
|
|
31
35
|
- **`spark build`** — empties `dist/`, copies `public/` verbatim (components ship
|
|
32
36
|
as authored), bundles the HTML entry's scripts/styles with `Bun.build`
|
|
33
37
|
(hashed under `assets/`, `base` honored), then runs the **pipeline** in order.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spark-html-bun",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Dev server, build, and preview for spark-html apps
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Dev server, build, and preview for spark-html apps — built entirely on Bun. Scoped component HMR over a plain WebSocket, import-map dev serving (no bundling in dev), Bun.build for the app shell, and an explicit post-build pipeline (prerender, image, font, manifest, offline, sri). Zero dependencies.",
|
|
5
5
|
"homepage": "https://wilkinnovo.github.io/spark",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
package/src/index.js
CHANGED
|
@@ -27,8 +27,12 @@
|
|
|
27
27
|
* from /@modules/<name>) — no bundling in dev at all, the browser runs
|
|
28
28
|
* your ES modules directly. Scoped component HMR rides a plain WebSocket
|
|
29
29
|
* (/__spark_hmr) + fs.watch: edit a component file and only its instances
|
|
30
|
-
* re-mount
|
|
31
|
-
*
|
|
30
|
+
* re-mount — fresh markup AND fresh scoped CSS — sibling state preserved
|
|
31
|
+
* (slotted/loop-managed hosts full-reload, always correct; a component not
|
|
32
|
+
* mounted on the current page is a no-op — fragments are no-cache, the next
|
|
33
|
+
* mount fetches it fresh). Stylesheet (.css) edits swap the matching <link>
|
|
34
|
+
* in place, no reload; page HTML / JS module edits full-reload. Broadcasts
|
|
35
|
+
* are debounced so editor save patterns (temp file + rename) send one update.
|
|
32
36
|
* • build — empty outDir, copy publicDir verbatim (components ship as
|
|
33
37
|
* authored), Bun.build the HTML entry (scripts/styles bundled + hashed
|
|
34
38
|
* under assets/, HTML rewritten, base honored via publicPath), then run
|
|
@@ -123,36 +127,73 @@ export async function loadConfig(root = process.cwd(), overrides = {}) {
|
|
|
123
127
|
|
|
124
128
|
// The scoped-HMR client. The re-mount logic (unmount host → placeholder →
|
|
125
129
|
// re-mount; slotted/managed hosts full-reload) rides a plain WebSocket the
|
|
126
|
-
// dev server owns, instead of a bundler's HMR channel.
|
|
130
|
+
// dev server owns, instead of a bundler's HMR channel. Messages:
|
|
131
|
+
// { name } component fragment changed → re-mount its instances in place
|
|
132
|
+
// { css } stylesheet changed → swap the matching <link> (no reload)
|
|
133
|
+
// { reload } page-level file changed → full reload
|
|
127
134
|
const HMR_CLIENT = `
|
|
128
135
|
import { mount, unmount } from 'spark-html';
|
|
136
|
+
|
|
137
|
+
// Swap a stylesheet in place: load the cache-busted copy alongside, remove the
|
|
138
|
+
// old one when it's ready — no flash of unstyled content.
|
|
139
|
+
function swapCss(path) {
|
|
140
|
+
for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
|
|
141
|
+
const url = new URL(link.href, location.href);
|
|
142
|
+
if (url.origin !== location.origin || url.pathname !== path) continue;
|
|
143
|
+
url.searchParams.set('t', Date.now());
|
|
144
|
+
const next = link.cloneNode();
|
|
145
|
+
next.href = url.pathname + url.search;
|
|
146
|
+
next.onload = () => link.remove();
|
|
147
|
+
link.after(next);
|
|
148
|
+
console.log('[spark] ⚡ css-updated', path);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function update(name) {
|
|
153
|
+
const hosts = [...document.querySelectorAll('[name="' + name + '"]')];
|
|
154
|
+
// Not mounted right now (e.g. it lives on another route): nothing to do —
|
|
155
|
+
// fragments are served no-cache, so the next mount fetches it fresh anyway.
|
|
156
|
+
if (!hosts.length) return;
|
|
157
|
+
// Scoped HMR only for simple top-level hosts; slotted or loop/if-managed
|
|
158
|
+
// hosts fall back to a full reload so the result is always correct.
|
|
159
|
+
if (hosts.some((h) => h.__sparkHadSlots || h.__sparkManaged)) { location.reload(); return; }
|
|
160
|
+
try {
|
|
161
|
+
// Drop the component's injected style so the re-mount installs the fresh
|
|
162
|
+
// one (bootComponent dedupes by data-spark tag and would keep the stale CSS).
|
|
163
|
+
const style = document.querySelector('style[data-spark="' + name + '"]');
|
|
164
|
+
if (style) style.remove();
|
|
165
|
+
for (const host of hosts) {
|
|
166
|
+
const ph = document.createElement('div');
|
|
167
|
+
ph.setAttribute('import', host.__sparkImportPath || ('components/' + name + '.html'));
|
|
168
|
+
const props = host.__sparkProps || {};
|
|
169
|
+
for (const k in props) {
|
|
170
|
+
const v = props[k];
|
|
171
|
+
try { ph.setAttribute(k, typeof v === 'string' ? v : JSON.stringify(v)); } catch (e) {}
|
|
172
|
+
}
|
|
173
|
+
const cls = host.getAttribute('class'); if (cls) ph.setAttribute('class', cls);
|
|
174
|
+
if (host.id) ph.id = host.id;
|
|
175
|
+
const parent = host.parentNode;
|
|
176
|
+
unmount(host);
|
|
177
|
+
host.replaceWith(ph);
|
|
178
|
+
await mount(parent);
|
|
179
|
+
}
|
|
180
|
+
console.log('[spark] ⚡ hot-updated', name);
|
|
181
|
+
} catch (e) { location.reload(); }
|
|
182
|
+
}
|
|
183
|
+
|
|
129
184
|
function connect() {
|
|
130
185
|
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/__spark_hmr');
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const props = host.__sparkProps || {};
|
|
143
|
-
for (const k in props) {
|
|
144
|
-
const v = props[k];
|
|
145
|
-
try { ph.setAttribute(k, typeof v === 'string' ? v : JSON.stringify(v)); } catch (e) {}
|
|
146
|
-
}
|
|
147
|
-
const cls = host.getAttribute('class'); if (cls) ph.setAttribute('class', cls);
|
|
148
|
-
if (host.id) ph.id = host.id;
|
|
149
|
-
const parent = host.parentNode;
|
|
150
|
-
unmount(host);
|
|
151
|
-
host.replaceWith(ph);
|
|
152
|
-
await mount(parent);
|
|
153
|
-
}
|
|
154
|
-
console.log('[spark] ⚡ hot-updated', name);
|
|
155
|
-
} catch (e) { location.reload(); }
|
|
186
|
+
// Updates run strictly one after another — a second save landing while a
|
|
187
|
+
// re-mount is mid-flight must not observe the placeholder (it would find no
|
|
188
|
+
// host and mis-classify the state), so every message queues on the chain.
|
|
189
|
+
let chain = Promise.resolve();
|
|
190
|
+
ws.onmessage = (ev) => {
|
|
191
|
+
const msg = JSON.parse(ev.data);
|
|
192
|
+
chain = chain.then(() => {
|
|
193
|
+
if (msg.reload) { location.reload(); return; }
|
|
194
|
+
if (msg.css) { swapCss(msg.css); return; }
|
|
195
|
+
return update(msg.name);
|
|
196
|
+
}).catch(() => {});
|
|
156
197
|
};
|
|
157
198
|
ws.onclose = () => setTimeout(connect, 1000); // server restarted — retry
|
|
158
199
|
}
|
|
@@ -206,7 +247,8 @@ async function transformPage(html, config, { dev }) {
|
|
|
206
247
|
const inject =
|
|
207
248
|
`<script type="importmap">${importMap}</script>\n` +
|
|
208
249
|
`<script type="module">${HMR_CLIENT}</script>\n`;
|
|
209
|
-
|
|
250
|
+
// `<head(\s…)?>` — never match a page's <header> element.
|
|
251
|
+
if (/<head(\s[^>]*)?>/i.test(out)) return out.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n${inject}`);
|
|
210
252
|
return inject + out;
|
|
211
253
|
}
|
|
212
254
|
|
|
@@ -294,15 +336,56 @@ export async function dev(overrides = {}) {
|
|
|
294
336
|
},
|
|
295
337
|
});
|
|
296
338
|
|
|
297
|
-
// Watch
|
|
339
|
+
// Watch the whole project for dev edits and broadcast the right HMR message:
|
|
340
|
+
// component fragments → scoped re-mount, stylesheets → in-place <link> swap,
|
|
341
|
+
// page HTML (the entry, or any other served page) → full reload. Broadcasts
|
|
342
|
+
// are debounced per key — editors save via temp-file + rename and emit
|
|
343
|
+
// several fs events per keystroke, and a duplicate message arriving while
|
|
344
|
+
// the client is mid-re-mount would mis-read the DOM.
|
|
298
345
|
const componentsRoot = existsSync(join(pub, componentsDir)) ? join(pub, componentsDir) : join(projectRoot, componentsDir);
|
|
346
|
+
const outAbs = resolve(projectRoot, config.outDir);
|
|
347
|
+
const timers = new Map();
|
|
348
|
+
const broadcast = (key, msg) => {
|
|
349
|
+
clearTimeout(timers.get(key));
|
|
350
|
+
timers.set(key, setTimeout(() => {
|
|
351
|
+
timers.delete(key);
|
|
352
|
+
server.publish('spark-hmr', JSON.stringify(msg));
|
|
353
|
+
}, 50));
|
|
354
|
+
};
|
|
355
|
+
const onChange = (base) => (_event, filename) => {
|
|
356
|
+
if (!filename) return;
|
|
357
|
+
const rel = String(filename);
|
|
358
|
+
// Never react to build output, deps, or VCS internals.
|
|
359
|
+
if (/(^|\/)(node_modules|\.git)(\/|$)/.test(rel)) return;
|
|
360
|
+
const abs = join(base, rel);
|
|
361
|
+
if (abs === outAbs || abs.startsWith(outAbs + sep)) return;
|
|
362
|
+
if (rel.endsWith('.css')) {
|
|
363
|
+
// The URL the page loads this file under: public/ files are served from
|
|
364
|
+
// the site root, project files from their project-relative path.
|
|
365
|
+
const underPub = abs.startsWith(pub + sep);
|
|
366
|
+
const urlPath = '/' + (underPub ? abs.slice(pub.length + 1) : abs.slice(projectRoot.length + 1)).split(sep).join('/');
|
|
367
|
+
broadcast(urlPath, { css: urlPath });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// App modules are served raw — an edit only takes effect on a fresh page.
|
|
371
|
+
if (/\.(js|mjs)$/.test(rel)) { broadcast('/', { reload: true }); return; }
|
|
372
|
+
if (!rel.endsWith('.html')) return;
|
|
373
|
+
if (abs.startsWith(componentsRoot + sep)) {
|
|
374
|
+
const name = rel.split('/').pop().replace(/\.html$/, '');
|
|
375
|
+
broadcast(name, { name });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
// A page (the entry or any other top-level HTML) — only a reload is correct.
|
|
379
|
+
broadcast('/', { reload: true });
|
|
380
|
+
};
|
|
299
381
|
let unwatch = () => {};
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
382
|
+
{
|
|
383
|
+
const stops = [watchTree(projectRoot, onChange(projectRoot))];
|
|
384
|
+
// publicDir outside the project root (unusual, but configurable).
|
|
385
|
+
if (existsSync(pub) && !pub.startsWith(projectRoot + sep) && pub !== projectRoot) {
|
|
386
|
+
stops.push(watchTree(pub, onChange(pub)));
|
|
387
|
+
}
|
|
388
|
+
unwatch = () => stops.forEach((s) => s());
|
|
306
389
|
}
|
|
307
390
|
|
|
308
391
|
const stop = server.stop.bind(server);
|