CASE · 112026 — LIVE · OSSSOLO · END-TO-ENDOSS · YAML → PDF · 12 TEMPLATES
◦ CVMAKE · cvmake.codevena.dev

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.

§ 01Problem · motivation

Why this exists.

CV-Builder im Markt sind entweder DB-gehostete SaaS-Mausefallen oder Word-Templates mit fünf Schriften und drei Farben. Beides ignoriert, dass ein Lebenslauf ein versionierbares, persönliches Artefakt ist — kein Eintrag in einer fremden Datenbank.

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.

§ 02Constraints · operating box

The box it had to fit in.

OSS und fork-freundlich, Renderer-Parität zwischen CLI und Web, Visual-Regression als Build-Gate, Photo-Pipeline ohne Server-Annahmen.
C/01 · OSS-POSTURE
MIT-Lizenz, öffentliches Repo, 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.
C/02 · MONOREPO
pnpm 9 Workspace + Turbo, vier Packages (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.
C/03 · YAML-FIRST
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".
C/04 · TEMPLATES
12 Templates × 3+ Paletten — 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.
C/05 · RENDERER
React-DOM-Server rendert das Template zu statischem HTML, Puppeteer als Layout-Engine druckt es nach PDF. Browser-Singleton im Process-Memory, fonts.ready mit 5s-Timeout, preferCSSPageSize für CSS-kontrollierte Page-Größe.
C/06 · PAGINATION
Mehrseitige Lebensläufe brauchen einen Top-Spacer ab Seite 2, und Chromium paginiert jeden Grid-Track unabhängig — Sidebar und Main müssen beide gespacert werden, sonst läuft die Sidebar-Bahn flush gegen die Page-2-Oberkante.
C/07 · PHOTOS
Foto-Pipeline über 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.
C/08 · VRT
Pixelmatch Visual-Regression über alle 12 Templates × Paletten als Build-Gate. Baselines werden auf Linux-CI generiert (Font-Hinting drift), macOS-Dev läuft gegen Linux-PNGs, ein eigener update-baselines.yml-Workflow re-bakes die Snapshots auf Knopfdruck.
§ 03Architecture · YAML to PDF

How it renders.

Drei Spuren: oben der Input-Pfad (YAML → Zod → Photo → Registry), Mitte der Render-Pfad (Template → React-SSR → CSS-Vars → HTML), unten der Output-Pfad (Chromium → Paginate → Pixelmatch → PDF/CDN).
cvmake.codevena.dev·templates 12·palettes 34·packages 4·license MIT
ms/PDF 0·renders/h 0
YAML · 01
cv.{de,en}.yaml
source of truth · gitignored
loaderjs-yaml · CORE_SCHEMA
SCHEMA · 02
Zod · CVDataSchema
safeParse · file:line:col errors
contractYAML ↔ templates
PHOTO · 03
embedPhoto · base64
sharp · WebP+JPG · idempotent
embeddata: URL · no base-URL
REGISTRY · 04
12 templates
getTemplate(id) · validateTemplate
palettes3+ per template
TEMPLATE · 05
React component
TemplateProps · data + palette + labels
rangeacademic → tech-dev
SSR · 06
renderToStaticMarkup
react-dom/server · pure string
i18nlabels · DE/EN
CSSVARS · 07
palette → :root
--accent · --bg · --text · accentOverride
formathex · OKLCH-ready
HTML · 08
static document
html + css · render-context-free
fonts.ready0
CHROME · 09
Puppeteer singleton
browserPromise · pooled · sandbox-off
PAGINATE · 10
spacer injection
main + aside · 16pt · MAX_PAGES=12
VRT · 11
pixelmatch
Linux CI baselines · OS-asymmetric
gate12 × palettes · build-blocking
PDF · 12
out/cv.pdf
A4 · printBackground · CSS @page
deployCoolify · Hetzner · MIT
BUILD LOG · pnpm cvmake build · puppeteer · pixelmatch
14:02:11yamlloaded cv.de.yaml · locale=de inferred · 2.1 KB
14:02:11schemaCVDataSchema.safeParse · ok · 14 sections
14:02:11photoembedPhoto · /photos/markus.jpg · 84 KB · data: URL
14:02:11rendertemplate=modern-minimal · palette=fjord · ssr ok
14:02:11chromepage.fonts.ready settled · 148ms · variable sans
14:02:12paginatespacer inserted · main p#3 · aside p#3 · 2 pages
14:02:12pdfout/cv.pdf · 212ms · 184 KB · A4 · printBackground
14:02:14vrtpixelmatch · 12 templates × 3 palettes · 0 drift
14:02:11yamlloaded cv.de.yaml · locale=de inferred · 2.1 KB
14:02:11schemaCVDataSchema.safeParse · ok · 14 sections
14:02:11photoembedPhoto · /photos/markus.jpg · 84 KB · data: URL
14:02:11rendertemplate=modern-minimal · palette=fjord · ssr ok
14:02:11chromepage.fonts.ready settled · 148ms · variable sans
14:02:12paginatespacer inserted · main p#3 · aside p#3 · 2 pages
14:02:12pdfout/cv.pdf · 212ms · 184 KB · A4 · printBackground
14:02:14vrtpixelmatch · 12 templates × 3 palettes · 0 drift
§ 04Decisions · trade-offs

Six deliberate choices.

Pro Entscheidung: was gewählt, statt was, warum.
D/01

Puppeteer + headless Chrome statt jsPDF / pdf-lib / React-PDF.

chosen
React-SSR rendert das Template zu HTML, ein im Process-Memory gehaltener Puppeteer-browserPromise druckt mit preferCSSPageSize und printBackground nach PDF
instead of
jsPDF/PDFKit (manuelle Layout-Primitive) oder React-PDF (parallele Layout-Engine, eigenes Flexbox-Subset, kein echtes CSS)
reason
Der Browser ist die Layout-Engine, die ich für 12 Templates × Paletten ohnehin brauche — Flex, Grid, OpenType, Hyphenation, Print-CSS. Eine jsPDF-Route hätte bedeutet, CSS in JavaScript nachzubauen für jede Funktion, die ich schon habe. React-PDF kann das ein Stück weit, aber @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.
D/02

YAML + Zod + Locale-aus-Dateinamen statt JSON Resume oder DB-Backend.

chosen
cv.de.yaml/cv.en.yaml als Source of Truth, Locale aus dem Dateinamen inferiert, CVDataSchema.safeParse als Eingangstor, eigeneYAMLParseError/ValidationError mit file + line + column
instead of
JSON Resume (JSON, single-locale, runtime-Locale-Switch) oder eine DB-Tabelle hinter einer SaaS-Auth
reason
YAML ist greppbar, diffbar, kommentar-freundlich, copy-paste-freundlich — und der einzige Format-Choice, der ein git-natives Artifact ergibt. JSON Resume hat denselben Datengraph, aber ohne Kommentare und mit einer Multi-Locale-Story, die im eigenen Schema unbeholfen wird. Eine DB hinter SaaS-Auth ist die Anti-These der Story: der Nutzer übergibt seinen Lebenslauf an einen Anbieter, der morgen Preise erhöht oder das Produkt sunsetted. Zod als Vertrag zwischen YAML und Template heißt: ein fehlender Pflicht-Block wird beim Build gefangen, nicht beim PDF-Export. Locale aus dem Dateinamen ist ein kostenfreier Default — wer meta.locale explizit setzt, überschreibt es.
D/03

pnpm-Workspace mit 4 Packages + 3 Apps statt Single-Package-Repo.

chosen
@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/showcase
instead of
ein einziges Package mit src/cli, src/web, src/templates — alles in einem package.json
reason
Die Renderer-Trennung ist keine Day-2-Aufräum-Aktion, sie ist die Architektur: cvmake-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.
D/04

Insert-time-Pagination-Spacer statt @page-Margin.

chosen
Vor dem PDF-Print misst 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
instead of
@page { margin: 28mm 0 18mm 0 } und Chromium den Top-Spacer selbst rendern lassen
reason
@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.
D/05

Base64-data-URL-Photo-Embed statt Base-URL + Filesystem-Pfad.

chosen
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 ist
instead of
Puppeteer eine Base-URL übergeben und Templates auf relative Photo-Pfade ausrichten
reason
page.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.
D/06

Lockstep 4-Package npm-Publish + ein self-hosted Editor statt git-clone-only und serverless.

chosen
@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.
instead of
git-clone-only bleiben (fork-or-nothing), oder den Editor auf eine Serverless-Plattform deployen
reason
„Fork the repo" ist eine gute Contributor-Story und eine schwache First-PDF-Story — clone + install + build sind ~5 Minuten, 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.
§ 05Highlights · interesting bits

Things that were not obvious.

Edge-Cases und Details, die erst beim Bauen klar wurden.

Browser-Singleton vs per-Render Spawn

H/01
Die naive Implementation: 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

H/02
Templates laden Webfonts (variable Sans, Display-Serif, Mono). Naive Lösung: 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

H/03
Ein Template mit Sidebar (<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

H/04
Visual-Tests auf macOS-Dev erstellt, auf Linux-CI grün — oder umgekehrt. Font-Hinting unterscheidet sich systemweit: derselbe Text rendert pixelweise anders auf Cairo (Linux) und CoreText (macOS). Naive Pixelmatch-Baselines flippen damit zwischen Dev und CI.

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.
§ 06Stack · in production

What's running.

Working toolchain — nichts Theoretisches.
Next.js 16 · App Router · Web-EditorReact 18 · DOM-Server · RendererPuppeteer · headless ChromeZod · CVDataSchemapnpm 9 · Turbo · WorkspaceCommander 12 · CLITailwind 4 · postcssreact-hook-form · @hookform/resolverssharp · WebP + JPG · photo pipelinejs-yaml · CORE_SCHEMAVitest 2 · Unit + IntegrationPlaywright · e2e · load-edit-savepixelmatch · Visual RegressionBiome · lint + formatGitHub Actions · CI + baselines workflownpm · @codevena/cvmake-* · public packagesCoolify · dedicated HetznerCloudflare · push-to-deployMIT License · public OSS
§ 07Reflection · takeaways

What I learned.

Live als OSS-Tool auf cvmake.codevena.dev, 12 Templates im Showcase. Diese Dinge nehme ich in die nächsten mit.

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.

◦ NEXT CASE · 01 / 11
AzadiFeed
← alle Projekte