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 +25 -0
- package/addons/pages/files/lib/pages.js +26 -6
- package/addons/posts/files/lib/posts.js +13 -12
- 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,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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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,
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
|
-
|
|
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
|
@@ -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) => {
|