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.
Files changed (43) hide show
  1. package/CHANGELOG.md +26 -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 +87 -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 +1051 -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 +87 -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 +87 -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 +86 -3
  43. package/templates/starter/setup/setup.js +59 -10
@@ -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
- fs.writeFileSync(file, String(body ?? ""));
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() === "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
  );