create-volt 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ All notable changes to `create-volt` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.32.0] - 2026-06-29
8
+
9
+ ### Changed
10
+ - **Brand refresh:** the ⚡ glyph is replaced with the Volt logo across the demo
11
+ app, the setup wizard, and Studio; scaffolded apps now ship `logo.webp` +
12
+ `favicon.webp` and link a favicon (so apps get a real tab icon). README titles
13
+ keep the ⚡.
14
+
15
+ ## [0.31.0] - 2026-06-29
16
+
17
+ ### Added
18
+ - **`posts` add-on — a blog (the WordPress content model).** Markdown in `posts/`
19
+ becomes a paginated index at `/blog`, single posts at `/blog/<slug>`, plus
20
+ `/category/<name>`, `/tag/<name>`, and an RSS feed at `/feed.xml`. Posts carry
21
+ date/author/category/tags front-matter (date can also come from a
22
+ `YYYY-MM-DD-` filename prefix); drafts (`draft: true`) are skipped. Renders in
23
+ the site theme and reuses pages' SEO — single posts get an auto Article
24
+ JSON-LD + `og:type=article`. Depends on the `pages` add-on. New `/docs/posts`.
25
+
7
26
  ## [0.30.0] - 2026-06-29
8
27
 
9
28
  ### Fixed
@@ -413,6 +432,8 @@ All notable changes to `create-volt` are documented here. The format follows
413
432
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
414
433
  and auto-detects npm / pnpm / yarn / bun for the install step.
415
434
 
435
+ [0.32.0]: https://github.com/MIR-2025/volt/releases/tag/v0.32.0
436
+ [0.31.0]: https://github.com/MIR-2025/volt/releases/tag/v0.31.0
416
437
  [0.30.0]: https://github.com/MIR-2025/volt/releases/tag/v0.30.0
417
438
  [0.29.0]: https://github.com/MIR-2025/volt/releases/tag/v0.29.0
418
439
  [0.28.0]: https://github.com/MIR-2025/volt/releases/tag/v0.28.0
