@prudentbird/voxx 1.0.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/LICENSE +21 -0
- package/README.md +41 -0
- package/dist/index.mjs +1346 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +65 -0
- package/templates/blog/hello-world.md.tpl +42 -0
- package/templates/blog/layout.tsx.tpl +44 -0
- package/templates/blog/page.tsx.tpl +27 -0
- package/templates/blog/post-list.tsx.tpl +45 -0
- package/templates/blog/post-page.tsx.tpl +41 -0
- package/templates/blog/slug-page.tsx.tpl +40 -0
- package/templates/changelog/layout.tsx.tpl +44 -0
- package/templates/changelog/page.tsx.tpl +27 -0
- package/templates/changelog/release-list.tsx.tpl +33 -0
- package/templates/changelog/release.md.tpl +15 -0
- package/templates/docs/doc-page.tsx.tpl +65 -0
- package/templates/docs/getting-started-index.md.tpl +9 -0
- package/templates/docs/index.md.tpl +16 -0
- package/templates/docs/installation.md.tpl +17 -0
- package/templates/docs/layout-root.tsx.tpl +52 -0
- package/templates/docs/layout.tsx.tpl +37 -0
- package/templates/docs/mobile-nav.tsx.tpl +90 -0
- package/templates/docs/page.tsx.tpl +50 -0
- package/templates/docs/sidebar-nav.tsx.tpl +39 -0
- package/templates/shared/content-version.ts.tpl +1 -0
- package/templates/shared/data.ts.tpl +34 -0
- package/templates/shared/instrumentation.ts.tpl +5 -0
- package/templates/shared/llms-full-route.ts.tpl +9 -0
- package/templates/shared/llms-route.ts.tpl +9 -0
- package/templates/shared/metadata.ts.tpl +39 -0
- package/templates/shared/on-this-page.tsx.tpl +213 -0
- package/templates/shared/robots.ts.tpl +13 -0
- package/templates/shared/rss-route.ts.tpl +9 -0
- package/templates/shared/sitemap.ts.tpl +23 -0
- package/templates/shared/theme-toggle.tsx.tpl +64 -0
- package/templates/shared/voxx.json.tpl +26 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1346 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { access, copyFile, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, extname, join, normalize, relative, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { DEFAULT_CONFIG, buildNavTree, buildSeo, escapeXml, formatDate, getPosts, humanize, loadConfig, parseVersion, renderLlmsFull, renderLlmsTxtSections, renderRobotsTxt, renderRss, renderSitemap, resolveCollectionDefaults, rssPath, sectionHeading, serializeJsonLd, slugify, splitDatePrefix, splitOrderPrefix } from "@prudentbird/voxx-core";
|
|
10
|
+
import { watch } from "node:fs";
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
//#region src/util.ts
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const TEMPLATES_DIR = fileURLToPath(new URL("../templates", import.meta.url));
|
|
16
|
+
const useColor = process.stdout.isTTY && !process.env["NO_COLOR"];
|
|
17
|
+
const wrap = (code) => (s) => useColor ? `[${code}m${s}[0m` : s;
|
|
18
|
+
/** ANSI color helpers that degrade gracefully in non-TTY environments. */
|
|
19
|
+
const c = {
|
|
20
|
+
bold: wrap("1"),
|
|
21
|
+
dim: wrap("2"),
|
|
22
|
+
red: wrap("31"),
|
|
23
|
+
green: wrap("32"),
|
|
24
|
+
yellow: wrap("33"),
|
|
25
|
+
cyan: wrap("36")
|
|
26
|
+
};
|
|
27
|
+
/** Prefixed console helpers used throughout the CLI commands. */
|
|
28
|
+
const log = {
|
|
29
|
+
info: (msg) => console.log(msg),
|
|
30
|
+
success: (msg) => console.log(`${c.green("✓")} ${msg}`),
|
|
31
|
+
warn: (msg) => console.log(`${c.yellow("!")} ${msg}`),
|
|
32
|
+
error: (msg) => console.error(`${c.red("✗")} ${msg}`)
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Returns `true` if the path exists and is accessible, `false` otherwise.
|
|
36
|
+
*/
|
|
37
|
+
async function exists(path) {
|
|
38
|
+
try {
|
|
39
|
+
await access(path);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Reads a scaffold template file by name from the bundled `templates/` directory.
|
|
47
|
+
*
|
|
48
|
+
* @param name - Template filename, e.g. `"shared/data.ts.tpl"`.
|
|
49
|
+
*/
|
|
50
|
+
async function readTemplate(name) {
|
|
51
|
+
return readFile(join(TEMPLATES_DIR, name), "utf8");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Replaces `{{KEY}}` placeholders in a template string.
|
|
55
|
+
*
|
|
56
|
+
* @param tpl - Template string containing `{{VARIABLE}}` tokens.
|
|
57
|
+
* @param vars - Map of token names to replacement values.
|
|
58
|
+
*/
|
|
59
|
+
function render(tpl, vars = {}) {
|
|
60
|
+
return tpl.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Writes `content` to `path`, creating parent directories as needed.
|
|
64
|
+
* Skips the write if the file already exists and `force` is `false`.
|
|
65
|
+
*
|
|
66
|
+
* @param path - Destination file path.
|
|
67
|
+
* @param content - File content to write.
|
|
68
|
+
* @param force - Overwrite existing files when `true`. Defaults to `false`.
|
|
69
|
+
* @returns `"written"` if the file was created/replaced, `"skipped"` if it already existed.
|
|
70
|
+
*/
|
|
71
|
+
async function writeFileSafe(path, content, force = false) {
|
|
72
|
+
await mkdir(dirname(path), { recursive: true });
|
|
73
|
+
if (!force && await exists(path)) return "skipped";
|
|
74
|
+
await writeFile(path, content);
|
|
75
|
+
return "written";
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolves the path to a sub-asset of the installed `@prudentbird/voxx-core` package.
|
|
79
|
+
*
|
|
80
|
+
* @param subpath - Package-relative path, e.g. `"theme/voxx.css"`.
|
|
81
|
+
*/
|
|
82
|
+
function resolveCoreAsset(subpath) {
|
|
83
|
+
return require.resolve(`@prudentbird/voxx-core/${subpath}`);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Converts a package name or slug to Title Case.
|
|
87
|
+
*
|
|
88
|
+
* Strips any npm scope prefix (`@scope/`) before converting.
|
|
89
|
+
*/
|
|
90
|
+
function titleCase(name) {
|
|
91
|
+
return name.replace(/^@[^/]+\//, "").replace(/[-_]+/g, " ").replace(/\b\w/g, (m) => m.toUpperCase()).trim();
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Returns `true` if the path is a safe relative path that stays within the
|
|
95
|
+
* project root (no absolute paths, no `..` traversal, no null bytes).
|
|
96
|
+
*/
|
|
97
|
+
function isSafeRelPath(p) {
|
|
98
|
+
if (p === "") return true;
|
|
99
|
+
if (p.includes("\0")) return false;
|
|
100
|
+
if (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)) return false;
|
|
101
|
+
return p.split(/[\\/]/).every((seg) => seg !== "..");
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Normalizes a URL base path to always have a leading slash and no trailing slash.
|
|
105
|
+
*
|
|
106
|
+
* @param base - Raw base path, e.g. `"blog/"` or `"/blog"`.
|
|
107
|
+
* @returns Normalized path, e.g. `"/blog"`.
|
|
108
|
+
*/
|
|
109
|
+
function normalizeBase(base) {
|
|
110
|
+
const withLead = base.startsWith("/") ? base : `/${base}`;
|
|
111
|
+
return withLead.length > 1 ? withLead.replace(/\/+$/, "") : withLead;
|
|
112
|
+
}
|
|
113
|
+
const YAML_PLAIN_RE = /^[A-Za-z0-9](?:[A-Za-z0-9 ._/()-]*[A-Za-z0-9._/()-])?$/;
|
|
114
|
+
/**
|
|
115
|
+
* Returns a YAML-safe representation of `value`.
|
|
116
|
+
*
|
|
117
|
+
* Plain strings that match the safe character set are returned as-is;
|
|
118
|
+
* everything else is JSON-quoted.
|
|
119
|
+
*/
|
|
120
|
+
function yamlValue(value) {
|
|
121
|
+
return YAML_PLAIN_RE.test(value) ? value : JSON.stringify(value);
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/commands/init.ts
|
|
125
|
+
const APP_DIR_CANDIDATES = ["app", "src/app"];
|
|
126
|
+
const GLOBALS_CANDIDATES = [
|
|
127
|
+
"app/globals.css",
|
|
128
|
+
"src/app/globals.css",
|
|
129
|
+
"styles/globals.css",
|
|
130
|
+
"src/styles/globals.css",
|
|
131
|
+
"app/global.css"
|
|
132
|
+
];
|
|
133
|
+
const PRESETS = [
|
|
134
|
+
"blog",
|
|
135
|
+
"docs",
|
|
136
|
+
"changelog"
|
|
137
|
+
];
|
|
138
|
+
const PRESET_DEFAULTS = {
|
|
139
|
+
blog: {
|
|
140
|
+
title: "My Blog",
|
|
141
|
+
description: "A blog built with Voxx."
|
|
142
|
+
},
|
|
143
|
+
docs: {
|
|
144
|
+
title: "My Docs",
|
|
145
|
+
description: "Documentation built with Voxx."
|
|
146
|
+
},
|
|
147
|
+
changelog: {
|
|
148
|
+
title: "Changelog",
|
|
149
|
+
description: "Release notes built with Voxx."
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
async function readPkg(cwd) {
|
|
153
|
+
if (await exists(join(cwd, "package.json"))) return JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
|
|
154
|
+
return {};
|
|
155
|
+
}
|
|
156
|
+
function pkgHasNext(pkg) {
|
|
157
|
+
return Boolean(pkg.dependencies?.["next"] ?? pkg.devDependencies?.["next"]);
|
|
158
|
+
}
|
|
159
|
+
async function detectAppDir(cwd) {
|
|
160
|
+
for (const dir of APP_DIR_CANDIDATES) if (await exists(join(cwd, dir))) return dir;
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
async function detectTokens(cwd) {
|
|
164
|
+
for (const rel of GLOBALS_CANDIDATES) {
|
|
165
|
+
const path = join(cwd, rel);
|
|
166
|
+
if (await exists(path)) {
|
|
167
|
+
const css = await readFile(path, "utf8");
|
|
168
|
+
if (css.includes("--background") || css.includes("--foreground")) return true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
async function copyAsset(subpath, target, force) {
|
|
174
|
+
if (!force && await exists(target)) return "skipped";
|
|
175
|
+
await mkdir(dirname(target), { recursive: true });
|
|
176
|
+
await copyFile(resolveCoreAsset(subpath), target);
|
|
177
|
+
return "written";
|
|
178
|
+
}
|
|
179
|
+
function run(cmd, args, cwd) {
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const child = spawn(cmd, args, {
|
|
182
|
+
cwd,
|
|
183
|
+
stdio: "inherit"
|
|
184
|
+
});
|
|
185
|
+
child.on("error", reject);
|
|
186
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
async function chooseSetup() {
|
|
190
|
+
if (!process.stdin.isTTY) return "static";
|
|
191
|
+
log.warn("No Next.js app detected here.");
|
|
192
|
+
log.info(` ${c.cyan("1.")} Static site — markdown in, HTML/CSS out via ${c.cyan("voxx build")}`);
|
|
193
|
+
log.info(` ${c.cyan("2.")} New Next.js app — runs ${c.cyan("create-next-app")}, then scaffolds`);
|
|
194
|
+
const rl = createInterface({
|
|
195
|
+
input: process.stdin,
|
|
196
|
+
output: process.stdout
|
|
197
|
+
});
|
|
198
|
+
try {
|
|
199
|
+
for (;;) {
|
|
200
|
+
const answer = (await rl.question(`Choose [1/2] (default 1): `)).trim();
|
|
201
|
+
if (answer === "" || answer === "1") return "static";
|
|
202
|
+
if (answer === "2") return "next";
|
|
203
|
+
log.warn("Please answer 1 or 2.");
|
|
204
|
+
}
|
|
205
|
+
} finally {
|
|
206
|
+
rl.close();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function createNextApp(cwd) {
|
|
210
|
+
const rl = createInterface({
|
|
211
|
+
input: process.stdin,
|
|
212
|
+
output: process.stdout
|
|
213
|
+
});
|
|
214
|
+
let dir;
|
|
215
|
+
try {
|
|
216
|
+
dir = (await rl.question(`Directory for the new app (default ${c.cyan("my-app")}): `)).trim() || "my-app";
|
|
217
|
+
} finally {
|
|
218
|
+
rl.close();
|
|
219
|
+
}
|
|
220
|
+
log.info("");
|
|
221
|
+
if (await run("npx", ["create-next-app@latest", dir], cwd) !== 0) {
|
|
222
|
+
log.error("create-next-app failed — nothing scaffolded.");
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
return join(cwd, dir);
|
|
226
|
+
}
|
|
227
|
+
async function isolateCreatedApp(cwd, appDir) {
|
|
228
|
+
const siteDir = join(cwd, appDir, "(site)");
|
|
229
|
+
const moved = [];
|
|
230
|
+
for (const file of [
|
|
231
|
+
"layout.tsx",
|
|
232
|
+
"layout.js",
|
|
233
|
+
"page.tsx",
|
|
234
|
+
"page.js",
|
|
235
|
+
"page.module.css"
|
|
236
|
+
]) {
|
|
237
|
+
const from = join(cwd, appDir, file);
|
|
238
|
+
if (!await exists(from)) continue;
|
|
239
|
+
await mkdir(siteDir, { recursive: true });
|
|
240
|
+
await rename(from, join(siteDir, file));
|
|
241
|
+
moved.push(file);
|
|
242
|
+
}
|
|
243
|
+
if (!moved.some((f) => f.startsWith("layout."))) return false;
|
|
244
|
+
for (const file of ["layout.tsx", "layout.js"]) {
|
|
245
|
+
const path = join(siteDir, file);
|
|
246
|
+
if (!await exists(path)) continue;
|
|
247
|
+
await writeFile(path, (await readFile(path, "utf8")).replace("\"./globals.css\"", "\"../globals.css\""));
|
|
248
|
+
}
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
const NEXT_CONFIG_FILES = [
|
|
252
|
+
"next.config.ts",
|
|
253
|
+
"next.config.mjs",
|
|
254
|
+
"next.config.js",
|
|
255
|
+
"next.config.cjs"
|
|
256
|
+
];
|
|
257
|
+
function nextMajor(pkg) {
|
|
258
|
+
const range = pkg.dependencies?.["next"] ?? pkg.devDependencies?.["next"] ?? "";
|
|
259
|
+
const m = /(\d+)/.exec(range);
|
|
260
|
+
return m ? Number(m[1]) : null;
|
|
261
|
+
}
|
|
262
|
+
const MINIMAL_NEXT_CONFIG = `import type { NextConfig } from "next";
|
|
263
|
+
|
|
264
|
+
const nextConfig: NextConfig = {
|
|
265
|
+
cacheComponents: true,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
export default nextConfig;
|
|
269
|
+
`;
|
|
270
|
+
async function enableCacheComponents(cwd, pkg) {
|
|
271
|
+
const major = nextMajor(pkg);
|
|
272
|
+
if (major !== null && major < 16) return { kind: "unsupported" };
|
|
273
|
+
let file = null;
|
|
274
|
+
for (const name of NEXT_CONFIG_FILES) if (await exists(join(cwd, name))) {
|
|
275
|
+
file = name;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
if (!file) {
|
|
279
|
+
if (major === null) return { kind: "manual" };
|
|
280
|
+
await writeFile(join(cwd, "next.config.ts"), MINIMAL_NEXT_CONFIG);
|
|
281
|
+
return {
|
|
282
|
+
kind: "created",
|
|
283
|
+
file: "next.config.ts"
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const source = await readFile(join(cwd, file), "utf8");
|
|
287
|
+
if (/\bcacheComponents\b/.test(source)) return {
|
|
288
|
+
kind: "already",
|
|
289
|
+
file
|
|
290
|
+
};
|
|
291
|
+
const m = /(const\s+nextConfig\s*(?::\s*[\w.]+)?\s*=\s*|export\s+default\s+|module\.exports\s*=\s*)\{/.exec(source);
|
|
292
|
+
if (!m) return { kind: "manual" };
|
|
293
|
+
const at = m.index + m[0].length;
|
|
294
|
+
const rest = source.slice(at);
|
|
295
|
+
const insert = rest.trimStart().startsWith("}") ? "\n cacheComponents: true,\n" : "\n cacheComponents: true,";
|
|
296
|
+
await writeFile(join(cwd, file), source.slice(0, at) + insert + rest);
|
|
297
|
+
return {
|
|
298
|
+
kind: "enabled",
|
|
299
|
+
file
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function planAdd(raw, preset, values) {
|
|
303
|
+
const rawCollections = Array.isArray(raw["collections"]) ? raw["collections"] : [];
|
|
304
|
+
const rawContent = raw["content"] ?? void 0;
|
|
305
|
+
const existing = rawCollections.length > 0 ? rawCollections.map(resolveCollectionDefaults) : [{
|
|
306
|
+
name: rawContent?.type ?? DEFAULT_CONFIG.content.type,
|
|
307
|
+
type: rawContent?.type ?? DEFAULT_CONFIG.content.type,
|
|
308
|
+
dir: rawContent?.dir ?? DEFAULT_CONFIG.content.dir,
|
|
309
|
+
basePath: rawContent?.basePath ?? DEFAULT_CONFIG.content.basePath,
|
|
310
|
+
drafts: rawContent?.drafts ?? false
|
|
311
|
+
}];
|
|
312
|
+
const first = existing[0];
|
|
313
|
+
const name = values.name ?? preset;
|
|
314
|
+
const dir = values.dir ?? `content/${name}`;
|
|
315
|
+
const basePath = normalizeBase(values.base ?? `/${name}`);
|
|
316
|
+
if (existing.some((c) => c.name === name)) return {
|
|
317
|
+
ok: false,
|
|
318
|
+
message: `Collection "${name}" already exists — defined: ${existing.map((c) => c.name).join(", ")}. Pass --name <other>.`
|
|
319
|
+
};
|
|
320
|
+
const dupBase = existing.find((c) => c.basePath === basePath);
|
|
321
|
+
if (dupBase) return {
|
|
322
|
+
ok: false,
|
|
323
|
+
message: `Base path "${basePath}" is already used by collection "${dupBase.name}" — pass --base <path>.`
|
|
324
|
+
};
|
|
325
|
+
const dupDir = existing.find((c) => c.dir === dir);
|
|
326
|
+
if (dupDir) return {
|
|
327
|
+
ok: false,
|
|
328
|
+
message: `Content dir "${dir}" is already used by collection "${dupDir.name}" — pass --dir <dir>.`
|
|
329
|
+
};
|
|
330
|
+
const next = {
|
|
331
|
+
name,
|
|
332
|
+
type: preset,
|
|
333
|
+
dir,
|
|
334
|
+
basePath,
|
|
335
|
+
drafts: false
|
|
336
|
+
};
|
|
337
|
+
if (rawCollections.length > 0) return {
|
|
338
|
+
ok: true,
|
|
339
|
+
out: {
|
|
340
|
+
...raw,
|
|
341
|
+
collections: [...rawCollections, next]
|
|
342
|
+
},
|
|
343
|
+
name,
|
|
344
|
+
dir,
|
|
345
|
+
basePath
|
|
346
|
+
};
|
|
347
|
+
const migrated = {
|
|
348
|
+
name: first.name,
|
|
349
|
+
...rawContent ?? {}
|
|
350
|
+
};
|
|
351
|
+
const reResolved = resolveCollectionDefaults(migrated);
|
|
352
|
+
if (reResolved.dir !== first.dir) migrated["dir"] = first.dir;
|
|
353
|
+
if (reResolved.basePath !== first.basePath) migrated["basePath"] = first.basePath;
|
|
354
|
+
const collections = [migrated, next];
|
|
355
|
+
const out = {};
|
|
356
|
+
let inserted = false;
|
|
357
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
358
|
+
if (key === "content" || key === "collections") {
|
|
359
|
+
if (!inserted) {
|
|
360
|
+
out["collections"] = collections;
|
|
361
|
+
inserted = true;
|
|
362
|
+
}
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
out[key] = value;
|
|
366
|
+
}
|
|
367
|
+
if (!inserted) out["collections"] = collections;
|
|
368
|
+
return {
|
|
369
|
+
ok: true,
|
|
370
|
+
out,
|
|
371
|
+
name,
|
|
372
|
+
dir,
|
|
373
|
+
basePath
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
async function init(argv) {
|
|
377
|
+
const { values, positionals } = parseArgs({
|
|
378
|
+
args: argv,
|
|
379
|
+
options: {
|
|
380
|
+
dir: { type: "string" },
|
|
381
|
+
base: { type: "string" },
|
|
382
|
+
app: { type: "string" },
|
|
383
|
+
name: { type: "string" },
|
|
384
|
+
add: { type: "boolean" },
|
|
385
|
+
force: { type: "boolean" }
|
|
386
|
+
},
|
|
387
|
+
allowPositionals: true
|
|
388
|
+
});
|
|
389
|
+
const presetArg = positionals[0] ?? "blog";
|
|
390
|
+
if (!PRESETS.includes(presetArg)) {
|
|
391
|
+
log.error(`Unknown preset "${presetArg}" — expected one of: ${PRESETS.join(", ")}.`);
|
|
392
|
+
process.exitCode = 1;
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const preset = presetArg;
|
|
396
|
+
const add = Boolean(values.add);
|
|
397
|
+
const force = Boolean(values.force);
|
|
398
|
+
if (add && force) {
|
|
399
|
+
log.error("--add and --force cannot be combined — --add never overwrites. Delete the collection's files and re-run if you need a clean re-scaffold.");
|
|
400
|
+
process.exitCode = 1;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (!add && values.name !== void 0) {
|
|
404
|
+
log.error("--name only applies with --add.");
|
|
405
|
+
process.exitCode = 1;
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (values.dir !== void 0 && !isSafeRelPath(values.dir)) {
|
|
409
|
+
log.error(`Invalid --dir "${values.dir}" — must stay within the project (no "..").`);
|
|
410
|
+
process.exitCode = 1;
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (values.app !== void 0 && !isSafeRelPath(values.app)) {
|
|
414
|
+
log.error(`Invalid --app "${values.app}" — must stay within the project (no "..").`);
|
|
415
|
+
process.exitCode = 1;
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (values.base !== void 0 && values.base.split(/[\\/]/).includes("..")) {
|
|
419
|
+
log.error(`Invalid --base "${values.base}" — must not contain "..".`);
|
|
420
|
+
process.exitCode = 1;
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
let cwd = process.cwd();
|
|
424
|
+
let contentDir = values.dir ?? "content";
|
|
425
|
+
let basePath = normalizeBase(values.base ?? `/${preset}`);
|
|
426
|
+
let collectionName = preset;
|
|
427
|
+
let pkg = await readPkg(cwd);
|
|
428
|
+
let hasNext = pkgHasNext(pkg);
|
|
429
|
+
let createdAppDir = null;
|
|
430
|
+
let staticChoice = false;
|
|
431
|
+
const results = [];
|
|
432
|
+
if (add) {
|
|
433
|
+
const cfgPath = join(cwd, "voxx.json");
|
|
434
|
+
if (!await exists(cfgPath)) {
|
|
435
|
+
log.error(`--add requires an existing voxx.json — run ${c.cyan(`voxx init ${preset}`)} first.`);
|
|
436
|
+
process.exitCode = 1;
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
let raw;
|
|
440
|
+
try {
|
|
441
|
+
raw = JSON.parse(await readFile(cfgPath, "utf8"));
|
|
442
|
+
} catch {
|
|
443
|
+
log.error("voxx.json is not valid JSON — fix it before running --add.");
|
|
444
|
+
process.exitCode = 1;
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const plan = planAdd(raw, preset, values);
|
|
448
|
+
if (!plan.ok) {
|
|
449
|
+
log.error(plan.message);
|
|
450
|
+
process.exitCode = 1;
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
collectionName = plan.name;
|
|
454
|
+
contentDir = plan.dir;
|
|
455
|
+
basePath = plan.basePath;
|
|
456
|
+
await writeFile(cfgPath, `${JSON.stringify(plan.out, null, 2)}\n`);
|
|
457
|
+
results.push([`voxx.json (added collection "${collectionName}")`, "written"]);
|
|
458
|
+
} else if (!hasNext) if (await chooseSetup() === "next") {
|
|
459
|
+
createdAppDir = await createNextApp(cwd);
|
|
460
|
+
if (!createdAppDir) {
|
|
461
|
+
process.exitCode = 1;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
cwd = createdAppDir;
|
|
465
|
+
pkg = await readPkg(cwd);
|
|
466
|
+
hasNext = pkgHasNext(pkg);
|
|
467
|
+
} else staticChoice = true;
|
|
468
|
+
const baseSegment = basePath.slice(1);
|
|
469
|
+
const appDir = values.app ?? await detectAppDir(cwd);
|
|
470
|
+
const siteTitle = pkg.name ? titleCase(pkg.name) : PRESET_DEFAULTS[preset].title;
|
|
471
|
+
if (!add) results.push(["voxx.json", await writeFileSafe(join(cwd, "voxx.json"), render(await readTemplate("shared/voxx.json.tpl"), {
|
|
472
|
+
SITE_TITLE: siteTitle,
|
|
473
|
+
SITE_DESCRIPTION: PRESET_DEFAULTS[preset].description,
|
|
474
|
+
SITE_URL: "https://example.com",
|
|
475
|
+
TYPE: preset,
|
|
476
|
+
CONTENT_DIR: contentDir,
|
|
477
|
+
BASE_PATH: basePath
|
|
478
|
+
}), force)]);
|
|
479
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
480
|
+
const samples = preset === "docs" ? [
|
|
481
|
+
["docs/index.md.tpl", "index.md"],
|
|
482
|
+
["docs/getting-started-index.md.tpl", join("01-getting-started", "index.md")],
|
|
483
|
+
["docs/installation.md.tpl", join("01-getting-started", "01-installation.md")]
|
|
484
|
+
] : preset === "changelog" ? [[
|
|
485
|
+
"changelog/release.md.tpl",
|
|
486
|
+
"0.1.0.md",
|
|
487
|
+
{ DATE: today }
|
|
488
|
+
]] : [[
|
|
489
|
+
"blog/hello-world.md.tpl",
|
|
490
|
+
"hello-world.md",
|
|
491
|
+
{ DATE: today }
|
|
492
|
+
]];
|
|
493
|
+
for (const [tpl, rel, vars] of samples) results.push([join(contentDir, rel), await writeFileSafe(join(cwd, contentDir, rel), render(await readTemplate(tpl), vars ?? {}), force)]);
|
|
494
|
+
let wroteGlobals = false;
|
|
495
|
+
let cache = null;
|
|
496
|
+
if (hasNext && appDir) {
|
|
497
|
+
const blogDir = join(cwd, appDir, baseSegment);
|
|
498
|
+
const voxxDir = join(blogDir, "_voxx");
|
|
499
|
+
const hasTokens = await detectTokens(cwd);
|
|
500
|
+
wroteGlobals = !hasTokens;
|
|
501
|
+
const globalsImport = hasTokens ? "" : "import \"./_voxx/voxx-globals.css\";";
|
|
502
|
+
const isolated = preset === "docs" && !add && createdAppDir !== null && baseSegment !== "" && await isolateCreatedApp(cwd, appDir);
|
|
503
|
+
if (isolated) results.push([`${join(appDir, "(site)")}/ (app layout moved — docs has its own root layout)`, "written"]);
|
|
504
|
+
const dataFromAppRoot = baseSegment ? `./${baseSegment}/_voxx/data` : "./_voxx/data";
|
|
505
|
+
const dataFromRouteDir = baseSegment ? `../${baseSegment}/_voxx/data` : "../_voxx/data";
|
|
506
|
+
results.push([relative(cwd, join(voxxDir, "voxx.css")), await copyAsset("theme/voxx.css", join(voxxDir, "voxx.css"), force)]);
|
|
507
|
+
if (wroteGlobals) results.push([relative(cwd, join(voxxDir, "voxx-globals.css")), await copyAsset("theme/demo-globals.css", join(voxxDir, "voxx-globals.css"), force)]);
|
|
508
|
+
const templated = [
|
|
509
|
+
[
|
|
510
|
+
preset === "docs" ? isolated ? "docs/layout-root.tsx.tpl" : "docs/layout.tsx.tpl" : `${preset}/layout.tsx.tpl`,
|
|
511
|
+
join(blogDir, "layout.tsx"),
|
|
512
|
+
{
|
|
513
|
+
GLOBALS_IMPORT: isolated && hasTokens ? "import \"../globals.css\";" : globalsImport,
|
|
514
|
+
BASE_PATH: basePath,
|
|
515
|
+
RSS_PATH: basePath === "/" ? "/rss.xml" : `${basePath}/rss.xml`
|
|
516
|
+
}
|
|
517
|
+
],
|
|
518
|
+
[
|
|
519
|
+
"shared/data.ts.tpl",
|
|
520
|
+
join(voxxDir, "data.ts"),
|
|
521
|
+
{ COLLECTION_ARG: `{ collection: ${JSON.stringify(collectionName)} }` }
|
|
522
|
+
],
|
|
523
|
+
["shared/content-version.ts.tpl", join(voxxDir, "content-version.ts")],
|
|
524
|
+
[
|
|
525
|
+
"shared/instrumentation.ts.tpl",
|
|
526
|
+
join(dirname(join(cwd, appDir)), "instrumentation.ts"),
|
|
527
|
+
void 0,
|
|
528
|
+
true
|
|
529
|
+
]
|
|
530
|
+
];
|
|
531
|
+
if (preset !== "changelog") templated.push(["shared/on-this-page.tsx.tpl", join(voxxDir, "on-this-page.tsx")], ["shared/metadata.ts.tpl", join(voxxDir, "metadata.ts")], [
|
|
532
|
+
"shared/sitemap.ts.tpl",
|
|
533
|
+
join(cwd, appDir, "sitemap.ts"),
|
|
534
|
+
{ DATA_IMPORT: dataFromAppRoot },
|
|
535
|
+
true
|
|
536
|
+
], [
|
|
537
|
+
"shared/robots.ts.tpl",
|
|
538
|
+
join(cwd, appDir, "robots.ts"),
|
|
539
|
+
{ DATA_IMPORT: dataFromAppRoot },
|
|
540
|
+
true
|
|
541
|
+
]);
|
|
542
|
+
templated.push([
|
|
543
|
+
"shared/llms-route.ts.tpl",
|
|
544
|
+
join(cwd, appDir, "llms.txt", "route.ts"),
|
|
545
|
+
{ DATA_IMPORT: dataFromRouteDir },
|
|
546
|
+
true
|
|
547
|
+
], [
|
|
548
|
+
"shared/llms-full-route.ts.tpl",
|
|
549
|
+
join(cwd, appDir, "llms-full.txt", "route.ts"),
|
|
550
|
+
{ DATA_IMPORT: dataFromRouteDir },
|
|
551
|
+
true
|
|
552
|
+
]);
|
|
553
|
+
if (preset !== "docs") templated.push([
|
|
554
|
+
"shared/rss-route.ts.tpl",
|
|
555
|
+
join(blogDir, "rss.xml", "route.ts"),
|
|
556
|
+
{ DATA_IMPORT: "../_voxx/data" }
|
|
557
|
+
]);
|
|
558
|
+
if (preset === "docs") templated.push(["docs/page.tsx.tpl", join(blogDir, "[[...slug]]", "page.tsx")], ["docs/doc-page.tsx.tpl", join(voxxDir, "doc-page.tsx")], ["docs/sidebar-nav.tsx.tpl", join(voxxDir, "sidebar-nav.tsx")], ["docs/mobile-nav.tsx.tpl", join(voxxDir, "mobile-nav.tsx")], ["shared/theme-toggle.tsx.tpl", join(voxxDir, "theme-toggle.tsx")]);
|
|
559
|
+
else if (preset === "changelog") templated.push(["changelog/page.tsx.tpl", join(blogDir, "page.tsx")], ["changelog/release-list.tsx.tpl", join(voxxDir, "release-list.tsx")], ["shared/theme-toggle.tsx.tpl", join(voxxDir, "theme-toggle.tsx")]);
|
|
560
|
+
else templated.push(["blog/page.tsx.tpl", join(blogDir, "page.tsx")], ["blog/slug-page.tsx.tpl", join(blogDir, "[slug]", "page.tsx")], ["blog/post-page.tsx.tpl", join(voxxDir, "post-page.tsx")], ["blog/post-list.tsx.tpl", join(voxxDir, "post-list.tsx")], ["shared/theme-toggle.tsx.tpl", join(voxxDir, "theme-toggle.tsx")]);
|
|
561
|
+
for (const [tpl, target, vars, siteWide] of templated) {
|
|
562
|
+
const content = render(await readTemplate(tpl), vars ?? {});
|
|
563
|
+
results.push([
|
|
564
|
+
relative(cwd, target),
|
|
565
|
+
await writeFileSafe(target, content, force),
|
|
566
|
+
siteWide
|
|
567
|
+
]);
|
|
568
|
+
}
|
|
569
|
+
cache = await enableCacheComponents(cwd, pkg);
|
|
570
|
+
if (cache.kind === "enabled" || cache.kind === "created") results.push([`${cache.file} (cacheComponents: true)`, "written"]);
|
|
571
|
+
}
|
|
572
|
+
log.info("");
|
|
573
|
+
log.info(c.bold(add ? "voxx init --add" : "voxx init"));
|
|
574
|
+
for (const [label, status, siteWide] of results) {
|
|
575
|
+
const mark = status === "written" ? c.green("+") : c.dim("•");
|
|
576
|
+
const note = status === "skipped" ? add && siteWide ? c.dim(" (site-wide, already present)") : c.dim(" (exists, skipped)") : "";
|
|
577
|
+
const prefix = createdAppDir ? `${relative(process.cwd(), cwd)}/` : "";
|
|
578
|
+
log.info(` ${mark} ${prefix}${label}${note}`);
|
|
579
|
+
}
|
|
580
|
+
log.info("");
|
|
581
|
+
log.info(c.bold("Next steps:"));
|
|
582
|
+
if (add) {
|
|
583
|
+
log.info(` 1. Write content in ${c.cyan(`${contentDir}/`)} (try ${c.cyan(`voxx new "Title" --collection ${collectionName}`)}).`);
|
|
584
|
+
if (hasNext && appDir) log.info(` 2. Run your dev server and open ${c.cyan(basePath)}.`);
|
|
585
|
+
else if (hasNext) log.warn("Next.js found but no app/ directory — pass --app <dir> to scaffold routes.");
|
|
586
|
+
else log.info(` 2. Run ${c.cyan("voxx build")} to render static HTML to ${c.cyan("./dist")}.`);
|
|
587
|
+
log.info(` ${c.dim("(Feature defaults like rss/toc follow the first collection's type — review \"features\" in voxx.json.)")}`);
|
|
588
|
+
log.info("");
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (staticChoice) {
|
|
592
|
+
const writeHint = preset === "docs" ? `Write pages in ${c.cyan(`${contentDir}/`)} — folders become sections.` : preset === "changelog" ? `Add releases in ${c.cyan(`${contentDir}/`)} (try ${c.cyan("voxx new \"0.2.0\"")}).` : `Write posts in ${c.cyan(`${contentDir}/`)} (try ${c.cyan("voxx new \"My post\"")}).`;
|
|
593
|
+
log.info(` 1. ${writeHint}`);
|
|
594
|
+
log.info(` 2. Set ${c.cyan("site.url")} in ${c.cyan("voxx.json")}.`);
|
|
595
|
+
log.info(` 3. Run ${c.cyan("voxx build")} to render static HTML to ${c.cyan("./dist")}.`);
|
|
596
|
+
} else if (!hasNext) {
|
|
597
|
+
log.warn("No Next.js detected — wrote voxx.json + sample content only.");
|
|
598
|
+
log.info(` Install the engine: ${c.cyan("npm i @prudentbird/voxx-core")}`);
|
|
599
|
+
log.info(` Or build static HTML: ${c.cyan("npx voxx build")}`);
|
|
600
|
+
} else if (!appDir) log.warn("Next.js found but no app/ directory — pass --app <dir> to scaffold routes.");
|
|
601
|
+
else {
|
|
602
|
+
let step = 1;
|
|
603
|
+
if (createdAppDir) log.info(` ${step++}. ${c.cyan(`cd ${relative(process.cwd(), createdAppDir)}`)}`);
|
|
604
|
+
log.info(` ${step++}. Install the engine: ${c.cyan("npm i @prudentbird/voxx-core")}`);
|
|
605
|
+
if (cache?.kind === "manual") log.info(` ${step++}. Enable Cache Components — add ${c.cyan("cacheComponents: true")} to next.config.`);
|
|
606
|
+
else if (cache?.kind === "unsupported") log.info(` ${step++}. Upgrade to Next 16+ and add ${c.cyan("cacheComponents: true")} to next.config.`);
|
|
607
|
+
log.info(` ${step++}. Set ${c.cyan("site.url")} in ${c.cyan("voxx.json")}.`);
|
|
608
|
+
log.info(` ${step}. Run your dev server and open ${c.cyan(basePath)}.`);
|
|
609
|
+
if (cache?.kind === "already") log.info(` ${c.dim(`(Cache Components already enabled in ${cache.file}.)`)}`);
|
|
610
|
+
if (wroteGlobals) log.info(` ${c.dim("(No design tokens found — added voxx-globals.css so it looks good out of the box.)")}`);
|
|
611
|
+
if (preset === "docs" && !createdAppDir && appDir) log.info(` ${c.dim(`(Heads up: your root layout wraps ${basePath} — its navbar/footer will show there too. To isolate the docs, see https://voxx.prudentbird.com/docs/reference/layouts.)`)}`);
|
|
612
|
+
}
|
|
613
|
+
log.info("");
|
|
614
|
+
}
|
|
615
|
+
//#endregion
|
|
616
|
+
//#region src/commands/build.ts
|
|
617
|
+
function stripLead(path) {
|
|
618
|
+
return path.replace(/^\/+/, "");
|
|
619
|
+
}
|
|
620
|
+
function headTags(seo, config) {
|
|
621
|
+
const tags = [`<meta name="description" content="${escapeXml(seo.description)}">`, `<link rel="canonical" href="${escapeXml(seo.canonical)}">`];
|
|
622
|
+
const og = seo.openGraph;
|
|
623
|
+
if (og) tags.push(`<meta property="og:type" content="article">`, `<meta property="og:title" content="${escapeXml(og.title)}">`, `<meta property="og:description" content="${escapeXml(og.description)}">`, `<meta property="og:url" content="${escapeXml(og.url)}">`, `<meta property="og:site_name" content="${escapeXml(og.siteName)}">`, ...og.images.map((src) => `<meta property="og:image" content="${escapeXml(src)}">`));
|
|
624
|
+
if (seo.twitter) tags.push(`<meta name="twitter:card" content="summary_large_image">`, `<meta name="twitter:title" content="${escapeXml(seo.twitter.title)}">`, `<meta name="twitter:description" content="${escapeXml(seo.twitter.description)}">`);
|
|
625
|
+
if (config.seo.jsonLd && seo.jsonLd) tags.push(`<script type="application/ld+json">${serializeJsonLd(seo.jsonLd)}<\/script>`);
|
|
626
|
+
return tags.join("\n ");
|
|
627
|
+
}
|
|
628
|
+
function shell(opts) {
|
|
629
|
+
return `<!doctype html>
|
|
630
|
+
<html lang="${escapeXml(opts.lang)}">
|
|
631
|
+
<head>
|
|
632
|
+
<meta charset="utf-8">
|
|
633
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
634
|
+
<title>${escapeXml(opts.title)}</title>
|
|
635
|
+
<link rel="stylesheet" href="/_voxx/voxx-globals.css">
|
|
636
|
+
<link rel="stylesheet" href="/_voxx/voxx.css">
|
|
637
|
+
${opts.head}
|
|
638
|
+
</head>
|
|
639
|
+
<body>
|
|
640
|
+
${opts.body}
|
|
641
|
+
</body>
|
|
642
|
+
</html>
|
|
643
|
+
`;
|
|
644
|
+
}
|
|
645
|
+
function siteHeader(config) {
|
|
646
|
+
const base = config.content.basePath || "/";
|
|
647
|
+
const rss = config.features.rss ? `<div class="voxx-header__actions"><a class="voxx-icon-button" href="${escapeXml(rssPath(config))}" aria-label="RSS feed"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="5" cy="19" r="1" fill="currentColor"/></svg></a></div>` : "";
|
|
648
|
+
return ` <header class="voxx voxx-header">
|
|
649
|
+
<a class="voxx-header__title" href="${escapeXml(base)}">${escapeXml(config.site.title)}</a>
|
|
650
|
+
${rss}
|
|
651
|
+
</header>`;
|
|
652
|
+
}
|
|
653
|
+
function metaLine(post, config) {
|
|
654
|
+
const rt = config.features.readingTime ? ` · ${post.readingTimeMinutes} min read` : "";
|
|
655
|
+
return `<time datetime="${escapeXml(post.date)}">${escapeXml(formatDate(post.date, config.site.locale))}</time>${escapeXml(rt)}`;
|
|
656
|
+
}
|
|
657
|
+
function tocAside(post) {
|
|
658
|
+
if (post.toc.length === 0) return "";
|
|
659
|
+
return ` <aside class="voxx-aside"><div class="voxx-aside__inner">
|
|
660
|
+
<nav class="voxx-toc" aria-label="On this page">
|
|
661
|
+
<p class="voxx-toc__title"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2.5 4h7M2.5 8h11M2.5 12h7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>On this page</p>
|
|
662
|
+
<ul class="voxx-toc__list">
|
|
663
|
+
${post.toc.map((t) => ` <li class="voxx-toc__item" data-depth="${t.depth}"><a href="#${escapeXml(t.id)}">${escapeXml(t.text)}</a></li>`).join("\n")}
|
|
664
|
+
</ul>
|
|
665
|
+
</nav>
|
|
666
|
+
</div></aside>`;
|
|
667
|
+
}
|
|
668
|
+
function indexBody(posts, config) {
|
|
669
|
+
const cards = posts.length === 0 ? `<p class="voxx-empty">No posts yet.</p>` : `<ul class="voxx-postlist">
|
|
670
|
+
${posts.map((post) => {
|
|
671
|
+
const tags = config.features.tags && post.tags.length ? `<ul class="voxx-tags">${post.tags.map((t) => `<li class="voxx-tag">${escapeXml(t)}</li>`).join("")}</ul>` : "";
|
|
672
|
+
const excerpt = post.excerpt ? `<p class="voxx-postcard__excerpt">${escapeXml(post.excerpt)}</p>` : "";
|
|
673
|
+
return ` <li class="voxx-postcard"><a class="voxx-postcard__link" href="${escapeXml(post.url)}">
|
|
674
|
+
<h2 class="voxx-postcard__title">${escapeXml(post.title)}</h2>
|
|
675
|
+
<p class="voxx-postcard__meta">${metaLine(post, config)}</p>
|
|
676
|
+
${excerpt}
|
|
677
|
+
${tags}
|
|
678
|
+
</a></li>`;
|
|
679
|
+
}).join("\n")}
|
|
680
|
+
</ul>`;
|
|
681
|
+
return ` <main class="voxx voxx-index">
|
|
682
|
+
<header class="voxx-index__header">
|
|
683
|
+
<h1>${escapeXml(config.site.title)}</h1>
|
|
684
|
+
${config.site.description ? `<p class="voxx-index__desc">${escapeXml(config.site.description)}</p>` : ""}
|
|
685
|
+
</header>
|
|
686
|
+
${cards}
|
|
687
|
+
</main>`;
|
|
688
|
+
}
|
|
689
|
+
function postBody(post, config) {
|
|
690
|
+
const aside = config.features.toc ? tocAside(post) : "";
|
|
691
|
+
return ` <main class="voxx voxx-layout">
|
|
692
|
+
<article class="voxx-article">
|
|
693
|
+
<header class="voxx-article__header">
|
|
694
|
+
<h1>${escapeXml(post.title)}</h1>
|
|
695
|
+
<p class="voxx-article__meta">${metaLine(post, config)}</p>
|
|
696
|
+
</header>
|
|
697
|
+
<div class="voxx-prose">${post.html}</div>
|
|
698
|
+
</article>
|
|
699
|
+
${aside}
|
|
700
|
+
</main>`;
|
|
701
|
+
}
|
|
702
|
+
function navHtml(items, activeUrl) {
|
|
703
|
+
if (items.length === 0) return "";
|
|
704
|
+
return `<ul class="voxx-nav__list">\n${items.map((item) => {
|
|
705
|
+
return `<li>${item.url ? `<a class="voxx-nav__link" href="${escapeXml(item.url)}"${item.url === activeUrl ? " data-active=\"true\"" : ""}>${escapeXml(item.title)}</a>` : `<span class="voxx-nav__section">${escapeXml(item.title)}</span>`}${navHtml(item.children, activeUrl)}</li>`;
|
|
706
|
+
}).join("\n")}\n</ul>`;
|
|
707
|
+
}
|
|
708
|
+
function pagerHtml(prev, next) {
|
|
709
|
+
if (!prev && !next) return "";
|
|
710
|
+
const link = (post, label, cls) => `<a class="voxx-pager__link${cls}" href="${escapeXml(post.url)}"><span class="voxx-pager__label">${label}</span><span class="voxx-pager__title">${escapeXml(post.title)}</span></a>`;
|
|
711
|
+
return ` <nav class="voxx-pager">
|
|
712
|
+
${prev ? link(prev, "Previous", "") : "<span></span>"}
|
|
713
|
+
${next ? link(next, "Next", " voxx-pager__link--next") : "<span></span>"}
|
|
714
|
+
</nav>`;
|
|
715
|
+
}
|
|
716
|
+
function docsSidebar(nav, activeUrl, config) {
|
|
717
|
+
const base = config.content.basePath || "/";
|
|
718
|
+
return `<aside class="voxx-docs__nav"><div class="voxx-docs__nav-inner">
|
|
719
|
+
<div class="voxx-docs__nav-header">
|
|
720
|
+
<details class="voxx-docs__menu"><summary aria-label="Navigation"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg></summary>
|
|
721
|
+
<div class="voxx-docs__menu-panel"><nav class="voxx-nav">${navHtml(nav, activeUrl)}</nav></div>
|
|
722
|
+
</details>
|
|
723
|
+
<a class="voxx-docs__title" href="${escapeXml(base)}">${escapeXml(config.site.title)}</a>
|
|
724
|
+
</div>
|
|
725
|
+
<nav class="voxx-nav">${navHtml(nav, activeUrl)}</nav>
|
|
726
|
+
</div></aside>`;
|
|
727
|
+
}
|
|
728
|
+
function docBody(post, posts, index, nav, config) {
|
|
729
|
+
const prev = index > 0 ? posts[index - 1] : void 0;
|
|
730
|
+
const next = posts[index + 1];
|
|
731
|
+
const aside = config.features.toc ? tocAside(post) : "";
|
|
732
|
+
const desc = post.description ? `<p class="voxx-article__meta">${escapeXml(post.description)}</p>` : "";
|
|
733
|
+
return ` <div class="voxx voxx-docs">
|
|
734
|
+
${docsSidebar(nav, post.url, config)}
|
|
735
|
+
<main class="voxx-layout">
|
|
736
|
+
<article class="voxx-article">
|
|
737
|
+
<header class="voxx-article__header">
|
|
738
|
+
<h1>${escapeXml(post.title)}</h1>
|
|
739
|
+
${desc}
|
|
740
|
+
</header>
|
|
741
|
+
<div class="voxx-prose">${post.html}</div>
|
|
742
|
+
${pagerHtml(prev, next)}
|
|
743
|
+
</article>
|
|
744
|
+
${aside}
|
|
745
|
+
</main>
|
|
746
|
+
</div>`;
|
|
747
|
+
}
|
|
748
|
+
function docsIndexBody(nav, config) {
|
|
749
|
+
return ` <div class="voxx voxx-docs">
|
|
750
|
+
${docsSidebar(nav, "", config)}
|
|
751
|
+
<main class="voxx-layout">
|
|
752
|
+
<article class="voxx-article">
|
|
753
|
+
<header class="voxx-article__header">
|
|
754
|
+
<h1>${escapeXml(config.site.title)}</h1>
|
|
755
|
+
${config.site.description ? `<p class="voxx-article__meta">${escapeXml(config.site.description)}</p>` : ""}
|
|
756
|
+
</header>
|
|
757
|
+
<div class="voxx-prose">${navHtml(nav, "")}</div>
|
|
758
|
+
</article>
|
|
759
|
+
</main>
|
|
760
|
+
</div>`;
|
|
761
|
+
}
|
|
762
|
+
function changelogBody(posts, config) {
|
|
763
|
+
const releases = posts.length === 0 ? `<p class="voxx-empty">No releases yet.</p>` : `<div class="voxx-releases">
|
|
764
|
+
${posts.map((post) => ` <section class="voxx-release" id="${escapeXml(post.slug)}">
|
|
765
|
+
<header class="voxx-release__header">
|
|
766
|
+
<h2 class="voxx-release__version"><a href="#${escapeXml(post.slug)}">${escapeXml(post.version ? `v${post.version}` : post.title)}</a></h2>
|
|
767
|
+
<time datetime="${escapeXml(post.date)}">${escapeXml(formatDate(post.date, config.site.locale))}</time>
|
|
768
|
+
</header>
|
|
769
|
+
<div class="voxx-prose">${post.html}</div>
|
|
770
|
+
</section>`).join("\n")}
|
|
771
|
+
</div>`;
|
|
772
|
+
return ` <main class="voxx voxx-index">
|
|
773
|
+
<header class="voxx-index__header">
|
|
774
|
+
<h1>${escapeXml(config.site.title)}</h1>
|
|
775
|
+
${config.site.description ? `<p class="voxx-index__desc">${escapeXml(config.site.description)}</p>` : ""}
|
|
776
|
+
</header>
|
|
777
|
+
${releases}
|
|
778
|
+
</main>`;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Rewrite root-absolute internal links (`href`/`src` beginning with a single
|
|
782
|
+
* `/`) to paths relative to the page's own location. This keeps navigation and
|
|
783
|
+
* asset links working whether the generated site is served from the domain
|
|
784
|
+
* root, a subpath, or opened directly from the filesystem. Protocol-relative
|
|
785
|
+
* (`//host`) and external URLs are left untouched.
|
|
786
|
+
*/
|
|
787
|
+
function relativizeLinks(html, fromDir, outDir) {
|
|
788
|
+
const prefix = relative(fromDir, outDir).split(sep).join("/") || ".";
|
|
789
|
+
return html.replace(/\b(href|src)="\/(?!\/)([^"]*)"/g, `$1="${prefix}/$2"`);
|
|
790
|
+
}
|
|
791
|
+
async function writePage(path, html, outDir) {
|
|
792
|
+
await mkdir(dirname(path), { recursive: true });
|
|
793
|
+
await writeFile(path, path.endsWith(".html") ? relativizeLinks(html, dirname(path), outDir) : html);
|
|
794
|
+
}
|
|
795
|
+
const SOURCE_RE = /\.mdx?$/;
|
|
796
|
+
async function copyContentAssets(contentDir, targetDir) {
|
|
797
|
+
let copied = 0;
|
|
798
|
+
let entries;
|
|
799
|
+
try {
|
|
800
|
+
entries = await readdir(contentDir, {
|
|
801
|
+
recursive: true,
|
|
802
|
+
withFileTypes: true
|
|
803
|
+
});
|
|
804
|
+
} catch {
|
|
805
|
+
return 0;
|
|
806
|
+
}
|
|
807
|
+
for (const entry of entries) {
|
|
808
|
+
if (!entry.isFile()) continue;
|
|
809
|
+
const name = entry.name;
|
|
810
|
+
if (SOURCE_RE.test(name) || name.startsWith(".")) continue;
|
|
811
|
+
const rel = relative(contentDir, join(entry.parentPath, name));
|
|
812
|
+
const source = join(contentDir, rel);
|
|
813
|
+
const target = join(targetDir, rel);
|
|
814
|
+
await mkdir(dirname(target), { recursive: true });
|
|
815
|
+
await copyFile(source, target);
|
|
816
|
+
copied++;
|
|
817
|
+
}
|
|
818
|
+
return copied;
|
|
819
|
+
}
|
|
820
|
+
async function buildCollection(config, posts, outDir) {
|
|
821
|
+
const type = config.content.type;
|
|
822
|
+
const indexPath = join(outDir, stripLead(config.content.basePath), "index.html");
|
|
823
|
+
if (type === "changelog") await writePage(indexPath, shell({
|
|
824
|
+
title: config.site.title,
|
|
825
|
+
lang: config.site.locale,
|
|
826
|
+
head: `<meta name="description" content="${escapeXml(config.site.description)}">`,
|
|
827
|
+
body: `${siteHeader(config)}\n${changelogBody(posts, config)}`
|
|
828
|
+
}), outDir);
|
|
829
|
+
else if (type === "docs") {
|
|
830
|
+
const nav = buildNavTree(posts);
|
|
831
|
+
for (let i = 0; i < posts.length; i++) {
|
|
832
|
+
const post = posts[i];
|
|
833
|
+
const seo = buildSeo(post, config);
|
|
834
|
+
await writePage(join(outDir, stripLead(post.url), "index.html"), shell({
|
|
835
|
+
title: `${post.title} — ${config.site.title}`,
|
|
836
|
+
lang: config.site.locale,
|
|
837
|
+
head: headTags(seo, config),
|
|
838
|
+
body: docBody(post, posts, i, nav, config)
|
|
839
|
+
}), outDir);
|
|
840
|
+
}
|
|
841
|
+
if (!posts.some((p) => p.path.length === 0)) await writePage(indexPath, shell({
|
|
842
|
+
title: config.site.title,
|
|
843
|
+
lang: config.site.locale,
|
|
844
|
+
head: `<meta name="description" content="${escapeXml(config.site.description)}">`,
|
|
845
|
+
body: docsIndexBody(nav, config)
|
|
846
|
+
}), outDir);
|
|
847
|
+
} else {
|
|
848
|
+
await writePage(indexPath, shell({
|
|
849
|
+
title: config.site.title,
|
|
850
|
+
lang: config.site.locale,
|
|
851
|
+
head: `<meta name="description" content="${escapeXml(config.site.description)}">`,
|
|
852
|
+
body: `${siteHeader(config)}\n${indexBody(posts, config)}`
|
|
853
|
+
}), outDir);
|
|
854
|
+
for (const post of posts) {
|
|
855
|
+
const seo = buildSeo(post, config);
|
|
856
|
+
await writePage(join(outDir, stripLead(post.url), "index.html"), shell({
|
|
857
|
+
title: `${post.title} — ${config.site.title}`,
|
|
858
|
+
lang: config.site.locale,
|
|
859
|
+
head: headTags(seo, config),
|
|
860
|
+
body: `${siteHeader(config)}\n${postBody(post, config)}`
|
|
861
|
+
}), outDir);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
async function buildSite(opts) {
|
|
866
|
+
const { cwd, outDir } = opts;
|
|
867
|
+
const config = await loadConfig({ cwd });
|
|
868
|
+
const collections = config.collections;
|
|
869
|
+
const multi = collections.length > 1;
|
|
870
|
+
await mkdir(join(outDir, "_voxx"), { recursive: true });
|
|
871
|
+
await copyFile(resolveCoreAsset("theme/voxx.css"), join(outDir, "_voxx", "voxx.css"));
|
|
872
|
+
await copyFile(resolveCoreAsset("theme/demo-globals.css"), join(outDir, "_voxx", "voxx-globals.css"));
|
|
873
|
+
const allPosts = [];
|
|
874
|
+
const sections = [];
|
|
875
|
+
let pageCount = 0;
|
|
876
|
+
for (const collection of collections) {
|
|
877
|
+
const view = {
|
|
878
|
+
...config,
|
|
879
|
+
content: { ...collection }
|
|
880
|
+
};
|
|
881
|
+
const posts = await getPosts({
|
|
882
|
+
config: view,
|
|
883
|
+
includeDrafts: opts.includeDrafts
|
|
884
|
+
});
|
|
885
|
+
await buildCollection(view, posts, outDir);
|
|
886
|
+
const assetTarget = join(outDir, stripLead(collection.basePath));
|
|
887
|
+
await copyContentAssets(collection.dir, assetTarget);
|
|
888
|
+
if (config.features.rss && isFeedType(collection)) await writePage(join(outDir, stripLead(rssPath(view))), renderRss(posts, view), outDir);
|
|
889
|
+
sections.push({
|
|
890
|
+
heading: multi ? humanize(collection.name) : sectionHeading(collection.type),
|
|
891
|
+
posts
|
|
892
|
+
});
|
|
893
|
+
allPosts.push(...posts);
|
|
894
|
+
pageCount += posts.length;
|
|
895
|
+
}
|
|
896
|
+
if (config.features.sitemap) {
|
|
897
|
+
await writeFile(join(outDir, "sitemap.xml"), renderSitemap(allPosts, config, { indexPaths: collections.map((col) => col.basePath) }));
|
|
898
|
+
await writeFile(join(outDir, "robots.txt"), renderRobotsTxt(config));
|
|
899
|
+
}
|
|
900
|
+
if (config.features.llmsTxt) {
|
|
901
|
+
await writeFile(join(outDir, "llms.txt"), renderLlmsTxtSections(sections, config));
|
|
902
|
+
await writeFile(join(outDir, "llms-full.txt"), renderLlmsFull(allPosts, config));
|
|
903
|
+
}
|
|
904
|
+
return {
|
|
905
|
+
config,
|
|
906
|
+
pageCount,
|
|
907
|
+
collectionCount: collections.length,
|
|
908
|
+
indexRel: join(relative(cwd, outDir), stripLead(collections[0].basePath), "index.html")
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
function isFeedType(collection) {
|
|
912
|
+
return collection.type === "blog" || collection.type === "changelog";
|
|
913
|
+
}
|
|
914
|
+
async function build(argv) {
|
|
915
|
+
const { values } = parseArgs({
|
|
916
|
+
args: argv,
|
|
917
|
+
options: {
|
|
918
|
+
out: { type: "string" },
|
|
919
|
+
drafts: { type: "boolean" }
|
|
920
|
+
},
|
|
921
|
+
allowPositionals: true
|
|
922
|
+
});
|
|
923
|
+
const cwd = process.cwd();
|
|
924
|
+
if (!await exists(join(cwd, "voxx.json"))) {
|
|
925
|
+
log.error("No voxx.json found. Run `voxx init` first.");
|
|
926
|
+
process.exitCode = 1;
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const outDir = join(cwd, values.out ?? "dist");
|
|
930
|
+
const result = await buildSite({
|
|
931
|
+
cwd,
|
|
932
|
+
outDir,
|
|
933
|
+
includeDrafts: values.drafts ? true : void 0
|
|
934
|
+
});
|
|
935
|
+
const { config } = result;
|
|
936
|
+
if (!config.site.url || /example\.com/.test(config.site.url)) log.warn(`site.url is ${config.site.url ? `"${config.site.url}"` : "empty"} — feeds, sitemap, and SEO tags need the real production URL in voxx.json.`);
|
|
937
|
+
const type = config.content.type;
|
|
938
|
+
const noun = type === "changelog" ? "release" : type === "docs" ? "page" : "post";
|
|
939
|
+
const what = result.collectionCount > 1 ? `${result.pageCount} pages across ${result.collectionCount} collections` : `${result.pageCount} ${noun}${result.pageCount === 1 ? "" : "s"}`;
|
|
940
|
+
log.success(`Built ${what} → ${relative(cwd, outDir)}/`);
|
|
941
|
+
log.info(` Open ${c.cyan(result.indexRel)} in a browser, or run ${c.cyan("voxx dev")} to preview with a local server.`);
|
|
942
|
+
}
|
|
943
|
+
//#endregion
|
|
944
|
+
//#region src/commands/dev.ts
|
|
945
|
+
const MIME = {
|
|
946
|
+
".html": "text/html; charset=utf-8",
|
|
947
|
+
".css": "text/css; charset=utf-8",
|
|
948
|
+
".js": "text/javascript; charset=utf-8",
|
|
949
|
+
".json": "application/json; charset=utf-8",
|
|
950
|
+
".xml": "application/xml; charset=utf-8",
|
|
951
|
+
".txt": "text/plain; charset=utf-8",
|
|
952
|
+
".svg": "image/svg+xml",
|
|
953
|
+
".png": "image/png",
|
|
954
|
+
".jpg": "image/jpeg",
|
|
955
|
+
".jpeg": "image/jpeg",
|
|
956
|
+
".gif": "image/gif",
|
|
957
|
+
".webp": "image/webp",
|
|
958
|
+
".avif": "image/avif",
|
|
959
|
+
".ico": "image/x-icon",
|
|
960
|
+
".woff": "font/woff",
|
|
961
|
+
".woff2": "font/woff2",
|
|
962
|
+
".mp4": "video/mp4",
|
|
963
|
+
".webm": "video/webm",
|
|
964
|
+
".pdf": "application/pdf"
|
|
965
|
+
};
|
|
966
|
+
async function serveFile(outDir, urlPath) {
|
|
967
|
+
const safe = normalize(decodeURIComponent(urlPath.split("?")[0] ?? "/")).replace(/^(\.\.[\\/])+/, "");
|
|
968
|
+
const candidates = safe.endsWith("/") ? [join(safe, "index.html")] : [safe, join(safe, "index.html")];
|
|
969
|
+
for (const rel of candidates) {
|
|
970
|
+
const path = join(outDir, rel);
|
|
971
|
+
if (!path.startsWith(outDir)) continue;
|
|
972
|
+
try {
|
|
973
|
+
const body = await readFile(path);
|
|
974
|
+
return {
|
|
975
|
+
status: 200,
|
|
976
|
+
type: MIME[extname(path).toLowerCase()] ?? "application/octet-stream",
|
|
977
|
+
body
|
|
978
|
+
};
|
|
979
|
+
} catch {}
|
|
980
|
+
}
|
|
981
|
+
return {
|
|
982
|
+
status: 404,
|
|
983
|
+
type: "text/html; charset=utf-8",
|
|
984
|
+
body: Buffer.from("<h1>404</h1><p>Not found.</p>")
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
async function dev(argv) {
|
|
988
|
+
const { values } = parseArgs({
|
|
989
|
+
args: argv,
|
|
990
|
+
options: {
|
|
991
|
+
port: { type: "string" },
|
|
992
|
+
drafts: { type: "boolean" }
|
|
993
|
+
},
|
|
994
|
+
allowPositionals: true
|
|
995
|
+
});
|
|
996
|
+
const cwd = process.cwd();
|
|
997
|
+
if (!await exists(join(cwd, "voxx.json"))) {
|
|
998
|
+
log.error("No voxx.json found. Run `voxx init` first.");
|
|
999
|
+
process.exitCode = 1;
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const port = Number(values.port ?? 4321);
|
|
1003
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
1004
|
+
log.error(`Invalid port "${values.port}".`);
|
|
1005
|
+
process.exitCode = 1;
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
const includeDrafts = values.drafts ?? true;
|
|
1009
|
+
const outDir = await mkdtemp(join(tmpdir(), "voxx-dev-"));
|
|
1010
|
+
let building = Promise.resolve();
|
|
1011
|
+
let dirty = false;
|
|
1012
|
+
const rebuild = (label) => {
|
|
1013
|
+
building = building.then(async () => {
|
|
1014
|
+
try {
|
|
1015
|
+
const started = Date.now();
|
|
1016
|
+
const result = await buildSite({
|
|
1017
|
+
cwd,
|
|
1018
|
+
outDir,
|
|
1019
|
+
includeDrafts
|
|
1020
|
+
});
|
|
1021
|
+
log.success(`${label} — ${result.pageCount} pages in ${Date.now() - started}ms`);
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
return building;
|
|
1027
|
+
};
|
|
1028
|
+
await rebuild("Built");
|
|
1029
|
+
const watchers = [];
|
|
1030
|
+
const watchPath = (path, recursive) => {
|
|
1031
|
+
try {
|
|
1032
|
+
const watcher = watch(path, { recursive }, () => {
|
|
1033
|
+
if (dirty) return;
|
|
1034
|
+
dirty = true;
|
|
1035
|
+
setTimeout(() => {
|
|
1036
|
+
dirty = false;
|
|
1037
|
+
rebuild("Rebuilt");
|
|
1038
|
+
}, 150);
|
|
1039
|
+
});
|
|
1040
|
+
watchers.push(watcher);
|
|
1041
|
+
} catch {
|
|
1042
|
+
log.warn(`Could not watch ${path}.`);
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
const config = await loadConfig({ cwd });
|
|
1046
|
+
watchPath(join(cwd, "voxx.json"), false);
|
|
1047
|
+
for (const collection of config.collections) if (await exists(collection.dir)) watchPath(collection.dir, true);
|
|
1048
|
+
const server = createServer((req, res) => {
|
|
1049
|
+
building.then(() => serveFile(outDir, req.url ?? "/")).then(({ status, type, body }) => {
|
|
1050
|
+
res.writeHead(status, {
|
|
1051
|
+
"Content-Type": type,
|
|
1052
|
+
"Cache-Control": "no-store"
|
|
1053
|
+
});
|
|
1054
|
+
res.end(body);
|
|
1055
|
+
}).catch((err) => {
|
|
1056
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
1057
|
+
if (!res.headersSent) res.writeHead(500, { "Content-Type": "text/plain" });
|
|
1058
|
+
res.end("Internal error");
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
await new Promise((resolve, reject) => {
|
|
1062
|
+
server.once("error", reject);
|
|
1063
|
+
server.listen(port, resolve);
|
|
1064
|
+
}).catch((err) => {
|
|
1065
|
+
log.error(err.code === "EADDRINUSE" ? `Port ${port} is in use — pass --port <n>.` : err.message);
|
|
1066
|
+
process.exitCode = 1;
|
|
1067
|
+
});
|
|
1068
|
+
if (process.exitCode === 1) return;
|
|
1069
|
+
const base = config.collections[0].basePath;
|
|
1070
|
+
log.info("");
|
|
1071
|
+
log.success(`voxx dev serving ${c.cyan(`http://localhost:${port}${base}`)}`);
|
|
1072
|
+
log.info(` Watching content for changes${includeDrafts ? " (drafts included)" : ""}. Press Ctrl+C to stop.`);
|
|
1073
|
+
const close = () => new Promise((resolve) => {
|
|
1074
|
+
for (const watcher of watchers) watcher.close();
|
|
1075
|
+
server.close(() => {
|
|
1076
|
+
rm(outDir, {
|
|
1077
|
+
recursive: true,
|
|
1078
|
+
force: true
|
|
1079
|
+
}).finally(resolve);
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
const shutdown = () => {
|
|
1083
|
+
close().then(() => process.exit(0));
|
|
1084
|
+
};
|
|
1085
|
+
process.on("SIGINT", shutdown);
|
|
1086
|
+
process.on("SIGTERM", shutdown);
|
|
1087
|
+
return {
|
|
1088
|
+
port,
|
|
1089
|
+
close
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
//#endregion
|
|
1093
|
+
//#region src/commands/new.ts
|
|
1094
|
+
const MD_RE = /\.md$/;
|
|
1095
|
+
async function readContentConfig(cwd, collectionName) {
|
|
1096
|
+
const cfgPath = join(cwd, "voxx.json");
|
|
1097
|
+
if (!await exists(cfgPath)) return {
|
|
1098
|
+
type: "blog",
|
|
1099
|
+
dir: "content"
|
|
1100
|
+
};
|
|
1101
|
+
const cfg = JSON.parse(await readFile(cfgPath, "utf8"));
|
|
1102
|
+
const collections = (cfg.collections ?? []).map(resolveCollectionDefaults);
|
|
1103
|
+
if (collectionName) {
|
|
1104
|
+
const found = collections.find((c) => c.name === collectionName);
|
|
1105
|
+
if (!found) {
|
|
1106
|
+
log.error(`Unknown collection "${collectionName}" — defined: ${collections.map((c) => c.name).join(", ")}`);
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
return found;
|
|
1110
|
+
}
|
|
1111
|
+
const first = collections[0] ?? {
|
|
1112
|
+
type: cfg.content?.type ?? "blog",
|
|
1113
|
+
dir: cfg.content?.dir ?? "content"
|
|
1114
|
+
};
|
|
1115
|
+
return {
|
|
1116
|
+
type: first.type,
|
|
1117
|
+
dir: first.dir
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
async function existingSlugs(dir) {
|
|
1121
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
1122
|
+
try {
|
|
1123
|
+
for (const entry of await readdir(dir)) {
|
|
1124
|
+
if (!MD_RE.test(entry)) continue;
|
|
1125
|
+
const { rest } = splitDatePrefix(entry.replace(MD_RE, ""));
|
|
1126
|
+
slugs.add(slugify(splitOrderPrefix(rest).rest));
|
|
1127
|
+
}
|
|
1128
|
+
} catch {}
|
|
1129
|
+
return slugs;
|
|
1130
|
+
}
|
|
1131
|
+
function suggestSlug(base, taken) {
|
|
1132
|
+
let slug = base;
|
|
1133
|
+
for (let n = 2; taken.has(slug); n++) slug = `${base}-${n}`;
|
|
1134
|
+
return slug;
|
|
1135
|
+
}
|
|
1136
|
+
async function resolveSlug(base, taken) {
|
|
1137
|
+
if (!taken.has(base)) return base;
|
|
1138
|
+
let suggestion = suggestSlug(base, taken);
|
|
1139
|
+
if (!process.stdin.isTTY) {
|
|
1140
|
+
log.warn(`Slug "${base}" is taken — using "${suggestion}".`);
|
|
1141
|
+
return suggestion;
|
|
1142
|
+
}
|
|
1143
|
+
const rl = createInterface({
|
|
1144
|
+
input: process.stdin,
|
|
1145
|
+
output: process.stdout
|
|
1146
|
+
});
|
|
1147
|
+
try {
|
|
1148
|
+
let takenName = base;
|
|
1149
|
+
for (;;) {
|
|
1150
|
+
const answer = (await rl.question(`Slug "${takenName}" is taken. Enter a new slug, or press Enter for "${suggestion}": `)).trim();
|
|
1151
|
+
if (!answer) return suggestion;
|
|
1152
|
+
const candidate = slugify(answer);
|
|
1153
|
+
if (!candidate) {
|
|
1154
|
+
log.warn("Please enter a valid slug.");
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
if (!taken.has(candidate)) return candidate;
|
|
1158
|
+
log.warn(`"${candidate}" is also taken.`);
|
|
1159
|
+
takenName = candidate;
|
|
1160
|
+
suggestion = suggestSlug(candidate, taken);
|
|
1161
|
+
}
|
|
1162
|
+
} finally {
|
|
1163
|
+
rl.close();
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
async function newPost(argv) {
|
|
1167
|
+
const { values, positionals } = parseArgs({
|
|
1168
|
+
args: argv,
|
|
1169
|
+
options: {
|
|
1170
|
+
dir: { type: "string" },
|
|
1171
|
+
date: { type: "string" },
|
|
1172
|
+
slug: { type: "string" },
|
|
1173
|
+
flat: { type: "boolean" },
|
|
1174
|
+
section: { type: "string" },
|
|
1175
|
+
order: { type: "string" },
|
|
1176
|
+
collection: { type: "string" },
|
|
1177
|
+
index: { type: "boolean" }
|
|
1178
|
+
},
|
|
1179
|
+
allowPositionals: true
|
|
1180
|
+
});
|
|
1181
|
+
const title = positionals.join(" ").trim();
|
|
1182
|
+
if (!title) {
|
|
1183
|
+
log.error("Usage: voxx new \"My Post Title\"");
|
|
1184
|
+
process.exitCode = 1;
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
const cwd = process.cwd();
|
|
1188
|
+
const detected = await readContentConfig(cwd, values.collection);
|
|
1189
|
+
if (!detected) {
|
|
1190
|
+
process.exitCode = 1;
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
const contentDir = values.dir ?? detected.dir;
|
|
1194
|
+
if (!isSafeRelPath(contentDir)) {
|
|
1195
|
+
log.error(`Invalid --dir "${contentDir}" — must stay within the project (no "..").`);
|
|
1196
|
+
process.exitCode = 1;
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (values.index && detected.type !== "docs") {
|
|
1200
|
+
log.error("--index only applies to docs content.");
|
|
1201
|
+
process.exitCode = 1;
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
const date = values.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1205
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
1206
|
+
log.error(`Invalid --date "${values.date}" — expected YYYY-MM-DD.`);
|
|
1207
|
+
process.exitCode = 1;
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
if (detected.type === "changelog") {
|
|
1211
|
+
const version = parseVersion(title) ?? title;
|
|
1212
|
+
if (!isSafeRelPath(`${version}.md`) || version.includes("/") || version.includes("\\")) {
|
|
1213
|
+
log.error(`Invalid changelog name "${title}" — use a version like "1.2.0".`);
|
|
1214
|
+
process.exitCode = 1;
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const target = join(cwd, contentDir, `${version}.md`);
|
|
1218
|
+
if (await writeFileSafe(target, [
|
|
1219
|
+
"---",
|
|
1220
|
+
`title: ${yamlValue(`v${version}`)}`,
|
|
1221
|
+
`version: ${yamlValue(version)}`,
|
|
1222
|
+
`date: ${date}`,
|
|
1223
|
+
"---",
|
|
1224
|
+
"",
|
|
1225
|
+
"### Added",
|
|
1226
|
+
"",
|
|
1227
|
+
"- ",
|
|
1228
|
+
""
|
|
1229
|
+
].join("\n"), false) === "skipped") log.warn(`${relative(cwd, target)} already exists — left untouched.`);
|
|
1230
|
+
else log.success(`Created ${relative(cwd, target)}`);
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
const baseSlug = slugify(values.slug ?? title);
|
|
1234
|
+
if (!baseSlug) {
|
|
1235
|
+
log.error("Could not derive a slug — pass --slug.");
|
|
1236
|
+
process.exitCode = 1;
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
if (detected.type === "docs") {
|
|
1240
|
+
if (values.section && !isSafeRelPath(values.section)) {
|
|
1241
|
+
log.error(`Invalid --section "${values.section}" — must stay within the content directory (no "..").`);
|
|
1242
|
+
process.exitCode = 1;
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
const section = (values.section ?? "").split("/").filter(Boolean).join("/");
|
|
1246
|
+
const targetDir = section ? join(cwd, contentDir, section) : join(cwd, contentDir);
|
|
1247
|
+
if (values.index) {
|
|
1248
|
+
const target = join(targetDir, "index.md");
|
|
1249
|
+
if (await writeFileSafe(target, [
|
|
1250
|
+
"---",
|
|
1251
|
+
`title: ${yamlValue(title)}`,
|
|
1252
|
+
"description: ",
|
|
1253
|
+
"---",
|
|
1254
|
+
"",
|
|
1255
|
+
"Write your section landing page here.",
|
|
1256
|
+
""
|
|
1257
|
+
].join("\n"), false) === "skipped") log.warn(`${relative(cwd, target)} already exists — left untouched.`);
|
|
1258
|
+
else log.success(`Created ${relative(cwd, target)}`);
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
const slug = await resolveSlug(baseSlug, await existingSlugs(targetDir));
|
|
1262
|
+
const order = values.order ? Number(values.order) : void 0;
|
|
1263
|
+
const target = join(targetDir, order !== void 0 && Number.isFinite(order) ? `${String(order).padStart(2, "0")}-${slug}.md` : `${slug}.md`);
|
|
1264
|
+
if (await writeFileSafe(target, [
|
|
1265
|
+
"---",
|
|
1266
|
+
`title: ${yamlValue(title)}`,
|
|
1267
|
+
"description: ",
|
|
1268
|
+
"---",
|
|
1269
|
+
"",
|
|
1270
|
+
"Write your page here.",
|
|
1271
|
+
""
|
|
1272
|
+
].join("\n"), false) === "skipped") log.warn(`${relative(cwd, target)} already exists — left untouched.`);
|
|
1273
|
+
else log.success(`Created ${relative(cwd, target)}`);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const slug = await resolveSlug(baseSlug, await existingSlugs(join(cwd, contentDir)));
|
|
1277
|
+
const target = join(cwd, contentDir, values.flat ? `${slug}.md` : `${date}-${slug}.md`);
|
|
1278
|
+
if (await writeFileSafe(target, [
|
|
1279
|
+
"---",
|
|
1280
|
+
`title: ${yamlValue(title)}`,
|
|
1281
|
+
"description: ",
|
|
1282
|
+
`date: ${date}`,
|
|
1283
|
+
`slug: ${slug}`,
|
|
1284
|
+
"tags: []",
|
|
1285
|
+
"---",
|
|
1286
|
+
"",
|
|
1287
|
+
"Write your post here.",
|
|
1288
|
+
""
|
|
1289
|
+
].join("\n"), false) === "skipped") log.warn(`${relative(cwd, target)} already exists — left untouched.`);
|
|
1290
|
+
else log.success(`Created ${relative(cwd, target)}`);
|
|
1291
|
+
}
|
|
1292
|
+
//#endregion
|
|
1293
|
+
//#region src/index.ts
|
|
1294
|
+
const HELP = `${c.bold("voxx")} — a zero-friction, file-based CMS for blogs and docs
|
|
1295
|
+
|
|
1296
|
+
${c.bold("Usage:")}
|
|
1297
|
+
voxx init [blog|docs|changelog] [--dir <content>] [--base <path>] [--app <dir>] [--force]
|
|
1298
|
+
voxx init --add [blog|docs|changelog] [--name <name>] [--dir <dir>] [--base <path>] [--app <dir>]
|
|
1299
|
+
voxx new "Title" [--collection <name>] [--slug <slug>] [--dir <content>] [--date <YYYY-MM-DD>] [--flat] [--section <path>] [--order <n>] [--index]
|
|
1300
|
+
voxx build [--out <dir>] [--drafts]
|
|
1301
|
+
voxx dev [--port <n>] [--drafts]
|
|
1302
|
+
|
|
1303
|
+
${c.bold("Examples:")}
|
|
1304
|
+
voxx init Scaffold a blog into your Next.js app
|
|
1305
|
+
voxx init docs Scaffold a docs site instead
|
|
1306
|
+
voxx init changelog Scaffold a release-notes page
|
|
1307
|
+
voxx init --add docs Add a docs collection to an existing site
|
|
1308
|
+
voxx new "Hello world" Create a new markdown post (or doc page, or release)
|
|
1309
|
+
voxx new "Guides" --section guides --index Create a docs section's index.md landing page
|
|
1310
|
+
voxx build Render a static HTML site to ./dist
|
|
1311
|
+
voxx dev Preview the static site locally, rebuilding on change
|
|
1312
|
+
`;
|
|
1313
|
+
async function main() {
|
|
1314
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
1315
|
+
switch (cmd) {
|
|
1316
|
+
case "init":
|
|
1317
|
+
await init(rest);
|
|
1318
|
+
break;
|
|
1319
|
+
case "new":
|
|
1320
|
+
await newPost(rest);
|
|
1321
|
+
break;
|
|
1322
|
+
case "build":
|
|
1323
|
+
await build(rest);
|
|
1324
|
+
break;
|
|
1325
|
+
case "dev":
|
|
1326
|
+
await dev(rest);
|
|
1327
|
+
break;
|
|
1328
|
+
case void 0:
|
|
1329
|
+
case "-h":
|
|
1330
|
+
case "--help":
|
|
1331
|
+
log.info(HELP);
|
|
1332
|
+
break;
|
|
1333
|
+
default:
|
|
1334
|
+
log.error(`Unknown command: ${cmd}`);
|
|
1335
|
+
log.info(HELP);
|
|
1336
|
+
process.exitCode = 1;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
main().catch((err) => {
|
|
1340
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
1341
|
+
process.exitCode = 1;
|
|
1342
|
+
});
|
|
1343
|
+
//#endregion
|
|
1344
|
+
export {};
|
|
1345
|
+
|
|
1346
|
+
//# sourceMappingURL=index.mjs.map
|