create-volt 0.52.0 → 0.54.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,25 @@ 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.54.0] - 2026-07-04
8
+
9
+ ### Added
10
+ - **Config editor renders themed.** The in-config WYSIWYG loads the active theme's
11
+ CSS (new `/setup/theme-css` → RTEPro `exportCSS`), so pages look like the published
12
+ site as you edit — new pages included.
13
+
14
+ ### Fixed
15
+ - **Log analytics bot/attack counts were always 0** — they read `.bot`/`.attack`,
16
+ but mir-sentinel's parseLine returns `.isBot`/`.isAttack`. Fixed in `--logs`.
17
+
18
+ ## [0.53.0] - 2026-07-04
19
+
20
+ ### Added
21
+ - **`create-volt env`** — writes a documented `.env.example` for a deploying admin
22
+ to fill in: every var grouped and commented, structural values (PORT, SITE_NAME,
23
+ VOLT_ADDONS…) seeded from an existing `.env`, secrets left blank. `--print` to
24
+ stdout, `--force` to overwrite.
25
+
7
26
  ## [0.52.0] - 2026-07-04
8
27
 
9
28
  ### Added
@@ -684,6 +703,8 @@ All notable changes to `create-volt` are documented here. The format follows
684
703
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
685
704
  and auto-detects npm / pnpm / yarn / bun for the install step.
686
705
 
706
+ [0.54.0]: https://github.com/MIR-2025/volt/releases/tag/v0.54.0
707
+ [0.53.0]: https://github.com/MIR-2025/volt/releases/tag/v0.53.0
687
708
  [0.52.0]: https://github.com/MIR-2025/volt/releases/tag/v0.52.0
688
709
  [0.51.0]: https://github.com/MIR-2025/volt/releases/tag/v0.51.0
689
710
  [0.50.0]: https://github.com/MIR-2025/volt/releases/tag/v0.50.0
package/index.js CHANGED
@@ -318,6 +318,86 @@ if (positionals[0] === "config") {
318
318
  process.exit(res.status ?? 0);
319
319
  }
320
320
 
321
+ // --- `env` subcommand: write a documented .env.example for a deploying admin to
322
+ // fill in. Seeds structural (non-secret) values from an existing .env; secrets
323
+ // stay blank. `--print` to stdout, `--force` to overwrite. ---
324
+ if (positionals[0] === "env") {
325
+ const cwd = process.cwd();
326
+ const cur = {};
327
+ const envPath = path.join(cwd, ".env");
328
+ if (fs.existsSync(envPath)) {
329
+ for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
330
+ const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/);
331
+ if (m) cur[m[1]] = m[2].replace(/\s+#.*$/, "");
332
+ }
333
+ }
334
+ const v = (k, d = "") => cur[k] || d;
335
+ const tmpl = `# .env — Volt configuration. Fill in what your deployment needs, then keep this
336
+ # file OUT of git. Generated by \`create-volt env\`. Uncomment the lines you use.
337
+
338
+ # --- Core ---
339
+ PORT=${v("PORT", "3000")}
340
+ SITE_NAME=${v("SITE_NAME", "My Site")}
341
+ SITE_URL=${v("SITE_URL")} # absolute base URL (RSS/canonical/OG), e.g. https://example.com
342
+ # SITE_TZ=${v("SITE_TZ")} # IANA timezone for dates, e.g. America/New_York
343
+ # CONFIG_PORT=5050 # the --edit/--studio config UI's own port
344
+
345
+ # --- Add-ons (comma-separated backends to enable) ---
346
+ VOLT_ADDONS=${v("VOLT_ADDONS", "pages")} # pages,posts,db,auth,mailer,media,realtime
347
+
348
+ # --- Database (add-on: db) ---
349
+ # DB_DRIVER=memory # memory | mongodb | mysql | postgres
350
+ # MONGODB_URI=
351
+ # MONGODB_DATABASE=
352
+ # DATABASE_URL= # mysql:// or postgres:// URL
353
+
354
+ # --- Email (add-on: mailer) — magic-link auth, receipts ---
355
+ # One SMTP_URL, or discrete host/port/user/pass:
356
+ # SMTP_URL=smtps://user:pass@smtp.host:465
357
+ # SMTP_HOST=
358
+ # SMTP_PORT=465
359
+ # SMTP_SECURE=true # true for 465, false for 587 (STARTTLS)
360
+ # SMTP_USER=
361
+ # SMTP_PASS=
362
+ # MAIL_FROM=My Site <hello@example.com>
363
+
364
+ # --- Admin editor (volt-addon-editor) ---
365
+ # ADMIN_PATH=/your-secret-path # the gated editor lives at /<ADMIN_PATH>
366
+ # ADMIN_EMAILS=you@example.com # allowlist of who may log into the editor
367
+
368
+ # --- Media uploads (add-on: media; needs auth) ---
369
+ # MEDIA_DRIVER=local # local | s3
370
+ # S3_ENDPOINT=
371
+ # S3_REGION=
372
+ # S3_BUCKET=
373
+ # S3_KEY=
374
+ # S3_SECRET=
375
+ # S3_PUBLIC_BASE=
376
+
377
+ # --- AI (editor "write with AI") ---
378
+ # AI_PROVIDER=anthropic # anthropic | openai | gemini
379
+ # Bring your own key:
380
+ # ANTHROPIC_API_KEY=
381
+ # OPENAI_API_KEY=
382
+ # GEMINI_API_KEY=
383
+ # — or — the hosted tier (free-capped, then pay-as-you-go):
384
+ # VOLT_AI_TOKEN= # generate one in the config wizard
385
+ # VOLT_AI_GATEWAY=https://voltjs.com/api/ai
386
+ `;
387
+ if (flags.has("--print")) {
388
+ process.stdout.write(tmpl);
389
+ process.exit(0);
390
+ }
391
+ const out = path.join(cwd, ".env.example");
392
+ if (fs.existsSync(out) && !flags.has("--force")) {
393
+ die(`${cyan(".env.example")} already exists — pass ${cyan("--force")} to overwrite, or ${cyan("--print")} for stdout.`);
394
+ }
395
+ fs.writeFileSync(out, tmpl);
396
+ console.log(`\n${green("✔")} Wrote ${bold(".env.example")} — a documented template for a deploying admin.`);
397
+ console.log(dim(` Copy it to .env on the server and fill in the values you need; secrets stay blank here.`));
398
+ process.exit(0);
399
+ }
400
+
321
401
  // Write imported markdown pages to disk and print a summary (shared by both importers).
