create-volt 0.53.0 → 0.55.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 +21 -0
- package/package.json +1 -1
- package/templates/blog/server.js +125 -1
- package/templates/blog/setup/setup.js +33 -6
- package/templates/default/server.js +125 -1
- package/templates/default/setup/setup.js +33 -6
- package/templates/docs/server.js +125 -1
- package/templates/docs/setup/setup.js +33 -6
- package/templates/starter/server.js +125 -1
- package/templates/starter/setup/setup.js +33 -6
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.55.0] - 2026-07-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Inline "Test" buttons for SMTP + AI in the config wizard** — the result shows
|
|
11
|
+
right next to each button. `/setup/test-smtp` verifies connection/auth (nodemailer,
|
|
12
|
+
or a TCP reachability fallback); `/setup/test-ai` does a 1-token live call to the
|
|
13
|
+
provider key or the hosted gateway. The DB test result is inline now too.
|
|
14
|
+
|
|
15
|
+
## [0.54.0] - 2026-07-04
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **Config editor renders themed.** The in-config WYSIWYG loads the active theme's
|
|
19
|
+
CSS (new `/setup/theme-css` → RTEPro `exportCSS`), so pages look like the published
|
|
20
|
+
site as you edit — new pages included.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **Log analytics bot/attack counts were always 0** — they read `.bot`/`.attack`,
|
|
24
|
+
but mir-sentinel's parseLine returns `.isBot`/`.isAttack`. Fixed in `--logs`.
|
|
25
|
+
|
|
7
26
|
## [0.53.0] - 2026-07-04
|
|
8
27
|
|
|
9
28
|
### Added
|
|
@@ -692,6 +711,8 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
692
711
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
693
712
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
694
713
|
|
|
714
|
+
[0.55.0]: https://github.com/MIR-2025/volt/releases/tag/v0.55.0
|
|
715
|
+
[0.54.0]: https://github.com/MIR-2025/volt/releases/tag/v0.54.0
|
|
695
716
|
[0.53.0]: https://github.com/MIR-2025/volt/releases/tag/v0.53.0
|
|
696
717
|
[0.52.0]: https://github.com/MIR-2025/volt/releases/tag/v0.52.0
|
|
697
718
|
[0.51.0]: https://github.com/MIR-2025/volt/releases/tag/v0.51.0
|
package/package.json
CHANGED
package/templates/blog/server.js
CHANGED
|
@@ -440,6 +440,32 @@ function startSetup() {
|
|
|
440
440
|
});
|
|
441
441
|
return;
|
|
442
442
|
}
|
|
443
|
+
// --- active theme's CSS, so the in-config editor renders pages themed ---
|
|
444
|
+
if (req.method === "GET" && p === "/setup/theme-css") {
|
|
445
|
+
res.setHeader("Content-Type", "text/css; charset=utf-8");
|
|
446
|
+
(async () => {
|
|
447
|
+
const theme = (readEnvFile().THEME || "").trim();
|
|
448
|
+
const load = async (rel) => {
|
|
449
|
+
try {
|
|
450
|
+
return (await imp(rel)).css || "";
|
|
451
|
+
} catch {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
let css = null;
|
|
456
|
+
if (theme) css = await load(path.join(".volt", "themes", theme, "index.js"));
|
|
457
|
+
if (css == null && fs.existsSync(path.join(__dirname, "pages", "_theme.js"))) css = await load(path.join("pages", "_theme.js"));
|
|
458
|
+
if (css == null && theme) {
|
|
459
|
+
try {
|
|
460
|
+
css = (await import(`volt-theme-${theme}`)).css || "";
|
|
461
|
+
} catch {
|
|
462
|
+
css = null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
res.end(css || "");
|
|
466
|
+
})();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
443
469
|
// --- content manager: list / read / write / delete pages + posts ---
|
|
444
470
|
if (req.method === "GET" && p === "/setup/content") {
|
|
445
471
|
const list = (type) => {
|
|
@@ -538,6 +564,104 @@ function startSetup() {
|
|
|
538
564
|
});
|
|
539
565
|
return;
|
|
540
566
|
}
|
|
567
|
+
// verify SMTP creds (form values merged over the saved .env) — auth check via
|
|
568
|
+
// nodemailer if available, else a TCP reachability check.
|
|
569
|
+
if (req.method === "POST" && p === "/setup/test-smtp") {
|
|
570
|
+
let body = "";
|
|
571
|
+
req.on("data", (c) => (body += c));
|
|
572
|
+
req.on("end", async () => {
|
|
573
|
+
res.setHeader("Content-Type", "application/json");
|
|
574
|
+
const done = (o) => res.end(JSON.stringify(o));
|
|
575
|
+
try {
|
|
576
|
+
const { env = {} } = JSON.parse(body || "{}");
|
|
577
|
+
const cfg = { ...readEnvFile(), ...env };
|
|
578
|
+
const url = cfg.SMTP_URL;
|
|
579
|
+
const host = cfg.SMTP_HOST;
|
|
580
|
+
if (!url && !host) return done({ ok: false, error: "no SMTP config (set SMTP_URL or SMTP_HOST)" });
|
|
581
|
+
let nodemailer;
|
|
582
|
+
try {
|
|
583
|
+
nodemailer = (await import("nodemailer")).default;
|
|
584
|
+
} catch {
|
|
585
|
+
/* not installed */
|
|
586
|
+
}
|
|
587
|
+
if (nodemailer) {
|
|
588
|
+
const transport = url
|
|
589
|
+
? nodemailer.createTransport(url)
|
|
590
|
+
: nodemailer.createTransport({ host, port: Number(cfg.SMTP_PORT) || 587, secure: /^(1|true|yes|on)$/i.test(cfg.SMTP_SECURE || "") || Number(cfg.SMTP_PORT) === 465, auth: cfg.SMTP_USER ? { user: cfg.SMTP_USER, pass: cfg.SMTP_PASS } : undefined });
|
|
591
|
+
await transport.verify();
|
|
592
|
+
return done({ ok: true, detail: "connection + auth OK" });
|
|
593
|
+
}
|
|
594
|
+
const net = await import("node:net");
|
|
595
|
+
let h = host;
|
|
596
|
+
let prt = Number(cfg.SMTP_PORT) || 587;
|
|
597
|
+
if (url) {
|
|
598
|
+
const u = new URL(url.replace(/^smtps?:\/\//, "http://"));
|
|
599
|
+
h = u.hostname;
|
|
600
|
+
prt = Number(u.port) || (url.startsWith("smtps") ? 465 : 587);
|
|
601
|
+
}
|
|
602
|
+
await new Promise((resolve, reject) => {
|
|
603
|
+
const s = net.connect(prt, h, () => {
|
|
604
|
+
s.end();
|
|
605
|
+
resolve();
|
|
606
|
+
});
|
|
607
|
+
s.setTimeout(5000, () => {
|
|
608
|
+
s.destroy();
|
|
609
|
+
reject(new Error("timeout"));
|
|
610
|
+
});
|
|
611
|
+
s.on("error", reject);
|
|
612
|
+
});
|
|
613
|
+
done({ ok: true, detail: `${h}:${prt} reachable — enable the mailer add-on for a full auth test` });
|
|
614
|
+
} catch (e) {
|
|
615
|
+
done({ ok: false, error: e.message });
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// verify the AI provider key (or gateway token) with a 1-token live call.
|
|
621
|
+
if (req.method === "POST" && p === "/setup/test-ai") {
|
|
622
|
+
let body = "";
|
|
623
|
+
req.on("data", (c) => (body += c));
|
|
624
|
+
req.on("end", async () => {
|
|
625
|
+
res.setHeader("Content-Type", "application/json");
|
|
626
|
+
const done = (o) => res.end(JSON.stringify(o));
|
|
627
|
+
try {
|
|
628
|
+
const { env = {} } = JSON.parse(body || "{}");
|
|
629
|
+
const cfg = { ...readEnvFile(), ...env };
|
|
630
|
+
const provider = cfg.AI_PROVIDER || "anthropic";
|
|
631
|
+
const key = { anthropic: cfg.ANTHROPIC_API_KEY, openai: cfg.OPENAI_API_KEY, gemini: cfg.GEMINI_API_KEY }[provider];
|
|
632
|
+
let url, headers, payload, label;
|
|
633
|
+
if (key) {
|
|
634
|
+
label = provider + " key";
|
|
635
|
+
if (provider === "anthropic") {
|
|
636
|
+
url = "https://api.anthropic.com/v1/messages";
|
|
637
|
+
headers = { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" };
|
|
638
|
+
payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
639
|
+
} else if (provider === "openai") {
|
|
640
|
+
url = "https://api.openai.com/v1/chat/completions";
|
|
641
|
+
headers = { authorization: "Bearer " + key, "content-type": "application/json" };
|
|
642
|
+
payload = { model: cfg.AI_MODEL || "gpt-4o-mini", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
643
|
+
} else {
|
|
644
|
+
url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${key}`;
|
|
645
|
+
headers = { "content-type": "application/json" };
|
|
646
|
+
payload = { contents: [{ parts: [{ text: "hi" }] }] };
|
|
647
|
+
}
|
|
648
|
+
} else if (cfg.VOLT_AI_TOKEN) {
|
|
649
|
+
label = "hosted gateway";
|
|
650
|
+
url = cfg.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai";
|
|
651
|
+
headers = { authorization: "Bearer " + cfg.VOLT_AI_TOKEN, "content-type": "application/json" };
|
|
652
|
+
payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
653
|
+
} else {
|
|
654
|
+
return done({ ok: false, error: "no AI key or VOLT_AI_TOKEN set" });
|
|
655
|
+
}
|
|
656
|
+
const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
|
|
657
|
+
if (r.ok) return done({ ok: true, detail: `${label} works` });
|
|
658
|
+
done({ ok: false, error: `${r.status}: ${(await r.text()).slice(0, 120)}` });
|
|
659
|
+
} catch (e) {
|
|
660
|
+
done({ ok: false, error: e.message });
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
541
665
|
if (req.method === "POST" && p === "/setup/apply") {
|
|
542
666
|
let body = "";
|
|
543
667
|
req.on("data", (c) => (body += c));
|
|
@@ -785,7 +909,7 @@ async function startLogs() {
|
|
|
785
909
|
const f = sources()[u.searchParams.get("source")];
|
|
786
910
|
if (!f) return json(res, { ok: false });
|
|
787
911
|
const parsed = tail(f, 5000).map((l) => parseLine(l));
|
|
788
|
-
return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.
|
|
912
|
+
return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.isBot).length, attacks: parsed.filter((x) => x && x.isAttack).length });
|
|
789
913
|
}
|
|
790
914
|
// add/remove a source ("add servers") — written to .volt/logs.json
|
|
791
915
|
if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
|
|
@@ -39,6 +39,12 @@ const state = signal({
|
|
|
39
39
|
const set = (patch) => state({ ...state(), ...patch });
|
|
40
40
|
const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
|
|
41
41
|
const status = signal("");
|
|
42
|
+
// per-test inline results (shown right next to each Test button)
|
|
43
|
+
const dbTest = signal("");
|
|
44
|
+
const smtpTest = signal("");
|
|
45
|
+
const aiTest = signal("");
|
|
46
|
+
const testResult = (m) => (m ? html`<span class="small ms-2 ${m.startsWith("✓") ? "text-success" : m.startsWith("✗") ? "text-danger" : "text-muted"}">${m}</span>` : "");
|
|
47
|
+
const envObj = () => Object.fromEntries(env().split("\n").filter((l) => /^[A-Za-z0-9_]+=/.test(l)).map((l) => { const i = l.indexOf("="); return [l.slice(0, i), l.slice(i + 1)]; }));
|
|
42
48
|
|
|
43
49
|
// selected add-ons, dependencies expanded, in display order
|
|
44
50
|
function effective(s) {
|
|
@@ -149,12 +155,30 @@ async function testDb() {
|
|
|
149
155
|
} else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
|
|
150
156
|
e.DATABASE_URL = s.dbUrl;
|
|
151
157
|
}
|
|
152
|
-
|
|
158
|
+
dbTest("Testing…");
|
|
153
159
|
try {
|
|
154
160
|
const r = await (await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: e }) })).json();
|
|
155
|
-
|
|
161
|
+
dbTest(r.ok ? `✓ Connected (${r.driver})` : `✗ ${r.error}`);
|
|
156
162
|
} catch {
|
|
157
|
-
|
|
163
|
+
dbTest("✗ network error");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function testSmtp() {
|
|
167
|
+
smtpTest("Testing…");
|
|
168
|
+
try {
|
|
169
|
+
const r = await (await fetch("/setup/test-smtp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
|
|
170
|
+
smtpTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
|
|
171
|
+
} catch {
|
|
172
|
+
smtpTest("✗ network error");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function testAi() {
|
|
176
|
+
aiTest("Testing…");
|
|
177
|
+
try {
|
|
178
|
+
const r = await (await fetch("/setup/test-ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
|
|
179
|
+
aiTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
|
|
180
|
+
} catch {
|
|
181
|
+
aiTest("✗ network error");
|
|
158
182
|
}
|
|
159
183
|
}
|
|
160
184
|
|
|
@@ -221,7 +245,7 @@ const dbSettings = () =>
|
|
|
221
245
|
: dbDriver() === "mysql" || dbDriver() === "postgres"
|
|
222
246
|
? field("DATABASE_URL", "dbUrl", dbDriver() + "://user:pass@host/db")
|
|
223
247
|
: null}
|
|
224
|
-
${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button
|
|
248
|
+
${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>${() => testResult(dbTest())}` : null)}`;
|
|
225
249
|
|
|
226
250
|
const mediaSettings = () =>
|
|
227
251
|
html`<div class="mb-2">
|
|
@@ -270,6 +294,7 @@ const aiSettings = () =>
|
|
|
270
294
|
${field("API key", "aiKey", "sk-…")}
|
|
271
295
|
<div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
|
|
272
296
|
${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
|
|
297
|
+
<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testAi}>Test AI</button>${() => testResult(aiTest())}</div>
|
|
273
298
|
</div>
|
|
274
299
|
</details>`;
|
|
275
300
|
|
|
@@ -323,6 +348,8 @@ async function genToken() {
|
|
|
323
348
|
const items = signal({ pages: [], posts: [] });
|
|
324
349
|
const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
|
|
325
350
|
let ed = null; // live RTEPro instance for the open editor
|
|
351
|
+
let themeCss = ""; // active theme's CSS, so the editor renders pages themed
|
|
352
|
+
fetch("/setup/theme-css").then((r) => r.text()).then((c) => { themeCss = c; }).catch(() => {});
|
|
326
353
|
const loadItems = async () => items(await (await fetch("/setup/content")).json());
|
|
327
354
|
// raw .md → { title, body, isHtml }; RTEPro takes markdown directly (setMarkdown),
|
|
328
355
|
// so no markdown library is needed.
|
|
@@ -333,7 +360,7 @@ function parseDoc(raw) {
|
|
|
333
360
|
return { title, body: fm ? raw.slice(fm[0].length) : raw, isHtml: /^format:\s*html\s*$/m.test(front) };
|
|
334
361
|
}
|
|
335
362
|
function mountEditor(doc) {
|
|
336
|
-
ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic" });
|
|
363
|
+
ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic", exportCSS: themeCss });
|
|
337
364
|
if (doc && doc.isHtml) ed.setHTML(doc.body || "");
|
|
338
365
|
else ed.setMarkdown((doc && doc.body) || "");
|
|
339
366
|
}
|
|
@@ -415,7 +442,7 @@ const configView = () =>
|
|
|
415
442
|
${field("SITE_NAME", "siteName", "My Site")}
|
|
416
443
|
${() => (hasContent() ? themePicker() : null)}
|
|
417
444
|
${() => (hasDb() ? dbSettings() : null)}
|
|
418
|
-
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}
|
|
445
|
+
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}<div class="mb-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testSmtp}>Test SMTP</button>${() => testResult(smtpTest())}</div>` : null)}
|
|
419
446
|
${() => (hasMedia() ? mediaSettings() : null)}
|
|
420
447
|
${aiSettings()}
|
|
421
448
|
${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
|
|
@@ -440,6 +440,32 @@ function startSetup() {
|
|
|
440
440
|
});
|
|
441
441
|
return;
|
|
442
442
|
}
|
|
443
|
+
// --- active theme's CSS, so the in-config editor renders pages themed ---
|
|
444
|
+
if (req.method === "GET" && p === "/setup/theme-css") {
|
|
445
|
+
res.setHeader("Content-Type", "text/css; charset=utf-8");
|
|
446
|
+
(async () => {
|
|
447
|
+
const theme = (readEnvFile().THEME || "").trim();
|
|
448
|
+
const load = async (rel) => {
|
|
449
|
+
try {
|
|
450
|
+
return (await imp(rel)).css || "";
|
|
451
|
+
} catch {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
let css = null;
|
|
456
|
+
if (theme) css = await load(path.join(".volt", "themes", theme, "index.js"));
|
|
457
|
+
if (css == null && fs.existsSync(path.join(__dirname, "pages", "_theme.js"))) css = await load(path.join("pages", "_theme.js"));
|
|
458
|
+
if (css == null && theme) {
|
|
459
|
+
try {
|
|
460
|
+
css = (await import(`volt-theme-${theme}`)).css || "";
|
|
461
|
+
} catch {
|
|
462
|
+
css = null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
res.end(css || "");
|
|
466
|
+
})();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
443
469
|
// --- content manager: list / read / write / delete pages + posts ---
|
|
444
470
|
if (req.method === "GET" && p === "/setup/content") {
|
|
445
471
|
const list = (type) => {
|
|
@@ -538,6 +564,104 @@ function startSetup() {
|
|
|
538
564
|
});
|
|
539
565
|
return;
|
|
540
566
|
}
|
|
567
|
+
// verify SMTP creds (form values merged over the saved .env) — auth check via
|
|
568
|
+
// nodemailer if available, else a TCP reachability check.
|
|
569
|
+
if (req.method === "POST" && p === "/setup/test-smtp") {
|
|
570
|
+
let body = "";
|
|
571
|
+
req.on("data", (c) => (body += c));
|
|
572
|
+
req.on("end", async () => {
|
|
573
|
+
res.setHeader("Content-Type", "application/json");
|
|
574
|
+
const done = (o) => res.end(JSON.stringify(o));
|
|
575
|
+
try {
|
|
576
|
+
const { env = {} } = JSON.parse(body || "{}");
|
|
577
|
+
const cfg = { ...readEnvFile(), ...env };
|
|
578
|
+
const url = cfg.SMTP_URL;
|
|
579
|
+
const host = cfg.SMTP_HOST;
|
|
580
|
+
if (!url && !host) return done({ ok: false, error: "no SMTP config (set SMTP_URL or SMTP_HOST)" });
|
|
581
|
+
let nodemailer;
|
|
582
|
+
try {
|
|
583
|
+
nodemailer = (await import("nodemailer")).default;
|
|
584
|
+
} catch {
|
|
585
|
+
/* not installed */
|
|
586
|
+
}
|
|
587
|
+
if (nodemailer) {
|
|
588
|
+
const transport = url
|
|
589
|
+
? nodemailer.createTransport(url)
|
|
590
|
+
: nodemailer.createTransport({ host, port: Number(cfg.SMTP_PORT) || 587, secure: /^(1|true|yes|on)$/i.test(cfg.SMTP_SECURE || "") || Number(cfg.SMTP_PORT) === 465, auth: cfg.SMTP_USER ? { user: cfg.SMTP_USER, pass: cfg.SMTP_PASS } : undefined });
|
|
591
|
+
await transport.verify();
|
|
592
|
+
return done({ ok: true, detail: "connection + auth OK" });
|
|
593
|
+
}
|
|
594
|
+
const net = await import("node:net");
|
|
595
|
+
let h = host;
|
|
596
|
+
let prt = Number(cfg.SMTP_PORT) || 587;
|
|
597
|
+
if (url) {
|
|
598
|
+
const u = new URL(url.replace(/^smtps?:\/\//, "http://"));
|
|
599
|
+
h = u.hostname;
|
|
600
|
+
prt = Number(u.port) || (url.startsWith("smtps") ? 465 : 587);
|
|
601
|
+
}
|
|
602
|
+
await new Promise((resolve, reject) => {
|
|
603
|
+
const s = net.connect(prt, h, () => {
|
|
604
|
+
s.end();
|
|
605
|
+
resolve();
|
|
606
|
+
});
|
|
607
|
+
s.setTimeout(5000, () => {
|
|
608
|
+
s.destroy();
|
|
609
|
+
reject(new Error("timeout"));
|
|
610
|
+
});
|
|
611
|
+
s.on("error", reject);
|
|
612
|
+
});
|
|
613
|
+
done({ ok: true, detail: `${h}:${prt} reachable — enable the mailer add-on for a full auth test` });
|
|
614
|
+
} catch (e) {
|
|
615
|
+
done({ ok: false, error: e.message });
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// verify the AI provider key (or gateway token) with a 1-token live call.
|
|
621
|
+
if (req.method === "POST" && p === "/setup/test-ai") {
|
|
622
|
+
let body = "";
|
|
623
|
+
req.on("data", (c) => (body += c));
|
|
624
|
+
req.on("end", async () => {
|
|
625
|
+
res.setHeader("Content-Type", "application/json");
|
|
626
|
+
const done = (o) => res.end(JSON.stringify(o));
|
|
627
|
+
try {
|
|
628
|
+
const { env = {} } = JSON.parse(body || "{}");
|
|
629
|
+
const cfg = { ...readEnvFile(), ...env };
|
|
630
|
+
const provider = cfg.AI_PROVIDER || "anthropic";
|
|
631
|
+
const key = { anthropic: cfg.ANTHROPIC_API_KEY, openai: cfg.OPENAI_API_KEY, gemini: cfg.GEMINI_API_KEY }[provider];
|
|
632
|
+
let url, headers, payload, label;
|
|
633
|
+
if (key) {
|
|
634
|
+
label = provider + " key";
|
|
635
|
+
if (provider === "anthropic") {
|
|
636
|
+
url = "https://api.anthropic.com/v1/messages";
|
|
637
|
+
headers = { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" };
|
|
638
|
+
payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
639
|
+
} else if (provider === "openai") {
|
|
640
|
+
url = "https://api.openai.com/v1/chat/completions";
|
|
641
|
+
headers = { authorization: "Bearer " + key, "content-type": "application/json" };
|
|
642
|
+
payload = { model: cfg.AI_MODEL || "gpt-4o-mini", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
643
|
+
} else {
|
|
644
|
+
url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${key}`;
|
|
645
|
+
headers = { "content-type": "application/json" };
|
|
646
|
+
payload = { contents: [{ parts: [{ text: "hi" }] }] };
|
|
647
|
+
}
|
|
648
|
+
} else if (cfg.VOLT_AI_TOKEN) {
|
|
649
|
+
label = "hosted gateway";
|
|
650
|
+
url = cfg.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai";
|
|
651
|
+
headers = { authorization: "Bearer " + cfg.VOLT_AI_TOKEN, "content-type": "application/json" };
|
|
652
|
+
payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
653
|
+
} else {
|
|
654
|
+
return done({ ok: false, error: "no AI key or VOLT_AI_TOKEN set" });
|
|
655
|
+
}
|
|
656
|
+
const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
|
|
657
|
+
if (r.ok) return done({ ok: true, detail: `${label} works` });
|
|
658
|
+
done({ ok: false, error: `${r.status}: ${(await r.text()).slice(0, 120)}` });
|
|
659
|
+
} catch (e) {
|
|
660
|
+
done({ ok: false, error: e.message });
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
541
665
|
if (req.method === "POST" && p === "/setup/apply") {
|
|
542
666
|
let body = "";
|
|
543
667
|
req.on("data", (c) => (body += c));
|
|
@@ -785,7 +909,7 @@ async function startLogs() {
|
|
|
785
909
|
const f = sources()[u.searchParams.get("source")];
|
|
786
910
|
if (!f) return json(res, { ok: false });
|
|
787
911
|
const parsed = tail(f, 5000).map((l) => parseLine(l));
|
|
788
|
-
return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.
|
|
912
|
+
return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.isBot).length, attacks: parsed.filter((x) => x && x.isAttack).length });
|
|
789
913
|
}
|
|
790
914
|
// add/remove a source ("add servers") — written to .volt/logs.json
|
|
791
915
|
if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
|
|
@@ -39,6 +39,12 @@ const state = signal({
|
|
|
39
39
|
const set = (patch) => state({ ...state(), ...patch });
|
|
40
40
|
const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
|
|
41
41
|
const status = signal("");
|
|
42
|
+
// per-test inline results (shown right next to each Test button)
|
|
43
|
+
const dbTest = signal("");
|
|
44
|
+
const smtpTest = signal("");
|
|
45
|
+
const aiTest = signal("");
|
|
46
|
+
const testResult = (m) => (m ? html`<span class="small ms-2 ${m.startsWith("✓") ? "text-success" : m.startsWith("✗") ? "text-danger" : "text-muted"}">${m}</span>` : "");
|
|
47
|
+
const envObj = () => Object.fromEntries(env().split("\n").filter((l) => /^[A-Za-z0-9_]+=/.test(l)).map((l) => { const i = l.indexOf("="); return [l.slice(0, i), l.slice(i + 1)]; }));
|
|
42
48
|
|
|
43
49
|
// selected add-ons, dependencies expanded, in display order
|
|
44
50
|
function effective(s) {
|
|
@@ -149,12 +155,30 @@ async function testDb() {
|
|
|
149
155
|
} else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
|
|
150
156
|
e.DATABASE_URL = s.dbUrl;
|
|
151
157
|
}
|
|
152
|
-
|
|
158
|
+
dbTest("Testing…");
|
|
153
159
|
try {
|
|
154
160
|
const r = await (await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: e }) })).json();
|
|
155
|
-
|
|
161
|
+
dbTest(r.ok ? `✓ Connected (${r.driver})` : `✗ ${r.error}`);
|
|
156
162
|
} catch {
|
|
157
|
-
|
|
163
|
+
dbTest("✗ network error");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function testSmtp() {
|
|
167
|
+
smtpTest("Testing…");
|
|
168
|
+
try {
|
|
169
|
+
const r = await (await fetch("/setup/test-smtp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
|
|
170
|
+
smtpTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
|
|
171
|
+
} catch {
|
|
172
|
+
smtpTest("✗ network error");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function testAi() {
|
|
176
|
+
aiTest("Testing…");
|
|
177
|
+
try {
|
|
178
|
+
const r = await (await fetch("/setup/test-ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
|
|
179
|
+
aiTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
|
|
180
|
+
} catch {
|
|
181
|
+
aiTest("✗ network error");
|
|
158
182
|
}
|
|
159
183
|
}
|
|
160
184
|
|
|
@@ -221,7 +245,7 @@ const dbSettings = () =>
|
|
|
221
245
|
: dbDriver() === "mysql" || dbDriver() === "postgres"
|
|
222
246
|
? field("DATABASE_URL", "dbUrl", dbDriver() + "://user:pass@host/db")
|
|
223
247
|
: null}
|
|
224
|
-
${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button
|
|
248
|
+
${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>${() => testResult(dbTest())}` : null)}`;
|
|
225
249
|
|
|
226
250
|
const mediaSettings = () =>
|
|
227
251
|
html`<div class="mb-2">
|
|
@@ -270,6 +294,7 @@ const aiSettings = () =>
|
|
|
270
294
|
${field("API key", "aiKey", "sk-…")}
|
|
271
295
|
<div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
|
|
272
296
|
${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
|
|
297
|
+
<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testAi}>Test AI</button>${() => testResult(aiTest())}</div>
|
|
273
298
|
</div>
|
|
274
299
|
</details>`;
|
|
275
300
|
|
|
@@ -323,6 +348,8 @@ async function genToken() {
|
|
|
323
348
|
const items = signal({ pages: [], posts: [] });
|
|
324
349
|
const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
|
|
325
350
|
let ed = null; // live RTEPro instance for the open editor
|
|
351
|
+
let themeCss = ""; // active theme's CSS, so the editor renders pages themed
|
|
352
|
+
fetch("/setup/theme-css").then((r) => r.text()).then((c) => { themeCss = c; }).catch(() => {});
|
|
326
353
|
const loadItems = async () => items(await (await fetch("/setup/content")).json());
|
|
327
354
|
// raw .md → { title, body, isHtml }; RTEPro takes markdown directly (setMarkdown),
|
|
328
355
|
// so no markdown library is needed.
|
|
@@ -333,7 +360,7 @@ function parseDoc(raw) {
|
|
|
333
360
|
return { title, body: fm ? raw.slice(fm[0].length) : raw, isHtml: /^format:\s*html\s*$/m.test(front) };
|
|
334
361
|
}
|
|
335
362
|
function mountEditor(doc) {
|
|
336
|
-
ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic" });
|
|
363
|
+
ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic", exportCSS: themeCss });
|
|
337
364
|
if (doc && doc.isHtml) ed.setHTML(doc.body || "");
|
|
338
365
|
else ed.setMarkdown((doc && doc.body) || "");
|
|
339
366
|
}
|
|
@@ -415,7 +442,7 @@ const configView = () =>
|
|
|
415
442
|
${field("SITE_NAME", "siteName", "My Site")}
|
|
416
443
|
${() => (hasContent() ? themePicker() : null)}
|
|
417
444
|
${() => (hasDb() ? dbSettings() : null)}
|
|
418
|
-
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}
|
|
445
|
+
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}<div class="mb-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testSmtp}>Test SMTP</button>${() => testResult(smtpTest())}</div>` : null)}
|
|
419
446
|
${() => (hasMedia() ? mediaSettings() : null)}
|
|
420
447
|
${aiSettings()}
|
|
421
448
|
${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
|
package/templates/docs/server.js
CHANGED
|
@@ -440,6 +440,32 @@ function startSetup() {
|
|
|
440
440
|
});
|
|
441
441
|
return;
|
|
442
442
|
}
|
|
443
|
+
// --- active theme's CSS, so the in-config editor renders pages themed ---
|
|
444
|
+
if (req.method === "GET" && p === "/setup/theme-css") {
|
|
445
|
+
res.setHeader("Content-Type", "text/css; charset=utf-8");
|
|
446
|
+
(async () => {
|
|
447
|
+
const theme = (readEnvFile().THEME || "").trim();
|
|
448
|
+
const load = async (rel) => {
|
|
449
|
+
try {
|
|
450
|
+
return (await imp(rel)).css || "";
|
|
451
|
+
} catch {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
let css = null;
|
|
456
|
+
if (theme) css = await load(path.join(".volt", "themes", theme, "index.js"));
|
|
457
|
+
if (css == null && fs.existsSync(path.join(__dirname, "pages", "_theme.js"))) css = await load(path.join("pages", "_theme.js"));
|
|
458
|
+
if (css == null && theme) {
|
|
459
|
+
try {
|
|
460
|
+
css = (await import(`volt-theme-${theme}`)).css || "";
|
|
461
|
+
} catch {
|
|
462
|
+
css = null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
res.end(css || "");
|
|
466
|
+
})();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
443
469
|
// --- content manager: list / read / write / delete pages + posts ---
|
|
444
470
|
if (req.method === "GET" && p === "/setup/content") {
|
|
445
471
|
const list = (type) => {
|
|
@@ -538,6 +564,104 @@ function startSetup() {
|
|
|
538
564
|
});
|
|
539
565
|
return;
|
|
540
566
|
}
|
|
567
|
+
// verify SMTP creds (form values merged over the saved .env) — auth check via
|
|
568
|
+
// nodemailer if available, else a TCP reachability check.
|
|
569
|
+
if (req.method === "POST" && p === "/setup/test-smtp") {
|
|
570
|
+
let body = "";
|
|
571
|
+
req.on("data", (c) => (body += c));
|
|
572
|
+
req.on("end", async () => {
|
|
573
|
+
res.setHeader("Content-Type", "application/json");
|
|
574
|
+
const done = (o) => res.end(JSON.stringify(o));
|
|
575
|
+
try {
|
|
576
|
+
const { env = {} } = JSON.parse(body || "{}");
|
|
577
|
+
const cfg = { ...readEnvFile(), ...env };
|
|
578
|
+
const url = cfg.SMTP_URL;
|
|
579
|
+
const host = cfg.SMTP_HOST;
|
|
580
|
+
if (!url && !host) return done({ ok: false, error: "no SMTP config (set SMTP_URL or SMTP_HOST)" });
|
|
581
|
+
let nodemailer;
|
|
582
|
+
try {
|
|
583
|
+
nodemailer = (await import("nodemailer")).default;
|
|
584
|
+
} catch {
|
|
585
|
+
/* not installed */
|
|
586
|
+
}
|
|
587
|
+
if (nodemailer) {
|
|
588
|
+
const transport = url
|
|
589
|
+
? nodemailer.createTransport(url)
|
|
590
|
+
: nodemailer.createTransport({ host, port: Number(cfg.SMTP_PORT) || 587, secure: /^(1|true|yes|on)$/i.test(cfg.SMTP_SECURE || "") || Number(cfg.SMTP_PORT) === 465, auth: cfg.SMTP_USER ? { user: cfg.SMTP_USER, pass: cfg.SMTP_PASS } : undefined });
|
|
591
|
+
await transport.verify();
|
|
592
|
+
return done({ ok: true, detail: "connection + auth OK" });
|
|
593
|
+
}
|
|
594
|
+
const net = await import("node:net");
|
|
595
|
+
let h = host;
|
|
596
|
+
let prt = Number(cfg.SMTP_PORT) || 587;
|
|
597
|
+
if (url) {
|
|
598
|
+
const u = new URL(url.replace(/^smtps?:\/\//, "http://"));
|
|
599
|
+
h = u.hostname;
|
|
600
|
+
prt = Number(u.port) || (url.startsWith("smtps") ? 465 : 587);
|
|
601
|
+
}
|
|
602
|
+
await new Promise((resolve, reject) => {
|
|
603
|
+
const s = net.connect(prt, h, () => {
|
|
604
|
+
s.end();
|
|
605
|
+
resolve();
|
|
606
|
+
});
|
|
607
|
+
s.setTimeout(5000, () => {
|
|
608
|
+
s.destroy();
|
|
609
|
+
reject(new Error("timeout"));
|
|
610
|
+
});
|
|
611
|
+
s.on("error", reject);
|
|
612
|
+
});
|
|
613
|
+
done({ ok: true, detail: `${h}:${prt} reachable — enable the mailer add-on for a full auth test` });
|
|
614
|
+
} catch (e) {
|
|
615
|
+
done({ ok: false, error: e.message });
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// verify the AI provider key (or gateway token) with a 1-token live call.
|
|
621
|
+
if (req.method === "POST" && p === "/setup/test-ai") {
|
|
622
|
+
let body = "";
|
|
623
|
+
req.on("data", (c) => (body += c));
|
|
624
|
+
req.on("end", async () => {
|
|
625
|
+
res.setHeader("Content-Type", "application/json");
|
|
626
|
+
const done = (o) => res.end(JSON.stringify(o));
|
|
627
|
+
try {
|
|
628
|
+
const { env = {} } = JSON.parse(body || "{}");
|
|
629
|
+
const cfg = { ...readEnvFile(), ...env };
|
|
630
|
+
const provider = cfg.AI_PROVIDER || "anthropic";
|
|
631
|
+
const key = { anthropic: cfg.ANTHROPIC_API_KEY, openai: cfg.OPENAI_API_KEY, gemini: cfg.GEMINI_API_KEY }[provider];
|
|
632
|
+
let url, headers, payload, label;
|
|
633
|
+
if (key) {
|
|
634
|
+
label = provider + " key";
|
|
635
|
+
if (provider === "anthropic") {
|
|
636
|
+
url = "https://api.anthropic.com/v1/messages";
|
|
637
|
+
headers = { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" };
|
|
638
|
+
payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
639
|
+
} else if (provider === "openai") {
|
|
640
|
+
url = "https://api.openai.com/v1/chat/completions";
|
|
641
|
+
headers = { authorization: "Bearer " + key, "content-type": "application/json" };
|
|
642
|
+
payload = { model: cfg.AI_MODEL || "gpt-4o-mini", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
643
|
+
} else {
|
|
644
|
+
url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${key}`;
|
|
645
|
+
headers = { "content-type": "application/json" };
|
|
646
|
+
payload = { contents: [{ parts: [{ text: "hi" }] }] };
|
|
647
|
+
}
|
|
648
|
+
} else if (cfg.VOLT_AI_TOKEN) {
|
|
649
|
+
label = "hosted gateway";
|
|
650
|
+
url = cfg.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai";
|
|
651
|
+
headers = { authorization: "Bearer " + cfg.VOLT_AI_TOKEN, "content-type": "application/json" };
|
|
652
|
+
payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
653
|
+
} else {
|
|
654
|
+
return done({ ok: false, error: "no AI key or VOLT_AI_TOKEN set" });
|
|
655
|
+
}
|
|
656
|
+
const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
|
|
657
|
+
if (r.ok) return done({ ok: true, detail: `${label} works` });
|
|
658
|
+
done({ ok: false, error: `${r.status}: ${(await r.text()).slice(0, 120)}` });
|
|
659
|
+
} catch (e) {
|
|
660
|
+
done({ ok: false, error: e.message });
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
541
665
|
if (req.method === "POST" && p === "/setup/apply") {
|
|
542
666
|
let body = "";
|
|
543
667
|
req.on("data", (c) => (body += c));
|
|
@@ -785,7 +909,7 @@ async function startLogs() {
|
|
|
785
909
|
const f = sources()[u.searchParams.get("source")];
|
|
786
910
|
if (!f) return json(res, { ok: false });
|
|
787
911
|
const parsed = tail(f, 5000).map((l) => parseLine(l));
|
|
788
|
-
return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.
|
|
912
|
+
return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.isBot).length, attacks: parsed.filter((x) => x && x.isAttack).length });
|
|
789
913
|
}
|
|
790
914
|
// add/remove a source ("add servers") — written to .volt/logs.json
|
|
791
915
|
if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
|
|
@@ -39,6 +39,12 @@ const state = signal({
|
|
|
39
39
|
const set = (patch) => state({ ...state(), ...patch });
|
|
40
40
|
const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
|
|
41
41
|
const status = signal("");
|
|
42
|
+
// per-test inline results (shown right next to each Test button)
|
|
43
|
+
const dbTest = signal("");
|
|
44
|
+
const smtpTest = signal("");
|
|
45
|
+
const aiTest = signal("");
|
|
46
|
+
const testResult = (m) => (m ? html`<span class="small ms-2 ${m.startsWith("✓") ? "text-success" : m.startsWith("✗") ? "text-danger" : "text-muted"}">${m}</span>` : "");
|
|
47
|
+
const envObj = () => Object.fromEntries(env().split("\n").filter((l) => /^[A-Za-z0-9_]+=/.test(l)).map((l) => { const i = l.indexOf("="); return [l.slice(0, i), l.slice(i + 1)]; }));
|
|
42
48
|
|
|
43
49
|
// selected add-ons, dependencies expanded, in display order
|
|
44
50
|
function effective(s) {
|
|
@@ -149,12 +155,30 @@ async function testDb() {
|
|
|
149
155
|
} else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
|
|
150
156
|
e.DATABASE_URL = s.dbUrl;
|
|
151
157
|
}
|
|
152
|
-
|
|
158
|
+
dbTest("Testing…");
|
|
153
159
|
try {
|
|
154
160
|
const r = await (await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: e }) })).json();
|
|
155
|
-
|
|
161
|
+
dbTest(r.ok ? `✓ Connected (${r.driver})` : `✗ ${r.error}`);
|
|
156
162
|
} catch {
|
|
157
|
-
|
|
163
|
+
dbTest("✗ network error");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function testSmtp() {
|
|
167
|
+
smtpTest("Testing…");
|
|
168
|
+
try {
|
|
169
|
+
const r = await (await fetch("/setup/test-smtp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
|
|
170
|
+
smtpTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
|
|
171
|
+
} catch {
|
|
172
|
+
smtpTest("✗ network error");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function testAi() {
|
|
176
|
+
aiTest("Testing…");
|
|
177
|
+
try {
|
|
178
|
+
const r = await (await fetch("/setup/test-ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
|
|
179
|
+
aiTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
|
|
180
|
+
} catch {
|
|
181
|
+
aiTest("✗ network error");
|
|
158
182
|
}
|
|
159
183
|
}
|
|
160
184
|
|
|
@@ -221,7 +245,7 @@ const dbSettings = () =>
|
|
|
221
245
|
: dbDriver() === "mysql" || dbDriver() === "postgres"
|
|
222
246
|
? field("DATABASE_URL", "dbUrl", dbDriver() + "://user:pass@host/db")
|
|
223
247
|
: null}
|
|
224
|
-
${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button
|
|
248
|
+
${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>${() => testResult(dbTest())}` : null)}`;
|
|
225
249
|
|
|
226
250
|
const mediaSettings = () =>
|
|
227
251
|
html`<div class="mb-2">
|
|
@@ -270,6 +294,7 @@ const aiSettings = () =>
|
|
|
270
294
|
${field("API key", "aiKey", "sk-…")}
|
|
271
295
|
<div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
|
|
272
296
|
${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
|
|
297
|
+
<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testAi}>Test AI</button>${() => testResult(aiTest())}</div>
|
|
273
298
|
</div>
|
|
274
299
|
</details>`;
|
|
275
300
|
|
|
@@ -323,6 +348,8 @@ async function genToken() {
|
|
|
323
348
|
const items = signal({ pages: [], posts: [] });
|
|
324
349
|
const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
|
|
325
350
|
let ed = null; // live RTEPro instance for the open editor
|
|
351
|
+
let themeCss = ""; // active theme's CSS, so the editor renders pages themed
|
|
352
|
+
fetch("/setup/theme-css").then((r) => r.text()).then((c) => { themeCss = c; }).catch(() => {});
|
|
326
353
|
const loadItems = async () => items(await (await fetch("/setup/content")).json());
|
|
327
354
|
// raw .md → { title, body, isHtml }; RTEPro takes markdown directly (setMarkdown),
|
|
328
355
|
// so no markdown library is needed.
|
|
@@ -333,7 +360,7 @@ function parseDoc(raw) {
|
|
|
333
360
|
return { title, body: fm ? raw.slice(fm[0].length) : raw, isHtml: /^format:\s*html\s*$/m.test(front) };
|
|
334
361
|
}
|
|
335
362
|
function mountEditor(doc) {
|
|
336
|
-
ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic" });
|
|
363
|
+
ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic", exportCSS: themeCss });
|
|
337
364
|
if (doc && doc.isHtml) ed.setHTML(doc.body || "");
|
|
338
365
|
else ed.setMarkdown((doc && doc.body) || "");
|
|
339
366
|
}
|
|
@@ -415,7 +442,7 @@ const configView = () =>
|
|
|
415
442
|
${field("SITE_NAME", "siteName", "My Site")}
|
|
416
443
|
${() => (hasContent() ? themePicker() : null)}
|
|
417
444
|
${() => (hasDb() ? dbSettings() : null)}
|
|
418
|
-
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}
|
|
445
|
+
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}<div class="mb-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testSmtp}>Test SMTP</button>${() => testResult(smtpTest())}</div>` : null)}
|
|
419
446
|
${() => (hasMedia() ? mediaSettings() : null)}
|
|
420
447
|
${aiSettings()}
|
|
421
448
|
${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
|
|
@@ -466,6 +466,32 @@ function startSetup() {
|
|
|
466
466
|
});
|
|
467
467
|
return;
|
|
468
468
|
}
|
|
469
|
+
// --- active theme's CSS, so the in-config editor renders pages themed ---
|
|
470
|
+
if (req.method === "GET" && p === "/setup/theme-css") {
|
|
471
|
+
res.setHeader("Content-Type", "text/css; charset=utf-8");
|
|
472
|
+
(async () => {
|
|
473
|
+
const theme = (readEnvFile().THEME || "").trim();
|
|
474
|
+
const load = async (rel) => {
|
|
475
|
+
try {
|
|
476
|
+
return (await imp(rel)).css || "";
|
|
477
|
+
} catch {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
let css = null;
|
|
482
|
+
if (theme) css = await load(path.join(".volt", "themes", theme, "index.js"));
|
|
483
|
+
if (css == null && fs.existsSync(path.join(__dirname, "pages", "_theme.js"))) css = await load(path.join("pages", "_theme.js"));
|
|
484
|
+
if (css == null && theme) {
|
|
485
|
+
try {
|
|
486
|
+
css = (await import(`volt-theme-${theme}`)).css || "";
|
|
487
|
+
} catch {
|
|
488
|
+
css = null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
res.end(css || "");
|
|
492
|
+
})();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
469
495
|
// --- content manager: list / read / write / delete pages + posts ---
|
|
470
496
|
if (req.method === "GET" && p === "/setup/content") {
|
|
471
497
|
const list = (type) => {
|
|
@@ -564,6 +590,104 @@ function startSetup() {
|
|
|
564
590
|
});
|
|
565
591
|
return;
|
|
566
592
|
}
|
|
593
|
+
// verify SMTP creds (form values merged over the saved .env) — auth check via
|
|
594
|
+
// nodemailer if available, else a TCP reachability check.
|
|
595
|
+
if (req.method === "POST" && p === "/setup/test-smtp") {
|
|
596
|
+
let body = "";
|
|
597
|
+
req.on("data", (c) => (body += c));
|
|
598
|
+
req.on("end", async () => {
|
|
599
|
+
res.setHeader("Content-Type", "application/json");
|
|
600
|
+
const done = (o) => res.end(JSON.stringify(o));
|
|
601
|
+
try {
|
|
602
|
+
const { env = {} } = JSON.parse(body || "{}");
|
|
603
|
+
const cfg = { ...readEnvFile(), ...env };
|
|
604
|
+
const url = cfg.SMTP_URL;
|
|
605
|
+
const host = cfg.SMTP_HOST;
|
|
606
|
+
if (!url && !host) return done({ ok: false, error: "no SMTP config (set SMTP_URL or SMTP_HOST)" });
|
|
607
|
+
let nodemailer;
|
|
608
|
+
try {
|
|
609
|
+
nodemailer = (await import("nodemailer")).default;
|
|
610
|
+
} catch {
|
|
611
|
+
/* not installed */
|
|
612
|
+
}
|
|
613
|
+
if (nodemailer) {
|
|
614
|
+
const transport = url
|
|
615
|
+
? nodemailer.createTransport(url)
|
|
616
|
+
: nodemailer.createTransport({ host, port: Number(cfg.SMTP_PORT) || 587, secure: /^(1|true|yes|on)$/i.test(cfg.SMTP_SECURE || "") || Number(cfg.SMTP_PORT) === 465, auth: cfg.SMTP_USER ? { user: cfg.SMTP_USER, pass: cfg.SMTP_PASS } : undefined });
|
|
617
|
+
await transport.verify();
|
|
618
|
+
return done({ ok: true, detail: "connection + auth OK" });
|
|
619
|
+
}
|
|
620
|
+
const net = await import("node:net");
|
|
621
|
+
let h = host;
|
|
622
|
+
let prt = Number(cfg.SMTP_PORT) || 587;
|
|
623
|
+
if (url) {
|
|
624
|
+
const u = new URL(url.replace(/^smtps?:\/\//, "http://"));
|
|
625
|
+
h = u.hostname;
|
|
626
|
+
prt = Number(u.port) || (url.startsWith("smtps") ? 465 : 587);
|
|
627
|
+
}
|
|
628
|
+
await new Promise((resolve, reject) => {
|
|
629
|
+
const s = net.connect(prt, h, () => {
|
|
630
|
+
s.end();
|
|
631
|
+
resolve();
|
|
632
|
+
});
|
|
633
|
+
s.setTimeout(5000, () => {
|
|
634
|
+
s.destroy();
|
|
635
|
+
reject(new Error("timeout"));
|
|
636
|
+
});
|
|
637
|
+
s.on("error", reject);
|
|
638
|
+
});
|
|
639
|
+
done({ ok: true, detail: `${h}:${prt} reachable — enable the mailer add-on for a full auth test` });
|
|
640
|
+
} catch (e) {
|
|
641
|
+
done({ ok: false, error: e.message });
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// verify the AI provider key (or gateway token) with a 1-token live call.
|
|
647
|
+
if (req.method === "POST" && p === "/setup/test-ai") {
|
|
648
|
+
let body = "";
|
|
649
|
+
req.on("data", (c) => (body += c));
|
|
650
|
+
req.on("end", async () => {
|
|
651
|
+
res.setHeader("Content-Type", "application/json");
|
|
652
|
+
const done = (o) => res.end(JSON.stringify(o));
|
|
653
|
+
try {
|
|
654
|
+
const { env = {} } = JSON.parse(body || "{}");
|
|
655
|
+
const cfg = { ...readEnvFile(), ...env };
|
|
656
|
+
const provider = cfg.AI_PROVIDER || "anthropic";
|
|
657
|
+
const key = { anthropic: cfg.ANTHROPIC_API_KEY, openai: cfg.OPENAI_API_KEY, gemini: cfg.GEMINI_API_KEY }[provider];
|
|
658
|
+
let url, headers, payload, label;
|
|
659
|
+
if (key) {
|
|
660
|
+
label = provider + " key";
|
|
661
|
+
if (provider === "anthropic") {
|
|
662
|
+
url = "https://api.anthropic.com/v1/messages";
|
|
663
|
+
headers = { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" };
|
|
664
|
+
payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
665
|
+
} else if (provider === "openai") {
|
|
666
|
+
url = "https://api.openai.com/v1/chat/completions";
|
|
667
|
+
headers = { authorization: "Bearer " + key, "content-type": "application/json" };
|
|
668
|
+
payload = { model: cfg.AI_MODEL || "gpt-4o-mini", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
669
|
+
} else {
|
|
670
|
+
url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${key}`;
|
|
671
|
+
headers = { "content-type": "application/json" };
|
|
672
|
+
payload = { contents: [{ parts: [{ text: "hi" }] }] };
|
|
673
|
+
}
|
|
674
|
+
} else if (cfg.VOLT_AI_TOKEN) {
|
|
675
|
+
label = "hosted gateway";
|
|
676
|
+
url = cfg.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai";
|
|
677
|
+
headers = { authorization: "Bearer " + cfg.VOLT_AI_TOKEN, "content-type": "application/json" };
|
|
678
|
+
payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
679
|
+
} else {
|
|
680
|
+
return done({ ok: false, error: "no AI key or VOLT_AI_TOKEN set" });
|
|
681
|
+
}
|
|
682
|
+
const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
|
|
683
|
+
if (r.ok) return done({ ok: true, detail: `${label} works` });
|
|
684
|
+
done({ ok: false, error: `${r.status}: ${(await r.text()).slice(0, 120)}` });
|
|
685
|
+
} catch (e) {
|
|
686
|
+
done({ ok: false, error: e.message });
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
567
691
|
if (req.method === "POST" && p === "/setup/apply") {
|
|
568
692
|
let body = "";
|
|
569
693
|
req.on("data", (c) => (body += c));
|
|
@@ -811,7 +935,7 @@ async function startLogs() {
|
|
|
811
935
|
const f = sources()[u.searchParams.get("source")];
|
|
812
936
|
if (!f) return json(res, { ok: false });
|
|
813
937
|
const parsed = tail(f, 5000).map((l) => parseLine(l));
|
|
814
|
-
return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.
|
|
938
|
+
return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.isBot).length, attacks: parsed.filter((x) => x && x.isAttack).length });
|
|
815
939
|
}
|
|
816
940
|
// add/remove a source ("add servers") — written to .volt/logs.json
|
|
817
941
|
if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
|
|
@@ -39,6 +39,12 @@ const state = signal({
|
|
|
39
39
|
const set = (patch) => state({ ...state(), ...patch });
|
|
40
40
|
const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
|
|
41
41
|
const status = signal("");
|
|
42
|
+
// per-test inline results (shown right next to each Test button)
|
|
43
|
+
const dbTest = signal("");
|
|
44
|
+
const smtpTest = signal("");
|
|
45
|
+
const aiTest = signal("");
|
|
46
|
+
const testResult = (m) => (m ? html`<span class="small ms-2 ${m.startsWith("✓") ? "text-success" : m.startsWith("✗") ? "text-danger" : "text-muted"}">${m}</span>` : "");
|
|
47
|
+
const envObj = () => Object.fromEntries(env().split("\n").filter((l) => /^[A-Za-z0-9_]+=/.test(l)).map((l) => { const i = l.indexOf("="); return [l.slice(0, i), l.slice(i + 1)]; }));
|
|
42
48
|
|
|
43
49
|
// selected add-ons, dependencies expanded, in display order
|
|
44
50
|
function effective(s) {
|
|
@@ -149,12 +155,30 @@ async function testDb() {
|
|
|
149
155
|
} else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
|
|
150
156
|
e.DATABASE_URL = s.dbUrl;
|
|
151
157
|
}
|
|
152
|
-
|
|
158
|
+
dbTest("Testing…");
|
|
153
159
|
try {
|
|
154
160
|
const r = await (await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: e }) })).json();
|
|
155
|
-
|
|
161
|
+
dbTest(r.ok ? `✓ Connected (${r.driver})` : `✗ ${r.error}`);
|
|
156
162
|
} catch {
|
|
157
|
-
|
|
163
|
+
dbTest("✗ network error");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function testSmtp() {
|
|
167
|
+
smtpTest("Testing…");
|
|
168
|
+
try {
|
|
169
|
+
const r = await (await fetch("/setup/test-smtp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
|
|
170
|
+
smtpTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
|
|
171
|
+
} catch {
|
|
172
|
+
smtpTest("✗ network error");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function testAi() {
|
|
176
|
+
aiTest("Testing…");
|
|
177
|
+
try {
|
|
178
|
+
const r = await (await fetch("/setup/test-ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
|
|
179
|
+
aiTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
|
|
180
|
+
} catch {
|
|
181
|
+
aiTest("✗ network error");
|
|
158
182
|
}
|
|
159
183
|
}
|
|
160
184
|
|
|
@@ -221,7 +245,7 @@ const dbSettings = () =>
|
|
|
221
245
|
: dbDriver() === "mysql" || dbDriver() === "postgres"
|
|
222
246
|
? field("DATABASE_URL", "dbUrl", dbDriver() + "://user:pass@host/db")
|
|
223
247
|
: null}
|
|
224
|
-
${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button
|
|
248
|
+
${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>${() => testResult(dbTest())}` : null)}`;
|
|
225
249
|
|
|
226
250
|
const mediaSettings = () =>
|
|
227
251
|
html`<div class="mb-2">
|
|
@@ -270,6 +294,7 @@ const aiSettings = () =>
|
|
|
270
294
|
${field("API key", "aiKey", "sk-…")}
|
|
271
295
|
<div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
|
|
272
296
|
${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
|
|
297
|
+
<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testAi}>Test AI</button>${() => testResult(aiTest())}</div>
|
|
273
298
|
</div>
|
|
274
299
|
</details>`;
|
|
275
300
|
|
|
@@ -323,6 +348,8 @@ async function genToken() {
|
|
|
323
348
|
const items = signal({ pages: [], posts: [] });
|
|
324
349
|
const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
|
|
325
350
|
let ed = null; // live RTEPro instance for the open editor
|
|
351
|
+
let themeCss = ""; // active theme's CSS, so the editor renders pages themed
|
|
352
|
+
fetch("/setup/theme-css").then((r) => r.text()).then((c) => { themeCss = c; }).catch(() => {});
|
|
326
353
|
const loadItems = async () => items(await (await fetch("/setup/content")).json());
|
|
327
354
|
// raw .md → { title, body, isHtml }; RTEPro takes markdown directly (setMarkdown),
|
|
328
355
|
// so no markdown library is needed.
|
|
@@ -333,7 +360,7 @@ function parseDoc(raw) {
|
|
|
333
360
|
return { title, body: fm ? raw.slice(fm[0].length) : raw, isHtml: /^format:\s*html\s*$/m.test(front) };
|
|
334
361
|
}
|
|
335
362
|
function mountEditor(doc) {
|
|
336
|
-
ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic" });
|
|
363
|
+
ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic", exportCSS: themeCss });
|
|
337
364
|
if (doc && doc.isHtml) ed.setHTML(doc.body || "");
|
|
338
365
|
else ed.setMarkdown((doc && doc.body) || "");
|
|
339
366
|
}
|
|
@@ -415,7 +442,7 @@ const configView = () =>
|
|
|
415
442
|
${field("SITE_NAME", "siteName", "My Site")}
|
|
416
443
|
${() => (hasContent() ? themePicker() : null)}
|
|
417
444
|
${() => (hasDb() ? dbSettings() : null)}
|
|
418
|
-
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}
|
|
445
|
+
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}<div class="mb-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testSmtp}>Test SMTP</button>${() => testResult(smtpTest())}</div>` : null)}
|
|
419
446
|
${() => (hasMedia() ? mediaSettings() : null)}
|
|
420
447
|
${aiSettings()}
|
|
421
448
|
${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
|