/* 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)}