322
402
  function emitImported(imported, stats, outDir) {
323
403
  fs.mkdirSync(outDir, { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.52.0",
3
+ "version": "0.54.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": {
@@ -440,6 +440,32 @@ function startSetup() {
440
440
  });
441
441
  return;
442
442
  }
443
+ // --- active theme's CSS, so the in-config editor renders pages themed ---
444
+ if (req.method === "GET" && p === "/setup/theme-css") {
445
+ res.setHeader("Content-Type", "text/css; charset=utf-8");
446
+ (async () => {
447
+ const theme = (readEnvFile().THEME || "").trim();
448
+ const load = async (rel) => {
449
+ try {
450
+ return (await imp(rel)).css || "";
451
+ } catch {
452
+ return null;
453
+ }
454
+ };
455
+ let css = null;
456
+ if (theme) css = await load(path.join(".volt", "themes", theme, "index.js"));
457
+ if (css == null && fs.existsSync(path.join(__dirname, "pages", "_theme.js"))) css = await load(path.join("pages", "_theme.js"));
458
+ if (css == null && theme) {
459
+ try {
460
+ css = (await import(`volt-theme-${theme}`)).css || "";
461
+ } catch {
462
+ css = null;
463
+ }
464
+ }
465
+ res.end(css || "");
466
+ })();
467
+ return;
468
+ }
443
469
  // --- content manager: list / read / write / delete pages + posts ---
444
470
  if (req.method === "GET" && p === "/setup/content") {
445
471
  const list = (type) => {
@@ -785,7 +811,7 @@ async function startLogs() {
785
811
  const f = sources()[u.searchParams.get("source")];
786
812
  if (!f) return json(res, { ok: false });
787
813
  const parsed = tail(f, 5000).map((l) => parseLine(l));
788
- return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.bot).length, attacks: parsed.filter((x) => x && x.attack).length });
814
+ return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.isBot).length, attacks: parsed.filter((x) => x && x.isAttack).length });
789
815
  }
790
816
  // add/remove a source ("add servers") — written to .volt/logs.json
791
817
  if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
@@ -323,6 +323,8 @@ async function genToken() {
323
323
  const items = signal({ pages: [], posts: [] });
324
324
  const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
325
325
  let ed = null; // live RTEPro instance for the open editor
326
+ let themeCss = ""; // active theme's CSS, so the editor renders pages themed
327
+ fetch("/setup/theme-css").then((r) => r.text()).then((c) => { themeCss = c; }).catch(() => {});
326
328
  const loadItems = async () => items(await (await fetch("/setup/content")).json());
327
329
  // raw .md → { title, body, isHtml }; RTEPro takes markdown directly (setMarkdown),
328
330
  // so no markdown library is needed.
@@ -333,7 +335,7 @@ function parseDoc(raw) {
333
335
  return { title, body: fm ? raw.slice(fm[0].length) : raw, isHtml: /^format:\s*html\s*$/m.test(front) };
334
336
  }
335
337
  function mountEditor(doc) {
336
- ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic" });
338
+ ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic", exportCSS: themeCss });
337
339
  if (doc && doc.isHtml) ed.setHTML(doc.body || "");
338
340
  else ed.setMarkdown((doc && doc.body) || "");
339
341
  }
@@ -440,6 +440,32 @@ function startSetup() {
440
440
  });
