create-volt 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@ 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.27.0] - 2026-06-29
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **Reactive crash on conditional re-render** (`volt.js`) — a signal write
|
|
11
|
+
notified a *snapshot* of subscribers, so a parent reactive block that disposed
|
|
12
|
+
a nested one mid-update would still run the stale nested effect on detached DOM
|
|
13
|
+
("Cannot read properties of null (reading 'insertBefore')"). Disposed effects
|
|
14
|
+
now skip their queued run, and `renderRange` guards against a detached range.
|
|
15
|
+
Fixes the **setup wizard** crashing when toggling add-ons or changing the DB
|
|
16
|
+
driver. Regression-tested in the headless-browser suite.
|
|
17
|
+
|
|
18
|
+
## [0.26.0] - 2026-06-29
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **Themes / shared layout for the `pages` add-on** — pages render into a layout:
|
|
22
|
+
(1) `pages/_header.html` + `_footer.html` partials (no code); (2) a local
|
|
23
|
+
`pages/_theme.js` exporting `layout({ title, head, content, meta })` (+ optional
|
|
24
|
+
`css`); (3) a third-party `volt-theme-<name>` package selected with
|
|
25
|
+
`THEME=<name>`. `create-volt create-theme <name>` scaffolds one. Resolution:
|
|
26
|
+
THEME → local `_theme.js` → built-in default.
|
|
27
|
+
- **One stylesheet for page + editor** — the active theme's CSS is served at
|
|
28
|
+
`/_theme.css` (a theme's `export const css`, or `pages/_theme.css`, or the
|
|
29
|
+
default). Pages link it, and the WYSIWYG editor loads it into RTEPro's
|
|
30
|
+
`exportCSS`, so the preview matches the published page — CSS authored once.
|
|
31
|
+
- **Site-wide OG image default** — `OG_IMAGE` in `.env` is the `og:image` for
|
|
32
|
+
pages without a per-page `image`.
|
|
33
|
+
|
|
7
34
|
## [0.25.0] - 2026-06-29
|
|
8
35
|
|
|
9
36
|
### Added
|
|
@@ -354,6 +381,8 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
354
381
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
355
382
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
356
383
|
|
|
384
|
+
[0.27.0]: https://github.com/MIR-2025/volt/releases/tag/v0.27.0
|
|
385
|
+
[0.26.0]: https://github.com/MIR-2025/volt/releases/tag/v0.26.0
|
|
357
386
|
[0.25.0]: https://github.com/MIR-2025/volt/releases/tag/v0.25.0
|
|
358
387
|
[0.24.0]: https://github.com/MIR-2025/volt/releases/tag/v0.24.0
|
|
359
388
|
[0.23.0]: https://github.com/MIR-2025/volt/releases/tag/v0.23.0
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Pages are code-owned files (trusted), so their markdown HTML is served as-is.
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
6
7
|
// express + marked are imported lazily in pagesRouter() so this module's pure
|
|
7
8
|
// helpers (parseFrontMatter, isSafeSlug) load without those deps installed.
|
|
8
9
|
|
|
@@ -61,10 +62,11 @@ function metaHead(meta) {
|
|
|
61
62
|
t.push(`<meta property="og:description" content="${esc(desc)}" />`);
|
|
62
63
|
}
|
|
63
64
|
t.push(`<meta property="og:type" content="${esc(meta.type || "website")}" />`);
|
|
64
|
-
|
|
65
|
+
const image = meta.image || process.env.OG_IMAGE; // per-page, else a site-wide default
|
|
66
|
+
if (image) t.push(`<meta property="og:image" content="${esc(image)}" />`);
|
|
65
67
|
if (meta.url || meta.canonical) t.push(`<meta property="og:url" content="${esc(meta.url || meta.canonical)}" />`);
|
|
66
68
|
if (meta.canonical) t.push(`<link rel="canonical" href="${esc(meta.canonical)}" />`);
|
|
67
|
-
t.push(`<meta name="twitter:card" content="${
|
|
69
|
+
t.push(`<meta name="twitter:card" content="${image ? "summary_large_image" : "summary"}" />`);
|
|
68
70
|
if (meta.jsonld) {
|
|
69
71
|
let ok = false;
|
|
70
72
|
try {
|
|
@@ -78,14 +80,8 @@ function metaHead(meta) {
|
|
|
78
80
|
return t.join("\n");
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
84
|
-
<title>${esc(meta.title || "")}</title>
|
|
85
|
-
${metaHead(meta)}
|
|
86
|
-
<style>
|
|
87
|
-
:root { color-scheme: light dark }
|
|
88
|
-
body { max-width: 720px; margin: 2.5rem auto; padding: 0 1.1rem; font: 17px/1.7 system-ui, -apple-system, sans-serif; }
|
|
83
|
+
const DEFAULT_CSS = `:root { color-scheme: light dark }
|
|
84
|
+
body { max-width: 720px; margin: 0 auto; padding: 2rem 1.1rem; font: 17px/1.7 system-ui, -apple-system, sans-serif; }
|
|
89
85
|
h1, h2, h3 { line-height: 1.25; margin: 1.6rem 0 .6rem }
|
|
90
86
|
pre { background: #0b0d11; color: #cfe3ff; padding: 1rem; border-radius: 10px; overflow: auto }
|
|
91
87
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em }
|
|
@@ -93,14 +89,64 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .
|
|
|
93
89
|
img { max-width: 100% } a { color: #0b67d6 }
|
|
94
90
|
blockquote { border-left: 3px solid #ccc; margin: 1rem 0; padding: .2rem 1rem; opacity: .8 }
|
|
95
91
|
table { border-collapse: collapse } td, th { border: 1px solid #ccc; padding: .4rem .7rem }
|
|
96
|
-
|
|
92
|
+
header, footer { max-width: 720px; margin: 0 auto; padding: 0 1.1rem }`;
|
|
93
|
+
|
|
94
|
+
// Built-in default theme: wraps content with optional pages/_header.html and
|
|
95
|
+
// pages/_footer.html partials (read fresh each request → live edits).
|
|
96
|
+
function defaultLayout(dir) {
|
|
97
|
+
const part = (f) => {
|
|
98
|
+
const p = path.join(dir, f);
|
|
99
|
+
return fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
|
|
100
|
+
};
|
|
101
|
+
// links /_theme.css (the active theme's CSS) so the page and the editor preview
|
|
102
|
+
// share one stylesheet.
|
|
103
|
+
return ({ title, head, content }) => `<!doctype html><html lang="en"><head><meta charset="utf-8" />
|
|
104
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
105
|
+
<title>${esc(title)}</title>
|
|
106
|
+
${head}
|
|
107
|
+
<link rel="stylesheet" href="/_theme.css" /></head><body>${part("_header.html")}<main>${content}</main>${part("_footer.html")}</body></html>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The active theme's CSS — a `_theme.css` override in pages/, else the default.
|
|
111
|
+
function themeCss(dir) {
|
|
112
|
+
const p = path.join(dir, "_theme.css");
|
|
113
|
+
return fs.existsSync(p) ? fs.readFileSync(p, "utf8") : DEFAULT_CSS;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Resolve the layout + its CSS. THEME=<name> → npm "volt-theme-<name>"; else a
|
|
117
|
+
// local pages/_theme.js; else the built-in default. A theme may `export const css`
|
|
118
|
+
// (served at /_theme.css, shared with the editor); otherwise pages/_theme.css or
|
|
119
|
+
// the default CSS is used.
|
|
120
|
+
async function loadTheme(dir, env) {
|
|
121
|
+
const wrap = (m) => {
|
|
122
|
+
const layout = m && (m.layout || m.default);
|
|
123
|
+
return layout ? { layout, css: m.css || themeCss(dir) } : null;
|
|
124
|
+
};
|
|
125
|
+
if (env.THEME) {
|
|
126
|
+
for (const id of [`volt-theme-${env.THEME}`, env.THEME]) {
|
|
127
|
+
try {
|
|
128
|
+
const t = wrap(await import(id));
|
|
129
|
+
if (t) return t;
|
|
130
|
+
} catch {
|
|
131
|
+
/* try next */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const local = path.join(dir, "_theme.js");
|
|
136
|
+
if (fs.existsSync(local)) {
|
|
137
|
+
const t = wrap(await import(pathToFileURL(local).href));
|
|
138
|
+
if (t) return t;
|
|
139
|
+
}
|
|
140
|
+
return { layout: defaultLayout(dir), css: themeCss(dir) };
|
|
97
141
|
}
|
|
98
142
|
|
|
99
143
|
export async function pagesRouter({ dir }) {
|
|
100
144
|
const express = (await import("express")).default;
|
|
101
145
|
const { marked } = await import("marked");
|
|
102
146
|
ensure(dir);
|
|
147
|
+
const { layout, css } = await loadTheme(dir, process.env);
|
|
103
148
|
const r = express.Router();
|
|
149
|
+
r.get("/_theme.css", (_req, res) => res.type("css").send(css));
|
|
104
150
|
r.get("/:slug", (req, res, next) => {
|
|
105
151
|
const slug = req.params.slug;
|
|
106
152
|
if (!isSafeSlug(slug)) return next(); // safe slug only — no traversal
|
|
@@ -109,8 +155,9 @@ export async function pagesRouter({ dir }) {
|
|
|
109
155
|
const { meta, body } = parseFrontMatter(fs.readFileSync(file, "utf8"));
|
|
110
156
|
// `format: html` pages (e.g. from the WYSIWYG editor) are served verbatim to
|
|
111
157
|
// preserve complex layouts; everything else is markdown rendered with marked.
|
|
112
|
-
const
|
|
113
|
-
|
|
158
|
+
const content = meta.format === "html" ? body : marked.parse(body);
|
|
159
|
+
const m = { ...meta, title: meta.title || slug };
|
|
160
|
+
res.type("html").send(layout({ title: m.title, head: metaHead(m), content, meta: m }));
|
|
114
161
|
});
|
|
115
162
|
return r;
|
|
116
163
|
}
|
package/index.js
CHANGED
|
@@ -183,6 +183,54 @@ if (positionals[0] === "create-addon") {
|
|
|
183
183
|
process.exit(0);
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
// --- `create-theme` subcommand: scaffold a publishable theme (shared layout) ---
|
|
187
|
+
if (positionals[0] === "create-theme") {
|
|
188
|
+
const name = positionals[1];
|
|
189
|
+
if (!name || !/^[a-z0-9][a-z0-9-]*$/.test(name)) die(`Usage: ${cyan("create-volt create-theme <name>")} — name: lowercase letters, numbers, hyphens.`);
|
|
190
|
+
const dir = path.resolve(`volt-theme-${name}`);
|
|
191
|
+
if (fs.existsSync(dir) && !flags.has("--force")) die(`${cyan(dir)} already exists (use ${cyan("--force")}).`);
|
|
192
|
+
const THEME_INDEX = [
|
|
193
|
+
"// A Volt theme. layout(ctx) wraps page content in a full HTML document —",
|
|
194
|
+
"// this is your shared header/footer/styling for every page.",
|
|
195
|
+
"// ctx = { title, head, content, meta }",
|
|
196
|
+
"// - title: page title (from front-matter)",
|
|
197
|
+
"// - head: SEO/social tags (OG, Twitter, canonical, JSON-LD) — put in <head>",
|
|
198
|
+
"// - content: the rendered page HTML",
|
|
199
|
+
"// - meta: the front-matter object",
|
|
200
|
+
"// `css` is served at /_theme.css and shared with the WYSIWYG editor preview.",
|
|
201
|
+
"export const css = `body { font: 17px/1.7 system-ui, sans-serif; max-width: 760px; margin: 0 auto; padding: 1rem } header, footer { opacity: .75; padding: .5rem 0 }`;",
|
|
202
|
+
"",
|
|
203
|
+
"export function layout({ title, head, content }) {",
|
|
204
|
+
' return `<!doctype html><html lang="en"><head><meta charset="utf-8" />',
|
|
205
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
206
|
+
"<title>${title}</title>",
|
|
207
|
+
"${head}",
|
|
208
|
+
'<link rel="stylesheet" href="/_theme.css" />',
|
|
209
|
+
"</head><body>",
|
|
210
|
+
' <header><strong>__NAME__</strong> · <a href="/">Home</a></header>',
|
|
211
|
+
" <main>${content}</main>",
|
|
212
|
+
" <footer><small>Built with Volt</small></footer>",
|
|
213
|
+
"</body></html>`;",
|
|
214
|
+
"}",
|
|
215
|
+
"",
|
|
216
|
+
].join("\n");
|
|
217
|
+
const THEME_README = [
|
|
218
|
+
"# volt-theme-__NAME__", "", "A [Volt](https://voltjs.com) theme — a shared layout (header/footer/styling) for `pages`.", "",
|
|
219
|
+
"## Use (in a Volt app with the pages add-on)", "", "```", "npm install volt-theme-__NAME__", "```", "",
|
|
220
|
+
"Then set `THEME=__NAME__` in `.env` and restart. Every page renders inside this theme's `layout()`.", "",
|
|
221
|
+
"## Develop", "", "Edit `index.js` — `layout({ title, head, content, meta })` returns the full HTML document. Put `head` in `<head>` (it carries the SEO/OG/JSON-LD tags).", "", "```", "npm publish", "```", "",
|
|
222
|
+
].join("\n");
|
|
223
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
224
|
+
const pkgJson = { name: `volt-theme-${name}`, version: "0.1.0", description: `A Volt theme: ${name}`, type: "module", main: "index.js", keywords: ["volt", "volt-theme", "theme"], files: ["index.js"], license: "MIT" };
|
|
225
|
+
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkgJson, null, 2) + "\n");
|
|
226
|
+
fs.writeFileSync(path.join(dir, "index.js"), THEME_INDEX.replace(/__NAME__/g, name));
|
|
227
|
+
fs.writeFileSync(path.join(dir, "README.md"), THEME_README.replace(/__NAME__/g, name));
|
|
228
|
+
console.log(`${cyan("✓ created")} ${path.relative(process.cwd(), dir) || dir} — a Volt theme.`);
|
|
229
|
+
console.log(dim(` edit index.js (layout), then publish: cd volt-theme-${name} && npm publish`));
|
|
230
|
+
console.log(dim(` use it: npm install volt-theme-${name} then set THEME=${name} in .env`));
|
|
231
|
+
process.exit(0);
|
|
232
|
+
}
|
|
233
|
+
|
|
186
234
|
// --- `update` subcommand: refresh public/volt.js in the current app to the
|
|
187
235
|
// version bundled with this create-volt (so `npx create-volt@latest update`
|
|
188
236
|
// pulls the latest library). Only touches the library file — never the user's
|
package/package.json
CHANGED
|
@@ -47,7 +47,11 @@ export function effect(fn) {
|
|
|
47
47
|
deps: new Set(),
|
|
48
48
|
children: new Set(),
|
|
49
49
|
parent: activeEffect,
|
|
50
|
+
disposed: false,
|
|
50
51
|
run() {
|
|
52
|
+
// A signal write notifies a *snapshot* of subscribers; a parent re-render
|
|
53
|
+
// can dispose this effect before its turn in that snapshot — so skip if so.
|
|
54
|
+
if (eff.disposed) return;
|
|
51
55
|
disposeChildren(eff);
|
|
52
56
|
cleanupDeps(eff);
|
|
53
57
|
const prev = activeEffect;
|
|
@@ -59,6 +63,7 @@ export function effect(fn) {
|
|
|
59
63
|
}
|
|
60
64
|
},
|
|
61
65
|
dispose() {
|
|
66
|
+
eff.disposed = true;
|
|
62
67
|
disposeChildren(eff);
|
|
63
68
|
cleanupDeps(eff);
|
|
64
69
|
if (eff.parent) eff.parent.children.delete(eff);
|
|
@@ -152,6 +157,7 @@ function appendChild(parent, child) {
|
|
|
152
157
|
|
|
153
158
|
// Replace everything between the start/end anchors with `value`'s nodes.
|
|
154
159
|
function renderRange(start, end, value) {
|
|
160
|
+
if (!end.parentNode) return; // range detached (parent re-rendered) — nothing to do
|
|
155
161
|
let n = start.nextSibling;
|
|
156
162
|
while (n && n !== end) {
|
|
157
163
|
const t = n.nextSibling;
|
|
@@ -47,7 +47,11 @@ export function effect(fn) {
|
|
|
47
47
|
deps: new Set(),
|
|
48
48
|
children: new Set(),
|
|
49
49
|
parent: activeEffect,
|
|
50
|
+
disposed: false,
|
|
50
51
|
run() {
|
|
52
|
+
// A signal write notifies a *snapshot* of subscribers; a parent re-render
|
|
53
|
+
// can dispose this effect before its turn in that snapshot — so skip if so.
|
|
54
|
+
if (eff.disposed) return;
|
|
51
55
|
disposeChildren(eff);
|
|
52
56
|
cleanupDeps(eff);
|
|
53
57
|
const prev = activeEffect;
|
|
@@ -59,6 +63,7 @@ export function effect(fn) {
|
|
|
59
63
|
}
|
|
60
64
|
},
|
|
61
65
|
dispose() {
|
|
66
|
+
eff.disposed = true;
|
|
62
67
|
disposeChildren(eff);
|
|
63
68
|
cleanupDeps(eff);
|
|
64
69
|
if (eff.parent) eff.parent.children.delete(eff);
|
|
@@ -152,6 +157,7 @@ function appendChild(parent, child) {
|
|
|
152
157
|
|
|
153
158
|
// Replace everything between the start/end anchors with `value`'s nodes.
|
|
154
159
|
function renderRange(start, end, value) {
|
|
160
|
+
if (!end.parentNode) return; // range detached (parent re-rendered) — nothing to do
|
|
155
161
|
let n = start.nextSibling;
|
|
156
162
|
while (n && n !== end) {
|
|
157
163
|
const t = n.nextSibling;
|
|
@@ -47,7 +47,11 @@ export function effect(fn) {
|
|
|
47
47
|
deps: new Set(),
|
|
48
48
|
children: new Set(),
|
|
49
49
|
parent: activeEffect,
|
|
50
|
+
disposed: false,
|
|
50
51
|
run() {
|
|
52
|
+
// A signal write notifies a *snapshot* of subscribers; a parent re-render
|
|
53
|
+
// can dispose this effect before its turn in that snapshot — so skip if so.
|
|
54
|
+
if (eff.disposed) return;
|
|
51
55
|
disposeChildren(eff);
|
|
52
56
|
cleanupDeps(eff);
|
|
53
57
|
const prev = activeEffect;
|
|
@@ -59,6 +63,7 @@ export function effect(fn) {
|
|
|
59
63
|
}
|
|
60
64
|
},
|
|
61
65
|
dispose() {
|
|
66
|
+
eff.disposed = true;
|
|
62
67
|
disposeChildren(eff);
|
|
63
68
|
cleanupDeps(eff);
|
|
64
69
|
if (eff.parent) eff.parent.children.delete(eff);
|
|
@@ -152,6 +157,7 @@ function appendChild(parent, child) {
|
|
|
152
157
|
|
|
153
158
|
// Replace everything between the start/end anchors with `value`'s nodes.
|
|
154
159
|
function renderRange(start, end, value) {
|
|
160
|
+
if (!end.parentNode) return; // range detached (parent re-rendered) — nothing to do
|
|
155
161
|
let n = start.nextSibling;
|
|
156
162
|
while (n && n !== end) {
|
|
157
163
|
const t = n.nextSibling;
|