/* generate-cv.ts spboucher.ai Web Author: Simon-Pierre Boucher Mail: contact@spboucher.ai */ /** * Regenerates the two "Software" sections of cv-source/cv.html from * lib/apps.ts (the single source of truth for apps and projects), then * renders public/cv/Simon-Pierre-Boucher-CV.pdf with chrome-headless-shell. * * Run with: npm run cv (requires Node >= 23 for native TS imports) * * Adding a project to lib/apps.ts and re-running this script is all it * takes to keep the CV PDF in sync — no manual HTML edits. */ import { execFileSync } from "node:child_process"; import { globSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { iosApps, openSourceProjects, zyquoApps, type OpenSourceProject, type ZyquoApp, } from "../lib/apps.ts"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const CV_HTML = join(ROOT, "cv-source", "cv.html"); const CV_PDF = join(ROOT, "public", "cv", "Simon-Pierre-Boucher-CV.pdf"); const MAX_TAG_CHARS = 110; function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">"); } /** Compress a project description into a short CV tag line. */ function tagFor(description: string): string { let text = description.replace(/\s+/g, " ").trim(); // Prefer the first sentence when it fits. const firstSentence = text.match(/^(.+?[.!?])(\s|$)/)?.[1]; if (firstSentence && firstSentence.length <= MAX_TAG_CHARS) { text = firstSentence; } if (text.length > MAX_TAG_CHARS) { text = text.slice(0, MAX_TAG_CHARS); text = text.slice(0, text.lastIndexOf(" ")) + "…"; } text = text.replace(/[.…]+$/, (m) => (m === "…" ? "…" : "")); // Match the CV's lowercase tag style, but keep acronyms (AI, LLM…) intact. if (/^[A-Z][a-z]/.test(text) || /^(A|An) /.test(text)) text = text[0].toLowerCase() + text.slice(1); return text; } function urlFor(p: { demo?: string; repo: string }): string { return (p.demo ?? p.repo).replace(/^https?:\/\//, "").replace(/\/$/, ""); } function appLine(name: string, tag: string, url: string): string { return `

${escapeHtml(name)} — ${escapeHtml(tag)}

${escapeHtml(url)}

`; } function renderSection( entries: { name: string; description: string; demo?: string; repo: string }[], ): string { const lines = entries.map((e) => appLine(e.name, tagFor(e.description), urlFor(e)), ); return `
\n${lines.join("\n")}\n
`; } function replaceBetween( html: string, beginMarker: string, endMarker: string, content: string, ): string { const begin = html.indexOf(beginMarker); const end = html.indexOf(endMarker); if (begin === -1 || end === -1 || end < begin) { throw new Error(`CV markers not found: ${beginMarker} / ${endMarker}`); } const head = html.slice(0, begin + beginMarker.length); const tail = html.slice(end); return `${head}\n${content}\n ${tail}`; } // Native macOS apps = the Zyquo suite + Swift open-source apps; // everything else lands in Web Platforms & Open Source. const swiftApps = openSourceProjects.filter((p) => p.language === "Swift"); const webProjects = openSourceProjects.filter((p) => p.language !== "Swift"); const macosEntries: (ZyquoApp | OpenSourceProject)[] = [ ...zyquoApps, ...swiftApps, ]; let html = readFileSync(CV_HTML, "utf8"); html = replaceBetween( html, "", "", renderSection(macosEntries), ); html = replaceBetween( html, "", "", renderSection(iosApps), ); html = replaceBetween( html, "", "", renderSection(webProjects), ); writeFileSync(CV_HTML, html); console.log( `cv.html updated — ${macosEntries.length} macOS apps, ${iosApps.length} iOS apps, ${webProjects.length} web/OSS projects.`, ); // Render the PDF with the Playwright-bundled chrome-headless-shell. const shells = globSync( join( process.env.HOME ?? "~", "Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-arm64/chrome-headless-shell", ), ).sort(); const shell = shells[shells.length - 1]; if (!shell) { console.error( "chrome-headless-shell not found under ~/Library/Caches/ms-playwright — PDF not rendered.", ); process.exit(1); } execFileSync(shell, [ "--headless", "--no-pdf-header-footer", `--print-to-pdf=${CV_PDF}`, "--virtual-time-budget=15000", `file://${CV_HTML}`, ]); console.log(`PDF rendered → ${CV_PDF}`);