create-volt 0.55.1 → 0.56.1

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/index.js +1 -1
  3. package/package.json +1 -1
  4. package/templates/blog/public/volt.js +7 -2
  5. package/templates/blog/server.js +101 -3
  6. package/templates/blog/setup/setup.js +59 -10
  7. package/templates/business/Dockerfile +20 -0
  8. package/templates/business/Procfile +1 -0
  9. package/templates/business/README.md +25 -0
  10. package/templates/business/dockerignore +6 -0
  11. package/templates/business/ecosystem.config.cjs +5 -0
  12. package/templates/business/env +2 -0
  13. package/templates/business/fly.toml +15 -0
  14. package/templates/business/gitignore +5 -0
  15. package/templates/business/package.json +21 -0
  16. package/templates/business/pages/_theme.js +65 -0
  17. package/templates/business/pages/about.md +30 -0
  18. package/templates/business/pages/contact.md +23 -0
  19. package/templates/business/pages/index.md +41 -0
  20. package/templates/business/pages/products.md +27 -0
  21. package/templates/business/public/app.js +89 -0
  22. package/templates/business/public/favicon.webp +0 -0
  23. package/templates/business/public/logo.webp +0 -0
  24. package/templates/business/public/volt-ssr.js +63 -0
  25. package/templates/business/public/volt.js +355 -0
  26. package/templates/business/render.yaml +15 -0
  27. package/templates/business/server.js +1065 -0
  28. package/templates/business/setup/index.html +46 -0
  29. package/templates/business/setup/logs.html +29 -0
  30. package/templates/business/setup/logs.js +58 -0
  31. package/templates/business/setup/setup.js +509 -0
  32. package/templates/business/setup/studio.html +29 -0
  33. package/templates/business/views/index.html +42 -0
  34. package/templates/default/public/volt.js +7 -2
  35. package/templates/default/server.js +101 -3
  36. package/templates/default/setup/setup.js +59 -10
  37. package/templates/docs/public/volt.js +7 -2
  38. package/templates/docs/server.js +101 -3
  39. package/templates/docs/setup/setup.js +59 -10
  40. package/templates/guestbook/public/volt.js +7 -2
  41. package/templates/starter/public/volt.js +7 -2
  42. package/templates/starter/server.js +100 -3
  43. package/templates/starter/setup/setup.js +59 -10
package/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ 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.56.1] - 2026-07-05
8
+
9
+ ### Fixed
10
+ - **Media library thumbnails / editor image previews showed as broken inside the
11
+ config.** The config server served a fixed asset allowlist and had no `/media/`
12
+ route, so uploaded files 404'd in the config UI — even though they uploaded fine
13
+ and render on the live site (the running app serves them via `express.static`).
14
+ The config now serves `public/media/<name>` with a path-traversal guard.
15
+
16
+ ## [0.56.0] - 2026-07-05
17
+
18
+ ### Added
19
+ - **Full site templates.** `--template business` scaffolds a complete multi-page
20
+ site (Home, About, Products, Contact) with a sticky-nav theme, hero, product grid,
21
+ CTA, and swap-your-own media slots — the "install a theme with demo content, then
22
+ make it yours" experience.
23
+ - **Media library in the config.** A new **Media** view uploads / browses / deletes
24
+ images and video (stored in `public/media/`, served at `/media/<name>`) — a
25
+ Bootstrap card grid with thumbnails, copy-URL, and delete.
26
+ - **Editor media, de-base64'd on save.** RTEPro's built-in picker inlines "Choose
27
+ File" uploads as base64; on save they're extracted to `public/media/<hash>.<ext>`
28
+ (content-hash deduped) and the `src` rewritten to a `/media` URL, so pages stay
29
+ lean and editor uploads land in the library.
30
+
31
+ ### Fixed
32
+ - **Boolean attributes.** `readonly=${false}` (and `required`/`multiple`/`hidden`/…)
33
+ now correctly turn the attribute OFF — any value, including the string `"false"`,
34
+ previously left it on. `readonly` also maps to the `readOnly` DOM property.
35
+ - **Hosted-token button.** "Generate a free hosted token" shows its result inline
36
+ next to the button, with a clear message when the gateway isn't reachable (was a
37
+ silent failure buried in the status line).
38
+ - `default/server.js` now imports `node:crypto` — it had relied on the global Web
39
+ Crypto, which has no `createHash`.
40
+
7
41
  ## [0.55.1] - 2026-07-05
8
42
 
9
43
  ### Security
@@ -720,6 +754,8 @@ All notable changes to `create-volt` are documented here. The format follows
720
754
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
721
755
  and auto-detects npm / pnpm / yarn / bun for the install step.
722
756
 
757
+ [0.56.1]: https://github.com/MIR-2025/volt/releases/tag/v0.56.1
758
+ [0.56.0]: https://github.com/MIR-2025/volt/releases/tag/v0.56.0
723
759
  [0.55.1]: https://github.com/MIR-2025/volt/releases/tag/v0.55.1
