feat(cv-export): add PDF export functionality with async job support

Implement PDF export feature with both synchronous and asynchronous modes. Includes:
- New cv-export-server service using Puppeteer
- Shared printable HTML builder module
- ExportControls React component with job status tracking
- Classic template for PDF output
- API endpoints for job management

The system supports cancelable async jobs with polling and error handling. Both client and server share the same HTML rendering logic via the shared-printable module.
This commit is contained in:
geulah 2025-10-06 01:10:02 +01:00
parent adeb8473b7
commit 0ebf5fe3de
20 changed files with 3269 additions and 153 deletions

View File

@ -13,6 +13,7 @@
"@tiptap/react": "^3.6.5",
"@tiptap/starter-kit": "^3.6.5",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"dompurify": "^3.2.7",
"postcss": "^8.5.6",
"react": "^19.1.1",
@ -2658,6 +2659,12 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@ -2695,6 +2702,17 @@
"postcss": "^8.1.0"
}
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -2768,6 +2786,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -2845,6 +2876,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -2911,6 +2954,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -2930,6 +2982,20 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.230",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.230.tgz",
@ -2962,6 +3028,51 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
@ -3327,6 +3438,42 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -3355,6 +3502,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@ -3365,6 +3521,43 @@
"node": ">=6.9.0"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -3391,6 +3584,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -3415,6 +3620,45 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -3907,6 +4151,15 @@
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
@ -3937,6 +4190,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -4367,6 +4641,12 @@
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@ -15,6 +15,7 @@
"@tiptap/react": "^3.6.5",
"@tiptap/starter-kit": "^3.6.5",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"dompurify": "^3.2.7",
"postcss": "^8.5.6",
"react": "^19.1.1",

View File

