create-volt 0.35.0 → 0.37.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 +34 -0
- package/addons/pages/files/lib/pages.js +12 -0
- package/index.js +2 -1
- package/package.json +1 -1
- package/templates/blog/server.js +69 -5
- package/templates/blog/setup/setup.js +97 -3
- package/templates/default/server.js +69 -5
- package/templates/default/setup/setup.js +67 -1
- package/templates/docs/server.js +69 -5
- package/templates/docs/setup/setup.js +97 -3
- package/templates/starter/server.js +69 -5
- package/templates/starter/setup/setup.js +67 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,38 @@ 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.37.0] - 2026-06-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Bundled themes + a wizard theme picker.** create-volt ships paper/midnight/
|
|
11
|
+
classic under `.volt/themes`; the setup wizard has a **Theme** dropdown (no npm
|
|
12
|
+
needed) with a **Customize** button that copies the chosen theme to
|
|
13
|
+
`pages/_theme.js` for editing. `THEME` now also resolves bundled themes.
|
|
14
|
+
- **More wizard settings:** `SITE_NAME`, `SITE_URL`, `CONFIG_PORT`, and optional
|
|
15
|
+
**AI keys** (`AI_PROVIDER` + Anthropic/OpenAI/Gemini key — written to `.env`,
|
|
16
|
+
kept server-side).
|
|
17
|
+
- **`CONFIG_PORT` defaults to 5050** for the `--edit`/`--studio` config UI, so it
|
|
18
|
+
never collides with a running app.
|
|
19
|
+
- **Inject `<script>` tags** for third-party libs: per-page front-matter
|
|
20
|
+
`scripts:` (comma-separated URLs) and/or a site-wide `SITE_SCRIPTS` env, loaded
|
|
21
|
+
deferred (works on pages + posts).
|
|
22
|
+
|
|
23
|
+
## [0.36.0] - 2026-06-29
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **`--port <n>` and `CONFIG_PORT`.** `--port` overrides the listen port for any
|
|
27
|
+
run; `CONFIG_PORT` in `.env` gives the `--edit`/`--studio` config UI its own
|
|
28
|
+
port, so it never collides with a running app. An in-use config port now prints
|
|
29
|
+
a clear hint instead of a raw `EADDRINUSE` stack.
|
|
30
|
+
- **Hot reload watches `pages/` and `posts/`.** Editing markdown content now
|
|
31
|
+
reloads the browser (content is read per request, so the edit shows). Theme
|
|
32
|
+
files (`_theme.js`) still need a restart — ES modules cache.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- The `blog` + `docs` templates had drifted from `default`s `server.js`/wizard and
|
|
36
|
+
silently missed recent fixes (including the hot-reload watcher). Re-synced, with
|
|
37
|
+
a test that fails if they drift again.
|
|
38
|
+
|
|
7
39
|
## [0.35.0] - 2026-06-29
|
|
8
40
|
|
|
9
41
|
### Fixed
|
|
@@ -463,6 +495,8 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
463
495
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
464
496
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
465
497
|
|
|
498
|
+
[0.37.0]: https://github.com/MIR-2025/volt/releases/tag/v0.37.0
|
|
499
|
+
[0.36.0]: https://github.com/MIR-2025/volt/releases/tag/v0.36.0
|
|
466
500
|
[0.35.0]: https://github.com/MIR-2025/volt/releases/tag/v0.35.0
|
|
467
501
|
[0.34.0]: https://github.com/MIR-2025/volt/releases/tag/v0.34.0
|
|
468
502
|
[0.33.0]: https://github.com/MIR-2025/volt/releases/tag/v0.33.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
|
|
|
@@ -123,6 +129,12 @@ export async function loadTheme(dir, env) {
|
|
|
123
129
|
return layout ? { layout, css: m.css || themeCss(dir) } : null;
|
|
124
130
|
};
|
|
125
131
|
if (env.THEME) {
|
|
132
|
+
// a theme bundled by create-volt (.volt/themes/<name>/index.js) — no npm needed
|
|
133
|
+
const bundled = path.resolve(dir, "..", ".volt", "themes", env.THEME, "index.js");
|
|
134
|
+
if (fs.existsSync(bundled)) {
|
|
135
|
+
const t = wrap(await import(pathToFileURL(bundled).href));
|
|
136
|
+
if (t) return t;
|
|
137
|
+
}
|
|
126
138
|
for (const id of [`volt-theme-${env.THEME}`, env.THEME]) {
|
|
127
139
|
try {
|
|
128
140
|
const t = wrap(await import(id));
|
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
package/templates/blog/server.js
CHANGED
|
@@ -20,7 +20,27 @@ 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)
|
|
26
|
+
|
|
27
|
+
// `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
|
|
28
|
+
// --edit/--studio dodge a port the running app already holds, and runs the app
|
|
29
|
+
// itself on a one-off port. Explicit flag wins over PORT in .env.
|
|
30
|
+
function cliPort() {
|
|
31
|
+
const i = process.argv.indexOf("--port");
|
|
32
|
+
const raw = i > -1 ? process.argv[i + 1] : (process.argv.find((a) => a.startsWith("--port=")) || "").split("=")[1];
|
|
33
|
+
const n = Number(raw);
|
|
34
|
+
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Port for the disposable config UI (--edit / --studio): --port wins, then
|
|
38
|
+
// CONFIG_PORT in .env (run it on its own port so it never clashes with the app),
|
|
39
|
+
// then the app's PORT, then the date-port.
|
|
40
|
+
function configPort() {
|
|
41
|
+
const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
|
|
42
|
+
return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
|
|
43
|
+
}
|
|
24
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" };
|
|
25
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" };
|
|
26
46
|
|
|
@@ -50,6 +70,23 @@ function availableAddons() {
|
|
|
50
70
|
});
|
|
51
71
|
}
|
|
52
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
|
+
|
|
53
90
|
// Which add-ons does VOLT_ADDONS turn on (dependencies expanded)?
|
|
54
91
|
function enabledFrom(env) {
|
|
55
92
|
const metas = Object.fromEntries(availableAddons().map((a) => [a.name, a]));
|
|
@@ -101,7 +138,7 @@ function openBrowser(url) {
|
|
|
101
138
|
|
|
102
139
|
// --- the actual app: wires whichever add-ons .env enables ---
|
|
103
140
|
async function startApp() {
|
|
104
|
-
const PORT = Number(process.env.PORT) || DEFAULT_PORT;
|
|
141
|
+
const PORT = cliPort() || Number(process.env.PORT) || DEFAULT_PORT;
|
|
105
142
|
const enabled = enabledFrom(process.env);
|
|
106
143
|
const app = express();
|
|
107
144
|
app.disable("x-powered-by");
|
|
@@ -188,7 +225,12 @@ async function startApp() {
|
|
|
188
225
|
};
|
|
189
226
|
w(dir);
|
|
190
227
|
};
|
|
191
|
-
|
|
228
|
+
// watch content dirs too (pages/posts markdown is read per request, so a
|
|
229
|
+
// browser reload shows the edit); skip dirs that don't exist.
|
|
230
|
+
for (const d of ["views", "public", "pages", "posts"]) {
|
|
231
|
+
const full = path.join(__dirname, d);
|
|
232
|
+
if (fs.existsSync(full)) watchRecursive(full);
|
|
233
|
+
}
|
|
192
234
|
|
|
193
235
|
const on = [...enabled];
|
|
194
236
|
server.listen(PORT, () => console.log(`â¡ Volt â http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
|
|
@@ -221,7 +263,7 @@ function ensureDriverInstalled(driver) {
|
|
|
221
263
|
|
|
222
264
|
// --- the disposable setup wizard (localhost only) ---
|
|
223
265
|
function startSetup() {
|
|
224
|
-
const PORT =
|
|
266
|
+
const PORT = configPort();
|
|
225
267
|
const assets = {
|
|
226
268
|
"/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
|
|
227
269
|
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
@@ -243,7 +285,27 @@ function startSetup() {
|
|
|
243
285
|
}
|
|
244
286
|
if (req.method === "GET" && p === "/setup/state") {
|
|
245
287
|
res.setHeader("Content-Type", "application/json");
|
|
246
|
-
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;
|
|
247
309
|
}
|
|
248
310
|
if (req.method === "POST" && p === "/setup/test-db") {
|
|
249
311
|
let body = "";
|
|
@@ -341,6 +403,7 @@ function startSetup() {
|
|
|
341
403
|
res.end("not found");
|
|
342
404
|
});
|
|
343
405
|
|
|
406
|
+
server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
|
|
344
407
|
server.listen(PORT, "127.0.0.1", () => {
|
|
345
408
|
const url = `http://localhost:${PORT}`;
|
|
346
409
|
console.log(`\nâ¡ Volt setup â ${url}`);
|
|
@@ -379,7 +442,7 @@ async function startStudio() {
|
|
|
379
442
|
console.error("Studio: couldn't connect the store â " + e.message);
|
|
380
443
|
process.exit(1);
|
|
381
444
|
}
|
|
382
|
-
const PORT =
|
|
445
|
+
const PORT = configPort();
|
|
383
446
|
const visible = (n) => n && !HIDDEN_COLLECTIONS.has(n);
|
|
384
447
|
const assets = {
|
|
385
448
|
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
@@ -431,6 +494,7 @@ async function startStudio() {
|
|
|
431
494
|
}
|
|
432
495
|
});
|
|
433
496
|
|
|
497
|
+
server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
|
|
434
498
|
server.listen(PORT, "127.0.0.1", () => {
|
|
435
499
|
const url = `http://localhost:${PORT}`;
|
|
436
500
|
console.log(`\nâ¡ Volt Studio â ${url} (${store.name})`);
|
|
@@ -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));
|
|
@@ -25,6 +25,15 @@ const state = signal({
|
|
|
25
25
|
s3Secret: current.S3_SECRET || "",
|
|
26
26
|
s3PublicBase: current.S3_PUBLIC_BASE || "",
|
|
27
27
|
port: current.PORT || String(defaultPort),
|
|
28
|
+
// detect the admin's timezone from their browser (the wizard runs here), so
|
|
29
|
+
// dates render in their zone — not the server's (usually UTC on a host).
|
|
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 || "",
|
|
28
37
|
});
|
|
29
38
|
const set = (patch) => state({ ...state(), ...patch });
|
|
30
39
|
const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
|
|
@@ -42,10 +51,37 @@ function effective(s) {
|
|
|
42
51
|
return order.filter((n) => want.has(n));
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
// which *enabled* add-ons pull in `name` as a (transitive) dependency
|
|
55
|
+
function requiredBy(s, name) {
|
|
56
|
+
const causes = [];
|
|
57
|
+
for (const n of order) {
|
|
58
|
+
if (n === name || !s.addons[n]) continue;
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
const visit = (x) => {
|
|
61
|
+
if (seen.has(x)) return;
|
|
62
|
+
seen.add(x);
|
|
63
|
+
(depsOf[x] || []).forEach(visit);
|
|
64
|
+
};
|
|
65
|
+
(depsOf[n] || []).forEach(visit);
|
|
66
|
+
if (seen.has(name)) causes.push(n);
|
|
67
|
+
}
|
|
68
|
+
return causes;
|
|
69
|
+
}
|
|
70
|
+
|
|
45
71
|
const clean = (v) => String(v).replace(/[\r\n]/g, "").trim(); // one value per line; no injection
|
|
46
72
|
function genEnv(s) {
|
|
47
73
|
const eff = effective(s);
|
|
48
74
|
const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${clean(s.port)}`];
|
|
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
|
+
}
|
|
49
85
|
if (eff.includes("db")) {
|
|
50
86
|
out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
|
|
51
87
|
if (s.dbDriver === "mongodb") {
|
|
@@ -83,6 +119,24 @@ const mediaDriver = computed(() => state().mediaDriver);
|
|
|
83
119
|
const hasDb = computed(() => eff().includes("db"));
|
|
84
120
|
const hasMailer = computed(() => eff().includes("mailer"));
|
|
85
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
|
+
}
|
|
86
140
|
|
|
87
141
|
async function testDb() {
|
|
88
142
|
const s = state();
|
|
@@ -133,11 +187,18 @@ const field = (label, key, placeholder = "") =>
|
|
|
133
187
|
<input class="form-control" placeholder=${placeholder} value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
|
|
134
188
|
</div>`;
|
|
135
189
|
|
|
190
|
+
// A dependency pulled in by another enabled add-on shows as checked + disabled
|
|
191
|
+
// (you can't turn it off while something needs it), with a "required by" note —
|
|
192
|
+
// so the .env's VOLT_ADDONS always matches what the boxes show.
|
|
136
193
|
const addonRow = (a) =>
|
|
137
194
|
html`<div class="form-check mb-2">
|
|
138
|
-
<input class="form-check-input" type="checkbox" id=${"x-" + a.name}
|
|
195
|
+
<input class="form-check-input" type="checkbox" id=${"x-" + a.name}
|
|
196
|
+
checked=${() => eff().includes(a.name)}
|
|
197
|
+
disabled=${() => !state().addons[a.name] && eff().includes(a.name)}
|
|
198
|
+
onchange=${() => toggle(a.name)} />
|
|
139
199
|
<label class="form-check-label" for=${"x-" + a.name}>
|
|
140
|
-
<span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}
|
|
200
|
+
<span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}${() =>
|
|
201
|
+
!state().addons[a.name] && eff().includes(a.name) ? html` <span class="text-muted small">· required by ${requiredBy(state(), a.name).join(", ")}</span>` : ""}
|
|
141
202
|
<div class="small text-muted">${a.description}</div>
|
|
142
203
|
</label>
|
|
143
204
|
</div>`;
|
|
@@ -173,6 +234,34 @@ const mediaSettings = () =>
|
|
|
173
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")}`
|
|
174
235
|
: null}`;
|
|
175
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
|
+
|
|
176
265
|
mount(
|
|
177
266
|
"#app",
|
|
178
267
|
available.length
|
|
@@ -185,9 +274,14 @@ mount(
|
|
|
185
274
|
html`<div class="card-x p-4 mb-3">
|
|
186
275
|
<h2 class="h6 mb-3">Settings</h2>
|
|
187
276
|
${field("PORT", "port", String(defaultPort))}
|
|
277
|
+
${field("SITE_NAME", "siteName", "My Site")}
|
|
278
|
+
${() => (hasContent() ? themePicker() : null)}
|
|
188
279
|
${() => (hasDb() ? dbSettings() : null)}
|
|
189
280
|
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
|
|
190
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))}
|
|
191
285
|
</div>`,
|
|
192
286
|
html`<div class="card-x p-4 mb-3">
|
|
193
287
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
@@ -20,7 +20,27 @@ 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)
|
|
26
|
+
|
|
27
|
+
// `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
|
|
28
|
+
// --edit/--studio dodge a port the running app already holds, and runs the app
|
|
29
|
+
// itself on a one-off port. Explicit flag wins over PORT in .env.
|
|
30
|
+
function cliPort() {
|
|
31
|
+
const i = process.argv.indexOf("--port");
|
|
32
|
+
const raw = i > -1 ? process.argv[i + 1] : (process.argv.find((a) => a.startsWith("--port=")) || "").split("=")[1];
|
|
33
|
+
const n = Number(raw);
|
|
34
|
+
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Port for the disposable config UI (--edit / --studio): --port wins, then
|
|
38
|
+
// CONFIG_PORT in .env (run it on its own port so it never clashes with the app),
|
|
39
|
+
// then the app's PORT, then the date-port.
|
|
40
|
+
function configPort() {
|
|
41
|
+
const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
|
|
42
|
+
return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
|
|
43
|
+
}
|
|
24
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" };
|
|
25
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" };
|
|
26
46
|
|
|
@@ -50,6 +70,23 @@ function availableAddons() {
|
|
|
50
70
|
});
|
|
51
71
|
}
|
|
52
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
|
+
|
|
53
90
|
// Which add-ons does VOLT_ADDONS turn on (dependencies expanded)?
|
|
54
91
|
function enabledFrom(env) {
|
|
55
92
|
const metas = Object.fromEntries(availableAddons().map((a) => [a.name, a]));
|
|
@@ -101,7 +138,7 @@ function openBrowser(url) {
|
|
|
101
138
|
|
|
102
139
|
// --- the actual app: wires whichever add-ons .env enables ---
|
|
103
140
|
async function startApp() {
|
|
104
|
-
const PORT = Number(process.env.PORT) || DEFAULT_PORT;
|
|
141
|
+
const PORT = cliPort() || Number(process.env.PORT) || DEFAULT_PORT;
|
|
105
142
|
const enabled = enabledFrom(process.env);
|
|
106
143
|
const app = express();
|
|
107
144
|
app.disable("x-powered-by");
|
|
@@ -188,7 +225,12 @@ async function startApp() {
|
|
|
188
225
|
};
|
|
189
226
|
w(dir);
|
|
190
227
|
};
|
|
191
|
-
|
|
228
|
+
// watch content dirs too (pages/posts markdown is read per request, so a
|
|
229
|
+
// browser reload shows the edit); skip dirs that don't exist.
|
|
230
|
+
for (const d of ["views", "public", "pages", "posts"]) {
|
|
231
|
+
const full = path.join(__dirname, d);
|
|
232
|
+
if (fs.existsSync(full)) watchRecursive(full);
|
|
233
|
+
}
|
|
192
234
|
|
|
193
235
|
const on = [...enabled];
|
|
194
236
|
server.listen(PORT, () => console.log(`â¡ Volt â http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
|
|
@@ -221,7 +263,7 @@ function ensureDriverInstalled(driver) {
|
|
|
221
263
|
|
|
222
264
|
// --- the disposable setup wizard (localhost only) ---
|
|
223
265
|
function startSetup() {
|
|
224
|
-
const PORT =
|
|
266
|
+
const PORT = configPort();
|
|
225
267
|
const assets = {
|
|
226
268
|
"/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
|
|
227
269
|
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
@@ -243,7 +285,27 @@ function startSetup() {
|
|
|
243
285
|
}
|
|
244
286
|
if (req.method === "GET" && p === "/setup/state") {
|
|
245
287
|
res.setHeader("Content-Type", "application/json");
|
|
246
|
-
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;
|
|
247
309
|
}
|
|
248
310
|
if (req.method === "POST" && p === "/setup/test-db") {
|
|
249
311
|
let body = "";
|
|
@@ -341,6 +403,7 @@ function startSetup() {
|
|
|
341
403
|
res.end("not found");
|
|
342
404
|
});
|
|
343
405
|
|
|
406
|
+
server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
|
|
344
407
|
server.listen(PORT, "127.0.0.1", () => {
|
|
345
408
|
const url = `http://localhost:${PORT}`;
|
|
346
409
|
console.log(`\nâ¡ Volt setup â ${url}`);
|
|
@@ -379,7 +442,7 @@ async function startStudio() {
|
|
|
379
442
|
console.error("Studio: couldn't connect the store â " + e.message);
|
|
380
443
|
process.exit(1);
|
|
381
444
|
}
|
|
382
|
-
const PORT =
|
|
445
|
+
const PORT = configPort();
|
|
383
446
|
const visible = (n) => n && !HIDDEN_COLLECTIONS.has(n);
|
|
384
447
|
const assets = {
|
|
385
448
|
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
@@ -431,6 +494,7 @@ async function startStudio() {
|
|
|
431
494
|
}
|
|
432
495
|
});
|
|
433
496
|
|
|
497
|
+
server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
|
|
434
498
|
server.listen(PORT, "127.0.0.1", () => {
|
|
435
499
|
const url = `http://localhost:${PORT}`;
|
|
436
500
|
console.log(`\nâ¡ Volt Studio â ${url} (${store.name})`);
|
|
@@ -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">
|
package/templates/docs/server.js
CHANGED
|
@@ -20,7 +20,27 @@ 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)
|
|
26
|
+
|
|
27
|
+
// `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
|
|
28
|
+
// --edit/--studio dodge a port the running app already holds, and runs the app
|
|
29
|
+
// itself on a one-off port. Explicit flag wins over PORT in .env.
|
|
30
|
+
function cliPort() {
|
|
31
|
+
const i = process.argv.indexOf("--port");
|
|
32
|
+
const raw = i > -1 ? process.argv[i + 1] : (process.argv.find((a) => a.startsWith("--port=")) || "").split("=")[1];
|
|
33
|
+
const n = Number(raw);
|
|
34
|
+
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Port for the disposable config UI (--edit / --studio): --port wins, then
|
|
38
|
+
// CONFIG_PORT in .env (run it on its own port so it never clashes with the app),
|
|
39
|
+
// then the app's PORT, then the date-port.
|
|
40
|
+
function configPort() {
|
|
41
|
+
const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
|
|
42
|
+
return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
|
|
43
|
+
}
|
|
24
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" };
|
|
25
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" };
|
|
26
46
|
|
|
@@ -50,6 +70,23 @@ function availableAddons() {
|
|
|
50
70
|
});
|
|
51
71
|
}
|
|
52
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
|
+
|
|
53
90
|
// Which add-ons does VOLT_ADDONS turn on (dependencies expanded)?
|
|
54
91
|
function enabledFrom(env) {
|
|
55
92
|
const metas = Object.fromEntries(availableAddons().map((a) => [a.name, a]));
|
|
@@ -101,7 +138,7 @@ function openBrowser(url) {
|
|
|
101
138
|
|
|
102
139
|
// --- the actual app: wires whichever add-ons .env enables ---
|
|
103
140
|
async function startApp() {
|
|
104
|
-
const PORT = Number(process.env.PORT) || DEFAULT_PORT;
|
|
141
|
+
const PORT = cliPort() || Number(process.env.PORT) || DEFAULT_PORT;
|
|
105
142
|
const enabled = enabledFrom(process.env);
|
|
106
143
|
const app = express();
|
|
107
144
|
app.disable("x-powered-by");
|
|
@@ -188,7 +225,12 @@ async function startApp() {
|
|
|
188
225
|
};
|
|
189
226
|
w(dir);
|
|
190
227
|
};
|
|
191
|
-
|
|
228
|
+
// watch content dirs too (pages/posts markdown is read per request, so a
|
|
229
|
+
// browser reload shows the edit); skip dirs that don't exist.
|
|
230
|
+
for (const d of ["views", "public", "pages", "posts"]) {
|
|
231
|
+
const full = path.join(__dirname, d);
|
|
232
|
+
if (fs.existsSync(full)) watchRecursive(full);
|
|
233
|
+
}
|
|
192
234
|
|
|
193
235
|
const on = [...enabled];
|
|
194
236
|
server.listen(PORT, () => console.log(`â¡ Volt â http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
|
|
@@ -221,7 +263,7 @@ function ensureDriverInstalled(driver) {
|
|
|
221
263
|
|
|
222
264
|
// --- the disposable setup wizard (localhost only) ---
|
|
223
265
|
function startSetup() {
|
|
224
|
-
const PORT =
|
|
266
|
+
const PORT = configPort();
|
|
225
267
|
const assets = {
|
|
226
268
|
"/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
|
|
227
269
|
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
@@ -243,7 +285,27 @@ function startSetup() {
|
|
|
243
285
|
}
|
|
244
286
|
if (req.method === "GET" && p === "/setup/state") {
|
|
245
287
|
res.setHeader("Content-Type", "application/json");
|
|
246
|
-
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;
|
|
247
309
|
}
|
|
248
310
|
if (req.method === "POST" && p === "/setup/test-db") {
|
|
249
311
|
let body = "";
|
|
@@ -341,6 +403,7 @@ function startSetup() {
|
|
|
341
403
|
res.end("not found");
|
|
342
404
|
});
|
|
343
405
|
|
|
406
|
+
server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
|
|
344
407
|
server.listen(PORT, "127.0.0.1", () => {
|
|
345
408
|
const url = `http://localhost:${PORT}`;
|
|
346
409
|
console.log(`\nâ¡ Volt setup â ${url}`);
|
|
@@ -379,7 +442,7 @@ async function startStudio() {
|
|
|
379
442
|
console.error("Studio: couldn't connect the store â " + e.message);
|
|
380
443
|
process.exit(1);
|
|
381
444
|
}
|
|
382
|
-
const PORT =
|
|
445
|
+
const PORT = configPort();
|
|
383
446
|
const visible = (n) => n && !HIDDEN_COLLECTIONS.has(n);
|
|
384
447
|
const assets = {
|
|
385
448
|
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
@@ -431,6 +494,7 @@ async function startStudio() {
|
|
|
431
494
|
}
|
|
432
495
|
});
|
|
433
496
|
|
|
497
|
+
server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
|
|
434
498
|
server.listen(PORT, "127.0.0.1", () => {
|
|
435
499
|
const url = `http://localhost:${PORT}`;
|
|
436
500
|
console.log(`\nâ¡ Volt Studio â ${url} (${store.name})`);
|
|
@@ -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));
|
|
@@ -25,6 +25,15 @@ const state = signal({
|
|
|
25
25
|
s3Secret: current.S3_SECRET || "",
|
|
26
26
|
s3PublicBase: current.S3_PUBLIC_BASE || "",
|
|
27
27
|
port: current.PORT || String(defaultPort),
|
|
28
|
+
// detect the admin's timezone from their browser (the wizard runs here), so
|
|
29
|
+
// dates render in their zone — not the server's (usually UTC on a host).
|
|
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 || "",
|
|
28
37
|
});
|
|
29
38
|
const set = (patch) => state({ ...state(), ...patch });
|
|
30
39
|
const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
|
|
@@ -42,10 +51,37 @@ function effective(s) {
|
|
|
42
51
|
return order.filter((n) => want.has(n));
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
// which *enabled* add-ons pull in `name` as a (transitive) dependency
|
|
55
|
+
function requiredBy(s, name) {
|
|
56
|
+
const causes = [];
|
|
57
|
+
for (const n of order) {
|
|
58
|
+
if (n === name || !s.addons[n]) continue;
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
const visit = (x) => {
|
|
61
|
+
if (seen.has(x)) return;
|
|
62
|
+
seen.add(x);
|
|
63
|
+
(depsOf[x] || []).forEach(visit);
|
|
64
|
+
};
|
|
65
|
+
(depsOf[n] || []).forEach(visit);
|
|
66
|
+
if (seen.has(name)) causes.push(n);
|
|
67
|
+
}
|
|
68
|
+
return causes;
|
|
69
|
+
}
|
|
70
|
+
|
|
45
71
|
const clean = (v) => String(v).replace(/[\r\n]/g, "").trim(); // one value per line; no injection
|
|
46
72
|
function genEnv(s) {
|
|
47
73
|
const eff = effective(s);
|
|
48
74
|
const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${clean(s.port)}`];
|
|
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
|
+
}
|
|
49
85
|
if (eff.includes("db")) {
|
|
50
86
|
out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
|
|
51
87
|
if (s.dbDriver === "mongodb") {
|
|
@@ -83,6 +119,24 @@ const mediaDriver = computed(() => state().mediaDriver);
|
|
|
83
119
|
const hasDb = computed(() => eff().includes("db"));
|
|
84
120
|
const hasMailer = computed(() => eff().includes("mailer"));
|
|
85
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
|
+
}
|
|
86
140
|
|
|
87
141
|
async function testDb() {
|
|
88
142
|
const s = state();
|
|
@@ -133,11 +187,18 @@ const field = (label, key, placeholder = "") =>
|
|
|
133
187
|
<input class="form-control" placeholder=${placeholder} value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
|
|
134
188
|
</div>`;
|
|
135
189
|
|
|
190
|
+
// A dependency pulled in by another enabled add-on shows as checked + disabled
|
|
191
|
+
// (you can't turn it off while something needs it), with a "required by" note —
|
|
192
|
+
// so the .env's VOLT_ADDONS always matches what the boxes show.
|
|
136
193
|
const addonRow = (a) =>
|
|
137
194
|
html`<div class="form-check mb-2">
|
|
138
|
-
<input class="form-check-input" type="checkbox" id=${"x-" + a.name}
|
|
195
|
+
<input class="form-check-input" type="checkbox" id=${"x-" + a.name}
|
|
196
|
+
checked=${() => eff().includes(a.name)}
|
|
197
|
+
disabled=${() => !state().addons[a.name] && eff().includes(a.name)}
|
|
198
|
+
onchange=${() => toggle(a.name)} />
|
|
139
199
|
<label class="form-check-label" for=${"x-" + a.name}>
|
|
140
|
-
<span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}
|
|
200
|
+
<span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}${() =>
|
|
201
|
+
!state().addons[a.name] && eff().includes(a.name) ? html` <span class="text-muted small">· required by ${requiredBy(state(), a.name).join(", ")}</span>` : ""}
|
|
141
202
|
<div class="small text-muted">${a.description}</div>
|
|
142
203
|
</label>
|
|
143
204
|
</div>`;
|
|
@@ -173,6 +234,34 @@ const mediaSettings = () =>
|
|
|
173
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")}`
|
|
174
235
|
: null}`;
|
|
175
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
|
+
|
|
176
265
|
mount(
|
|
177
266
|
"#app",
|
|
178
267
|
available.length
|
|
@@ -185,9 +274,14 @@ mount(
|
|
|
185
274
|
html`<div class="card-x p-4 mb-3">
|
|
186
275
|
<h2 class="h6 mb-3">Settings</h2>
|
|
187
276
|
${field("PORT", "port", String(defaultPort))}
|
|
277
|
+
${field("SITE_NAME", "siteName", "My Site")}
|
|
278
|
+
${() => (hasContent() ? themePicker() : null)}
|
|
188
279
|
${() => (hasDb() ? dbSettings() : null)}
|
|
189
280
|
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
|
|
190
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))}
|
|
191
285
|
</div>`,
|
|
192
286
|
html`<div class="card-x p-4 mb-3">
|
|
193
287
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
@@ -21,7 +21,27 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
21
21
|
const ENV_PATH = path.join(__dirname, ".env");
|
|
22
22
|
const PKG_PATH = path.join(__dirname, "package.json");
|
|
23
23
|
const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
|
|
24
|
+
const THEMES_DIR = path.join(__dirname, ".volt", "themes"); // bundled themes the wizard can pick
|
|
24
25
|
const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
|
|
26
|
+
const CONFIG_DEFAULT_PORT = 5050; // the --edit/--studio config UI's default port (its own, so it never clashes with a running app)
|
|
27
|
+
|
|
28
|
+
// `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
|
|
29
|
+
// --edit/--studio dodge a port the running app already holds, and runs the app
|
|
30
|
+
// itself on a one-off port. Explicit flag wins over PORT in .env.
|
|
31
|
+
function cliPort() {
|
|
32
|
+
const i = process.argv.indexOf("--port");
|
|
33
|
+
const raw = i > -1 ? process.argv[i + 1] : (process.argv.find((a) => a.startsWith("--port=")) || "").split("=")[1];
|
|
34
|
+
const n = Number(raw);
|
|
35
|
+
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Port for the disposable config UI (--edit / --studio): --port wins, then
|
|
39
|
+
// CONFIG_PORT in .env (run it on its own port so it never clashes with the app),
|
|
40
|
+
// then the app's PORT, then the date-port.
|
|
41
|
+
function configPort() {
|
|
42
|
+
const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
|
|
43
|
+
return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
|
|
44
|
+
}
|
|
25
45
|
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" };
|
|
26
46
|
const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
|
|
27
47
|
|
|
@@ -51,6 +71,23 @@ function availableAddons() {
|
|
|
51
71
|
});
|
|
52
72
|
}
|
|
53
73
|
|
|
74
|
+
// Themes bundled by create-volt (under .volt/themes), pickable in the wizard.
|
|
75
|
+
function availableThemes() {
|
|
76
|
+
if (!fs.existsSync(THEMES_DIR)) return [];
|
|
77
|
+
return fs
|
|
78
|
+
.readdirSync(THEMES_DIR, { withFileTypes: true })
|
|
79
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(THEMES_DIR, e.name, "index.js")))
|
|
80
|
+
.map((e) => {
|
|
81
|
+
let description = "";
|
|
82
|
+
try {
|
|
83
|
+
description = JSON.parse(fs.readFileSync(path.join(THEMES_DIR, e.name, "meta.json"), "utf8")).description;
|
|
84
|
+
} catch {
|
|
85
|
+
/* no meta */
|
|
86
|
+
}
|
|
87
|
+
return { name: e.name, description };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
54
91
|
// Which add-ons does VOLT_ADDONS turn on (dependencies expanded)?
|
|
55
92
|
function enabledFrom(env) {
|
|
56
93
|
const metas = Object.fromEntries(availableAddons().map((a) => [a.name, a]));
|
|
@@ -102,7 +139,7 @@ function openBrowser(url) {
|
|
|
102
139
|
|
|
103
140
|
// --- the actual app: wires whichever add-ons .env enables ---
|
|
104
141
|
async function startApp() {
|
|
105
|
-
const PORT = Number(process.env.PORT) || DEFAULT_PORT;
|
|
142
|
+
const PORT = cliPort() || Number(process.env.PORT) || DEFAULT_PORT;
|
|
106
143
|
const enabled = enabledFrom(process.env);
|
|
107
144
|
const app = express();
|
|
108
145
|
app.disable("x-powered-by");
|
|
@@ -214,7 +251,12 @@ async function startApp() {
|
|
|
214
251
|
};
|
|
215
252
|
w(dir);
|
|
216
253
|
};
|
|
217
|
-
|
|
254
|
+
// watch content dirs too (pages/posts markdown is read per request, so a
|
|
255
|
+
// browser reload shows the edit); skip dirs that don't exist.
|
|
256
|
+
for (const d of ["views", "public", "pages", "posts"]) {
|
|
257
|
+
const full = path.join(__dirname, d);
|
|
258
|
+
if (fs.existsSync(full)) watchRecursive(full);
|
|
259
|
+
}
|
|
218
260
|
|
|
219
261
|
const on = [...enabled];
|
|
220
262
|
server.listen(PORT, () => console.log(`â¡ Volt â http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
|
|
@@ -247,7 +289,7 @@ function ensureDriverInstalled(driver) {
|
|
|
247
289
|
|
|
248
290
|
// --- the disposable setup wizard (localhost only) ---
|
|
249
291
|
function startSetup() {
|
|
250
|
-
const PORT =
|
|
292
|
+
const PORT = configPort();
|
|
251
293
|
const assets = {
|
|
252
294
|
"/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
|
|
253
295
|
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
@@ -269,7 +311,27 @@ function startSetup() {
|
|
|
269
311
|
}
|
|
270
312
|
if (req.method === "GET" && p === "/setup/state") {
|
|
271
313
|
res.setHeader("Content-Type", "application/json");
|
|
272
|
-
return res.end(JSON.stringify({ available: availableAddons(), current: readEnvFile(), defaultPort: DEFAULT_PORT }));
|
|
314
|
+
return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
|
|
315
|
+
}
|
|
316
|
+
// "Customize": copy a bundled theme into pages/_theme.js so it can be edited.
|
|
317
|
+
if (req.method === "POST" && p === "/setup/eject-theme") {
|
|
318
|
+
let body = "";
|
|
319
|
+
req.on("data", (c) => (body += c));
|
|
320
|
+
req.on("end", () => {
|
|
321
|
+
res.setHeader("Content-Type", "application/json");
|
|
322
|
+
try {
|
|
323
|
+
const { theme } = JSON.parse(body || "{}");
|
|
324
|
+
const src = path.join(THEMES_DIR, String(theme || ""), "index.js");
|
|
325
|
+
if (!theme || !/^[a-z0-9-]+$/i.test(theme) || !fs.existsSync(src)) return res.end(JSON.stringify({ ok: false, error: "unknown theme" }));
|
|
326
|
+
const dest = path.join(__dirname, "pages", "_theme.js");
|
|
327
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
328
|
+
fs.copyFileSync(src, dest);
|
|
329
|
+
res.end(JSON.stringify({ ok: true, path: "pages/_theme.js" }));
|
|
330
|
+
} catch (e) {
|
|
331
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
273
335
|
}
|
|
274
336
|
if (req.method === "POST" && p === "/setup/test-db") {
|
|
275
337
|
let body = "";
|
|
@@ -367,6 +429,7 @@ function startSetup() {
|
|
|
367
429
|
res.end("not found");
|
|
368
430
|
});
|
|
369
431
|
|
|
432
|
+
server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
|
|
370
433
|
server.listen(PORT, "127.0.0.1", () => {
|
|
371
434
|
const url = `http://localhost:${PORT}`;
|
|
372
435
|
console.log(`\nâ¡ Volt setup â ${url}`);
|
|
@@ -405,7 +468,7 @@ async function startStudio() {
|
|
|
405
468
|
console.error("Studio: couldn't connect the store â " + e.message);
|
|
406
469
|
process.exit(1);
|
|
407
470
|
}
|
|
408
|
-
const PORT =
|
|
471
|
+
const PORT = configPort();
|
|
409
472
|
const visible = (n) => n && !HIDDEN_COLLECTIONS.has(n);
|
|
410
473
|
const assets = {
|
|
411
474
|
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
@@ -457,6 +520,7 @@ async function startStudio() {
|
|
|
457
520
|
}
|
|
458
521
|
});
|
|
459
522
|
|
|
523
|
+
server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
|
|
460
524
|
server.listen(PORT, "127.0.0.1", () => {
|
|
461
525
|
const url = `http://localhost:${PORT}`;
|
|
462
526
|
console.log(`\nâ¡ Volt Studio â ${url} (${store.name})`);
|
|
@@ -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">
|