724
760
  [0.55.0]: https://github.com/MIR-2025/volt/releases/tag/v0.55.0
725
761
  [0.54.0]: https://github.com/MIR-2025/volt/releases/tag/v0.54.0
package/index.js CHANGED
@@ -40,7 +40,7 @@ ${bold("Usage")}
40
40
  npx create-volt@latest studio # browse your data — ephemeral, localhost (like Prisma Studio)
41
41
 
42
42
  ${bold("Options")}
43
- --template <name> Template: default | blog | docs | starter | guestbook (default: default)
43
+ --template <name> Template: default | blog | docs | starter | guestbook | business (default: default)
44
44
  --port <number> Dev port for the app (default: derived from today's date)
45
45
  --skip-install Don't run the package manager install step
46
46
  --no-git Don't initialize a git repository
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.55.1",
3
+ "version": "0.56.1",
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": {
@@ -125,14 +125,19 @@ export function mount(target, ...children) {
125
125
  return parent;
126
126
  }
127
127
 
128
+ // Boolean attributes: presence means "on", so a false/null/"false" value must turn
129
+ // them OFF (readonly="false" is still readonly in HTML). Set via the DOM property;
130
+ // readonly's property is readOnly, so map it.
131
+ const BOOL_ATTRS = new Set(["checked", "disabled", "selected", "readonly", "required", "multiple", "hidden", "autofocus", "open"]);
132
+ const BOOL_PROP = { readonly: "readOnly" };
128
133
  function setAttr(node, name, value) {
129
134
  if (name === "value") {
130
135
  const v = value ?? "";
131
136
  if (node.value !== v) node.value = v; // skip redundant writes — they reset the caret while typing
132
137
  return;
133
138
  }
134
- if (name === "checked" || name === "disabled" || name === "selected") {
135
- node[name] = !!value && value !== "false";
139
+ if (BOOL_ATTRS.has(name)) {
140
+ node[BOOL_PROP[name] || name] = !!value && value !== "false";
136
141
  return;
137
142
  }
138
143
  if (value === false || value == null) {
@@ -11,6 +11,7 @@
11
11
  import http from "node:http";
12
12
  import fs from "node:fs";
13
13
  import path from "node:path";
14
+ import crypto from "node:crypto";
14
15
  import { spawn, spawnSync } from "node:child_process";
15
16
  import { fileURLToPath, pathToFileURL } from "node:url";
16
17
  import os from "node:os";
@@ -312,6 +313,20 @@ function startSetup() {
312
313
  res.setHeader("Content-Type", assets[p][0]);
313
314
  return res.end(assets[p][1]);
314
315
  }
316
+ // Serve uploaded media so library thumbnails + editor previews render inside the
317
+ // config (the running app serves these via express.static; the config didn't).
318
+ if (req.method === "GET" && p.startsWith("/media/")) {
319
+ const base = path.join(__dirname, "public", "media");
320
+ const f = path.resolve(base, decodeURIComponent(p.slice("/media/".length)));
321
+ if ((f === base || f.startsWith(base + path.sep)) && fs.existsSync(f) && fs.statSync(f).isFile()) {
322
+ const ext = (f.split(".").pop() || "").toLowerCase();
323
+ const mime = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", svg: "image/svg+xml", bmp: "image/bmp", ico: "image/x-icon", mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", ogv: "video/ogg", m4v: "video/x-m4v", ogg: "audio/ogg", mp3: "audio/mpeg", wav: "audio/wav" }[ext] || "application/octet-stream";
324
+ res.setHeader("Content-Type", mime);
325
+ return res.end(fs.readFileSync(f));
326
+ }
327
+ res.statusCode = 404;
328
+ return res.end("not found");
329
+ }
315
330
  if (req.method === "GET" && p === "/setup/state") {
316
331
  res.setHeader("Content-Type", "application/json");
317
332
  return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
@@ -350,9 +365,9 @@ function startSetup() {
350
365
  const env = readEnvFile();
351
366
  const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
352
367
  fetch(base + "/api/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ app: env.SITE_NAME || "volt-app" }) })
353
- .then((r) => r.json())
368
+ .then((r) => (r.ok ? r.json() : { ok: false, error: `hosted AI gateway not available (HTTP ${r.status}) — is it deployed?` }))
354
369
  .then((j) => res.end(JSON.stringify(j)))
355
- .catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
370
+ .catch(() => res.end(JSON.stringify({ ok: false, error: "hosted AI gateway unreachable" })));
356
371
  return;
357
372
  }
358
373
  // --- AI proxy for the in-config editor (RTEPro). Uses a local provider key
@@ -466,6 +481,71 @@ function startSetup() {
466
481
  })();
467
482
  return;
468
483
  }
484
+ // --- media library: list / upload / delete files in public/media (served at
485
+ // /media/<name>). Shell-gated (config only). ---
486
+ if (req.method === "GET" && p === "/setup/media") {
487
+ res.setHeader("Content-Type", "application/json");
488
+ const dir = path.join(__dirname, "public", "media");
489
+ let items = [];
490
+ try {
491
+ items = fs
492
+ .readdirSync(dir)
493
+ .filter((f) => !f.startsWith("."))
494
+ .map((f) => ({ name: f, url: "/media/" + f, size: fs.statSync(path.join(dir, f)).size }))
495
+ .sort((a, b) => a.name.localeCompare(b.name));
496
+ } catch {
497
+ /* no media dir yet */
498
+ }
499
+ return res.end(JSON.stringify({ items }));
500
+ }
501
+ if (req.method === "POST" && p === "/setup/media/upload") {
502
+ res.setHeader("Content-Type", "application/json");
503
+ const name = (u.searchParams.get("name") || "").replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "").slice(0, 120);
504
+ if (!name || !/\.[A-Za-z0-9]+$/.test(name)) return res.end(JSON.stringify({ ok: false, error: "bad filename" }));
505
+ const dir = path.join(__dirname, "public", "media");
506
+ fs.mkdirSync(dir, { recursive: true });
507
+ const chunks = [];
508
+ let size = 0;
509
+ let tooBig = false;
510
+ req.on("data", (c) => {
511
+ if (tooBig) return;
512
+ size += c.length;
513
+ if (size > 100 * 1024 * 1024) tooBig = true;
514
+ else chunks.push(c);
515
+ });
516
+ req.on("end", () => {
517
+ if (tooBig) {
518
+ res.statusCode = 413;
519
+ return res.end(JSON.stringify({ ok: false, error: "file too large (max 100MB)" }));
520
+ }
521
+ try {
522
+ fs.writeFileSync(path.join(dir, name), Buffer.concat(chunks));
523
+ res.end(JSON.stringify({ ok: true, url: "/media/" + name, name }));
524
+ } catch (e) {
525
+ res.statusCode = 400;
526
+ res.end(JSON.stringify({ ok: false, error: e.message }));
527
+ }
528
+ });
529
+ return;
530
+ }
531
+ if (req.method === "POST" && p === "/setup/media/delete") {
532
+ let mbody = "";
533
+ req.on("data", (c) => (mbody += c));
534
+ req.on("end", () => {
535
+ res.setHeader("Content-Type", "application/json");
536
+ try {
537
+ const { name } = JSON.parse(mbody || "{}");
538
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name || "")) throw new Error("bad name");
539
+ const f = path.join(__dirname, "public", "media", name);
540
+ if (fs.existsSync(f)) fs.unlinkSync(f);
541
+ res.end(JSON.stringify({ ok: true }));
542
+ } catch (e) {
543
+ res.statusCode = 400;
544
+ res.end(JSON.stringify({ ok: false, error: e.message }));
545
+ }
546
+ });
547
+ return;
548
+ }
469
549
  // --- content manager: list / read / write / delete pages + posts ---
