Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9ed80c2
feat(cv): add typed tailored CV variants
yotamon Sep 3, 2026
41d4483
feat(cv): add reusable PDF render CLI
yotamon Sep 3, 2026
02fa512
fix(cv): make variant CLI parsing deterministic
yotamon Sep 3, 2026
464dc38
test(cv): cover tailored variant resolution
yotamon Sep 3, 2026
a91f9f7
feat(cv): add cross-platform CV generator command
yotamon Sep 3, 2026
4385ae6
chore(cv): ignore generated CV PDFs
yotamon Sep 3, 2026
7d5e8c8
docs(cv): document repeatable tailored CV workflow
yotamon Sep 3, 2026
be2e9b6
test(cv): enforce two-page tailored PDF output
yotamon Sep 3, 2026
9739e9f
ci(cv): validate and render tailored CVs
yotamon Sep 3, 2026
9e7fee9
fix(cv): keep CLI compatible with production typecheck
yotamon Sep 3, 2026
723be89
feat(cv): add validated one-off tailoring payloads
yotamon Sep 3, 2026
f07eaa7
feat(cv): expose production PDF render API
yotamon Sep 3, 2026
e3b3769
test(cv): cover dynamic tailoring safeguards
yotamon Sep 3, 2026
3826366
test(cv): include dynamic tailoring in generator CI
yotamon Sep 3, 2026
345608c
fix(cv): satisfy react-pdf route render typing
yotamon Sep 3, 2026
5a6ef86
Align tailored CV generator with canonical CV
yotamon Sep 3, 2026
ba30a65
Guard canonical CV identity and positioning
yotamon Sep 3, 2026
adaa666
ci(cv): isolate generator validation stages
yotamon Sep 3, 2026
cf37923
test(cv): align PDF assertions with canonical positioning
yotamon Sep 3, 2026
484906d
Merge main into cv-generator-v2 and preserve canonical CV updates
yotamon Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/cv-generator.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: CV Generator

on:
push:
branches:
- cv-generator-v2
paths:
- 'lib/cv/**'
- 'app/**/cv/**'
- 'messages/src/en/cv.json'
- 'scripts/cv.mjs'
- 'scripts/render-cv-pdf.tsx'
- 'tests/cv/**'
- '.github/workflows/cv-generator.yml'
workflow_dispatch:

jobs:
validate-and-render:
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Test CV variant data
run: pnpm vitest run tests/cv/cv-variants.test.ts

- name: Test CV tailoring rules
run: pnpm vitest run tests/cv/cv-tailoring.test.ts

- name: Test canonical CV PDF export
run: pnpm vitest run tests/cv/cv-pdf-export.test.tsx

- name: Test tailored CV PDF export
run: pnpm vitest run tests/cv/cv-tailored-pdf-export.test.tsx

- name: Render CV PDFs
run: node scripts/cv.mjs render-all

