create-volt 0.28.0 → 0.30.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 +26 -0
- package/index.js +2 -10
- package/lib/date-port.js +21 -0
- package/lib/import-wp-db.js +77 -0
- package/lib/import-wxr.js +152 -0
- package/package.json +2 -1
- package/templates/default/public/volt.js +2 -1
- 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/setup/setup.js +18 -10
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ 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.30.0] - 2026-06-29
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **Setup wizard: typing in a settings field no longer drops focus.** The
|
|
11
|
+
conditional sections (DB-driver fields, S3 fields) read the whole `state()`
|
|
12
|
+
signal, so every keystroke re-rendered the section and recreated the `<input>`,
|
|
13
|
+
losing focus. They now key on memoized primitive derivations (`dbDriver`,
|
|
14
|
+
`mediaDriver`, `hasDb`/`hasMailer`/`hasMedia`) so a section re-renders only when
|
|
15
|
+
its discriminant changes.
|
|
16
|
+
- **`volt.js`: reactive `value` bindings skip redundant DOM writes.** `setAttr`
|
|
17
|
+
no longer reassigns `node.value` when it already equals the new value — those
|
|
18
|
+
writes were resetting the caret to the end while typing. Benefits every Volt
|
|
19
|
+
app, not just the wizard.
|
|
20
|
+
|
|
21
|
+
## [0.29.0] - 2026-06-29
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **Date-derived default port is now valid year-round.** For Oct–Dec the
|
|
25
|
+
readable YY+M+DD form is 6 digits (e.g. `261010`) and exceeds the max TCP port
|
|
26
|
+
65535, so create-volt previously refused to scaffold without `--port`. It now
|
|
27
|
+
falls back to a deterministic in-range port `1024 + (YYYYMMDD % 64512)` when the
|
|
28
|
+
readable form overflows; Jan–Sep keep the readable port unchanged. Extracted to
|
|
29
|
+
`lib/date-port.js` and unit-tested.
|
|
30
|
+
|
|
7
31
|
## [0.28.0] - 2026-06-29
|
|
8
32
|
|
|
9
33
|
### Fixed
|
|
@@ -389,6 +413,8 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
389
413
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
390
414
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
391
415
|
|
|
416
|
+
[0.30.0]: https://github.com/MIR-2025/volt/releases/tag/v0.30.0
|
|
417
|
+
[0.29.0]: https://github.com/MIR-2025/volt/releases/tag/v0.29.0
|
|
392
418
|
[0.28.0]: https://github.com/MIR-2025/volt/releases/tag/v0.28.0
|
|
393
419
|
[0.27.0]: https://github.com/MIR-2025/volt/releases/tag/v0.27.0
|
|
394
420
|
[0.26.0]: https://github.com/MIR-2025/volt/releases/tag/v0.26.0
|
package/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import path from "node:path";
|
|
|
13
13
|
import { spawnSync } from "node:child_process";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { createRequire } from "node:module";
|
|
16
|
+
import { datePort } from "./lib/date-port.js";
|
|
16
17
|
|
|
17
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
const require = createRequire(import.meta.url);
|
|
@@ -364,12 +365,6 @@ if (positionals[0] === "studio") {
|
|
|
364
365
|
// Resolve the dev port: --port wins, else derive it from today's date as
|
|
365
366
|
// two-digit-year + month (no leading zero) + two-digit-day (house convention),
|
|
366
367
|
// so apps scaffolded on different days don't collide on the same port.
|
|
367
|
-
function datePort(d) {
|
|
368
|
-
const yy = String(d.getFullYear()).slice(-2);
|
|
369
|
-
const mm = String(d.getMonth() + 1);
|
|
370
|
-
const dd = String(d.getDate()).padStart(2, "0");
|
|
371
|
-
return Number(`${yy}${mm}${dd}`);
|
|
372
|
-
}
|
|
373
368
|
let port;
|
|
374
369
|
if (portArg != null) {
|
|
375
370
|
port = Number(portArg);
|
|
@@ -377,10 +372,7 @@ if (portArg != null) {
|
|
|
377
372
|
die(`Invalid --port "${portArg}" — use a whole number between 1 and 65535.`);
|
|
378
373
|
}
|
|
379
374
|
} else {
|
|
380
|
-
port = datePort(new Date());
|
|
381
|
-
if (port > 65535) {
|
|
382
|
-
die(`The date-derived port (${port}) is above 65535 — pass --port <1-65535>.`);
|
|
383
|
-
}
|
|
375
|
+
port = datePort(new Date()); // always 1–65535 (see lib/date-port.js)
|
|
384
376
|
}
|
|
385
377
|
|
|
386
378
|
const rawName = positionals[0];
|
package/lib/date-port.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// date-port.js — derive an app's dev port from its start date.
|
|
2
|
+
//
|
|
3
|
+
// Convention: two-digit year + month (no leading zero) + two-digit day,
|
|
4
|
+
// concatenated → e.g. 2026-06-28 → 26628. Readable, and unique per date
|
|
5
|
+
// (year and day are fixed-width, so the month's 1-vs-2-digit width is
|
|
6
|
+
// unambiguous).
|
|
7
|
+
//
|
|
8
|
+
// Caveat: for Oct–Dec the month is two digits, so the value is 6 digits
|
|
9
|
+
// (e.g. 2026-10-10 → 261010) — above the max TCP port (65535). When the
|
|
10
|
+
// readable form overflows, fall back to a deterministic in-range port
|
|
11
|
+
// derived from the full date: 1024 + (YYYYMMDD % 64512) ∈ [1024, 65535].
|
|
12
|
+
// Always valid, always the same for a given date.
|
|
13
|
+
export function datePort(d) {
|
|
14
|
+
const yy = String(d.getFullYear()).slice(-2);
|
|
15
|
+
const mm = String(d.getMonth() + 1); // no leading zero
|
|
16
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
17
|
+
const readable = Number(`${yy}${mm}${dd}`);
|
|
18
|
+
if (readable <= 65535) return readable;
|
|
19
|
+
const ymd = d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
|
|
20
|
+
return 1024 + (ymd % 64512);
|
|
21
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// import-wp-db.js — import WordPress content by reading its MySQL/MariaDB
|
|
2
|
+
// database directly. The niche path for when the REST API is disabled but you
|
|
3
|
+
// have DB access (e.g. on the server or over an SSH tunnel). Reuses the WXR
|
|
4
|
+
// converter (transform → markdown). mysql2 is loaded lazily so create-volt stays
|
|
5
|
+
// dependency-free; the DB connection is injectable for testing.
|
|
6
|
+
import { transform } from "./import-wxr.js";
|
|
7
|
+
|
|
8
|
+
// Table prefix goes into table names (which can't be parameterized), so it must
|
|
9
|
+
// be a safe SQL identifier — this is the one injection vector. Validate hard.
|
|
10
|
+
export function validatePrefix(p) {
|
|
11
|
+
if (!/^[A-Za-z0-9_]+$/.test(p)) throw new Error(`Invalid --prefix "${p}" — only letters, numbers, and underscore are allowed.`);
|
|
12
|
+
return p;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Shape DB rows (posts + flat term rows) into the common item shape.
|
|
16
|
+
export function rowsToItems(posts, terms) {
|
|
17
|
+
const byPost = new Map();
|
|
18
|
+
for (const t of terms || []) {
|
|
19
|
+
const m = byPost.get(t.object_id) || { categories: [], tags: [] };
|
|
20
|
+
(t.taxonomy === "post_tag" ? m.tags : m.categories).push(t.name);
|
|
21
|
+
byPost.set(t.object_id, m);
|
|
22
|
+
}
|
|
23
|
+
return (posts || []).map((p) => {
|
|
24
|
+
const t = byPost.get(p.ID) || { categories: [], tags: [] };
|
|
25
|
+
return {
|
|
26
|
+
type: p.post_type || "post",
|
|
27
|
+
title: p.post_title || p.post_name || "Untitled",
|
|
28
|
+
slug: p.post_name || "",
|
|
29
|
+
status: p.post_status || "publish",
|
|
30
|
+
date: p.post_date ? String(p.post_date).replace("T", " ").replace(/\.\d+$/, "") : "",
|
|
31
|
+
content: p.post_content || "",
|
|
32
|
+
categories: t.categories,
|
|
33
|
+
tags: t.tags,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function defaultConnect(dbUrl) {
|
|
39
|
+
let mysql;
|
|
40
|
+
try {
|
|
41
|
+
mysql = await import("mysql2/promise");
|
|
42
|
+
} catch {
|
|
43
|
+
throw new Error("mysql2 is required for import-wp-db — install it: npm i mysql2");
|
|
44
|
+
}
|
|
45
|
+
const c = await mysql.createConnection(dbUrl);
|
|
46
|
+
return {
|
|
47
|
+
query: async (sql) => {
|
|
48
|
+
const [rows] = await c.query(sql);
|
|
49
|
+
return rows;
|
|
50
|
+
},
|
|
51
|
+
end: () => c.end(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Read posts + pages (and their terms) from a WordPress database.
|
|
56
|
+
export async function fetchWPFromDB(dbUrl, { prefix = "wp_", drafts = false, connect = defaultConnect } = {}) {
|
|
57
|
+
validatePrefix(prefix);
|
|
58
|
+
const statuses = (drafts ? ["publish", "draft", "pending", "private", "future"] : ["publish"]).map((s) => `'${s}'`).join(","); // fixed allowlist, safe
|
|
59
|
+
const conn = await connect(dbUrl);
|
|
60
|
+
try {
|
|
61
|
+
const posts = await conn.query(
|
|
62
|
+
`SELECT ID, post_title, post_name, post_content, post_status, post_type, post_date FROM \`${prefix}posts\` WHERE post_type IN ('post','page') AND post_status IN (${statuses})`,
|
|
63
|
+
);
|
|
64
|
+
const terms = await conn.query(
|
|
65
|
+
`SELECT tr.object_id AS object_id, tt.taxonomy AS taxonomy, t.name AS name FROM \`${prefix}term_relationships\` tr` +
|
|
66
|
+
` JOIN \`${prefix}term_taxonomy\` tt ON tr.term_taxonomy_id = tt.term_taxonomy_id` +
|
|
67
|
+
` JOIN \`${prefix}terms\` t ON tt.term_id = t.term_id WHERE tt.taxonomy IN ('category','post_tag')`,
|
|
68
|
+
);
|
|
69
|
+
return rowsToItems(posts, terms);
|
|
70
|
+
} finally {
|
|
71
|
+
await conn.end?.();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function runImportFromDB(dbUrl, opts = {}) {
|
|
76
|
+
return transform(await fetchWPFromDB(dbUrl, opts), opts);
|
|
77
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// import-wxr.js — import a WordPress export (WXR XML) into Volt markdown pages.
|
|
2
|
+
// Zero-dep: WXR is a consistent, machine-generated format, so a focused parser
|
|
3
|
+
// is more honest than pulling a general XML library. Pure functions (parseWXR,
|
|
4
|
+
// toMarkdown, runImport) so they're unit-testable; file I/O lives in the CLI.
|
|
5
|
+
|
|
6
|
+
const unCdata = (s) => s.replace(/^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/, "$1");
|
|
7
|
+
|
|
8
|
+
const decode = (s) =>
|
|
9
|
+
String(s)
|
|
10
|
+
.replace(/</g, "<")
|
|
11
|
+
.replace(/>/g, ">")
|
|
12
|
+
.replace(/"/g, '"')
|
|
13
|
+
.replace(/�?39;|'|'/g, "'")
|
|
14
|
+
.replace(/’/g, "’")
|
|
15
|
+
.replace(/‘/g, "‘")
|
|
16
|
+
.replace(/“/g, "“")
|
|
17
|
+
.replace(/”/g, "”")
|
|
18
|
+
.replace(/–/g, "–")
|
|
19
|
+
.replace(/—/g, "—")
|
|
20
|
+
.replace(/ /g, " ")
|
|
21
|
+
.replace(/&/g, "&");
|
|
22
|
+
|
|
23
|
+
const tagOf = (block, name) => {
|
|
24
|
+
const m = block.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`));
|
|
25
|
+
return m ? unCdata(m[1]).trim() : "";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const catsOf = (block, domain) =>
|
|
29
|
+
[...block.matchAll(new RegExp(`<category domain="${domain}"[^>]*>([\\s\\S]*?)</category>`, "g"))].map((m) => decode(unCdata(m[1]).trim())).filter(Boolean);
|
|
30
|
+
|
|
31
|
+
// Parse a WXR document into raw items.
|
|
32
|
+
export function parseWXR(xml) {
|
|
33
|
+
const items = [];
|
|
34
|
+
for (const m of String(xml).matchAll(/<item>([\s\S]*?)<\/item>/g)) {
|
|
35
|
+
const b = m[1];
|
|
36
|
+
items.push({
|
|
37
|
+
type: tagOf(b, "wp:post_type") || "post",
|
|
38
|
+
title: decode(tagOf(b, "title")),
|
|
39
|
+
slug: tagOf(b, "wp:post_name"),
|
|
40
|
+
status: tagOf(b, "wp:status") || "publish",
|
|
41
|
+
date: tagOf(b, "wp:post_date") || tagOf(b, "pubDate"),
|
|
42
|
+
content: unCdata((b.match(/<content:encoded>([\s\S]*?)<\/content:encoded>/) || [, ""])[1]).trim(),
|
|
43
|
+
categories: catsOf(b, "category"),
|
|
44
|
+
tags: catsOf(b, "post_tag"),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return items;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const slugify = (s) =>
|
|
51
|
+
String(s)
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
54
|
+
.replace(/^-+|-+$/g, "")
|
|
55
|
+
.slice(0, 80) || "page";
|
|
56
|
+
|
|
57
|
+
// WP content is HTML (often with Gutenberg block comments). Strip the block
|
|
58
|
+
// comments; the rest is valid HTML that the pages add-on's `marked` passes
|
|
59
|
+
// through. Pages are author-trusted files.
|
|
60
|
+
const cleanBody = (html) =>
|
|
61
|
+
String(html)
|
|
62
|
+
.replace(/<!--\s*\/?wp:[\s\S]*?-->/g, "")
|
|
63
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
64
|
+
.trim();
|
|
65
|
+
|
|
66
|
+
// Render one item to a markdown file body (front-matter + content).
|
|
67
|
+
export function toMarkdown(item) {
|
|
68
|
+
const fm = ["---", `title: ${String(item.title || "Untitled").replace(/[\r\n]+/g, " ").trim()}`];
|
|
69
|
+
if (item.date) fm.push(`date: ${item.date}`);
|
|
70
|
+
if (item.tags && item.tags.length) fm.push(`tags: ${item.tags.join(", ")}`);
|
|
71
|
+
fm.push("---", "");
|
|
72
|
+
return fm.join("\n") + cleanBody(item.content) + "\n";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Map a WP REST API object (?_embed=1) into the same item shape as parseWXR.
|
|
76
|
+
function mapREST(p, type) {
|
|
77
|
+
const flat = ((p._embedded && p._embedded["wp:term"]) || []).flat();
|
|
78
|
+
const byTax = (tax) => flat.filter((t) => t && t.taxonomy === tax).map((t) => decode(t.name)).filter(Boolean);
|
|
79
|
+
return {
|
|
80
|
+
type,
|
|
81
|
+
title: decode((p.title && p.title.rendered) || p.slug || "Untitled"),
|
|
82
|
+
slug: p.slug || "",
|
|
83
|
+
status: p.status || "publish",
|
|
84
|
+
date: String(p.date || "").replace("T", " ").replace(/\.\d+$/, ""),
|
|
85
|
+
content: (p.content && p.content.rendered) || "",
|
|
86
|
+
categories: byTax("category"),
|
|
87
|
+
tags: byTax("post_tag"),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Fetch posts + pages from a live WordPress site over the REST API. Credentials
|
|
92
|
+
// (Application Password) are only needed for non-published content. fetchImpl is
|
|
93
|
+
// injectable for testing.
|
|
94
|
+
export async function fetchWP(baseUrl, { user, appPassword, drafts = false, fetchImpl = fetch } = {}) {
|
|
95
|
+
const base = String(baseUrl).replace(/\/+$/, "");
|
|
96
|
+
const headers = { Accept: "application/json" };
|
|
97
|
+
if (user && appPassword) headers.Authorization = "Basic " + Buffer.from(`${user}:${appPassword}`).toString("base64");
|
|
98
|
+
const statuses = drafts ? "publish,draft,pending,private,future" : "publish";
|
|
99
|
+
const items = [];
|
|
100
|
+
for (const [kind, type] of [["posts", "post"], ["pages", "page"]]) {
|
|
101
|
+
let page = 1;
|
|
102
|
+
let totalPages = 1;
|
|
103
|
+
do {
|
|
104
|
+
const url = `${base}/wp-json/wp/v2/${kind}?per_page=100&page=${page}&status=${statuses}&_embed=1`;
|
|
105
|
+
const res = await fetchImpl(url, { headers });
|
|
106
|
+
if (res.status === 400 && page > 1) break; // WP 400s past the last page
|
|
107
|
+
if (res.status === 401 || res.status === 403) throw new Error(`WP REST ${kind}: ${res.status} — credentials needed/insufficient for status=${statuses}`);
|
|
108
|
+
if (!res.ok) throw new Error(`WP REST ${kind} page ${page}: ${res.status} ${res.statusText || ""}`.trim());
|
|
109
|
+
totalPages = Number(res.headers && res.headers.get && res.headers.get("X-WP-TotalPages")) || 1;
|
|
110
|
+
const arr = await res.json();
|
|
111
|
+
if (!Array.isArray(arr) || arr.length === 0) break;
|
|
112
|
+
for (const p of arr) items.push(mapREST(p, type));
|
|
113
|
+
page++;
|
|
114
|
+
} while (page <= totalPages);
|
|
115
|
+
}
|
|
116
|
+
return items;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Select + transform raw items into importable markdown pages. Returns { imported, stats }.
|
|
120
|
+
export function transform(all, { types = ["page", "post"], drafts = false } = {}) {
|
|
121
|
+
const stats = { total: all.length, byType: {}, draftsSkipped: 0, otherTypeSkipped: 0 };
|
|
122
|
+
for (const i of all) stats.byType[i.type] = (stats.byType[i.type] || 0) + 1;
|
|
123
|
+
|
|
124
|
+
const used = new Set();
|
|
125
|
+
const imported = [];
|
|
126
|
+
for (const i of all) {
|
|
127
|
+
if (!types.includes(i.type)) {
|
|
128
|
+
stats.otherTypeSkipped++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (i.status !== "publish" && !drafts) {
|
|
132
|
+
stats.draftsSkipped++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
let slug = i.slug && /^[a-z0-9][a-z0-9-]*$/i.test(i.slug) ? i.slug.toLowerCase() : slugify(i.title || i.slug);
|
|
136
|
+
let s = slug;
|
|
137
|
+
for (let n = 2; used.has(s); n++) s = `${slug}-${n}`;
|
|
138
|
+
used.add(s);
|
|
139
|
+
imported.push({ slug: s, type: i.type, title: i.title, filename: `${s}.md`, markdown: toMarkdown(i) });
|
|
140
|
+
}
|
|
141
|
+
return { imported, stats };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Import from a WXR export string (offline file path).
|
|
145
|
+
export function runImport(xml, opts = {}) {
|
|
146
|
+
return transform(parseWXR(xml), opts);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Import from a live WordPress site over the REST API.
|
|
150
|
+
export async function runImportFromWP(baseUrl, opts = {}) {
|
|
151
|
+
return transform(await fetchWP(baseUrl, opts), opts);
|
|
152
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-volt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Scaffold a new Volt app — no-build, signals-based UI with Socket.io hot reload.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"index.js",
|
|
11
|
+
"lib",
|
|
11
12
|
"templates",
|
|
12
13
|
"addons",
|
|
13
14
|
"CHANGELOG.md"
|
|
@@ -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") {
|
|
@@ -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") {
|
|
@@ -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">
|