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.
- package/README.md +6 -2
- package/package.json +1 -1
- 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
|
|
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.
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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 +
|
|
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:
|
|
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
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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 (
|
|
511
|
+
if (isFile(f)) {
|
|
415
512
|
return new Response(Bun.file(f), { headers: { 'Content-Type': mime(f) } });
|
|
416
513
|
}
|
|
417
514
|
}
|