create-volt 0.29.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 +27 -0
- package/addons/pages/files/lib/pages.js +2 -2
- package/addons/posts/files/lib/posts.js +168 -0
- package/addons/posts/meta.json +8 -0
- package/package.json +1 -1
- package/templates/default/public/volt.js +2 -1
- package/templates/default/server.js +28 -26
- package/templates/default/setup/setup.js +18 -10
- package/templates/guestbook/public/volt.js +2 -1
- package/templates/starter/public/volt.js +2 -1
- package/templates/starter/server.js +29 -27
- package/templates/starter/setup/setup.js +18 -10
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/), and this project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.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
|
+
|
|
18
|
+
## [0.30.0] - 2026-06-29
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **Setup wizard: typing in a settings field no longer drops focus.** The
|
|
22
|
+
conditional sections (DB-driver fields, S3 fields) read the whole `state()`
|
|
23
|
+
signal, so every keystroke re-rendered the section and recreated the `<input>`,
|
|
24
|
+
losing focus. They now key on memoized primitive derivations (`dbDriver`,
|
|
25
|
+
`mediaDriver`, `hasDb`/`hasMailer`/`hasMedia`) so a section re-renders only when
|
|
26
|
+
its discriminant changes.
|
|
27
|
+
- **`volt.js`: reactive `value` bindings skip redundant DOM writes.** `setAttr`
|
|
28
|
+
no longer reassigns `node.value` when it already equals the new value — those
|
|
29
|
+
writes were resetting the caret to the end while typing. Benefits every Volt
|
|
30
|
+
app, not just the wizard.
|
|
31
|
+
|
|
7
32
|
## [0.29.0] - 2026-06-29
|
|
8
33
|
|
|
9
34
|
### Fixed
|
|
@@ -399,6 +424,8 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
399
424
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
400
425
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
401
426
|
|
|
427
|
+
[0.31.0]: https://github.com/MIR-2025/volt/releases/tag/v0.31.0
|
|
428
|
+
[0.30.0]: https://github.com/MIR-2025/volt/releases/tag/v0.30.0
|
|
402
429
|
[0.29.0]: https://github.com/MIR-2025/volt/releases/tag/v0.29.0
|
|
403
430
|
[0.28.0]: https://github.com/MIR-2025/volt/releases/tag/v0.28.0
|
|
404
431
|
[0.27.0]: https://github.com/MIR-2025/volt/releases/tag/v0.27.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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 ? " · " + 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}">← Newer</a>` : "<span></span>";
|
|
69
|
+
const next = page < totalPages ? `<a href="${baseUrl}?page=${page + 1}" style="margin-left:auto">Older →</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 ? " · " + esc(p.meta.author) : ""}${p.meta.category ? " · " + 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
|
@@ -127,7 +127,8 @@ export function mount(target, ...children) {
|
|
|
127
127
|
|
|
128
128
|
function setAttr(node, name, value) {
|
|
129
129
|
if (name === "value") {
|
|
130
|
-
|
|
130
|
+
const v = value ?? "";
|
|
131
|
+
if (node.value !== v) node.value = v; // skip redundant writes — they reset the caret while typing
|
|
131
132
|
return;
|
|
132
133
|
}
|
|
133
134
|
if (name === "checked" || name === "disabled" || name === "selected") {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// server.js
|
|
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
|
|
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
|
|
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
|
|
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>
|
|
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
|
|
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
|
|
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 ?? "?"}
|
|
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(
|
|
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*=.*\
|
|
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
|
|
323
|
+
console.log("[volt] saved .env â starting the appâ¦");
|
|
322
324
|
handoff();
|
|
323
325
|
});
|
|
324
326
|
} else {
|
|
325
|
-
console.log("[volt] saved .env
|
|
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
|
|
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
|
|
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(`
|
|
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
|
|
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 (
|
|
362
|
-
// Not a route in the running app
|
|
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
|
|
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
|
|
431
|
-
console.log(" Browse your data. localhost-only, disposable
|
|
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
|
|
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(`
|
|
440
|
+
console.log(` â¦then open ${url}.`);
|
|
439
441
|
}
|
|
440
442
|
console.log("");
|
|
441
443
|
openBrowser(url);
|
|
@@ -75,6 +75,14 @@ function genEnv(s) {
|
|
|
75
75
|
}
|
|
76
76
|
const env = computed(() => genEnv(state()));
|
|
77
77
|
const eff = computed(() => effective(state()));
|
|
78
|
+
// Memoized, primitive-valued derivations: a conditional section keyed on these
|
|
79
|
+
// only re-renders when the *discriminant* changes — not on every keystroke in a
|
|
80
|
+
// field it contains (which would recreate the input and drop focus).
|
|
81
|
+
const dbDriver = computed(() => state().dbDriver);
|
|
82
|
+
const mediaDriver = computed(() => state().mediaDriver);
|
|
83
|
+
const hasDb = computed(() => eff().includes("db"));
|
|
84
|
+
const hasMailer = computed(() => eff().includes("mailer"));
|
|
85
|
+
const hasMedia = computed(() => eff().includes("media"));
|
|
78
86
|
|
|
79
87
|
async function testDb() {
|
|
80
88
|
const s = state();
|
|
@@ -137,7 +145,7 @@ const addonRow = (a) =>
|
|
|
137
145
|
const dbSettings = () =>
|
|
138
146
|
html`<div class="mb-2">
|
|
139
147
|
<label class="form-label small mb-1">Database (DB_DRIVER)</label>
|
|
140
|
-
<select class="form-select" value=${() =>
|
|
148
|
+
<select class="form-select" value=${() => dbDriver()} onchange=${(e) => set({ dbDriver: e.target.value })}>
|
|
141
149
|
<option value="memory">memory (no setup)</option>
|
|
142
150
|
<option value="mongodb">mongodb</option>
|
|
143
151
|
<option value="mysql">mysql</option>
|
|
@@ -145,23 +153,23 @@ const dbSettings = () =>
|
|
|
145
153
|
</select>
|
|
146
154
|
</div>
|
|
147
155
|
${() =>
|
|
148
|
-
|
|
156
|
+
dbDriver() === "mongodb"
|
|
149
157
|
? html`${field("MONGODB_URI", "mongoUri", "mongodb://user:pass@host:27017/db")}${field("MONGODB_DATABASE", "mongoDb", "db")}`
|
|
150
|
-
:
|
|
151
|
-
? field("DATABASE_URL", "dbUrl",
|
|
158
|
+
: dbDriver() === "mysql" || dbDriver() === "postgres"
|
|
159
|
+
? field("DATABASE_URL", "dbUrl", dbDriver() + "://user:pass@host/db")
|
|
152
160
|
: null}
|
|
153
|
-
${() => (
|
|
161
|
+
${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>` : null)}`;
|
|
154
162
|
|
|
155
163
|
const mediaSettings = () =>
|
|
156
164
|
html`<div class="mb-2">
|
|
157
165
|
<label class="form-label small mb-1">Media storage (MEDIA_DRIVER)</label>
|
|
158
|
-
<select class="form-select" value=${() =>
|
|
166
|
+
<select class="form-select" value=${() => mediaDriver()} onchange=${(e) => set({ mediaDriver: e.target.value })}>
|
|
159
167
|
<option value="local">local (disk)</option>
|
|
160
168
|
<option value="s3">s3 — AWS S3 / DigitalOcean Spaces</option>
|
|
161
169
|
</select>
|
|
162
170
|
</div>
|
|
163
171
|
${() =>
|
|
164
|
-
|
|
172
|
+
mediaDriver() === "s3"
|
|
165
173
|
? html`${field("S3_ENDPOINT", "s3Endpoint", "https://nyc3.digitaloceanspaces.com")}${field("S3_REGION", "s3Region", "us-east-1")}${field("S3_BUCKET", "s3Bucket", "my-space")}${field("S3_KEY", "s3Key", "access key")}${field("S3_SECRET", "s3Secret", "secret key")}${field("S3_PUBLIC_BASE (optional CDN base)", "s3PublicBase", "https://cdn.example.com")}`
|
|
166
174
|
: null}`;
|
|
167
175
|
|
|
@@ -177,9 +185,9 @@ mount(
|
|
|
177
185
|
html`<div class="card-x p-4 mb-3">
|
|
178
186
|
<h2 class="h6 mb-3">Settings</h2>
|
|
179
187
|
${field("PORT", "port", String(defaultPort))}
|
|
180
|
-
${() => (
|
|
181
|
-
${() => (
|
|
182
|
-
${() => (
|
|
188
|
+
${() => (hasDb() ? dbSettings() : null)}
|
|
189
|
+
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
|
|
190
|
+
${() => (hasMedia() ? mediaSettings() : null)}
|
|
183
191
|
</div>`,
|
|
184
192
|
html`<div class="card-x p-4 mb-3">
|
|
185
193
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
@@ -127,7 +127,8 @@ export function mount(target, ...children) {
|
|
|
127
127
|
|
|
128
128
|
function setAttr(node, name, value) {
|
|
129
129
|
if (name === "value") {
|
|
130
|
-
|
|
130
|
+
const v = value ?? "";
|
|
131
|
+
if (node.value !== v) node.value = v; // skip redundant writes — they reset the caret while typing
|
|
131
132
|
return;
|
|
132
133
|
}
|
|
133
134
|
if (name === "checked" || name === "disabled" || name === "selected") {
|
|
@@ -127,7 +127,8 @@ export function mount(target, ...children) {
|
|
|
127
127
|
|
|
128
128
|
function setAttr(node, name, value) {
|
|
129
129
|
if (name === "value") {
|
|
130
|
-
|
|
130
|
+
const v = value ?? "";
|
|
131
|
+
if (node.value !== v) node.value = v; // skip redundant writes — they reset the caret while typing
|
|
131
132
|
return;
|
|
132
133
|
}
|
|
133
134
|
if (name === "checked" || name === "disabled" || name === "selected") {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// server.js
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>
|
|
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
|
|
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
|
|
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 ?? "?"}
|
|
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(
|
|
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*=.*\
|
|
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
|
|
349
|
+
console.log("[volt] saved .env â starting the appâ¦");
|
|
348
350
|
handoff();
|
|
349
351
|
});
|
|
350
352
|
} else {
|
|
351
|
-
console.log("[volt] saved .env
|
|
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
|
|
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
|
|
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(`
|
|
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
|
|
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 (
|
|
388
|
-
// Not a route in the running app
|
|
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
|
|
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
|
|
457
|
-
console.log(" Browse your data. localhost-only, disposable
|
|
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
|
|
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(`
|
|
466
|
+
console.log(` â¦then open ${url}.`);
|
|
465
467
|
}
|
|
466
468
|
console.log("");
|
|
467
469
|
openBrowser(url);
|
|
@@ -75,6 +75,14 @@ function genEnv(s) {
|
|
|
75
75
|
}
|
|
76
76
|
const env = computed(() => genEnv(state()));
|
|
77
77
|
const eff = computed(() => effective(state()));
|
|
78
|
+
// Memoized, primitive-valued derivations: a conditional section keyed on these
|
|
79
|
+
// only re-renders when the *discriminant* changes — not on every keystroke in a
|
|
80
|
+
// field it contains (which would recreate the input and drop focus).
|
|
81
|
+
const dbDriver = computed(() => state().dbDriver);
|
|
82
|
+
const mediaDriver = computed(() => state().mediaDriver);
|
|
83
|
+
const hasDb = computed(() => eff().includes("db"));
|
|
84
|
+
const hasMailer = computed(() => eff().includes("mailer"));
|
|
85
|
+
const hasMedia = computed(() => eff().includes("media"));
|
|
78
86
|
|
|
79
87
|
async function testDb() {
|
|
80
88
|
const s = state();
|
|
@@ -137,7 +145,7 @@ const addonRow = (a) =>
|
|
|
137
145
|
const dbSettings = () =>
|
|
138
146
|
html`<div class="mb-2">
|
|
139
147
|
<label class="form-label small mb-1">Database (DB_DRIVER)</label>
|
|
140
|
-
<select class="form-select" value=${() =>
|
|
148
|
+
<select class="form-select" value=${() => dbDriver()} onchange=${(e) => set({ dbDriver: e.target.value })}>
|
|
141
149
|
<option value="memory">memory (no setup)</option>
|
|
142
150
|
<option value="mongodb">mongodb</option>
|
|
143
151
|
<option value="mysql">mysql</option>
|
|
@@ -145,23 +153,23 @@ const dbSettings = () =>
|
|
|
145
153
|
</select>
|
|
146
154
|
</div>
|
|
147
155
|
${() =>
|
|
148
|
-
|
|
156
|
+
dbDriver() === "mongodb"
|
|
149
157
|
? html`${field("MONGODB_URI", "mongoUri", "mongodb://user:pass@host:27017/db")}${field("MONGODB_DATABASE", "mongoDb", "db")}`
|
|
150
|
-
:
|
|
151
|
-
? field("DATABASE_URL", "dbUrl",
|
|
158
|
+
: dbDriver() === "mysql" || dbDriver() === "postgres"
|
|
159
|
+
? field("DATABASE_URL", "dbUrl", dbDriver() + "://user:pass@host/db")
|
|
152
160
|
: null}
|
|
153
|
-
${() => (
|
|
161
|
+
${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>` : null)}`;
|
|
154
162
|
|
|
155
163
|
const mediaSettings = () =>
|
|
156
164
|
html`<div class="mb-2">
|
|
157
165
|
<label class="form-label small mb-1">Media storage (MEDIA_DRIVER)</label>
|
|
158
|
-
<select class="form-select" value=${() =>
|
|
166
|
+
<select class="form-select" value=${() => mediaDriver()} onchange=${(e) => set({ mediaDriver: e.target.value })}>
|
|
159
167
|
<option value="local">local (disk)</option>
|
|
160
168
|
<option value="s3">s3 — AWS S3 / DigitalOcean Spaces</option>
|
|
161
169
|
</select>
|
|
162
170
|
</div>
|
|
163
171
|
${() =>
|
|
164
|
-
|
|
172
|
+
mediaDriver() === "s3"
|
|
165
173
|
? html`${field("S3_ENDPOINT", "s3Endpoint", "https://nyc3.digitaloceanspaces.com")}${field("S3_REGION", "s3Region", "us-east-1")}${field("S3_BUCKET", "s3Bucket", "my-space")}${field("S3_KEY", "s3Key", "access key")}${field("S3_SECRET", "s3Secret", "secret key")}${field("S3_PUBLIC_BASE (optional CDN base)", "s3PublicBase", "https://cdn.example.com")}`
|
|
166
174
|
: null}`;
|
|
167
175
|
|
|
@@ -177,9 +185,9 @@ mount(
|
|
|
177
185
|
html`<div class="card-x p-4 mb-3">
|
|
178
186
|
<h2 class="h6 mb-3">Settings</h2>
|
|
179
187
|
${field("PORT", "port", String(defaultPort))}
|
|
180
|
-
${() => (
|
|
181
|
-
${() => (
|
|
182
|
-
${() => (
|
|
188
|
+
${() => (hasDb() ? dbSettings() : null)}
|
|
189
|
+
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
|
|
190
|
+
${() => (hasMedia() ? mediaSettings() : null)}
|
|
183
191
|
</div>`,
|
|
184
192
|
html`<div class="card-x p-4 mb-3">
|
|
185
193
|
<div class="d-flex justify-content-between align-items-center mb-2">
|