441
441
  return;
442
442
  }
443
+ // --- active theme's CSS, so the in-config editor renders pages themed ---
444
+ if (req.method === "GET" && p === "/setup/theme-css") {
445
+ res.setHeader("Content-Type", "text/css; charset=utf-8");
446
+ (async () => {
447
+ const theme = (readEnvFile().THEME || "").trim();
448
+ const load = async (rel) => {
449
+ try {
450
+ return (await imp(rel)).css || "";
451
+ } catch {
452
+ return null;
453
+ }
454
+ };
455
+ let css = null;
456
+ if (theme) css = await load(path.join(".volt", "themes", theme, "index.js"));
457
+ if (css == null && fs.existsSync(path.join(__dirname, "pages", "_theme.js"))) css = await load(path.join("pages", "_theme.js"));
458
+ if (css == null && theme) {
459
+ try {
460
+ css = (await import(`volt-theme-${theme}`)).css || "";
461
+ } catch {
462
+ css = null;
463
+ }
464
+ }
465
+ res.end(css || "");
466
+ })();
467
+ return;
468
+ }
443
469
  // --- content manager: list / read / write / delete pages + posts ---
444
470
  if (req.method === "GET" && p === "/setup/content") {
445
471
  const list = (type) => {
@@ -785,7 +811,7 @@ async function startLogs() {
785
811
  const f = sources()[u.searchParams.get("source")];
786
812
  if (!f) return json(res, { ok: false });
787
813
  const parsed = tail(f, 5000).map((l) => parseLine(l));
788
- return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.bot).length, attacks: parsed.filter((x) => x && x.attack).length });
814
+ return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.isBot).length, attacks: parsed.filter((x) => x && x.isAttack).length });
789
815
  }
790
816
  // add/remove a source ("add servers") — written to .volt/logs.json
791
817
  if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
@@ -323,6 +323,8 @@ async function genToken() {
323
323
  const items = signal({ pages: [], posts: [] });
324
324
  const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
325
325
  let ed = null; // live RTEPro instance for the open editor
326
+ let themeCss = ""; // active theme's CSS, so the editor renders pages themed
327
+ fetch("/setup/theme-css").then((r) => r.text()).then((c) => { themeCss = c; }).catch(() => {});
326
328
  const loadItems = async () => items(await (await fetch("/setup/content")).json());
327
329
  // raw .md → { title, body, isHtml }; RTEPro takes markdown directly (setMarkdown),
328
330
  // so no markdown library is needed.
@@ -333,7 +335,7 @@ function parseDoc(raw) {
333
335
  return { title, body: fm ? raw.slice(fm[0].length) : raw, isHtml: /^format:\s*html\s*$/m.test(front) };
334
336
  }
335
337
  function mountEditor(doc) {
336
- ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic" });
338
+ ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic", exportCSS: themeCss });
337
339
  if (doc && doc.isHtml) ed.setHTML(doc.body || "");
338
340
  else ed.setMarkdown((doc && doc.body) || "");
339
341
  }
@@ -440,6 +440,32 @@ function startSetup() {
440
440
  });
441
441
  return;
442
442
  }
443
+ // --- active theme's CSS, so the in-config editor renders pages themed ---
444
+ if (req.method === "GET" && p === "/setup/theme-css") {
445
+ res.setHeader("Content-Type", "text/css; charset=utf-8");
446
+ (async () => {
447
+ const theme = (readEnvFile().THEME || "").trim();
448
+ const load = async (rel) => {
449
+ try {
450
+ return (await imp(rel)).css || "";
451
+ } catch {
452
+ return null;
453
+ }
454
+ };
455
+ let css = null;
456
+ if (theme) css = await load(path.join(".volt", "themes", theme, "index.js"));
457
+ if (css == null && fs.existsSync(path.join(__dirname, "pages", "_theme.js"))) css = await load(path.join("pages", "_theme.js"));
458
+ if (css == null && theme) {
459
+ try {
460
+ css = (await import(`volt-theme-${theme}`)).css || "";
461
+ } catch {
462
+ css = null;
463
+ }
464
+ }
465
+ res.end(css || "");
466
+ })();
467
+ return;
468
+ }
443
469
  // --- content manager: list / read / write / delete pages + posts ---
