Building a Filterable Resume Page with PDF Export and CI/CD for Astro
The resume page at /resume filters by years of experience (1, 2, 5, or 10), filters by company, and exports the same data as JSON, YAML, PDF, and DOCX. One file, src/data/resume.json, drives every view. Editing that file updates the page, the API endpoints, and the two pre-generated downloads.
I built it on December 22 alongside a CI/CD pipeline, because the next day a Dependabot PR tried to upgrade Tailwind CSS from v3 to v4 and I needed something between git push and production.
The Data Layer
LinkedIn exports the data you need. Settings & Privacy → Data Privacy → “Get a copy of your data” → “The works”. An email arrives in minutes for the small categories, up to 24 hours for everything. The ZIP contains a folder of CSV files: Profile.csv for headline and summary, Positions.csv for every job with company and dates and description, Education.csv, Skills.csv, Certifications.csv.
What it does not export: your profile photo, detailed job highlights (descriptions tend to be sparse), your LinkedIn URL itself, recommendations text.
linkedin-to-resume.mjs reads those CSVs, handles LinkedIn’s multiline quoted fields, maps to the JSON Resume schema, extracts bullet points from descriptions as highlights, and auto-categorizes skills into Cloud / Programming / Databases / etc.:
node scripts/linkedin-to-resume.mjs ~/Downloads/Basic_LinkedInDataExport_12-22-2025/const positions = readCSVFile(exportDir, 'Positions.csv');
const work = positions.map(pos => ({ name: pos['Company Name'], position: pos['Title'], startDate: formatDate(pos['Started On']), // "Jan 2020" → "2020-01" endDate: formatDate(pos['Finished On']), summary: pos['Description'], highlights: extractBulletPoints(pos['Description'])}));The output is a JSON file ready for editing. Thirty minutes of enrichment turned the sparse LinkedIn data into specific achievements with metrics.
A recruiter looking for the last 2 years clicks a button and sees exactly that.
The Filtering
Client-side JavaScript filters without page reloads:
const filterExperience = (years) => { const cutoffDate = new Date(); cutoffDate.setFullYear(cutoffDate.getFullYear() - years);
document.querySelectorAll('.experience-item').forEach(item => { const endDate = item.dataset.endDate; const show = !endDate || new Date(endDate) >= cutoffDate; item.style.display = show ? 'block' : 'none'; });};A recruiter looking for the last 2 years clicks a button and sees exactly that.
Thirty minutes of enrichment turned the sparse LinkedIn data into specific achievements with metrics.
The PDF
My first attempt used html2pdf.js for client-side generation. Content Security Policy blocked the CDN script.
The fix was to generate at build time. A Node script launches Puppeteer, points it at the locally-served /resume/, injects a compact stylesheet (11px body, hidden nav), and writes the result to dist/resume.pdf:
async function generatePDF() { const browser = await puppeteer.launch({ headless: true }); const page = await browser.newPage();
await page.goto('http://localhost:3456/resume/', { waitUntil: 'networkidle0' });
await page.evaluate(() => { const style = document.createElement('style'); style.textContent = ` body { font-size: 11px !important; } h1 { font-size: 24px !important; } `; document.head.appendChild(style); document.querySelector('nav').style.display = 'none'; });
await page.pdf({ path: 'dist/resume.pdf', format: 'Letter', printBackground: true, margin: { top: '0.4in', right: '0.4in', bottom: '0.4in', left: '0.4in' } });
await browser.close();}DOCX is built programmatically from the same JSON via the docx library: a Document with sections of Paragraph and TextRun nodes serialized through Packer.toBuffer(), then written to dist/resume.docx.
Puppeteer and docx are installed locally but kept out of package.json. Cloudflare’s build step does not need either, and including them produced a peer-dependency conflict with Tailwind that broke the deploy. npm install puppeteer docx runs once on my laptop; npm run resume:generate runs the two scripts:
# Local only, not in package.jsonnpm install puppeteer docx
# Generate resume filesnpm run resume:generateThe Pipeline
ci.yml (and the sibling ci-tech.yml for the tech subsite) runs two jobs on every push and PR. build-and-test checks out, sets up Node 22 with npm cache, runs npm ci, builds the site, and validates that every image reference resolves. security-audit runs npm audit --audit-level=high and fails the build on vulnerabilities. Lighthouse runs on PRs only.
jobs: build-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '22' cache: 'npm' - run: npm ci - run: npm run build - run: npm run validate:images
security-audit: runs-on: ubuntu-latest steps: - run: npm ci - run: npm audit --audit-level=highDependabot auto-merges patch and minor updates when CI passes. Major updates get a warning comment and stay open for human review:
- name: Auto-merge patch updates if: steps.metadata.outputs.update-type == 'version-update:semver-patch' run: gh pr merge --auto --squash "$PR_URL"
- name: Comment on major updates if: steps.metadata.outputs.update-type == 'version-update:semver-major' run: | gh pr comment "$PR_URL" --body "Major version update, requires manual review"The Tailwind v4 PR
The day after I wired the pipeline, Dependabot opened a PR upgrading Tailwind from v3 to v4. v4 is a complete rewrite; the build would have failed and the auto-merge would have refused it on the major-version rule, but I closed the PR with an explanation and added a permanent ignore so future v4 PRs do not reappear:
ignore: - dependency-name: "tailwindcss" update-types: ["version-update:semver-major"]The same rule shape now blocks any major-version surprise on the packages I am not ready to migrate.
src/data/resume.json is the single source of truth, linkedin-to-resume.mjs is the import path, Puppeteer plus docx are the build-time exporters, and ci.yml plus the Dependabot config are the seatbelt. The JSON Resume schema means the data is portable if I ever swap tools; the API endpoints (/api/resume.json, /api/resume.yaml) mean other tools can read it without scraping the page.
The same rule shape now blocks any major-version surprise on the packages I am not ready to migrate.