470
550
  if (req.method === "GET" && p === "/setup/content") {
471
551
  const list = (type) => {
@@ -506,7 +586,25 @@ function startSetup() {
506
586
  return res.end(JSON.stringify({ ok: true }));
507
587
  }
508
588
  fs.mkdirSync(dir, { recursive: true });
509
- fs.writeFileSync(file, String(body ?? ""));
589
+ // RTEPro's media picker inlines "Choose File" uploads as base64 data URLs.
590
+ // Extract them to public/media/<hash>.<ext> and rewrite the src, so pages
591
+ // stay lean and the uploads land in the media library.
592
+ const mediaDir = path.join(__dirname, "public", "media");
593
+ const extFor = (mime) =>
594
+ ({ "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", "image/avif": "avif", "image/svg+xml": "svg", "video/mp4": "mp4", "video/webm": "webm", "video/ogg": "ogv", "audio/mpeg": "mp3", "audio/ogg": "ogg", "audio/wav": "wav" }[mime.toLowerCase()] || (mime.split("/")[1] || "bin").replace(/[^a-z0-9]+/gi, "").slice(0, 8) || "bin");
595
+ const finalBody = String(body ?? "").replace(/(<(?:img|video|audio|source)\b[^>]*?\ssrc=")data:([\w.+-]+\/[\w.+-]+);base64,([^"]+)(")/gi, (m, pre, mime, b64, post) => {
596
+ try {
597
+ const buf = Buffer.from(b64, "base64");
598
+ const name = crypto.createHash("sha1").update(buf).digest("hex").slice(0, 16) + "." + extFor(mime);
599
+ fs.mkdirSync(mediaDir, { recursive: true });
600
+ const dest = path.join(mediaDir, name);
601
+ if (!fs.existsSync(dest)) fs.writeFileSync(dest, buf);
602
+ return pre + "/media/" + name + post;
603
+ } catch {
604
+ return m;
605
+ }
606
+ });
607
+ fs.writeFileSync(file, finalBody);
510
608
  res.end(JSON.stringify({ ok: true, file: (type === "post" ? "posts/" : "pages/") + slug + ".md" }));
511
609
  } catch (e) {
512
610
  res.statusCode = 400;
@@ -43,6 +43,7 @@ const status = signal("");
43
43
  const dbTest = signal("");
44
44
  const smtpTest = signal("");
45
45
  const aiTest = signal("");
46
+ const genMsg = signal("");
46
47
  const testResult = (m) => (m ? html`<span class="small ms-2 ${m.startsWith("✓") ? "text-success" : m.startsWith("✗") ? "text-danger" : "text-muted"}">${m}</span>` : "");
47
48
  const envObj = () => Object.fromEntries(env().split("\n").filter((l) => /^[A-Za-z0-9_]+=/.test(l)).map((l) => { const i = l.indexOf("="); return [l.slice(0, i), l.slice(i + 1)]; }));
48
49
 
@@ -293,18 +294,66 @@ const aiSettings = () =>
293
294
  ${() => html`<a class="small d-inline-block mb-1" href=${AI_KEY_URL[state().aiProvider] || AI_KEY_URL.anthropic} target="_blank" rel="noopener">Get a ${state().aiProvider} key → paste it below (stays server-side in .env)</a>`}
294
295
  ${field("API key", "aiKey", "sk-…")}
295
296
  <div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
296
- ${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
297
+ ${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>${() => testResult(genMsg())}`)}
297
298
  <div class="mt-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testAi}>Test AI</button>${() => testResult(aiTest())}</div>
298
299
  </div>
299
300
  </details>`;
300
301
 
301
302
  // --- Manage content (a second screen reached via "Manage content →") ---
302
- const view = signal("config"); // "config" | "manage"
303
- // Desktop-only config: keep settings readable, but let the editor go wide.
303
+ const view = signal("config"); // "config" | "manage" | "media"
304
+ // Desktop-only config: keep settings readable, but let the editor + library go wide.
304
305
  effect(() => {
305
306
  const w = document.getElementById("wrap");
306
- if (w) w.style.maxWidth = view() === "manage" ? "min(1200px, 95vw)" : "720px";
307
+ if (w) w.style.maxWidth = view() !== "config" ? "min(1200px, 95vw)" : "720px";
307
308
  });
309
+ // --- media library: upload / browse / delete files served at /media/<name> ---
310
+ const media = signal([]);
311
+ const loadMedia = async () => media(((await (await fetch("/setup/media")).json()).items) || []);
312
+ async function uploadMedia(file) {
313
+ status(`Uploading ${file.name}…`);
314
+ try {
315
+ const r = await (await fetch("/setup/media/upload?name=" + encodeURIComponent(file.name), { method: "POST", body: file })).json();
316
+ status(r.ok ? `Uploaded → ${r.url}` : `Upload failed: ${r.error || "?"}`);
317
+ } catch {
318
+ status("Upload failed.");
319
+ }
320
+ loadMedia();
321
+ }
322
+ async function delMedia(name) {
323
+ if (typeof confirm === "function" && !confirm(`Delete ${name}?`)) return;
324
+ await fetch("/setup/media/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) });
325
+ loadMedia();
326
+ }
327
+ const isImg = (n) => /\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)$/i.test(n);
328
+ const isVid = (n) => /\.(mp4|webm|mov|ogg|ogv|m4v)$/i.test(n);
329
+ const kb = (n) => (n < 1024 ? n + " B" : n < 1048576 ? Math.round(n / 1024) + " KB" : (n / 1048576).toFixed(1) + " MB");
330
+ const mediaThumb = (m) =>
331
+ isImg(m.name)
332
+ ? html`<img src=${m.url} loading="lazy" class="object-fit-cover" alt=${m.name} />`
333
+ : isVid(m.name)
334
+ ? html`<video src=${m.url} muted class="object-fit-cover"></video>`
335
+ : html`<div class="d-flex align-items-center justify-content-center text-white-50 small text-uppercase">${m.name.split(".").pop()}</div>`;
336
+ const mediaTile = (m) =>
337
+ html`<div class="col"><div class="card h-100 shadow-sm">
338
+ <div class="ratio ratio-4x3 bg-dark rounded-top overflow-hidden">${mediaThumb(m)}</div>
339
+ <div class="card-body p-2">
340
+ <div class="small text-truncate" title=${m.name}>${m.name}</div>
341
+ <div class="small text-muted mb-2">${kb(m.size)}</div>
342
+ <div class="btn-group btn-group-sm w-100" role="group">
343
+ <button type="button" class="btn btn-outline-secondary" onclick=${() => (navigator.clipboard && navigator.clipboard.writeText(m.url), status(`Copied ${m.url}`))}>Copy URL</button>
344
+ <button type="button" class="btn btn-outline-danger flex-grow-0" title="Delete" onclick=${() => delMedia(m.name)}>✕</button>
345
+ </div>
346
+ </div>
347
+ </div></div>`;
348
+ const mediaView = () =>
349
+ html`<div class="card">
350
+ <div class="card-header d-flex justify-content-between align-items-center"><h2 class="h6 mb-0">Media library</h2><button class="btn btn-sm btn-outline-secondary" onclick=${() => view("config")}>← Settings</button></div>
351
+ <div class="card-body">
352
+ <input type="file" class="form-control mb-2" accept="image/*,video/*" multiple onchange=${(e) => { for (const f of e.target.files) uploadMedia(f); e.target.value = ""; }} />
353
+ <p class="small text-muted">Uploads are stored in <code>public/media/</code> and served at <code>/media/&lt;name&gt;</code>. Copy a URL and paste it into a page's image slot in the editor. (Max 100&nbsp;MB per file.)</p>
354
+ ${() => (media().length ? html`<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 g-3">${media().map(mediaTile)}</div>` : html`<div class="text-muted small border rounded p-4 text-center">No media yet — upload above.</div>`)}
355
+ </div>
356
+ </div>`;
308
357
  // upgrade check: compare bundled version to npm latest; offer a one-click upgrade
309
358
  const upgrade = signal(null); // { current, latest, available }
310
359
  fetch("/setup/upgrade-check").then((r) => r.json()).then((u) => upgrade(u)).catch(() => {});
@@ -334,15 +383,15 @@ async function buyCredits(amountUsd) {
334
383
  }
335
384
  }
336
385
  async function genToken() {
337
- status("Requesting a hosted token…");
386
+ genMsg("Requesting…");
338
387
  try {
339
388
  const r = await (await fetch("/setup/gen-token", { method: "POST" })).json();
340
389
  if (r.ok && r.token) {
341
390
  set({ aiToken: r.token });
342
- status("Hosted token generated — click Apply to save it.");
343
- } else status("Could not get a token: " + (r.error || "?"));
391
+ genMsg(" token generated — Apply to save");
392
+ } else genMsg(" " + (r.error || "no token"));
344
393
  } catch {
345
- status("Token request failed.");
394
+ genMsg(" request failed");
346
395
  }
347
396
  }
348
397
  const items = signal({ pages: [], posts: [] });
@@ -449,12 +498,12 @@ const configView = () =>
449
498
  ${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
450
499
  </div>
451
500
  <div class="card-x p-4 mb-3">
452
- <div class="d-flex justify-content-between align-items-center mb-2"><h2 class="h6 mb-0">.env</h2><div class="d-flex gap-2">${() => (hasContent() ? html`<button class="btn btn-outline-light btn-sm" onclick=${() => (view("manage"), loadItems())}>Manage content →</button>` : "")}<button class="btn btn-primary btn-sm" onclick=${apply}>Apply & start →</button></div></div>
501
+ <div class="d-flex justify-content-between align-items-center mb-2"><h2 class="h6 mb-0">.env</h2><div class="d-flex gap-2">${() => (hasContent() ? html`<button class="btn btn-outline-light btn-sm" onclick=${() => (view("manage"), loadItems())}>Manage content →</button>` : "")}<button class="btn btn-outline-light btn-sm" onclick=${() => (view("media"), loadMedia())}>Media →</button><button class="btn btn-primary btn-sm" onclick=${apply}>Apply & start →</button></div></div>
453
502
  <pre class="mb-0" style="background:#0b0d11;border:1px solid #232a36;border-radius:10px;padding:12px;color:#cfe3ff;white-space:pre-wrap">${env}</pre>
454
503
  </div>`;
455
504
 
456
505
  mount(
457
506
  "#app",
458
- () => (view() === "config" ? configView() : manageView()),
507
+ () => (view() === "config" ? configView() : view() === "media" ? mediaView() : manageView()),
459
508
  () => (status() ? html`<p class="small accent">${status}</p>` : null),
460
509
  );
@@ -0,0 +1,20 @@
1
+ # Volt app — production container. Runs on Render, Fly.io, Railway, DO App
2
+ # Platform, and any container host. They handle the server, DNS, and TLS; you
3
+ # just set config as env vars.
4
+ #
5
+ # Configure via the platform's env vars (NOT a committed .env):
6
+ # VOLT_ADDONS=db,auth,... DB_DRIVER=... MONGODB_URI / DATABASE_URL
7
+ # MEDIA_DRIVER=s3 S3_ENDPOINT/S3_REGION/S3_BUCKET/S3_KEY/S3_SECRET etc.
8
+ #
9
+ # Tip: run the local wizard first (`npm run dev`) so the add-on packages are
10
+ # saved into package.json; commit package.json, then deploy and set the same
11
+ # config as env vars here. NODE_ENV=production makes the app boot straight up
12
+ # (no setup wizard).
13
+ FROM node:22-alpine
14
+ WORKDIR /app
15
+ ENV NODE_ENV=production
16
+ COPY package*.json ./
17
+ RUN npm install --omit=dev
18
+ COPY . .
19
+ EXPOSE 8080
20
+ CMD ["node", "server.js"]
@@ -0,0 +1 @@
1
+ web: node server.js
@@ -0,0 +1,25 @@
1
+ # Northwind — a full Volt site template
2
+
3
+ A complete, multi-page business/store site: **Home, About, Products, Contact**, a
4
+ sticky nav, hero, feature + product grids, and a call-to-action band — all in your
5
+ site theme.
6
+
7
+ ```
8
+ npm create volt@latest my-site -- --template business
9
+ cd my-site && npm run dev
10
+ ```
11
+
12
+ ## Make it yours
13
+
14
+ Everything is editable content, no code required:
15
+
16
+ - **Text** — open the config (`npm run dev -- --edit`) → **Manage content**, and edit
17
+ each page in the visual editor.
18
+ - **Images & video** — every `.slot` block (`📷 / 🎬`) is a placeholder. Drop your own
19
+ image or video in via the editor; the media add-on stores it and swaps it in.
20
+ - **Brand** — set `SITE_NAME` in the config; the nav, footer, and titles pick it up.
21
+ - **Theme** — colors, spacing, and layout live in `pages/_theme.js` (CSS variables at
22
+ the top). Edit there, or pick a different theme in the config.
23
+
24
+ Pages are `pages/*.md` with `format: html` for rich layouts; add or remove pages by
25
+ adding/removing files in `pages/`.
@@ -0,0 +1,6 @@
1
+ node_modules
2
+ .git
3
+ .env
4
+ *.log
5
+ npm-debug.log*
6
+ .DS_Store
@@ -0,0 +1,5 @@
1
+ // PM2 process config. Start under pm2 with `npm run pm2` (pm2 is fetched via npx
2
+ // if you don't have it installed); reload cleanly with `npm run pm2:restart` (no
3
+ // port clash), tail logs with `npm run pm2:logs`, remove with `npm run pm2:stop`.
4
+ const { name } = require("./package.json");
5
+ module.exports = { apps: [{ name, script: "server.js" }] };
@@ -0,0 +1,2 @@
1
+ VOLT_ADDONS=pages
2
+ SITE_NAME=Northwind Co
@@ -0,0 +1,15 @@
1
+ # Fly.io — run `fly launch` (it uses the Dockerfile), then set config:
2
+ # fly secrets set VOLT_ADDONS=db,auth DB_DRIVER=mongodb MONGODB_URI=...
3
+ app = "volt-app"
4
+
5
+ [build]
6
+
7
+ [http_service]
8
+ internal_port = 8080
9
+ force_https = true
10
+ auto_stop_machines = true
11
+ auto_start_machines = true
12
+
13
+ [env]
14
+ PORT = "8080"
15
+ NODE_ENV = "production"
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ npm-debug.log*
3
+ .DS_Store
4
+ .env
5
+ *.local
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "volt-app",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "A Volt app — no-build, signals-based UI with Socket.io hot reload.",
6
+ "type": "module",
7
+ "main": "server.js",
8
+ "scripts": {
9
+ "start": "node server.js",
10
+ "dev": "node server.js",
11
+ "pm2": "npx --yes pm2 start ecosystem.config.cjs",
12
+ "pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
13
+ "pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
14
+ "pm2:logs": "npx --yes pm2 logs",
15
+ "logs": "node server.js --logs"
16
+ },
17
+ "dependencies": {
18
+ "express": "^4.22.2",
19
+ "socket.io": "^4.8.3"
20
+ }
21
+ }
@@ -0,0 +1,65 @@
1
+ // pages/_theme.js — "Northwind" full-site theme: sticky nav, hero, feature +
2
+ // product grids, CTA band, footer. Content pages (format: html) provide the
3
+ // sections. Media slots (.slot) are placeholders you swap your own image/video
4
+ // into from the config editor.
5
+ const NAME = process.env.SITE_NAME || "Northwind Co";
6
+ const NAV = [
7
+ ["/", "Home"],
8
+ ["/about", "About"],
9
+ ["/products", "Products"],
10
+ ["/contact", "Contact"],
11
+ ];
12
+
13
+ export const css = `
14
+ :root{--ink:#141a1f;--bg:#fff;--soft:#f4f6f8;--line:#e6eaef;--brand:#0e7c66;--brand2:#0b5d4c;--muted:#5c6a76;--radius:16px}
15
+ *{box-sizing:border-box}
16
+ html{scroll-behavior:smooth}
17
+ body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
18
+ a{color:var(--brand);text-decoration:none}
19
+ img,video{max-width:100%;display:block}
20
+ .wrap{max-width:1100px;margin:0 auto;padding:0 1.25rem}
21
+ header.nav{position:sticky;top:0;z-index:20;background:rgba(255,255,255,.85);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
22
+ header.nav .wrap{display:flex;align-items:center;gap:1.5rem;height:64px}
23
+ header.nav .brand{font-weight:800;font-size:1.15rem;color:var(--ink)}
24
+ header.nav nav{margin-left:auto;display:flex;gap:1.5rem;align-items:center}
25
+ header.nav nav a{color:var(--muted);font-weight:500}
26
+ header.nav nav a:hover{color:var(--ink)}
27
+ header.nav .cta{background:var(--brand);color:#fff;padding:.5rem 1rem;border-radius:999px;font-weight:600}
28
+ header.nav .cta:hover{background:var(--brand2)}
29
+ section{padding:4.5rem 0}
30
+ section.alt{background:var(--soft)}
31
+ .eyebrow{color:var(--brand);font-weight:700;letter-spacing:.06em;text-transform:uppercase;font-size:.8rem;margin:0}
32
+ h1{font-size:clamp(2.2rem,5vw,3.4rem);line-height:1.08;letter-spacing:-.02em;margin:.4rem 0}
33
+ h2{font-size:clamp(1.6rem,3vw,2.2rem);line-height:1.15;letter-spacing:-.01em;margin:.3rem 0 1.2rem}
34
+ p.lead{font-size:1.2rem;color:var(--muted);max-width:46ch}
35
+ .btn{display:inline-block;background:var(--brand);color:#fff;font-weight:600;padding:.8rem 1.4rem;border-radius:999px}
36
+ .btn:hover{background:var(--brand2)}
37
+ .btn.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}
38
+ .hero{display:grid;grid-template-columns:1.1fr 1fr;gap:3rem;align-items:center}
39
+ @media(max-width:820px){.hero{grid-template-columns:1fr}}
40
+ .grid{display:grid;gap:1.5rem}
41
+ .grid.c3{grid-template-columns:repeat(3,1fr)}
42
+ .grid.c2{grid-template-columns:repeat(2,1fr)}
43
+ @media(max-width:820px){.grid.c3,.grid.c2{grid-template-columns:1fr}}
44
+ .card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:1.5rem}
45
+ .card h3{margin:.2rem 0 .4rem}
46
+ .card .price{color:var(--brand);font-weight:700}
47
+ .slot{position:relative;border-radius:var(--radius);overflow:hidden;background:linear-gradient(135deg,#0e7c66 0%,#0b5d4c 55%,#134e4a 100%);aspect-ratio:4/3;display:flex;align-items:center;justify-content:center;color:#dff5ee;text-align:center;margin-bottom:1rem}
48
+ .slot.wide{aspect-ratio:16/9}.slot.tall{aspect-ratio:3/4}
49
+ .slot span{font-size:.85rem;opacity:.92;padding:.5rem 1rem;border:1px dashed rgba(255,255,255,.45);border-radius:8px}
50
+ .slot img,.slot video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;margin:0}
51
+ .cta-band{background:var(--brand);color:#fff;border-radius:var(--radius);padding:3rem;text-align:center}
52
+ .cta-band h2{color:#fff}.cta-band .btn{background:#fff;color:var(--brand)}
53
+ footer.site{border-top:1px solid var(--line);color:var(--muted);padding:2.5rem 0;font-size:.92rem}
54
+ footer.site .wrap{display:flex;justify-content:space-between;gap:1rem;flex-wrap:wrap}
55
+ `;
56
+
57
+ export function layout({ title, head, content }) {
58
+ const nav = NAV.map(([h, l], i) => (i === NAV.length - 1 ? `<a class="cta" href="${h}">${l}</a>` : `<a href="${h}">${l}</a>`)).join("");
59
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
60
+ <title>${title}</title>${head}<link rel="stylesheet" href="/_theme.css"/></head><body>
61
+ <header class="nav"><div class="wrap"><a class="brand" href="/">${NAME}</a><nav>${nav}</nav></div></header>
62
+ ${content}
63
+ <footer class="site"><div class="wrap"><span>© ${NAME}</span><span>Built with <a href="https://voltjs.com">Volt</a></span></div></footer>
64
+ </body></html>`;
65
+ }
@@ -0,0 +1,30 @@
1
+ ---
2
+ title: About — Northwind Co
3
+ description: Our story, our people, our promise. Edit this page in the Volt config.
4
+ format: html
5
+ ---
6
+ <section><div class="wrap hero">
7
+ <div>
8
+ <p class="eyebrow">About us</p>
9
+ <h1>A small team with a big standard</h1>
10
+ <p class="lead">Tell your story here — how you started, what you believe, and why customers keep coming back. Replace every word with your own.</p>
11
+ </div>
12
+ <div class="slot tall"><span>📷 Team or founder photo</span></div>
13
+ </div></section>
14
+
15
+ <section class="alt"><div class="wrap">
16
+ <h2>What we stand for</h2>
17
+ <div class="grid c3">
18
+ <div class="card"><h3>Our mission</h3><p>One or two sentences on the difference you set out to make.</p></div>
19
+ <div class="card"><h3>How we work</h3><p>Your process, materials, or approach — what customers should know.</p></div>
20
+ <div class="card"><h3>Our promise</h3><p>The guarantee or commitment that stands behind everything you sell.</p></div>
21
+ </div>
22
+ </div></section>
23
+
24
+ <section><div class="wrap" style="max-width:720px">
25
+ <h2>The long version</h2>
26
+ <p>This is a normal content area — write as much as you like. Add paragraphs, headings, images, or a video. In the Volt config editor you can format it visually or drop in Markdown, and swap any image or video for your own.</p>
27
+ <div class="slot wide"><span>🎬 A short brand video works great here</span></div>
28
+ <p>Close with what happens next: an invitation to browse your products or get in touch.</p>
29
+ <p><a class="btn" href="/contact">Say hello</a></p>
30
+ </div></section>
@@ -0,0 +1,23 @@
1
+ ---
2
+ title: Contact — Northwind Co
3
+ description: Get in touch. Swap in your own details, map, or photo.
4
+ format: html
5
+ ---
6
+ <section><div class="wrap hero">
7
+ <div>
8
+ <p class="eyebrow">Contact</p>
9
+ <h1>Let's talk</h1>
10
+ <p class="lead">Questions, custom orders, or wholesale — reach out and we'll get back to you.</p>
11
+ <p><a class="btn" href="mailto:hello@example.com">Email us</a></p>
12
+ </div>
13
+ <div class="slot"><span>📷 Storefront photo or a map</span></div>
14
+ </div></section>
15
+
16
+ <section class="alt"><div class="wrap">
17
+ <div class="grid c3">
18
+ <div class="card"><h3>Email</h3><p><a href="mailto:hello@example.com">hello@example.com</a></p></div>
19
+ <div class="card"><h3>Phone</h3><p><a href="tel:+15555550123">+1 (555) 555-0123</a></p></div>
20
+ <div class="card"><h3>Visit</h3><p>123 Market Street<br/>Your City, ST 00000</p></div>
21
+ </div>
22
+ <p style="margin-top:1.5rem;color:var(--muted)">Want a working contact form? Enable the <code>mailer</code> add-on in the config and wire a POST route — see the docs.</p>
23
+ </div></section>
@@ -0,0 +1,41 @@
1
+ ---
2
+ title: Northwind Co — Quality goods, thoughtfully made
3
+ description: A full site template built with Volt. Swap in your own text, images, and video — no code.
4
+ format: html
5
+ ---
6
+ <section><div class="wrap hero">
7
+ <div>
8
+ <p class="eyebrow">Northwind Co</p>
9
+ <h1>Quality goods, thoughtfully made</h1>
10
+ <p class="lead">Every word, image, and video on this site is yours to edit. Make it your brand in minutes — right from the Volt config.</p>
11
+ <p><a class="btn" href="/products">Shop products</a> &nbsp; <a class="btn ghost" href="/about">Our story</a></p>
12
+ </div>
13
+ <div class="slot wide"><span>📷 / 🎬 Hero image or video — upload yours</span></div>
14
+ </div></section>
15
+
16
+ <section class="alt"><div class="wrap">
17
+ <p class="eyebrow">Why us</p>
18
+ <h2>Built for people who sweat the details</h2>
19
+ <div class="grid c3">
20
+ <div class="card"><h3>Made to last</h3><p>Say what makes your product or service stand out. Edit this copy in the config editor.</p></div>
21
+ <div class="card"><h3>Fast, careful delivery</h3><p>Swap this for your own promise — shipping, turnaround, support, guarantees.</p></div>
22
+ <div class="card"><h3>Loved by many</h3><p>Add a stat, a short testimonial, or social proof here to build trust.</p></div>
23
+ </div>
24
+ </div></section>
25
+
26
+ <section><div class="wrap">
27
+ <p class="eyebrow">Featured</p>
28
+ <h2>Popular products</h2>
29
+ <div class="grid c3">
30
+ <div class="card"><div class="slot"><span>📷 Product photo</span></div><h3>Product one</h3><p class="price">$49</p></div>
31
+ <div class="card"><div class="slot"><span>📷 Product photo</span></div><h3>Product two</h3><p class="price">$79</p></div>
32
+ <div class="card"><div class="slot"><span>📷 Product photo</span></div><h3>Product three</h3><p class="price">$120</p></div>
33
+ </div>
34
+ <p style="margin-top:1.5rem"><a class="btn ghost" href="/products">See all products →</a></p>
35
+ </div></section>
36
+
37
+ <section class="alt"><div class="wrap"><div class="cta-band">
38
+ <h2>Ready to make it yours?</h2>
39
+ <p>Edit every page and swap the media in the Volt config — no code required.</p>
40
+ <p><a class="btn" href="/contact">Get in touch</a></p>
41
+ </div></div></section>