create-volt 0.36.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,22 @@ 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
+
7
23
  ## [0.36.0] - 2026-06-29
8
24
 
9
25
  ### Added
@@ -479,6 +495,7 @@ All notable changes to `create-volt` are documented here. The format follows
479
495
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
480
496
  and auto-detects npm / pnpm / yarn / bun for the install step.
481
497
 
498
+ [0.37.0]: https://github.com/MIR-2025/volt/releases/tag/v0.37.0
482
499
  [0.36.0]: https://github.com/MIR-2025/volt/releases/tag/v0.36.0
483
500
  [0.35.0]: https://github.com/MIR-2025/volt/releases/tag/v0.35.0
484
501
  [0.34.0]: https://github.com/MIR-2025/volt/releases/tag/v0.34.0
@@ -77,6 +77,12 @@ export function metaHead(meta) {
77
77
  }
78
78
  if (ok) t.push(`<script type="application/ld+json">${meta.jsonld.replace(/</g, "\\u003c")}</script>`);
79
79
  }
80
+ // custom <script> tags for third-party libs: per-page front-matter `scripts:`
81
+ // (comma-separated URLs) and/or a site-wide SITE_SCRIPTS env. Loaded deferred.
82
+ const scripts = [process.env.SITE_SCRIPTS, meta.scripts].filter(Boolean).join(",");
83
+ for (const url of scripts.split(",").map((s) => s.trim()).filter(Boolean)) {
84
+ t.push(`<script src="${esc(url)}" defer></script>`);
85
+ }
80
86
  return t.join("\n");
81
87
  }
82
88
 
@@ -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.36.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,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
20
  const ENV_PATH = path.join(__dirname, ".env");
21
21
  const PKG_PATH = path.join(__dirname, "package.json");
22
22
  const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
23
+ const THEMES_DIR = path.join(__dirname, ".volt", "themes"); // bundled themes the wizard can pick
23
24
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
25
+ const CONFIG_DEFAULT_PORT = 5050; // the --edit/--studio config UI's default port (its own, so it never clashes with a running app)
24
26
 
25
27
  // `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
26
28
  // --edit/--studio dodge a port the running app already holds, and runs the app
@@ -37,7 +39,7 @@ function cliPort() {
37
39
  // then the app's PORT, then the date-port.
38
40
  function configPort() {
39
41
  const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
40
- return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || Number(process.env.PORT) || Number(env.PORT) || DEFAULT_PORT;
42
+ return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
41
43
  }
42
44
  const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
43
45
  const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
@@ -68,6 +70,23 @@ function availableAddons() {
68
70
  });
69
71
  }
70
72
 
73
+ // Themes bundled by create-volt (under .volt/themes), pickable in the wizard.
74
+ function availableThemes() {
75
+ if (!fs.existsSync(THEMES_DIR)) return [];
76
+ return fs
77
+ .readdirSync(THEMES_DIR, { withFileTypes: true })
78
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(THEMES_DIR, e.name, "index.js")))
79
+ .map((e) => {
80
+ let description = "";
81
+ try {
82
+ description = JSON.parse(fs.readFileSync(path.join(THEMES_DIR, e.name, "meta.json"), "utf8")).description;
83
+ } catch {
84
+ /* no meta */
85
+ }
86
+ return { name: e.name, description };
87
+ });
88
+ }
89
+
71
90
  // Which add-ons does VOLT_ADDONS turn on (dependencies expanded)?
