spark-html-bun 0.1.2 → 0.1.4

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 +4 -4
  3. package/src/index.js +156 -48
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,8 +1,8 @@
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.",
5
- "homepage": "https://wilkinnovo.github.io/spark",
3
+ "version": "0.1.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
+ "homepage": "https://wilkinnovo.github.io/spark-html",
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
8
8
  "types": "./src/index.d.ts",
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",
27
- "url": "git+https://github.com/wilkinnovo/spark.git"
27
+ "url": "git+https://github.com/wilkinnovo/spark-html.git"
28
28
  },
29
29
  "keywords": [
30
30
  "bun",
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
@@ -42,7 +46,7 @@
42
46
  * devRoutes?({ config }) → { '/path': { type, body() } }, // dev serving
43
47
  * transformHtml?(html, { dev }) } // dev page injection
44
48
  */
45
- import { join, resolve, extname, basename, sep } from 'node:path';
49
+ import { join, resolve, dirname, extname, basename, sep } from 'node:path';
46
50
  import { existsSync, watch, readdirSync, statSync, readFileSync } from 'node:fs';
47
51
  import { rm, mkdir, cp, readFile } from 'node:fs/promises';
48
52
 
@@ -123,52 +127,107 @@ 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
  }
159
200
  connect();
160
201
  `;
161
202
 
203
+ // Resolve a bare package to the { dir, entry } of its module entry file, so we
204
+ // can serve the entry AND its sibling files under one /@modules/<pkg>/ prefix.
205
+ // Cached — the resolution doesn't change while the server is up.
206
+ const moduleInfoCache = new Map();
207
+ function moduleEntry(pkg, projectRoot) {
208
+ const key = projectRoot + '\0' + pkg;
209
+ if (moduleInfoCache.has(key)) return moduleInfoCache.get(key);
210
+ let info = null;
211
+ try {
212
+ const file = Bun.resolveSync(pkg, projectRoot);
213
+ info = { dir: dirname(file), entry: file.slice(file.lastIndexOf('/') + 1) };
214
+ } catch { /* unresolvable — leave null */ }
215
+ moduleInfoCache.set(key, info);
216
+ return info;
217
+ }
218
+
162
219
  // Import map for the app's bare specifiers: every dependency in package.json
163
- // maps to /@modules/<name>, which the dev server resolves with Bun's resolver.
164
- // Spark packages are single-file modules whose only bare import is
165
- // 'spark-html' (also in the map), so no rewriting is needed anywhere.
220
+ // maps to /@modules/<name>/<entry-file>. The trailing entry filename matters
221
+ // a package's own relative imports (e.g. spark-html-theme's `./init.js`) resolve
222
+ // against that URL, so they land at /@modules/<name>/init.js and stay inside the
223
+ // package instead of collapsing to /@modules/init.js (a 404 that blanks the app).
166
224
  function buildImportMap(projectRoot) {
167
225
  const imports = {};
168
226
  try {
169
227
  const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
170
228
  for (const dep of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })) {
171
- imports[dep] = `/@modules/${dep}`;
229
+ const info = moduleEntry(dep, projectRoot);
230
+ if (info) imports[dep] = `/@modules/${dep}/${info.entry}`;
172
231
  }
173
232
  } catch { /* no package.json — no bare imports to map */ }
174
233
  return imports;
@@ -206,7 +265,8 @@ async function transformPage(html, config, { dev }) {
206
265
  const inject =
207
266
  `<script type="importmap">${importMap}</script>\n` +
208
267
  `<script type="module">${HMR_CLIENT}</script>\n`;
209
- if (/<head[^>]*>/i.test(out)) return out.replace(/<head[^>]*>/i, (m) => `${m}\n${inject}`);
268
+ // `<head(\s…)?>` never match a page's <header> element.
269
+ if (/<head(\s[^>]*)?>/i.test(out)) return out.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n${inject}`);
210
270
  return inject + out;
211
271
  }
212
272
 
@@ -243,15 +303,22 @@ export async function dev(overrides = {}) {
243
303
  return new Response(await route.body(), { headers: { 'Content-Type': route.type } });
244
304
  }
245
305
 
