create-volt 0.37.0 → 0.39.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,29 @@ 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.39.0] - 2026-06-29
8
+
9
+ ### Fixed
10
+ - **Hot reload now reaches content pages.** Pages/posts are server-rendered HTML
11
+ that dont load `volt.js`, so the 0.38 morph client never ran on them. In dev,
12
+ the `pages`/`posts` add-ons now inject the hot-reload client (socket.io +
13
+ `volt.js`) into every served page. Verified with Chromium: editing a post
14
+ morphs the DOM in place — scroll position and page state preserved, no full
15
+ reload. Nothing is injected in production.
16
+
17
+ ## [0.38.0] - 2026-06-29
18
+
19
+ ### Added
20
+ - **Smart hot reload (live DOM morph).** Editing markdown / HTML / templates now
21
+ patches only the changed DOM nodes instead of a full page reload — focus, caret,
22
+ scroll, and untouched subtrees survive. CSS edits swap the stylesheet in place;
23
+ JS edits (and client-rendered `#app` pages) still do a full reload, and any morph
24
+ error falls back to one. The watcher tells the client which file changed.
25
+ - **Themes hot-reload in dev.** Editing `_theme.js` / `_theme.css` / a bundled
26
+ theme now reflects immediately — theme imports are mtime cache-busted in dev
27
+ (previously a restart was needed). Pages + posts share the live resolver
28
+ (`themeResolver`).
29
+
7
30
  ## [0.37.0] - 2026-06-29
8
31
 
9
32
  ### Added
@@ -495,6 +518,8 @@ All notable changes to `create-volt` are documented here. The format follows
495
518
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
496
519
  and auto-detects npm / pnpm / yarn / bun for the install step.
497
520
 
521
+ [0.39.0]: https://github.com/MIR-2025/volt/releases/tag/v0.39.0
522
+ [0.38.0]: https://github.com/MIR-2025/volt/releases/tag/v0.38.0
498
523
  [0.37.0]: https://github.com/MIR-2025/volt/releases/tag/v0.37.0
499
524
  [0.36.0]: https://github.com/MIR-2025/volt/releases/tag/v0.36.0
500
525
  [0.35.0]: https://github.com/MIR-2025/volt/releases/tag/v0.35.0
@@ -123,6 +123,18 @@ 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
+
131
+ // In dev, inject the hot-reload client into served pages — content pages don't
132
+ // otherwise load volt.js, so they'd never receive reload/morph events. socket.io
133
+ // serves its client at /socket.io/socket.io.js; volt.js (a module) runs the
134
+ // hot-reload IIFE. Nothing is injected in production.
135
+ const HOT = DEV ? '\n<script src="/socket.io/socket.io.js"></script><script type="module" src="/volt.js"></script>\n' : "";
136
+ export const injectHot = (html) => (!HOT ? html : html.includes("</body>") ? html.replace("</body>", HOT + "</body>") : html + HOT);
137
+
126
138
  export async function loadTheme(dir, env) {
127
139
  const wrap = (m) => {
128
140
  const layout = m && (m.layout || m.default);
@@ -132,7 +144,7 @@ export async function loadTheme(dir, env) {
132
144
  // a theme bundled by create-volt (.volt/themes/<name>/index.js) — no npm needed
133
145
  const bundled = path.resolve(dir, "..", ".volt", "themes", env.THEME, "index.js");
134
146
  if (fs.existsSync(bundled)) {
135
- const t = wrap(await import(pathToFileURL(bundled).href));
147
+ const t = wrap(await import(freshUrl(bundled)));
136
148
  if (t) return t;
137
149
  }
138
150
  for (const id of [`volt-theme-${env.THEME}`, env.THEME]) {
@@ -146,20 +158,27 @@ export async function loadTheme(dir, env) {
146
158
  }
147
159
  const local = path.join(dir, "_theme.js");
148
160
  if (fs.existsSync(local)) {
149
- const t = wrap(await import(pathToFileURL(local).href));
161
+ const t = wrap(await import(freshUrl(local)));
150
162
  if (t) return t;
151
163
  }
152
164
  return { layout: defaultLayout(dir), css: themeCss(dir) };
153
165
  }
154
166
 
167
+ // A theme getter that caches in production but re-resolves every call in dev, so
168
+ // theme edits hot-reload. Shared by pages + posts so they render the same theme.
169
+ export function themeResolver(dir) {
170
+ let cached = null;
171
+ return async () => (cached && !DEV ? cached : (cached = await loadTheme(dir, process.env)));
172
+ }
173
+
155
174
  export async function pagesRouter({ dir }) {
156
175
  const express = (await import("express")).default;
157
176
  const { marked } = await import("marked");
158
177
  ensure(dir);
159
- const { layout, css } = await loadTheme(dir, process.env);
178
+ const getTheme = themeResolver(dir);
160
179
  const r = express.Router();
161
- r.get("/_theme.css", (_req, res) => res.type("css").send(css));
162
- r.get("/:slug", (req, res, next) => {
180
+ r.get("/_theme.css", async (_req, res) => res.type("css").send((await getTheme()).css));
181
+ r.get("/:slug", async (req, res, next) => {
163
182
  const slug = req.params.slug;
164
183
  if (!isSafeSlug(slug)) return next(); // safe slug only — no traversal
165
184
  const file = path.join(dir, slug + ".md");
@@ -169,7 +188,8 @@ export async function pagesRouter({ dir }) {
169
188
  // preserve complex layouts; everything else is markdown rendered with marked.
170
189
  const content = meta.format === "html" ? body : marked.parse(body);
171
190
  const m = { ...meta, title: meta.title || slug };
172
- res.type("html").send(layout({ title: m.title, head: metaHead(m), content, meta: m }));
191
+ const { layout } = await getTheme();
192
+ res.type("html").send(injectHot(layout({ title: m.title, head: metaHead(m), content, meta: m })));
173
193
  });
174
194
  return r;
175
195
  }
@@ -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, injectHot } 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
- return layout({ title, head: metaHead(m), content, meta: m });
122
+ const { layout } = await getTheme();
123
+ return injectHot(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.39.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) => {