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.
Files changed (3) hide show
  1. package/README.md +6 -2
  2. package/package.json +2 -2
  3. 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, sibling state preserved (slotted / loop-managed hosts full-reload,
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.2",
4
- "description": "Dev server, build, and preview for spark-html apps \u2014 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.",
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, sibling state preserved (slotted/loop-managed hosts full-reload,
31
- * always correct the exact semantics of the Vite plugin).
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
- ws.onmessage = async (ev) => {
132
- const { name } = JSON.parse(ev.data);
133
- const hosts = [...document.querySelectorAll('[name="' + name + '"]')];
134
- if (!hosts.length) { location.reload(); return; }
135
- // Scoped HMR only for simple top-level hosts; slotted or loop/if-managed
136
- // hosts fall back to a full reload so the result is always correct.
137
- if (hosts.some((h) => h.__sparkHadSlots || h.__sparkManaged)) { location.reload(); return; }
138
- try {
139
- for (const host of hosts) {
140
- const ph = document.createElement('div');
141
- ph.setAttribute('import', host.__sparkImportPath || ('components/' + name + '.html'));
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
- if (/<head[^>]*>/i.test(out)) return out.replace(/<head[^>]*>/i, (m) => `${m}\n${inject}`);
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 component fragments; broadcast the component name on change.
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
- if (existsSync(componentsRoot)) {
301
- unwatch = watchTree(componentsRoot, (_event, filename) => {
302
- if (!filename || !String(filename).endsWith('.html')) return;
303
- const name = String(filename).split('/').pop().replace(/\.html$/, '');
304
- server.publish('spark-hmr', JSON.stringify({ name }));
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);