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

Every CV diffable. Every PDF reproducible.

A fork-friendly OSS builder that treats the résumé as YAML as the source of truth and renders it to PDF across 12 typographically composed templates. Zod schema as the contract, React SSR in the renderer, Puppeteer as the layout engine, pixelmatch as the visual-regression floor. One pnpm workspace, three apps (CLI · web editor · showcase), one renderer. On npm as @codevena/cvmake-cli, the editor live at editor.cvmake.codevena.dev. MIT-licensed, public on GitHub.

§ 01Problem · motivation

Why this exists.

CV builders on the market are either DB-hosted SaaS traps or Word templates with five fonts and three colours. Both ignore that a résumé is a versionable, personal artefact — not a row in someone else's database.

A résumé is a piece of text that gets rewritten fifteen times over ten years — language flips, ordering flips, photo flips, style flips. The tools on the market treat that like account onboarding: signup, fill in form, pay, download. What the user actually wants is a text file in their own git and a deterministic renderer.

cvmake puts that on the right side of the line: the repo ships the renderer, the templates, and a data/cvs/example.de.yaml; your own CV lands in data/cvs/cv.de.yaml and is gitignored. pnpm cvmake build cv.de.yaml produces the PDF — locally, no account, no server, no tracking. The web UI on top is a convenience layer that mutates the same YAML file; it's an interface, not a source.

§ 02Constraints · operating box

The box it had to fit in.

OSS and fork-friendly, renderer parity between CLI and web, visual regression as a build gate, photo pipeline without server assumptions.
C/01 · OSS POSTURE
MIT license, public repo, CONTRIBUTING.md, SECURITY.md, GitHub Actions CI. "Fork your own CV" is the canonical user story — the user's CV stays local-only (cv.*.yaml gitignored), only the example example.*.yaml is tracked.
C/02 · MONOREPO
pnpm 9 workspace + Turbo, four packages (schema, core, templates, ui), three apps (cli, web, showcase). The renderer is one package shared by CLI and web — layout drift between terminal export and browser preview is impossible because there is only one code path.
C/03 · YAML FIRST
cv.de.yaml, cv.en.yaml — one file per locale, locale inferred from the filename. The Zod schema in @codevena/cvmake-schema is the contract between YAML and templates; a parse error reports file + line + column, not an anonymous "validation failed".
C/04 · TEMPLATES
12 templates × 3+ palettes — academic, bauhaus, classic-serif, corporate, creative-accent, editorial, magazine, modern-minimal, monochrome-dark, noir, swiss, tech-dev. Every template is a React component that renders against the same TemplateProps signature.
C/05 · RENDERER
React-DOM-Server renders the template to static HTML, Puppeteer is the layout engine that prints it to PDF. Browser singleton in process memory, fonts.ready raced against a 5s timeout, preferCSSPageSize so page size lives in CSS.
C/06 · PAGINATION
Multi-page CVs need a top spacer from page 2 onward, and Chromium paginates each grid track independently — sidebar and main both need spacers, otherwise the sidebar track sits flush against the top of page 2.
C/07 · PHOTOS
Photo pipeline through sharp — upload, crop, WebP+JPG output. Before the PDF render embedPhoto() substitutes the image as a base64 data URL into the HTML: no base-URL tricks, the same HTML output renders in CLI, CI, and web preview without environmental assumptions.
C/08 · VRT
Pixelmatch visual regression across all 12 templates × palettes as a build gate. Baselines are generated on Linux CI (font hinting drift), macOS dev runs against the Linux PNGs, and a dedicated update-baselines.yml workflow re-bakes the snapshots on demand.
§ 03Architecture · YAML to PDF

How it renders.