246
- // Bare-specifier modules: /@modules/<name> → Bun-resolved entry file.
306
+ // Bare-specifier modules: /@modules/<name>/<file> → the package's entry
307
+ // (or a sibling file it imports relatively), served from the entry's dir.
247
308
  if (path.startsWith('/@modules/')) {
248
- const spec = path.slice('/@modules/'.length);
249
- try {
250
- const file = Bun.resolveSync(spec, projectRoot);
251
- return new Response(Bun.file(file), { headers: { 'Content-Type': 'text/javascript' } });
252
- } catch {
253
- return new Response(`/* cannot resolve "${spec}" */`, { status: 404, headers: { 'Content-Type': 'text/javascript' } });
309
+ const rest = path.slice('/@modules/'.length);
310
+ const slash = rest.indexOf('/');
311
+ const pkg = slash === -1 ? rest : rest.slice(0, slash);
312
+ const subpath = slash === -1 ? '' : rest.slice(slash + 1);
313
+ const info = moduleEntry(pkg, projectRoot);
314
+ if (info) {
315
+ const file = resolve(info.dir, subpath || info.entry);
316
+ // Guard against escaping the package dir via a crafted subpath.
317
+ if (file.startsWith(info.dir + sep) && isFile(file)) {
318
+ return new Response(Bun.file(file), { headers: { 'Content-Type': 'text/javascript' } });
319
+ }
254
320
  }
321
+ return new Response(`/* cannot resolve "${rest}" */`, { status: 404, headers: { 'Content-Type': 'text/javascript' } });
255
322
  }
256
323
 
257
324
  // Static lookup: project root first (index.html, src/…), then publicDir.
@@ -294,15 +361,56 @@ export async function dev(overrides = {}) {
294
361
  },
295
362
  });
296
363
 
297
- // Watch component fragments; broadcast the component name on change.
364
+ // Watch the whole project for dev edits and broadcast the right HMR message:
365
+ // component fragments → scoped re-mount, stylesheets → in-place <link> swap,
366
+ // page HTML (the entry, or any other served page) → full reload. Broadcasts
367
+ // are debounced per key — editors save via temp-file + rename and emit
368
+ // several fs events per keystroke, and a duplicate message arriving while
369
+ // the client is mid-re-mount would mis-read the DOM.
298
370
  const componentsRoot = existsSync(join(pub, componentsDir)) ? join(pub, componentsDir) : join(projectRoot, componentsDir);
371
+ const outAbs = resolve(projectRoot, config.outDir);
372
+ const timers = new Map();
373
+ const broadcast = (key, msg) => {
374
+ clearTimeout(timers.get(key));
375
+ timers.set(key, setTimeout(() => {
376
+ timers.delete(key);
377
+ server.publish('spark-hmr', JSON.stringify(msg));
378
+ }, 50));
379
+ };
380
+ const onChange = (base) => (_event, filename) => {
381
+ if (!filename) return;
382
+ const rel = String(filename);
383
+ // Never react to build output, deps, or VCS internals.
384
+ if (/(^|\/)(node_modules|\.git)(\/|$)/.test(rel)) return;
385
+ const abs = join(base, rel);
386
+ if (abs === outAbs || abs.startsWith(outAbs + sep)) return;
387
+ if (rel.endsWith('.css')) {
388
+ // The URL the page loads this file under: public/ files are served from
389
+ // the site root, project files from their project-relative path.
390
+ const underPub = abs.startsWith(pub + sep);
391
+ const urlPath = '/' + (underPub ? abs.slice(pub.length + 1) : abs.slice(projectRoot.length + 1)).split(sep).join('/');
392
+ broadcast(urlPath, { css: urlPath });
393
+ return;
394
+ }
395
+ // App modules are served raw — an edit only takes effect on a fresh page.
396
+ if (/\.(js|mjs)$/.test(rel)) { broadcast('/', { reload: true }); return; }
397
+ if (!rel.endsWith('.html')) return;
398
+ if (abs.startsWith(componentsRoot + sep)) {
399
+ const name = rel.split('/').pop().replace(/\.html$/, '');
400
+ broadcast(name, { name });
401
+ return;
402
+ }
403
+ // A page (the entry or any other top-level HTML) — only a reload is correct.
404
+ broadcast('/', { reload: true });
405
+ };
299
406
  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
- });
407
+ {
408
+ const stops = [watchTree(projectRoot, onChange(projectRoot))];
409
+ // publicDir outside the project root (unusual, but configurable).
410
+ if (existsSync(pub) && !pub.startsWith(projectRoot + sep) && pub !== projectRoot) {
411
+ stops.push(watchTree(pub, onChange(pub)));
412
+ }
413
+ unwatch = () => stops.forEach((s) => s());
306
414
  }
307
415
 
308
416
  const stop = server.stop.bind(server);