Every CV diffable. Every PDF reproducible.
Fork-freundlicher OSS-Builder, der einen Lebenslauf als YAML als Source of Truth behandelt und über 12 typografisch durchkomponierte Templates nach PDF rendert. Zod-Schema als Vertrag, React-SSR im Renderer, Puppeteer als Layout-Engine, pixelmatch als Visual-Regression-Floor. Ein pnpm- Workspace, drei Apps (CLI · Web-Editor · Showcase), ein Renderer. Auf npm als @codevena/cvmake-cli, der Editor live unter editor.cvmake.codevena.dev. MIT-Lizenz, public auf GitHub.
Why this exists.
Ein Lebenslauf ist ein Text, der über zehn Jahre fünfzehnmal umgeschrieben wird — Sprache wechselt, Reihenfolge wechselt, Foto wechselt, Stil wechselt. Die Tools, die der Markt anbietet, behandeln das wie ein Account-Onboarding: signup, fill in form, pay, download. Was der Nutzer eigentlich braucht, ist eine Text-Datei in seinem eigenen Git und ein deterministischer Renderer.
cvmake stellt das auf die richtige Seite: das Repo enthält den Renderer, die Templates und ein data/cvs/example.de.yaml; der eigene CV landet in data/cvs/cv.de.yaml und ist gitignored. pnpm cvmake build cv.de.yaml erzeugt das PDF — lokal, ohne Account, ohne Server, ohne Tracking. Die Web-UI darüber ist eine Bequemlichkeitsschicht, die dieselbe YAML-Datei schreibt; sie ist Interface, nicht Source.
The box it had to fit in.
CONTRIBUTING.md, SECURITY.md, GitHub-Actions-CI. „Fork your own CV" ist die Standard-User-Story — der eigene Lebenslauf bleibt local-only (cv.*.yaml gitignored), nur das Beispiel example.*.yaml ist getrackt.schema, core, templates, ui), drei Apps (cli, web, showcase). Renderer ist ein Package, das CLI und Web teilen — Layout-Drift zwischen Terminal-Export und Browser-Preview ist ausgeschlossen, weil es nur einen Code-Pfad gibt.cv.de.yaml, cv.en.yaml — eine Datei pro Sprache, Locale aus dem Dateinamen inferiert. Zod-Schema in @codevena/cvmake-schema ist der Vertrag zwischen YAML und Templates; ein Parse-Fehler zeigt Datei + Zeile + Spalte, nicht ein anonymes „validation failed".academic, bauhaus, classic-serif, corporate, creative-accent, editorial, magazine, modern-minimal, monochrome-dark, noir, swiss, tech-dev. Jedes Template ist eine React-Komponente, die gegen dieselbe TemplateProps-Signatur rendert.fonts.ready mit 5s-Timeout, preferCSSPageSize für CSS-kontrollierte Page-Größe.sharp — Upload, Crop, WebP+JPG-Output. Vor dem PDF-Render wird das Foto via embedPhoto() als base64 data-URL ins HTML einsubstituiert: kein Base-URL-Trick nötig, derselbe HTML-Output rendert in CLI, CI und Web-Preview.update-baselines.yml-Workflow re-bakes die Snapshots auf Knopfdruck.How it renders.
Six deliberate choices.
Puppeteer + headless Chrome statt jsPDF / pdf-lib / React-PDF.
browserPromise druckt mit preferCSSPageSize und printBackground nach PDF@page-Regeln, break-inside, OKLCH-Farben und Variable-Fonts sind Browser-Features. Der Preis ist Chromium im Deploy-Artifact (~300 MB), bewusst gezahlt. Web-Editor und CLI rendern dieselben Pixel, weil sie dieselbe Rendering-Engine fragen — kein Layout-Drift zwischen Preview und Export.YAML + Zod + Locale-aus-Dateinamen statt JSON Resume oder DB-Backend.
cv.de.yaml/cv.en.yaml als Source of Truth, Locale aus dem Dateinamen inferiert, CVDataSchema.safeParse als Eingangstor, eigeneYAMLParseError/ValidationError mit file + line + columnmeta.locale explizit setzt, überschreibt es.pnpm-Workspace mit 4 Packages + 3 Apps statt Single-Package-Repo.
@codevena/cvmake-schema (Zod-Verträge) → cvmake-core (Renderer + Loader + Photo) → cvmake-templates (12 React-Templates) → cvmake-ui, konsumiert von apps/cli, apps/web (Next.js 16) und apps/showcasecvmake-templates kann ohne CLI publiziert werden, das Schema kann von Dritt-Templates importiert werden, der Web-Editor baut auf demselben renderCV() wie das CLI auf — keine Layout-Drift möglich, weil es nur eine Render-Funktion gibt. Turbo cached pro Package; ein Template-Edit re-buildet nicht den ganzen Workspace. Der Preis ist die Initial-Bootstrap-Komplexität (workspace-Protokoll, pinned Versions, build-Order). Den Preis zahlt man einmal; Layout-Drift zwischen zwei Render- Pfaden zahlt man bei jedem Template-Edit.Insert-time-Pagination-Spacer statt @page-Margin.
page.evaluate() die natürlichen Page-Breaks im gerenderten DOM und injiziert einen 16pt-Spacer-Div vor das Element, das sonst flush gegen die Page-Top stünde — für <main> und <aside> getrennt, weil Chromium Grid-Tracks unabhängig paginiert@page-Margin reserviert Platz auf jeder Seite inklusive Seite 1 — der Hero-Header verschiebt sich, das full-bleed Sidebar-Gradient bricht. Was ich brauche, ist Top-Breathing-Space ab Seite 2, nicht ab Seite 1. Chromium paginiert außerdem jeden CSS-Grid-Track separat: ein Spacer-Div im <main> drückt die Sidebar-Inhalte nicht mit nach unten, weil die Sidebar in einem eigenen Track-Container lebt. Lösung: Spacer-Injection misst getBoundingClientRect der Children pro Container, findet den ersten Child, der über die Page-Grenze rutschen würde, und schiebt einendiv.cv-page-spacer davor. MAX_PAGES=12 als Safety-Cap, damit ein degeneriertes Layout den Render-Loop nicht endlos heizen kann.Base64-data-URL-Photo-Embed statt Base-URL + Filesystem-Pfad.
embedPhoto() liest das Bild, sniffft den Mimetype über die Extension, base64-encoded es, ersetzt das src durch eine data:-URL — idempotent, wenn das Photo bereits eine data-URL istpage.setContent() hat per Default keine Base-URL, also würden relative Photo-Pfade ins Leere zeigen. Eine Base-URL zu setzen heißt: lokalen Dev-Server starten (CLI muss ein HTTP-Server werden), oder file://-Pfade jonglieren (CI-spezifisch, Permission-flaky). Die data-URL ist render-context-frei: derselbe HTML-String rendert in CLI, CI, Web-Preview, ohne dass irgendeine der drei Umgebungen eine Server-Annahme hat. Idempotenz: wer das Photo schon als data-URL übergibt (z.B. aus dem Web-Editor, der direkt encoded), bekommt es unverändert zurück — keine doppelte Base64-Schichtung.Lockstep 4-Package npm-Publish + ein self-hosted Editor statt git-clone-only und serverless.
@codevena/cvmake-* (schema, core, templates, cli) auf npm publiziert im Lockstep auf einer gemeinsamen Version; npx @codevena/cvmake-cli build cv.yaml als Zero-Clone-Pfad. Der Web-Editor läuft als langlebiger Container auf Coolify/Hetzner unter editor.cvmake.codevena.dev; der Showcase bleibt statisch auf GitHub Pages.npx ist ~30 Sekunden. Die drei Workspace-Deps des CLI erzwingen den Publish all-four-or-nothing — cvmake-cli allein würde nicht auflösen — also releasen die vier Packages im Lockstep auf einer gemeinsamen Version und der Cross-Dependency-Vertrag bleibt trivial. Der Editor kann nicht serverless: Puppeteer braucht einen echten Node-Prozess und ein ~300 MB Chromium, also ist es ein Container auf einer Box, keine Function. Und ein öffentlicher Editor kann nicht der local-first-Editor sein — er war gebaut, um cv.*.yaml auf disk zu lesen und zu schreiben, aber jeder Besucher würde dieselben Dateien überschreiben. NEXT_PUBLIC_DEMO_MODE rüstet eine stateless Posture nach — nur Example-CVs, kein Autosave, Photo-Crop komplett client-seitig, „Download YAML" statt disk. Die nicht-offensichtliche Falle: NEXT_PUBLIC_*-Vars werden zur Build-Zeit inlined, das Flag nur zur Container-Runtime zu setzen hat den Server (demo) gegen den Client (not-demo) split-brained — es muss vor next build gesetzt sein.Things that were not obvious.
Browser-Singleton vs per-Render Spawn
puppeteer.launch() pro PDF-Export, browser.close() am Ende. 1.5–2.5s Spin-up pro Export — bei einem Live-Preview, das auf jeden Form-Edit ein PDF re-rendert, ist das die ganze gefühlte Latenz.Lösung:
let browserPromise: Promise<Browser> | null im Modul-Scope, lazy initialisiert, prozess-weit wiederverwendet. shutdownPdfBrowser() als expliziter Teardown-Hook für CLI und Tests. Pro Export wird nur eine neue Page geöffnet — die teure Browser-Initialisierung passiert einmal pro Prozess. Aus 2s wird sub-200ms ab dem zweiten Render; CLI-Single-Shot spinnt einmal hoch und schließt sauber.fonts.ready mit Race-Timeout
waitUntil: "networkidle0" und drucken. Falsch — networkidle heißt „kein Request mehr offen", nicht „Fonts gerendert". Bei langsamer Network rutscht der ersten Render durch, bevor Glyphen ersetzt sind: PDF in Fallback-Font, halb-stylisches Endprodukt.Fix:
page.evaluate(() => document.fonts.ready) race-d gegen 5s-Timeout. Schnelle Renders warten genau so lange, wie der Browser braucht; ein hängender Font-Provider blockt den Export maximal 5 Sekunden, statt forever. Der Timeout ist einSafety-Net, nicht der Default-Pfad — typische Renders settlen in 100–300ms.Grid-Tracks paginieren unabhängig
<aside> + <main> als CSS-Grid) paginiert in Chromium nicht wie eine Spalte — Sidebar und Main sind separate Pagination-Tracks. Wenn die Sidebar 1.2 Seiten lang ist, fällt der Überlauf flush an die Top von Page 2; Main hat dort vielleicht schon einen Section-Header, aber die Sidebar ignoriert ihn.Fix: der Spacer-Injection-Code findet
<main> und <aside> separat und behandelt beide als eigenständige Pagination-Tracks. Pro Track wird der erste Child gesucht, der die Page-Grenze überschreitet, und ein Spacer davor injiziert. Das Ergebnis: beide Spalten haben ab Seite 2 dasselbe Top-Breathing, die Page-Continuation liest sich wie ein einzelner Flow.Visual-Regression-Baselines auf Linux
Fix: Baselines werden ausschließlich auf Linux-CI generiert (
.github/workflows/update-baselines.yml), packages/templates/__tests__/__visual__/<id>/*.png ist die Source of Truth. macOS-Dev läuft Visual-Tests gegen die Linux-PNGs und akzeptiert die Drift — der Knopf zum Re-Baken ist ein manuell triggerbarer Workflow. Die OS-Drift wird so zu einerasymmetrischen Entscheidung: CI ist autoritativ, Dev ist Vorschau.What's running.
What I learned.
Der Browser ist die Layout-Engine.
Mein erster Reflex war jsPDF — leichtgewichtig, keine Chromium-Abhängigkeit, scheinbar „proper". Ein Wochenende später hatte ich Spalten manuell auf der jsPDF-Canvas positioniert und gemerkt: ich baue eine parallele CSS-Implementation in JavaScript. Jede Template-Idee wurde zur Frage „bekomme ich das durch jsPDF?", statt „bekomme ich das durch CSS?". Der Switch zu Puppeteer trade-d Container-Größe gegen Design-Geschwindigkeit — und für einen CV-Builder ist Design-Geschwindigkeit das, was Templates produziert. Chromium ist nicht das Problem; ein zweites Layout-System ist das Problem.
YAML als User-Surface, der Editor als Convenience.
Die Web-UI war Versuchung, sie zur Source of Truth zu machen — Formulare sind das natürliche Editing-Surface, eine DB-Tabelle wäre das natürliche Persist-Surface, ein Account das natürliche Recovery-Surface. Vier Schritte und cvmake wäre das gewesen, was es nicht sein wollte: eine weitere SaaS-Mausefalle. Lösung: der Web-Editor mutiert die YAML-Datei im Filesystem, die ist die Source of Truth, das Repo ist der Speicher. Ergebnis: der Web-App ist stateless, der CV ist vom Anbieter unabhängig, und derselbe Nutzer kann morgen im Editor und übermorgen in Vim arbeiten — beide schreiben dieselbe Datei. Convenience ohne Vendor-Lock. Der öffentliche Editor unter editor.cvmake.codevena.dev treibt dieselbe Idee einen Schritt weiter — eine stateless Demo: editieren, Preview, Export, YAML herunterladen. Das Tool besitzt deine Daten nie, lokal oder gehostet.