create-volt 0.30.0 → 0.31.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,17 @@ 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.31.0] - 2026-06-29
8
+
9
+ ### Added
10
+ - **`posts` add-on — a blog (the WordPress content model).** Markdown in `posts/`
11
+ becomes a paginated index at `/blog`, single posts at `/blog/<slug>`, plus
12
+ `/category/<name>`, `/tag/<name>`, and an RSS feed at `/feed.xml`. Posts carry
13
+ date/author/category/tags front-matter (date can also come from a
14
+ `YYYY-MM-DD-` filename prefix); drafts (`draft: true`) are skipped. Renders in
15
+ the site theme and reuses pages' SEO — single posts get an auto Article
16
+ JSON-LD + `og:type=article`. Depends on the `pages` add-on. New `/docs/posts`.
17
+
7
18
  ## [0.30.0] - 2026-06-29
8
19
 
9
20
  ### Fixed
@@ -413,6 +424,7 @@ All notable changes to `create-volt` are documented here. The format follows
413
424
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
414
425
  and auto-detects npm / pnpm / yarn / bun for the install step.
415
426
 
427
+ [0.31.0]: https://github.com/MIR-2025/volt/releases/tag/v0.31.0
416
428
  [0.30.0]: https://github.com/MIR-2025/volt/releases/tag/v0.30.0
417
429
  [0.29.0]: https://github.com/MIR-2025/volt/releases/tag/v0.29.0
418
430
  [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.31.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
 
@@ -314,15 +316,15 @@ function startSetup() {
314
316
  server.closeIdleConnections?.();
315
317
  };
316
318
  if (added.length) {
317
- console.log(`[volt] installing ${added.join(", ")}…`);
319
+ console.log(`[volt] installing ${added.join(", ")}…`);
318
320
  const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
319
321
  npm.on("error", () => handoff());
320
322
  npm.on("close", () => {
321
- console.log("[volt] saved .env starting the app");
323
+ console.log("[volt] saved .env — starting the app…");
322
324
  handoff();
323
325
  });
324
326
  } else {
325
- console.log("[volt] saved .env starting the app");
327
+ console.log("[volt] saved .env — starting the app…");
326
328
  handoff();
327
329
  }
328
330
  });
@@ -339,18 +341,18 @@ function startSetup() {
339
341
 
340
342
  server.listen(PORT, "127.0.0.1", () => {
341
343
  const url = `http://localhost:${PORT}`;
342
- console.log(`\n Volt setup ${url}`);
344
+ console.log(`\n⚡ Volt setup → ${url}`);
343
345
  console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
344
346
  const ssh = process.env.SSH_CONNECTION;
345
347
  if (ssh) {
346
348
  const host = ssh.split(" ")[2];
347
349
  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:");
350
+ console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
349
351
  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).`);
352
+ console.log(` …then open ${url} on your machine (the tunnel points it here).`);
351
353
  }
352
354
  console.log("");
353
- if (openBrowser(url)) console.log(" (opening your browser)\n");
355
+ if (openBrowser(url)) console.log(" (opening your browser…)\n");
354
356
  });
355
357
  }
356
358
 
@@ -358,8 +360,8 @@ function readEnvFileLines() {
358
360
  return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
359
361
  }
360
362
 
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
363
+ // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
364
+ // Not a route in the running app — it only exists while you run `--studio`, on
363
365
  // loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
364
366
  const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
365
367
  async function startStudio() {
@@ -372,7 +374,7 @@ async function startStudio() {
372
374
  try {
373
375
  store = await (await addonMod("db")).createStore();
374
376
  } catch (e) {
375
- console.error("Studio: couldn't connect the store " + e.message);
377
+ console.error("Studio: couldn't connect the store — " + e.message);
376
378
  process.exit(1);
377
379
  }
378
380
  const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
@@ -427,15 +429,15 @@ async function startStudio() {
427
429
 
428
430
  server.listen(PORT, "127.0.0.1", () => {
429
431
  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.");
432
+ console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
433
+ console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
432
434
  const ssh = process.env.SSH_CONNECTION;
433
435
  if (ssh) {
434
436
  const host = ssh.split(" ")[2];
435
437
  const user = process.env.USER || process.env.USERNAME || "you";
436
- console.log(" Remote box from your LOCAL machine:");
438
+ console.log(" Remote box — from your LOCAL machine:");
437
439
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
438
- console.log(` then open ${url}.`);
440
+ console.log(` …then open ${url}.`);
439
441
  }
440
442
  console.log("");
441
443
  openBrowser(url);
@@ -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
 
@@ -340,15 +342,15 @@ function startSetup() {
340
342
  server.closeIdleConnections?.();
341
343
  };
342
344
  if (added.length) {
343
- console.log(`[volt] installing ${added.join(", ")}…`);
345
+ console.log(`[volt] installing ${added.join(", ")}…`);
344
346
  const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
345
347
  npm.on("error", () => handoff());
346
348
  npm.on("close", () => {
347
- console.log("[volt] saved .env starting the app");
349
+ console.log("[volt] saved .env — starting the app…");
348
350
  handoff();
349
351
  });
350
352
  } else {
351
- console.log("[volt] saved .env starting the app");
353
+ console.log("[volt] saved .env — starting the app…");
352
354
  handoff();
353
355
  }
354
356
  });
@@ -365,18 +367,18 @@ function startSetup() {
365
367
 
366
368
  server.listen(PORT, "127.0.0.1", () => {
367
369
  const url = `http://localhost:${PORT}`;
368
- console.log(`\n Volt setup ${url}`);
370
+ console.log(`\n⚡ Volt setup → ${url}`);
369
371
  console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
370
372
  const ssh = process.env.SSH_CONNECTION;
371
373
  if (ssh) {
372
374
  const host = ssh.split(" ")[2];
373
375
  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:");
376
+ console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
375
377
  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).`);
378
+ console.log(` …then open ${url} on your machine (the tunnel points it here).`);
377
379
  }
378
380
  console.log("");
379
- if (openBrowser(url)) console.log(" (opening your browser)\n");
381
+ if (openBrowser(url)) console.log(" (opening your browser…)\n");
380
382
  });
381
383
  }
382
384
 
@@ -384,8 +386,8 @@ function readEnvFileLines() {
384
386
  return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
385
387
  }
386
388
 
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
389
+ // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
390
+ // Not a route in the running app — it only exists while you run `--studio`, on
389
391
  // loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
390
392
  const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
391
393
  async function startStudio() {
@@ -398,7 +400,7 @@ async function startStudio() {
398
400
  try {
399
401
  store = await (await addonMod("db")).createStore();
400
402
  } catch (e) {
401
- console.error("Studio: couldn't connect the store " + e.message);
403
+ console.error("Studio: couldn't connect the store — " + e.message);
402
404
  process.exit(1);
403
405
  }
404
406
  const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
@@ -453,15 +455,15 @@ async function startStudio() {
453
455
 
454
456
  server.listen(PORT, "127.0.0.1", () => {
455
457
  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.");
458
+ console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
459
+ console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
458
460
  const ssh = process.env.SSH_CONNECTION;
459
461
  if (ssh) {
460
462
  const host = ssh.split(" ")[2];
461
463
  const user = process.env.USER || process.env.USERNAME || "you";
462
- console.log(" Remote box from your LOCAL machine:");
464
+ console.log(" Remote box — from your LOCAL machine:");
463
465
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
464
- console.log(` then open ${url}.`);
466
+ console.log(` …then open ${url}.`);
465
467
  }
466
468
  console.log("");
467
469
  openBrowser(url);