create-volt 0.55.0 → 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 +36 -0
- package/index.js +1 -1
- package/package.json +1 -1
- package/templates/blog/public/volt.js +7 -2
- package/templates/blog/server.js +88 -4
- 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 +88 -4
- package/templates/default/setup/setup.js +59 -10
- package/templates/docs/public/volt.js +7 -2
- package/templates/docs/server.js +88 -4
- 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 +87 -4
- package/templates/starter/setup/setup.js +59 -10
|
@@ -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) {
|
|
@@ -43,7 +43,7 @@ function configPort() {
|
|
|
43
43
|
const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
|
|
44
44
|
return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
|
|
45
45
|
}
|
|
46
|
-
const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^
|
|
46
|
+
const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^9.0.3", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
|
|
47
47
|
const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
|
|
48
48
|
|
|
49
49
|
// --- tiny .env loader (no dependency); never overrides an existing env var ---
|
|
@@ -376,9 +376,9 @@ function startSetup() {
|
|
|
376
376
|
const env = readEnvFile();
|
|
377
377
|
const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
|
|
378
378
|
fetch(base + "/api/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ app: env.SITE_NAME || "volt-app" }) })
|
|
379
|
-
.then((r) => r.json())
|
|
379
|
+
.then((r) => (r.ok ? r.json() : { ok: false, error: `hosted AI gateway not available (HTTP ${r.status}) — is it deployed?` }))
|
|
380
380
|
.then((j) => res.end(JSON.stringify(j)))
|
|
381
|
-
.catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
|
|
381
|
+
.catch(() => res.end(JSON.stringify({ ok: false, error: "hosted AI gateway unreachable" })));
|
|
382
382
|
return;
|
|
383
383
|
}
|
|
384
384
|
// --- AI proxy for the in-config editor (RTEPro). Uses a local provider key
|
|
@@ -492,6 +492,71 @@ function startSetup() {
|
|
|
492
492
|
})();
|
|
493
493
|
return;
|
|
494
494
|
}
|
|
495
|
+
// --- media library: list / upload / delete files in public/media (served at
|
|
496
|
+
// /media/<name>). Shell-gated (config only). ---
|
|
497
|
+
if (req.method === "GET" && p === "/setup/media") {
|
|
498
|
+
res.setHeader("Content-Type", "application/json");
|
|
499
|
+
const dir = path.join(__dirname, "public", "media");
|
|
500
|
+
let items = [];
|
|
501
|
+
try {
|
|
502
|
+
items = fs
|
|
503
|
+
.readdirSync(dir)
|
|
504
|
+
.filter((f) => !f.startsWith("."))
|
|
505
|
+
.map((f) => ({ name: f, url: "/media/" + f, size: fs.statSync(path.join(dir, f)).size }))
|
|
506
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
507
|
+
} catch {
|
|
508
|
+
/* no media dir yet */
|
|
509
|
+
}
|
|
510
|
+
return res.end(JSON.stringify({ items }));
|
|
511
|
+
}
|
|
512
|
+
if (req.method === "POST" && p === "/setup/media/upload") {
|
|
513
|
+
res.setHeader("Content-Type", "application/json");
|
|
514
|
+
const name = (u.searchParams.get("name") || "").replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "").slice(0, 120);
|
|
515
|
+
if (!name || !/\.[A-Za-z0-9]+$/.test(name)) return res.end(JSON.stringify({ ok: false, error: "bad filename" }));
|
|
516
|
+
const dir = path.join(__dirname, "public", "media");
|
|
517
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
518
|
+
const chunks = [];
|
|
519
|
+
let size = 0;
|
|
520
|
+
let tooBig = false;
|
|
521
|
+
req.on("data", (c) => {
|
|
522
|
+
if (tooBig) return;
|
|
523
|
+
size += c.length;
|
|
524
|
+
if (size > 100 * 1024 * 1024) tooBig = true;
|
|
525
|
+
else chunks.push(c);
|
|
526
|
+
});
|
|
527
|
+
req.on("end", () => {
|
|
528
|
+
if (tooBig) {
|
|
529
|
+
res.statusCode = 413;
|
|
530
|
+
return res.end(JSON.stringify({ ok: false, error: "file too large (max 100MB)" }));
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
fs.writeFileSync(path.join(dir, name), Buffer.concat(chunks));
|
|
534
|
+
res.end(JSON.stringify({ ok: true, url: "/media/" + name, name }));
|
|
535
|
+
} catch (e) {
|
|
536
|
+
res.statusCode = 400;
|
|
537
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (req.method === "POST" && p === "/setup/media/delete") {
|
|
543
|
+
let mbody = "";
|
|
544
|
+
req.on("data", (c) => (mbody += c));
|
|
545
|
+
req.on("end", () => {
|
|
546
|
+
res.setHeader("Content-Type", "application/json");
|
|
547
|
+
try {
|
|
548
|
+
const { name } = JSON.parse(mbody || "{}");
|
|
549
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name || "")) throw new Error("bad name");
|
|
550
|
+
const f = path.join(__dirname, "public", "media", name);
|
|
551
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
552
|
+
res.end(JSON.stringify({ ok: true }));
|
|
553
|
+
} catch (e) {
|
|
554
|
+
res.statusCode = 400;
|
|
555
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
495
560
|
// --- content manager: list / read / write / delete pages + posts ---
|
|
496
561
|
if (req.method === "GET" && p === "/setup/content") {
|
|
497
562
|
const list = (type) => {
|
|
@@ -532,7 +597,25 @@ function startSetup() {
|
|
|
532
597
|
return res.end(JSON.stringify({ ok: true }));
|
|
533
598
|
}
|
|
534
599
|
fs.mkdirSync(dir, { recursive: true });
|
|
535
|
-
|
|
600
|
+
// RTEPro's media picker inlines "Choose File" uploads as base64 data URLs.
|
|
601
|
+
// Extract them to public/media/<hash>.<ext> and rewrite the src, so pages
|
|
602
|
+
// stay lean and the uploads land in the media library.
|
|
603
|
+
const mediaDir = path.join(__dirname, "public", "media");
|
|
604
|
+
const extFor = (mime) =>
|
|
605
|
+
({ "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");
|
|
606
|
+
const finalBody = String(body ?? "").replace(/(<(?:img|video|audio|source)\b[^>]*?\ssrc=")data:([\w.+-]+\/[\w.+-]+);base64,([^"]+)(")/gi, (m, pre, mime, b64, post) => {
|
|
607
|
+
try {
|
|
608
|
+
const buf = Buffer.from(b64, "base64");
|
|
609
|
+
const name = crypto.createHash("sha1").update(buf).digest("hex").slice(0, 16) + "." + extFor(mime);
|
|
610
|
+
fs.mkdirSync(mediaDir, { recursive: true });
|
|
611
|
+
const dest = path.join(mediaDir, name);
|
|
612
|
+
if (!fs.existsSync(dest)) fs.writeFileSync(dest, buf);
|
|
613
|
+
return pre + "/media/" + name + post;
|
|
614
|
+
} catch {
|
|
615
|
+
return m;
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
fs.writeFileSync(file, finalBody);
|
|
536
619
|
res.end(JSON.stringify({ ok: true, file: (type === "post" ? "posts/" : "pages/") + slug + ".md" }));
|
|
537
620
|
} catch (e) {
|
|
538
621
|
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
|
);
|