create-volt 0.37.0 → 0.38.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/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to `create-volt` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.38.0] - 2026-06-29
8
+
9
+ ### Added
10
+ - **Smart hot reload (live DOM morph).** Editing markdown / HTML / templates now
11
+ patches only the changed DOM nodes instead of a full page reload — focus, caret,
12
+ scroll, and untouched subtrees survive. CSS edits swap the stylesheet in place;
13
+ JS edits (and client-rendered `#app` pages) still do a full reload, and any morph
14
+ error falls back to one. The watcher tells the client which file changed.
15
+ - **Themes hot-reload in dev.** Editing `_theme.js` / `_theme.css` / a bundled
16
+ theme now reflects immediately — theme imports are mtime cache-busted in dev
17
+ (previously a restart was needed). Pages + posts share the live resolver
18
+ (`themeResolver`).
19
+
7
20
  ## [0.37.0] - 2026-06-29
8
21
 
9
22
  ### Added
@@ -495,6 +508,7 @@ All notable changes to `create-volt` are documented here. The format follows
495
508
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
496
509
  and auto-detects npm / pnpm / yarn / bun for the install step.
497
510
 
511
+ [0.38.0]: https://github.com/MIR-2025/volt/releases/tag/v0.38.0
498
512
  [0.37.0]: https://github.com/MIR-2025/volt/releases/tag/v0.37.0
499
513
  [0.36.0]: https://github.com/MIR-2025/volt/releases/tag/v0.36.0
500
514
  [0.35.0]: https://github.com/MIR-2025/volt/releases/tag/v0.35.0
