CV Generation with publishpipe¶
Overview¶
CV PDFs are currently hand-crafted files committed to client/public/. Any content
update (new role, changed skills, date bump) requires manually re-exporting from an
external tool, renaming, and re-committing. There is no single source of truth — the
TypeScript data modules in client/src/data/ are diverged from the PDF content.
publishpipe is a Bun-based
Markdown-to-PDF pipeline that converts .md files with YAML frontmatter → HTML
(Nunjucks templates) → print-ready PDF (pagedjs-cli). It supports English and Dutch
locale, multi-chapter projects, and variable substitution.
Constraint: The existing download flow (/api/download-cv, cv-download-dialog.tsx,
and the static PDFs in client/public/) is kept intact. publishpipe is an additive,
experimental feature for local authoring. Promotion to client/public/ is an
explicit opt-in step.
End goal: After the CI-based authoring flow is stable, Phases 1–5 are the foundation for Phase 6: a live, on-demand generation endpoint in the FastAPI backend that renders the CV from live API data at request time and returns the PDF directly to the user — no static files needed.
Current State¶
| File | Role |
|---|---|
client/public/CV Sven Relijveld 2025-07-01_English.pdf |
Static English CV, manually maintained |
client/public/CV Sven Relijveld 2025-07-01_Nederlands.pdf |
Static Dutch CV, manually maintained |
client/src/components/cv-download-dialog.tsx |
Language-selection dialog in the UI |
api/main.py (GET /api/download-cv) |
Rate-limited endpoint that serves the static PDFs |
e2e/cv-download.spec.ts |
Playwright tests for the download flow |
Data that should drive the CV content lives in:
| Module | Content |
|---|---|
client/src/data/experience.ts |
Work history (roles, companies, periods, technologies) |
client/src/data/skills.ts |
Skill categories and individual skills |
client/src/data/certifications.ts |
Certifications |
client/src/data/awards.ts |
Awards |
Phases¶
Phase 1 — Install publishpipe¶
- Create a
cv/directory at the repo root as a scoped workspace. - Install Bun (if not already available in CI/dev) — publishpipe requires Bun as its runtime.
- Add publishpipe as a dev dependency inside
cv/:
- Add two scripts to the root
package.json:
"cv:generate": "cd cv && bun run publishpipe build",
"cv:publish": "cp cv/dist/*.pdf client/public/"
cv:generate writes to cv/dist/ (isolated). cv:publish is the explicit
promotion step to client/public/.
Phase 2 — Author CV Markdown sources¶
Create two source files:
Each file uses YAML frontmatter for metadata and publishpipe variables:
Content is authored in Markdown, referencing the existing data modules in
client/src/data/ as the source of truth. Key sections:
- Profile — short summary paragraph
- Work Experience — from
experience.ts(sectionExperiences+pageExperiences) - Skills — from
skills.ts(grouped by category) - Certifications — from
certifications.ts - Awards — from
awards.ts
Use publishpipe's page-break syntax (--- or <!-- pagebreak -->) to control
pagination between sections.
Phase 3 — Configure publishpipe project¶
Create cv/publishpipe.config.yml (multi-chapter project mode):
Optionally create a Nunjucks layout template at cv/templates/cv.njk for
CV-specific styling (header with name/contact, running footer with page numbers,
two-column skill layout).
Phase 4 — Output directory¶
- Default output:
cv/dist/— never committed to git (add to.gitignore). - Promotion to
client/public/is explicit viabun run cv:publish. - Existing static PDFs remain unchanged until explicitly promoted.
Add to .gitignore:
Phase 5 — CI workflow¶
Add .github/workflows/generate-cv.yml:
name: Generate CV
on:
workflow_dispatch:
push:
branches: [main]
paths: [cv/**]
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: cd cv && bun install
- run: bun run cv:generate
- uses: actions/upload-artifact@v4
with:
name: cv-pdfs
path: cv/dist/*.pdf
The workflow uploads the generated PDFs as a build artifact. Committing them back to
client/public/ (promotion) remains a manual step.
Phase 6 — Runtime on-demand generation from live API data¶
Goal: Replace the static-file serving with a real-time pipeline: when a user requests a CV download, the backend fetches live data from its own API, renders the CV Markdown template, invokes publishpipe, and streams the resulting PDF back — all within a single HTTP request.
Architecture¶
User → GET /api/cv/generate?lang=en
│
▼
FastAPI handler
│
├─ 1. Fetch live data from internal API data sources
│ (projects, experience, skills, certifications, awards)
│
├─ 2. Render cv/templates/{lang}.md.j2 (Jinja2)
│ Injects fetched data into the Markdown template
│
├─ 3. Write rendered Markdown to a temp file
│
├─ 4. subprocess: bun run publishpipe build <tempfile>
│
├─ 5. Stream the generated PDF as a FileResponse
│
└─ 6. Clean up temp files (finally block)
New endpoint¶
- Rate-limited to 2 requests / minute per IP (generation is expensive; tighter
than the existing
/api/download-cvlimit of 10/min). - Returns
Content-Type: application/pdfwithContent-Disposition: attachment; filename="CV Sven Relijveld <date>_<lang>.pdf". - Timeout: 30 s (publishpipe + pagedjs-cli typically takes 5–10 s).
The existing /api/download-cv endpoint remains unchanged (static file fallback).
Markdown templates with Jinja2 placeholders¶
cv/english.md and cv/dutch.md (from Phase 2) are promoted to Jinja2 templates
(cv/templates/english.md.j2, cv/templates/dutch.md.j2). Dynamic sections use
Jinja2 syntax over the live data payload:
The Python backend renders these with jinja2.Environment before passing the result
to publishpipe, keeping publishpipe's role purely as Markdown → PDF.
Data sources¶
| CV section | API source |
|---|---|
| Work Experience | GET /api/experience (or direct Python data until PostgreSQL migration) |
| Skills | GET /api/skills |
| Projects | GET /api/projects (summary cards) |
| Certifications | In-process data in api/main.py (no endpoint yet) |
| Awards | In-process data in api/main.py (no endpoint yet) |
Initially the handler can access the Python data structures directly (same process) to avoid internal HTTP round-trips. After the PostgreSQL CRUD migration these become real async DB reads.
Bun in the backend container¶
publishpipe requires Bun. Add Bun to api/Dockerfile using the official install
script in a build stage, then copy the binary into the runtime stage:
# --- Bun stage ---
FROM oven/bun:1 AS bun-stage
# --- Runtime stage ---
FROM python:3.11-slim
COPY --from=bun-stage /usr/local/bin/bun /usr/local/bin/bun
COPY --from=bun-stage /usr/local/bin/bunx /usr/local/bin/bunx
# Install publishpipe + pagedjs-cli into the cv/ workspace
COPY cv/ /app/cv/
RUN cd /app/cv && bun install
pagedjs-cli has a Chromium dependency — pin to a headless-capable version and add
--no-sandbox flag (same approach as existing draw.io CI export via xvfb).
Frontend change¶
cv-download-dialog.tsx gets a second download option:
| Option | Endpoint | Label |
|---|---|---|
| Static (existing) | GET /api/download-cv?lang=… |
"Download CV (cached)" |
| Live (new) | GET /api/cv/generate?lang=… |
"Generate fresh CV" |
The dialog shows a loading spinner during generation and surfaces a toast on error.
Caching (optional, post-MVP)¶
To avoid re-generating on every click, add an in-memory or Redis cache keyed by
(lang, data_hash). A 1-hour TTL is sufficient; cache is invalidated when the
underlying data changes (hook into the write path of the PostgreSQL CRUD endpoints).
File Checklist¶
Phases 1–5 (CI authoring):
| File | Action |
|---|---|
cv/templates/english.md.j2 |
Create — English CV Jinja2 template |
cv/templates/dutch.md.j2 |
Create — Dutch CV Jinja2 template |
cv/publishpipe.config.yml |
Create — multi-project config |
cv/templates/cv.njk |
Create (optional) — custom Nunjucks layout |
cv/package.json |
Create — Bun workspace with publishpipe dep |
package.json |
Add cv:generate and cv:publish scripts |
.gitignore |
Add cv/dist/ and cv/node_modules/ |
.github/workflows/generate-cv.yml |
Create — CI artifact workflow |
Phase 6 (runtime generation):
| File | Action |
|---|---|
api/main.py |
Add GET /api/cv/generate endpoint with Jinja2 render + publishpipe subprocess |
api/requirements.txt |
Add jinja2 (if not already present) |
api/Dockerfile |
Add Bun multi-stage install + cv/ workspace copy |
client/src/components/cv-download-dialog.tsx |
Add "Generate fresh CV" option with loading state |
e2e/cv-download.spec.ts |
Add tests for the new dynamic endpoint |
Files not changed by Phases 1–5 (preserved until Phase 6):
| File | Reason |
|---|---|
client/public/CV*.pdf |
Untouched until explicit cv:publish |
api/main.py |
Existing /api/download-cv endpoint unchanged |
client/src/components/cv-download-dialog.tsx |
Existing UI unchanged |
e2e/cv-download.spec.ts |
Existing E2E tests unchanged |
Verification¶
Phases 1–5:
bun run cv:generateproduces two PDFs incv/dist/without errors.- PDFs open correctly and contain current role, skills, and certifications.
bun run cv:publishcopies them toclient/public/with correct filenames.GET /api/download-cv?lang=englishand?lang=nederlandsstill serve files correctly.playwright test e2e/cv-download.spec.tspasses without modification.- GitHub Actions workflow succeeds and uploads PDFs as a build artifact.
Phase 6:
GET /api/cv/generate?lang=englishreturns a valid PDF with live data within 30 s.- The generated PDF reflects the current API data (add a new experience entry and verify it appears in the downloaded PDF without any file promotion step).
- Rate limiting rejects a third request within 60 s with
HTTP 429. - "Generate fresh CV" button in the dialog shows a spinner during generation and triggers the correct download on success.
playwright test e2e/cv-download.spec.tscovers the new endpoint (mocked subprocess for CI speed; full integration test with real publishpipe in a separate job).
See Roadmap for related backlog items (CV download endpoint metrics).