@@ -52,7 +52,7 @@ export function parseFrontMatter(src) {
52
52
  export const isSafeSlug = (s) => /^[a-z0-9][a-z0-9-]*$/i.test(s);
53
53
 
54
54
  // SEO/social head from front-matter: description, image, type, canonical, jsonld.
55
- function metaHead(meta) {
55
+ export function metaHead(meta) {
56
56
  const t = [];
57
57
  const title = meta.title || "";
58
58
  const desc = meta.description || "";
@@ -117,7 +117,7 @@ function themeCss(dir) {
117
117
  // local pages/_theme.js; else the built-in default. A theme may `export const css`
118
118
  // (served at /_theme.css, shared with the editor); otherwise pages/_theme.css or
119
119
  // the default CSS is used.
120
- async function loadTheme(dir, env) {
120
+ export async function loadTheme(dir, env) {
121
121
  const wrap = (m) => {
122
122
  const layout = m && (m.layout || m.default);
123
123
  return layout ? { layout, css: m.css || themeCss(dir) } : null;
@@ -0,0 +1,168 @@
1
+ // posts.js — a blog content type. Markdown files in posts/ become a paginated
2
+ // index at /blog, single posts at /blog/<slug>, plus /category/<name>,
3
+ // /tag/<name>, and an RSS feed at /feed.xml. Renders in the site theme and
4
+ // reuses pages' front-matter + SEO helpers (OG, Twitter, JSON-LD).
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ // express + marked are imported lazily in postsRouter() so the pure helpers load
8
+ // without those deps. Theme + SEO come from the pages add-on (a dependency).
9
+ import { parseFrontMatter, isSafeSlug, metaHead, loadTheme } from "../../../pages/files/lib/pages.js";
10
+
11
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
12
+ const slugify = (s) => String(s).toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
13
+ const postLink = (slug) => "/blog/" + slug;
14
+ const catLink = (c) => `<a href="/category/${slugify(c)}">${esc(c)}</a>`;
15
+ const tagLink = (t) => `<a class="post-tag" href="/tag/${slugify(t)}">${esc(t)}</a>`;
16
+ const tagsOf = (meta) => String(meta.tags || "").split(",").map((s) => s.trim()).filter(Boolean);
17
+
18
+ function fmtDate(d) {
19
+ if (!d) return "";
20
+ const t = new Date(d);
21
+ return isNaN(t.getTime()) ? esc(d) : t.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
22
+ }
23
+
24
+ function excerpt(p) {
25
+ if (p.meta.description) return p.meta.description;
26
+ const text = String(p.body).replace(/<[^>]+>/g, " ").replace(/[#>*_`[\]()!]+/g, " ").replace(/\s+/g, " ").trim();
27
+ return text.slice(0, 160) + (text.length > 160 ? "…" : "");
28
+ }
29
+
30
+ // Read + parse every post, newest first. Date comes from front-matter `date:` or
31
+ // a YYYY-MM-DD- filename prefix; slug from front-matter `slug:` or the filename
32
+ // (prefix stripped). Drafts (draft: true) are skipped.
33
+ export function readPosts(dir) {
34
+ if (!fs.existsSync(dir)) return [];
35
+ const out = [];
36
+ for (const f of fs.readdirSync(dir)) {
37
+ if (!f.endsWith(".md")) continue;
38
+ const { meta, body } = parseFrontMatter(fs.readFileSync(path.join(dir, f), "utf8"));
39
+ if (String(meta.draft).toLowerCase() === "true") continue;
40
+ let name = f.replace(/\.md$/, "");
41
+ let date = meta.date || "";
42
+ const m = name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
43
+ if (m) {
44
+ if (!date) date = m[1];
45
+ name = m[2];
46
+ }
47
+ const slug = slugify(meta.slug || name);
48
+ if (!isSafeSlug(slug)) continue;
49
+ out.push({ slug, date, meta, body });
50
+ }
51
+ return out.sort((a, b) => String(b.date).localeCompare(String(a.date)));
52
+ }
53
+
54
+ function renderList(posts, { heading, page = 1, totalPages = 1, baseUrl }) {
55
+ const items = posts.length
56
+ ? posts
57
+ .map(
58
+ (p) => `<li class="post-item" style="margin:0 0 1.5rem">
59
+ <a href="${postLink(p.slug)}" style="font-size:1.2rem;font-weight:600">${esc(p.meta.title || p.slug)}</a>
60
+ <div class="post-meta" style="opacity:.7;font-size:.9rem">${fmtDate(p.date)}${p.meta.category ? " &middot; " + catLink(p.meta.category) : ""}</div>
61
+ <p style="margin:.3rem 0 0">${esc(excerpt(p))}</p>
62
+ </li>`,
63
+ )
64
+ .join("\n")
65
+ : "<li>No posts yet.</li>";
66
+ let nav = "";
67
+ if (totalPages > 1) {
68
+ const prev = page > 1 ? `<a href="${baseUrl}?page=${page - 1}">&larr; Newer</a>` : "<span></span>";
69
+ const next = page < totalPages ? `<a href="${baseUrl}?page=${page + 1}" style="margin-left:auto">Older &rarr;</a>` : "";
70
+ nav = `<nav class="post-pager" style="display:flex;margin-top:1.5rem">${prev}${next}</nav>`;
71
+ }
72
+ return `<h1>${esc(heading)}</h1><ul class="post-list" style="list-style:none;padding:0">${items}</ul>${nav}`;
73
+ }
74
+
75
+ function renderPost(p, marked) {
76
+ const title = p.meta.title || p.slug;
77
+ const body = p.meta.format === "html" ? p.body : marked.parse(p.body);
78
+ const tags = tagsOf(p.meta);
79
+ const meta = `<div class="post-meta" style="opacity:.7;font-size:.9rem;margin-bottom:1rem">${fmtDate(p.date)}${p.meta.author ? " &middot; " + esc(p.meta.author) : ""}${p.meta.category ? " &middot; " + catLink(p.meta.category) : ""}</div>`;
80
+ const tagHtml = tags.length ? `<div class="post-tags" style="margin-top:1.5rem">Tags: ${tags.map(tagLink).join(" ")}</div>` : "";
81
+ return `<article><h1>${esc(title)}</h1>${meta}${body}${tagHtml}</article>`;
82
+ }
83
+
84
+ function feedXml(posts) {
85
+ const base = (process.env.SITE_URL || "").replace(/\/+$/, "");
86
+ const name = process.env.SITE_NAME || "Blog";
87
+ const items = posts
88
+ .slice(0, 20)
89
+ .map(
90
+ (p) => ` <item>
91
+ <title>${esc(p.meta.title || p.slug)}</title>
92
+ <link>${esc(base + postLink(p.slug))}</link>
93
+ <guid isPermaLink="${base ? "true" : "false"}">${esc(base + postLink(p.slug))}</guid>${p.date && !isNaN(new Date(p.date).getTime()) ? `\n <pubDate>${new Date(p.date).toUTCString()}</pubDate>` : ""}
94
+ <description>${esc(excerpt(p))}</description>
95
+ </item>`,
96
+ )
97
+ .join("\n");
98
+ return `<?xml version="1.0" encoding="UTF-8"?>
99
+ <rss version="2.0"><channel>
100
+ <title>${esc(name)}</title>
101
+ <link>${esc(base + "/blog")}</link>
102
+ <description>${esc(name)} — posts</description>
103
+ ${items}
104
+ </channel></rss>`;
105
+ }
106
+
107
+ export async function postsRouter({ dir, themeDir }) {
108
+ const express = (await import("express")).default;
109
+ const { marked } = await import("marked");
110
+ fs.mkdirSync(dir, { recursive: true });
111
+ const { layout } = await loadTheme(themeDir || dir, process.env); // same theme as pages
112
+ const PER = Math.max(1, Number(process.env.POSTS_PER_PAGE) || 10);
113
+ const render = ({ title, content, meta = {} }) => {
114
+ const m = { ...meta, title };
115
+ return layout({ title, head: metaHead(m), content, meta: m });
116
+ };
117
+ const r = express.Router();
118
+
119
+ r.get("/blog", (req, res) => {
120
+ const all = readPosts(dir);
121
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
122
+ const totalPages = Math.max(1, Math.ceil(all.length / PER));
123
+ const slice = all.slice((page - 1) * PER, page * PER);
124
+ const heading = process.env.SITE_NAME || "Blog";
125
+ res.type("html").send(render({ title: heading, meta: { description: heading + " — latest posts" }, content: renderList(slice, { heading, page, totalPages, baseUrl: "/blog" }) }));
126
+ });
127
+
128
+ r.get("/blog/:slug", (req, res, next) => {
129
+ if (!isSafeSlug(req.params.slug)) return next();
130
+ const post = readPosts(dir).find((p) => p.slug === req.params.slug);
131
+ if (!post) return next();
132
+ const title = post.meta.title || post.slug;
133
+ const autoLd = JSON.stringify({
134
+ "@context": "https://schema.org",
135
+ "@type": "Article",
136
+ headline: title,
137
+ ...(post.date ? { datePublished: post.date } : {}),
138
+ ...(post.meta.author ? { author: { "@type": "Person", name: post.meta.author } } : {}),
139
+ });
140
+ res.type("html").send(
141
+ render({
142
+ title,
143
+ meta: { ...post.meta, title, type: "article", jsonld: post.meta.jsonld || autoLd },
144
+ content: renderPost(post, marked),
145
+ }),
146
+ );
147
+ });
148
+
149
+ r.get("/category/:name", (req, res, next) => {
150
+ if (!isSafeSlug(req.params.name)) return next();
151
+ const list = readPosts(dir).filter((p) => slugify(p.meta.category) === req.params.name);
152
+ if (!list.length) return next();
153
+ res.type("html").send(render({ title: "Category: " + req.params.name, content: renderList(list, { heading: "Category: " + req.params.name, baseUrl: "/category/" + req.params.name }) }));
154
+ });
155
+
156
+ r.get("/tag/:name", (req, res, next) => {
157
+ if (!isSafeSlug(req.params.name)) return next();
158
+ const list = readPosts(dir).filter((p) => tagsOf(p.meta).some((t) => slugify(t) === req.params.name));
159
+ if (!list.length) return next();
160
+ res.type("html").send(render({ title: "Tag: " + req.params.name, content: renderList(list, { heading: "Tag: " + req.params.name, baseUrl: "/tag/" + req.params.name }) }));
161
+ });
162
+
163
+ r.get("/feed.xml", (_req, res) => {
164
+ res.type("application/rss+xml").send(feedXml(readPosts(dir)));
165
+ });
166
+
167
+ return r;
168
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "description": "Blog posts with dates, authors, tags + categories. Drop markdown in posts/ → a paginated index at /blog, single posts at /blog/<slug>, /category/<name>, /tag/<name>, and an RSS feed at /feed.xml. Renders in your site theme and reuses pages' SEO (OG + JSON-LD).",
3
+ "dependsOn": ["pages"],
4
+ "sentinel": "lib/posts.js",
5
+ "install": ["marked"],
6
+ "optional": {},
7
+ "wiring": "Add markdown files to posts/ (created on first run), e.g. posts/2026-06-29-hello.md with front-matter: title, date, author, category, tags (comma-separated), description. The date can also come from a YYYY-MM-DD- filename prefix. Set SITE_URL in .env for absolute links in the RSS feed; POSTS_PER_PAGE (default 10) controls pagination."
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Scaffold a new Volt app — no-build, signals-based UI with Socket.io hot reload.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,9 +1,9 @@
1
- // server.js dev server with a built-in first-run setup wizard.
1
+ // server.js — dev server with a built-in first-run setup wizard.
2
2
  //
3
3
  // First run (no .env) or `node server.js --edit` (-e) opens a disposable, local
4
4
  // config page: tick add-ons, fill settings, Apply. Apply writes .env (a
5
5
  // VOLT_ADDONS list + settings) and adds any needed packages to package.json,
6
- // runs npm install, then starts the app which wires whatever .env enables.
6
+ // runs npm install, then starts the app — which wires whatever .env enables.
7
7
  // Add-on code is bundled under .volt/addons; nothing is copied into your code.
8
8
  //
9
9
  // No build step, no env-file flag: .env is auto-loaded below.
@@ -22,7 +22,7 @@ const PKG_PATH = path.join(__dirname, "package.json");
22
22
  const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
23
23
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
24
24
  const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
25
- const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", media: "media.js" };
25
+ const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
26
26
 
27
27
  // --- tiny .env loader (no dependency); never overrides an existing env var ---
28
28
  function readEnvFile() {
@@ -67,7 +67,7 @@ const imp = (rel) => import(pathToFileURL(path.join(__dirname, rel)).href);
67
67
  const addonMod = (n) => imp(path.join(".volt", "addons", n, "files", "lib", LIB_FILE[n]));
68
68
 
69
69
  // Built-in add-ons are wired explicitly below; everything else in VOLT_ADDONS is
70
- // a third-party add-on a local .volt/addons/<name>/index.js or an installed
70
+ // a third-party add-on — a local .volt/addons/<name>/index.js or an installed
71
71
  // npm package "volt-addon-<name>" exporting register(ctx). See /docs/plugins.
72
72
  const BUILTINS = new Set(Object.keys(LIB_FILE));
73
73
  async function loadAddon(name) {
@@ -91,7 +91,7 @@ function openBrowser(url) {
91
91
  const args = plat === "win32" ? ["/c", "start", "", url] : [url];
92
92
  try {
93
93
  const child = spawn(cmd, args, { stdio: "ignore", detached: true });
94
- child.on("error", () => {}); // launcher missing emits async, don't crash
94
+ child.on("error", () => {}); // launcher missing — emits async, don't crash
95
95
  child.unref();
96
96
  return true;
97
97
  } catch {
@@ -134,14 +134,16 @@ async function startApp() {
134
134
  app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
135
135
  }
136
136
 
137
- // markdown pages (/<slug> pages/<slug>.md) mounted last, so app routes win
137
+ // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
138
+ // blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) — before pages so /blog wins; renders in the same theme.
139
+ if (enabled.has("posts")) app.use(await (await addonMod("posts")).postsRouter({ dir: path.join(__dirname, "posts"), themeDir: path.join(__dirname, "pages") }));
138
140
  if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
139
141
 
140
142
  const server = http.createServer(app);
141
143
  const io = new SocketServer(server);
142
144
  if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
143
145
 
144
- // third-party add-ons register(ctx). When auth is on, requireAuth/sessionFromReq
146
+ // third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
145
147
  // are provided so add-ons can gate routes by login.
146
148
  let requireAuth = null;
147
149
  let sessionFromReq = null;
@@ -157,7 +159,7 @@ async function startApp() {
157
159
  if (typeof register === "function") {
158
160
  await register({ app, express, io, store, mailer, env: process.env, requireAuth, sessionFromReq, log: (...a) => console.log(`[${name}]`, ...a) });
159
161
  } else {
160
- console.warn(`[volt] add-on "${name}" not found or missing a register() export skipped`);
162
+ console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
161
163
  }
162
164
  }
163
165
 
@@ -165,7 +167,7 @@ async function startApp() {
165
167
  const onChange = (file) => {
166
168
  clearTimeout(timer);
167
169
  timer = setTimeout(() => {
168
- console.log(`[volt] change: ${file ?? "?"} reload`);
170
+ console.log(`[volt] change: ${file ?? "?"} → reload`);
169
171
  io.emit("volt:reload");
170
172
  }, 80);
171
173
  };
@@ -189,7 +191,7 @@ async function startApp() {
189
191
  for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
190
192
 
191
193
  const on = [...enabled];
192
- server.listen(PORT, () => console.log(`⚡ Volt http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
194
+ server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
193
195
  }
194
196
 
195
197
  // Packages an .env's selections need, beyond what package.json already has.
@@ -202,7 +204,7 @@ function neededPackages(env) {
202
204
  if (driver === "mysql") want.push("mysql2");
203
205
  if (driver === "postgres") want.push("pg");
204
206
  if (/^\s*SMTP_URL\s*=\s*\S/m.test(env)) want.push("nodemailer");
205
- if (/^\s*VOLT_ADDONS\s*=.*\bpages\b/m.test(env)) want.push("marked");
207
+ if (/^\s*VOLT_ADDONS\s*=.*\b(pages|posts)\b/m.test(env)) want.push("marked");
206
208
  if (/^\s*VOLT_ADDONS\s*=.*\bmedia\b/m.test(env)) want.push("busboy");
207
209
  if (/^\s*MEDIA_DRIVER\s*=\s*s3\b/m.test(env)) want.push("@aws-sdk/client-s3");
208
210
  return want.filter((p) => !deps[p]);
@@ -213,7 +215,7 @@ function neededPackages(env) {
213
215
  function ensureDriverInstalled(driver) {
214
216
  const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
215
217
  if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
216
- console.log(`[volt] installing ${pkg} for the connection test…`);
218
+ console.log(`[volt] installing ${pkg} for the connection test…`);
217
219
  spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
218
220
  }
219
221
 
@@ -223,6 +225,8 @@ function startSetup() {
223
225
  const assets = {
224
226
  "/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
225
227
  "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
228
+ "/logo.webp": ["image/webp", fs.readFileSync(path.join(__dirname, "public", "logo.webp"))],
229
+ "/favicon.webp": ["image/webp", fs.readFileSync(path.join(__dirname, "public", "favicon.webp"))],
226
230
  };
227
231
  const indexHtml = fs.readFileSync(path.join(__dirname, "setup", "index.html"));
228
232
 
@@ -314,15 +318,15 @@ function startSetup() {
314
318
  server.closeIdleConnections?.();
315
319
  };
316
320
  if (added.length) {
317
- console.log(`[volt] installing ${added.join(", ")}…`);
321
+ console.log(`[volt] installing ${added.join(", ")}…`);
318
322
  const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
319
323
  npm.on("error", () => handoff());
320
324
  npm.on("close", () => {
321
- console.log("[volt] saved .env starting the app");
325
+ console.log("[volt] saved .env — starting the app…");
322
326
  handoff();
323
327
  });
324
328
  } else {
325
- console.log("[volt] saved .env starting the app");
329
+ console.log("[volt] saved .env — starting the app…");
326
330
  handoff();
327
331
  }
328
332
  });
@@ -339,18 +343,18 @@ function startSetup() {
339
343
 
340
344
  server.listen(PORT, "127.0.0.1", () => {
341
345
  const url = `http://localhost:${PORT}`;
342
- console.log(`\n Volt setup ${url}`);
346
+ console.log(`\n⚡ Volt setup → ${url}`);
343
347
  console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
344
348
  const ssh = process.env.SSH_CONNECTION;
345
349
  if (ssh) {
346
350
  const host = ssh.split(" ")[2];
347
351
  const user = process.env.USER || process.env.USERNAME || "you";
348
- console.log(" Remote box the server is up here; bridge it from your LOCAL machine:");
352
+ console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
349
353
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
350
- console.log(` then open ${url} on your machine (the tunnel points it here).`);
354
+ console.log(` …then open ${url} on your machine (the tunnel points it here).`);
351
355
  }
352
356
  console.log("");
353
- if (openBrowser(url)) console.log(" (opening your browser)\n");
357
+ if (openBrowser(url)) console.log(" (opening your browser…)\n");
354
358
  });
355
359
  }
356
360
 
@@ -358,8 +362,8 @@ function readEnvFileLines() {
358
362
  return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
359
363
  }
360
364
 
361
- // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
362
- // Not a route in the running app it only exists while you run `--studio`, on
365
+ // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
366
+ // Not a route in the running app — it only exists while you run `--studio`, on
363
367
  // loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
364
368
  const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
365
369
  async function startStudio() {
@@ -372,13 +376,15 @@ async function startStudio() {
372
376
  try {
373
377
  store = await (await addonMod("db")).createStore();
374
378
  } catch (e) {
375
- console.error("Studio: couldn't connect the store " + e.message);
379
+ console.error("Studio: couldn't connect the store — " + e.message);
376
380
  process.exit(1);
377
381
  }
378
382
  const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
379
383
  const visible = (n) => n && !HIDDEN_COLLECTIONS.has(n);
380
384
  const assets = {
381
385
  "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
386
+ "/logo.webp": ["image/webp", fs.readFileSync(path.join(__dirname, "public", "logo.webp"))],
387
+ "/favicon.webp": ["image/webp", fs.readFileSync(path.join(__dirname, "public", "favicon.webp"))],
382
388
  "/db-admin-ui.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(ADDONS_DIR, "db", "files", "public", "db-admin-ui.js"))],
383
389
  };
384
390
  const studioHtml = fs.readFileSync(path.join(__dirname, "setup", "studio.html"));
@@ -427,15 +433,15 @@ async function startStudio() {
427
433
 
428
434
  server.listen(PORT, "127.0.0.1", () => {
429
435
  const url = `http://localhost:${PORT}`;
430
- console.log(`\n Volt Studio ${url} (${store.name})`);
431
- console.log(" Browse your data. localhost-only, disposable Ctrl-C when done.");
436
+ console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
437
+ console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
432
438
  const ssh = process.env.SSH_CONNECTION;
433
439
  if (ssh) {
434
440
  const host = ssh.split(" ")[2];
435
441
  const user = process.env.USER || process.env.USERNAME || "you";
436
- console.log(" Remote box from your LOCAL machine:");
442
+ console.log(" Remote box — from your LOCAL machine:");
437
443
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
438
- console.log(` then open ${url}.`);
444
+ console.log(` …then open ${url}.`);
439
445
  }
440
446
  console.log("");
441
447
  openBrowser(url);
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" href="/favicon.webp" />
6
7
  <title>Set up your Volt app</title>
7
8
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
8
9
  <style>
@@ -17,7 +18,7 @@
17
18
  <body>
18
19
  <main class="container py-5" style="max-width: 640px;">
19
20
  <header class="mb-4">
20
- <h1 class="h3"><span class="accent">⚡ Set up your Volt app</span></h1>
21
+ <h1 class="h3"><span class="accent"><img src="/logo.webp" alt="" style="height:1em;vertical-align:-.15em" /> Set up your Volt app</span></h1>
21
22
  <p class="text-muted mb-0">Fill these in and the app starts. This page is disposable — it disappears once you click Apply. Re-open it anytime with <code>npm run dev -- --edit</code>.</p>
22
23
  </header>
23
24
  <div id="app"></div>
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" href="/favicon.webp" />
6
7
  <title>Volt Studio</title>
7
8
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
8
9
  <style>
@@ -14,7 +15,7 @@
14
15
  <body>
15
16
  <main class="container py-5" style="max-width: 820px;">
16
17
  <header class="mb-4">
17
- <h1 class="h3"><span class="accent">⚡ Volt Studio</span> <small class="text-muted">data browser</small></h1>
18
+ <h1 class="h3"><span class="accent"><img src="/logo.webp" alt="" style="height:1em;vertical-align:-.15em" /> Volt Studio</span> <small class="text-muted">data browser</small></h1>
18
19
  <p class="text-muted mb-0">Browse the database in your <code>.env</code>. Disposable &amp; localhost-only — close the terminal when done.</p>
19
20
  </header>
20
21
  <div id="app"></div>
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" href="/favicon.webp" />
6
7
  <title>Volt — signals, no build, hot reload</title>
7
8
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
8
9
  <style>
@@ -19,7 +20,7 @@
19
20
  <body>
20
21
  <main class="container py-5" style="max-width: 720px;">
21
22
  <header class="text-center mb-5">
22
- <h1 class="brand display-5"><span class="accent">⚡ Volt</span></h1>
23
+ <h1 class="brand display-5"><span class="accent"><img src="/logo.webp" alt="" style="height:1em;vertical-align:-.15em" /> Volt</span></h1>
23
24
  <p class="text-muted mb-0">
24
25
  Fine-grained signals · no JSX · no virtual DOM · live hot reload.
25
26
  Edit <code>public/app.js</code> and save — this page reloads itself.
@@ -147,7 +147,7 @@ const TABS = [
147
147
  const nav = () =>
148
148
  html`<nav class="navx py-2 mb-4">
149
149
  <div class="container d-flex align-items-center" style="max-width:760px">
150
- <span class="brand me-3"><span class="accent">⚡ Volt</span></span>
150
+ <span class="brand me-3"><span class="accent"><img src="/logo.webp" alt="" style="height:1em;vertical-align:-.15em" /> Volt</span></span>
151
151
  ${TABS.map(([key, label]) => html`<button class=${() => "btn btn-link btn-sm " + (section() === key ? "active" : "")} onclick=${() => section(key)}>${label}</button>`)}
152
152
  <span class="ms-auto small text-muted">${() => (me() ? shortName(me()) : "guest")}</span>
153
153
  </div>
@@ -1,9 +1,9 @@
1
- // server.js dev server with a built-in first-run setup wizard.
1
+ // server.js — dev server with a built-in first-run setup wizard.
2
2
  //
3
3
  // First run (no .env) or `node server.js --edit` (-e) opens a disposable, local
4
4
  // config page: tick add-ons, fill settings, Apply. Apply writes .env (a
5
5
  // VOLT_ADDONS list + settings) and adds any needed packages to package.json,
6
- // runs npm install, then starts the app which wires whatever .env enables.
6
+ // runs npm install, then starts the app — which wires whatever .env enables.
7
7
  // Add-on code is bundled under .volt/addons; nothing is copied into your code.
8
8
  //
9
9
  // No build step, no env-file flag: .env is auto-loaded below.
@@ -23,7 +23,7 @@ const PKG_PATH = path.join(__dirname, "package.json");
23
23
  const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
24
24
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
25
25
  const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
26
- const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", media: "media.js" };
26
+ const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
27
27
 
28
28
  // --- tiny .env loader (no dependency); never overrides an existing env var ---
29
29
  function readEnvFile() {
@@ -68,7 +68,7 @@ const imp = (rel) => import(pathToFileURL(path.join(__dirname, rel)).href);
68
68
  const addonMod = (n) => imp(path.join(".volt", "addons", n, "files", "lib", LIB_FILE[n]));
69
69
 
70
70
  // Built-in add-ons are wired explicitly below; everything else in VOLT_ADDONS is
71
- // a third-party add-on a local .volt/addons/<name>/index.js or an installed
71
+ // a third-party add-on — a local .volt/addons/<name>/index.js or an installed
72
72
  // npm package "volt-addon-<name>" exporting register(ctx). See /docs/plugins.
73
73
  const BUILTINS = new Set(Object.keys(LIB_FILE));
74
74
  async function loadAddon(name) {
@@ -92,7 +92,7 @@ function openBrowser(url) {
92
92
  const args = plat === "win32" ? ["/c", "start", "", url] : [url];
93
93
  try {
94
94
  const child = spawn(cmd, args, { stdio: "ignore", detached: true });
95
- child.on("error", () => {}); // launcher missing emits async, don't crash
95
+ child.on("error", () => {}); // launcher missing — emits async, don't crash
96
96
  child.unref();
97
97
  return true;
98
98
  } catch {
@@ -120,7 +120,7 @@ async function startApp() {
120
120
  if (enabled.has("mailer")) mailer = await (await addonMod("mailer")).createMailer();
121
121
  if (enabled.has("auth") && store && mailer) app.use((await addonMod("auth")).authRouter({ store, mailer }));
122
122
 
123
- // notes a per-user CRUD example (auth-gated, owner-scoped, db-backed)
123
+ // notes — a per-user CRUD example (auth-gated, owner-scoped, db-backed)
124
124
  if (enabled.has("db") && enabled.has("auth") && store) {
125
125
  const guard = (await addonMod("auth")).requireAuth(store);
126
126
  const notes = store.collection("notes");
@@ -160,14 +160,16 @@ async function startApp() {
160
160
  app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
161
161
  }
162
162
 
163
- // markdown pages (/<slug> pages/<slug>.md) mounted last, so app routes win
163
+ // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
164
+ // blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) — before pages so /blog wins; renders in the same theme.
165
+ if (enabled.has("posts")) app.use(await (await addonMod("posts")).postsRouter({ dir: path.join(__dirname, "posts"), themeDir: path.join(__dirname, "pages") }));
164
166
  if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
165
167
 
166
168
  const server = http.createServer(app);
167
169
  const io = new SocketServer(server);
168
170
  if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
169
171
 
170
- // third-party add-ons register(ctx). When auth is on, requireAuth/sessionFromReq
172
+ // third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
171
173
  // are provided so add-ons can gate routes by login.
172
174
  let requireAuth = null;
173
175
  let sessionFromReq = null;
@@ -183,7 +185,7 @@ async function startApp() {
183
185
  if (typeof register === "function") {
184
186
  await register({ app, express, io, store, mailer, env: process.env, requireAuth, sessionFromReq, log: (...a) => console.log(`[${name}]`, ...a) });
185
187
  } else {
186
- console.warn(`[volt] add-on "${name}" not found or missing a register() export skipped`);
188
+ console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
187
189
  }
188
190
  }
189
191
 
@@ -191,7 +193,7 @@ async function startApp() {
191
193
  const onChange = (file) => {
192
194
  clearTimeout(timer);
193
195
  timer = setTimeout(() => {
194
- console.log(`[volt] change: ${file ?? "?"} reload`);
196
+ console.log(`[volt] change: ${file ?? "?"} → reload`);
195
197
  io.emit("volt:reload");
196
198
  }, 80);
197
199
  };
@@ -215,7 +217,7 @@ async function startApp() {
215
217
  for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
216
218
 
217
219
  const on = [...enabled];
218
- server.listen(PORT, () => console.log(`⚡ Volt http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
220
+ server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
219
221
  }
220
222
 
221
223
  // Packages an .env's selections need, beyond what package.json already has.
@@ -228,7 +230,7 @@ function neededPackages(env) {
228
230
  if (driver === "mysql") want.push("mysql2");
229
231
  if (driver === "postgres") want.push("pg");
230
232
  if (/^\s*SMTP_URL\s*=\s*\S/m.test(env)) want.push("nodemailer");
231
- if (/^\s*VOLT_ADDONS\s*=.*\bpages\b/m.test(env)) want.push("marked");
233
+ if (/^\s*VOLT_ADDONS\s*=.*\b(pages|posts)\b/m.test(env)) want.push("marked");
232
234
  if (/^\s*VOLT_ADDONS\s*=.*\bmedia\b/m.test(env)) want.push("busboy");
233
235
  if (/^\s*MEDIA_DRIVER\s*=\s*s3\b/m.test(env)) want.push("@aws-sdk/client-s3");
234
236
  return want.filter((p) => !deps[p]);
@@ -239,7 +241,7 @@ function neededPackages(env) {
239
241
  function ensureDriverInstalled(driver) {
240
242
  const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
241
243
  if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
242
- console.log(`[volt] installing ${pkg} for the connection test…`);
244
+ console.log(`[volt] installing ${pkg} for the connection test…`);
243
245
  spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
244
246
  }
245
247
 
@@ -249,6 +251,8 @@ function startSetup() {
249
251
  const assets = {
250
252
  "/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
251
253
  "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
254
+ "/logo.webp": ["image/webp", fs.readFileSync(path.join(__dirname, "public", "logo.webp"))],
255
+ "/favicon.webp": ["image/webp", fs.readFileSync(path.join(__dirname, "public", "favicon.webp"))],
252
256
  };
253
257
  const indexHtml = fs.readFileSync(path.join(__dirname, "setup", "index.html"));
254
258
 
@@ -340,15 +344,15 @@ function startSetup() {
340
344
  server.closeIdleConnections?.();
341
345
  };
342
346
  if (added.length) {
343
- console.log(`[volt] installing ${added.join(", ")}…`);
347
+ console.log(`[volt] installing ${added.join(", ")}…`);
344
348
  const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
345
349
  npm.on("error", () => handoff());
346
350
  npm.on("close", () => {
347
- console.log("[volt] saved .env starting the app");
351
+ console.log("[volt] saved .env — starting the app…");
348
352
  handoff();
349
353
  });
350
354
  } else {
351
- console.log("[volt] saved .env starting the app");
355
+ console.log("[volt] saved .env — starting the app…");
352
356
  handoff();
353
357
  }
354
358
  });
@@ -365,18 +369,18 @@ function startSetup() {
365
369
 
366
370
  server.listen(PORT, "127.0.0.1", () => {
367
371
  const url = `http://localhost:${PORT}`;
368
- console.log(`\n Volt setup ${url}`);
372
+ console.log(`\n⚡ Volt setup → ${url}`);
369
373
  console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
370
374
  const ssh = process.env.SSH_CONNECTION;
371
375
  if (ssh) {
372
376
  const host = ssh.split(" ")[2];
373
377
  const user = process.env.USER || process.env.USERNAME || "you";
374
- console.log(" Remote box the server is up here; bridge it from your LOCAL machine:");
378
+ console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
375
379
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
376
- console.log(` then open ${url} on your machine (the tunnel points it here).`);
380
+ console.log(` …then open ${url} on your machine (the tunnel points it here).`);
377
381
  }
378
382
  console.log("");
379
- if (openBrowser(url)) console.log(" (opening your browser)\n");
383
+ if (openBrowser(url)) console.log(" (opening your browser…)\n");
380
384
  });
381
385
  }
382
386
 
@@ -384,8 +388,8 @@ function readEnvFileLines() {
384
388
  return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
385
389
  }
386
390
 
387
- // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
388
- // Not a route in the running app it only exists while you run `--studio`, on
391
+ // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
392
+ // Not a route in the running app — it only exists while you run `--studio`, on
389
393
  // loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
390
394
  const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
391
395
  async function startStudio() {
@@ -398,13 +402,15 @@ async function startStudio() {
398
402
  try {
399
403
  store = await (await addonMod("db")).createStore();
400
404
  } catch (e) {
401
- console.error("Studio: couldn't connect the store " + e.message);
405
+ console.error("Studio: couldn't connect the store — " + e.message);
402
406
  process.exit(1);
403
407
  }
404
408
  const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
405
409
  const visible = (n) => n && !HIDDEN_COLLECTIONS.has(n);
406
410
  const assets = {
407
411
  "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
412
+ "/logo.webp": ["image/webp", fs.readFileSync(path.join(__dirname, "public", "logo.webp"))],
413
+ "/favicon.webp": ["image/webp", fs.readFileSync(path.join(__dirname, "public", "favicon.webp"))],
408
414
  "/db-admin-ui.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(ADDONS_DIR, "db", "files", "public", "db-admin-ui.js"))],
409
415
  };
410
416
  const studioHtml = fs.readFileSync(path.join(__dirname, "setup", "studio.html"));
@@ -453,15 +459,15 @@ async function startStudio() {
453
459
 
454
460
  server.listen(PORT, "127.0.0.1", () => {
455
461
  const url = `http://localhost:${PORT}`;
456
- console.log(`\n Volt Studio ${url} (${store.name})`);
457
- console.log(" Browse your data. localhost-only, disposable Ctrl-C when done.");
462
+ console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
463
+ console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
458
464
  const ssh = process.env.SSH_CONNECTION;
459
465
  if (ssh) {
460
466
  const host = ssh.split(" ")[2];
461
467
  const user = process.env.USER || process.env.USERNAME || "you";
462
- console.log(" Remote box from your LOCAL machine:");
468
+ console.log(" Remote box — from your LOCAL machine:");
463
469
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
464
- console.log(` then open ${url}.`);
470
+ console.log(` …then open ${url}.`);
465
471
  }
466
472
  console.log("");
467
473
  openBrowser(url);
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" href="/favicon.webp" />
6
7
  <title>Set up your Volt app</title>
7
8
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
8
9
  <style>
@@ -17,7 +18,7 @@
17
18
  <body>
18
19
  <main class="container py-5" style="max-width: 640px;">
19
20
  <header class="mb-4">
20
- <h1 class="h3"><span class="accent">⚡ Set up your Volt app</span></h1>
21
+ <h1 class="h3"><span class="accent"><img src="/logo.webp" alt="" style="height:1em;vertical-align:-.15em" /> Set up your Volt app</span></h1>
21
22
  <p class="text-muted mb-0">Fill these in and the app starts. This page is disposable — it disappears once you click Apply. Re-open it anytime with <code>npm run dev -- --edit</code>.</p>
22
23
  </header>
23
24
  <div id="app"></div>
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" href="/favicon.webp" />
6
7
  <title>Volt Studio</title>
7
8
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
8
9
  <style>
@@ -14,7 +15,7 @@
14
15
  <body>
15
16
  <main class="container py-5" style="max-width: 820px;">
16
17
  <header class="mb-4">
17
- <h1 class="h3"><span class="accent">⚡ Volt Studio</span> <small class="text-muted">data browser</small></h1>
18
+ <h1 class="h3"><span class="accent"><img src="/logo.webp" alt="" style="height:1em;vertical-align:-.15em" /> Volt Studio</span> <small class="text-muted">data browser</small></h1>
18
19
  <p class="text-muted mb-0">Browse the database in your <code>.env</code>. Disposable &amp; localhost-only — close the terminal when done.</p>
19
20
  </header>
20
21
  <div id="app"></div>
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" href="/favicon.webp" />
6
7
  <title>Volt starter</title>
7
8
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
8
9
  <style>