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 +14 -0
- package/addons/pages/files/lib/pages.js +18 -5
- package/addons/posts/files/lib/posts.js +12 -11
- package/package.json +1 -1
- package/templates/blog/public/volt.js +80 -3
- package/templates/blog/server.js +1 -1
- package/templates/default/public/volt.js +80 -3
- package/templates/default/server.js +1 -1
- package/templates/docs/public/volt.js +80 -3
- package/templates/docs/server.js +1 -1
- package/templates/guestbook/public/volt.js +80 -3
- package/templates/starter/public/volt.js +80 -3
- package/templates/starter/server.js +1 -1
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(
|
|
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(
|
|
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
|
|
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,
|
|
9
|
+
import { parseFrontMatter, isSafeSlug, metaHead, themeResolver } from "../../../pages/files/lib/pages.js";
|
|
10
10
|
|
|
11
11
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
|
|
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
|
@@ -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
|
-
|
|
268
|
-
|
|
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);
|
package/templates/blog/server.js
CHANGED
|
@@ -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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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);
|
package/templates/docs/server.js
CHANGED
|
@@ -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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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) => {
|