444
470
  if (req.method === "GET" && p === "/setup/content") {
445
471
  const list = (type) => {
@@ -785,7 +811,7 @@ async function startLogs() {
785
811
  const f = sources()[u.searchParams.get("source")];
786
812
  if (!f) return json(res, { ok: false });
787
813
  const parsed = tail(f, 5000).map((l) => parseLine(l));
788
- return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.bot).length, attacks: parsed.filter((x) => x && x.attack).length });
814
+ return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.isBot).length, attacks: parsed.filter((x) => x && x.isAttack).length });
789
815
  }
790
816
  // add/remove a source ("add servers") — written to .volt/logs.json
791
817
  if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
@@ -323,6 +323,8 @@ async function genToken() {
323
323
  const items = signal({ pages: [], posts: [] });
324
324
  const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
325
325
  let ed = null; // live RTEPro instance for the open editor
326
+ let themeCss = ""; // active theme's CSS, so the editor renders pages themed
327
+ fetch("/setup/theme-css").then((r) => r.text()).then((c) => { themeCss = c; }).catch(() => {});
326
328
  const loadItems = async () => items(await (await fetch("/setup/content")).json());
327
329
  // raw .md → { title, body, isHtml }; RTEPro takes markdown directly (setMarkdown),
328
330
  // so no markdown library is needed.
@@ -333,7 +335,7 @@ function parseDoc(raw) {
333
335
  return { title, body: fm ? raw.slice(fm[0].length) : raw, isHtml: /^format:\s*html\s*$/m.test(front) };
334
336
  }
335
337
  function mountEditor(doc) {
336
- ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic" });
338
+ ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic", exportCSS: themeCss });
337
339
  if (doc && doc.isHtml) ed.setHTML(doc.body || "");
338
340
  else ed.setMarkdown((doc && doc.body) || "");
339
341
  }
@@ -466,6 +466,32 @@ function startSetup() {
466
466
  });
467
467
  return;
468
468
  }
469
+ // --- active theme's CSS, so the in-config editor renders pages themed ---
470
+ if (req.method === "GET" && p === "/setup/theme-css") {
471
+ res.setHeader("Content-Type", "text/css; charset=utf-8");
472
+ (async () => {
473
+ const theme = (readEnvFile().THEME || "").trim();
474
+ const load = async (rel) => {
475
+ try {
476
+ return (await imp(rel)).css || "";
477
+ } catch {
478
+ return null;
479
+ }
480
+ };
481
+ let css = null;
482
+ if (theme) css = await load(path.join(".volt", "themes", theme, "index.js"));
483
+ if (css == null && fs.existsSync(path.join(__dirname, "pages", "_theme.js"))) css = await load(path.join("pages", "_theme.js"));
484
+ if (css == null && theme) {
485
+ try {
486
+ css = (await import(`volt-theme-${theme}`)).css || "";
487
+ } catch {
488
+ css = null;
489
+ }
490
+ }
491
+ res.end(css || "");
492
+ })();
493
+ return;
494
+ }
469
495
  // --- content manager: list / read / write / delete pages + posts ---
470
496
  if (req.method === "GET" && p === "/setup/content") {
471
497
  const list = (type) => {
@@ -811,7 +837,7 @@ async function startLogs() {
811
837
  const f = sources()[u.searchParams.get("source")];
812
838
  if (!f) return json(res, { ok: false });
813
839
  const parsed = tail(f, 5000).map((l) => parseLine(l));
814
- return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.bot).length, attacks: parsed.filter((x) => x && x.attack).length });
840
+ return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.isBot).length, attacks: parsed.filter((x) => x && x.isAttack).length });
815
841
  }
816
842
  // add/remove a source ("add servers") — written to .volt/logs.json
817
843
  if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
@@ -323,6 +323,8 @@ async function genToken() {
323
323
  const items = signal({ pages: [], posts: [] });
324
324
  const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
325
325
  let ed = null; // live RTEPro instance for the open editor
326
+ let themeCss = ""; // active theme's CSS, so the editor renders pages themed
327
+ fetch("/setup/theme-css").then((r) => r.text()).then((c) => { themeCss = c; }).catch(() => {});
326
328
  const loadItems = async () => items(await (await fetch("/setup/content")).json());
327
329
  // raw .md → { title, body, isHtml }; RTEPro takes markdown directly (setMarkdown),
328
330
  // so no markdown library is needed.
@@ -333,7 +335,7 @@ function parseDoc(raw) {
333
335
  return { title, body: fm ? raw.slice(fm[0].length) : raw, isHtml: /^format:\s*html\s*$/m.test(front) };
334
336
  }
335
337
  function mountEditor(doc) {
336
- ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic" });
338
+ ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic", exportCSS: themeCss });
337
339
  if (doc && doc.isHtml) ed.setHTML(doc.body || "");
338
340
  else ed.setMarkdown((doc && doc.body) || "");
339
341
  }