create-volt 0.28.0 → 0.29.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 +11 -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/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ 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.29.0] - 2026-06-29
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **Date-derived default port is now valid year-round.** For Oct–Dec the
|
|
11
|
+
readable YY+M+DD form is 6 digits (e.g. `261010`) and exceeds the max TCP port
|
|
12
|
+
65535, so create-volt previously refused to scaffold without `--port`. It now
|
|
13
|
+
falls back to a deterministic in-range port `1024 + (YYYYMMDD % 64512)` when the
|
|
14
|
+
readable form overflows; Jan–Sep keep the readable port unchanged. Extracted to
|
|
15
|
+
`lib/date-port.js` and unit-tested.
|
|
16
|
+
|
|
7
17
|
## [0.28.0] - 2026-06-29
|
|
8
18
|
|
|
9
19
|
### Fixed
|
|
@@ -389,6 +399,7 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
389
399
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
390
400
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
391
401
|
|
|
402
|
+
[0.29.0]: https://github.com/MIR-2025/volt/releases/tag/v0.29.0
|
|
392
403
|
[0.28.0]: https://github.com/MIR-2025/volt/releases/tag/v0.28.0
|
|
393
404
|
[0.27.0]: https://github.com/MIR-2025/volt/releases/tag/v0.27.0
|
|
394
405
|
[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.29.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"
|