spark-html-bun 0.1.0

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 ADDED
@@ -0,0 +1,97 @@
1
+ # ⚡ spark-html-bun
2
+
3
+ Dev server, bundler, and preview server for [spark-html](https://www.npmjs.com/package/spark-html)
4
+ apps — built **entirely on [Bun](https://bun.sh)**, zero dependencies. It's what
5
+ replaces the old Vite setup: `spark dev` / `spark build` / `spark preview`.
6
+
7
+ ```jsonc
8
+ // package.json
9
+ "scripts": {
10
+ "dev": "spark dev",
11
+ "build": "spark build",
12
+ "preview": "spark preview"
13
+ }
14
+ ```
15
+
16
+ ```bash
17
+ bun add -d spark-html-bun
18
+ bun dev
19
+ ```
20
+
21
+ ## What each command does
22
+
23
+ - **`spark dev`** — `Bun.serve` over your project root + `public/`. Component
24
+ fragments (`components/*.html`) are served **raw** with `no-cache`, never
25
+ rewritten. Bare imports resolve through an injected `<script type="importmap">`
26
+ (built from your `package.json`), so the browser runs your ES modules directly
27
+ — **no bundling in dev**. Scoped component HMR rides a plain WebSocket
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).
31
+ - **`spark build`** — empties `dist/`, copies `public/` verbatim (components ship
32
+ as authored), bundles the HTML entry's scripts/styles with `Bun.build`
33
+ (hashed under `assets/`, `base` honored), then runs the **pipeline** in order.
34
+ - **`spark preview`** — static server over `dist/` with the same rewrites the
35
+ deploy targets apply: exact file → `path + '.html'` (the `_redirects`
36
+ convention `spark-prerender` emits) → `404.html`.
37
+
38
+ CLI flags: `--port N`, `--base /repo/`, `--out-dir dir`, `--strict-port`.
39
+
40
+ ## Configuration
41
+
42
+ Everything has a default — `spark.config.js` is optional:
43
+
44
+ ```js
45
+ // spark.config.js
46
+ import prerender from 'spark-prerender/bun';
47
+ import image from 'spark-html-image/bun';
48
+
49
+ export default {
50
+ base: '/', // deploy prefix (GitHub Pages: '/repo/')
51
+ entry: 'index.html',
52
+ outDir: 'dist',
53
+ publicDir: 'public',
54
+ componentsDir: 'components',
55
+ pipeline: [prerender({ site: 'https://example.com' }), image()],
56
+ };
57
+ ```
58
+
59
+ The **pipeline** is an explicit, ordered array of build steps — each companion
60
+ package ships one at `pkg/bun`:
61
+
62
+ | Step | Package |
63
+ |------|---------|
64
+ | `prerender()` | `spark-prerender/bun` — SEO prerender + sitemap/robots/redirects |
65
+ | `image()` | `spark-html-image/bun` — webp/avif + responsive `srcset` |
66
+ | `font()` | `spark-html-font/bun` — preload + size-adjusted fallbacks |
67
+ | `manifest()` | `spark-html-manifest/bun` — PWA manifest + icons + worker |
68
+ | `offline()` | `spark-html-offline/bun` — offline service worker |
69
+ | `sri()` | `spark-html-sri/bun` — hash + stamp integrity (run **last**) |
70
+
71
+ Order matters: `prerender()` first (it writes one HTML file per route), then the
72
+ steps that rewrite those pages; `sri()` last so it hashes the final bytes.
73
+
74
+ ## `import.meta.env`
75
+
76
+ Vite-compatible `BASE_URL` / `DEV` / `PROD` / `MODE` are available in your app
77
+ source — substituted at serve time in dev, and via `Bun.build`'s `define` in the
78
+ build. No bundler config needed.
79
+
80
+ ## Programmatic API
81
+
82
+ ```js
83
+ import { dev, build, preview, loadConfig } from 'spark-html-bun';
84
+
85
+ const server = await dev({ port: 3000 }); // returns the Bun server
86
+ await build({ base: '/repo/' }); // returns { outDir }
87
+ ```
88
+
89
+ ## Requirements
90
+
91
+ Bun ≥ 1.2. Spark itself has no hard dependency on this package — any static file
92
+ server works — but `spark-html-bun` gives you scoped HMR, no-build dev, and the
93
+ whole build pipeline in one tool.
94
+
95
+ ## License
96
+
97
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * spark — dev / build / preview for spark-html apps, on Bun.
4
+ *
5
+ * spark dev [--port 3000] [--base /]
6
+ * spark build [--base /repo/]
7
+ * spark preview [--port 4173] [--strict-port]
8
+ */
9
+ import { dev, build, preview } from '../src/index.js';
10
+
11
+ const [cmd, ...rest] = process.argv.slice(2);
12
+
13
+ function flags(args) {
14
+ const out = {};
15
+ for (let i = 0; i < args.length; i++) {
16
+ if (args[i] === '--port') out.port = Number(args[++i]);
17
+ else if (args[i] === '--base') out.base = args[++i];
18
+ else if (args[i] === '--strict-port') out.strictPort = true;
19
+ else if (args[i] === '--out-dir') out.outDir = args[++i];
20
+ }
21
+ return out;
22
+ }
23
+
24
+ const opts = flags(rest);
25
+
26
+ try {
27
+ if (cmd === 'dev') await dev(opts);
28
+ else if (cmd === 'build') { await build(opts); process.exit(0); }
29
+ else if (cmd === 'preview') await preview(opts);
30
+ else {
31
+ console.log('spark <dev|build|preview> [--port N] [--base /path/] [--out-dir dist]');
32
+ process.exit(cmd ? 1 : 0);
33
+ }
34
+ } catch (e) {
35
+ console.error(`[spark] ${e.message}`);
36
+ process.exit(1);
37
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "spark-html-bun",
3
+ "version": "0.1.0",
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",
6
+ "type": "module",
7
+ "main": "./src/index.js",
8
+ "types": "./src/index.d.ts",
9
+ "bin": {
10
+ "spark": "./bin/cli.js"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./src/index.d.ts",
15
+ "default": "./src/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "src",
20
+ "bin"
21
+ ],
22
+ "engines": {
23
+ "bun": ">=1.2.0"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/wilkinnovo/spark.git"
28
+ },
29
+ "keywords": [
30
+ "bun",
31
+ "dev-server",
32
+ "bundler",
33
+ "spark-html",
34
+ "no-build"
35
+ ],
36
+ "license": "MIT"
37
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ /** A post-build pipeline step (what `spark-prerender/bun` etc. return). */
2
+ export interface PipelineStep {
3
+ name: string;
4
+ /** Build: rewrite/emit files in outDir. */
5
+ run?(ctx: { outDir: string; base: string; projectRoot: string }): void | Promise<void>;
6
+ /** Dev: extra routes to serve (e.g. /manifest.webmanifest, service workers). */
7
+ devRoutes?(ctx: { config: SparkConfig }): Record<string, { type: string; body(): string | Promise<string> }>;
8
+ /** Dev/build: transform a served HTML page. */
9
+ transformHtml?(html: string, ctx: { dev: boolean }): string | Promise<string>;
10
+ }
11
+
12
+ export interface SparkConfig {
13
+ /** Deploy prefix, e.g. '/repo/' on GitHub Pages. Default '/'. */
14
+ base?: string;
15
+ /** The HTML entry to bundle. Default 'index.html'. */
16
+ entry?: string;
17
+ /** Build output dir. Default 'dist'. */
18
+ outDir?: string;
19
+ /** Static dir copied verbatim into the build. Default 'public'. */
20
+ publicDir?: string;
21
+ /** Component fragment dir (gets no-cache dev headers + HMR). Default 'components'. */
22
+ componentsDir?: string;
23
+ /** Post-build steps, run in order over outDir. */
24
+ pipeline?: PipelineStep[];
25
+ /** Dev/preview port. */
26
+ port?: number;
27
+ }
28
+
29
+ export interface RunOptions extends SparkConfig {
30
+ /** Project root (defaults to cwd). */
31
+ root?: string;
32
+ quiet?: boolean;
33
+ }
34
+
35
+ /** Load spark.config.js from root and merge with defaults/overrides. */
36
+ export function loadConfig(root?: string, overrides?: RunOptions): Promise<Required<SparkConfig> & { projectRoot: string }>;
37
+
38
+ /** Start the dev server (static + import map + WebSocket component HMR). */
39
+ export function dev(overrides?: RunOptions): Promise<{ port: number; stop(): void }>;
40
+
41
+ /** Build: copy publicDir, Bun.build the entry, run the pipeline. */
42
+ export function build(overrides?: RunOptions): Promise<{ outDir: string }>;
43
+
44
+ /** Serve outDir with path→path.html→404.html fallbacks. */
45
+ export function preview(overrides?: RunOptions): Promise<{ port: number; stop(): void }>;
46
+
47
+ declare const _default: { dev: typeof dev; build: typeof build; preview: typeof preview; loadConfig: typeof loadConfig };
48
+ export default _default;
package/src/index.js ADDED
@@ -0,0 +1,407 @@
1
+ /**
2
+ * spark-html-bun — dev server, build, and preview for spark-html apps,
3
+ * built entirely on Bun. Replaces Vite with ~400 dependency-free lines.
4
+ *
5
+ * // package.json scripts (spark-html-bun ships the `spark` bin)
6
+ * "dev": "spark dev",
7
+ * "build": "spark build",
8
+ * "preview": "spark preview"
9
+ *
10
+ * // spark.config.js (optional — everything has a default)
11
+ * import prerender from 'spark-prerender/bun';
12
+ * import image from 'spark-html-image/bun';
13
+ * export default {
14
+ * base: '/', // deploy prefix (GH Pages: '/repo/')
15
+ * entry: 'index.html',
16
+ * outDir: 'dist',
17
+ * publicDir: 'public',
18
+ * componentsDir: 'components',
19
+ * pipeline: [prerender({ site: 'https://example.com' }), image()],
20
+ * };
21
+ *
22
+ * What each command does:
23
+ * • dev — Bun.serve over the project root + publicDir. Component fragments
24
+ * get Content-Type + no-cache (same two headers the Vite middleware set).
25
+ * Bare import specifiers resolve through an injected <script type=
26
+ * "importmap"> (built from the app's package.json dependencies, served
27
+ * from /@modules/<name>) — no bundling in dev at all, the browser runs
28
+ * your ES modules directly. Scoped component HMR rides a plain WebSocket
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).
32
+ * • build — empty outDir, copy publicDir verbatim (components ship as
33
+ * authored), Bun.build the HTML entry (scripts/styles bundled + hashed
34
+ * under assets/, HTML rewritten, base honored via publicPath), then run
35
+ * the pipeline steps in order over outDir.
36
+ * • preview — static server over outDir with the same rewrites the deploy
37
+ * targets apply: exact file → path + '.html' (the _redirects convention)
38
+ * → 404.html.
39
+ *
40
+ * Pipeline step contract (what `spark-prerender/bun` etc. return):
41
+ * { name, run({ outDir, base, projectRoot }), // build
42
+ * devRoutes?({ config }) → { '/path': { type, body() } }, // dev serving
43
+ * transformHtml?(html, { dev }) } // dev page injection
44
+ */
45
+ import { join, resolve, extname } from 'node:path';
46
+ import { existsSync, watch, readdirSync, statSync, readFileSync } from 'node:fs';
47
+ import { rm, mkdir, cp, readFile } from 'node:fs/promises';
48
+
49
+ const MIME = {
50
+ '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
51
+ '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml',
52
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
53
+ '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif',
54
+ '.ico': 'image/x-icon', '.txt': 'text/plain', '.xml': 'application/xml',
55
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
56
+ '.otf': 'font/otf', '.map': 'application/json', '.webmanifest': 'application/manifest+json',
57
+ '.wasm': 'application/wasm', '.pdf': 'application/pdf', '.mp4': 'video/mp4',
58
+ };
59
+ const mime = (path) => MIME[extname(path).toLowerCase()] || 'application/octet-stream';
60
+
61
+ // ── config ──────────────────────────────────────────────────────────────
62
+
63
+ const DEFAULTS = {
64
+ base: '/', entry: 'index.html', outDir: 'dist',
65
+ publicDir: 'public', componentsDir: 'components', pipeline: [],
66
+ };
67
+
68
+ // Vite-compatible `import.meta.env` — replaced wholesale (object literal) so
69
+ // both `import.meta.env.BASE_URL` and the optional-chained `import.meta.env?.DEV`
70
+ // resolve. Dev serves raw modules to the browser (where import.meta.env is
71
+ // undefined), so we substitute at serve time; the build substitutes via
72
+ // Bun.build's `define`. Same object shape Vite exposes.
73
+ function envLiteral(config, dev) {
74
+ return JSON.stringify({
75
+ BASE_URL: config.base,
76
+ DEV: dev,
77
+ PROD: !dev,
78
+ MODE: dev ? 'development' : 'production',
79
+ SSR: false,
80
+ });
81
+ }
82
+
83
+ /** Load spark.config.js from `root` (if present) and merge with defaults. */
84
+ export async function loadConfig(root = process.cwd(), overrides = {}) {
85
+ let fileConfig = {};
86
+ for (const name of ['spark.config.js', 'spark.config.mjs', 'spark.config.ts']) {
87
+ const file = join(root, name);
88
+ if (existsSync(file)) {
89
+ fileConfig = (await import(file)).default || {};
90
+ break;
91
+ }
92
+ }
93
+ const config = { ...DEFAULTS, ...fileConfig, ...overrides, projectRoot: resolve(root) };
94
+ let base = config.base || '/';
95
+ if (!base.startsWith('/')) base = '/' + base;
96
+ if (!base.endsWith('/')) base += '/';
97
+ config.base = base;
98
+ return config;
99
+ }
100
+
101
+ // ── dev ─────────────────────────────────────────────────────────────────
102
+
103
+ // The scoped-HMR client. The re-mount logic (unmount host → placeholder →
104
+ // re-mount; slotted/managed hosts full-reload) rides a plain WebSocket the
105
+ // dev server owns, instead of a bundler's HMR channel.
106
+ const HMR_CLIENT = `
107
+ import { mount, unmount } from 'spark-html';
108
+ function connect() {
109
+ const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/__spark_hmr');
110
+ ws.onmessage = async (ev) => {
111
+ const { name } = JSON.parse(ev.data);
112
+ const hosts = [...document.querySelectorAll('[name="' + name + '"]')];
113
+ if (!hosts.length) { location.reload(); return; }
114
+ // Scoped HMR only for simple top-level hosts; slotted or loop/if-managed
115
+ // hosts fall back to a full reload so the result is always correct.
116
+ if (hosts.some((h) => h.__sparkHadSlots || h.__sparkManaged)) { location.reload(); return; }
117
+ try {
118
+ for (const host of hosts) {
119
+ const ph = document.createElement('div');
120
+ ph.setAttribute('import', host.__sparkImportPath || ('components/' + name + '.html'));
121
+ const props = host.__sparkProps || {};
122
+ for (const k in props) {
123
+ const v = props[k];
124
+ try { ph.setAttribute(k, typeof v === 'string' ? v : JSON.stringify(v)); } catch (e) {}
125
+ }
126
+ const cls = host.getAttribute('class'); if (cls) ph.setAttribute('class', cls);
127
+ if (host.id) ph.id = host.id;
128
+ const parent = host.parentNode;
129
+ unmount(host);
130
+ host.replaceWith(ph);
131
+ await mount(parent);
132
+ }
133
+ console.log('[spark] ⚡ hot-updated', name);
134
+ } catch (e) { location.reload(); }
135
+ };
136
+ ws.onclose = () => setTimeout(connect, 1000); // server restarted — retry
137
+ }
138
+ connect();
139
+ `;
140
+
141
+ // Import map for the app's bare specifiers: every dependency in package.json
142
+ // maps to /@modules/<name>, which the dev server resolves with Bun's resolver.
143
+ // Spark packages are single-file modules whose only bare import is
144
+ // 'spark-html' (also in the map), so no rewriting is needed anywhere.
145
+ function buildImportMap(projectRoot) {
146
+ const imports = {};
147
+ try {
148
+ const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
149
+ for (const dep of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })) {
150
+ imports[dep] = `/@modules/${dep}`;
151
+ }
152
+ } catch { /* no package.json — no bare imports to map */ }
153
+ return imports;
154
+ }
155
+
156
+ // Watch a directory tree for component edits. fs.watch({recursive}) works on
157
+ // Bun/Linux, but fall back to per-directory watchers if it ever throws.
158
+ function watchTree(dir, onChange) {
159
+ const watchers = [];
160
+ try {
161
+ watchers.push(watch(dir, { recursive: true }, onChange));
162
+ } catch {
163
+ const walk = (d) => {
164
+ watchers.push(watch(d, onChange));
165
+ for (const name of readdirSync(d)) {
166
+ const full = join(d, name);
167
+ try { if (statSync(full).isDirectory()) walk(full); } catch { /* raced */ }
168
+ }
169
+ };
170
+ walk(dir);
171
+ }
172
+ return () => watchers.forEach((w) => w.close());
173
+ }
174
+
175
+ async function transformPage(html, config, { dev }) {
176
+ let out = html;
177
+ for (const step of config.pipeline) {
178
+ if (typeof step.transformHtml === 'function') {
179
+ out = (await step.transformHtml(out, { dev })) || out;
180
+ }
181
+ }
182
+ if (!dev) return out;
183
+ const importMap = JSON.stringify({ imports: buildImportMap(config.projectRoot) });
184
+ // The import map must precede every module script — inject at head start.
185
+ const inject =
186
+ `<script type="importmap">${importMap}</script>\n` +
187
+ `<script type="module">${HMR_CLIENT}</script>\n`;
188
+ if (/<head[^>]*>/i.test(out)) return out.replace(/<head[^>]*>/i, (m) => `${m}\n${inject}`);
189
+ return inject + out;
190
+ }
191
+
192
+ /**
193
+ * Start the dev server. Returns the Bun server (call .stop() to shut down).
194
+ */
195
+ export async function dev(overrides = {}) {
196
+ const config = await loadConfig(overrides.root || process.cwd(), overrides);
197
+ const { projectRoot, publicDir, componentsDir } = config;
198
+ const pub = join(projectRoot, publicDir);
199
+
200
+ // Collect dev routes from pipeline steps (manifest/offline serve workers).
201
+ const stepRoutes = {};
202
+ for (const step of config.pipeline) {
203
+ if (typeof step.devRoutes === 'function') Object.assign(stepRoutes, step.devRoutes({ config }));
204
+ }
205
+
206
+ const server = Bun.serve({
207
+ port: overrides.port ?? config.port ?? 3000,
208
+ development: true,
209
+ async fetch(req, srv) {
210
+ const url = new URL(req.url);
211
+ let path = decodeURIComponent(url.pathname);
212
+
213
+ // WebSocket channel for scoped component HMR.
214
+ if (path === '/__spark_hmr') {
215
+ return srv.upgrade(req) ? undefined : new Response('upgrade failed', { status: 400 });
216
+ }
217
+
218
+ // Pipeline dev routes (e.g. /manifest.webmanifest, service workers).
219
+ const route = stepRoutes[path];
220
+ if (route) {
221
+ return new Response(await route.body(), { headers: { 'Content-Type': route.type } });
222
+ }
223
+
224
+ // Bare-specifier modules: /@modules/<name> → Bun-resolved entry file.
225
+ if (path.startsWith('/@modules/')) {
226
+ const spec = path.slice('/@modules/'.length);
227
+ try {
228
+ const file = Bun.resolveSync(spec, projectRoot);
229
+ return new Response(Bun.file(file), { headers: { 'Content-Type': 'text/javascript' } });
230
+ } catch {
231
+ return new Response(`/* cannot resolve "${spec}" */`, { status: 404, headers: { 'Content-Type': 'text/javascript' } });
232
+ }
233
+ }
234
+
235
+ // Static lookup: project root first (index.html, src/…), then publicDir.
236
+ const rel = path.replace(/^\/+/, '');
237
+ const candidates = [join(projectRoot, rel), join(pub, rel)];
238
+ let file = null;
239
+ for (const c of candidates) {
240
+ if (existsSync(c) && statSync(c).isFile()) { file = c; break; }
241
+ }
242
+
243
+ // SPA fallback: extensionless paths serve the app shell (the router
244
+ // resolves the route client-side — same behavior as Vite dev).
245
+ if (!file && !extname(path)) file = join(projectRoot, config.entry);
246
+ if (!file || !existsSync(file)) return new Response('Not found', { status: 404 });
247
+
248
+ const headers = { 'Content-Type': mime(file) };
249
+ const isFragment = path.includes(`/${componentsDir}/`) && path.endsWith('.html');
250
+ if (isFragment) {
251
+ // Always re-fetch fresh on HMR — the two headers the Vite middleware set.
252
+ headers['Cache-Control'] = 'no-cache';
253
+ return new Response(Bun.file(file), { headers });
254
+ }
255
+ if (file.endsWith('.html')) {
256
+ const html = await readFile(file, 'utf8');
257
+ return new Response(await transformPage(html, config, { dev: true }), { headers });
258
+ }
259
+ // App source modules: substitute import.meta.env (Vite-compatible) so
260
+ // BASE_URL / DEV / PROD work in dev without a bundler. node_modules are
261
+ // served via /@modules/ above and never reach here.
262
+ if (/\.(js|mjs)$/.test(file)) {
263
+ const code = (await readFile(file, 'utf8')).replaceAll('import.meta.env', envLiteral(config, true));
264
+ return new Response(code, { headers });
265
+ }
266
+ return new Response(Bun.file(file), { headers });
267
+ },
268
+ websocket: {
269
+ open(ws) { ws.subscribe('spark-hmr'); },
270
+ message() { /* client never sends */ },
271
+ },
272
+ });
273
+
274
+ // Watch component fragments; broadcast the component name on change.
275
+ const componentsRoot = existsSync(join(pub, componentsDir)) ? join(pub, componentsDir) : join(projectRoot, componentsDir);
276
+ let unwatch = () => {};
277
+ if (existsSync(componentsRoot)) {
278
+ unwatch = watchTree(componentsRoot, (_event, filename) => {
279
+ if (!filename || !String(filename).endsWith('.html')) return;
280
+ const name = String(filename).split('/').pop().replace(/\.html$/, '');
281
+ server.publish('spark-hmr', JSON.stringify({ name }));
282
+ });
283
+ }
284
+
285
+ const stop = server.stop.bind(server);
286
+ server.stop = (...args) => { unwatch(); return stop(...args); };
287
+ if (!overrides.quiet) {
288
+ console.log(`[spark] ⚡ dev server — http://localhost:${server.port}/`);
289
+ }
290
+ return server;
291
+ }
292
+
293
+ // ── build ───────────────────────────────────────────────────────────────
294
+
295
+ /**
296
+ * Build the app: copy publicDir, bundle the HTML entry with Bun.build, run
297
+ * the pipeline over outDir. Returns { outDir }.
298
+ */
299
+ export async function build(overrides = {}) {
300
+ const config = await loadConfig(overrides.root || process.cwd(), overrides);
301
+ const { projectRoot, base } = config;
302
+ const outDir = resolve(projectRoot, config.outDir);
303
+ const pub = join(projectRoot, config.publicDir);
304
+ const entry = join(projectRoot, config.entry);
305
+
306
+ await rm(outDir, { recursive: true, force: true });
307
+ await mkdir(outDir, { recursive: true });
308
+
309
+ // Components (and everything else in public/) ship exactly as authored.
310
+ if (existsSync(pub)) await cp(pub, outDir, { recursive: true });
311
+
312
+ // Bundle the app shell — but never hand Bun the HTML itself (it hard-fails
313
+ // on refs it can't resolve, and pages legitimately reference public/ files
314
+ // that only exist in the output). Instead: find the module scripts and
315
+ // stylesheet links that resolve to PROJECT files, bundle those, splice the
316
+ // hashed asset URLs back in, and ship every other byte of HTML as authored.
317
+ if (existsSync(entry)) {
318
+ const entryDir = join(entry, '..');
319
+ let html = await readFile(entry, 'utf8');
320
+
321
+ const tagRe = /<script\b[^>]*\btype\s*=\s*["']module["'][^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*><\/script>|<link\b[^>]*\brel\s*=\s*["']stylesheet["'][^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>/gi;
322
+ const found = []; // { url, file }
323
+ for (const m of html.matchAll(tagRe)) {
324
+ const url = m[1] || m[2];
325
+ if (!url || /^[a-z]+:|^\/\//i.test(url)) continue; // remote — leave alone
326
+ const clean = url.split(/[?#]/)[0];
327
+ const file = clean.startsWith('/') ? join(projectRoot, clean.slice(1)) : join(entryDir, clean);
328
+ // Only bundle files that live in the PROJECT (src/…) — anything served
329
+ // from publicDir ships verbatim and its URL already works.
330
+ if (existsSync(file) && !file.startsWith(pub + '/')) found.push({ url, file });
331
+ }
332
+
333
+ if (found.length) {
334
+ const result = await Bun.build({
335
+ entrypoints: found.map((f) => f.file),
336
+ outdir: join(outDir, 'assets'),
337
+ minify: true,
338
+ splitting: true,
339
+ format: 'esm',
340
+ publicPath: `${base}assets/`,
341
+ define: { 'import.meta.env': envLiteral(config, false) },
342
+ naming: { entry: '[name]-[hash].[ext]', chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]' },
343
+ });
344
+ if (!result.success) {
345
+ const msgs = (result.logs || []).map((l) => l.message || String(l)).join('\n');
346
+ throw new Error(`[spark] build failed:\n${msgs}`);
347
+ }
348
+ // Entry outputs come back in entrypoint order — map each to its URL.
349
+ const entryOuts = result.outputs.filter((o) => o.kind === 'entry-point');
350
+ found.forEach((f, i) => {
351
+ const name = entryOuts[i] && entryOuts[i].path.split('/').pop();
352
+ if (name) html = html.replaceAll(f.url, `${base}assets/${name}`);
353
+ });
354
+ }
355
+ await Bun.write(join(outDir, config.entry.split('/').pop()), html);
356
+ }
357
+
358
+ for (const step of config.pipeline) {
359
+ if (typeof step.run === 'function') await step.run({ outDir, base, projectRoot });
360
+ }
361
+
362
+ if (!overrides.quiet) console.log(`[spark] ⚡ built → ${config.outDir}/`);
363
+ return { outDir };
364
+ }
365
+
366
+ // ── preview ─────────────────────────────────────────────────────────────
367
+
368
+ /**
369
+ * Serve the built outDir the way the deploy targets do: exact file →
370
+ * `path + '.html'` (the _redirects convention prerender emits) → 404.html.
371
+ */
372
+ export async function preview(overrides = {}) {
373
+ const config = await loadConfig(overrides.root || process.cwd(), overrides);
374
+ const outDir = resolve(config.projectRoot, config.outDir);
375
+ const base = config.base;
376
+
377
+ const server = Bun.serve({
378
+ port: overrides.port ?? config.port ?? 4173,
379
+ fetch(req) {
380
+ const url = new URL(req.url);
381
+ let path = decodeURIComponent(url.pathname);
382
+ if (base !== '/' && path.startsWith(base)) path = '/' + path.slice(base.length);
383
+ const rel = path.replace(/^\/+/, '');
384
+ const tryFiles = [
385
+ join(outDir, rel === '' ? 'index.html' : rel),
386
+ join(outDir, rel + '.html'),
387
+ rel !== '' && rel.endsWith('/') ? join(outDir, rel, 'index.html') : null,
388
+ ].filter(Boolean);
389
+ for (const f of tryFiles) {
390
+ if (existsSync(f) && statSync(f).isFile()) {
391
+ return new Response(Bun.file(f), { headers: { 'Content-Type': mime(f) } });
392
+ }
393
+ }
394
+ const notFound = join(outDir, '404.html');
395
+ if (existsSync(notFound)) {
396
+ return new Response(Bun.file(notFound), { status: 404, headers: { 'Content-Type': 'text/html' } });
397
+ }
398
+ return new Response('Not found', { status: 404 });
399
+ },
400
+ });
401
+ if (!overrides.quiet) {
402
+ console.log(`[spark] ⚡ preview — http://localhost:${server.port}${base}`);
403
+ }
404
+ return server;
405
+ }
406
+
407
+ export default { dev, build, preview, loadConfig };