spark-html-bun 0.1.1 → 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 +1 -1
  3. package/src/index.js +142 -45
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,6 +1,6 @@
1
1
  {
2
2
  "name": "spark-html-bun",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
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",
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
@@ -60,6 +64,13 @@ function decodePath(pathname) {
60
64
  try { return decodeURIComponent(pathname); } catch { return null; }
61
65
  }
62
66
 
67
+ // One stat instead of existsSync + statSync — this runs per request candidate
68
+ // on the dev/preview hot path.
69
+ function isFile(p) {
70
+ const s = statSync(p, { throwIfNoEntry: false });
71
+ return s !== undefined && s.isFile();
72
+ }
73
+
63
74
  const MIME = {
64
75
  '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
65
76
  '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml',
@@ -116,36 +127,73 @@ export async function loadConfig(root = process.cwd(), overrides = {}) {
116
127
 
117
128
  // The scoped-HMR client. The re-mount logic (unmount host → placeholder →
118
129
  // re-mount; slotted/managed hosts full-reload) rides a plain WebSocket the
119
- // 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
120
134
  const HMR_CLIENT = `
121
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
+
122
184
  function connect() {
123
185
  const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/__spark_hmr');
124
- ws.onmessage = async (ev) => {
125
- const { name } = JSON.parse(ev.data);
126
- const hosts = [...document.querySelectorAll('[name="' + name + '"]')];
127
- if (!hosts.length) { location.reload(); return; }
128
- // Scoped HMR only for simple top-level hosts; slotted or loop/if-managed
129
- // hosts fall back to a full reload so the result is always correct.
130
- if (hosts.some((h) => h.__sparkHadSlots || h.__sparkManaged)) { location.reload(); return; }
131
- try {
132
- for (const host of hosts) {
133
- const ph = document.createElement('div');
134
- ph.setAttribute('import', host.__sparkImportPath || ('components/' + name + '.html'));
135
- const props = host.__sparkProps || {};
136
- for (const k in props) {
137
- const v = props[k];
138
- try { ph.setAttribute(k, typeof v === 'string' ? v : JSON.stringify(v)); } catch (e) {}
139
- }
140
- const cls = host.getAttribute('class'); if (cls) ph.setAttribute('class', cls);
141
- if (host.id) ph.id = host.id;
142
- const parent = host.parentNode;
143
- unmount(host);
144
- host.replaceWith(ph);
145
- await mount(parent);
146
- }
147
- console.log('[spark] ⚡ hot-updated', name);
148
- } 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(() => {});
149
197
  };
150
198
  ws.onclose = () => setTimeout(connect, 1000); // server restarted — retry
151
199
  }
@@ -199,7 +247,8 @@ async function transformPage(html, config, { dev }) {
199
247
  const inject =
200
248
  `<script type="importmap">${importMap}</script>\n` +
201
249
  `<script type="module">${HMR_CLIENT}</script>\n`;
202
- 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}`);
203
252
  return inject + out;
204
253
  }
205
254
 
@@ -253,7 +302,7 @@ export async function dev(overrides = {}) {
253
302
  let file = null;
254
303
  for (const b of [projectRoot, pub]) {
255
304
  const c = safeJoin(b, rel);
256
- if (c && existsSync(c) && statSync(c).isFile()) { file = c; break; }
305
+ if (c && isFile(c)) { file = c; break; }
257
306
  }
258
307
 
259
308
  // SPA fallback: extensionless paths serve the app shell (the router
@@ -287,15 +336,56 @@ export async function dev(overrides = {}) {
287
336
  },
288
337
  });
289
338
 
290
- // 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.
291
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
+ };
292
381
  let unwatch = () => {};
293
- if (existsSync(componentsRoot)) {
294
- unwatch = watchTree(componentsRoot, (_event, filename) => {
295
- if (!filename || !String(filename).endsWith('.html')) return;
296
- const name = String(filename).split('/').pop().replace(/\.html$/, '');
297
- server.publish('spark-hmr', JSON.stringify({ name }));
298
- });
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());
299
389
  }
300
390
 
301
391
  const stop = server.stop.bind(server);
@@ -343,12 +433,17 @@ export async function build(overrides = {}) {
343
433
  const file = clean.startsWith('/') ? join(projectRoot, clean.slice(1)) : join(entryDir, clean);
344
434
  // Only bundle files that live in the PROJECT (src/…) — anything served
345
435
  // from publicDir ships verbatim and its URL already works.
346
- if (existsSync(file) && !file.startsWith(pub + '/')) found.push({ url, file });
436
+ if (existsSync(file) && !file.startsWith(pub + sep)) found.push({ url, file });
347
437
  }
348
438
 
349
439
  if (found.length) {
440
+ // Bun.build DEDUPES duplicate entrypoints (the same file listed twice —
441
+ // or reached via two URL spellings — yields ONE output), so bundle the
442
+ // UNIQUE files and map file → hashed name; never map outputs back by
443
+ // found-index, which would splice the wrong asset URL into the page.
444
+ const files = [...new Set(found.map((f) => f.file))];
350
445
  const result = await Bun.build({
351
- entrypoints: found.map((f) => f.file),
446
+ entrypoints: files,
352
447
  outdir: join(outDir, 'assets'),
353
448
  minify: true,
354
449
  splitting: true,
@@ -362,17 +457,19 @@ export async function build(overrides = {}) {
362
457
  throw new Error(`[spark] build failed:\n${msgs}`);
363
458
  }
364
459
  // Entry outputs come back in entrypoint order (verified for Bun's
365
- // splitting output, incl. same-basename entries) — map each to its URL.
460
+ // splitting output, incl. same-basename entries) — map each unique
461
+ // file to its hashed name, then rewrite every tag that referenced it.
366
462
  const entryOuts = result.outputs.filter((o) => o.kind === 'entry-point');
367
- found.forEach((f, i) => {
368
- const name = entryOuts[i] && basename(entryOuts[i].path);
369
- if (!name) return;
463
+ const outName = new Map(files.map((file, i) => [file, entryOuts[i] && basename(entryOuts[i].path)]));
464
+ for (const f of found) {
465
+ const name = outName.get(f.file);
466
+ if (!name) continue;
370
467
  const to = `${base}assets/${name}`;
371
468
  // Replace only the quoted attribute value, so a longer URL that ends
372
469
  // with this one (e.g. /lib/app.js vs /app.js) is never corrupted the
373
470
  // way a bare replaceAll(url) would corrupt it.
374
471
  for (const q of ['"', "'"]) html = html.split(`${q}${f.url}${q}`).join(`${q}${to}${q}`);
375
- });
472
+ }
376
473
  }
377
474
  await Bun.write(join(outDir, config.entry.split('/').pop()), html);
378
475
  }
@@ -411,7 +508,7 @@ export async function preview(overrides = {}) {
411
508
  rel !== '' && rel.endsWith('/') ? safeJoin(outDir, join(rel, 'index.html')) : null,
412
509
  ].filter(Boolean);
413
510
  for (const f of tryFiles) {
414
- if (existsSync(f) && statSync(f).isFile()) {
511
+ if (isFile(f)) {
415
512
  return new Response(Bun.file(f), { headers: { 'Content-Type': mime(f) } });
416
513
  }
417
514
  }