Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env node
2// papermill.mjs — Build script for translated papers
3// Usage:
4// node papermill.mjs build Build all translated PDFs
5// node papermill.mjs build da Build only Danish translations
6// node papermill.mjs build es Build only Spanish translations
7// node papermill.mjs build zh Build only Chinese translations
8// node papermill.mjs build --format cards Build cards-format PDFs
9// node papermill.mjs deploy Copy compiled PDFs to site directory
10// node papermill.mjs sync-index Extract titles from .tex files → translations.json
11// node papermill.mjs status Show which translations exist
12
13import { execSync } from "child_process";
14import {
15 readdirSync,
16 existsSync,
17 copyFileSync,
18 readFileSync,
19 writeFileSync,
20 mkdirSync,
21} from "fs";
22import { join, basename } from "path";
23
24const PAPERS_DIR = new URL(".", import.meta.url).pathname;
25const SITE_DIR = join(
26 PAPERS_DIR,
27 "../system/public/papers.aesthetic.computer",
28);
29const LANGS = ["da", "es", "zh", "ja"];
30const LANG_NAMES = { da: "Danish", es: "Spanish", zh: "Chinese", ja: "Japanese" };
31
32// Map paper dir name to output PDF base name (matching existing site naming)
33const PAPER_MAP = {
34 "arxiv-ac": {
35 base: "ac",
36 siteName: "aesthetic-computer-26-arxiv",
37 paperId: "ac",
38 },
39 "arxiv-api": {
40 base: "api",
41 siteName: "piece-api-26-arxiv",
42 paperId: "api",
43 },
44 "arxiv-archaeology": {
45 base: "archaeology",
46 siteName: "repo-archaeology-26-arxiv",
47 paperId: "archaeology",
48 },
49 "arxiv-dead-ends": {
50 base: "dead-ends",
51 siteName: "dead-ends-26-arxiv",
52 paperId: "dead-ends",
53 },
54 "arxiv-diversity": {
55 base: "diversity",
56 siteName: "citation-diversity-audit-26",
57 paperId: "diversity",
58 },
59 "arxiv-folk-songs": {
60 base: "folk-songs",
61 siteName: "folk-songs-26-arxiv",
62 paperId: "folk-songs",
63 },
64 "arxiv-goodiepal": {
65 base: "goodiepal",
66 siteName: "radical-computer-art-26-arxiv",
67 paperId: "goodiepal",
68 },
69 "arxiv-kidlisp": {
70 base: "kidlisp",
71 siteName: "kidlisp-26-arxiv",
72 paperId: "kidlisp",
73 },
74 "arxiv-kidlisp-reference": {
75 base: "kidlisp-reference",
76 siteName: "kidlisp-reference-26-arxiv",
77 paperId: "kidlisp-ref",
78 },
79 "arxiv-network-audit": {
80 base: "network-audit",
81 siteName: "network-audit-26-arxiv",
82 paperId: "network-audit",
83 },
84 "arxiv-notepat": {
85 base: "notepat",
86 siteName: "notepat-26-arxiv",
87 paperId: "notepat",
88 },
89 "arxiv-os": {
90 base: "os",
91 siteName: "ac-native-os-26-arxiv",
92 paperId: "os",
93 },
94 "arxiv-pieces": {
95 base: "pieces",
96 siteName: "pieces-not-programs-26-arxiv",
97 paperId: "pieces",
98 },
99 "arxiv-sustainability": {
100 base: "sustainability",
101 siteName: "who-pays-for-creative-tools-26-arxiv",
102 paperId: "who-pays",
103 },
104 "arxiv-whistlegraph": {
105 base: "whistlegraph",
106 siteName: "whistlegraph-26-arxiv",
107 paperId: "whistlegraph",
108 },
109 "arxiv-complex": {
110 base: "complex",
111 siteName: "sucking-on-the-complex-26-arxiv",
112 paperId: "complex",
113 },
114 "arxiv-plork": {
115 base: "plork",
116 siteName: "plorking-the-planet-26-arxiv",
117 paperId: "plork",
118 },
119 "arxiv-calarts": {
120 base: "calarts",
121 siteName: "calarts-callouts-papers-26-arxiv",
122 paperId: "calarts",
123 },
124 "arxiv-futures": {
125 base: "futures",
126 siteName: "five-years-from-now-26-arxiv",
127 paperId: "futures",
128 },
129 "arxiv-identity": {
130 base: "identity",
131 siteName: "handle-identity-atproto-26-arxiv",
132 paperId: "identity",
133 },
134 "arxiv-open-schools": {
135 base: "open-schools",
136 siteName: "open-schools-26-arxiv",
137 paperId: "open-schools",
138 },
139 "arxiv-kidlisp-cards": {
140 base: "kidlisp-cards",
141 siteName: "kidlisp-cards-26-arxiv",
142 paperId: "kidlisp-cards",
143 },
144 "arxiv-score-analysis": {
145 base: "score-analysis",
146 siteName: "reading-the-score-26-arxiv",
147 paperId: "score-analysis",
148 },
149};
150
151// --- File discovery ---
152
153function findTranslatedFiles(format) {
154 const results = [];
155 for (const [dir, info] of Object.entries(PAPER_MAP)) {
156 const paperDir = join(PAPERS_DIR, dir);
157 if (!existsSync(paperDir)) continue;
158 for (const lang of LANGS) {
159 const suffix = format ? `-${format}` : "";
160 const texBase = format
161 ? `${info.base}-${format}-${lang}`
162 : `${info.base}-${lang}`;
163 const texFile = join(paperDir, `${texBase}.tex`);
164 const pdfFile = join(paperDir, `${texBase}.pdf`);
165 const siteBase = format
166 ? `${info.siteName}-${format}-${lang}`
167 : `${info.siteName}-${lang}`;
168 results.push({
169 dir,
170 lang,
171 format: format || "layout",
172 base: info.base,
173 siteName: info.siteName,
174 texFile,
175 pdfFile,
176 texExists: existsSync(texFile),
177 pdfExists: existsSync(pdfFile),
178 sitePdf: join(SITE_DIR, `${siteBase}.pdf`),
179 });
180 }
181 }
182 return results;
183}
184
185function findCardsFiles() {
186 const results = [];
187 for (const [dir, info] of Object.entries(PAPER_MAP)) {
188 const paperDir = join(PAPERS_DIR, dir);
189 if (!existsSync(paperDir)) continue;
190 const texFile = join(paperDir, `${info.base}-cards.tex`);
191 const pdfFile = join(paperDir, `${info.base}-cards.pdf`);
192 results.push({
193 dir,
194 lang: "en",
195 format: "cards",
196 base: info.base,
197 siteName: info.siteName,
198 texFile,
199 pdfFile,
200 texExists: existsSync(texFile),
201 pdfExists: existsSync(pdfFile),
202 sitePdf: join(SITE_DIR, `${info.siteName}-cards.pdf`),
203 });
204 }
205 return results;
206}
207
208function buildPaper(entry) {
209 if (!entry.texExists) {
210 console.log(
211 ` SKIP ${entry.dir}/${basename(entry.texFile)} (not found)`,
212 );
213 return false;
214 }
215 const paperDir = join(PAPERS_DIR, entry.dir);
216 const texName = basename(entry.texFile, ".tex");
217 console.log(` BUILD ${entry.dir}/${texName}.tex ...`);
218 try {
219 // Run xelatex + bibtex + xelatex + xelatex (full 3-pass build for citations)
220 execSync(
221 `cd "${paperDir}" && xelatex -interaction=nonstopmode "${texName}.tex" && bibtex "${texName}" 2>/dev/null; xelatex -interaction=nonstopmode "${texName}.tex" && xelatex -interaction=nonstopmode "${texName}.tex"`,
222 { stdio: "pipe", timeout: 180000 },
223 );
224 console.log(` OK ${texName}.pdf`);
225 return true;
226 } catch (e) {
227 console.error(` FAIL ${texName}.tex — ${e.message?.slice(0, 200)}`);
228 try {
229 const log = execSync(
230 `tail -30 "${join(paperDir, texName + ".log")}"`,
231 { encoding: "utf8" },
232 );
233 console.error(` LOG:\n${log}`);
234 } catch (_) {}
235 return false;
236 }
237}
238
239function deployPaper(entry) {
240 if (!entry.pdfExists) return false;
241 mkdirSync(SITE_DIR, { recursive: true });
242 copyFileSync(entry.pdfFile, entry.sitePdf);
243 console.log(` DEPLOY ${basename(entry.sitePdf)}`);
244 return true;
245}
246
247// --- Title extraction from .tex files ---
248
249function extractTitleFromTex(texPath) {
250 if (!existsSync(texPath)) return null;
251 const content = readFileSync(texPath, "utf8");
252 const lines = content.split("\n").slice(0, 250); // Title can be after preamble
253
254 let titleParts = [];
255 let subtitle = null;
256
257 // Title pattern: any bold font + large fontsize + \color{*dark*} — can span multiple lines
258 // Matches \acbold, \kidlispbold, \wgbold, etc.
259 const titleRe =
260 /\\[a-z]+bold\\fontsize\{[^}]+\}\{[^}]+\}\\selectfont\\color\{[a-z]+\}\s*(.+?)\s*\}\\par/;
261 // Subtitle pattern: any light font + smaller fontsize + \color{*pink/brand*}
262 const subtitleRe =
263 /\\[a-z]+(?:light|font)\\fontsize\{[^}]+\}\{[^}]+\}\\selectfont\\color\{[a-z]+\}\s*(.+?)\s*\}\\par/;
264
265 for (const line of lines) {
266 const boldMatch = line.match(titleRe);
267 if (boldMatch) {
268 titleParts.push(boldMatch[1].replace(/\\par$/, "").trim());
269 }
270
271 const lightMatch = line.match(subtitleRe);
272 if (lightMatch && !subtitle) {
273 subtitle = lightMatch[1].replace(/\\par$/, "").trim();
274 }
275 }
276
277 // Join multi-line titles (e.g., "Playable" + "Folk Songs")
278 let title = titleParts.length > 0 ? titleParts.join(" ") : null;
279
280 // Clean up LaTeX commands from extracted text
281 if (title) title = cleanLatex(title);
282 if (subtitle) subtitle = cleanLatex(subtitle);
283
284 return { title, subtitle };
285}
286
287function cleanLatex(text) {
288 return text
289 .replace(/\\ac\{\}/g, "Aesthetic Computer")
290 .replace(/\\acos\{\}/g, "AC Native OS")
291 .replace(/\\np\{\}/g, "notepat")
292 .replace(/\\acdot/g, ".")
293 .replace(/\{\\color\{[^}]+\}([^}]*)\}/g, "$1") // {\color{acpink}text} → text
294 .replace(/\\color\{[^}]+\}/g, "") // bare \color{...}
295 .replace(/\\textsc\{([^}]+)\}/g, "$1")
296 .replace(/\\textbf\{([^}]+)\}/g, "$1")
297 .replace(/\\textit\{([^}]+)\}/g, "$1")
298 .replace(/\\texttt\{([^}]+)\}/g, "$1")
299 .replace(/\\emph\{([^}]+)\}/g, "$1")
300 .replace(/\\url\{([^}]+)\}/g, "$1")
301 .replace(/\\href\{[^}]+\}\{([^}]+)\}/g, "$1")
302 .replace(/\\\\/g, "")
303 .replace(/\\,/g, "")
304 .replace(/\\&/g, "&")
305 .replace(/---/g, "\u2014")
306 .replace(/--/g, "\u2013")
307 .replace(/``/g, "\u201c")
308 .replace(/''/g, "\u201d")
309 .replace(/~/g, " ")
310 .replace(/\s+/g, " ")
311 .trim();
312}
313
314function syncIndex() {
315 console.log("\nSync-index: extracting titles from .tex files...\n");
316 const translations = {};
317
318 for (const [dir, info] of Object.entries(PAPER_MAP)) {
319 const paperDir = join(PAPERS_DIR, dir);
320 if (!existsSync(paperDir)) continue;
321
322 const paperId = info.paperId;
323 const entry = {};
324
325 // Extract English title + subtitle (base .tex file)
326 const enTex = join(paperDir, `${info.base}.tex`);
327 const enData = extractTitleFromTex(enTex);
328 if (enData) {
329 entry.en = {};
330 if (enData.title) entry.en.title = enData.title;
331 if (enData.subtitle) entry.en.subtitle = enData.subtitle;
332 }
333
334 // Extract translated titles
335 for (const lang of LANGS) {
336 const texPath = join(paperDir, `${info.base}-${lang}.tex`);
337 const data = extractTitleFromTex(texPath);
338 if (data && data.title) {
339 entry[lang] = {};
340 entry[lang].title = data.title;
341 if (data.subtitle) entry[lang].subtitle = data.subtitle;
342 }
343 }
344
345 if (Object.keys(entry).length > 0) {
346 translations[paperId] = entry;
347 }
348 }
349
350 const outPath = join(SITE_DIR, "translations.json");
351 mkdirSync(SITE_DIR, { recursive: true });
352 writeFileSync(outPath, JSON.stringify(translations, null, 2), "utf8");
353 console.log(` WROTE ${outPath}`);
354 console.log(
355 ` ${Object.keys(translations).length} papers, ${LANGS.length} languages\n`,
356 );
357 return translations;
358}
359
360// --- CLI ---
361const args = process.argv.slice(2);
362const cmd = args[0];
363const langFilter = args.find((a) => LANGS.includes(a));
364const formatFlag = args.includes("--format")
365 ? args[args.indexOf("--format") + 1]
366 : null;
367
368if (cmd === "status" || !cmd) {
369 const files = findTranslatedFiles();
370 console.log("\nPapermill Translation Status\n");
371 console.log(
372 "Paper".padEnd(30) +
373 LANGS.map((l) => LANG_NAMES[l].padEnd(12)).join(""),
374 );
375 console.log("-".repeat(30 + LANGS.length * 12));
376 let currentDir = "";
377 for (const f of files) {
378 if (f.dir !== currentDir) {
379 currentDir = f.dir;
380 process.stdout.write(f.dir.padEnd(30));
381 }
382 const status = f.pdfExists ? "PDF" : f.texExists ? "tex" : "---";
383 process.stdout.write(status.padEnd(12));
384 if (LANGS.indexOf(f.lang) === LANGS.length - 1)
385 process.stdout.write("\n");
386 }
387 // Cards status
388 const cardsFiles = findCardsFiles();
389 const hasCards = cardsFiles.some((f) => f.texExists);
390 if (hasCards) {
391 console.log("Cards Format\n");
392 console.log("Paper".padEnd(30) + "Status");
393 console.log("-".repeat(42));
394 for (const f of cardsFiles) {
395 const status = f.pdfExists ? "PDF" : f.texExists ? "tex" : "---";
396 console.log(f.dir.padEnd(30) + status);
397 }
398 console.log();
399 }
400} else if (cmd === "build") {
401 let files;
402 if (formatFlag === "cards") {
403 // Build English cards
404 files = findCardsFiles();
405 } else {
406 files = findTranslatedFiles(formatFlag).filter(
407 (f) => !langFilter || f.lang === langFilter,
408 );
409 }
410 const toBuild = files.filter((f) => f.texExists);
411 const label = formatFlag ? ` (${formatFlag} format)` : "";
412 console.log(
413 `\nBuilding ${toBuild.length} paper${toBuild.length !== 1 ? "s" : ""}${label}...\n`,
414 );
415 let ok = 0,
416 fail = 0;
417 for (const entry of toBuild) {
418 if (buildPaper(entry)) ok++;
419 else fail++;
420 }
421 console.log(`\nDone: ${ok} built, ${fail} failed.\n`);
422} else if (cmd === "deploy") {
423 const files = findTranslatedFiles();
424 const toDeploy = files.filter((f) => f.pdfExists);
425 console.log(
426 `\nDeploying ${toDeploy.length} translated PDF${toDeploy.length !== 1 ? "s" : ""}...\n`,
427 );
428 for (const entry of toDeploy) {
429 deployPaper(entry);
430 }
431 // Deploy English cards PDFs
432 const cardsFiles = findCardsFiles();
433 const cardsToDeploy = cardsFiles.filter((f) => f.pdfExists);
434 if (cardsToDeploy.length > 0) {
435 console.log(
436 `\nDeploying ${cardsToDeploy.length} cards PDF${cardsToDeploy.length !== 1 ? "s" : ""}...\n`,
437 );
438 for (const entry of cardsToDeploy) {
439 deployPaper(entry);
440 }
441 }
442 // Auto sync-index on deploy
443 syncIndex();
444 console.log("\nDone.\n");
445} else if (cmd === "sync-index") {
446 syncIndex();
447} else {
448 console.log(
449 "Usage: node papermill.mjs [build [da|es|zh] [--format cards] | deploy | sync-index | status]",
450 );
451}