All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m44s
493 lines
15 KiB
TypeScript
493 lines
15 KiB
TypeScript
import type { APIRoute } from "astro";
|
|
import { chromium } from "playwright";
|
|
import { config } from "../../../config";
|
|
import * as TOML from "@iarna/toml";
|
|
|
|
// Helper function to fetch and return SVG icon from Simple Icons CDN
|
|
async function getSimpleIcon(iconName: string): Promise<string> {
|
|
try {
|
|
const response = await fetch(
|
|
`https://cdn.jsdelivr.net/npm/simple-icons@v10/icons/${iconName.toLowerCase()}.svg`,
|
|
);
|
|
if (!response.ok) {
|
|
console.warn(`Failed to fetch icon: ${iconName}`);
|
|
return "";
|
|
}
|
|
const svgContent = await response.text();
|
|
return svgContent.replace(
|
|
"<svg",
|
|
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;"',
|
|
);
|
|
} catch (error) {
|
|
console.warn(`Error fetching icon ${iconName}:`, error);
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// Helper function to get MDI icon SVG
|
|
function getMdiIcon(iconName: string): string {
|
|
const iconMap: { [key: string]: string } = {
|
|
"mdi:email":
|
|
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;" viewBox="0 0 24 24"><path d="M20,8L12,13L4,8V6L12,11L20,6M20,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6C2.89,4 20,4.89 20,4Z"/></svg>',
|
|
"mdi:download":
|
|
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;" viewBox="0 0 24 24"><path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"/></svg>',
|
|
"mdi:link":
|
|
'<svg style="width: 12px; height: 12px; display: inline-block; vertical-align: middle; fill: currentColor;" viewBox="0 0 24 24"><path d="M3.9,12C3.9,10.29 5.29,8.9 7,8.9H11V7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H11V15.1H7C5.29,15.1 3.9,13.71 3.9,12M8,13H16V11H8V13M17,7H13V8.9H17C18.71,8.9 20.1,10.29 20.1,12C20.1,13.71 18.71,15.1 17,15.1H13V17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7Z"/></svg>',
|
|
};
|
|
return iconMap[iconName] || "";
|
|
}
|
|
|
|
interface ResumeData {
|
|
basics: {
|
|
name: string;
|
|
email: string;
|
|
website?: string;
|
|
profiles: {
|
|
network: string;
|
|
username: string;
|
|
url: string;
|
|
}[];
|
|
};
|
|
layout?: {
|
|
left_column?: string[];
|
|
right_column?: string[];
|
|
};
|
|
summary: {
|
|
content: string;
|
|
};
|
|
experience: {
|
|
company: string;
|
|
position: string;
|
|
location: string;
|
|
date: string;
|
|
description: string[];
|
|
url?: string;
|
|
}[];
|
|
education: {
|
|
institution: string;
|
|
degree: string;
|
|
field: string;
|
|
date: string;
|
|
details?: string[];
|
|
}[];
|
|
skills: {
|
|
name: string;
|
|
level: number;
|
|
}[];
|
|
volunteer: {
|
|
organization: string;
|
|
position: string;
|
|
date: string;
|
|
}[];
|
|
awards: {
|
|
title: string;
|
|
organization: string;
|
|
date: string;
|
|
description?: string;
|
|
}[];
|
|
}
|
|
|
|
// Template helper functions
|
|
const createSection = (
|
|
title: string,
|
|
content: string,
|
|
spacing = "space-y-3",
|
|
) => `
|
|
<section>
|
|
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300">
|
|
${title}
|
|
</h2>
|
|
<div class="${spacing}">
|
|
${content}
|
|
</div>
|
|
</section>
|
|
`;
|
|
|
|
const createExperienceItem = (exp: any) => `
|
|
<div class="mb-3 pl-2 border-l-2 border-blue-600">
|
|
<h3 class="text-xs font-semibold text-gray-900 mb-1">${exp.position}</h3>
|
|
<div class="text-xs text-gray-600 mb-1">
|
|
<span class="font-medium">${exp.company}</span>
|
|
<span class="mx-1">•</span>
|
|
<span>${exp.date}</span>
|
|
<span class="mx-1">•</span>
|
|
<span>${exp.location}</span>
|
|
</div>
|
|
<ul class="text-xs text-gray-700 leading-tight ml-3 list-disc">
|
|
${exp.description.map((item: string) => `<li class="mb-1">${item}</li>`).join("")}
|
|
</ul>
|
|
</div>
|
|
`;
|
|
|
|
const createSkillItem = (skill: any) => {
|
|
const progressValue = skill.level * 20;
|
|
return `
|
|
<div class="mb-1">
|
|
<div class="flex justify-between items-center mb-0.5">
|
|
<span class="text-xs font-medium text-gray-900">${skill.name}</span>
|
|
<span class="text-xs text-gray-600">${skill.level}/5</span>
|
|
</div>
|
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
|
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: ${progressValue}%"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
};
|
|
|
|
const createEducationItem = (edu: any) => {
|
|
const detailsList = edu.details
|
|
? edu.details
|
|
.map((detail: string) => `<li class="mb-1">${detail}</li>`)
|
|
.join("")
|
|
: "";
|
|
|
|
return `
|
|
<div class="mb-3 pl-2 border-l-2 border-green-600">
|
|
<h3 class="text-xs font-semibold text-gray-900 mb-1">${edu.institution}</h3>
|
|
<div class="text-xs text-gray-600 mb-1">
|
|
<span class="font-medium">${edu.degree} in ${edu.field}</span>
|
|
<span class="mx-1">•</span>
|
|
<span>${edu.date}</span>
|
|
</div>
|
|
${detailsList ? `<ul class="text-xs text-gray-700 leading-tight ml-3 list-disc">${detailsList}</ul>` : ""}
|
|
</div>
|
|
`;
|
|
};
|
|
|
|
const createVolunteerItem = (vol: any) => `
|
|
<div class="mb-2 pl-2 border-l-2 border-purple-600">
|
|
<h3 class="text-xs font-semibold text-gray-900 mb-1">${vol.organization}</h3>
|
|
<div class="text-xs text-gray-600">
|
|
<span class="font-medium">${vol.position}</span>
|
|
<span class="mx-1">•</span>
|
|
<span>${vol.date}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const createAwardItem = (award: any) => `
|
|
<div class="mb-2 pl-2 border-l-2 border-yellow-600">
|
|
<h3 class="text-xs font-semibold text-gray-900 mb-1">${award.title}</h3>
|
|
<div class="text-xs text-gray-600 mb-1">
|
|
<span class="font-medium">${award.organization}</span>
|
|
<span class="mx-1">•</span>
|
|
<span>${award.date}</span>
|
|
</div>
|
|
${award.description ? `<div class="text-xs text-gray-700 leading-tight">${award.description}</div>` : ""}
|
|
</div>
|
|
`;
|
|
|
|
const createHead = (name: string) => `
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>${name} - Resume</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<style>
|
|
@media print {
|
|
body {
|
|
print-color-adjust: exact;
|
|
-webkit-print-color-adjust: exact;
|
|
}
|
|
}
|
|
.resume-container {
|
|
max-width: 8.5in;
|
|
min-height: 11in;
|
|
}
|
|
</style>
|
|
</head>
|
|
`;
|
|
|
|
const createHeader = (
|
|
basics: any,
|
|
emailIcon: string,
|
|
profileIcons: { [key: string]: string },
|
|
) => `
|
|
<header class="text-center mb-3 pb-2 border-b-2 border-gray-300">
|
|
<h1 class="text-3xl font-bold text-gray-900 mb-1">${basics.name}</h1>
|
|
<div class="flex justify-center items-center flex-wrap gap-4 text-xs text-gray-600">
|
|
${basics.email ? `<div class="flex items-center gap-1">${emailIcon} ${basics.email}</div>` : ""}
|
|
${
|
|
basics.profiles
|
|
?.map((profile: any) => {
|
|
const icon = profileIcons[profile.network] || "";
|
|
const displayUrl = profile.url
|
|
.replace(/^https?:\/\//, "")
|
|
.replace(/\/$/, "");
|
|
return `<div class="flex items-center gap-1">${icon} ${displayUrl}</div>`;
|
|
})
|
|
.join("") || ""
|
|
}
|
|
</div>
|
|
</header>
|
|
`;
|
|
|
|
const createSummarySection = (summary: any, resumeConfig: any) => {
|
|
if (
|
|
!summary ||
|
|
!summary.content ||
|
|
resumeConfig.sections.summary?.enabled === false
|
|
)
|
|
return "";
|
|
|
|
return `
|
|
<section class="mb-3">
|
|
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300">
|
|
${resumeConfig.sections.summary?.title || "Summary"}
|
|
</h2>
|
|
<div class="text-xs text-gray-700 leading-tight">${summary.content}</div>
|
|
</section>
|
|
`;
|
|
};
|
|
|
|
const createColumnSections = (
|
|
sectionNames: string[],
|
|
sections: { [key: string]: string },
|
|
resumeConfig: any,
|
|
) => {
|
|
const sectionConfig = {
|
|
experience: {
|
|
title: resumeConfig.sections.experience?.title || "Experience",
|
|
enabled: resumeConfig.sections.experience?.enabled !== false,
|
|
spacing: "space-y-3",
|
|
},
|
|
skills: {
|
|
title: resumeConfig.sections.skills?.title || "Skills",
|
|
enabled: resumeConfig.sections.skills?.enabled !== false,
|
|
spacing: "space-y-1",
|
|
},
|
|
education: {
|
|
title: resumeConfig.sections.education?.title || "Education",
|
|
enabled: resumeConfig.sections.education?.enabled !== false,
|
|
spacing: "space-y-3",
|
|
},
|
|
volunteer: {
|
|
title: resumeConfig.sections.volunteer?.title || "Volunteer Work",
|
|
enabled: resumeConfig.sections.volunteer?.enabled !== false,
|
|
spacing: "space-y-2",
|
|
},
|
|
awards: {
|
|
title: resumeConfig.sections.awards?.title || "Awards & Recognition",
|
|
enabled: resumeConfig.sections.awards?.enabled !== false,
|
|
spacing: "space-y-2",
|
|
},
|
|
};
|
|
|
|
return sectionNames
|
|
.map((sectionName) => {
|
|
const config = sectionConfig[sectionName as keyof typeof sectionConfig];
|
|
const content = sections[sectionName];
|
|
|
|
// Skip if section doesn't exist in config, has no content, or is disabled
|
|
if (!config || !content || content.trim() === "" || !config.enabled)
|
|
return "";
|
|
|
|
return createSection(config.title, content, config.spacing);
|
|
})
|
|
.filter((section) => section !== "")
|
|
.join("");
|
|
};
|
|
|
|
const fetchProfileIcons = async (profiles: any[]) => {
|
|
const profileIcons: { [key: string]: string } = {};
|
|
if (profiles) {
|
|
for (const profile of profiles) {
|
|
const iconName = profile.network.toLowerCase();
|
|
profileIcons[profile.network] = await getSimpleIcon(iconName);
|
|
}
|
|
}
|
|
return profileIcons;
|
|
};
|
|
|
|
const generateResumeHTML = async (data: ResumeData): Promise<string> => {
|
|
const resumeConfig = config.resumeConfig;
|
|
// Use layout from TOML data, fallback to site config, then to default
|
|
const layout = data.layout
|
|
? {
|
|
leftColumn: data.layout.left_column || [
|
|
"experience",
|
|
"volunteer",
|
|
"awards",
|
|
],
|
|
rightColumn: data.layout.right_column || ["skills", "education"],
|
|
}
|
|
: resumeConfig.layout || {
|
|
leftColumn: ["experience", "volunteer", "awards"],
|
|
rightColumn: ["skills", "education"],
|
|
};
|
|
|
|
// Pre-fetch icons
|
|
const profileIcons = await fetchProfileIcons(data.basics.profiles);
|
|
const emailIcon = getMdiIcon("mdi:email");
|
|
|
|
// Generate section content
|
|
const sections = {
|
|
experience: Array.isArray(data.experience)
|
|
? data.experience.map(createExperienceItem).join("")
|
|
: "",
|
|
skills: Array.isArray(data.skills)
|
|
? data.skills.map(createSkillItem).join("")
|
|
: "",
|
|
education: Array.isArray(data.education)
|
|
? data.education.map(createEducationItem).join("")
|
|
: "",
|
|
volunteer: Array.isArray(data.volunteer)
|
|
? data.volunteer.map(createVolunteerItem).join("")
|
|
: "",
|
|
awards: Array.isArray(data.awards)
|
|
? data.awards.map(createAwardItem).join("")
|
|
: "",
|
|
};
|
|
|
|
return `
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
${createHead(data.basics.name)}
|
|
<body class="bg-white text-gray-900 text-xs leading-tight p-3">
|
|
<div class="resume-container mx-auto">
|
|
${createHeader(data.basics, emailIcon, profileIcons)}
|
|
${createSummarySection(data.summary, resumeConfig)}
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="space-y-4">
|
|
${createColumnSections(layout.leftColumn ?? [], sections, resumeConfig)}
|
|
</div>
|
|
<div class="space-y-4">
|
|
${createColumnSections(layout.rightColumn ?? [], sections, resumeConfig)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
};
|
|
|
|
async function generatePDFFromToml(tomlContent: string): Promise<Uint8Array> {
|
|
const resumeData: ResumeData = TOML.parse(
|
|
tomlContent,
|
|
) as unknown as ResumeData;
|
|
const htmlContent = await generateResumeHTML(resumeData);
|
|
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
executablePath:
|
|
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ||
|
|
(process.env.NODE_ENV === "production"
|
|
? "/usr/bin/chromium-browser"
|
|
: undefined),
|
|
args: [
|
|
"--no-sandbox",
|
|
"--disable-setuid-sandbox",
|
|
"--disable-dev-shm-usage",
|
|
"--disable-gpu",
|
|
"--disable-web-security",
|
|
"--disable-features=VizDisplayCompositor",
|
|
],
|
|
});
|
|
|
|
const page = await browser.newPage();
|
|
await page.setContent(htmlContent, { waitUntil: "networkidle" });
|
|
|
|
const pdfBuffer = await page.pdf({
|
|
format: "A4",
|
|
margin: {
|
|
top: "0.2in",
|
|
bottom: "0.2in",
|
|
left: "0.2in",
|
|
right: "0.2in",
|
|
},
|
|
printBackground: true,
|
|
scale: 0.9,
|
|
});
|
|
|
|
await browser.close();
|
|
return pdfBuffer;
|
|
}
|
|
|
|
export const GET: APIRoute = async ({ request }) => {
|
|
try {
|
|
if (!config.resumeConfig.tomlFile || !config.resumeConfig.tomlFile.trim()) {
|
|
return new Response("Resume not configured", { status: 404 });
|
|
}
|
|
|
|
let tomlContent: string;
|
|
|
|
// Check if tomlFile is a path (starts with /) or raw content
|
|
if (config.resumeConfig.tomlFile.startsWith("/")) {
|
|
// It's a file path - fetch it
|
|
const url = new URL(request.url);
|
|
const baseUrl = `${url.protocol}//${url.host}`;
|
|
|
|
const response = await fetch(`${baseUrl}${config.resumeConfig.tomlFile}`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to fetch resume: ${response.status} ${response.statusText}`,
|
|
);
|
|
}
|
|
|
|
tomlContent = await response.text();
|
|
} else {
|
|
// It's raw TOML content
|
|
tomlContent = config.resumeConfig.tomlFile;
|
|
}
|
|
|
|
const pdfBuffer = await generatePDFFromToml(tomlContent);
|
|
|
|
return new Response(pdfBuffer, {
|
|
headers: {
|
|
"Content-Type": "application/pdf",
|
|
"Content-Disposition": `attachment; filename="Atridad_Lahiji_Resume.pdf"`,
|
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
Pragma: "no-cache",
|
|
Expires: "0",
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("Error generating PDF:", error);
|
|
return new Response("Error generating PDF", { status: 500 });
|
|
}
|
|
};
|
|
|
|
export const POST: APIRoute = async ({ request }) => {
|
|
try {
|
|
const tomlContent = await request.text();
|
|
|
|
if (!tomlContent.trim()) {
|
|
return new Response("TOML content is required", { status: 400 });
|
|
}
|
|
|
|
// Validate TOML content
|
|
let resumeData: ResumeData;
|
|
try {
|
|
resumeData = TOML.parse(tomlContent) as unknown as ResumeData;
|
|
} catch (parseError) {
|
|
return new Response(
|
|
`Invalid TOML format: ${parseError instanceof Error ? parseError.message : "Unknown error"}`,
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Basic validation
|
|
if (!resumeData.basics?.name) {
|
|
return new Response("Resume must include basics.name", { status: 400 });
|
|
}
|
|
|
|
const pdfBuffer = await generatePDFFromToml(tomlContent);
|
|
|
|
const filename = `${resumeData.basics.name.replace(/[^a-zA-Z0-9]/g, "_")}_Resume.pdf`;
|
|
|
|
return new Response(pdfBuffer, {
|
|
headers: {
|
|
"Content-Type": "application/pdf",
|
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
Pragma: "no-cache",
|
|
Expires: "0",
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("Error generating PDF:", error);
|
|
return new Response("Error generating PDF", { status: 500 });
|
|
}
|
|
};
|