Three lanes: top is the input path (YAML → Zod → photo → registry), middle is the render path (template → React SSR → CSS vars → HTML), bottom is the output path (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.

Per decision: what was chosen, instead of what, and why.
D/01

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

chosen
React SSR renders the template to HTML, a process-memory browserPromise prints with preferCSSPageSize and printBackground to PDF.
instead of
jsPDF/PDFKit (manual layout primitives) or React-PDF (parallel layout engine, its own flexbox subset, no real CSS)
reason
The browser is the layout engine I need for 12 templates × palettes anyway — flex, grid, OpenType, hyphenation, print CSS. A jsPDF route would have meant reimplementing CSS in JavaScript for every feature I already get for free. React-PDF can carry part of the load, but @page, break-inside, OKLCH colours, and variable fonts are browser features. The price is shipping Chromium in the deploy artefact (~300 MB), paid deliberately. Web editor and CLI render the same pixels because they ask the same rendering engine — no layout drift between preview and export.
D/02

YAML + Zod + locale-from-filename instead of JSON Resume or a DB backend.

chosen
cv.de.yaml/cv.en.yaml as the source of truth, locale inferred from the filename, CVDataSchema.safeParse as the entry gate, custom YAMLParseError/ValidationError reporting file + line + column.
instead of
JSON Resume (JSON, single-locale, runtime locale switching) or a DB row behind SaaS auth
reason
YAML is greppable, diffable, comment-friendly, copy-paste- friendly — and the only format choice that produces a git-native artefact. JSON Resume has the same data graph but loses comments and turns multi-locale into a clumsy story. A DB behind SaaS auth is the anti-thesis: the user hands their CV to a vendor who raises prices or sunsets the product tomorrow. Zod as a contract between YAML and template means a missing required block is caught at build, not at export. Locale-from-filename is a free default — anyone who explicitly sets meta.locale overrides it.
D/03

pnpm workspace with 4 packages + 3 apps instead of a single-package repo.

chosen
@codevena/cvmake-schema (Zod contracts) → cvmake-core (renderer + loader + photo) → cvmake-templates (12 React templates) → cvmake-ui, consumed by apps/cli, apps/web (Next.js 16), and apps/showcase.
instead of
one package with src/cli, src/web, src/templates — everything in a single package.json
reason
The split isn't a day-2 tidy-up, it's the architecture: cvmake-templates can publish without the CLI, the schema can be imported by third-party templates, the web editor builds on the same renderCV() as the CLI — no layout drift possible because there is only one render function. Turbo caches per package; a template edit doesn't rebuild the whole workspace. The price is the bootstrap complexity (workspace protocol, pinned versions, build order). You pay that price once; layout drift between two render paths charges interest on every edit.
D/04

Insert-time pagination spacers instead of @page margins.

chosen
Before printing, page.evaluate() measures the natural page breaks in the rendered DOM and injects a 16pt spacer div before the element that would otherwise sit flush against page top — for <main> and <aside> separately, because Chromium paginates grid tracks independently.
instead of
@page { margin: 28mm 0 18mm 0 } and let Chromium render the top spacer itself
reason
@page margin reserves space on every page including page 1 — the hero header shifts, the full-bleed sidebar gradient breaks. What I actually need is top breathing space from page 2 onward, not from page 1. Chromium also paginates each CSS grid track independently: a spacer div inside <main> does not push the sidebar children down, because the sidebar lives in its own track container. Solution: the spacer-injection code measures getBoundingClientRect of each child per container, finds the first child that would cross the page boundary, and inserts a div.cv-page-spacer before it. MAX_PAGES=12 as a safety cap so a degenerate layout can't loop the renderer hot forever.
D/05

Base64 data-URL photo embedding instead of base-URL + filesystem path.

chosen
embedPhoto() reads the image, sniffs the mimetype from the extension, base64-encodes it, and replaces src with a data: URL — idempotent if the photo is already a data URL.
instead of
passing Puppeteer a base URL and authoring templates against relative photo paths
reason
page.setContent() ships with no base URL by default, so relative photo paths would point nowhere. Setting a base URL means either spinning up a local dev server (the CLI becomes an HTTP server) or juggling file:// paths (CI-specific, permission-flaky). The data URL is render-context-free: the same HTML string renders in CLI, CI, and web preview without any of the three environments making a server assumption. Idempotency: anyone who hands in a photo as a data URL already (e.g., from the web editor that encodes directly) gets it back unchanged — no double base64 layering.
D/06

Lockstep 4-package npm publish + a self-hosted editor instead of git-clone-only and serverless.

chosen
@codevena/cvmake-* (schema, core, templates, cli) published to npm in lockstep at one shared version; npx @codevena/cvmake-cli build cv.yaml as the zero-clone path. The web editor runs as a long-lived container on Coolify/Hetzner at editor.cvmake.codevena.dev; the showcase stays static on GitHub Pages.
instead of
staying git-clone-only (fork-or-nothing), or deploying the editor to a serverless platform
reason
"Fork the repo" is a great contributor story and a poor first-PDF story — clone + install + build is ~5 minutes, npx is ~30 seconds. The CLI's three workspace deps force the publish all-four-or-nothing — cvmake-cli alone wouldn't resolve — so the four packages release in lockstep at one shared version and the cross-dependency contract stays trivial. The editor can't go serverless: Puppeteer wants a real Node process and a ~300 MB Chromium, so it is a container on a box, not a function. And a public editor cannot be the local-first editor — it was built to read and write cv.*.yaml on disk, but every visitor would clobber the same files. NEXT_PUBLIC_DEMO_MODE retrofits a stateless posture — example CVs only, no autosave, photo crop done entirely client-side, "Download YAML" instead of disk. The non-obvious trap: NEXT_PUBLIC_* vars are inlined at build time, so setting the flag only at container runtime split-brained the server (demo) against the client (not-demo) — it has to be set before next build.
§ 05Highlights · interesting bits

Things that were not obvious.

Edge cases and details that only became clear during build.

Browser singleton vs per-render spawn

H/01
The naive implementation: puppeteer.launch() per PDF export, browser.close() at the end. 1.5–2.5s spin-up per export — for a live preview that re-renders a PDF on every form edit, that is the entire perceived latency.

Fix: let browserPromise: Promise<Browser> | null at module scope, lazily initialised, reused process-wide. shutdownPdfBrowser() as an explicit teardown hook for CLI and tests. Each export opens only a new page — the expensive browser bootstrap happens once per process. 2s becomes sub-200ms from the second render onward; CLI single-shot spins up once and closes cleanly.

fonts.ready raced against a timeout

H/02
Templates load webfonts (variable sans, display serif, mono). Naive solution: waitUntil: "networkidle0" then print. Wrong — networkidle means "no outstanding requests", not "fonts rendered". On slow networks the first render slips through before glyphs are swapped: PDF in fallback font, half-styled output.

Fix: page.evaluate(() => document.fonts.ready) raced against a 5s timeout. Fast renders wait exactly as long as the browser needs; a stuck font provider blocks the export for at most 5 seconds, not forever. The timeout is a safety net, not the default path — typical renders settle in 100–300ms.

Grid tracks paginate independently

H/03
A template with a sidebar (<aside> + <main> as a CSS grid) does not paginate like a single column in Chromium — sidebar and main are separate pagination tracks. If the sidebar is 1.2 pages long, the overflow lands flush at the top of page 2; main may have a section header there already, but the sidebar ignores it.

Fix: the spacer-injection code finds <main> and <aside> separately and treats each as its own pagination track. Per track it looks for the first child that crosses the page boundary and inserts a spacer before it. The result: both columns get the same top breathing space from page 2 onward, and page continuation reads as a single flow.

Visual regression baselines on Linux

H/04
Visual tests authored on macOS dev, green on Linux CI — or the other way around. Font hinting differs across OSes: the same text renders pixel-different on Cairo (Linux) and CoreText (macOS). Naive pixelmatch baselines flip between dev and CI.

Fix: baselines are generated only on Linux CI (.github/workflows/update-baselines.yml), packages/templates/__tests__/__visual__/<id>/*.png is the source of truth. macOS dev runs visual tests against the Linux PNGs and accepts the drift — the re-bake button is a manually triggered workflow. The OS drift becomes an asymmetric decision: CI is authoritative, dev is preview.
§ 06Stack · in production

What's running.

Working toolchain — nothing theoretical.
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 as an OSS tool at cvmake.codevena.dev with 12 templates in the showcase. These are the things I'm taking forward.

The browser is the layout engine.

My first reflex was jsPDF — lightweight, no Chromium dependency, "proper" in some abstract sense. One weekend later I had columns manually positioned on the jsPDF canvas and realised I was building a parallel CSS implementation in JavaScript. Every template idea turned into "can I get this through jsPDF" instead of "can I get this through CSS". Switching to Puppeteer trades container size for design speed — and for a CV builder design speed is what produces templates. Chromium isn't the problem; a second layout system is the problem.

YAML as the user surface, the editor as convenience.

The web UI was a temptation to make it the source of truth — forms are the natural editing surface, a DB row is the natural persistence surface, an account is the natural recovery surface. Four steps and cvmake would have become exactly what it was trying not to be: another SaaS trap. Fix: the web editor mutates the YAML file on disk, that file is the source of truth, the user's repo is storage. Result: the web app is stateless, the CV is vendor- independent, and the same user can edit in the browser one day and in vim the next — both write the same file. Convenience without vendor lock. The public editor at editor.cvmake.codevena.dev pushes the same idea one step further — a stateless demo: edit, preview, export, download the YAML. The tool never owns your data, local or hosted.

◦ NEXT CASE · 01 / 11
AzadiFeed
← all projects