Fixed resume gen
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled

This commit is contained in:
2025-06-26 23:41:29 -06:00
parent b4298e78ef
commit 0d43c3af47
7 changed files with 270 additions and 230 deletions

View File

@ -7,7 +7,7 @@ My personal website built with Astro and Preact!
- **Resume Management** - **Resume Management**
- **Blog Posts** - **Blog Posts**
- **Projects** - **Projects**
- **Talks & Presentations** - **Talks**
- **Terminal View** - **Terminal View**
## Resume Configuration ## Resume Configuration
@ -34,6 +34,10 @@ Each section can be configured in `src/config/data.ts`:
```typescript ```typescript
export const resumeConfig: ResumeConfig = { export const resumeConfig: ResumeConfig = {
tomlFile: "/files/resume.toml", tomlFile: "/files/resume.toml",
layout: {
leftColumn: ["experience", "volunteer", "awards"],
rightColumn: ["skills", "education"],
},
sections: { sections: {
summary: { summary: {
title: "Professional Summary", title: "Professional Summary",
@ -52,6 +56,49 @@ export const resumeConfig: ResumeConfig = {
}; };
``` ```
### Layout Configuration
The resume layout is fully customizable. You can control which sections appear in which column and their order:
```typescript
layout: {
leftColumn: ["experience", "volunteer", "awards"],
rightColumn: ["skills", "education"],
}
```
**Available sections for layout:**
- `experience` - Work experience
- `education` - Educational background
- `skills` - Technical and professional skills
- `volunteer` - Volunteer work
- `awards` - Awards and recognition
**Layout Rules:**
- Sections can be placed in either column
- Order within each column is determined by array order
- Missing sections are automatically excluded
- The `summary` section always appears at the top (full width)
- The `profiles` section appears in the header area
**Example Layouts:**
*Skills-focused layout:*
```typescript
layout: {
leftColumn: ["skills", "education"],
rightColumn: ["experience", "awards", "volunteer"],
}
```
*Experience-heavy layout:*
```typescript
layout: {
leftColumn: ["experience"],
rightColumn: ["skills", "education", "volunteer", "awards"],
}
```
### Resume Data Format (TOML) ### Resume Data Format (TOML)
Create a `resume.toml` file in the `public/files/` directory: Create a `resume.toml` file in the `public/files/` directory:
@ -114,50 +161,15 @@ date = "2023"
description = "Brief description of the award" description = "Brief description of the award"
``` ```
### Section Field Details
#### Skills Section
- `level`: Integer from 1-5 representing proficiency level
- Displays as progress bars with visual indicators
#### Experience Section
- `description`: Array of strings for bullet points
- `url`: Optional company website link
#### Education Section
- `details`: Optional array of additional information (coursework, achievements, etc.)
#### Awards Section
- `description`: Optional additional details about the award
#### Profiles Section
- `network`: Used for icon selection (GitHub, LinkedIn, etc.)
- Icons automatically selected based on network name
## Usage
1. **Configure Resume**: Edit `src/config/data.ts` to enable/disable sections
2. **Add Resume Data**: Create `public/files/resume.toml` with your information
3. **View Resume**: Navigate to `/resume` on your site
4. **Generate PDF**: Click "Generate PDF Resume" button for downloadable PDF
## Development ## Development
```bash ```bash
# Install dependencies # Install dependencies
npm install pnpm i
# Start development server # Start development server
npm run dev pnpm dev
# Build for production # Build for production
npm run build pnpm build
``` ```
## Resume PDF Generation
The system automatically generates PDFs using Puppeteer with:
- Optimized layout for A4 paper
- Print-friendly styling
- Consistent formatting across sections
- Proper page breaks and margins

View File

@ -3,11 +3,6 @@ name = "Atridad Lahiji"
email = "me@atri.dad" email = "me@atri.dad"
website = "https://atri.dad" website = "https://atri.dad"
[[basics.profiles]]
network = "GitHub"
username = "atridadl"
url = "https://github.com/atridadl"
[[basics.profiles]] [[basics.profiles]]
network = "LinkedIn" network = "LinkedIn"
username = "atridadl" username = "atridadl"

View File

@ -1,12 +1,12 @@
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
interface PdfDownloadButtonProps { interface ResumeDownloadButtonProps {
className?: string; className?: string;
} }
export default function PdfDownloadButton({ export default function ResumeDownloadButton({
className = "", className = "",
}: PdfDownloadButtonProps) { }: ResumeDownloadButtonProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -15,7 +15,7 @@ export default function PdfDownloadButton({
setError(null); setError(null);
try { try {
const response = await fetch("/api/resume/pdf"); const response = await fetch(`/api/resume/pdf?t=${Date.now()}`);
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
@ -57,18 +57,7 @@ export default function PdfDownloadButton({
Generating PDF... Generating PDF...
</> </>
) : ( ) : (
<> <>Download Resume</>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z" />
</svg>
Generate PDF Resume
</>
)} )}
</button> </button>
{error && <div class="mt-2 text-error text-sm">{error}</div>} {error && <div class="mt-2 text-error text-sm">{error}</div>}

View File

@ -13,8 +13,8 @@ import type {
// Import Lucide Icons // Import Lucide Icons
import { import {
Home, Home,
NotebookPen, Newspaper,
BriefcaseBusiness, FileUser,
CodeXml, CodeXml,
Terminal as TerminalIcon, Terminal as TerminalIcon,
Megaphone, Megaphone,
@ -68,6 +68,10 @@ export const resumeConfig: ResumeConfig = {
filename: "Atridad_Lahiji_Resume.pdf", filename: "Atridad_Lahiji_Resume.pdf",
displayText: "Download Resume (PDF)", displayText: "Download Resume (PDF)",
}, },
layout: {
leftColumn: ["experience", "awards"],
rightColumn: ["skills", "education", "volunteer"],
},
sections: { sections: {
enabled: [ enabled: [
"summary", "summary",
@ -75,7 +79,6 @@ export const resumeConfig: ResumeConfig = {
"education", "education",
"skills", "skills",
"volunteer", "volunteer",
"profiles",
"awards", "awards",
], ],
summary: { summary: {
@ -98,10 +101,7 @@ export const resumeConfig: ResumeConfig = {
title: "Volunteer Work", title: "Volunteer Work",
enabled: true, enabled: true,
}, },
profiles: {
title: "Professional Profiles",
enabled: true,
},
awards: { awards: {
title: "Awards & Recognition", title: "Awards & Recognition",
enabled: true, enabled: true,
@ -297,7 +297,7 @@ export const navigationItems: NavigationItem[] = [
name: "Posts", name: "Posts",
path: "/posts", path: "/posts",
tooltip: "Posts", tooltip: "Posts",
icon: NotebookPen, icon: Newspaper,
enabled: true, enabled: true,
isActive: (path: string) => isActive: (path: string) =>
path.startsWith("/posts") || path.startsWith("/post/"), path.startsWith("/posts") || path.startsWith("/post/"),
@ -307,7 +307,7 @@ export const navigationItems: NavigationItem[] = [
name: "Resume", name: "Resume",
path: "/resume", path: "/resume",
tooltip: "Resume", tooltip: "Resume",
icon: BriefcaseBusiness, icon: FileUser,
enabled: !!(resumeConfig.tomlFile && resumeConfig.tomlFile.trim()), enabled: !!(resumeConfig.tomlFile && resumeConfig.tomlFile.trim()),
}, },
{ {

View File

@ -3,6 +3,41 @@ import puppeteer from "puppeteer";
import { siteConfig } from "../../../config/data"; import { siteConfig } from "../../../config/data";
import * as TOML from "@iarna/toml"; 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();
// Add inline styles for proper sizing and color
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 { interface ResumeData {
basics: { basics: {
name: string; name: string;
@ -49,9 +84,58 @@ interface ResumeData {
}[]; }[];
} }
const generateResumeHTML = (data: ResumeData): string => { // Helper function to generate sections for a column
function generateColumnSections(
sectionNames: string[] = [],
sectionData: { [key: string]: any },
): string {
return sectionNames
.map((sectionName) => {
const section = sectionData[sectionName];
if (
!section ||
!section.data ||
!section.data.length ||
!section.enabled
) {
return "";
}
return `
<section>
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300">
${section.title}
</h2>
<div class="${section.spacing}">
${section.html}
</div>
</section>
`;
})
.join("");
}
const generateResumeHTML = async (data: ResumeData): Promise<string> => {
const resumeConfig = siteConfig.resume; const resumeConfig = siteConfig.resume;
// Get layout configuration with defaults
const layout = resumeConfig.layout || {
leftColumn: ["experience", "volunteer", "awards"],
rightColumn: ["skills", "education"],
};
// Pre-fetch icons for profiles
const profileIcons: { [key: string]: string } = {};
if (data.basics.profiles) {
for (const profile of data.basics.profiles) {
const iconName = profile.network.toLowerCase();
profileIcons[profile.network] = await getSimpleIcon(iconName);
}
}
// Get email icon
const emailIcon = getMdiIcon("mdi:email");
const skillsHTML = const skillsHTML =
data.skills data.skills
?.map((skill) => { ?.map((skill) => {
@ -178,16 +262,17 @@ const generateResumeHTML = (data: ResumeData): string => {
<header class="text-center mb-3 pb-2 border-b-2 border-gray-300"> <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">${data.basics.name}</h1> <h1 class="text-3xl font-bold text-gray-900 mb-1">${data.basics.name}</h1>
<div class="flex justify-center items-center flex-wrap gap-4 text-xs text-gray-600"> <div class="flex justify-center items-center flex-wrap gap-4 text-xs text-gray-600">
${data.basics.email ? `<div class="flex items-center gap-1">📧 ${data.basics.email}</div>` : ""} ${data.basics.email ? `<div class="flex items-center gap-1">${emailIcon} ${data.basics.email}</div>` : ""}
${ ${
data.basics.profiles?.find((p) => p.network === "GitHub") data.basics.profiles
? `<div class="flex items-center gap-1">🔗 github.com/${data.basics.profiles.find((p) => p.network === "GitHub")?.username}</div>` ?.map((profile) => {
: "" const icon = profileIcons[profile.network] || "";
} const displayUrl = profile.url
${ .replace(/^https?:\/\//, "")
data.basics.profiles?.find((p) => p.network === "LinkedIn") .replace(/\/$/, "");
? `<div class="flex items-center gap-1">💼 linkedin.com/in/${data.basics.profiles.find((p) => p.network === "LinkedIn")?.username}</div>` return `<div class="flex items-center gap-1">${icon} ${displayUrl}</div>`;
: "" })
.join("") || ""
} }
</div> </div>
</header> </header>
@ -207,92 +292,95 @@ const generateResumeHTML = (data: ResumeData): string => {
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-4"> <div class="space-y-4">
${ ${generateColumnSections(layout.leftColumn, {
data.experience && experience: {
data.experience.length > 0 && data: data.experience,
resumeConfig.sections.experience?.enabled html: experienceHTML,
? ` title:
<section> resumeConfig.sections.experience?.title || "Experience",
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300"> enabled: resumeConfig.sections.experience?.enabled,
${resumeConfig.sections.experience.title || "Experience"} spacing: "space-y-3",
</h2> },
<div class="space-y-3"> volunteer: {
${experienceHTML} data: data.volunteer,
</div> html: volunteerHTML,
</section> title:
` resumeConfig.sections.volunteer?.title ||
: "" "Volunteer Work",
} enabled: resumeConfig.sections.volunteer?.enabled,
spacing: "space-y-2",
${ },
data.volunteer && awards: {
data.volunteer.length > 0 && data: data.awards,
resumeConfig.sections.volunteer?.enabled html: awardsHTML,
? ` title:
<section> resumeConfig.sections.awards?.title ||
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300"> "Awards & Recognition",
${resumeConfig.sections.volunteer.title || "Volunteer Work"} enabled: resumeConfig.sections.awards?.enabled,
</h2> spacing: "space-y-2",
<div class="space-y-2"> },
${volunteerHTML} skills: {
</div> data: data.skills,
</section> html: skillsHTML,
` title: resumeConfig.sections.skills?.title || "Skills",
: "" enabled: resumeConfig.sections.skills?.enabled,
} spacing: "space-y-1",
},
${ education: {
data.awards && data: data.education,
data.awards.length > 0 && html: educationHTML,
resumeConfig.sections.awards?.enabled title:
? ` resumeConfig.sections.education?.title || "Education",
<section> enabled: resumeConfig.sections.education?.enabled,
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300"> spacing: "space-y-3",
${resumeConfig.sections.awards.title || "Awards & Recognition"} },
</h2> })}
<div class="space-y-2">
${awardsHTML}
</div>
</section>
`
: ""
}
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
${ ${generateColumnSections(layout.rightColumn, {
data.skills && experience: {
data.skills.length > 0 && data: data.experience,
resumeConfig.sections.skills?.enabled html: experienceHTML,
? ` title:
<section> resumeConfig.sections.experience?.title || "Experience",
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300"> enabled: resumeConfig.sections.experience?.enabled,
${resumeConfig.sections.skills.title || "Skills"} spacing: "space-y-3",
</h2> },
<div class="space-y-1"> volunteer: {
${skillsHTML} data: data.volunteer,
</div> html: volunteerHTML,
</section> title:
` resumeConfig.sections.volunteer?.title ||
: "" "Volunteer Work",
} enabled: resumeConfig.sections.volunteer?.enabled,
spacing: "space-y-2",
${ },
data.education && awards: {
data.education.length > 0 && data: data.awards,
resumeConfig.sections.education?.enabled html: awardsHTML,
? ` title:
<section> resumeConfig.sections.awards?.title ||
<h2 class="text-sm font-semibold text-gray-900 mb-2 pb-1 border-b border-gray-300"> "Awards & Recognition",
${resumeConfig.sections.education.title || "Education"} enabled: resumeConfig.sections.awards?.enabled,
</h2> spacing: "space-y-2",
<div class="space-y-3"> },
${educationHTML} skills: {
</div> data: data.skills,
</section> html: skillsHTML,
` title: resumeConfig.sections.skills?.title || "Skills",
: "" enabled: resumeConfig.sections.skills?.enabled,
} spacing: "space-y-1",
},
education: {
data: data.education,
html: educationHTML,
title:
resumeConfig.sections.education?.title || "Education",
enabled: resumeConfig.sections.education?.enabled,
spacing: "space-y-3",
},
})}
</div> </div>
</div> </div>
</div> </div>
@ -323,7 +411,7 @@ export const GET: APIRoute = async ({ request }) => {
tomlContent, tomlContent,
) as unknown as ResumeData; ) as unknown as ResumeData;
const htmlContent = generateResumeHTML(resumeData); const htmlContent = await generateResumeHTML(resumeData);
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
headless: true, headless: true,
@ -366,7 +454,9 @@ export const GET: APIRoute = async ({ request }) => {
headers: { headers: {
"Content-Type": "application/pdf", "Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="Atridad_Lahiji_Resume.pdf"`, "Content-Disposition": `attachment; filename="Atridad_Lahiji_Resume.pdf"`,
"Cache-Control": "public, max-age=3600", "Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
}, },
}); });
} catch (error) { } catch (error) {

View File

@ -2,7 +2,7 @@
import { Icon } from "astro-icon/components"; import { Icon } from "astro-icon/components";
import Layout from "../layouts/Layout.astro"; import Layout from "../layouts/Layout.astro";
import ResumeSkills from "../components/ResumeSkills"; import ResumeSkills from "../components/ResumeSkills";
import PdfDownloadButton from "../components/PdfDownloadButton"; import ResumeDownloadButton from "../components/ResumeDownloadButton";
import { siteConfig } from "../config/data"; import { siteConfig } from "../config/data";
import "../styles/global.css"; import "../styles/global.css";
import * as TOML from "@iarna/toml"; import * as TOML from "@iarna/toml";
@ -111,40 +111,24 @@ if (!data) {
) )
} }
{ {
data.basics.profiles.find((p) => p.network === "GitHub") && ( data.basics.profiles.map((profile) => {
<a const iconName = `simple-icons:${profile.network.toLowerCase()}`;
href={ return (
data.basics.profiles.find( <a
(p) => p.network === "GitHub", href={profile.url}
)!.url target="_blank"
} rel="noopener noreferrer"
target="_blank" class="link link-hover inline-flex items-center gap-1 text-sm sm:text-base"
rel="noopener noreferrer" >
class="link link-hover inline-flex items-center gap-1 text-sm sm:text-base" <Icon name={iconName} />
> {profile.network}
<Icon name="simple-icons:github" /> GitHub </a>
</a> );
) })
}
{
data.basics.profiles.find((p) => p.network === "LinkedIn") && (
<a
href={
data.basics.profiles.find(
(p) => p.network === "LinkedIn",
)!.url
}
target="_blank"
rel="noopener noreferrer"
class="link link-hover inline-flex items-center gap-1 text-sm sm:text-base"
>
<Icon name="simple-icons:linkedin" /> LinkedIn
</a>
)
} }
</div> </div>
<PdfDownloadButton client:load /> <ResumeDownloadButton client:load />
{ {
data.summary && resumeConfig.sections.summary?.enabled && ( data.summary && resumeConfig.sections.summary?.enabled && (
@ -159,40 +143,6 @@ if (!data) {
) )
} }
{
data.basics.profiles &&
data.basics.profiles.length > 0 &&
resumeConfig.sections.profiles?.enabled && (
<div class="card bg-base-200 shadow-xl mb-4 sm:mb-6">
<div class="card-body p-4 sm:p-6 break-words">
<h2 class="card-title text-xl sm:text-2xl">
{resumeConfig.sections.profiles.title ||
"Profiles"}
</h2>
<div class="flex flex-wrap gap-3 sm:gap-4">
{data.basics.profiles.map((profile) => {
// Use Simple Icons directly based on network name
// Convert network name to lowercase and use simple-icons format
const iconName = `simple-icons:${profile.network.toLowerCase()}`;
return (
<a
href={profile.url}
target="_blank"
rel="noopener noreferrer"
class="link link-hover inline-flex items-center gap-1 text-sm sm:text-base"
>
<Icon name={iconName} />
{profile.network}
</a>
);
})}
</div>
</div>
</div>
)
}
{ {
data.skills && data.skills &&
data.skills.length > 0 && data.skills.length > 0 &&

View File

@ -57,6 +57,10 @@ export interface ResumeConfig {
filename: string; filename: string;
displayText: string; displayText: string;
}; };
layout?: {
leftColumn?: string[];
rightColumn?: string[];
};
sections: { sections: {
enabled: string[]; enabled: string[];
summary?: { summary?: {