create-volt 0.55.1 → 0.56.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 +26 -0
- package/index.js +1 -1
- package/package.json +1 -1
- package/templates/blog/public/volt.js +7 -2
- package/templates/blog/server.js +87 -3
- package/templates/blog/setup/setup.js +59 -10
- package/templates/business/Dockerfile +20 -0
- package/templates/business/Procfile +1 -0
- package/templates/business/README.md +25 -0
- package/templates/business/dockerignore +6 -0
- package/templates/business/ecosystem.config.cjs +5 -0
- package/templates/business/env +2 -0
- package/templates/business/fly.toml +15 -0
- package/templates/business/gitignore +5 -0
- package/templates/business/package.json +21 -0
- package/templates/business/pages/_theme.js +65 -0
- package/templates/business/pages/about.md +30 -0
- package/templates/business/pages/contact.md +23 -0
- package/templates/business/pages/index.md +41 -0
- package/templates/business/pages/products.md +27 -0
- package/templates/business/public/app.js +89 -0
- package/templates/business/public/favicon.webp +0 -0
- package/templates/business/public/logo.webp +0 -0
- package/templates/business/public/volt-ssr.js +63 -0
- package/templates/business/public/volt.js +355 -0
- package/templates/business/render.yaml +15 -0
- package/templates/business/server.js +1051 -0
- package/templates/business/setup/index.html +46 -0
- package/templates/business/setup/logs.html +29 -0
- package/templates/business/setup/logs.js +58 -0
- package/templates/business/setup/setup.js +509 -0
- package/templates/business/setup/studio.html +29 -0
- package/templates/business/views/index.html +42 -0
- package/templates/default/public/volt.js +7 -2
- package/templates/default/server.js +87 -3
- package/templates/default/setup/setup.js +59 -10
- package/templates/docs/public/volt.js +7 -2
- package/templates/docs/server.js +87 -3
- package/templates/docs/setup/setup.js +59 -10
- package/templates/guestbook/public/volt.js +7 -2
- package/templates/starter/public/volt.js +7 -2
- package/templates/starter/server.js +86 -3
- package/templates/starter/setup/setup.js +59 -10
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ 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.0] - 2026-07-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Full site templates.** `--template business` scaffolds a complete multi-page
|
|
11
|
+
site (Home, About, Products, Contact) with a sticky-nav theme, hero, product grid,
|
|
12
|
+
CTA, and swap-your-own media slots — the "install a theme with demo content, then
|
|
13
|
+
make it yours" experience.
|
|
14
|
+
- **Media library in the config.** A new **Media** view uploads / browses / deletes
|
|
15
|
+
images and video (stored in `public/media/`, served at `/media/<name>`) — a
|
|
16
|
+
Bootstrap card grid with thumbnails, copy-URL, and delete.
|
|
17
|
+
- **Editor media, de-base64'd on save.** RTEPro's built-in picker inlines "Choose
|
|
18
|
+
File" uploads as base64; on save they're extracted to `public/media/<hash>.<ext>`
|
|
19
|
+
(content-hash deduped) and the `src` rewritten to a `/media` URL, so pages stay
|
|
20
|
+
lean and editor uploads land in the library.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **Boolean attributes.** `readonly=${false}` (and `required`/`multiple`/`hidden`/…)
|
|
24
|
+
now correctly turn the attribute OFF — any value, including the string `"false"`,
|
|
25
|
+
previously left it on. `readonly` also maps to the `readOnly` DOM property.
|
|
26
|
+
- **Hosted-token button.** "Generate a free hosted token" shows its result inline
|
|
27
|
+
next to the button, with a clear message when the gateway isn't reachable (was a
|
|
28
|
+
silent failure buried in the status line).
|
|
29
|
+
- `default/server.js` now imports `node:crypto` — it had relied on the global Web
|
|
30
|
+
Crypto, which has no `createHash`.
|
|
31
|
+
|
|
7
32
|
## [0.55.1] - 2026-07-05
|
|
8
33
|
|
|
9
34
|
### Security
|
|
@@ -720,6 +745,7 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
720
745
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
721
746
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
722
747
|
|
|
748
|
+
[0.56.0]: https://github.com/MIR-2025/volt/releases/tag/v0.56.0
|
|
723
749
|
[0.55.1]: https://github.com/MIR-2025/volt/releases/tag/v0.55.1
|
|
724
750
|
[0.55.0]: https://github.com/MIR-2025/volt/releases/tag/v0.55.0
|
|
725
751
|
[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
|
@@ -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
|
|
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) {
|
package/templates/blog/server.js
CHANGED
|
@@ -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";
|
|
@@ -350,9 +351,9 @@ function startSetup() {
|
|
|
350
351
|
const env = readEnvFile();
|
|
351
352
|
const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
|
|
352
353
|
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())
|
|
354
|
+
.then((r) => (r.ok ? r.json() : { ok: false, error: `hosted AI gateway not available (HTTP ${r.status}) — is it deployed?` }))
|
|
354
355
|
.then((j) => res.end(JSON.stringify(j)))
|
|
355
|
-
.catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
|
|
356
|
+
.catch(() => res.end(JSON.stringify({ ok: false, error: "hosted AI gateway unreachable" })));
|
|
356
357
|
return;
|
|
357
358
|
}
|
|
358
359
|
// --- AI proxy for the in-config editor (RTEPro). Uses a local provider key
|
|
@@ -466,6 +467,71 @@ function startSetup() {
|
|
|
466
467
|
})();
|
|
467
468
|
return;
|
|
468
469
|
}
|
|
470
|
+
// --- media library: list / upload / delete files in public/media (served at
|
|
471
|
+
// /media/<name>). Shell-gated (config only). ---
|
|
472
|
+
if (req.method === "GET" && p === "/setup/media") {
|
|
473
|
+
res.setHeader("Content-Type", "application/json");
|
|
474
|
+
const dir = path.join(__dirname, "public", "media");
|
|
475
|
+
let items = [];
|
|
476
|
+
try {
|
|
477
|
+
items = fs
|
|
478
|
+
.readdirSync(dir)
|
|
479
|
+
.filter((f) => !f.startsWith("."))
|
|
480
|
+
.map((f) => ({ name: f, url: "/media/" + f, size: fs.statSync(path.join(dir, f)).size }))
|
|
481
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
482
|
+
} catch {
|
|
483
|
+
/* no media dir yet */
|
|
484
|
+
}
|
|
485
|
+
return res.end(JSON.stringify({ items }));
|
|
486
|
+
}
|
|
487
|
+
if (req.method === "POST" && p === "/setup/media/upload") {
|
|
488
|
+
res.setHeader("Content-Type", "application/json");
|
|
489
|
+
const name = (u.searchParams.get("name") || "").replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "").slice(0, 120);
|
|
490
|
+
if (!name || !/\.[A-Za-z0-9]+$/.test(name)) return res.end(JSON.stringify({ ok: false, error: "bad filename" }));
|
|
491
|
+
const dir = path.join(__dirname, "public", "media");
|
|
492
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
493
|
+
const chunks = [];
|
|
494
|
+
let size = 0;
|
|
495
|
+
let tooBig = false;
|
|
496
|
+
req.on("data", (c) => {
|
|
497
|
+
if (tooBig) return;
|
|
498
|
+
size += c.length;
|
|
499
|
+
if (size > 100 * 1024 * 1024) tooBig = true;
|
|
500
|
+
else chunks.push(c);
|
|
501
|
+
});
|
|
502
|
+
req.on("end", () => {
|
|
503
|
+
if (tooBig) {
|
|
504
|
+
res.statusCode = 413;
|
|
505
|
+
return res.end(JSON.stringify({ ok: false, error: "file too large (max 100MB)" }));
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
fs.writeFileSync(path.join(dir, name), Buffer.concat(chunks));
|
|
509
|
+
res.end(JSON.stringify({ ok: true, url: "/media/" + name, name }));
|
|
510
|
+
} catch (e) {
|
|
511
|
+
res.statusCode = 400;
|
|
512
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (req.method === "POST" && p === "/setup/media/delete") {
|
|
518
|
+
let mbody = "";
|
|
519
|
+
req.on("data", (c) => (mbody += c));
|
|
520
|
+
req.on("end", () => {
|
|
521
|
+
res.setHeader("Content-Type", "application/json");
|
|
522
|
+
try {
|
|
523
|
+
const { name } = JSON.parse(mbody || "{}");
|
|
524
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name || "")) throw new Error("bad name");
|
|
525
|
+
const f = path.join(__dirname, "public", "media", name);
|
|
526
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
527
|
+
res.end(JSON.stringify({ ok: true }));
|
|
528
|
+
} catch (e) {
|
|
529
|
+
res.statusCode = 400;
|
|
530
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
469
535
|
// --- content manager: list / read / write / delete pages + posts ---
|
|
470
536
|
if (req.method === "GET" && p === "/setup/content") {
|
|
471
537
|
const list = (type) => {
|
|
@@ -506,7 +572,25 @@ function startSetup() {
|
|
|
506
572
|
return res.end(JSON.stringify({ ok: true }));
|
|
507
573
|
}
|
|
508
574
|
fs.mkdirSync(dir, { recursive: true });
|
|
509
|
-
|
|
575
|
+
// RTEPro's media picker inlines "Choose File" uploads as base64 data URLs.
|
|
576
|
+
// Extract them to public/media/<hash>.<ext> and rewrite the src, so pages
|
|
577
|
+
// stay lean and the uploads land in the media library.
|
|
578
|
+
const mediaDir = path.join(__dirname, "public", "media");
|
|
579
|
+
const extFor = (mime) =>
|
|
580
|
+
({ "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");
|
|
581
|
+
const finalBody = String(body ?? "").replace(/(<(?:img|video|audio|source)\b[^>]*?\ssrc=")data:([\w.+-]+\/[\w.+-]+);base64,([^"]+)(")/gi, (m, pre, mime, b64, post) => {
|
|
582
|
+
try {
|
|
583
|
+
const buf = Buffer.from(b64, "base64");
|
|
584
|
+
const name = crypto.createHash("sha1").update(buf).digest("hex").slice(0, 16) + "." + extFor(mime);
|
|
585
|
+
fs.mkdirSync(mediaDir, { recursive: true });
|
|
586
|
+
const dest = path.join(mediaDir, name);
|
|
587
|
+
if (!fs.existsSync(dest)) fs.writeFileSync(dest, buf);
|
|
588
|
+
return pre + "/media/" + name + post;
|
|
589
|
+
} catch {
|
|
590
|
+
return m;
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
fs.writeFileSync(file, finalBody);
|
|
510
594
|
res.end(JSON.stringify({ ok: true, file: (type === "post" ? "posts/" : "pages/") + slug + ".md" }));
|
|
511
595
|
} catch (e) {
|
|
512
596
|
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()
|
|
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/<name></code>. Copy a URL and paste it into a page's image slot in the editor. (Max 100 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
|
-
|
|
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
|
-
|
|
343
|
-
} else
|
|
391
|
+
genMsg("✓ token generated — Apply to save");
|
|
392
|
+
} else genMsg("✗ " + (r.error || "no token"));
|
|
344
393
|
} catch {
|
|
345
|
-
|
|
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,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,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,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> <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>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Products — Northwind Co
|
|
3
|
+
description: Browse the collection. Replace each product's photo, name, and price with your own.
|
|
4
|
+
format: html
|
|
5
|
+
---
|
|
6
|
+
<section><div class="wrap">
|
|
7
|
+
<p class="eyebrow">Shop</p>
|
|
8
|
+
<h1>Products</h1>
|
|
9
|
+
<p class="lead">Replace each card with your own item — photo, name, price, and a short line of copy.</p>
|
|
10
|
+
</div></section>
|
|
11
|
+
|
|
12
|
+
<section class="alt"><div class="wrap">
|
|
13
|
+
<div class="grid c3">
|
|
14
|
+
<div class="card"><div class="slot"><span>📷 Product photo</span></div><h3>Everyday tote</h3><p class="price">$49</p><p>One line about the product — material, size, or the problem it solves.</p></div>
|
|
15
|
+
<div class="card"><div class="slot"><span>📷 Product photo</span></div><h3>Field jacket</h3><p class="price">$129</p><p>One line about the product — material, size, or the problem it solves.</p></div>
|
|
16
|
+
<div class="card"><div class="slot"><span>📷 Product photo</span></div><h3>Ceramic mug</h3><p class="price">$24</p><p>One line about the product — material, size, or the problem it solves.</p></div>
|
|
17
|
+
<div class="card"><div class="slot"><span>📷 Product photo</span></div><h3>Wool throw</h3><p class="price">$89</p><p>One line about the product — material, size, or the problem it solves.</p></div>
|
|
18
|
+
<div class="card"><div class="slot"><span>📷 Product photo</span></div><h3>Leather wallet</h3><p class="price">$59</p><p>One line about the product — material, size, or the problem it solves.</p></div>
|
|
19
|
+
<div class="card"><div class="slot"><span>📷 Product photo</span></div><h3>Canvas print</h3><p class="price">$39</p><p>One line about the product — material, size, or the problem it solves.</p></div>
|
|
20
|
+
</div>
|
|
21
|
+
</div></section>
|
|
22
|
+
|
|
23
|
+
<section><div class="wrap"><div class="cta-band">
|
|
24
|
+
<h2>Not sure where to start?</h2>
|
|
25
|
+
<p>Tell us what you're after and we'll point you to the right piece.</p>
|
|
26
|
+
<p><a class="btn" href="/contact">Contact us</a></p>
|
|
27
|
+
</div></div></section>
|