1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
|
/* Builds every published post into blog/<slug>/index.html, refreshes the blog
index inside index.html, emits blog.css / blog.js / feed.xml, and appends the
post-list rules to style.css.
Usage:
node _writing/build.mjs publish-ready output, drafts excluded
node _writing/build.mjs --drafts drafts included, into a scratch dir
that is never committed and so never
reaches the server
*/
import fs from "node:fs";
import path from "node:path";
import { renderPostPage, renderBlogIndex } from "./lib/template.mjs";
import { escapeHtml } from "./lib/markdown.mjs";
import { BLOG_CSS, INDEX_CSS } from "./lib/theme.mjs";
import { BLOG_JS } from "./lib/postjs.mjs";
import { loadPosts, ROOT_DIR, WRITING_DIR, BLOG_OUT_DIR } from "./lib/posts.mjs";
const SITE = "https://ericzou.dev";
const PREVIEW_DIR = path.join(WRITING_DIR, ".preview");
function replaceRegion(source, file, name, replacement) {
const start = `/* BLOG:${name}:START */`;
const end = `/* BLOG:${name}:END */`;
const htmlStart = `<!-- BLOG:${name}:START -->`;
const htmlEnd = `<!-- BLOG:${name}:END -->`;
const isHtml = source.includes(htmlStart);
const a = source.indexOf(isHtml ? htmlStart : start);
const b = source.indexOf(isHtml ? htmlEnd : end);
if (a === -1 || b === -1) {
throw new Error(`${file} is missing the BLOG:${name} markers`);
}
const head = a + (isHtml ? htmlStart : start).length;
// Keep whatever indentation the closing marker already had, so a rebuild
// doesn't reflow the surrounding file.
const lineStart = source.lastIndexOf("\n", b) + 1;
const indent = source.slice(lineStart, b);
return source.slice(0, head) + "\n" + replacement + "\n" + indent + source.slice(b);
}
/* Only the fields the page-side commands need. The payload ships inline in
every post page, so it stays small. */
const indexPayload = (posts) =>
posts.map((p) => ({ slug: p.slug, title: p.title, date: p.date, minutes: p.minutes }));
function rss(posts) {
const items = posts
.slice(0, 20)
.map((p) => {
const url = `${SITE}/blog/${p.slug}/`;
// RFC 822 at midnight UTC. Posts carry a date, not a time.
const pub = new Date(`${p.date}T00:00:00Z`).toUTCString();
return ` <item>
<title>${escapeHtml(p.title)}</title>
<link>${url}</link>
<guid isPermaLink="true">${url}</guid>
<pubDate>${pub}</pubDate>
<description>${escapeHtml(p.description)}</description>
</item>`;
})
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Eric Zou</title>
<link>${SITE}/</link>
<atom:link href="${SITE}/feed.xml" rel="self" type="application/rss+xml"/>
<description>Writing on agents, verification loops and shipping software.</description>
<language>en</language>
${items}
</channel>
</rss>
`;
}
function writePostPages(posts, outDir) {
const payload = indexPayload(posts);
fs.mkdirSync(outDir, { recursive: true });
const wanted = new Set(posts.map((p) => p.slug));
for (const entry of fs.readdirSync(outDir, { withFileTypes: true })) {
// Anything left from a renamed or deleted post has to go, or a stale page
// keeps serving at a URL the index no longer lists.
if (entry.isDirectory() && !wanted.has(entry.name)) {
fs.rmSync(path.join(outDir, entry.name), { recursive: true, force: true });
}
}
posts.forEach((post, i) => {
const html = renderPostPage({
post,
// Index order is newest first, so the next entry is the older post.
prev: posts[i + 1] || null,
next: posts[i - 1] || null,
index: payload,
isDraft: post.status === "draft",
});
const dir = path.join(outDir, post.slug);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "index.html"), html);
// The cover ships inside the post's own directory. A removed cover, or one
// swapped for another format, must not leave the old file serving.
const coverName = post.cover ? `cover.${post.cover.ext}` : null;
for (const f of fs.readdirSync(dir)) {
if (f.startsWith("cover.") && f !== coverName) fs.rmSync(path.join(dir, f));
}
if (post.cover) fs.copyFileSync(post.cover.file, path.join(dir, coverName));
});
}
function writeAssets(posts) {
fs.writeFileSync(path.join(ROOT_DIR, "blog.css"), BLOG_CSS);
fs.writeFileSync(path.join(ROOT_DIR, "blog.js"), BLOG_JS);
fs.writeFileSync(path.join(ROOT_DIR, "feed.xml"), rss(posts));
const styleFile = path.join(ROOT_DIR, "style.css");
fs.writeFileSync(styleFile, replaceRegion(fs.readFileSync(styleFile, "utf8"), "style.css", "INDEX", INDEX_CSS.trim()));
const indexFile = path.join(ROOT_DIR, "index.html");
let html = fs.readFileSync(indexFile, "utf8");
html = replaceRegion(html, "index.html", "INDEX", renderBlogIndex(posts));
html = replaceRegion(
html,
"index.html",
"DATA",
`\t\t<script id="blogData" type="application/json">${JSON.stringify(indexPayload(posts))}</script>`
);
fs.writeFileSync(indexFile, html);
}
function main() {
const drafts = process.argv.includes("--drafts");
const posts = loadPosts({ includeDrafts: drafts });
const outDir = drafts ? path.join(PREVIEW_DIR, "blog") : BLOG_OUT_DIR;
writePostPages(posts, outDir);
if (!drafts) writeAssets(posts);
const published = posts.filter((p) => p.status === "published").length;
const draftCount = posts.length - published;
console.log(
`built ${published} post${published === 1 ? "" : "s"}` +
(drafts ? ` + ${draftCount} draft${draftCount === 1 ? "" : "s"} (preview only)` : "") +
` -> ${path.relative(ROOT_DIR, outDir).replace(/\\/g, "/")}/`
);
}
main();
|