@ -0,0 +1,66 @@
import axios from 'axios';
import type { CV } from '../schema/cvSchema';
export interface ExportResult { filename: string; }
export interface JobResponse { jobId: string; }
export interface JobStatus { status: 'queued' | 'done' | 'error' | 'canceled'; filename?: string; error?: string; }
// Synchronous export (no job)
export async function exportPdf(cv: CV, endpoint = 'http://localhost:4000/export/pdf', templateId?: string): Promise<ExportResult> {
const response = await axios.post(endpoint, { ...cv, templateId }, { responseType: 'blob' });
const contentDisposition = response.headers['content-disposition'] || '';
const match = /filename="?([^";]+)"?/i.exec(contentDisposition);
const filename = match ? match[1] : `${cv.personal.firstName || 'Candidate'}_${cv.personal.lastName || 'CV'}.pdf`;
const blob = new Blob([response.data], { type: 'application/pdf' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
return { filename };
}
// Async export: request job, poll status with backoff, then download
export async function requestExportJob(cv: CV, endpoint = 'http://localhost:4000/export/pdf', templateId?: string): Promise<string> {
const response = await axios.post<JobResponse>(`${endpoint}?async=1`, { ...cv, templateId });
return response.data.jobId;
}
export async function pollJobStatus(jobId: string, baseUrl = 'http://localhost:4000', initialDelayMs = 500, maxDelayMs = 5000, maxAttempts = 10): Promise<JobStatus> {
let attempt = 0;
let delay = initialDelayMs;
while (attempt < maxAttempts) {
const { data } = await axios.get<JobStatus>(`${baseUrl}/export/status/${jobId}`);
if (data.status === 'done' || data.status === 'error' || data.status === 'canceled') return data;
await new Promise(r => setTimeout(r, delay));
delay = Math.min(Math.round(delay * 1.6), maxDelayMs);
attempt += 1;
}
return { status: 'error', error: 'Timeout waiting for export' };
}
export async function downloadJob(jobId: string, baseUrl = 'http://localhost:4000'): Promise<ExportResult> {
const response = await axios.get(`${baseUrl}/export/download/${jobId}`, { responseType: 'blob' });
const contentDisposition = response.headers['content-disposition'] || '';
const match = /filename="?([^";]+)"?/i.exec(contentDisposition);
const filename = match ? match[1] : `CV.pdf`;
const blob = new Blob([response.data], { type: 'application/pdf' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
return { filename };
}
export async function cancelJob(jobId: string, baseUrl = 'http://localhost:4000'): Promise<void> {
await axios.post(`${baseUrl}/export/cancel/${jobId}`);
}

View File

@ -0,0 +1,149 @@
import React, { useRef, useState } from 'react';
import { useCvStore } from '../store/cvStore';
import { exportPdf, requestExportJob, pollJobStatus, downloadJob, cancelJob } from '../api/export';
interface ExportControlsProps {
endpoint?: string;
className?: string;
}
const ExportControls: React.FC<ExportControlsProps> = ({ endpoint = 'http://localhost:4000/export/pdf', className = '' }) => {
const cv = useCvStore(state => state.cv);
const templateId = useCvStore(state => state.cv.templateId);
const updateTemplateId = useCvStore(state => state.updateTemplateId);
const [status, setStatus] = useState<'idle' | 'exporting' | 'error' | 'done'>('idle');
const [error, setError] = useState<string | null>(null);
const [lastFilename, setLastFilename] = useState<string | null>(null);
const [asyncMode, setAsyncMode] = useState<boolean>(true);
const currentJobId = useRef<string | null>(null);
async function handleExport() {
setStatus('exporting');
setError(null);
try {
if (!asyncMode) {
const { filename } = await exportPdf(cv, endpoint, templateId);
setLastFilename(filename);
setStatus('done');
setTimeout(() => setStatus('idle'), 1500);
return;
}
const jobId = await requestExportJob(cv, endpoint, templateId);
currentJobId.current = jobId;
const statusResult = await pollJobStatus(jobId, new URL(endpoint).origin);
if (statusResult.status === 'error') {
throw new Error(statusResult.error || 'Export job failed');
}
if (statusResult.status === 'canceled') {
setStatus('idle');
setError('Export canceled');
currentJobId.current = null;
return;
}
const { filename } = await downloadJob(jobId, new URL(endpoint).origin);
setLastFilename(filename);
setStatus('done');
setTimeout(() => setStatus('idle'), 1500);
} catch (err: unknown) {
let message = 'Export failed';
if (typeof err === 'object' && err !== null) {
const resp = (err as { response?: { data?: { error?: string } } }).response;
if (resp?.data?.error) {
message = resp.data.error;
} else if ('message' in err && typeof (err as { message?: string }).message === 'string') {
message = (err as { message?: string }).message as string;
}
}
setError(message);
setStatus('error');
}
}
async function handleCancel() {
if (!currentJobId.current) return;
try {
await cancelJob(currentJobId.current, new URL(endpoint).origin);
setStatus('idle');
setError(null);
currentJobId.current = null;
} catch (err) {
// Keep subtle; cancellation failures shouldn't block
console.warn('Cancel failed', err);
}
}
async function handleRetry() {
// reset and re-run export
setError(null);
await handleExport();
}
return (
<div className={`flex items-center gap-2 ${className}`}>
<label className="text-xs text-gray-600">Template</label>
<select
className="px-2 py-1 text-xs border rounded"
value={templateId}
onChange={e => updateTemplateId(e.target.value)}
disabled={status === 'exporting'}
>
<option value="ats">ATS-Friendly</option>
<option value="classic">Classic</option>
</select>
<label className="ml-2 text-xs text-gray-600">Async</label>
<input
type="checkbox"
className="ml-1"
checked={asyncMode}
onChange={e => setAsyncMode(e.target.checked)}
disabled={status === 'exporting'}
/>
<button
type="button"
onClick={handleExport}
disabled={status === 'exporting'}
className={`px-3 py-1.5 rounded-md text-sm border ${status === 'exporting' ? 'bg-gray-300 text-gray-700 border-gray-300' : 'bg-green-600 text-white border-green-600 hover:bg-green-700'}`}
title="Export to PDF"
>
{status === 'exporting' ? (
<span className="inline-flex items-center gap-1">
<span className="animate-spin inline-block w-3 h-3 border-2 border-white border-t-transparent rounded-full" />
Exporting
</span>
) : 'Export PDF'}
</button>
{status === 'exporting' && asyncMode && (
<button
type="button"
onClick={handleCancel}
className="px-2 py-1 rounded-md text-xs border bg-gray-100 text-gray-800 border-gray-300 hover:bg-gray-200"
title="Cancel export job"
>
Cancel
</button>
)}
{status === 'error' && (
<button
type="button"
onClick={handleRetry}
className="px-2 py-1 rounded-md text-xs border bg-orange-500 text-white border-orange-500 hover:bg-orange-600"
title="Retry export"
>
Retry
</button>
)}
{status === 'error' && (
<span className="text-xs text-red-600">{error}</span>
)}
{status === 'done' && lastFilename && (
<span className="text-xs text-gray-600">Downloaded: {lastFilename}</span>
)}
</div>
);
};
export default ExportControls;

View File

@ -1,7 +1,9 @@
import React, { useMemo, useState } from 'react';
import { useCvStore } from '../store/cvStore';
import ATSTemplate from '../templates/ATSTemplate';
import ClassicTemplate from '../templates/ClassicTemplate';
import { buildPrintableHtml } from '../utils/printable';
import ExportControls from './ExportControls';
interface PreviewPanelProps {
className?: string;
@ -37,6 +39,7 @@ const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
Printable
</button>
</div>
<ExportControls className="ml-3" />
</div>
</div>
@ -45,6 +48,7 @@ const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
<>
{/* Render the appropriate template based on templateId */}
{templateId === 'ats' && <ATSTemplate cv={cv} />}
{templateId === 'classic' && <ClassicTemplate cv={cv} />}
{/* Add more template options here as they are implemented */}
</>
)}