- name: Upload CV PDFs
uses: actions/upload-artifact@v4
with:
name: cv-pdfs
path: generated/cv/*.pdf
if-no-files-found: error
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,6 @@ build_out/
/data/social/*.json
/data/social/*.log
!/data/social/*.example.json

# locally generated CV PDFs
/generated/cv/
189 changes: 189 additions & 0 deletions app/api/cv/render/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { createElement } from 'react';
import { inflateRawSync } from 'node:zlib';
import type { Readable } from 'node:stream';
import { pdf } from '@react-pdf/renderer';
import { CVDocument } from '@/app/[locale]/(standalone)/cv/CVDocument';
import { resolveCvPdfAssets } from '@/lib/cv/cv-media';
import { cvVariantIds, listCVVariants, resolveCVVariant, type CVVariantId } from '@/lib/cv/cv-variants';
import { parseCVTailoringInput, resolveTailoredCV } from '@/lib/cv/cv-tailoring';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 30;

const MAX_ENCODED_PAYLOAD_CHARS = 16_000;
const MAX_DECOMPRESSED_PAYLOAD_BYTES = 32_000;
const MAX_RENDER_REQUESTS_PER_MINUTE = 12;
const RATE_LIMIT_ENTRY_TTL_MS = 60_000;

interface RateLimitEntry {
count: number;
resetAt: number;
}

const rateLimitEntries = new Map<string, RateLimitEntry>();

function getClientKey(request: Request) {
return (
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'unknown'
);
}

function isRateLimited(request: Request) {
const now = Date.now();
const key = getClientKey(request);
const current = rateLimitEntries.get(key);

if (!current || current.resetAt <= now) {
rateLimitEntries.set(key, { count: 1, resetAt: now + RATE_LIMIT_ENTRY_TTL_MS });
return false;
}

current.count += 1;
if (rateLimitEntries.size > 200) {
for (const [entryKey, entry] of rateLimitEntries) {
if (entry.resetAt <= now) rateLimitEntries.delete(entryKey);
}
}

return current.count > MAX_RENDER_REQUESTS_PER_MINUTE;
}

function jsonResponse(body: unknown, status = 200) {
return Response.json(body, {
status,
headers: {
'Cache-Control': 'no-store',
'X-Robots-Tag': 'noindex, nofollow, noarchive',
},
});
}

function decodeCompressedPayload(encoded: string) {
if (encoded.length > MAX_ENCODED_PAYLOAD_CHARS) {
throw new Error('Encoded CV payload is too large');
}

const compressed = Buffer.from(encoded, 'base64url');
const inflated = inflateRawSync(compressed, {
maxOutputLength: MAX_DECOMPRESSED_PAYLOAD_BYTES,
});

return JSON.parse(inflated.toString('utf8')) as unknown;
}

async function renderPdfBuffer(cv: ReturnType<typeof resolveCVVariant>['cv']) {
const resolvedAssets = await resolveCvPdfAssets();
const document = createElement(CVDocument, { cv, resolvedAssets });
const stream = (await pdf(document as Parameters<typeof pdf>[0]).toBuffer()) as Readable;

return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];

stream.on('data', (chunk: Buffer | Uint8Array | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}

function sanitizeFilename(filename: string) {
return filename.replace(/["\r\n]/g, '').slice(0, 180);
}

async function pdfResponse(
resolved: ReturnType<typeof resolveCVVariant>,
options: { inline?: boolean; cache?: boolean } = {}
) {
const buffer = await renderPdfBuffer(resolved.cv);
const disposition = options.inline ? 'inline' : 'attachment';
const filename = sanitizeFilename(resolved.filename);

return new Response(new Uint8Array(buffer), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `${disposition}; filename="${filename}"`,
'Content-Length': String(buffer.byteLength),
'Cache-Control': options.cache
? 'public, max-age=0, s-maxage=86400, stale-while-revalidate=604800'
: 'private, no-store',
'X-Robots-Tag': 'noindex, nofollow, noarchive',
},
});
}

export async function GET(request: Request) {
if (isRateLimited(request)) {
return jsonResponse({ error: 'Too many CV render requests. Try again shortly.' }, 429);
}

const url = new URL(request.url);
const payload = url.searchParams.get('payload');
const variant = url.searchParams.get('variant');
const inline = url.searchParams.get('inline') === '1';

try {
if (payload) {
const tailoredInput = parseCVTailoringInput(decodeCompressedPayload(payload));
return pdfResponse(resolveTailoredCV(tailoredInput), { inline, cache: false });
}

if (variant) {
if (!cvVariantIds.includes(variant as CVVariantId)) {
return jsonResponse(
{ error: `Unknown CV variant: ${variant}`, variants: listCVVariants() },
404
);
}

return pdfResponse(resolveCVVariant(variant as CVVariantId), { inline, cache: true });
}

return jsonResponse({
endpoint: '/api/cv/render',
variants: listCVVariants(),
usage: {
namedVariant: '/api/cv/render?variant=fullstack-healthcare',
inlineNamedVariant: '/api/cv/render?variant=product-frontend&inline=1',
tailoredGet:
'Deflate-raw a JSON tailoring payload, base64url encode it, then pass it as ?payload=<value>.',
tailoredPost: 'POST the same tailoring payload as application/json.',
},
safeguards: [
'Company, role title, employment dates, duration, and location cannot be overridden.',
'Payload sizes and text lengths are bounded.',
'Render requests are rate-limited on a best-effort per-instance basis.',
],
});
} catch (error) {
return jsonResponse(
{ error: error instanceof Error ? error.message : 'Invalid CV render request' },
400
);
}
}

export async function POST(request: Request) {
if (isRateLimited(request)) {
return jsonResponse({ error: 'Too many CV render requests. Try again shortly.' }, 429);
}

try {
const text = await request.text();
if (Buffer.byteLength(text, 'utf8') > MAX_DECOMPRESSED_PAYLOAD_BYTES) {
return jsonResponse({ error: 'CV tailoring payload is too large' }, 413);
}

const input = parseCVTailoringInput(JSON.parse(text) as unknown);
return pdfResponse(resolveTailoredCV(input), { cache: false });
} catch (error) {
return jsonResponse(
{ error: error instanceof Error ? error.message : 'Invalid CV render request' },
400
);
}
}
111 changes: 111 additions & 0 deletions docs/cv-generator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Tailored CV Generator

The CV system is designed so that creating a new tailored CV does not require editing the PDF layout.

## Source of truth

- `messages/src/en/cv.json` contains the canonical public CV content.
- `lib/cv/cv-data.ts` normalizes that content into the render model.
- `lib/cv/cv-variants.ts` contains named tailoring configs.
- `app/[locale]/(standalone)/cv/CVDocument.tsx` owns the PDF layout.
- `scripts/render-cv-pdf.tsx` renders variants to PDF.
- `scripts/cv.mjs` is the cross-platform command wrapper.

The public CV remains the `default` variant. Tailored variants are overlays on top of the canonical data rather than copied CV documents.

## Everyday usage

List available variants:

```bash
node scripts/cv.mjs list
```

Render one variant:

```bash
node scripts/cv.mjs render product-frontend
node scripts/cv.mjs render fullstack-healthcare
node scripts/cv.mjs render product-ai
```

Render every registered variant:

```bash
node scripts/cv.mjs render-all
```

Use a temporary output directory while experimenting:

```bash
node scripts/cv.mjs render product-ai --output ./tmp/cv-experiments
```

By default PDFs are written to `generated/cv/`, which is gitignored.

## Adding a new tailored CV

Add one config object in `lib/cv/cv-variants.ts` and add its ID to `cvVariantIds`.

A variant can control:

- PDF filename
- document title
- headline
- summary and meta description
- experience ordering
- experience descriptions and highlights
- skill-group ordering
- skill labels and items
- portfolio project ordering

Do not copy `CVDocument.tsx` for a new role. The layout should remain shared.

## Factual safety

Variant overrides intentionally cannot change the identity fields of an employment record:

- company
- role title
- dates
- duration
- location

Those stay canonical. Tailoring is limited to emphasis, summaries, descriptions, highlights, ordering, and skill presentation.

If a factual employment field needs correction, update the canonical CV source instead of hiding the correction inside one tailored variant.

## Current targeting lanes

The initial reusable variants are:

| Variant | Intended use |
| --- | --- |
| `product-frontend` | Senior Product Engineer roles with strong React / Next.js / frontend ownership |
| `fullstack-healthcare` | Senior Full-Stack roles, especially healthcare, telemedicine, APIs, integrations, and regulated products |
| `product-ai` | Product-oriented AI / full-stack roles where AI is part of a shipped product rather than ML research or model infrastructure |

All three preserve the complete employment history while changing the first-screen positioning and technical emphasis.

## Tests

Variant tests live in `tests/cv/cv-variants.test.ts`.

Run them with:

```bash
pnpm vitest run tests/cv/cv-variants.test.ts
```

The existing PDF export test remains in `tests/cv/cv-pdf-export.test.tsx` and continues to protect searchable text, page count, contact links, and PDF rendering.

## Recommended workflow for a specific job

1. Score the job before tailoring. Only spend time on roles that are genuinely strong matches.
2. Start from the closest reusable lane instead of creating a CV from scratch.
3. Add a new variant only when the job needs materially different emphasis.
4. Keep every claim factual. Do not invent metrics or technologies.
5. Render to a temporary directory while iterating.
6. Once the copy is final, render the named PDF and use that exact file for the application.

This keeps the layout stable while making content experimentation cheap and reversible.
Loading
Loading