create-volt 0.36.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 +31 -0
- package/addons/pages/files/lib/pages.js +29 -4
- package/addons/posts/files/lib/posts.js +12 -11
- package/index.js +2 -1
- package/package.json +1 -1
- package/templates/blog/public/volt.js +80 -3
- package/templates/blog/server.js +42 -3
- package/templates/blog/setup/setup.js +67 -1
- package/templates/default/public/volt.js +80 -3
- package/templates/default/server.js +42 -3
- package/templates/default/setup/setup.js +67 -1
- package/templates/docs/public/volt.js +80 -3
- package/templates/docs/server.js +42 -3
- package/templates/docs/setup/setup.js +67 -1
- package/templates/guestbook/public/volt.js +80 -3
- package/templates/starter/public/volt.js +80 -3
- package/templates/starter/server.js +42 -3
- package/templates/starter/setup/setup.js +67 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ 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
|
+
|
|
20
|
+
## [0.37.0] - 2026-06-29
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **Bundled themes + a wizard theme picker.** create-volt ships paper/midnight/
|
|
24
|
+
classic under `.volt/themes`; the setup wizard has a **Theme** dropdown (no npm
|
|
25
|
+
needed) with a **Customize** button that copies the chosen theme to
|
|
26
|
+
`pages/_theme.js` for editing. `THEME` now also resolves bundled themes.
|
|
27
|
+
- **More wizard settings:** `SITE_NAME`, `SITE_URL`, `CONFIG_PORT`, and optional
|
|
28
|
+
**AI keys** (`AI_PROVIDER` + Anthropic/OpenAI/Gemini key — written to `.env`,
|
|
29
|
+
kept server-side).
|
|
30
|
+
- **`CONFIG_PORT` defaults to 5050** for the `--edit`/`--studio` config UI, so it
|
|
31
|
+
never collides with a running app.
|
|
32
|
+
- **Inject `<script>` tags** for third-party libs: per-page front-matter
|
|
33
|
+
`scripts:` (comma-separated URLs) and/or a site-wide `SITE_SCRIPTS` env, loaded
|
|
34
|
+
deferred (works on pages + posts).
|
|
35
|
+
|
|
7
36
|
## [0.36.0] - 2026-06-29
|
|
8
37
|
|
|
9
38
|
### Added
|
|
@@ -479,6 +508,8 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
479
508
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
480
509
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
481
510
|
|
|
511
|
+
[0.38.0]: https://github.com/MIR-2025/volt/releases/tag/v0.38.0
|
|
512
|
+
[0.37.0]: https://github.com/MIR-2025/volt/releases/tag/v0.37.0
|
|
482
513
|
[0.36.0]: https://github.com/MIR-2025/volt/releases/tag/v0.36.0
|
|
483
514
|
[0.35.0]: https://github.com/MIR-2025/volt/releases/tag/v0.35.0
|
|
484
515
|
[0.34.0]: https://github.com/MIR-2025/volt/releases/tag/v0.34.0
|
|
@@ -77,6 +77,12 @@ export function metaHead(meta) {
|
|
|
77
77
|
}
|
|
78
78
|
if (ok) t.push(`<script type="application/ld+json">${meta.jsonld.replace(/</g, "\\u003c")}</script>`);
|
|
79
79
|
}
|
|
80
|
+
// custom <script> tags for third-party libs: per-page front-matter `scripts:`
|
|
81
|
+
// (comma-separated URLs) and/or a site-wide SITE_SCRIPTS env. Loaded deferred.
|
|
82
|
+
const scripts = [process.env.SITE_SCRIPTS, meta.scripts].filter(Boolean).join(",");
|
|
83
|
+
for (const url of scripts.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
84
|
+
t.push(`<script src="${esc(url)}" defer></script>`);
|
|
85
|
+
}
|
|
80
86
|
return t.join("\n");
|
|
81
87
|
}
|
|
82
88
|
|
|
@@ -117,12 +123,23 @@ function themeCss(dir) {
|
|
|
117
123
|
// local pages/_theme.js; else the built-in default. A theme may `export const css`
|
|
118
124
|
// (served at /_theme.css, shared with the editor); otherwise pages/_theme.css or
|
|
119
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
|
+
|
|
120
131
|
export async function loadTheme(dir, env) {
|
|
121
132
|
const wrap = (m) => {
|
|
122
133
|
const layout = m && (m.layout || m.default);
|
|
123
134
|
return layout ? { layout, css: m.css || themeCss(dir) } : null;
|
|
124
135
|
};
|
|
125
136
|
if (env.THEME) {
|
|
137
|
+
// a theme bundled by create-volt (.volt/themes/<name>/index.js) — no npm needed
|
|
138
|
+
const bundled = path.resolve(dir, "..", ".volt", "themes", env.THEME, "index.js");
|
|
139
|
+
if (fs.existsSync(bundled)) {
|
|
140
|
+
const t = wrap(await import(freshUrl(bundled)));
|
|
141
|
+
if (t) return t;
|
|
142
|
+
}
|
|
126
143
|
for (const id of [`volt-theme-${env.THEME}`, env.THEME]) {
|
|
127
144
|
try {
|
|
128
145
|
const t = wrap(await import(id));
|
|
@@ -134,20 +151,27 @@ export async function loadTheme(dir, env) {
|
|
|
134
151
|
}
|
|
135
152
|
const local = path.join(dir, "_theme.js");
|
|
136
153
|
if (fs.existsSync(local)) {
|
|
137
|
-
const t = wrap(await import(
|
|
154
|
+
const t = wrap(await import(freshUrl(local)));
|
|
138
155
|
if (t) return t;
|
|
139
156
|
}
|
|
140
157
|
return { layout: defaultLayout(dir), css: themeCss(dir) };
|
|
141
158
|
}
|
|
142
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
|
+
|
|
143
167
|
export async function pagesRouter({ dir }) {
|
|
144
168
|
const express = (await import("express")).default;
|
|
145
169
|
const { marked } = await import("marked");
|
|
146
170
|
ensure(dir);
|
|
147
|
-
const
|
|
171
|
+
const getTheme = themeResolver(dir);
|
|
148
172
|
const r = express.Router();
|
|
149
|
-
r.get("/_theme.css", (_req, res) => res.type("css").send(css));
|
|
150
|
-
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) => {
|
|
151
175
|
const slug = req.params.slug;
|
|
152
176
|
if (!isSafeSlug(slug)) return next(); // safe slug only — no traversal
|
|
153
177
|
const file = path.join(dir, slug + ".md");
|
|
@@ -157,6 +181,7 @@ export async function pagesRouter({ dir }) {
|
|
|
157
181
|
// preserve complex layouts; everything else is markdown rendered with marked.
|
|
158
182
|
const content = meta.format === "html" ? body : marked.parse(body);
|
|
159
183
|
const m = { ...meta, title: meta.title || slug };
|
|
184
|
+
const { layout } = await getTheme();
|
|
160
185
|
res.type("html").send(layout({ title: m.title, head: metaHead(m), content, meta: m }));
|
|
161
186
|
});
|
|
162
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/index.js
CHANGED
|
@@ -466,6 +466,7 @@ if (fs.existsSync(shippedDockerignore)) {
|
|
|
466
466
|
// (only for templates that ship the wizard, i.e. have a setup/ dir).
|
|
467
467
|
if (fs.existsSync(path.join(targetDir, "setup"))) {
|
|
468
468
|
fs.cpSync(path.join(__dirname, "addons"), path.join(targetDir, ".volt", "addons"), { recursive: true });
|
|
469
|
+
fs.cpSync(path.join(__dirname, "themes"), path.join(targetDir, ".volt", "themes"), { recursive: true }); // bundled themes the wizard can pick
|
|
469
470
|
}
|
|
470
471
|
|
|
471
472
|
// --- stamp the project name into package.json ---
|
|
@@ -549,7 +550,7 @@ console.log(`\n${green("✔")} ${bold("Done!")} Next steps:\n`);
|
|
|
549
550
|
console.log(` ${cyan("cd")} ${projectName}`);
|
|
550
551
|
if (!installed) console.log(` ${cyan(installCmd)}`);
|
|
551
552
|
console.log(` ${cyan(runCmd)}`);
|
|
552
|
-
console.log(`\nFirst run opens a quick ${bold("setup")} page at ${cyan("http://localhost:"
|
|
553
|
+
console.log(`\nFirst run opens a quick ${bold("setup")} page at ${cyan("http://localhost:5050")}; your app then runs at ${cyan("http://localhost:" + port)}.\n`);
|
|
553
554
|
|
|
554
555
|
if (flags.has("--start")) {
|
|
555
556
|
if (!installed) {
|
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
|
@@ -20,7 +20,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
20
20
|
const ENV_PATH = path.join(__dirname, ".env");
|
|
21
21
|
const PKG_PATH = path.join(__dirname, "package.json");
|
|
22
22
|
const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
|
|
23
|
+
const THEMES_DIR = path.join(__dirname, ".volt", "themes"); // bundled themes the wizard can pick
|
|
23
24
|
const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
|
|
25
|
+
const CONFIG_DEFAULT_PORT = 5050; // the --edit/--studio config UI's default port (its own, so it never clashes with a running app)
|
|
24
26
|
|
|
25
27
|
// `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
|
|
26
28
|
// --edit/--studio dodge a port the running app already holds, and runs the app
|
|
@@ -37,7 +39,7 @@ function cliPort() {
|
|
|
37
39
|
// then the app's PORT, then the date-port.
|
|
38
40
|
function configPort() {
|
|
39
41
|
const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
|
|
40
|
-
return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) ||
|
|
42
|
+
return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
|
|
41
43
|
}
|
|
42
44
|
const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
|
|
43
45
|
const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
|
|
@@ -68,6 +70,23 @@ function availableAddons() {
|
|
|
68
70
|
});
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
// Themes bundled by create-volt (under .volt/themes), pickable in the wizard.
|
|
74
|
+
function availableThemes() {
|
|
75
|
+
if (!fs.existsSync(THEMES_DIR)) return [];
|
|
76
|
+
return fs
|
|
77
|
+
.readdirSync(THEMES_DIR, { withFileTypes: true })
|
|
78
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(THEMES_DIR, e.name, "index.js")))
|
|
79
|
+
.map((e) => {
|
|
80
|
+
let description = "";
|
|
81
|
+
try {
|
|
82
|
+
description = JSON.parse(fs.readFileSync(path.join(THEMES_DIR, e.name, "meta.json"), "utf8")).description;
|
|
83
|
+
} catch {
|
|
84
|
+
/* no meta */
|
|
85
|
+
}
|
|
86
|
+
return { name: e.name, description };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
71
90
|
// Which add-ons does VOLT_ADDONS turn on (dependencies expanded)?
|
|
72
91
|
function enabledFrom(env) {
|
|
73
92
|
const metas = Object.fromEntries(availableAddons().map((a) => [a.name, a]));
|
|
@@ -186,7 +205,7 @@ async function startApp() {
|
|
|
186
205
|
clearTimeout(timer);
|
|
187
206
|
timer = setTimeout(() => {
|
|
188
207
|
console.log(`[volt] change: ${file ?? "?"} â reload`);
|
|
189
|
-
io.emit("volt:reload");
|
|
208
|
+
io.emit("volt:reload", { file });
|
|
190
209
|
}, 80);
|
|
191
210
|
};
|
|
192
211
|
const watchRecursive = (dir) => {
|
|
@@ -266,7 +285,27 @@ function startSetup() {
|
|
|
266
285
|
}
|
|
267
286
|
if (req.method === "GET" && p === "/setup/state") {
|
|
268
287
|
res.setHeader("Content-Type", "application/json");
|
|
269
|
-
return res.end(JSON.stringify({ available: availableAddons(), current: readEnvFile(), defaultPort: DEFAULT_PORT }));
|
|
288
|
+
return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
|
|
289
|
+
}
|
|
290
|
+
// "Customize": copy a bundled theme into pages/_theme.js so it can be edited.
|
|
291
|
+
if (req.method === "POST" && p === "/setup/eject-theme") {
|
|
292
|
+
let body = "";
|
|
293
|
+
req.on("data", (c) => (body += c));
|
|
294
|
+
req.on("end", () => {
|
|
295
|
+
res.setHeader("Content-Type", "application/json");
|
|
296
|
+
try {
|
|
297
|
+
const { theme } = JSON.parse(body || "{}");
|
|
298
|
+
const src = path.join(THEMES_DIR, String(theme || ""), "index.js");
|
|
299
|
+
if (!theme || !/^[a-z0-9-]+$/i.test(theme) || !fs.existsSync(src)) return res.end(JSON.stringify({ ok: false, error: "unknown theme" }));
|
|
300
|
+
const dest = path.join(__dirname, "pages", "_theme.js");
|
|
301
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
302
|
+
fs.copyFileSync(src, dest);
|
|
303
|
+
res.end(JSON.stringify({ ok: true, path: "pages/_theme.js" }));
|
|
304
|
+
} catch (e) {
|
|
305
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
270
309
|
}
|
|
271
310
|
if (req.method === "POST" && p === "/setup/test-db") {
|
|
272
311
|
let body = "";
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// just config.
|
|
5
5
|
import { signal, computed, html, mount } from "/volt.js";
|
|
6
6
|
|
|
7
|
-
const { available, current, defaultPort } = await (await fetch("/setup/state")).json();
|
|
7
|
+
const { available, themes = [], current, defaultPort, configDefaultPort = 5050 } = await (await fetch("/setup/state")).json();
|
|
8
8
|
const depsOf = Object.fromEntries(available.map((a) => [a.name, a.dependsOn || []]));
|
|
9
9
|
const order = available.map((a) => a.name);
|
|
10
10
|
const enabledNow = new Set(String(current.VOLT_ADDONS || "").split(",").map((s) => s.trim()).filter(Boolean));
|
|
@@ -28,6 +28,12 @@ const state = signal({
|
|
|
28
28
|
// detect the admin's timezone from their browser (the wizard runs here), so
|
|
29
29
|
// dates render in their zone — not the server's (usually UTC on a host).
|
|
30
30
|
tz: current.SITE_TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
|
31
|
+
siteName: current.SITE_NAME || "",
|
|
32
|
+
siteUrl: current.SITE_URL || "",
|
|
33
|
+
configPort: current.CONFIG_PORT || "",
|
|
34
|
+
theme: current.THEME || "",
|
|
35
|
+
aiProvider: current.AI_PROVIDER || "anthropic",
|
|
36
|
+
aiKey: current.ANTHROPIC_API_KEY || current.OPENAI_API_KEY || current.GEMINI_API_KEY || "",
|
|
31
37
|
});
|
|
32
38
|
const set = (patch) => state({ ...state(), ...patch });
|
|
33
39
|
const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
|
|
@@ -67,6 +73,15 @@ function genEnv(s) {
|
|
|
67
73
|
const eff = effective(s);
|
|
68
74
|
const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${clean(s.port)}`];
|
|
69
75
|
if (s.tz) out.push(`SITE_TZ=${clean(s.tz)}`); // admin's timezone, for date display
|
|
76
|
+
if (s.siteName) out.push(`SITE_NAME=${clean(s.siteName)}`);
|
|
77
|
+
if (s.siteUrl) out.push(`SITE_URL=${clean(s.siteUrl)}`);
|
|
78
|
+
if (s.configPort) out.push(`CONFIG_PORT=${clean(s.configPort)}`);
|
|
79
|
+
if ((eff.includes("pages") || eff.includes("posts")) && s.theme) out.push(`THEME=${clean(s.theme)}`);
|
|
80
|
+
if (s.aiKey) {
|
|
81
|
+
out.push(`AI_PROVIDER=${clean(s.aiProvider)}`);
|
|
82
|
+
const keyVar = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", gemini: "GEMINI_API_KEY" }[s.aiProvider] || "ANTHROPIC_API_KEY";
|
|
83
|
+
out.push(`${keyVar}=${clean(s.aiKey)}`);
|
|
84
|
+
}
|
|
70
85
|
if (eff.includes("db")) {
|
|
71
86
|
out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
|
|
72
87
|
if (s.dbDriver === "mongodb") {
|
|
@@ -104,6 +119,24 @@ const mediaDriver = computed(() => state().mediaDriver);
|
|
|
104
119
|
const hasDb = computed(() => eff().includes("db"));
|
|
105
120
|
const hasMailer = computed(() => eff().includes("mailer"));
|
|
106
121
|
const hasMedia = computed(() => eff().includes("media"));
|
|
122
|
+
const hasContent = computed(() => eff().includes("pages") || eff().includes("posts")); // themes apply to pages/posts
|
|
123
|
+
|
|
124
|
+
// "Customize": copy the selected bundled theme to pages/_theme.js, then use it
|
|
125
|
+
// locally (THEME cleared) so edits take effect.
|
|
126
|
+
async function ejectTheme() {
|
|
127
|
+
const theme = state().theme;
|
|
128
|
+
if (!theme) return;
|
|
129
|
+
status("Copying theme…");
|
|
130
|
+
try {
|
|
131
|
+
const r = await (await fetch("/setup/eject-theme", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ theme }) })).json();
|
|
132
|
+
if (r.ok) {
|
|
133
|
+
set({ theme: "" });
|
|
134
|
+
status(`Copied ${theme} → ${r.path}. Edit it freely; THEME was cleared so your local copy is used.`);
|
|
135
|
+
} else status("Error: " + (r.error || "?"));
|
|
136
|
+
} catch {
|
|
137
|
+
status("Network error copying theme.");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
107
140
|
|
|
108
141
|
async function testDb() {
|
|
109
142
|
const s = state();
|
|
@@ -201,6 +234,34 @@ const mediaSettings = () =>
|
|
|
201
234
|
? html`${field("S3_ENDPOINT", "s3Endpoint", "https://nyc3.digitaloceanspaces.com")}${field("S3_REGION", "s3Region", "us-east-1")}${field("S3_BUCKET", "s3Bucket", "my-space")}${field("S3_KEY", "s3Key", "access key")}${field("S3_SECRET", "s3Secret", "secret key")}${field("S3_PUBLIC_BASE (optional CDN base)", "s3PublicBase", "https://cdn.example.com")}`
|
|
202
235
|
: null}`;
|
|
203
236
|
|
|
237
|
+
// theme chooser: a bundled theme (or the built-in/local one), with Customize
|
|
238
|
+
const themePicker = () =>
|
|
239
|
+
html`<div class="mb-2">
|
|
240
|
+
<label class="form-label small mb-1">Theme (THEME)</label>
|
|
241
|
+
<select class="form-select" value=${() => state().theme} onchange=${(e) => set({ theme: e.target.value })}>
|
|
242
|
+
<option value="">default — built-in, or your pages/_theme.js</option>
|
|
243
|
+
${themes.map((t) => html`<option value=${t.name}>${t.name}${t.description ? " — " + t.description : ""}</option>`)}
|
|
244
|
+
</select>
|
|
245
|
+
${() =>
|
|
246
|
+
state().theme
|
|
247
|
+
? html`<button class="btn btn-sm btn-outline-secondary mt-1" onclick=${ejectTheme}>Customize → copy to pages/_theme.js</button>`
|
|
248
|
+
: html`<div class="small text-muted mt-1">Pick a starter theme, or keep the built-in / your local <code>pages/_theme.js</code>.</div>`}
|
|
249
|
+
</div>`;
|
|
250
|
+
|
|
251
|
+
// AI keys (optional) — used by the WYSIWYG editor's assistant. Kept server-side.
|
|
252
|
+
const aiSettings = () =>
|
|
253
|
+
html`<details class="mb-2"><summary class="form-label small mb-0" style="cursor:pointer">AI keys (optional) — for the editor's assistant</summary>
|
|
254
|
+
<div class="mt-2">
|
|
255
|
+
<label class="form-label small mb-1">Provider (AI_PROVIDER)</label>
|
|
256
|
+
<select class="form-select mb-2" value=${() => state().aiProvider} onchange=${(e) => set({ aiProvider: e.target.value })}>
|
|
257
|
+
<option value="anthropic">Anthropic (Claude)</option>
|
|
258
|
+
<option value="openai">OpenAI</option>
|
|
259
|
+
<option value="gemini">Google Gemini</option>
|
|
260
|
+
</select>
|
|
261
|
+
${field("API key — stays server-side, written to .env", "aiKey", "sk-…")}
|
|
262
|
+
</div>
|
|
263
|
+
</details>`;
|
|
264
|
+
|
|
204
265
|
mount(
|
|
205
266
|
"#app",
|
|
206
267
|
available.length
|
|
@@ -213,9 +274,14 @@ mount(
|
|
|
213
274
|
html`<div class="card-x p-4 mb-3">
|
|
214
275
|
<h2 class="h6 mb-3">Settings</h2>
|
|
215
276
|
${field("PORT", "port", String(defaultPort))}
|
|
277
|
+
${field("SITE_NAME", "siteName", "My Site")}
|
|
278
|
+
${() => (hasContent() ? themePicker() : null)}
|
|
216
279
|
${() => (hasDb() ? dbSettings() : null)}
|
|
217
280
|
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
|
|
218
281
|
${() => (hasMedia() ? mediaSettings() : null)}
|
|
282
|
+
${aiSettings()}
|
|
283
|
+
${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
|
|
284
|
+
${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
|
|
219
285
|
</div>`,
|
|
220
286
|
html`<div class="card-x p-4 mb-3">
|
|
221
287
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
@@ -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);
|