View File

@ -0,0 +1,133 @@
import React from 'react';
import type { CV } from '../schema/cvSchema';
interface ClassicTemplateProps {
cv: CV;
}
// Simple classic visual template for inline preview
const ClassicTemplate: React.FC<ClassicTemplateProps> = ({ cv }) => {
const personal = cv.personal || {};
const summary = cv.summary || '';
const work = cv.work || [];
const education = cv.education || [];
const skills = cv.skills || [];
const languages = cv.languages || [];
const certifications = cv.certifications || [];
return (
<div className="bg-white p-6 rounded-md shadow text-gray-800">
<header className="border-b pb-3 mb-4">
<h1 className="text-2xl font-semibold">
{personal.firstName} {personal.lastName}
</h1>
<div className="mt-1 text-sm text-gray-600 flex flex-wrap gap-2">
{personal.email && <span>{personal.email}</span>}
{personal.phone && <span>{personal.phone}</span>}
{(personal.city || personal.country) && (
<span>
{personal.city}{personal.city && personal.country ? ', ' : ''}{personal.country}
</span>
)}
</div>
</header>
{summary && (
<section className="mb-4">
<h2 className="text-lg font-medium text-gray-700 mb-2">Professional Summary</h2>
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: summary }} />
</section>
)}
{work.length > 0 && (
<section className="mb-4">
<h2 className="text-lg font-medium text-gray-700 mb-2">Work Experience</h2>
<div className="space-y-3">
{work.map((job) => (
<article key={job.id} className="">
<div className="flex justify-between items-start">
<div>
<h3 className="text-base font-semibold">{job.title}</h3>
<div className="text-sm text-gray-600">{job.company}</div>
</div>
<div className="text-sm text-gray-600">
{job.startDate} - {job.endDate || 'Present'}
{job.location && <div>{job.location}</div>}
</div>
</div>
{job.bullets && job.bullets.length > 0 && (
<ul className="list-disc pl-6 mt-2">
{job.bullets.map((b, i) => (
<li key={i}>{b}</li>
))}
</ul>
)}
</article>
))}
</div>
</section>
)}
{education.length > 0 && (
<section className="mb-4">
<h2 className="text-lg font-medium text-gray-700 mb-2">Education</h2>
<div className="space-y-3">
{education.map((ed) => (
<article key={ed.id}>
<div className="flex justify-between items-start">
<div>
<h3 className="text-base font-semibold">{ed.degree}</h3>
<div className="text-sm text-gray-600">{ed.school}</div>
</div>
<div className="text-sm text-gray-600">
{ed.startDate} {ed.endDate && <>- {ed.endDate}</>}
</div>
</div>
{/* GPA not present in schema; omit for now */}
</article>
))}
</div>
</section>
)}
{skills.length > 0 && (
<section className="mb-4">
<h2 className="text-lg font-medium text-gray-700 mb-2">Skills</h2>
<div className="flex flex-wrap gap-2">
{skills.map((s) => (
<span key={s.id} className="px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-sm">
{s.name}{s.level ? `${s.level}` : ''}
</span>
))}
</div>
</section>
)}
{languages.length > 0 && (
<section className="mb-4">
<h2 className="text-lg font-medium text-gray-700 mb-2">Languages</h2>
<div className="flex flex-wrap gap-2">
{languages.map((l) => (
<span key={l.id} className="px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-sm">
{l.name}{l.level ? `${l.level}` : ''}
</span>
))}
</div>
</section>
)}
{certifications.length > 0 && (
<section className="mb-2">
<h2 className="text-lg font-medium text-gray-700 mb-2">Certifications</h2>
<ul className="list-disc pl-6">
{certifications.map((c) => (
<li key={c.id}>{c.name}{c.issuer ? ` - ${c.issuer}` : ''}{c.date ? ` (${c.date})` : ''}</li>
))}
</ul>
</section>
)}
</div>
);
};
export default ClassicTemplate;

