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 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:" + port)}, then your app starts.\n`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "Scaffold a new Volt app — no-build, signals-based UI with Socket.io hot reload.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
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 = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_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 = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_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} checked=${() => state().addons[a.name]} onchange=${() => toggle(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
- for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
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 = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_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 = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_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">
@@ -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
- for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
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 = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_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 = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_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} checked=${() => state().addons[a.name]} onchange=${() => toggle(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
- for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
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 = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_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 = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_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">