72
91
  function enabledFrom(env) {
73
92
  const metas = Object.fromEntries(availableAddons().map((a) => [a.name, a]));
@@ -266,7 +285,27 @@ function startSetup() {
266
285
  }
267
286
  if (req.method === "GET" && p === "/setup/state") {
268
287
  res.setHeader("Content-Type", "application/json");
269
- return res.end(JSON.stringify({ available: availableAddons(), current: readEnvFile(), defaultPort: DEFAULT_PORT }));
288
+ return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
289
+ }
290
+ // "Customize": copy a bundled theme into pages/_theme.js so it can be edited.
291
+ if (req.method === "POST" && p === "/setup/eject-theme") {
292
+ let body = "";
293
+ req.on("data", (c) => (body += c));
294
+ req.on("end", () => {
295
+ res.setHeader("Content-Type", "application/json");
296
+ try {
297
+ const { theme } = JSON.parse(body || "{}");
298
+ const src = path.join(THEMES_DIR, String(theme || ""), "index.js");
299
+ if (!theme || !/^[a-z0-9-]+$/i.test(theme) || !fs.existsSync(src)) return res.end(JSON.stringify({ ok: false, error: "unknown theme" }));
300
+ const dest = path.join(__dirname, "pages", "_theme.js");
301
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
302
+ fs.copyFileSync(src, dest);
303
+ res.end(JSON.stringify({ ok: true, path: "pages/_theme.js" }));
304
+ } catch (e) {
305
+ res.end(JSON.stringify({ ok: false, error: e.message }));
306
+ }
307
+ });
308
+ return;
270
309
  }
271
310
  if (req.method === "POST" && p === "/setup/test-db") {
272
311
  let body = "";
@@ -4,7 +4,7 @@
4
4
  // just config.
5
5
  import { signal, computed, html, mount } from "/volt.js";
6
6
 
7
- const { available, current, defaultPort } = await (await fetch("/setup/state")).json();
7
+ const { available, themes = [], current, defaultPort, configDefaultPort = 5050 } = await (await fetch("/setup/state")).json();
8
8
  const depsOf = Object.fromEntries(available.map((a) => [a.name, a.dependsOn || []]));
9
9
  const order = available.map((a) => a.name);
10
10
  const enabledNow = new Set(String(current.VOLT_ADDONS || "").split(",").map((s) => s.trim()).filter(Boolean));
@@ -28,6 +28,12 @@ const state = signal({
28
28
  // detect the admin's timezone from their browser (the wizard runs here), so
29
29
  // dates render in their zone — not the server's (usually UTC on a host).
30
30
  tz: current.SITE_TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
31
+ siteName: current.SITE_NAME || "",
32
+ siteUrl: current.SITE_URL || "",
33
+ configPort: current.CONFIG_PORT || "",
34
+ theme: current.THEME || "",
35
+ aiProvider: current.AI_PROVIDER || "anthropic",
36
+ aiKey: current.ANTHROPIC_API_KEY || current.OPENAI_API_KEY || current.GEMINI_API_KEY || "",
31
37
  });
32
38
  const set = (patch) => state({ ...state(), ...patch });
33
39
  const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
@@ -67,6 +73,15 @@ function genEnv(s) {
67
73
  const eff = effective(s);
68
74
  const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${clean(s.port)}`];
69
75
  if (s.tz) out.push(`SITE_TZ=${clean(s.tz)}`); // admin's timezone, for date display
76
+ if (s.siteName) out.push(`SITE_NAME=${clean(s.siteName)}`);
77
+ if (s.siteUrl) out.push(`SITE_URL=${clean(s.siteUrl)}`);
78
+ if (s.configPort) out.push(`CONFIG_PORT=${clean(s.configPort)}`);
79
+ if ((eff.includes("pages") || eff.includes("posts")) && s.theme) out.push(`THEME=${clean(s.theme)}`);
80
+ if (s.aiKey) {
81
+ out.push(`AI_PROVIDER=${clean(s.aiProvider)}`);
82
+ const keyVar = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", gemini: "GEMINI_API_KEY" }[s.aiProvider] || "ANTHROPIC_API_KEY";
83
+ out.push(`${keyVar}=${clean(s.aiKey)}`);
84
+ }
70
85
  if (eff.includes("db")) {
71
86
  out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
72
87
  if (s.dbDriver === "mongodb") {
@@ -104,6 +119,24 @@ const mediaDriver = computed(() => state().mediaDriver);
104
119
  const hasDb = computed(() => eff().includes("db"));
105
120
  const hasMailer = computed(() => eff().includes("mailer"));
106
121
  const hasMedia = computed(() => eff().includes("media"));
122
+ const hasContent = computed(() => eff().includes("pages") || eff().includes("posts")); // themes apply to pages/posts
123
+
124
+ // "Customize": copy the selected bundled theme to pages/_theme.js, then use it
125
+ // locally (THEME cleared) so edits take effect.
126
+ async function ejectTheme() {
127
+ const theme = state().theme;
128
+ if (!theme) return;
129
+ status("Copying theme…");
130
+ try {
131
+ const r = await (await fetch("/setup/eject-theme", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ theme }) })).json();
132
+ if (r.ok) {
133
+ set({ theme: "" });
134
+ status(`Copied ${theme} → ${r.path}. Edit it freely; THEME was cleared so your local copy is used.`);
135
+ } else status("Error: " + (r.error || "?"));
136
+ } catch {
137
+ status("Network error copying theme.");
138
+ }
139
+ }
107
140
 
108
141
  async function testDb() {
109
142
  const s = state();
@@ -201,6 +234,34 @@ const mediaSettings = () =>
201
234
  ? html`${field("S3_ENDPOINT", "s3Endpoint", "https://nyc3.digitaloceanspaces.com")}${field("S3_REGION", "s3Region", "us-east-1")}${field("S3_BUCKET", "s3Bucket", "my-space")}${field("S3_KEY", "s3Key", "access key")}${field("S3_SECRET", "s3Secret", "secret key")}${field("S3_PUBLIC_BASE (optional CDN base)", "s3PublicBase", "https://cdn.example.com")}`
202
235
  : null}`;
203
236
 
237
+ // theme chooser: a bundled theme (or the built-in/local one), with Customize
238
+ const themePicker = () =>
239
+ html`<div class="mb-2">
240
+ <label class="form-label small mb-1">Theme (THEME)</label>
241
+ <select class="form-select" value=${() => state().theme} onchange=${(e) => set({ theme: e.target.value })}>
242
+ <option value="">default — built-in, or your pages/_theme.js</option>
243
+ ${themes.map((t) => html`<option value=${t.name}>${t.name}${t.description ? " — " + t.description : ""}</option>`)}
244
+ </select>
245
+ ${() =>
246
+ state().theme
247
+ ? html`<button class="btn btn-sm btn-outline-secondary mt-1" onclick=${ejectTheme}>Customize → copy to pages/_theme.js</button>`
248
+ : html`<div class="small text-muted mt-1">Pick a starter theme, or keep the built-in / your local <code>pages/_theme.js</code>.</div>`}
249
+ </div>`;
250
+
251
+ // AI keys (optional) — used by the WYSIWYG editor's assistant. Kept server-side.
252
+ const aiSettings = () =>
253
+ html`<details class="mb-2"><summary class="form-label small mb-0" style="cursor:pointer">AI keys (optional) — for the editor's assistant</summary>
254
+ <div class="mt-2">
255
+ <label class="form-label small mb-1">Provider (AI_PROVIDER)</label>
256
+ <select class="form-select mb-2" value=${() => state().aiProvider} onchange=${(e) => set({ aiProvider: e.target.value })}>
257
+ <option value="anthropic">Anthropic (Claude)</option>
258
+ <option value="openai">OpenAI</option>
259
+ <option value="gemini">Google Gemini</option>
260
+ </select>
261
+ ${field("API key — stays server-side, written to .env", "aiKey", "sk-…")}
262
+ </div>
263
+ </details>`;
264
+
204
265
  mount(
205
266
  "#app",
206
267
  available.length
@@ -213,9 +274,14 @@ mount(
213
274
  html`<div class="card-x p-4 mb-3">
214
275
  <h2 class="h6 mb-3">Settings</h2>
215
276
  ${field("PORT", "port", String(defaultPort))}
277
+ ${field("SITE_NAME", "siteName", "My Site")}
278
+ ${() => (hasContent() ? themePicker() : null)}
216
279
  ${() => (hasDb() ? dbSettings() : null)}
217
280
  ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
218
281
  ${() => (hasMedia() ? mediaSettings() : null)}
282
+ ${aiSettings()}
283
+ ${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
284
+ ${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
219
285
  </div>`,
220
286
  html`<div class="card-x p-4 mb-3">
221
287
  <div class="d-flex justify-content-between align-items-center mb-2">
@@ -20,7 +20,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
20
  const ENV_PATH = path.join(__dirname, ".env");
21
21
  const PKG_PATH = path.join(__dirname, "package.json");
22
22
  const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
23
+ const THEMES_DIR = path.join(__dirname, ".volt", "themes"); // bundled themes the wizard can pick
23
24
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
25
+ const CONFIG_DEFAULT_PORT = 5050; // the --edit/--studio config UI's default port (its own, so it never clashes with a running app)
24
26
 
25
27
  // `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
26
28
  // --edit/--studio dodge a port the running app already holds, and runs the app
@@ -37,7 +39,7 @@ function cliPort() {
37
39
  // then the app's PORT, then the date-port.
38
40
  function configPort() {
39
41
  const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
40
- return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || Number(process.env.PORT) || Number(env.PORT) || DEFAULT_PORT;
42
+ return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
41
43
  }
42
44
  const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
43
45
  const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
@@ -68,6 +70,23 @@ function availableAddons() {
68
70
  });
69
71
  }
70
72
 
73
+ // Themes bundled by create-volt (under .volt/themes), pickable in the wizard.
74
+ function availableThemes() {
75
+ if (!fs.existsSync(THEMES_DIR)) return [];
76
+ return fs
77
+ .readdirSync(THEMES_DIR, { withFileTypes: true })
78
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(THEMES_DIR, e.name, "index.js")))
79
+ .map((e) => {
80
+ let description = "";
81
+ try {
82
+ description = JSON.parse(fs.readFileSync(path.join(THEMES_DIR, e.name, "meta.json"), "utf8")).description;
83
+ } catch {
84
+ /* no meta */
85
+ }
86
+ return { name: e.name, description };
87
+ });
88
+ }
89
+
71
90
  // Which add-ons does VOLT_ADDONS turn on (dependencies expanded)?
72
91
  function enabledFrom(env) {
73
92
  const metas = Object.fromEntries(availableAddons().map((a) => [a.name, a]));
@@ -266,7 +285,27 @@ function startSetup() {
266
285
  }
267
286
  if (req.method === "GET" && p === "/setup/state") {
268
287
  res.setHeader("Content-Type", "application/json");
269
- return res.end(JSON.stringify({ available: availableAddons(), current: readEnvFile(), defaultPort: DEFAULT_PORT }));
288
+ return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
289
+ }
290
+ // "Customize": copy a bundled theme into pages/_theme.js so it can be edited.
291
+ if (req.method === "POST" && p === "/setup/eject-theme") {
292
+ let body = "";
293
+ req.on("data", (c) => (body += c));
294
+ req.on("end", () => {
295
+ res.setHeader("Content-Type", "application/json");
296
+ try {
297
+ const { theme } = JSON.parse(body || "{}");
298
+ const src = path.join(THEMES_DIR, String(theme || ""), "index.js");
299
+ if (!theme || !/^[a-z0-9-]+$/i.test(theme) || !fs.existsSync(src)) return res.end(JSON.stringify({ ok: false, error: "unknown theme" }));
300
+ const dest = path.join(__dirname, "pages", "_theme.js");
301
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
302
+ fs.copyFileSync(src, dest);
303
+ res.end(JSON.stringify({ ok: true, path: "pages/_theme.js" }));
304
+ } catch (e) {
305
+ res.end(JSON.stringify({ ok: false, error: e.message }));
306
+ }
307
+ });
308
+ return;
270
309
  }
271
310
  if (req.method === "POST" && p === "/setup/test-db") {
272
311
  let body = "";
@@ -4,7 +4,7 @@
4
4
  // just config.
5
5
  import { signal, computed, html, mount } from "/volt.js";
6
6
 
7
- const { available, current, defaultPort } = await (await fetch("/setup/state")).json();
7
+ const { available, themes = [], current, defaultPort, configDefaultPort = 5050 } = await (await fetch("/setup/state")).json();
8
8
  const depsOf = Object.fromEntries(available.map((a) => [a.name, a.dependsOn || []]));
9
9
  const order = available.map((a) => a.name);
10
10
  const enabledNow = new Set(String(current.VOLT_ADDONS || "").split(",").map((s) => s.trim()).filter(Boolean));
@@ -28,6 +28,12 @@ const state = signal({
28
28
  // detect the admin's timezone from their browser (the wizard runs here), so
29
29
  // dates render in their zone — not the server's (usually UTC on a host).
30
30
  tz: current.SITE_TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
31
+ siteName: current.SITE_NAME || "",
32
+ siteUrl: current.SITE_URL || "",
33
+ configPort: current.CONFIG_PORT || "",
34
+ theme: current.THEME || "",
35
+ aiProvider: current.AI_PROVIDER || "anthropic",
36
+ aiKey: current.ANTHROPIC_API_KEY || current.OPENAI_API_KEY || current.GEMINI_API_KEY || "",
31
37
  });
32
38
  const set = (patch) => state({ ...state(), ...patch });
33
39
  const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
@@ -67,6 +73,15 @@ function genEnv(s) {
67
73
  const eff = effective(s);
68
74
  const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${clean(s.port)}`];
69
75
  if (s.tz) out.push(`SITE_TZ=${clean(s.tz)}`); // admin's timezone, for date display
76
+ if (s.siteName) out.push(`SITE_NAME=${clean(s.siteName)}`);
77
+ if (s.siteUrl) out.push(`SITE_URL=${clean(s.siteUrl)}`);
78
+ if (s.configPort) out.push(`CONFIG_PORT=${clean(s.configPort)}`);
79
+ if ((eff.includes("pages") || eff.includes("posts")) && s.theme) out.push(`THEME=${clean(s.theme)}`);
80
+ if (s.aiKey) {
81
+ out.push(`AI_PROVIDER=${clean(s.aiProvider)}`);
82
+ const keyVar = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", gemini: "GEMINI_API_KEY" }[s.aiProvider] || "ANTHROPIC_API_KEY";
83
+ out.push(`${keyVar}=${clean(s.aiKey)}`);
84
+ }
70
85
  if (eff.includes("db")) {
71
86
  out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
72
87
  if (s.dbDriver === "mongodb") {
@@ -104,6 +119,24 @@ const mediaDriver = computed(() => state().mediaDriver);
104
119
  const hasDb = computed(() => eff().includes("db"));
105
120
  const hasMailer = computed(() => eff().includes("mailer"));
106
121
  const hasMedia = computed(() => eff().includes("media"));
122
+ const hasContent = computed(() => eff().includes("pages") || eff().includes("posts")); // themes apply to pages/posts
123
+
124
+ // "Customize": copy the selected bundled theme to pages/_theme.js, then use it
125
+ // locally (THEME cleared) so edits take effect.
126
+ async function ejectTheme() {
127
+ const theme = state().theme;
128
+ if (!theme) return;
129
+ status("Copying theme…");
130
+ try {
131
+ const r = await (await fetch("/setup/eject-theme", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ theme }) })).json();
132
+ if (r.ok) {
133
+ set({ theme: "" });
134
+ status(`Copied ${theme} → ${r.path}. Edit it freely; THEME was cleared so your local copy is used.`);
135
+ } else status("Error: " + (r.error || "?"));
136
+ } catch {
137
+ status("Network error copying theme.");
138
+ }
139
+ }
107
140
 
108
141
  async function testDb() {
109
142
  const s = state();
@@ -201,6 +234,34 @@ const mediaSettings = () =>
201
234
  ? html`${field("S3_ENDPOINT", "s3Endpoint", "https://nyc3.digitaloceanspaces.com")}${field("S3_REGION", "s3Region", "us-east-1")}${field("S3_BUCKET", "s3Bucket", "my-space")}${field("S3_KEY", "s3Key", "access key")}${field("S3_SECRET", "s3Secret", "secret key")}${field("S3_PUBLIC_BASE (optional CDN base)", "s3PublicBase", "https://cdn.example.com")}`
202
235
  : null}`;
203
236
 
237
+ // theme chooser: a bundled theme (or the built-in/local one), with Customize
238
+ const themePicker = () =>
239
+ html`<div class="mb-2">
240
+ <label class="form-label small mb-1">Theme (THEME)</label>
241
+ <select class="form-select" value=${() => state().theme} onchange=${(e) => set({ theme: e.target.value })}>
242
+ <option value="">default — built-in, or your pages/_theme.js</option>
243
+ ${themes.map((t) => html`<option value=${t.name}>${t.name}${t.description ? " — " + t.description : ""}</option>`)}
244
+ </select>
245
+ ${() =>
246
+ state().theme
247
+ ? html`<button class="btn btn-sm btn-outline-secondary mt-1" onclick=${ejectTheme}>Customize → copy to pages/_theme.js</button>`
248
+ : html`<div class="small text-muted mt-1">Pick a starter theme, or keep the built-in / your local <code>pages/_theme.js</code>.</div>`}
249
+ </div>`;
250
+
251
+ // AI keys (optional) — used by the WYSIWYG editor's assistant. Kept server-side.
252
+ const aiSettings = () =>
253
+ html`<details class="mb-2"><summary class="form-label small mb-0" style="cursor:pointer">AI keys (optional) — for the editor's assistant</summary>
254
+ <div class="mt-2">
255
+ <label class="form-label small mb-1">Provider (AI_PROVIDER)</label>
256
+ <select class="form-select mb-2" value=${() => state().aiProvider} onchange=${(e) => set({ aiProvider: e.target.value })}>
257
+ <option value="anthropic">Anthropic (Claude)</option>
258
+ <option value="openai">OpenAI</option>
259
+ <option value="gemini">Google Gemini</option>
260
+ </select>
261
+ ${field("API key — stays server-side, written to .env", "aiKey", "sk-…")}
262
+ </div>
263
+ </details>`;
264
+
204
265
  mount(
205
266
  "#app",
206
267
  available.length
@@ -213,9 +274,14 @@ mount(
213
274
  html`<div class="card-x p-4 mb-3">
214
275
  <h2 class="h6 mb-3">Settings</h2>
215
276
  ${field("PORT", "port", String(defaultPort))}
277
+ ${field("SITE_NAME", "siteName", "My Site")}
278
+ ${() => (hasContent() ? themePicker() : null)}
216
279
  ${() => (hasDb() ? dbSettings() : null)}
217
280
  ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
218
281
  ${() => (hasMedia() ? mediaSettings() : null)}
282
+ ${aiSettings()}
283
+ ${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
284
+ ${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
219
285
  </div>`,
220
286
  html`<div class="card-x p-4 mb-3">
221
287
  <div class="d-flex justify-content-between align-items-center mb-2">
@@ -20,7 +20,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
20
  const ENV_PATH = path.join(__dirname, ".env");
21
21
  const PKG_PATH = path.join(__dirname, "package.json");
22
22
  const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
23
+ const THEMES_DIR = path.join(__dirname, ".volt", "themes"); // bundled themes the wizard can pick
23
24
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
25
+ const CONFIG_DEFAULT_PORT = 5050; // the --edit/--studio config UI's default port (its own, so it never clashes with a running app)
24
26
 
25
27
  // `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
26
28
  // --edit/--studio dodge a port the running app already holds, and runs the app
@@ -37,7 +39,7 @@ function cliPort() {
37
39
  // then the app's PORT, then the date-port.
38
40
  function configPort() {
39
41
  const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
40
- return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || Number(process.env.PORT) || Number(env.PORT) || DEFAULT_PORT;
42
+ return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
41
43
  }
42
44
  const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
43
45
  const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
@@ -68,6 +70,23 @@ function availableAddons() {
68
70
  });
69
71
  }
70
72
 
73
+ // Themes bundled by create-volt (under .volt/themes), pickable in the wizard.
74
+ function availableThemes() {
75
+ if (!fs.existsSync(THEMES_DIR)) return [];
76
+ return fs
77
+ .readdirSync(THEMES_DIR, { withFileTypes: true })
78
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(THEMES_DIR, e.name, "index.js")))
79
+ .map((e) => {
80
+ let description = "";
81
+ try {
82
+ description = JSON.parse(fs.readFileSync(path.join(THEMES_DIR, e.name, "meta.json"), "utf8")).description;
83
+ } catch {
84
+ /* no meta */
85
+ }
86
+ return { name: e.name, description };
87
+ });
88
+ }
89
+
71
90
  // Which add-ons does VOLT_ADDONS turn on (dependencies expanded)?
72
91
  function enabledFrom(env) {
73
92
  const metas = Object.fromEntries(availableAddons().map((a) => [a.name, a]));
@@ -266,7 +285,27 @@ function startSetup() {
266
285
  }
267
286
  if (req.method === "GET" && p === "/setup/state") {
268
287
  res.setHeader("Content-Type", "application/json");
269
- return res.end(JSON.stringify({ available: availableAddons(), current: readEnvFile(), defaultPort: DEFAULT_PORT }));
288
+ return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
289
+ }
290
+ // "Customize": copy a bundled theme into pages/_theme.js so it can be edited.
291
+ if (req.method === "POST" && p === "/setup/eject-theme") {
292
+ let body = "";
293
+ req.on("data", (c) => (body += c));
294
+ req.on("end", () => {
295
+ res.setHeader("Content-Type", "application/json");
296
+ try {
297
+ const { theme } = JSON.parse(body || "{}");
298
+ const src = path.join(THEMES_DIR, String(theme || ""), "index.js");
299
+ if (!theme || !/^[a-z0-9-]+$/i.test(theme) || !fs.existsSync(src)) return res.end(JSON.stringify({ ok: false, error: "unknown theme" }));
300
+ const dest = path.join(__dirname, "pages", "_theme.js");
301
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
302
+ fs.copyFileSync(src, dest);
303
+ res.end(JSON.stringify({ ok: true, path: "pages/_theme.js" }));
304
+ } catch (e) {
305
+ res.end(JSON.stringify({ ok: false, error: e.message }));
306
+ }
307
+ });
308
+ return;
270
309
  }
271
310
  if (req.method === "POST" && p === "/setup/test-db") {
272
311
  let body = "";
@@ -4,7 +4,7 @@
4
4
  // just config.
5
5
  import { signal, computed, html, mount } from "/volt.js";
6
6
 
7
- const { available, current, defaultPort } = await (await fetch("/setup/state")).json();
7
+ const { available, themes = [], current, defaultPort, configDefaultPort = 5050 } = await (await fetch("/setup/state")).json();
8
8
  const depsOf = Object.fromEntries(available.map((a) => [a.name, a.dependsOn || []]));
9
9
  const order = available.map((a) => a.name);
10
10
  const enabledNow = new Set(String(current.VOLT_ADDONS || "").split(",").map((s) => s.trim()).filter(Boolean));
@@ -28,6 +28,12 @@ const state = signal({
28
28
  // detect the admin's timezone from their browser (the wizard runs here), so
29
29
  // dates render in their zone — not the server's (usually UTC on a host).
30
30
  tz: current.SITE_TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
31
+ siteName: current.SITE_NAME || "",
32
+ siteUrl: current.SITE_URL || "",
33
+ configPort: current.CONFIG_PORT || "",
34
+ theme: current.THEME || "",
35
+ aiProvider: current.AI_PROVIDER || "anthropic",
36
+ aiKey: current.ANTHROPIC_API_KEY || current.OPENAI_API_KEY || current.GEMINI_API_KEY || "",
31
37
  });
32
38
  const set = (patch) => state({ ...state(), ...patch });
33
39
  const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
@@ -67,6 +73,15 @@ function genEnv(s) {
67
73
  const eff = effective(s);
68
74
  const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${clean(s.port)}`];
69
75
  if (s.tz) out.push(`SITE_TZ=${clean(s.tz)}`); // admin's timezone, for date display
76
+ if (s.siteName) out.push(`SITE_NAME=${clean(s.siteName)}`);
77
+ if (s.siteUrl) out.push(`SITE_URL=${clean(s.siteUrl)}`);
78
+ if (s.configPort) out.push(`CONFIG_PORT=${clean(s.configPort)}`);
79
+ if ((eff.includes("pages") || eff.includes("posts")) && s.theme) out.push(`THEME=${clean(s.theme)}`);
80
+ if (s.aiKey) {
81
+ out.push(`AI_PROVIDER=${clean(s.aiProvider)}`);
82
+ const keyVar = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", gemini: "GEMINI_API_KEY" }[s.aiProvider] || "ANTHROPIC_API_KEY";
83
+ out.push(`${keyVar}=${clean(s.aiKey)}`);
84
+ }
70
85
  if (eff.includes("db")) {
71
86
  out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
72
87
  if (s.dbDriver === "mongodb") {
@@ -104,6 +119,24 @@ const mediaDriver = computed(() => state().mediaDriver);
104
119
  const hasDb = computed(() => eff().includes("db"));
105
120
  const hasMailer = computed(() => eff().includes("mailer"));
106
121
  const hasMedia = computed(() => eff().includes("media"));
122
+ const hasContent = computed(() => eff().includes("pages") || eff().includes("posts")); // themes apply to pages/posts
123
+
124
+ // "Customize": copy the selected bundled theme to pages/_theme.js, then use it
125
+ // locally (THEME cleared) so edits take effect.
126
+ async function ejectTheme() {
127
+ const theme = state().theme;
128
+ if (!theme) return;
129
+ status("Copying theme…");
130
+ try {
131
+ const r = await (await fetch("/setup/eject-theme", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ theme }) })).json();
132
+ if (r.ok) {
133
+ set({ theme: "" });
134
+ status(`Copied ${theme} → ${r.path}. Edit it freely; THEME was cleared so your local copy is used.`);
135
+ } else status("Error: " + (r.error || "?"));
136
+ } catch {
137
+ status("Network error copying theme.");
138
+ }
139
+ }
107
140
 
108
141
  async function testDb() {
109
142
  const s = state();
@@ -201,6 +234,34 @@ const mediaSettings = () =>
201
234
  ? html`${field("S3_ENDPOINT", "s3Endpoint", "https://nyc3.digitaloceanspaces.com")}${field("S3_REGION", "s3Region", "us-east-1")}${field("S3_BUCKET", "s3Bucket", "my-space")}${field("S3_KEY", "s3Key", "access key")}${field("S3_SECRET", "s3Secret", "secret key")}${field("S3_PUBLIC_BASE (optional CDN base)", "s3PublicBase", "https://cdn.example.com")}`
202
235
  : null}`;
203
236
 
237
+ // theme chooser: a bundled theme (or the built-in/local one), with Customize
238
+ const themePicker = () =>
239
+ html`<div class="mb-2">
240
+ <label class="form-label small mb-1">Theme (THEME)</label>
241
+ <select class="form-select" value=${() => state().theme} onchange=${(e) => set({ theme: e.target.value })}>
242
+ <option value="">default — built-in, or your pages/_theme.js</option>
243
+ ${themes.map((t) => html`<option value=${t.name}>${t.name}${t.description ? " — " + t.description : ""}</option>`)}
244
+ </select>
245
+ ${() =>
246
+ state().theme
247
+ ? html`<button class="btn btn-sm btn-outline-secondary mt-1" onclick=${ejectTheme}>Customize → copy to pages/_theme.js</button>`
248
+ : html`<div class="small text-muted mt-1">Pick a starter theme, or keep the built-in / your local <code>pages/_theme.js</code>.</div>`}
249
+ </div>`;
250
+
251
+ // AI keys (optional) — used by the WYSIWYG editor's assistant. Kept server-side.
252
+ const aiSettings = () =>
253
+ html`<details class="mb-2"><summary class="form-label small mb-0" style="cursor:pointer">AI keys (optional) — for the editor's assistant</summary>
254
+ <div class="mt-2">
255
+ <label class="form-label small mb-1">Provider (AI_PROVIDER)</label>
256
+ <select class="form-select mb-2" value=${() => state().aiProvider} onchange=${(e) => set({ aiProvider: e.target.value })}>
257
+ <option value="anthropic">Anthropic (Claude)</option>
258
+ <option value="openai">OpenAI</option>
259
+ <option value="gemini">Google Gemini</option>
260
+ </select>
261
+ ${field("API key — stays server-side, written to .env", "aiKey", "sk-…")}
262
+ </div>
263
+ </details>`;
264
+
204
265
  mount(
205
266
  "#app",
206
267
  available.length
@@ -213,9 +274,14 @@ mount(
213
274
  html`<div class="card-x p-4 mb-3">
214
275
  <h2 class="h6 mb-3">Settings</h2>
215
276
  ${field("PORT", "port", String(defaultPort))}
277
+ ${field("SITE_NAME", "siteName", "My Site")}
278
+ ${() => (hasContent() ? themePicker() : null)}
216
279
  ${() => (hasDb() ? dbSettings() : null)}
217
280
  ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
218
281
  ${() => (hasMedia() ? mediaSettings() : null)}
282
+ ${aiSettings()}
283
+ ${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
284
+ ${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
219
285
  </div>`,
220
286
  html`<div class="card-x p-4 mb-3">
221
287
  <div class="d-flex justify-content-between align-items-center mb-2">
@@ -21,7 +21,9 @@ 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)
25
27
 
26
28
  // `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
27
29
  // --edit/--studio dodge a port the running app already holds, and runs the app
@@ -38,7 +40,7 @@ function cliPort() {
38
40
  // then the app's PORT, then the date-port.
39
41
  function configPort() {
40
42
  const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
41
- return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || Number(process.env.PORT) || Number(env.PORT) || DEFAULT_PORT;
43
+ return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
42
44
  }
43
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" };
44
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" };
@@ -69,6 +71,23 @@ function availableAddons() {
69
71
  });
70
72
  }
71
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
+
72
91
  // Which add-ons does VOLT_ADDONS turn on (dependencies expanded)?
73
92
  function enabledFrom(env) {
74
93
  const metas = Object.fromEntries(availableAddons().map((a) => [a.name, a]));
@@ -292,7 +311,27 @@ function startSetup() {
292
311
  }
293
312
  if (req.method === "GET" && p === "/setup/state") {
294
313
  res.setHeader("Content-Type", "application/json");
295
- 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;
296
335
  }
297
336
  if (req.method === "POST" && p === "/setup/test-db") {
298
337
  let body = "";
@@ -4,7 +4,7 @@
4
4
  // just config.
5
5
  import { signal, computed, html, mount } from "/volt.js";
6
6
 
7
- const { available, current, defaultPort } = await (await fetch("/setup/state")).json();
7
+ const { available, themes = [], current, defaultPort, configDefaultPort = 5050 } = await (await fetch("/setup/state")).json();
8
8
  const depsOf = Object.fromEntries(available.map((a) => [a.name, a.dependsOn || []]));
9
9
  const order = available.map((a) => a.name);
10
10
  const enabledNow = new Set(String(current.VOLT_ADDONS || "").split(",").map((s) => s.trim()).filter(Boolean));
@@ -28,6 +28,12 @@ const state = signal({
28
28
  // detect the admin's timezone from their browser (the wizard runs here), so
29
29
  // dates render in their zone — not the server's (usually UTC on a host).
30
30
  tz: current.SITE_TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
31
+ siteName: current.SITE_NAME || "",
32
+ siteUrl: current.SITE_URL || "",
33
+ configPort: current.CONFIG_PORT || "",
34
+ theme: current.THEME || "",
35
+ aiProvider: current.AI_PROVIDER || "anthropic",
36
+ aiKey: current.ANTHROPIC_API_KEY || current.OPENAI_API_KEY || current.GEMINI_API_KEY || "",
31
37
  });
32
38
  const set = (patch) => state({ ...state(), ...patch });
33
39
  const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
@@ -67,6 +73,15 @@ function genEnv(s) {
67
73
  const eff = effective(s);
68
74
  const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${clean(s.port)}`];
69
75
  if (s.tz) out.push(`SITE_TZ=${clean(s.tz)}`); // admin's timezone, for date display
76
+ if (s.siteName) out.push(`SITE_NAME=${clean(s.siteName)}`);
77
+ if (s.siteUrl) out.push(`SITE_URL=${clean(s.siteUrl)}`);
78
+ if (s.configPort) out.push(`CONFIG_PORT=${clean(s.configPort)}`);
79
+ if ((eff.includes("pages") || eff.includes("posts")) && s.theme) out.push(`THEME=${clean(s.theme)}`);
80
+ if (s.aiKey) {
81
+ out.push(`AI_PROVIDER=${clean(s.aiProvider)}`);
82
+ const keyVar = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", gemini: "GEMINI_API_KEY" }[s.aiProvider] || "ANTHROPIC_API_KEY";
83
+ out.push(`${keyVar}=${clean(s.aiKey)}`);
84
+ }
70
85
  if (eff.includes("db")) {
71
86
  out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
72
87
  if (s.dbDriver === "mongodb") {
@@ -104,6 +119,24 @@ const mediaDriver = computed(() => state().mediaDriver);
104
119
  const hasDb = computed(() => eff().includes("db"));
105
120
  const hasMailer = computed(() => eff().includes("mailer"));
106
121
  const hasMedia = computed(() => eff().includes("media"));
122
+ const hasContent = computed(() => eff().includes("pages") || eff().includes("posts")); // themes apply to pages/posts
123
+
124
+ // "Customize": copy the selected bundled theme to pages/_theme.js, then use it
125
+ // locally (THEME cleared) so edits take effect.
126
+ async function ejectTheme() {
127
+ const theme = state().theme;
128
+ if (!theme) return;
129
+ status("Copying theme…");
130
+ try {
131
+ const r = await (await fetch("/setup/eject-theme", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ theme }) })).json();
132
+ if (r.ok) {
133
+ set({ theme: "" });
134
+ status(`Copied ${theme} → ${r.path}. Edit it freely; THEME was cleared so your local copy is used.`);
135
+ } else status("Error: " + (r.error || "?"));
136
+ } catch {
137
+ status("Network error copying theme.");
138
+ }
139
+ }
107
140
 
108
141
  async function testDb() {
109
142
  const s = state();
@@ -201,6 +234,34 @@ const mediaSettings = () =>
201
234
  ? html`${field("S3_ENDPOINT", "s3Endpoint", "https://nyc3.digitaloceanspaces.com")}${field("S3_REGION", "s3Region", "us-east-1")}${field("S3_BUCKET", "s3Bucket", "my-space")}${field("S3_KEY", "s3Key", "access key")}${field("S3_SECRET", "s3Secret", "secret key")}${field("S3_PUBLIC_BASE (optional CDN base)", "s3PublicBase", "https://cdn.example.com")}`
202
235
  : null}`;
203
236
 
237
+ // theme chooser: a bundled theme (or the built-in/local one), with Customize
238
+ const themePicker = () =>
239
+ html`<div class="mb-2">
240
+ <label class="form-label small mb-1">Theme (THEME)</label>
241
+ <select class="form-select" value=${() => state().theme} onchange=${(e) => set({ theme: e.target.value })}>
242
+ <option value="">default — built-in, or your pages/_theme.js</option>
243
+ ${themes.map((t) => html`<option value=${t.name}>${t.name}${t.description ? " — " + t.description : ""}</option>`)}
244
+ </select>
245
+ ${() =>
246
+ state().theme
247
+ ? html`<button class="btn btn-sm btn-outline-secondary mt-1" onclick=${ejectTheme}>Customize → copy to pages/_theme.js</button>`
248
+ : html`<div class="small text-muted mt-1">Pick a starter theme, or keep the built-in / your local <code>pages/_theme.js</code>.</div>`}
249
+ </div>`;
250
+
251
+ // AI keys (optional) — used by the WYSIWYG editor's assistant. Kept server-side.
252
+ const aiSettings = () =>
253
+ html`<details class="mb-2"><summary class="form-label small mb-0" style="cursor:pointer">AI keys (optional) — for the editor's assistant</summary>
254
+ <div class="mt-2">
255
+ <label class="form-label small mb-1">Provider (AI_PROVIDER)</label>
256
+ <select class="form-select mb-2" value=${() => state().aiProvider} onchange=${(e) => set({ aiProvider: e.target.value })}>
257
+ <option value="anthropic">Anthropic (Claude)</option>
258
+ <option value="openai">OpenAI</option>
259
+ <option value="gemini">Google Gemini</option>
260
+ </select>
261
+ ${field("API key — stays server-side, written to .env", "aiKey", "sk-…")}
262
+ </div>
263
+ </details>`;
264
+
204
265
  mount(
205
266
  "#app",
206
267
  available.length
@@ -213,9 +274,14 @@ mount(
213
274
  html`<div class="card-x p-4 mb-3">
214
275
  <h2 class="h6 mb-3">Settings</h2>
215
276
  ${field("PORT", "port", String(defaultPort))}
277
+ ${field("SITE_NAME", "siteName", "My Site")}
278
+ ${() => (hasContent() ? themePicker() : null)}
216
279
  ${() => (hasDb() ? dbSettings() : null)}
217
280
  ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
218
281
  ${() => (hasMedia() ? mediaSettings() : null)}
282
+ ${aiSettings()}
283
+ ${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
284
+ ${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
219
285
  </div>`,
220
286
  html`<div class="card-x p-4 mb-3">
221
287
  <div class="d-flex justify-content-between align-items-center mb-2">