spb/spboucher.ai Public
spboucher.ai — personal website of Simon-Pierre Boucher.
TypeScript 93.7%
HTML 5.3%
CSS 0.9%
1/*2 generate-cv.ts3 spboucher.ai Web4 Author: Simon-Pierre Boucher5 Mail: contact@spboucher.ai6*/78/**9 * Regenerates the two "Software" sections of cv-source/cv.html from10 * lib/apps.ts (the single source of truth for apps and projects), then11 * renders public/cv/Simon-Pierre-Boucher-CV.pdf with chrome-headless-shell.12 *13 * Run with: npm run cv (requires Node >= 23 for native TS imports)14 *15 * Adding a project to lib/apps.ts and re-running this script is all it16 * takes to keep the CV PDF in sync — no manual HTML edits.17 */1819import { execFileSync } from "node:child_process";20import { globSync, readFileSync, writeFileSync } from "node:fs";21import { dirname, join } from "node:path";22import { fileURLToPath } from "node:url";2324import {25 iosApps,26 openSourceProjects,27 zyquoApps,28 type OpenSourceProject,29 type ZyquoApp,30} from "../lib/apps.ts";3132const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");33const CV_HTML = join(ROOT, "cv-source", "cv.html");34const CV_PDF = join(ROOT, "public", "cv", "Simon-Pierre-Boucher-CV.pdf");3536const MAX_TAG_CHARS = 110;3738function escapeHtml(s: string): string {39 return s40 .replace(/&/g, "&")41 .replace(/</g, "<")42 .replace(/>/g, ">");43}4445/** Compress a project description into a short CV tag line. */46function tagFor(description: string): string {47 let text = description.replace(/\s+/g, " ").trim();48 // Prefer the first sentence when it fits.49 const firstSentence = text.match(/^(.+?[.!?])(\s|$)/)?.[1];50 if (firstSentence && firstSentence.length <= MAX_TAG_CHARS) {51 text = firstSentence;52 }53 if (text.length > MAX_TAG_CHARS) {54 text = text.slice(0, MAX_TAG_CHARS);55 text = text.slice(0, text.lastIndexOf(" ")) + "…";56 }57 text = text.replace(/[.…]+$/, (m) => (m === "…" ? "…" : ""));58 // Match the CV's lowercase tag style, but keep acronyms (AI, LLM…) intact.59 if (/^[A-Z][a-z]/.test(text) || /^(A|An) /.test(text))60 text = text[0].toLowerCase() + text.slice(1);61 return text;62}6364function urlFor(p: { demo?: string; repo: string }): string {65 return (p.demo ?? p.repo).replace(/^https?:\/\//, "").replace(/\/$/, "");66}6768function appLine(name: string, tag: string, url: string): string {69 return ` <div class="app"><p class="name">${escapeHtml(name)} <span class="tag">— ${escapeHtml(tag)}</span></p><p class="url">${escapeHtml(url)}</p></div>`;70}7172function renderSection(73 entries: { name: string; description: string; demo?: string; repo: string }[],74): string {75 const lines = entries.map((e) =>76 appLine(e.name, tagFor(e.description), urlFor(e)),77 );78 return ` <div class="apps">\n${lines.join("\n")}\n </div>`;79}8081function replaceBetween(82 html: string,83 beginMarker: string,84 endMarker: string,85 content: string,86): string {87 const begin = html.indexOf(beginMarker);88 const end = html.indexOf(endMarker);89 if (begin === -1 || end === -1 || end < begin) {90 throw new Error(`CV markers not found: ${beginMarker} / ${endMarker}`);91 }92 const head = html.slice(0, begin + beginMarker.length);93 const tail = html.slice(end);94 return `${head}\n${content}\n ${tail}`;95}9697// Native macOS apps = the Zyquo suite + Swift open-source apps;98// everything else lands in Web Platforms & Open Source.99const swiftApps = openSourceProjects.filter((p) => p.language === "Swift");100const webProjects = openSourceProjects.filter((p) => p.language !== "Swift");101const macosEntries: (ZyquoApp | OpenSourceProject)[] = [102 ...zyquoApps,103 ...swiftApps,104];105106let html = readFileSync(CV_HTML, "utf8");107html = replaceBetween(108 html,109 "<!-- APPS:MACOS:BEGIN — generated by scripts/generate-cv.ts from lib/apps.ts; do not edit by hand -->",110 "<!-- APPS:MACOS:END -->",111 renderSection(macosEntries),112);113html = replaceBetween(114 html,115 "<!-- APPS:IOS:BEGIN — generated by scripts/generate-cv.ts from lib/apps.ts; do not edit by hand -->",116 "<!-- APPS:IOS:END -->",117 renderSection(iosApps),118);119html = replaceBetween(120 html,121 "<!-- APPS:WEB:BEGIN — generated by scripts/generate-cv.ts from lib/apps.ts; do not edit by hand -->",122 "<!-- APPS:WEB:END -->",123 renderSection(webProjects),124);125writeFileSync(CV_HTML, html);126console.log(127 `cv.html updated — ${macosEntries.length} macOS apps, ${iosApps.length} iOS apps, ${webProjects.length} web/OSS projects.`,128);129130// Render the PDF with the Playwright-bundled chrome-headless-shell.131const shells = globSync(132 join(133 process.env.HOME ?? "~",134 "Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-arm64/chrome-headless-shell",135 ),136).sort();137const shell = shells[shells.length - 1];138if (!shell) {139 console.error(140 "chrome-headless-shell not found under ~/Library/Caches/ms-playwright — PDF not rendered.",141 );142 process.exit(1);143}144145execFileSync(shell, [146 "--headless",147 "--no-pdf-header-footer",148 `--print-to-pdf=${CV_PDF}`,149 "--virtual-time-budget=15000",150 `file://${CV_HTML}`,151]);152console.log(`PDF rendered → ${CV_PDF}`);153