Monorepo for Aesthetic.Computer aesthetic.computer
at main 451 lines 14 kB view raw
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}