Resume system overhaul :)
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m54s

This commit is contained in:
2025-07-18 12:14:23 -06:00
parent 26c0862b3e
commit 528060b85a
13 changed files with 951 additions and 360 deletions

View File

@@ -1,5 +1,5 @@
import type { APIRoute } from "astro";
import { chromium } from 'playwright';
import { chromium } from "playwright";
import { siteConfig } from "../../../config/data";
import * as TOML from "@iarna/toml";
@@ -48,6 +48,10 @@ interface ResumeData {
url: string;
}[];
};
layout?: {
left_column?: string[];
right_column?: string[];
};
summary: {
content: string;
};
@@ -84,7 +88,11 @@ interface ResumeData {
}
// Template helper functions
const createSection = (title: string, content: string, spacing = "space-y-3") => `
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}
@@ -128,7 +136,9 @@ const createSkillItem = (skill: any) => {
const createEducationItem = (edu: any) => {
const detailsList = edu.details
? edu.details.map((detail: string) => `<li class="mb-1">${detail}</li>`).join("")
? edu.details
.map((detail: string) => `<li class="mb-1">${detail}</li>`)
.join("")
: "";
return `
@@ -188,32 +198,42 @@ const createHead = (name: string) => `
</head>
`;
const createHeader = (basics: any, emailIcon: string, profileIcons: { [key: string]: string }) => `
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("") || ""
}
${
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 || !resumeConfig.sections.summary?.enabled) return "";
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"}
${resumeConfig.sections.summary?.title || "Summary"}
</h2>
<div class="text-xs text-gray-700 leading-tight">${summary.content}</div>
</section>
@@ -223,32 +243,32 @@ const createSummarySection = (summary: any, resumeConfig: any) => {
const createColumnSections = (
sectionNames: string[],
sections: { [key: string]: string },
resumeConfig: any
resumeConfig: any,
) => {
const sectionConfig = {
experience: {
title: resumeConfig.sections.experience?.title || "Experience",
enabled: resumeConfig.sections.experience?.enabled,
enabled: resumeConfig.sections.experience?.enabled !== false,
spacing: "space-y-3",
},
skills: {
title: resumeConfig.sections.skills?.title || "Skills",
enabled: resumeConfig.sections.skills?.enabled,
enabled: resumeConfig.sections.skills?.enabled !== false,
spacing: "space-y-1",
},
education: {
title: resumeConfig.sections.education?.title || "Education",
enabled: resumeConfig.sections.education?.enabled,
enabled: resumeConfig.sections.education?.enabled !== false,
spacing: "space-y-3",
},
volunteer: {
title: resumeConfig.sections.volunteer?.title || "Volunteer Work",
enabled: resumeConfig.sections.volunteer?.enabled,
enabled: resumeConfig.sections.volunteer?.enabled !== false,
spacing: "space-y-2",
},
awards: {
title: resumeConfig.sections.awards?.title || "Awards & Recognition",
enabled: resumeConfig.sections.awards?.enabled,
enabled: resumeConfig.sections.awards?.enabled !== false,
spacing: "space-y-2",
},
};
@@ -258,10 +278,13 @@ const createColumnSections = (
const config = sectionConfig[sectionName as keyof typeof sectionConfig];
const content = sections[sectionName];
if (!config || !content || !config.enabled) return "";
// 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("");
};
@@ -278,10 +301,20 @@ const fetchProfileIcons = async (profiles: any[]) => {
const generateResumeHTML = async (data: ResumeData): Promise<string> => {
const resumeConfig = siteConfig.resume;
const layout = resumeConfig.layout || {
leftColumn: ["experience", "volunteer", "awards"],
rightColumn: ["skills", "education"],
};
// 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);
@@ -289,11 +322,21 @@ const generateResumeHTML = async (data: ResumeData): Promise<string> => {
// 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("") : "",
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 `
@@ -318,61 +361,77 @@ const generateResumeHTML = async (data: ResumeData): Promise<string> => {
`;
};
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 (!siteConfig.resume.tomlFile || !siteConfig.resume.tomlFile.trim()) {
return new Response("Resume not configured", { status: 404 });
}
const url = new URL(request.url);
const baseUrl = `${url.protocol}//${url.host}`;
let tomlContent: string;
const response = await fetch(`${baseUrl}${siteConfig.resume.tomlFile}`);
// Check if tomlFile is a path (starts with /) or raw content
if (siteConfig.resume.tomlFile.startsWith("/")) {
// It's a file path - fetch it
const url = new URL(request.url);
const baseUrl = `${url.protocol}//${url.host}`;
if (!response.ok) {
throw new Error(
`Failed to fetch resume: ${response.status} ${response.statusText}`,
);
const response = await fetch(`${baseUrl}${siteConfig.resume.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 = siteConfig.resume.tomlFile;
}
const tomlContent = await response.text();
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();
const pdfBuffer = await generatePDFFromToml(tomlContent);
return new Response(pdfBuffer, {
headers: {
@@ -387,4 +446,47 @@ export const GET: APIRoute = async ({ request }) => {
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 });
}
};