View File

@ -0,0 +1,4 @@
declare module '../../../shared-printable/buildPrintableHtml.js' {
import type { CV } from '../schema/cvSchema';
export function buildPrintableHtml(cv: CV): string;
}

View File

@ -1,156 +1,12 @@
import type { CV } from '../schema/cvSchema';
import { escapeText, sanitizeHtml } from '../schema/cvSchema';
const formatDate = (dateStr?: string): string => {
if (!dateStr) return '';
try {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short' });
} catch {
return dateStr;
}
};
import { sanitizeHtml } from '../schema/cvSchema';
import { buildPrintableHtml as sharedBuild } from '../../../shared-printable/buildPrintableHtml.js';
// Thin wrapper to ensure client-side sanitization and shared rendering parity
export const buildPrintableHtml = (cv: CV): string => {
const { personal, summary, work, education, skills, languages, certifications } = cv;
const summaryHtml = summary ? sanitizeHtml(summary) : '';
const styles = `
<style>
@page { size: A4; margin: 20mm; }
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans, "Apple Color Emoji", "Segoe UI Emoji"; color: #1f2937; }
.page { width: 210mm; min-height: 297mm; margin: 0 auto; background: white; }
h1 { font-size: 24px; margin: 0 0 6px 0; }
h2 { font-size: 16px; margin: 16px 0 8px 0; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; color: #374151; }
h3 { font-size: 14px; margin: 0; }
.header { border-bottom: 1px solid #e5e7eb; padding-bottom: 12px; margin-bottom: 16px; }
.meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #6b7280; }
.section { margin-bottom: 16px; }
.job, .edu { margin-bottom: 12px; }
.row { display: flex; justify-content: space-between; align-items: flex-start; }
.small { font-size: 12px; color: #6b7280; }
ul { padding-left: 20px; }
li { margin: 4px 0; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { background: #f3f4f6; color: #1f2937; padding: 2px 8px; border-radius: 12px; font-size: 12px; }
</style>
`;
const headerLinks = (personal.links || [])
.map(l => `<a href="${escapeText(l.url)}" target="_blank" rel="noopener noreferrer">${escapeText(l.label || l.url)}</a>`)
.join(' · ');
const headerHtml = `
<div class="header">
<h1>${escapeText(personal.firstName)} ${escapeText(personal.lastName)}</h1>
<div class="meta">
${personal.email ? `<span>${escapeText(personal.email)}</span>` : ''}
${personal.phone ? `<span>${escapeText(personal.phone)}</span>` : ''}
${(personal.city || personal.country) ? `<span>${personal.city ? escapeText(personal.city) : ''}${personal.city && personal.country ? ', ' : ''}${personal.country ? escapeText(personal.country) : ''}</span>` : ''}
${headerLinks ? `<span>${headerLinks}</span>` : ''}
</div>
</div>
`;
const summarySection = summaryHtml ? `
<div class="section">
<h2>Professional Summary</h2>
<div>${summaryHtml}</div>
</div>
` : '';
const workSection = (work && work.length) ? `
<div class="section">
<h2>Work Experience</h2>
${work.map(job => `
<div class="job">
<div class="row">
<div>
<h3>${escapeText(job.title)}</h3>
<div class="small">${escapeText(job.company)}</div>
</div>
<div class="small">
${formatDate(job.startDate)} - ${job.endDate ? formatDate(job.endDate) : 'Present'}
${job.location ? `<div>${escapeText(job.location)}</div>` : ''}
</div>
</div>
${(job.bullets && job.bullets.length) ? `
<ul>
${job.bullets.map(b => `<li>${escapeText(b)}</li>`).join('')}
</ul>
` : ''}
</div>
`).join('')}
</div>
` : '';
const eduSection = (education && education.length) ? `
<div class="section">
<h2>Education</h2>
${education.map(edu => `
<div class="edu">
<div class="row">
<div>
<h3>${escapeText(edu.degree)}</h3>
<div class="small">${escapeText(edu.school)}</div>
</div>
<div class="small">
${formatDate(edu.startDate)} - ${edu.endDate ? formatDate(edu.endDate) : 'Present'}
</div>
</div>
${edu.notes ? `<div class="small">${escapeText(edu.notes)}</div>` : ''}
</div>
`).join('')}
</div>
` : '';
const skillsSection = (skills && skills.length) ? `
<div class="section">
<h2>Skills</h2>
<div class="chips">
${skills.map(s => `<span class="chip">${escapeText(s.name)}${s.level ? ` (${s.level})` : ''}</span>`).join('')}
</div>
</div>
` : '';
const languagesSection = (languages && languages.length) ? `
<div class="section">
<h2>Languages</h2>
<div class="chips">
${languages.map(l => `<span class="chip">${escapeText(l.name)}${l.level ? ` (${l.level})` : ''}</span>`).join('')}
</div>
</div>
` : '';
const certsSection = (certifications && certifications.length) ? `
<div class="section">
<h2>Certifications</h2>
<ul>
${certifications.map(c => `<li>${escapeText(c.name)}${c.issuer ? ` - ${escapeText(c.issuer)}` : ''}${c.date ? ` (${formatDate(c.date)})` : ''}</li>`).join('')}
</ul>
</div>
` : '';
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Printable CV</title>
${styles}
</head>
<body>
<div class="page">
${headerHtml}
${summarySection}
${workSection}
${eduSection}
${skillsSection}
${languagesSection}
${certsSection}
</div>
</body>
</html>`;
const cleaned: CV = {
...cv,
summary: cv.summary ? sanitizeHtml(cv.summary) : ''
};
return sharedBuild(cleaned);
};

View File

@ -24,5 +24,8 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": [
"src",
"../shared-printable/buildPrintableHtml.d.ts"
]
}

View File

@ -4,4 +4,9 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
fs: {
allow: ['..']
}
}
})

24
cv-export-server/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

2123
cv-export-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
{
"name": "cv-export-server",
"version": "1.0.0",
"description": "",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "NODE_ENV=development node server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"puppeteer": "^24.23.0",
"sanitize-html": "^2.17.0"
}
}

281
cv-export-server/server.js Normal file
View File

@ -0,0 +1,281 @@
import express from 'express';
import cors from 'cors';
import puppeteer from 'puppeteer';
import sanitizeHtml from 'sanitize-html';
import { buildPrintableHtml as sharedBuild } from '../shared-printable/buildPrintableHtml.js';
// Minimal escape function to avoid breaking HTML; summary is sanitized client-side.
function escapeText(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function buildPrintableHtml(cv) {
const { personal = {}, summary = '', work = [], education = [], skills = [], languages = [], certifications = [] } = cv || {};
const styles = `
<style>
@page { size: A4; margin: 20mm; }
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans; color: #1f2937; }
.page { width: 210mm; min-height: 297mm; margin: 0 auto; background: white; }
h1 { font-size: 24px; margin: 0 0 6px 0; }
h2 { font-size: 16px; margin: 16px 0 8px 0; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; color: #374151; }
h3 { font-size: 14px; margin: 0; }
.header { border-bottom: 1px solid #e5e7eb; padding-bottom: 12px; margin-bottom: 16px; }
.meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #6b7280; }
.section { margin-bottom: 16px; }
.job, .edu { margin-bottom: 12px; }
.row { display: flex; justify-content: space-between; align-items: flex-start; }
.small { font-size: 12px; color: #6b7280; }
ul { padding-left: 20px; }
li { margin: 4px 0; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { background: #f3f4f6; color: #1f2937; padding: 2px 8px; border-radius: 12px; font-size: 12px; }
</style>
`;
const headerLinks = (personal.links || [])
.map(l => `<a href="${escapeText(l.url)}" target="_blank" rel="noopener noreferrer">${escapeText(l.label || l.url)}</a>`)
.join(' · ');
const headerHtml = `
<div class="header">
<h1>${escapeText(personal.firstName || '')} ${escapeText(personal.lastName || '')}</h1>
<div class="meta">
${personal.email ? `<span>${escapeText(personal.email)}</span>` : ''}
${personal.phone ? `<span>${escapeText(personal.phone)}</span>` : ''}
${(personal.city || personal.country) ? `<span>${personal.city ? escapeText(personal.city) : ''}${personal.city && personal.country ? ', ' : ''}${personal.country ? escapeText(personal.country) : ''}</span>` : ''}
${headerLinks ? `<span>${headerLinks}</span>` : ''}
</div>
</div>
`;
const cleanSummary = typeof summary === 'string' ? sanitizeHtml(summary, {
allowedTags: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'a'],
allowedAttributes: { a: ['href', 'rel', 'target'] }
}) : '';
const summarySection = cleanSummary ? `
<div class="section">
<h2>Professional Summary</h2>
<div>${cleanSummary}</div>
</div>
` : '';
const workSection = (work && work.length) ? `
<div class="section">
<h2>Work Experience</h2>
${work.map(job => `
<div class="job">
<div class="row">
<div>
<h3>${escapeText(job.title)}</h3>
<div class="small">${escapeText(job.company)}</div>
</div>
<div class="small">
${escapeText(job.startDate || '')} - ${escapeText(job.endDate || 'Present')}
${job.location ? `<div>${escapeText(job.location)}</div>` : ''}
</div>
</div>
${(job.bullets && job.bullets.length) ? `
<ul>
${job.bullets.map(b => `<li>${escapeText(b)}</li>`).join('')}
</ul>
` : ''}
</div>
`).join('')}
</div>
` : '';
const eduSection = (education && education.length) ? `
<div class="section">
<h2>Education</h2>
${education.map(ed => `
<div class="edu">
<div class="row">
<div>
<h3>${escapeText(ed.degree)}</h3>
<div class="small">${escapeText(ed.school)}</div>
</div>
<div class="small">
${escapeText(ed.startDate || '')} - ${escapeText(ed.endDate || '')}
</div>
</div>
${ed.gpa ? `<div class="small">GPA: ${escapeText(ed.gpa)}</div>` : ''}
</div>
`).join('')}
</div>
` : '';
const skillsSection = (skills && skills.length) ? `
<div class="section">
<h2>Skills</h2>
<div class="chips">
${skills.map(s => `<span class="chip">${escapeText(s.name)}${s.level ? `${escapeText(s.level)}` : ''}</span>`).join('')}
</div>
</div>
` : '';
const languagesSection = (languages && languages.length) ? `
<div class="section">
<h2>Languages</h2>
<div class="chips">
${languages.map(l => `<span class="chip">${escapeText(l.name)}${l.level ? `${escapeText(l.level)}` : ''}</span>`).join('')}
</div>
</div>
` : '';
const certsSection = (certifications && certifications.length) ? `
<div class="section">
<h2>Certifications</h2>
<ul>
${certifications.map(c => `<li>${escapeText(c.name)}${c.issuer ? ` - ${escapeText(c.issuer)}` : ''}${c.date ? ` (${escapeText(c.date)})` : ''}</li>`).join('')}
</ul>
</div>
` : '';
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Printable CV</title>
${styles}
</head>
<body>
<div class="page">
${headerHtml}
${summarySection}
${workSection}
${eduSection}
${skillsSection}
${languagesSection}
${certsSection}
</div>
</body>
</html>`;
}
const app = express();
app.use(cors());
app.use(express.json({ limit: '1mb' }));
// Pluggable job store with in-memory default
class InMemoryJobStore {
constructor() {
this.map = new Map();
}
create(job) {
const id = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
this.map.set(id, { ...job, id });
return id;
}
get(id) { return this.map.get(id); }
set(id, data) { this.map.set(id, { ...(this.map.get(id) || {}), ...data }); }
cancel(id) {
const job = this.map.get(id);
if (!job) return false;
this.map.set(id, { ...job, status: 'canceled', error: null, buffer: null });
return true;
}
}
const jobs = new InMemoryJobStore();
app.post('/export/pdf', async (req, res) => {
try {
// Treat presence of the query param as async, and allow common truthy values
const asyncFlag = (
typeof req.query.async !== 'undefined'
? ['1', 'true', 'on', ''].includes(String(req.query.async).toLowerCase())
: req.body?.async === true
);
const templateId = req.query.templateId || req.body?.templateId || null;
const cv = req.body || {};
const sanitizedCv = {
...cv,
summary: typeof cv.summary === 'string' ? sanitizeHtml(cv.summary, {
allowedTags: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'a'],
allowedAttributes: { a: ['href', 'rel', 'target'] }
}) : ''
};
const firstName = (sanitizedCv.personal && sanitizedCv.personal.firstName) ? sanitizedCv.personal.firstName : 'Candidate';
const lastName = (sanitizedCv.personal && sanitizedCv.personal.lastName) ? sanitizedCv.personal.lastName : 'CV';
const filename = `${firstName}_${lastName}_CV.pdf`.replace(/\s+/g, '_');
if (asyncFlag) {
const jobId = jobs.create({ status: 'queued', filename, error: null, buffer: null, templateId });
setImmediate(async () => {
try {
const current = jobs.get(jobId);
if (current?.status === 'canceled') return; // honor cancel
const html = sharedBuild(sanitizedCv);
const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdfBuffer = await page.pdf({ format: 'A4', printBackground: true });
await browser.close();
jobs.set(jobId, { status: 'done', filename, error: null, buffer: pdfBuffer, templateId });
} catch (e) {
console.error('Async export error:', e);
jobs.set(jobId, { status: 'error', filename, error: 'Failed to create PDF', buffer: null, templateId });
}
});
return res.json({ jobId });
}
// Synchronous path
const html = sharedBuild(sanitizedCv);
const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdfBuffer = await page.pdf({ format: 'A4', printBackground: true });
await browser.close();
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`
});
res.send(pdfBuffer);
} catch (err) {
console.error('Export error:', err);
res.status(500).json({ error: 'Failed to create PDF' });
}
});
// Job status endpoint
app.get('/export/status/:id', (req, res) => {
const job = jobs.get(req.params.id);
if (!job) return res.status(404).json({ error: 'Job not found' });
res.json({ status: job.status, filename: job.filename, error: job.error });
});
// Job download endpoint
app.get('/export/download/:id', (req, res) => {
const job = jobs.get(req.params.id);
if (!job) return res.status(404).json({ error: 'Job not found' });
if (job.status !== 'done' || !job.buffer) return res.status(409).json({ error: 'Job not ready' });
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${job.filename}"`
});
res.send(job.buffer);
});
// Cancel job endpoint
app.post('/export/cancel/:id', (req, res) => {
const id = req.params.id;
const ok = jobs.cancel(id);
if (!ok) return res.status(404).json({ error: 'Job not found' });
return res.json({ status: 'canceled' });
});
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => {
console.log(`Export server listening on http://localhost:${PORT}`);
});

11
headers.txt Normal file
View File

@ -0,0 +1,11 @@
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/pdf
Content-Disposition: attachment; filename="Ada_Lovelace_CV.pdf"
Content-Length: 13298
ETag: W/"33f2-1gLBdxasSpROFsvw/XUGR63Xjhg"
Date: Sun, 05 Oct 2025 23:50:44 GMT
Connection: keep-alive
Keep-Alive: timeout=5

BIN
job.json Normal file

Binary file not shown.

1
out.json Normal file
View File

@ -0,0 +1 @@
{"jobId":"job_1759708234671_izajbw"}

BIN
out.pdf Normal file

Binary file not shown.

View File

@ -0,0 +1,2 @@
export type CV = import('../cv-engine/src/schema/cvSchema').CV;
export function buildPrintableHtml(cv: CV): string;

View File

@ -0,0 +1,152 @@
// ESM module: shared printable HTML builder
// Note: callers must sanitize any HTML fields (e.g., summary) before passing.
function escapeText(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function buildPrintableHtml(cv) {
const { personal = {}, summary = '', work = [], education = [], skills = [], languages = [], certifications = [] } = cv || {};
const styles = `
<style>
@page { size: A4; margin: 20mm; }
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans; color: #1f2937; }
.page { width: 210mm; min-height: 297mm; margin: 0 auto; background: white; }
h1 { font-size: 24px; margin: 0 0 6px 0; }
h2 { font-size: 16px; margin: 16px 0 8px 0; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; color: #374151; }
h3 { font-size: 14px; margin: 0; }
.header { border-bottom: 1px solid #e5e7eb; padding-bottom: 12px; margin-bottom: 16px; }
.meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #6b7280; }
.section { margin-bottom: 16px; }
.job, .edu { margin-bottom: 12px; }
.row { display: flex; justify-content: space-between; align-items: flex-start; }
.small { font-size: 12px; color: #6b7280; }
ul { padding-left: 20px; }
li { margin: 4px 0; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { background: #f3f4f6; color: #1f2937; padding: 2px 8px; border-radius: 12px; font-size: 12px; }
</style>
`;
const headerLinks = (personal.links || [])
.map(l => `<a href="${escapeText(l.url)}" target="_blank" rel="noopener noreferrer">${escapeText(l.label || l.url)}</a>`)
.join(' · ');
const headerHtml = `
<div class="header">
<h1>${escapeText(personal.firstName || '')} ${escapeText(personal.lastName || '')}</h1>
<div class="meta">
${personal.email ? `<span>${escapeText(personal.email)}</span>` : ''}
${personal.phone ? `<span>${escapeText(personal.phone)}</span>` : ''}
${(personal.city || personal.country) ? `<span>${personal.city ? escapeText(personal.city) : ''}${personal.city && personal.country ? ', ' : ''}${personal.country ? escapeText(personal.country) : ''}</span>` : ''}
${headerLinks ? `<span>${headerLinks}</span>` : ''}
</div>
</div>
`;
const summarySection = summary ? `
<div class="section">
<h2>Professional Summary</h2>
<div>${summary}</div>
</div>
` : '';
const workSection = (work && work.length) ? `
<div class="section">
<h2>Work Experience</h2>
${work.map(job => `
<div class="job">
<div class="row">
<div>
<h3>${escapeText(job.title)}</h3>
<div class="small">${escapeText(job.company)}</div>
</div>
<div class="small">
${escapeText(job.startDate || '')} - ${escapeText(job.endDate || 'Present')}
${job.location ? `<div>${escapeText(job.location)}</div>` : ''}
</div>
</div>
${(job.bullets && job.bullets.length) ? `
<ul>
${job.bullets.map(b => `<li>${escapeText(b)}</li>`).join('')}
</ul>
` : ''}
</div>
`).join('')}
</div>
` : '';
const eduSection = (education && education.length) ? `
<div class="section">
<h2>Education</h2>
${education.map(ed => `
<div class="edu">
<div class="row">
<div>
<h3>${escapeText(ed.degree)}</h3>
<div class="small">${escapeText(ed.school)}</div>
</div>
<div class="small">
${escapeText(ed.startDate || '')} - ${escapeText(ed.endDate || '')}
</div>
</div>
${ed.gpa ? `<div class="small">GPA: ${escapeText(ed.gpa)}</div>` : ''}
</div>
`).join('')}
</div>
` : '';
const skillsSection = (skills && skills.length) ? `
<div class="section">
<h2>Skills</h2>
<div class="chips">
${skills.map(s => `<span class="chip">${escapeText(s.name)}${s.level ? `${escapeText(s.level)}` : ''}</span>`).join('')}
</div>
</div>
` : '';
const languagesSection = (languages && languages.length) ? `
<div class="section">
<h2>Languages</h2>
<div class="chips">
${languages.map(l => `<span class="chip">${escapeText(l.name)}${l.level ? `${escapeText(l.level)}` : ''}</span>`).join('')}
</div>
</div>
` : '';
const certsSection = (certifications && certifications.length) ? `
<div class="section">
<h2>Certifications</h2>
<ul>
${certifications.map(c => `<li>${escapeText(c.name)}${c.issuer ? ` - ${escapeText(c.issuer)}` : ''}${c.date ? ` (${escapeText(c.date)})` : ''}</li>`).join('')}
</ul>
</div>
` : '';
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Printable CV</title>
${styles}
</head>
<body>
<div class="page">
${headerHtml}
${summarySection}
${workSection}
${eduSection}
${skillsSection}
${languagesSection}
${certsSection}
</div>
</body>
</html>`;
}