Skip to content

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/:
cd cv && bun add --dev publishpipe
  • 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:

cv/
├── english.md      ← English CV
└── dutch.md        ← Dutch CV

Each file uses YAML frontmatter for metadata and publishpipe variables:

yaml
---
title: "CV  Sven Relijveld"
date: "2026-04-12"
locale: en # or nl
output: "CV Sven Relijveld {{ date }}_English.pdf"
theme: light
---

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):

yaml
projects:
  english:
    entry: english.md
    output: "CV Sven Relijveld {{ date }}_English.pdf"
    locale: en
    theme: light
  dutch:
    entry: dutch.md
    output: "CV Sven Relijveld {{ date }}_Nederlands.pdf"
    locale: nl
    theme: light

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 via bun run cv:publish.
  • Existing static PDFs remain unchanged until explicitly promoted.

Add to .gitignore:

cv/dist/
cv/node_modules/

Phase 5 — CI workflow

Add .github/workflows/generate-cv.yml:

yaml
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

python
GET /api/cv/generate?lang={english|nederlands}
  • Rate-limited to 2 requests / minute per IP (generation is expensive; tighter than the existing /api/download-cv limit of 10/min).
  • Returns Content-Type: application/pdf with Content-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:

markdown
{% for job in experience %}

### {{ job.title }} — {{ job.company }}

_{{ job.period }}_

{{ job.description }}

**Technologies:** {{ job.technologies | join(", ") }}
{% endfor %}

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:

dockerfile
# --- 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:

  1. bun run cv:generate produces two PDFs in cv/dist/ without errors.
  2. PDFs open correctly and contain current role, skills, and certifications.
  3. bun run cv:publish copies them to client/public/ with correct filenames.
  4. GET /api/download-cv?lang=english and ?lang=nederlands still serve files correctly.
  5. playwright test e2e/cv-download.spec.ts passes without modification.
  6. GitHub Actions workflow succeeds and uploads PDFs as a build artifact.

Phase 6:

  1. GET /api/cv/generate?lang=english returns a valid PDF with live data within 30 s.
  2. 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).
  3. Rate limiting rejects a third request within 60 s with HTTP 429.
  4. "Generate fresh CV" button in the dialog shows a spinner during generation and triggers the correct download on success.
  5. playwright test e2e/cv-download.spec.ts covers 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).