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 "arxiv-latency": {
150 base: "latency",
151 siteName: "where-the-microseconds-go-26-arxiv",
152 paperId: "latency",
153 },
154 "arxiv-penrose": {
155 base: "penrose",
156 siteName: "diagrams-from-data-26-arxiv",
157 paperId: "penrose",
158 },
159};
160
161// --- File discovery ---
162
163function findTranslatedFiles(format) {
164 const results = [];
165 for (const [dir, info] of Object.entries(PAPER_MAP)) {
166 const paperDir = join(PAPERS_DIR, dir);
167 if (!existsSync(paperDir)) continue;
168 for (const lang of LANGS) {
169 const suffix = format ? `-${format}` : "";
170 const texBase = format
171 ? `${info.base}-${format}-${lang}`
172 : `${info.base}-${lang}`;
173 const texFile = join(paperDir, `${texBase}.tex`);
174 const pdfFile = join(paperDir, `${texBase}.pdf`);
175 const siteBase = format
176 ? `${info.siteName}-${format}-${lang}`
177 : `${info.siteName}-${lang}`;
178 results.push({
179 dir,
180 lang,
181 format: format || "layout",
182 base: info.base,
183 siteName: info.siteName,
184 texFile,
185 pdfFile,
186 texExists: existsSync(texFile),
187 pdfExists: existsSync(pdfFile),
188 sitePdf: join(SITE_DIR, `${siteBase}.pdf`),
189 });
190 }
191 }
192 return results;
193}
194
195function findCardsFiles() {
196 const results = [];
197 for (const [dir, info] of Object.entries(PAPER_MAP)) {
198 const paperDir = join(PAPERS_DIR, dir);
199 if (!existsSync(paperDir)) continue;
200 const texFile = join(paperDir, `${info.base}-cards.tex`);
201 const pdfFile = join(paperDir, `${info.base}-cards.pdf`);
202 results.push({
203 dir,
204 lang: "en",
205 format: "cards",
206 base: info.base,
207 siteName: info.siteName,
208 texFile,
209 pdfFile,
210 texExists: existsSync(texFile),
211 pdfExists: existsSync(pdfFile),
212 sitePdf: join(SITE_DIR, `${info.siteName}-cards.pdf`),
213 });
214 }
215 return results;
216}
217
218function buildPaper(entry) {
219 if (!entry.texExists) {
220 console.log(
221 ` SKIP ${entry.dir}/${basename(entry.texFile)} (not found)`,
222 );
223 return false;
224 }
225 const paperDir = join(PAPERS_DIR, entry.dir);
226 const texName = basename(entry.texFile, ".tex");
227 console.log(` BUILD ${entry.dir}/${texName}.tex ...`);
228 try {
229 // Run xelatex + bibtex + xelatex + xelatex (full 3-pass build for citations)
230 execSync(
231 `cd "${paperDir}" && xelatex -interaction=nonstopmode "${texName}.tex" && bibtex "${texName}" 2>/dev/null; xelatex -interaction=nonstopmode "${texName}.tex" && xelatex -interaction=nonstopmode "${texName}.tex"`,
232 { stdio: "pipe", timeout: 180000 },
233 );
234 console.log(` OK ${texName}.pdf`);
235 return true;
236 } catch (e) {
237 console.error(` FAIL ${texName}.tex — ${e.message?.slice(0, 200)}`);
238 try {
239 const log = execSync(
240 `tail -30 "${join(paperDir, texName + ".log")}"`,
241 { encoding: "utf8" },
242 );
243 console.error(` LOG:\n${log}`);
244 } catch (_) {}
245 return false;
246 }
247}
248
249function deployPaper(entry) {
250 if (!entry.pdfExists) return false;
251 mkdirSync(SITE_DIR, { recursive: true });
252 copyFileSync(entry.pdfFile, entry.sitePdf);
253 console.log(` DEPLOY ${basename(entry.sitePdf)}`);
254 return true;
255}
256
257// --- Title extraction from .tex files ---
258
259function extractTitleFromTex(texPath) {
260 if (!existsSync(texPath)) return null;
261 const content = readFileSync(texPath, "utf8");
262 const lines = content.split("\n").slice(0, 250); // Title can be after preamble
263
264 let titleParts = [];
265 let subtitle = null;
266
267 // Title pattern: any bold font + large fontsize + \color{*dark*} — can span multiple lines
268 // Matches \acbold, \kidlispbold, \wgbold, etc.
269 const titleRe =
270 /\\[a-z]+bold\\fontsize\{[^}]+\}\{[^}]+\}\\selectfont\\color\{[a-z]+\}\s*(.+?)\s*\}\\par/;
271 // Subtitle pattern: any light font + smaller fontsize + \color{*pink/brand*}
272 const subtitleRe =
273 /\\[a-z]+(?:light|font)\\fontsize\{[^}]+\}\{[^}]+\}\\selectfont\\color\{[a-z]+\}\s*(.+?)\s*\}\\par/;
274
275 for (const line of lines) {
276 const boldMatch = line.match(titleRe);
277 if (boldMatch) {
278 titleParts.push(boldMatch[1].replace(/\\par$/, "").trim());
279 }
280
281 const lightMatch = line.match(subtitleRe);
282 if (lightMatch && !subtitle) {
283 subtitle = lightMatch[1].replace(/\\par$/, "").trim();
284 }
285 }
286
287 // Join multi-line titles (e.g., "Playable" + "Folk Songs")
288 let title = titleParts.length > 0 ? titleParts.join(" ") : null;
289
290 // Clean up LaTeX commands from extracted text
291 if (title) title = cleanLatex(title);
292 if (subtitle) subtitle = cleanLatex(subtitle);
293
294 return { title, subtitle };
295}
296
297function cleanLatex(text) {
298 return text
299 .replace(/\\ac\{\}/g, "Aesthetic Computer")
300 .replace(/\\acos\{\}/g, "AC Native OS")
301 .replace(/\\np\{\}/g, "notepat")
302 .replace(/\\acdot/g, ".")
303 .replace(/\{\\color\{[^}]+\}([^}]*)\}/g, "$1") // {\color{acpink}text} → text
304 .replace(/\\color\{[^}]+\}/g, "") // bare \color{...}
305 .replace(/\\textsc\{([^}]+)\}/g, "$1")
306 .replace(/\\textbf\{([^}]+)\}/g, "$1")
307 .replace(/\\textit\{([^}]+)\}/g, "$1")
308 .replace(/\\texttt\{([^}]+)\}/g, "$1")
309 .replace(/\\emph\{([^}]+)\}/g, "$1")
310 .replace(/\\url\{([^}]+)\}/g, "$1")
311 .replace(/\\href\{[^}]+\}\{([^}]+)\}/g, "$1")
312 .replace(/\\\\/g, "")
313 .replace(/\\,/g, "")
314 .replace(/\\&/g, "&")
315 .replace(/---/g, "\u2014")
316 .replace(/--/g, "\u2013")
317 .replace(/``/g, "\u201c")
318 .replace(/''/g, "\u201d")
319 .replace(/~/g, " ")
320 .replace(/\s+/g, " ")
321 .trim();
322}
323
324function syncIndex() {
325 console.log("\nSync-index: extracting titles from .tex files...\n");
326 const translations = {};
327
328 for (const [dir, info] of Object.entries(PAPER_MAP)) {
329 const paperDir = join(PAPERS_DIR, dir);
330 if (!existsSync(paperDir)) continue;
331
332 const paperId = info.paperId;
333 const entry = {};
334
335 // Extract English title + subtitle (base .tex file)
336 const enTex = join(paperDir, `${info.base}.tex`);
337 const enData = extractTitleFromTex(enTex);
338 if (enData) {
339 entry.en = {};
340 if (enData.title) entry.en.title = enData.title;
341 if (enData.subtitle) entry.en.subtitle = enData.subtitle;
342 }
343
344 // Extract translated titles
345 for (const lang of LANGS) {
346 const texPath = join(paperDir, `${info.base}-${lang}.tex`);
347 const data = extractTitleFromTex(texPath);
348 if (data && data.title) {
349 entry[lang] = {};
350 entry[lang].title = data.title;
351 if (data.subtitle) entry[lang].subtitle = data.subtitle;
352 }
353 }
354
355 if (Object.keys(entry).length > 0) {
356 translations[paperId] = entry;
357 }
358 }
359
360 const outPath = join(SITE_DIR, "translations.json");
361 mkdirSync(SITE_DIR, { recursive: true });
362 writeFileSync(outPath, JSON.stringify(translations, null, 2), "utf8");
363 console.log(` WROTE ${outPath}`);
364 console.log(
365 ` ${Object.keys(translations).length} papers, ${LANGS.length} languages\n`,
366 );
367 return translations;
368}
369
370// --- CLI ---
371const args = process.argv.slice(2);
372const cmd = args[0];
373const langFilter = args.find((a) => LANGS.includes(a));
374const formatFlag = args.includes("--format")
375 ? args[args.indexOf("--format") + 1]
376 : null;
377
378if (cmd === "status" || !cmd) {
379 const files = findTranslatedFiles();
380 console.log("\nPapermill Translation Status\n");
381 console.log(
382 "Paper".padEnd(30) +
383 LANGS.map((l) => LANG_NAMES[l].padEnd(12)).join(""),
384 );
385 console.log("-".repeat(30 + LANGS.length * 12));
386 let currentDir = "";
387 for (const f of files) {
388 if (f.dir !== currentDir) {
389 currentDir = f.dir;
390 process.stdout.write(f.dir.padEnd(30));
391 }
392 const status = f.pdfExists ? "PDF" : f.texExists ? "tex" : "---";
393 process.stdout.write(status.padEnd(12));
394 if (LANGS.indexOf(f.lang) === LANGS.length - 1)
395 process.stdout.write("\n");
396 }
397 // Cards status
398 const cardsFiles = findCardsFiles();
399 const hasCards = cardsFiles.some((f) => f.texExists);
400 if (hasCards) {
401 console.log("Cards Format\n");
402 console.log("Paper".padEnd(30) + "Status");
403 console.log("-".repeat(42));
404 for (const f of cardsFiles) {
405 const status = f.pdfExists ? "PDF" : f.texExists ? "tex" : "---";
406 console.log(f.dir.padEnd(30) + status);
407 }
408 console.log();
409 }
410} else if (cmd === "build") {
411 let files;
412 if (formatFlag === "cards") {
413 // Build English cards
414 files = findCardsFiles();
415 } else {
416 files = findTranslatedFiles(formatFlag).filter(
417 (f) => !langFilter || f.lang === langFilter,
418 );
419 }
420 const toBuild = files.filter((f) => f.texExists);
421 const label = formatFlag ? ` (${formatFlag} format)` : "";
422 console.log(
423 `\nBuilding ${toBuild.length} paper${toBuild.length !== 1 ? "s" : ""}${label}...\n`,
424 );
425 let ok = 0,
426 fail = 0;
427 for (const entry of toBuild) {
428 if (buildPaper(entry)) ok++;
429 else fail++;
430 }
431 console.log(`\nDone: ${ok} built, ${fail} failed.\n`);
432} else if (cmd === "deploy") {
433 const files = findTranslatedFiles();
434 const toDeploy = files.filter((f) => f.pdfExists);
435 console.log(
436 `\nDeploying ${toDeploy.length} translated PDF${toDeploy.length !== 1 ? "s" : ""}...\n`,
437 );
438 for (const entry of toDeploy) {
439 deployPaper(entry);
440 }
441 // Deploy English cards PDFs
442 const cardsFiles = findCardsFiles();
443 const cardsToDeploy = cardsFiles.filter((f) => f.pdfExists);
444 if (cardsToDeploy.length > 0) {
445 console.log(
446 `\nDeploying ${cardsToDeploy.length} cards PDF${cardsToDeploy.length !== 1 ? "s" : ""}...\n`,
447 );
448 for (const entry of cardsToDeploy) {
449 deployPaper(entry);
450 }
451 }
452 // Auto sync-index on deploy
453 syncIndex();
454 console.log("\nDone.\n");
455} else if (cmd === "sync-index") {
456 syncIndex();
457} else {
458 console.log(
459 "Usage: node papermill.mjs [build [da|es|zh] [--format cards] | deploy | sync-index | status]",
460 );
461}