SPB Git

spb/spboucher.ai Public

spboucher.ai — personal website of Simon-Pierre Boucher.

TypeScript 93.7% HTML 5.3% CSS 0.9%
4.9 KB · 153 lines typescript
Raw Blame History
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, "&amp;")41    .replace(/</g, "&lt;")42    .replace(/>/g, "&gt;");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