@@ -123,6 +123,11 @@ function themeCss(dir) {
123
123
  // local pages/_theme.js; else the built-in default. A theme may `export const css`
124
124
  // (served at /_theme.css, shared with the editor); otherwise pages/_theme.css or
125
125
  // the default CSS is used.
126
+ const DEV = process.env.NODE_ENV !== "production";
127
+ // In dev, cache-bust theme imports by mtime so editing _theme.js shows up without
128
+ // a restart (ESM caches a given URL forever); unchanged files keep the same URL.
129
+ const freshUrl = (f) => pathToFileURL(f).href + (DEV ? "?t=" + fs.statSync(f).mtimeMs : "");
130
+
126
131
  export async function loadTheme(dir, env) {
127
132
  const wrap = (m) => {
128
133
  const layout = m && (m.layout || m.default);
@@ -132,7 +137,7 @@ export async function loadTheme(dir, env) {
132
137
  // a theme bundled by create-volt (.volt/themes/<name>/index.js) — no npm needed
133
138
  const bundled = path.resolve(dir, "..", ".volt", "themes", env.THEME, "index.js");
134
139
  if (fs.existsSync(bundled)) {
135
- const t = wrap(await import(pathToFileURL(bundled).href));
140
+ const t = wrap(await import(freshUrl(bundled)));
136
141
  if (t) return t;
137
142
  }
138
143
  for (const id of [`volt-theme-${env.THEME}`, env.THEME]) {
@@ -146,20 +151,27 @@ export async function loadTheme(dir, env) {
146
151
  }
147
152
  const local = path.join(dir, "_theme.js");
148
153
  if (fs.existsSync(local)) {
149
- const t = wrap(await import(pathToFileURL(local).href));
154
+ const t = wrap(await import(freshUrl(local)));
150
155
  if (t) return t;
151
156
  }
152
157
  return { layout: defaultLayout(dir), css: themeCss(dir) };
153
158
  }
154
159
 
160
+ // A theme getter that caches in production but re-resolves every call in dev, so
161
+ // theme edits hot-reload. Shared by pages + posts so they render the same theme.
162
+ export function themeResolver(dir) {
163
+ let cached = null;
164
+ return async () => (cached && !DEV ? cached : (cached = await loadTheme(dir, process.env)));
165
+ }
166
+
155
167
  export async function pagesRouter({ dir }) {
156
168
  const express = (await import("express")).default;
157
169
  const { marked } = await import("marked");
158
170
  ensure(dir);
159
- const { layout, css } = await loadTheme(dir, process.env);
171
+ const getTheme = themeResolver(dir);
160
172
  const r = express.Router();
161
- r.get("/_theme.css", (_req, res) => res.type("css").send(css));
162
- r.get("/:slug", (req, res, next) => {
173
+ r.get("/_theme.css", async (_req, res) => res.type("css").send((await getTheme()).css));
174
+ r.get("/:slug", async (req, res, next) => {
163
175
  const slug = req.params.slug;
164
176
  if (!isSafeSlug(slug)) return next(); // safe slug only — no traversal
165
177
  const file = path.join(dir, slug + ".md");
@@ -169,6 +181,7 @@ export async function pagesRouter({ dir }) {
169
181
  // preserve complex layouts; everything else is markdown rendered with marked.
170
182
  const content = meta.format === "html" ? body : marked.parse(body);
171
183
  const m = { ...meta, title: meta.title || slug };
184
+ const { layout } = await getTheme();
172
185
  res.type("html").send(layout({ title: m.title, head: metaHead(m), content, meta: m }));
173
186
  });
174
187
  return r;
@@ -6,7 +6,7 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  // express + marked are imported lazily in postsRouter() so the pure helpers load
8
8
  // without those deps. Theme + SEO come from the pages add-on (a dependency).
9
- import { parseFrontMatter, isSafeSlug, metaHead, loadTheme } from "../../../pages/files/lib/pages.js";
9
+ import { parseFrontMatter, isSafeSlug, metaHead, themeResolver } from "../../../pages/files/lib/pages.js";
10
10
 
11
11
  const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
12
12
  const slugify = (s) => String(s).toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -115,24 +115,25 @@ export async function postsRouter({ dir, themeDir }) {
115
115
  const express = (await import("express")).default;
116
116
  const { marked } = await import("marked");
117
117
  fs.mkdirSync(dir, { recursive: true });
118
- const { layout } = await loadTheme(themeDir || dir, process.env); // same theme as pages
118
+ const getTheme = themeResolver(themeDir || dir); // same theme as pages (live in dev)
119
119
  const PER = Math.max(1, Number(process.env.POSTS_PER_PAGE) || 10);
120
- const render = ({ title, content, meta = {} }) => {
120
+ const render = async ({ title, content, meta = {} }) => {
121
121
  const m = { ...meta, title };
122
+ const { layout } = await getTheme();
122
123
  return layout({ title, head: metaHead(m), content, meta: m });
123
124
  };
124
125
  const r = express.Router();
125
126
 
126
- r.get("/blog", (req, res) => {
127
+ r.get("/blog", async (req, res) => {
127
128
  const all = readPosts(dir);
128
129
  const page = Math.max(1, parseInt(req.query.page, 10) || 1);
129
130
  const totalPages = Math.max(1, Math.ceil(all.length / PER));
130
131
  const slice = all.slice((page - 1) * PER, page * PER);
131
132
  const heading = process.env.SITE_NAME || "Blog";
132
- res.type("html").send(render({ title: heading, meta: { description: heading + " — latest posts" }, content: renderList(slice, { heading, page, totalPages, baseUrl: "/blog" }) }));
133
+ res.type("html").send(await render({ title: heading, meta: { description: heading + " — latest posts" }, content: renderList(slice, { heading, page, totalPages, baseUrl: "/blog" }) }));
133
134
  });
134
135
 
135
- r.get("/blog/:slug", (req, res, next) => {
136
+ r.get("/blog/:slug", async (req, res, next) => {
136
137
  if (!isSafeSlug(req.params.slug)) return next();
137
138
  const post = readPosts(dir).find((p) => p.slug === req.params.slug);
138
139
  if (!post) return next();
@@ -145,7 +146,7 @@ export async function postsRouter({ dir, themeDir }) {
145
146
  ...(post.meta.author ? { author: { "@type": "Person", name: post.meta.author } } : {}),
146
147
  });
147
148
  res.type("html").send(
148
- render({
149
+ await render({
149
150
  title,
150
151
  meta: { ...post.meta, title, type: "article", jsonld: post.meta.jsonld || autoLd },
151
152
  content: renderPost(post, marked),
@@ -153,18 +154,18 @@ export async function postsRouter({ dir, themeDir }) {
153
154
  );
154
155
  });
155
156
 
156
- r.get("/category/:name", (req, res, next) => {
157
+ r.get("/category/:name", async (req, res, next) => {
157
158
  if (!isSafeSlug(req.params.name)) return next();
158
159
  const list = readPosts(dir).filter((p) => slugify(p.meta.category) === req.params.name);
159
160
  if (!list.length) return next();
160
- res.type("html").send(render({ title: "Category: " + req.params.name, content: renderList(list, { heading: "Category: " + req.params.name, baseUrl: "/category/" + req.params.name }) }));
161
+ res.type("html").send(await render({ title: "Category: " + req.params.name, content: renderList(list, { heading: "Category: " + req.params.name, baseUrl: "/category/" + req.params.name }) }));
161
162
  });
162
163
 
163
- r.get("/tag/:name", (req, res, next) => {
164
+ r.get("/tag/:name", async (req, res, next) => {
164
165
  if (!isSafeSlug(req.params.name)) return next();
165
166
  const list = readPosts(dir).filter((p) => tagsOf(p.meta).some((t) => slugify(t) === req.params.name));
166
167
  if (!list.length) return next();
167
- res.type("html").send(render({ title: "Tag: " + req.params.name, content: renderList(list, { heading: "Tag: " + req.params.name, baseUrl: "/tag/" + req.params.name }) }));
168
+ res.type("html").send(await render({ title: "Tag: " + req.params.name, content: renderList(list, { heading: "Tag: " + req.params.name, baseUrl: "/tag/" + req.params.name }) }));
168
169
  });
169
170
 
170
171
  r.get("/feed.xml", (_req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Scaffold a new Volt app — no-build, signals-based UI with Socket.io hot reload.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -262,11 +262,88 @@ function bindNodeHole(comment, value) {
262
262
 
263
263
  (function startHotReload() {
264
264
  if (typeof window === "undefined") return; // not a browser (SSR / Node imports / tests)
265
+
266
+ // Patch `from` to match `to` in place — only changed nodes are touched, so
267
+ // focus, caret, scroll, and untouched subtrees survive. Positional (no keys),
268
+ // which is plenty for a dev reload; the caller falls back to a full reload if
269
+ // anything throws.
270
+ const morph = (from, to) => {
271
+ const active = document.activeElement;
272
+ if (from.nodeType !== to.nodeType || from.nodeName !== to.nodeName) {
273
+ from.replaceWith(document.importNode(to, true));
274
+ return;
275
+ }
276
+ if (from.nodeType === 3 || from.nodeType === 8) {
277
+ if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue;
278
+ return;
279
+ }
280
+ if (from.nodeType !== 1) return;
281
+ for (const a of [...to.attributes]) {
282
+ if (from === active && a.name === "value") continue; // don't fight the typist
283
+ if (from.getAttribute(a.name) !== a.value) from.setAttribute(a.name, a.value);
284
+ }
285
+ for (const a of [...from.attributes]) if (!to.hasAttribute(a.name)) from.removeAttribute(a.name);
286
+ let f = from.firstChild,
287
+ t = to.firstChild;
288
+ while (f && t) {
289
+ const nf = f.nextSibling,
290
+ nt = t.nextSibling;
291
+ morph(f, t);
292
+ f = nf;
293
+ t = nt;
294
+ }
295
+ while (f) {
296
+ const nf = f.nextSibling;
297
+ from.removeChild(f);
298
+ f = nf;
299
+ }
300
+ while (t) {
301
+ const nt = t.nextSibling;
302
+ from.appendChild(document.importNode(t, true));
303
+ t = nt;
304
+ }
305
+ };
306
+
307
+ const bustStyles = () => {
308
+ for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
309
+ const u = new URL(link.getAttribute("href"), location.href);
310
+ u.searchParams.set("_hr", Date.now());
311
+ link.setAttribute("href", u.pathname + u.search);
312
+ }
313
+ };
314
+
315
+ let busy = false;
316
+ const onChange = async (info) => {
317
+ const file = (info && info.file) || "";
318
+ if (/\.css(\?|$)/i.test(file)) return bustStyles(); // pure CSS → swap, no reload
319
+ if (/\.(js|mjs)(\?|$)/i.test(file)) return location.reload(); // JS changed → must re-run
320
+ if (busy) return;
321
+ busy = true;
322
+ try {
323
+ const html = await (await fetch(location.href, { headers: { "x-volt-hot": "1" } })).text();
324
+ const doc = new DOMParser().parseFromString(html, "text/html");
325
+ // a client-rendered app (#app filled by JS, empty in server HTML) can't be
326
+ // morphed without wiping it — full reload instead.
327
+ const cur = document.querySelector("#app");
328
+ const next = doc.querySelector("#app");
329
+ if (cur && cur.children.length && next && !next.children.length) {
330
+ location.reload();
331
+ return;
332
+ }
333
+ morph(document.body, doc.body);
334
+ if (document.title !== doc.title) document.title = doc.title;
335
+ if (/_theme/.test(file)) bustStyles(); // theme/layout edit may change CSS too
336
+ } catch {
337
+ location.reload();
338
+ } finally {
339
+ busy = false;
340
+ }
341
+ };
342
+
265
343
  const connect = () => {
266
344
  if (!window.io) return false;
267
- const socket = window.io();
268
- socket.on("volt:reload", () => location.reload());
269
- console.log("[volt] hot reload connected");
345
+ window.io().on("volt:reload", onChange);
346
+ console.log("[volt] hot reload connected (live morph)");
270
347
  return true;
271
348
  };
272
349
  if (!connect()) window.addEventListener("load", connect);
@@ -205,7 +205,7 @@ async function startApp() {
205
205
  clearTimeout(timer);
206
206
  timer = setTimeout(() => {
207
207
  console.log(`[volt] change: ${file ?? "?"} → reload`);
208
- io.emit("volt:reload");
208
+ io.emit("volt:reload", { file });
209
209
  }, 80);
210
210
  };
211
211
  const watchRecursive = (dir) => {
@@ -262,11 +262,88 @@ function bindNodeHole(comment, value) {
262
262
 
263
263
  (function startHotReload() {
264
264
  if (typeof window === "undefined") return; // not a browser (SSR / Node imports / tests)
265
+
266
+ // Patch `from` to match `to` in place — only changed nodes are touched, so
267
+ // focus, caret, scroll, and untouched subtrees survive. Positional (no keys),
268
+ // which is plenty for a dev reload; the caller falls back to a full reload if
269
+ // anything throws.
270
+ const morph = (from, to) => {
271
+ const active = document.activeElement;
272
+ if (from.nodeType !== to.nodeType || from.nodeName !== to.nodeName) {
273
+ from.replaceWith(document.importNode(to, true));
274
+ return;
275
+ }
276
+ if (from.nodeType === 3 || from.nodeType === 8) {
277
+ if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue;
278
+ return;
279
+ }
280
+ if (from.nodeType !== 1) return;
281
+ for (const a of [...to.attributes]) {
282
+ if (from === active && a.name === "value") continue; // don't fight the typist
283
+ if (from.getAttribute(a.name) !== a.value) from.setAttribute(a.name, a.value);
284
+ }
285
+ for (const a of [...from.attributes]) if (!to.hasAttribute(a.name)) from.removeAttribute(a.name);
286
+ let f = from.firstChild,
287
+ t = to.firstChild;
288
+ while (f && t) {
289
+ const nf = f.nextSibling,
290
+ nt = t.nextSibling;
291
+ morph(f, t);
292
+ f = nf;
293
+ t = nt;
294
+ }
295
+ while (f) {
296
+ const nf = f.nextSibling;
297
+ from.removeChild(f);
298
+ f = nf;
299
+ }
300
+ while (t) {
301
+ const nt = t.nextSibling;
302
+ from.appendChild(document.importNode(t, true));
303
+ t = nt;
304
+ }
305
+ };
306
+
307
+ const bustStyles = () => {
308
+ for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
309
+ const u = new URL(link.getAttribute("href"), location.href);
310
+ u.searchParams.set("_hr", Date.now());
311
+ link.setAttribute("href", u.pathname + u.search);
312
+ }
313
+ };
314
+
315
+ let busy = false;
316
+ const onChange = async (info) => {
317
+ const file = (info && info.file) || "";
318
+ if (/\.css(\?|$)/i.test(file)) return bustStyles(); // pure CSS → swap, no reload
319
+ if (/\.(js|mjs)(\?|$)/i.test(file)) return location.reload(); // JS changed → must re-run
320
+ if (busy) return;
321
+ busy = true;
322
+ try {
323
+ const html = await (await fetch(location.href, { headers: { "x-volt-hot": "1" } })).text();
324
+ const doc = new DOMParser().parseFromString(html, "text/html");
325
+ // a client-rendered app (#app filled by JS, empty in server HTML) can't be
326
+ // morphed without wiping it — full reload instead.
327
+ const cur = document.querySelector("#app");
328
+ const next = doc.querySelector("#app");
329
+ if (cur && cur.children.length && next && !next.children.length) {
330
+ location.reload();
331
+ return;
332
+ }
333
+ morph(document.body, doc.body);
334
+ if (document.title !== doc.title) document.title = doc.title;
335
+ if (/_theme/.test(file)) bustStyles(); // theme/layout edit may change CSS too
336
+ } catch {
337
+ location.reload();
338
+ } finally {
339
+ busy = false;
340
+ }
341
+ };
342
+
265
343
  const connect = () => {
266
344
  if (!window.io) return false;
267
- const socket = window.io();
268
- socket.on("volt:reload", () => location.reload());
269
- console.log("[volt] hot reload connected");
345
+ window.io().on("volt:reload", onChange);
346
+ console.log("[volt] hot reload connected (live morph)");
270
347
  return true;
271
348
  };
272
349
  if (!connect()) window.addEventListener("load", connect);
@@ -205,7 +205,7 @@ async function startApp() {
205
205
  clearTimeout(timer);
206
206
  timer = setTimeout(() => {
207
207
  console.log(`[volt] change: ${file ?? "?"} → reload`);
208
- io.emit("volt:reload");
208
+ io.emit("volt:reload", { file });
209
209
  }, 80);
210
210
  };
211
211
  const watchRecursive = (dir) => {
@@ -262,11 +262,88 @@ function bindNodeHole(comment, value) {
262
262
 
263
263
  (function startHotReload() {
264
264
  if (typeof window === "undefined") return; // not a browser (SSR / Node imports / tests)
265
+
266
+ // Patch `from` to match `to` in place — only changed nodes are touched, so
267
+ // focus, caret, scroll, and untouched subtrees survive. Positional (no keys),
268
+ // which is plenty for a dev reload; the caller falls back to a full reload if
269
+ // anything throws.
270
+ const morph = (from, to) => {
271
+ const active = document.activeElement;
272
+ if (from.nodeType !== to.nodeType || from.nodeName !== to.nodeName) {
273
+ from.replaceWith(document.importNode(to, true));
274
+ return;
275
+ }
276
+ if (from.nodeType === 3 || from.nodeType === 8) {
277
+ if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue;
278
+ return;
279
+ }
280
+ if (from.nodeType !== 1) return;
281
+ for (const a of [...to.attributes]) {
282
+ if (from === active && a.name === "value") continue; // don't fight the typist
283
+ if (from.getAttribute(a.name) !== a.value) from.setAttribute(a.name, a.value);
284
+ }
285
+ for (const a of [...from.attributes]) if (!to.hasAttribute(a.name)) from.removeAttribute(a.name);
286
+ let f = from.firstChild,
287
+ t = to.firstChild;
288
+ while (f && t) {
289
+ const nf = f.nextSibling,
290
+ nt = t.nextSibling;
291
+ morph(f, t);
292
+ f = nf;
293
+ t = nt;
294
+ }
295
+ while (f) {
296
+ const nf = f.nextSibling;
297
+ from.removeChild(f);
298
+ f = nf;
299
+ }
300
+ while (t) {
301
+ const nt = t.nextSibling;
302
+ from.appendChild(document.importNode(t, true));
303
+ t = nt;
304
+ }
305
+ };
306
+
307
+ const bustStyles = () => {
308
+ for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
309
+ const u = new URL(link.getAttribute("href"), location.href);
310
+ u.searchParams.set("_hr", Date.now());
311
+ link.setAttribute("href", u.pathname + u.search);
312
+ }
313
+ };
314
+
315
+ let busy = false;
316
+ const onChange = async (info) => {
317
+ const file = (info && info.file) || "";
318
+ if (/\.css(\?|$)/i.test(file)) return bustStyles(); // pure CSS → swap, no reload
319
+ if (/\.(js|mjs)(\?|$)/i.test(file)) return location.reload(); // JS changed → must re-run
320
+ if (busy) return;
321
+ busy = true;
322
+ try {
323
+ const html = await (await fetch(location.href, { headers: { "x-volt-hot": "1" } })).text();
324
+ const doc = new DOMParser().parseFromString(html, "text/html");
325
+ // a client-rendered app (#app filled by JS, empty in server HTML) can't be
326
+ // morphed without wiping it — full reload instead.
327
+ const cur = document.querySelector("#app");
328
+ const next = doc.querySelector("#app");
329
+ if (cur && cur.children.length && next && !next.children.length) {
330
+ location.reload();
331
+ return;
332
+ }
333
+ morph(document.body, doc.body);
334
+ if (document.title !== doc.title) document.title = doc.title;
335
+ if (/_theme/.test(file)) bustStyles(); // theme/layout edit may change CSS too
336
+ } catch {
337
+ location.reload();
338
+ } finally {
339
+ busy = false;
340
+ }
341
+ };
342
+
265
343
  const connect = () => {
266
344
  if (!window.io) return false;
267
- const socket = window.io();
268
- socket.on("volt:reload", () => location.reload());
269
- console.log("[volt] hot reload connected");
345
+ window.io().on("volt:reload", onChange);
346
+ console.log("[volt] hot reload connected (live morph)");
270
347
  return true;
271
348
  };
272
349
  if (!connect()) window.addEventListener("load", connect);
@@ -205,7 +205,7 @@ async function startApp() {
205
205
  clearTimeout(timer);
206
206
  timer = setTimeout(() => {
207
207
  console.log(`[volt] change: ${file ?? "?"} → reload`);
208
- io.emit("volt:reload");
208
+ io.emit("volt:reload", { file });
209
209
  }, 80);
210
210
  };
211
211
  const watchRecursive = (dir) => {
@@ -262,11 +262,88 @@ function bindNodeHole(comment, value) {
262
262
 
263
263
  (function startHotReload() {
264
264
  if (typeof window === "undefined") return; // not a browser (SSR / Node imports / tests)
265
+
266
+ // Patch `from` to match `to` in place — only changed nodes are touched, so
267
+ // focus, caret, scroll, and untouched subtrees survive. Positional (no keys),
268
+ // which is plenty for a dev reload; the caller falls back to a full reload if
269
+ // anything throws.
270
+ const morph = (from, to) => {
271
+ const active = document.activeElement;
272
+ if (from.nodeType !== to.nodeType || from.nodeName !== to.nodeName) {
273
+ from.replaceWith(document.importNode(to, true));
274
+ return;
275
+ }
276
+ if (from.nodeType === 3 || from.nodeType === 8) {
277
+ if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue;
278
+ return;
279
+ }
280
+ if (from.nodeType !== 1) return;
281
+ for (const a of [...to.attributes]) {
282
+ if (from === active && a.name === "value") continue; // don't fight the typist
283
+ if (from.getAttribute(a.name) !== a.value) from.setAttribute(a.name, a.value);
284
+ }
285
+ for (const a of [...from.attributes]) if (!to.hasAttribute(a.name)) from.removeAttribute(a.name);
286
+ let f = from.firstChild,
287
+ t = to.firstChild;
288
+ while (f && t) {
289
+ const nf = f.nextSibling,
290
+ nt = t.nextSibling;
291
+ morph(f, t);
292
+ f = nf;
293
+ t = nt;
294
+ }
295
+ while (f) {
296
+ const nf = f.nextSibling;
297
+ from.removeChild(f);
298
+ f = nf;
299
+ }
300
+ while (t) {
301
+ const nt = t.nextSibling;
302
+ from.appendChild(document.importNode(t, true));
303
+ t = nt;
304
+ }
305
+ };
306
+
307
+ const bustStyles = () => {
308
+ for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
309
+ const u = new URL(link.getAttribute("href"), location.href);
310
+ u.searchParams.set("_hr", Date.now());
311
+ link.setAttribute("href", u.pathname + u.search);
312
+ }
313
+ };
314
+
315
+ let busy = false;
316
+ const onChange = async (info) => {
317
+ const file = (info && info.file) || "";
318
+ if (/\.css(\?|$)/i.test(file)) return bustStyles(); // pure CSS → swap, no reload
319
+ if (/\.(js|mjs)(\?|$)/i.test(file)) return location.reload(); // JS changed → must re-run
320
+ if (busy) return;
321
+ busy = true;
322
+ try {
323
+ const html = await (await fetch(location.href, { headers: { "x-volt-hot": "1" } })).text();
324
+ const doc = new DOMParser().parseFromString(html, "text/html");
325
+ // a client-rendered app (#app filled by JS, empty in server HTML) can't be
326
+ // morphed without wiping it — full reload instead.
327
+ const cur = document.querySelector("#app");
328
+ const next = doc.querySelector("#app");
329
+ if (cur && cur.children.length && next && !next.children.length) {
330
+ location.reload();
331
+ return;
332
+ }
333
+ morph(document.body, doc.body);
334
+ if (document.title !== doc.title) document.title = doc.title;
335
+ if (/_theme/.test(file)) bustStyles(); // theme/layout edit may change CSS too
336
+ } catch {
337
+ location.reload();
338
+ } finally {
339
+ busy = false;
340
+ }
341
+ };
342
+
265
343
  const connect = () => {
266
344
  if (!window.io) return false;
267
- const socket = window.io();
268
- socket.on("volt:reload", () => location.reload());
269
- console.log("[volt] hot reload connected");
345
+ window.io().on("volt:reload", onChange);
346
+ console.log("[volt] hot reload connected (live morph)");
270
347
  return true;
271
348
  };
272
349
  if (!connect()) window.addEventListener("load", connect);
@@ -262,11 +262,88 @@ function bindNodeHole(comment, value) {
262
262
 
263
263
  (function startHotReload() {
264
264
  if (typeof window === "undefined") return; // not a browser (SSR / Node imports / tests)
265
+
266
+ // Patch `from` to match `to` in place — only changed nodes are touched, so
267
+ // focus, caret, scroll, and untouched subtrees survive. Positional (no keys),
268
+ // which is plenty for a dev reload; the caller falls back to a full reload if
269
+ // anything throws.
270
+ const morph = (from, to) => {
271
+ const active = document.activeElement;
272
+ if (from.nodeType !== to.nodeType || from.nodeName !== to.nodeName) {
273
+ from.replaceWith(document.importNode(to, true));
274
+ return;
275
+ }
276
+ if (from.nodeType === 3 || from.nodeType === 8) {
277
+ if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue;
278
+ return;
279
+ }
280
+ if (from.nodeType !== 1) return;
281
+ for (const a of [...to.attributes]) {
282
+ if (from === active && a.name === "value") continue; // don't fight the typist
283
+ if (from.getAttribute(a.name) !== a.value) from.setAttribute(a.name, a.value);
284
+ }
285
+ for (const a of [...from.attributes]) if (!to.hasAttribute(a.name)) from.removeAttribute(a.name);
286
+ let f = from.firstChild,
287
+ t = to.firstChild;
288
+ while (f && t) {
289
+ const nf = f.nextSibling,
290
+ nt = t.nextSibling;
291
+ morph(f, t);
292
+ f = nf;
293
+ t = nt;
294
+ }
295
+ while (f) {
296
+ const nf = f.nextSibling;
297
+ from.removeChild(f);
298
+ f = nf;
299
+ }
300
+ while (t) {
301
+ const nt = t.nextSibling;
302
+ from.appendChild(document.importNode(t, true));
303
+ t = nt;
304
+ }
305
+ };
306
+
307
+ const bustStyles = () => {
308
+ for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
309
+ const u = new URL(link.getAttribute("href"), location.href);
310
+ u.searchParams.set("_hr", Date.now());
311
+ link.setAttribute("href", u.pathname + u.search);
312
+ }
313
+ };
314
+
315
+ let busy = false;
316
+ const onChange = async (info) => {
317
+ const file = (info && info.file) || "";
318
+ if (/\.css(\?|$)/i.test(file)) return bustStyles(); // pure CSS → swap, no reload
319
+ if (/\.(js|mjs)(\?|$)/i.test(file)) return location.reload(); // JS changed → must re-run
320
+ if (busy) return;
321
+ busy = true;
322
+ try {
323
+ const html = await (await fetch(location.href, { headers: { "x-volt-hot": "1" } })).text();
324
+ const doc = new DOMParser().parseFromString(html, "text/html");
325
+ // a client-rendered app (#app filled by JS, empty in server HTML) can't be
326
+ // morphed without wiping it — full reload instead.
327
+ const cur = document.querySelector("#app");
328
+ const next = doc.querySelector("#app");
329
+ if (cur && cur.children.length && next && !next.children.length) {
330
+ location.reload();
331
+ return;
332
+ }
333
+ morph(document.body, doc.body);
334
+ if (document.title !== doc.title) document.title = doc.title;
335
+ if (/_theme/.test(file)) bustStyles(); // theme/layout edit may change CSS too
336
+ } catch {
337
+ location.reload();
338
+ } finally {
339
+ busy = false;
340
+ }
341
+ };
342
+
265
343
  const connect = () => {
266
344
  if (!window.io) return false;
267
- const socket = window.io();
268
- socket.on("volt:reload", () => location.reload());
269
- console.log("[volt] hot reload connected");
345
+ window.io().on("volt:reload", onChange);
346
+ console.log("[volt] hot reload connected (live morph)");
270
347
  return true;
271
348
  };
272
349
  if (!connect()) window.addEventListener("load", connect);
@@ -231,7 +231,7 @@ async function startApp() {
231
231
  clearTimeout(timer);
232
232
  timer = setTimeout(() => {
233
233
  console.log(`[volt] change: ${file ?? "?"} → reload`);
234
- io.emit("volt:reload");
234
+ io.emit("volt:reload", { file });
235
235
  }, 80);
236
236
  };
237
237
  const watchRecursive = (dir) => {