1import {
2 readdir,
3 readFile,
4 mkdir,
5 rm,
6 cp,
7 writeFile,
8 access,
9} from "node:fs/promises";
10import path from "node:path";
11import { execFile } from "node:child_process";
12import { promisify } from "node:util";
13
14const repoRoot = path.resolve(new URL("..", import.meta.url).pathname);
15const docsDir = path.join(repoRoot, "docs");
16const devlogDir = path.join(repoRoot, "devlog");
17const siteSrcDir = path.join(repoRoot, "site");
18const outDir = path.join(repoRoot, "site-out");
19const outDocsDir = path.join(outDir, "docs");
20
21const execFileAsync = promisify(execFile);
22
23async function exists(filePath) {
24 try {
25 await access(filePath);
26 return true;
27 } catch {
28 return false;
29 }
30}
31
32function isMarkdown(filePath) {
33 return filePath.toLowerCase().endsWith(".md");
34}
35
36async function listMarkdownFiles(dir, prefix = "") {
37 const entries = await readdir(dir, { withFileTypes: true });
38 const out = [];
39 for (const e of entries) {
40 if (e.name.startsWith(".")) continue;
41 const rel = path.join(prefix, e.name);
42 const abs = path.join(dir, e.name);
43 if (e.isDirectory()) {
44 out.push(...(await listMarkdownFiles(abs, rel)));
45 } else if (e.isFile() && isMarkdown(e.name)) {
46 out.push(rel.replaceAll(path.sep, "/"));
47 }
48 }
49 return out.sort((a, b) => a.localeCompare(b));
50}
51
52function titleFromMarkdown(md, fallback) {
53 const lines = md.split(/\r?\n/);
54 for (const line of lines) {
55 const m = /^#\s+(.+)\s*$/.exec(line);
56 if (m) return m[1].trim();
57 }
58 return fallback.replace(/\.md$/i, "");
59}
60
61function normalizeTitle(title) {
62 let t = String(title || "").trim();
63 // Strip markdown links: [text](url) -> text
64 t = t.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
65 // If pages follow a "zat - ..." style, drop the redundant prefix in the nav.
66 t = t.replace(/^zat\s*-\s*/i, "");
67 // Cheaply capitalize (keeps the rest as-authored).
68 if (t.length) t = t[0].toUpperCase() + t.slice(1);
69 return t;
70}
71
72async function getBuildId() {
73 try {
74 const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], {
75 cwd: repoRoot,
76 });
77 const full = String(stdout || "").trim();
78 if (full) return full.slice(0, 12);
79 } catch {
80 // ignore
81 }
82 return String(Date.now());
83}
84
85async function main() {
86 await rm(outDir, { recursive: true, force: true });
87 await mkdir(outDir, { recursive: true });
88
89 // Copy static site shell
90 await cp(siteSrcDir, outDir, { recursive: true });
91
92 // Cache-bust immutable assets on Wisp by appending a per-commit query string.
93 const buildId = await getBuildId();
94 const outIndex = path.join(outDir, "index.html");
95 if (await exists(outIndex)) {
96 let html = await readFile(outIndex, "utf8");
97 html = html.replaceAll('href="./style.css"', `href="./style.css?v=${buildId}"`);
98 html = html.replaceAll(
99 'src="./vendor/marked.min.js"',
100 `src="./vendor/marked.min.js?v=${buildId}"`,
101 );
102 html = html.replaceAll(
103 'src="./app.js"',
104 `src="./app.js?v=${buildId}"`,
105 );
106 html = html.replaceAll(
107 'href="./favicon.svg"',
108 `href="./favicon.svg?v=${buildId}"`,
109 );
110 await writeFile(outIndex, html, "utf8");
111 }
112
113 // Copy docs
114 await mkdir(outDocsDir, { recursive: true });
115
116 const pages = [];
117
118 // Prefer an explicit docs homepage if present; otherwise use repo README as index.
119 const docsIndex = path.join(docsDir, "index.md");
120 if (!(await exists(docsIndex))) {
121 const readme = path.join(repoRoot, "README.md");
122 if (await exists(readme)) {
123 let md = await readFile(readme, "utf8");
124 // Strip docs/ prefix from links since we're now inside the docs context.
125 md = md.replace(/\]\(docs\//g, "](");
126 await writeFile(path.join(outDocsDir, "index.md"), md, "utf8");
127 pages.push({
128 path: "index.md",
129 title: normalizeTitle(titleFromMarkdown(md, "index.md")),
130 });
131 }
132 }
133
134 const changelog = path.join(repoRoot, "CHANGELOG.md");
135 const docsChangelog = path.join(docsDir, "changelog.md");
136 if ((await exists(changelog)) && !(await exists(docsChangelog))) {
137 const md = await readFile(changelog, "utf8");
138 await writeFile(path.join(outDocsDir, "changelog.md"), md, "utf8");
139 pages.push({
140 path: "changelog.md",
141 title: normalizeTitle(titleFromMarkdown(md, "changelog.md")),
142 });
143 }
144
145 const mdFiles = (await exists(docsDir)) ? await listMarkdownFiles(docsDir) : [];
146
147 // Copy all markdown under docs/ (including archives), but only include non-archive
148 // paths in the sidebar manifest.
149 for (const rel of mdFiles) {
150 const src = path.join(docsDir, rel);
151 const dst = path.join(outDocsDir, rel);
152 await mkdir(path.dirname(dst), { recursive: true });
153 await cp(src, dst);
154
155 const md = await readFile(src, "utf8");
156 if (!rel.startsWith("archive/")) {
157 pages.push({ path: rel, title: normalizeTitle(titleFromMarkdown(md, rel)) });
158 }
159 }
160
161 // Copy devlog files to docs/devlog/ and generate an index
162 const devlogFiles = (await exists(devlogDir)) ? await listMarkdownFiles(devlogDir) : [];
163 const devlogEntries = [];
164
165 for (const rel of devlogFiles) {
166 const src = path.join(devlogDir, rel);
167 const dst = path.join(outDocsDir, "devlog", rel);
168 await mkdir(path.dirname(dst), { recursive: true });
169 await cp(src, dst);
170
171 const md = await readFile(src, "utf8");
172 devlogEntries.push({
173 path: `devlog/${rel}`,
174 title: titleFromMarkdown(md, rel),
175 });
176 }
177
178 // Generate devlog index listing all entries (newest first by filename)
179 if (devlogEntries.length > 0) {
180 devlogEntries.sort((a, b) => b.path.localeCompare(a.path));
181 const indexMd = [
182 "# devlog",
183 "",
184 ...devlogEntries.map((e) => `- [${e.title}](${e.path})`),
185 "",
186 ].join("\n");
187 await writeFile(path.join(outDocsDir, "devlog", "index.md"), indexMd, "utf8");
188 }
189
190 // Stable nav order: README homepage, then roadmap, then changelog, then the rest.
191 pages.sort((a, b) => {
192 const order = (p) => {
193 if (p === "index.md") return 0;
194 if (p === "roadmap.md") return 1;
195 if (p === "changelog.md") return 2;
196 return 3;
197 };
198 const ao = order(a.path);
199 const bo = order(b.path);
200 if (ao !== bo) return ao - bo;
201 return a.title.localeCompare(b.title);
202 });
203
204 await writeFile(
205 path.join(outDir, "manifest.json"),
206 JSON.stringify({ pages }, null, 2) + "\n",
207 "utf8",
208 );
209
210 process.stdout.write(
211 `Built Wisp docs site: ${pages.length} markdown file(s) -> ${outDir}\n`,
212 );
213}
214
215await main();