Integration & Automation
Integrate JSON Resume into your workflows, websites, and automation pipelines. This guide covers LinkedIn integration, portfolio embedding, CI/CD automation, and deployment to modern hosting platforms.
Quick Start
GitHub Actions Auto-Publish
The fastest way to auto-publish your resume:
# .github/workflows/resume.yml
name: Update Resume
on:
push:
paths:
- 'resume.json'
- '.github/workflows/resume.yml'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install resume-cli
run: npm install -g resume-cli
- name: Publish to jsonresume.org
run: resume publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Vercel One-Click Deploy
Deploy your resume as a Next.js app:
# Clone starter template
npx create-next-app@latest my-resume --example https://github.com/jsonresume/nextjs-starter
# Add your resume
cp resume.json my-resume/public/
# Deploy to Vercel
cd my-resume
vercelNetlify Deploy
Deploy static HTML resume:
# netlify.toml
[build]
command = "npm install -g resume-cli && resume export index.html --theme professional"
publish = "."
[[redirects]]
from = "/*"
to = "/index.html"
status = 200LinkedIn Integration
Export LinkedIn to JSON Resume
Using linkedin-to-jsonresume NPM Package
# Install the converter
npm install -g linkedin-to-jsonresume
# Export your LinkedIn profile
# 1. Go to linkedin.com/settings/data-export
# 2. Request archive (includes profile data)
# 3. Download and extract the ZIP file
# Convert to JSON Resume format
linkedin-to-jsonresume path/to/linkedin-export.zip > resume.json
# Validate the output
resume validate resume.jsonManual LinkedIn Data Export
// scripts/linkedin-to-resume.js
import fs from 'fs';
import path from 'path';
/**
* Convert LinkedIn export to JSON Resume format
* LinkedIn provides: Profile.csv, Positions.csv, Education.csv, Skills.csv
*/
async function convertLinkedIn(exportPath) {
const resume = {
$schema:
'https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json',
basics: {},
work: [],
education: [],
skills: [],
};
// Parse Profile.csv
const profile = parseCSV(path.join(exportPath, 'Profile.csv'))[0];
resume.basics = {
name: `${profile['First Name']} ${profile['Last Name']}`,
label: profile['Headline'],
email: profile['Email Address'],
url: profile['Public Profile URL'],
summary: profile['Summary'],
location: {
city: profile['Geo Location'],
countryCode: profile['Country'],
},
};
// Parse Positions.csv
const positions = parseCSV(path.join(exportPath, 'Positions.csv'));
resume.work = positions.map((pos) => ({
name: pos['Company Name'],
position: pos['Title'],
startDate: formatDate(pos['Started On']),
endDate: pos['Finished On'] ? formatDate(pos['Finished On']) : null,
summary: pos['Description'],
url: pos['Company LinkedIn URL'],
}));
// Parse Education.csv
const education = parseCSV(path.join(exportPath, 'Education.csv'));
resume.education = education.map((edu) => ({
institution: edu['School Name'],
area: edu['Degree Name'],
studyType: edu['Degree'],
startDate: formatDate(edu['Start Date']),
endDate: formatDate(edu['End Date']),
courses: edu['Activities and Societies']
? edu['Activities and Societies'].split(',')
: [],
}));
// Parse Skills.csv
const skills = parseCSV(path.join(exportPath, 'Skills.csv'));
resume.skills = skills.map((skill) => ({
name: skill['Name'],
level: '', // LinkedIn doesn't export skill level
keywords: [],
}));
return resume;
}
function parseCSV(filePath) {
const csv = fs.readFileSync(filePath, 'utf8');
const lines = csv.split('\n');
const headers = lines[0].split(',');
return lines.slice(1).map((line) => {
const values = line.split(',');
return headers.reduce((obj, header, i) => {
obj[header.trim()] = values[i]?.trim() || '';
return obj;
}, {});
});
}
function formatDate(dateStr) {
// LinkedIn format: "MM/YYYY" or "YYYY"
if (!dateStr) return null;
const parts = dateStr.split('/');
if (parts.length === 2) {
return `${parts[1]}-${parts[0].padStart(2, '0')}-01`;
}
return `${dateStr}-01-01`;
}
// Usage
const resume = await convertLinkedIn('./linkedin-export');
fs.writeFileSync('resume.json', JSON.stringify(resume, null, 2));
console.log('✓ Converted LinkedIn export to resume.json');Using Chrome Extension (Manual Export)
// LinkedIn profile scraper bookmarklet
// Run in browser console on your LinkedIn profile page
(function () {
const resume = {
basics: {
name: document
.querySelector('.pv-text-details__left-panel h1')
?.textContent.trim(),
label: document
.querySelector('.pv-text-details__left-panel .text-body-medium')
?.textContent.trim(),
summary: document
.querySelector('.pv-about__summary-text')
?.textContent.trim(),
url: window.location.href,
profiles: [
{
network: 'LinkedIn',
url: window.location.href,
},
],
},
work: Array.from(document.querySelectorAll('#experience ~ div li')).map(
(exp) => ({
name: exp
.querySelector('.pv-entity__secondary-title')
?.textContent.trim(),
position: exp
.querySelector('.pv-entity__summary-info h3')
?.textContent.trim(),
startDate: exp
.querySelector('.pv-entity__date-range span:nth-child(2)')
?.textContent.split('–')[0]
.trim(),
endDate: exp
.querySelector('.pv-entity__date-range span:nth-child(2)')
?.textContent.split('–')[1]
?.trim(),
summary: exp
.querySelector('.pv-entity__description')
?.textContent.trim(),
})
),
};
console.log(JSON.stringify(resume, null, 2));
copy(JSON.stringify(resume, null, 2)); // Auto-copy to clipboard
alert('Resume data copied to clipboard!');
})();Sync LinkedIn Profile Updates
// scripts/sync-linkedin.js
import { chromium } from 'playwright';
/**
* Automated LinkedIn profile sync
* Requires LinkedIn session cookies
*/
async function syncLinkedInProfile() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
// Load saved LinkedIn session
const cookies = JSON.parse(fs.readFileSync('./linkedin-cookies.json'));
await context.addCookies(cookies);
const page = await context.newPage();
await page.goto('https://www.linkedin.com/in/me/');
// Extract profile data
const profileData = await page.evaluate(() => {
return {
name: document.querySelector('h1')?.textContent.trim(),
headline: document.querySelector('.text-body-medium')?.textContent.trim(),
// ... more fields
};
});
await browser.close();
// Merge with existing resume
const resume = JSON.parse(fs.readFileSync('./resume.json'));
resume.basics = { ...resume.basics, ...profileData };
fs.writeFileSync('./resume.json', JSON.stringify(resume, null, 2));
}Web Portfolio Integration
Embed in React Portfolio
// components/Resume.tsx
import { useEffect, useState } from 'react';
import type { ResumeSchema } from '@jsonresume/types';
export function Resume({ username }: { username: string }) {
const [resume, setResume] = useState<ResumeSchema | null>(null);
useEffect(() => {
fetch(`https://jsonresume.org/${username}.json`)
.then((res) => res.json())
.then(setResume)
.catch(console.error);
}, [username]);
if (!resume) return <div>Loading...</div>;
return (
<div className="resume">
<header>
<h1>{resume.basics?.name}</h1>
<p>{resume.basics?.label}</p>
<p>{resume.basics?.summary}</p>
</header>
<section>
<h2>Experience</h2>
{resume.work?.map((job, i) => (
<div key={i}>
<h3>
{job.position} at {job.name}
</h3>
<p>
{job.startDate} - {job.endDate || 'Present'}
</p>
<p>{job.summary}</p>
<ul>
{job.highlights?.map((h, j) => (
<li key={j}>{h}</li>
))}
</ul>
</div>
))}
</section>
</div>
);
}Embed in Vue.js Portfolio
<!-- components/JsonResume.vue -->
<template>
<div class="resume" v-if="resume">
<header>
<h1>{{ resume.basics?.name }}</h1>
<p class="label">{{ resume.basics?.label }}</p>
</header>
<section v-for="job in resume.work" :key="job.name">
<h3>{{ job.position }}</h3>
<p class="company">{{ job.name }}</p>
<p class="dates">
{{ formatDate(job.startDate) }} - {{ formatDate(job.endDate) }}
</p>
<ul>
<li v-for="(highlight, i) in job.highlights" :key="i">
{{ highlight }}
</li>
</ul>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import type { ResumeSchema } from '@jsonresume/types';
const props = defineProps<{ username: string }>();
const resume = ref<ResumeSchema | null>(null);
onMounted(async () => {
const res = await fetch(`https://jsonresume.org/${props.username}.json`);
resume.value = await res.json();
});
function formatDate(date: string | undefined) {
if (!date) return 'Present';
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
});
}
</script>Static Site Integration (Hugo)
<!-- layouts/partials/resume.html -->
{{ $resume := getJSON "https://jsonresume.org/" .Site.Params.github_username
".json" }}
<div class="resume">
<header>
<h1>{{ $resume.basics.name }}</h1>
<p>{{ $resume.basics.label }}</p>
<p>{{ $resume.basics.email }} | {{ $resume.basics.phone }}</p>
</header>
<section class="experience">
<h2>Experience</h2>
{{ range $resume.work }}
<div class="job">
<h3>{{ .position }} - {{ .name }}</h3>
<p class="dates">{{ .startDate }} to {{ .endDate }}</p>
<p>{{ .summary }}</p>
<ul>
{{ range .highlights }}
<li>{{ . }}</li>
{{ end }}
</ul>
</div>
{{ end }}
</section>
</div>Gatsby Portfolio Integration
// gatsby-config.js
module.exports = {
plugins: [
{
resolve: 'gatsby-source-filesystem',
options: {
name: 'resume',
path: `${__dirname}/resume.json`,
},
},
{
resolve: 'gatsby-transformer-json',
options: {
typeName: 'Resume',
},
},
],
};
// src/pages/resume.js
import React from 'react';
import { graphql } from 'gatsby';
export default function ResumePage({ data }) {
const resume = data.resumeJson;
return (
<div>
<h1>{resume.basics.name}</h1>
<p>{resume.basics.label}</p>
{resume.work.map((job, i) => (
<div key={i}>
<h2>
{job.position} at {job.name}
</h2>
<p>
{job.startDate} - {job.endDate}
</p>
</div>
))}
</div>
);
}
export const query = graphql`
query {
resumeJson {
basics {
name
label
email
summary
}
work {
name
position
startDate
endDate
summary
highlights
}
}
}
`;ATS System Support
Export for ATS Compatibility
// scripts/generate-ats-friendly.js
import fs from 'fs';
import { marked } from 'marked';
/**
* Generate ATS-friendly formats from JSON Resume
* Outputs: Plain text, Simple HTML, XML
*/
function generateATSFormats(resume) {
// Plain text version (best for ATS parsing)
const plainText = `
${resume.basics.name}
${resume.basics.label}
${resume.basics.email} | ${resume.basics.phone}
${resume.basics.location?.city}, ${resume.basics.location?.region}
SUMMARY
${resume.basics.summary}
EXPERIENCE
${resume.work
.map(
(job) => `
${job.position} - ${job.name}
${job.startDate} to ${job.endDate || 'Present'}
${job.summary}
${job.highlights?.map((h) => `• ${h}`).join('\n')}
`
)
.join('\n')}
EDUCATION
${resume.education
.map(
(edu) => `
${edu.studyType} in ${edu.area}
${edu.institution}, ${edu.startDate} - ${edu.endDate}
`
)
.join('\n')}
SKILLS
${resume.skills.map((s) => s.name).join(', ')}
`.trim();
// Simple HTML (ATS-safe, no CSS)
const simpleHTML = `
<!DOCTYPE html>
<html>
<head><title>${resume.basics.name} - Resume</title></head>
<body>
<h1>${resume.basics.name}</h1>
<p>${resume.basics.label}</p>
<p>${resume.basics.email} | ${resume.basics.phone}</p>
<h2>Summary</h2>
<p>${resume.basics.summary}</p>
<h2>Experience</h2>
${resume.work
.map(
(job) => `
<div>
<h3>${job.position} - ${job.name}</h3>
<p>${job.startDate} to ${job.endDate || 'Present'}</p>
<p>${job.summary}</p>
<ul>
${job.highlights?.map((h) => `<li>${h}</li>`).join('')}
</ul>
</div>
`
)
.join('')}
</body>
</html>
`;
// HR-XML format (some ATS systems support this)
const hrXML = `
<?xml version="1.0" encoding="UTF-8"?>
<Resume>
<StructuredXMLResume>
<ContactInfo>
<PersonName>
<FormattedName>${resume.basics.name}</FormattedName>
</PersonName>
<ContactMethod>
<InternetEmailAddress>${resume.basics.email}</InternetEmailAddress>
<Telephone>${resume.basics.phone}</Telephone>
</ContactMethod>
</ContactInfo>
<EmploymentHistory>
${resume.work
.map(
(job) => `
<EmployerOrg>
<EmployerOrgName>${job.name}</EmployerOrgName>
<PositionHistory>
<Title>${job.position}</Title>
<StartDate>${job.startDate}</StartDate>
${job.endDate ? `<EndDate>${job.endDate}</EndDate>` : ''}
<Description>${job.summary}</Description>
</PositionHistory>
</EmployerOrg>
`
)
.join('')}
</EmploymentHistory>
</StructuredXMLResume>
</Resume>
`;
return { plainText, simpleHTML, hrXML };
}
const resume = JSON.parse(fs.readFileSync('./resume.json', 'utf8'));
const formats = generateATSFormats(resume);
fs.writeFileSync('resume-ats.txt', formats.plainText);
fs.writeFileSync('resume-ats.html', formats.simpleHTML);
fs.writeFileSync('resume-ats.xml', formats.hrXML);
console.log('✓ Generated ATS-friendly formats');ATS Optimization Checker
// scripts/check-ats-compatibility.js
/**
* Check resume for ATS compatibility issues
*/
function checkATSCompatibility(resume) {
const issues = [];
const warnings = [];
// Check for required fields
if (!resume.basics?.name) issues.push('Missing name in basics');
if (!resume.basics?.email) issues.push('Missing email in basics');
if (!resume.basics?.phone)
warnings.push('Missing phone number (recommended)');
// Check date formats
resume.work?.forEach((job, i) => {
if (!job.startDate?.match(/^\d{4}-\d{2}-\d{2}$/)) {
warnings.push(
`Job ${i + 1}: Use YYYY-MM-DD date format for better ATS parsing`
);
}
});
// Check for keywords density
const text = JSON.stringify(resume).toLowerCase();
const keywordCount =
(resume.skills?.length || 0) +
(resume.work?.flatMap((j) => j.highlights || []).length || 0);
if (keywordCount < 20) {
warnings.push('Add more keywords/highlights for better ATS matching');
}
// Check for special characters that confuse ATS
const specialChars = ['•', '→', '★', '✓'];
const hasSpecialChars = specialChars.some((char) => text.includes(char));
if (hasSpecialChars) {
warnings.push('Avoid special characters/bullets - use plain text for ATS');
}
// Check file size (some ATS limit to 1MB)
const size = Buffer.byteLength(JSON.stringify(resume));
if (size > 1024 * 1024) {
warnings.push('Resume file size exceeds 1MB - some ATS may reject it');
}
return { issues, warnings, score: calculateATSScore(issues, warnings) };
}
function calculateATSScore(issues, warnings) {
let score = 100;
score -= issues.length * 20;
score -= warnings.length * 5;
return Math.max(0, score);
}
const resume = JSON.parse(fs.readFileSync('./resume.json', 'utf8'));
const result = checkATSCompatibility(resume);
console.log(`ATS Compatibility Score: ${result.score}/100`);
if (result.issues.length) {
console.log('\nIssues:');
result.issues.forEach((i) => console.log(` ✗ ${i}`));
}
if (result.warnings.length) {
console.log('\nWarnings:');
result.warnings.forEach((w) => console.log(` ⚠ ${w}`));
}Auto PDF Conversion
GitHub Actions PDF Generation
# .github/workflows/generate-pdf.yml
name: Generate Resume PDF
on:
push:
paths:
- 'resume.json'
workflow_dispatch:
jobs:
generate-pdf:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: |
npm install -g resume-cli
npm install -g puppeteer
- name: Generate PDF
run: |
resume export resume.pdf --theme professional
- name: Upload PDF artifact
uses: actions/upload-artifact@v4
with:
name: resume-pdf
path: resume.pdf
- name: Create Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: resume.pdf
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit PDF to repository
run: |
git config --global user.name 'Resume Bot'
git config --global user.email 'bot@jsonresume.org'
git add resume.pdf
git diff --staged --quiet || git commit -m "chore: update resume PDF [skip ci]"
git pushAutomated Multi-Format Export
# .github/workflows/export-all-formats.yml
name: Export All Resume Formats
on:
push:
branches: [main]
paths: ['resume.json']
jobs:
export:
runs-on: ubuntu-latest
strategy:
matrix:
format: [html, pdf, markdown, docx]
theme: [professional, standard, spartacus]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install tools
run: |
npm install -g resume-cli
npm install -g puppeteer
- name: Export resume
run: |
mkdir -p exports
resume export exports/resume-${{ matrix.theme }}.${{ matrix.format }} \
--theme ${{ matrix.theme }}
- name: Upload exports
uses: actions/upload-artifact@v4
with:
name: resume-${{ matrix.theme }}-${{ matrix.format }}
path: exports/Self-Hosted PDF Service
// server/pdf-service.js
import express from 'express';
import puppeteer from 'puppeteer';
import { resumeToHTML } from 'resume-cli';
const app = express();
app.use(express.json());
/**
* PDF generation microservice
* POST /generate-pdf
* Body: { resume: {...}, theme: 'professional' }
*/
app.post('/generate-pdf', async (req, res) => {
const { resume, theme = 'professional' } = req.body;
try {
// Convert resume to HTML
const html = await resumeToHTML(resume, theme);
// Generate PDF with Puppeteer
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '20mm',
bottom: '20mm',
left: '20mm',
},
});
await browser.close();
res.contentType('application/pdf');
res.send(pdf);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.listen(3000, () => {
console.log('PDF service running on port 3000');
});# Dockerfile for PDF service
FROM node:20-slim
# Install Chrome dependencies
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
libnss3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY server/ ./server/
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
EXPOSE 3000
CMD ["node", "server/pdf-service.js"]GitHub Pages Integration
Automatic GitHub Pages Deploy
# .github/workflows/gh-pages.yml
name: Deploy Resume to GitHub Pages
on:
push:
branches: [main]
paths:
- 'resume.json'
- '.github/workflows/gh-pages.yml'
permissions:
contents: read
pages: write
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install resume-cli
run: npm install -g resume-cli
- name: Generate HTML
run: |
mkdir -p public
resume export public/index.html --theme professional
cp resume.json public/resume.json
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: 'public'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4Multi-Theme GitHub Pages Site
# .github/workflows/multi-theme-pages.yml
name: Deploy Multi-Theme Resume Site
on:
push:
branches: [main]
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install -g resume-cli
- name: Generate all themes
run: |
mkdir -p public
# Generate index page with theme selector
cat > public/index.html <<EOF
<!DOCTYPE html>
<html>
<head>
<title>My Resume - Multiple Themes</title>
<style>
body { font-family: system-ui; max-width: 800px; margin: 50px auto; }
.theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; }
.theme-card { border: 1px solid #ddd; padding: 20px; border-radius: 8px; text-align: center; }
.theme-card a { text-decoration: none; color: #0066cc; font-weight: bold; }
</style>
</head>
<body>
<h1>My Resume</h1>
<p>Choose a theme:</p>
<div class="theme-grid">
<div class="theme-card"><a href="/professional.html">Professional</a></div>
<div class="theme-card"><a href="/standard.html">Standard</a></div>
<div class="theme-card"><a href="/spartacus.html">Spartacus</a></div>
<div class="theme-card"><a href="/flat.html">Flat</a></div>
<div class="theme-card"><a href="/cv.html">CV</a></div>
</div>
<p><a href="/resume.json">Download JSON</a> | <a href="/resume.pdf">Download PDF</a></p>
</body>
</html>
EOF
# Generate each theme
resume export public/professional.html --theme professional
resume export public/standard.html --theme standard
resume export public/spartacus.html --theme spartacus
resume export public/flat.html --theme flat
resume export public/cv.html --theme cv
# Copy JSON
cp resume.json public/resume.json
# Generate PDF
resume export public/resume.pdf --theme professional
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./publicCustom Domain Setup
# public/CNAME
resume.yourdomain.com# .github/workflows/gh-pages-custom-domain.yml
# ... previous steps ...
- name: Add CNAME
run: echo "resume.yourdomain.com" > public/CNAME
# ... deploy steps ...Then configure DNS:
# DNS Records
Type Name Value
CNAME resume yourusername.github.ioCI/CD Pipelines
GitLab CI Pipeline
# .gitlab-ci.yml
stages:
- validate
- build
- deploy
variables:
NODE_VERSION: '20'
validate:
stage: validate
image: node:${NODE_VERSION}
script:
- npm install -g resume-cli
- resume validate resume.json
only:
changes:
- resume.json
build:
stage: build
image: node:${NODE_VERSION}
script:
- npm install -g resume-cli
- npm install -g puppeteer
- mkdir -p artifacts
- resume export artifacts/resume.html --theme professional
- resume export artifacts/resume.pdf --theme professional
- cp resume.json artifacts/
artifacts:
paths:
- artifacts/
expire_in: 30 days
only:
changes:
- resume.json
deploy:production:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache curl
script:
- |
curl -X POST https://api.jsonresume.org/publish \
-H "Authorization: Bearer $JSONRESUME_TOKEN" \
-H "Content-Type: application/json" \
-d @resume.json
only:
- main
environment:
name: production
url: https://jsonresume.org/$CI_PROJECT_NAMEJenkins Pipeline
// Jenkinsfile
pipeline {
agent {
docker {
image 'node:20'
}
}
environment {
RESUME_FILE = 'resume.json'
}
stages {
stage('Install Dependencies') {
steps {
sh 'npm install -g resume-cli'
}
}
stage('Validate Resume') {
steps {
sh 'resume validate ${RESUME_FILE}'
}
}
stage('Generate Exports') {
parallel {
stage('HTML') {
steps {
sh 'resume export resume.html --theme professional'
}
}
stage('PDF') {
steps {
sh 'npm install -g puppeteer'
sh 'resume export resume.pdf --theme professional'
}
}
}
}
stage('Archive Artifacts') {
steps {
archiveArtifacts artifacts: 'resume.*', fingerprint: true
}
}
stage('Deploy') {
when {
branch 'main'
}
steps {
withCredentials([string(credentialsId: 'jsonresume-token', variable: 'TOKEN')]) {
sh '''
curl -X POST https://api.jsonresume.org/publish \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d @${RESUME_FILE}
'''
}
}
}
}
post {
success {
slackSend(
color: 'good',
message: "Resume updated successfully: ${env.BUILD_URL}"
)
}
failure {
slackSend(
color: 'danger',
message: "Resume build failed: ${env.BUILD_URL}"
)
}
}
}CircleCI Configuration
# .circleci/config.yml
version: 2.1
executors:
node:
docker:
- image: cimg/node:20.0
jobs:
validate:
executor: node
steps:
- checkout
- run:
name: Install resume-cli
command: npm install -g resume-cli
- run:
name: Validate resume
command: resume validate resume.json
build:
executor: node
steps:
- checkout
- run:
name: Install dependencies
command: |
npm install -g resume-cli
npm install -g puppeteer
- run:
name: Generate exports
command: |
mkdir -p ~/artifacts
resume export ~/artifacts/resume.html --theme professional
resume export ~/artifacts/resume.pdf --theme professional
cp resume.json ~/artifacts/
- persist_to_workspace:
root: ~/artifacts
paths:
- resume.*
- store_artifacts:
path: ~/artifacts
deploy:
executor: node
steps:
- checkout
- attach_workspace:
at: ~/artifacts
- run:
name: Deploy to jsonresume.org
command: |
curl -X POST https://api.jsonresume.org/publish \
-H "Authorization: Bearer ${JSONRESUME_TOKEN}" \
-H "Content-Type: application/json" \
-d @resume.json
workflows:
version: 2
build-and-deploy:
jobs:
- validate
- build:
requires:
- validate
- deploy:
requires:
- build
filters:
branches:
only: mainNotion & Airtable Integration
Notion Resume Sync
// scripts/notion-to-resume.js
import { Client } from '@notionhq/client';
import fs from 'fs';
const notion = new Client({ auth: process.env.NOTION_API_KEY });
/**
* Sync resume data from Notion database
* Database structure:
* - Work Experience: Table with Company, Role, Start Date, End Date, Description
* - Education: Table with School, Degree, Field, Start Date, End Date
* - Skills: Table with Name, Level, Keywords
*/
async function syncFromNotion() {
const resume = {
$schema:
'https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json',
basics: await getBasics(),
work: await getWorkExperience(),
education: await getEducation(),
skills: await getSkills(),
};
fs.writeFileSync('resume.json', JSON.stringify(resume, null, 2));
console.log('✓ Synced resume from Notion');
return resume;
}
async function getBasics() {
// Get basics from a Notion page
const page = await notion.pages.retrieve({
page_id: process.env.NOTION_BASICS_PAGE_ID,
});
return {
name: page.properties.Name?.title[0]?.plain_text,
label: page.properties.Label?.rich_text[0]?.plain_text,
email: page.properties.Email?.email,
phone: page.properties.Phone?.phone_number,
summary: page.properties.Summary?.rich_text[0]?.plain_text,
url: page.properties.Website?.url,
};
}
async function getWorkExperience() {
const database = await notion.databases.query({
database_id: process.env.NOTION_WORK_DB_ID,
sorts: [{ property: 'Start Date', direction: 'descending' }],
});
return database.results.map((page) => ({
name: page.properties.Company?.title[0]?.plain_text,
position: page.properties.Role?.rich_text[0]?.plain_text,
startDate: page.properties['Start Date']?.date?.start,
endDate: page.properties['End Date']?.date?.start,
summary: page.properties.Description?.rich_text[0]?.plain_text,
highlights:
page.properties.Highlights?.multi_select.map((h) => h.name) || [],
}));
}
async function getEducation() {
const database = await notion.databases.query({
database_id: process.env.NOTION_EDUCATION_DB_ID,
});
return database.results.map((page) => ({
institution: page.properties.School?.title[0]?.plain_text,
area: page.properties.Field?.rich_text[0]?.plain_text,
studyType: page.properties.Degree?.select?.name,
startDate: page.properties['Start Date']?.date?.start,
endDate: page.properties['End Date']?.date?.start,
}));
}
async function getSkills() {
const database = await notion.databases.query({
database_id: process.env.NOTION_SKILLS_DB_ID,
});
return database.results.map((page) => ({
name: page.properties.Name?.title[0]?.plain_text,
level: page.properties.Level?.select?.name,
keywords: page.properties.Keywords?.multi_select.map((k) => k.name) || [],
}));
}
// Run sync
syncFromNotion().catch(console.error);Airtable Resume Sync
// scripts/airtable-to-resume.js
import Airtable from 'airtable';
import fs from 'fs';
const base = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }).base(
process.env.AIRTABLE_BASE_ID
);
/**
* Sync resume from Airtable
* Base structure:
* - Basics (single record)
* - Work (multiple records)
* - Education (multiple records)
* - Skills (multiple records)
*/
async function syncFromAirtable() {
const resume = {
basics: await getBasics(),
work: await getWork(),
education: await getEducation(),
skills: await getSkills(),
};
fs.writeFileSync('resume.json', JSON.stringify(resume, null, 2));
console.log('✓ Synced resume from Airtable');
}
async function getBasics() {
const records = await base('Basics').select().firstPage();
const record = records[0].fields;
return {
name: record.Name,
label: record.Label,
email: record.Email,
phone: record.Phone,
summary: record.Summary,
url: record.Website,
location: {
city: record.City,
region: record.Region,
countryCode: record.Country,
},
};
}
async function getWork() {
const records = await base('Work')
.select({
sort: [{ field: 'Start Date', direction: 'desc' }],
})
.all();
return records.map((record) => ({
name: record.fields.Company,
position: record.fields.Position,
startDate: record.fields['Start Date'],
endDate: record.fields['End Date'],
summary: record.fields.Description,
highlights: record.fields.Highlights || [],
}));
}
async function getEducation() {
const records = await base('Education').select().all();
return records.map((record) => ({
institution: record.fields.School,
area: record.fields.Field,
studyType: record.fields.Degree,
startDate: record.fields['Start Date'],
endDate: record.fields['End Date'],
}));
}
async function getSkills() {
const records = await base('Skills').select().all();
return records.map((record) => ({
name: record.fields.Name,
level: record.fields.Level,
keywords: record.fields.Keywords || [],
}));
}
syncFromAirtable().catch(console.error);Bidirectional Sync with Webhooks
// server/airtable-webhook.js
import express from 'express';
import fs from 'fs';
import { syncFromAirtable } from './airtable-to-resume.js';
const app = express();
app.use(express.json());
/**
* Webhook endpoint for Airtable changes
* Configure webhook in Airtable: https://airtable.com/account/integrations
*/
app.post('/webhook/airtable', async (req, res) => {
const { baseId, changedRecords } = req.body;
console.log(`Airtable change detected in base ${baseId}`);
console.log(`Changed records:`, changedRecords);
try {
// Re-sync resume from Airtable
await syncFromAirtable();
// Trigger GitHub Actions to rebuild
await fetch('https://api.github.com/repos/username/resume/dispatches', {
method: 'POST',
headers: {
Authorization: `token ${process.env.GITHUB_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
event_type: 'airtable-update',
}),
});
res.json({
status: 'success',
message: 'Resume synced and rebuild triggered',
});
} catch (error) {
console.error('Sync failed:', error);
res.status(500).json({ status: 'error', message: error.message });
}
});
app.listen(3000, () => {
console.log('Airtable webhook server running on port 3000');
});Auto-Update from GitHub Activity
GitHub Contributions Sync
// scripts/sync-github-contributions.js
import { graphql } from '@octokit/graphql';
import fs from 'fs';
/**
* Auto-update resume with GitHub contributions
* Adds recent projects and contributions to portfolio section
*/
async function syncGitHubContributions() {
const graphqlWithAuth = graphql.defaults({
headers: {
authorization: `token ${process.env.GITHUB_TOKEN}`,
},
});
// Fetch recent repositories and contributions
const { user } = await graphqlWithAuth(`
{
user(login: "${process.env.GITHUB_USERNAME}") {
repositories(
first: 10
orderBy: { field: UPDATED_AT, direction: DESC }
privacy: PUBLIC
) {
nodes {
name
description
url
stargazerCount
primaryLanguage {
name
}
languages(first: 5) {
nodes {
name
}
}
}
}
contributionsCollection {
totalCommitContributions
totalPullRequestContributions
totalIssueContributions
}
}
}
`);
// Load existing resume
const resume = JSON.parse(fs.readFileSync('resume.json', 'utf8'));
// Update projects section
resume.projects = user.repositories.nodes.map((repo) => ({
name: repo.name,
description: repo.description,
url: repo.url,
keywords: repo.languages.nodes.map((l) => l.name),
highlights: [
`${repo.stargazerCount} stars`,
`Primary language: ${repo.primaryLanguage?.name || 'N/A'}`,
],
}));
// Add GitHub stats to summary
const stats = user.contributionsCollection;
const githubStats = `GitHub: ${stats.totalCommitContributions} commits, ${stats.totalPullRequestContributions} PRs, ${stats.totalIssueContributions} issues in the last year.`;
if (!resume.basics.summary.includes('GitHub:')) {
resume.basics.summary += `\n\n${githubStats}`;
}
fs.writeFileSync('resume.json', JSON.stringify(resume, null, 2));
console.log('✓ Synced GitHub contributions');
}
syncGitHubContributions().catch(console.error);Automated GitHub Workflow
# .github/workflows/auto-update-resume.yml
name: Auto-Update Resume from GitHub
on:
schedule:
# Run weekly on Sundays at midnight
- cron: '0 0 * * 0'
workflow_dispatch:
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install @octokit/graphql
- name: Sync GitHub contributions
run: node scripts/sync-github-contributions.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_USERNAME: ${{ github.repository_owner }}
- name: Commit changes
run: |
git config --global user.name 'Resume Bot'
git config --global user.email 'bot@jsonresume.org'
git add resume.json
git diff --staged --quiet || git commit -m "chore: auto-update GitHub contributions"
git pushZapier & Make.com Integration
Zapier Custom Integration
// zapier/triggers/resume-updated.js
/**
* Zapier Trigger: Resume Updated
* Triggers when resume.json changes in GitHub
*/
module.exports = {
key: 'resume_updated',
noun: 'Resume',
display: {
label: 'Resume Updated',
description: 'Triggers when your JSON Resume is updated',
},
operation: {
inputFields: [
{
key: 'github_repo',
label: 'GitHub Repository',
type: 'string',
required: true,
helpText: 'Format: username/repo-name',
},
],
perform: async (z, bundle) => {
const [owner, repo] = bundle.inputData.github_repo.split('/');
// Get latest commits for resume.json
const response = await z.request({
url: `https://api.github.com/repos/${owner}/${repo}/commits`,
params: {
path: 'resume.json',
per_page: 1,
},
});
const commit = response.data[0];
// Fetch resume content
const resumeResponse = await z.request({
url: `https://raw.githubusercontent.com/${owner}/${repo}/${commit.sha}/resume.json`,
});
return [
{
id: commit.sha,
resume: JSON.parse(resumeResponse.data),
updated_at: commit.commit.author.date,
message: commit.commit.message,
},
];
},
sample: {
id: 'abc123',
resume: {
basics: {
name: 'John Doe',
email: 'john@example.com',
},
},
updated_at: '2024-01-15T10:30:00Z',
message: 'Update work experience',
},
},
};// zapier/creates/publish-resume.js
/**
* Zapier Action: Publish Resume
* Publishes resume to jsonresume.org
*/
module.exports = {
key: 'publish_resume',
noun: 'Resume',
display: {
label: 'Publish Resume',
description: 'Publishes your resume to jsonresume.org',
},
operation: {
inputFields: [
{
key: 'resume',
label: 'Resume JSON',
type: 'text',
required: true,
helpText: 'Your complete JSON Resume data',
},
{
key: 'theme',
label: 'Theme',
type: 'string',
choices: ['professional', 'standard', 'spartacus', 'flat'],
default: 'professional',
},
],
perform: async (z, bundle) => {
const response = await z.request({
url: 'https://api.jsonresume.org/publish',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: {
resume: JSON.parse(bundle.inputData.resume),
theme: bundle.inputData.theme,
},
});
return response.data;
},
},
};Make.com HTTP Module
{
"name": "JSON Resume Publisher",
"modules": [
{
"id": 1,
"module": "gateway:CustomWebHook",
"version": 1,
"parameters": {
"hook": "resume-webhook",
"maxResults": 1
}
},
{
"id": 2,
"module": "http:ActionSendData",
"version": 3,
"parameters": {},
"mapper": {
"url": "https://api.jsonresume.org/publish",
"method": "post",
"headers": [
{
"name": "Content-Type",
"value": "application/json"
}
],
"qs": [],
"bodyType": "raw",
"parseResponse": true,
"body": "{{1.resume}}"
}
},
{
"id": 3,
"module": "github:ActionCreateCommit",
"version": 1,
"mapper": {
"repo": "resume",
"owner": "{{1.username}}",
"branch": "main",
"message": "Update resume via Make.com",
"files": [
{
"path": "resume.json",
"content": "{{1.resume}}"
}
]
}
}
]
}n8n Workflow
{
"name": "JSON Resume Auto-Publisher",
"nodes": [
{
"parameters": {
"path": "resume-webhook",
"responseMode": "lastNode"
},
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"position": [250, 300]
},
{
"parameters": {
"url": "https://api.github.com/repos/{{$json.owner}}/{{$json.repo}}/contents/resume.json",
"authentication": "genericCredentialType",
"genericAuthType": "oAuth2Api",
"requestMethod": "GET"
},
"name": "Get Resume from GitHub",
"type": "n8n-nodes-base.httpRequest",
"position": [450, 300]
},
{
"parameters": {
"functionCode": "const content = Buffer.from($input.item.json.content, 'base64').toString();\nreturn { json: JSON.parse(content) };"
},
"name": "Parse Resume",
"type": "n8n-nodes-base.function",
"position": [650, 300]
},
{
"parameters": {
"url": "https://api.jsonresume.org/publish",
"requestMethod": "POST",
"jsonParameters": true,
"bodyParametersJson": "={{ $json }}"
},
"name": "Publish to jsonresume.org",
"type": "n8n-nodes-base.httpRequest",
"position": [850, 300]
}
],
"connections": {
"Webhook": {
"main": [
[{ "node": "Get Resume from GitHub", "type": "main", "index": 0 }]
]
},
"Get Resume from GitHub": {
"main": [[{ "node": "Parse Resume", "type": "main", "index": 0 }]]
},
"Parse Resume": {
"main": [
[{ "node": "Publish to jsonresume.org", "type": "main", "index": 0 }]
]
}
}
}Mobile Apps & Bots
Telegram Bot Integration
// bot/telegram-resume-bot.js
import TelegramBot from 'node-telegram-bot-api';
import fs from 'fs';
import { resumeToHTML, resumeToPDF } from 'resume-cli';
const bot = new TelegramBot(process.env.TELEGRAM_BOT_TOKEN, { polling: true });
/**
* Telegram bot for resume management
* Commands:
* /start - Welcome message
* /resume - Get your resume as PDF
* /update - Update resume from file
* /preview - Preview resume in browser
*/
bot.onText(/\/start/, (msg) => {
const chatId = msg.chat.id;
bot.sendMessage(
chatId,
`
Welcome to JSON Resume Bot!
Commands:
/resume - Get your resume as PDF
/update - Send me your resume.json to update
/preview - Get preview link
/help - Show this message
`
);
});
bot.onText(/\/resume/, async (msg) => {
const chatId = msg.chat.id;
const username = msg.from.username;
try {
// Fetch user's resume
const response = await fetch(`https://jsonresume.org/${username}.json`);
const resume = await response.json();
// Generate PDF
const pdf = await resumeToPDF(resume, 'professional');
// Send PDF
bot.sendDocument(chatId, pdf, {
caption: 'Here is your resume!',
});
} catch (error) {
bot.sendMessage(chatId, `Error: ${error.message}`);
}
});
bot.on('document', async (msg) => {
const chatId = msg.chat.id;
const file = msg.document;
if (file.file_name !== 'resume.json') {
return bot.sendMessage(chatId, 'Please send a file named resume.json');
}
try {
// Download file
const fileData = await bot.downloadFile(file.file_id, './');
const resume = JSON.parse(fs.readFileSync(fileData, 'utf8'));
// Validate
const validation = await validateResume(resume);
if (!validation.valid) {
return bot.sendMessage(
chatId,
`Invalid resume:\n${validation.errors.join('\n')}`
);
}
// Publish
await publishResume(resume, msg.from.username);
bot.sendMessage(
chatId,
`✓ Resume updated!\nView at: https://jsonresume.org/${msg.from.username}`
);
} catch (error) {
bot.sendMessage(chatId, `Error: ${error.message}`);
}
});
bot.onText(/\/preview/, (msg) => {
const chatId = msg.chat.id;
const username = msg.from.username;
bot.sendMessage(
chatId,
`Preview your resume:\nhttps://jsonresume.org/${username}`
);
});
console.log('Telegram bot is running...');Discord Bot Integration
// bot/discord-resume-bot.js
import { Client, GatewayIntentBits, SlashCommandBuilder } from 'discord.js';
import fs from 'fs';
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
});
/**
* Discord bot for resume sharing
* Slash commands:
* /resume [username] - Show resume
* /resume-pdf [username] - Get PDF
* /my-resume - Show your own resume
*/
client.once('ready', () => {
console.log(`Discord bot logged in as ${client.user.tag}`);
// Register slash commands
const commands = [
new SlashCommandBuilder()
.setName('resume')
.setDescription('View a JSON Resume')
.addStringOption((option) =>
option
.setName('username')
.setDescription('GitHub username')
.setRequired(true)
),
new SlashCommandBuilder()
.setName('resume-pdf')
.setDescription('Get resume as PDF')
.addStringOption((option) =>
option
.setName('username')
.setDescription('GitHub username')
.setRequired(true)
),
new SlashCommandBuilder()
.setName('my-resume')
.setDescription('Show your own resume'),
];
client.application.commands.set(commands);
});
client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const { commandName } = interaction;
if (commandName === 'resume') {
const username = interaction.options.getString('username');
try {
const response = await fetch(`https://jsonresume.org/${username}.json`);
const resume = await response.json();
const embed = {
color: 0x0099ff,
title: resume.basics.name,
description: resume.basics.summary,
url: `https://jsonresume.org/${username}`,
fields: [
{
name: 'Position',
value: resume.basics.label || 'N/A',
},
{
name: 'Experience',
value:
resume.work.length > 0
? resume.work[0].name + ' - ' + resume.work[0].position
: 'N/A',
},
],
footer: {
text: `View full resume at jsonresume.org/${username}`,
},
};
await interaction.reply({ embeds: [embed] });
} catch (error) {
await interaction.reply(`Error: ${error.message}`);
}
}
if (commandName === 'resume-pdf') {
await interaction.deferReply();
const username = interaction.options.getString('username');
try {
const response = await fetch(`https://jsonresume.org/${username}.pdf`);
const buffer = await response.arrayBuffer();
await interaction.editReply({
content: `Here's the resume for ${username}:`,
files: [
{
attachment: Buffer.from(buffer),
name: `${username}-resume.pdf`,
},
],
});
} catch (error) {
await interaction.editReply(`Error: ${error.message}`);
}
}
});
client.login(process.env.DISCORD_BOT_TOKEN);iOS Shortcut Integration
// Example API endpoint for iOS Shortcuts
// GET /api/shortcuts/resume?username=johndoe&format=pdf
import express from 'express';
const app = express();
app.get('/api/shortcuts/resume', async (req, res) => {
const { username, format = 'json' } = req.query;
try {
const response = await fetch(
`https://jsonresume.org/${username}.${format}`
);
if (format === 'pdf') {
const buffer = await response.arrayBuffer();
res.contentType('application/pdf');
res.send(Buffer.from(buffer));
} else {
const data = await response.json();
res.json(data);
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});AI & LLM Tools Integration
OpenAI GPT Integration
// scripts/ai-resume-assistant.js
import OpenAI from 'openai';
import fs from 'fs';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
/**
* AI Resume Assistant
* - Improve resume content with AI
* - Generate professional summaries
* - Optimize for ATS
*/
async function improveResumeWithAI(resume) {
const prompt = `
You are a professional resume writer. Improve the following JSON Resume by:
1. Enhancing the summary to be more impactful
2. Improving work experience descriptions
3. Making highlights more achievement-focused
4. Optimizing for ATS systems
Original resume:
${JSON.stringify(resume, null, 2)}
Return ONLY valid JSON in the same format.
`;
const completion = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
});
const improved = JSON.parse(completion.choices[0].message.content);
fs.writeFileSync('resume-improved.json', JSON.stringify(improved, null, 2));
console.log('✓ Resume improved with AI');
return improved;
}
async function generateSummary(resume) {
const experience = resume.work
.map((j) => `${j.position} at ${j.name}`)
.join(', ');
const completion = await openai.chat.completions.create({
model: 'gpt-4',
messages: [
{
role: 'user',
content: `Write a professional 3-sentence summary for someone with this experience: ${experience}. Focus on skills, achievements, and career goals.`,
},
],
max_tokens: 150,
});
return completion.choices[0].message.content.trim();
}
// Usage
const resume = JSON.parse(fs.readFileSync('resume.json', 'utf8'));
const improved = await improveResumeWithAI(resume);Claude AI Integration
// scripts/claude-resume-optimizer.js
import Anthropic from '@anthropic-ai/sdk';
import fs from 'fs';
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
/**
* Use Claude to optimize resume for specific job descriptions
*/
async function optimizeForJobDescription(resume, jobDescription) {
const message = await anthropic.messages.create({
model: 'claude-3-opus-20240229',
max_tokens: 4096,
messages: [
{
role: 'user',
content: `
I have this resume (JSON Resume format):
${JSON.stringify(resume, null, 2)}
I'm applying for this job:
${jobDescription}
Please optimize my resume for this specific job by:
1. Tailoring the summary to match the job requirements
2. Reordering and highlighting relevant work experience
3. Emphasizing relevant skills
4. Adding keywords from the job description
Return ONLY the optimized JSON Resume, no explanations.
`,
},
],
});
const optimized = JSON.parse(message.content[0].text);
fs.writeFileSync('resume-optimized.json', JSON.stringify(optimized, null, 2));
console.log('✓ Resume optimized for job description');
return optimized;
}
// Usage
const resume = JSON.parse(fs.readFileSync('resume.json', 'utf8'));
const jobDesc = fs.readFileSync('job-description.txt', 'utf8');
await optimizeForJobDescription(resume, jobDesc);LangChain Integration
// scripts/langchain-resume-qa.js
import { ChatOpenAI } from '@langchain/openai';
import { PromptTemplate } from '@langchain/core/prompts';
import { LLMChain } from 'langchain/chains';
import fs from 'fs';
/**
* Answer questions about your resume using LangChain
*/
const resumeData = fs.readFileSync('resume.json', 'utf8');
const model = new ChatOpenAI({
modelName: 'gpt-4',
temperature: 0,
});
const template = `
You are a helpful assistant answering questions about this resume:
{resume}
Question: {question}
Answer concisely and professionally:
`;
const prompt = PromptTemplate.fromTemplate(template);
const chain = new LLMChain({
llm: model,
prompt: prompt,
});
async function askAboutResume(question) {
const result = await chain.call({
resume: resumeData,
question: question,
});
return result.text;
}
// Examples
console.log(await askAboutResume('What are my top 3 skills?'));
console.log(
await askAboutResume('How many years of experience do I have in Python?')
);
console.log(await askAboutResume('What was my most recent position?'));Vercel Deployment
Next.js App Router
// app/page.tsx
import { getResume } from '@/lib/resume';
export default async function Home() {
const resume = await getResume();
return (
<div className="max-w-4xl mx-auto py-12 px-4">
<header className="mb-8">
<h1 className="text-4xl font-bold">{resume.basics.name}</h1>
<p className="text-xl text-gray-600">{resume.basics.label}</p>
<p className="mt-4">{resume.basics.summary}</p>
</header>
<section>
<h2 className="text-2xl font-bold mb-4">Experience</h2>
{resume.work.map((job, i) => (
<div key={i} className="mb-6">
<h3 className="text-xl font-semibold">{job.position}</h3>
<p className="text-gray-600">
{job.name} • {job.startDate} - {job.endDate || 'Present'}
</p>
<p className="mt-2">{job.summary}</p>
</div>
))}
</section>
</div>
);
}// lib/resume.ts
import fs from 'fs/promises';
import path from 'path';
import type { ResumeSchema } from '@jsonresume/types';
export async function getResume(): Promise<ResumeSchema> {
// Option 1: Load from local file
const filePath = path.join(process.cwd(), 'resume.json');
const data = await fs.readFile(filePath, 'utf8');
return JSON.parse(data);
// Option 2: Fetch from GitHub Gist
// const response = await fetch('https://gist.githubusercontent.com/username/gist-id/raw/resume.json');
// return response.json();
// Option 3: Fetch from jsonresume.org
// const response = await fetch('https://jsonresume.org/username.json');
// return response.json();
}# Deploy to Vercel
vercel
# Or connect GitHub repo for auto-deploy
# Every push to main triggers a new deploymentVercel Serverless Functions
// api/resume.ts
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { resumeToHTML, resumeToPDF } from 'resume-cli';
/**
* Serverless function to generate resume in different formats
* GET /api/resume?format=html|pdf|json&theme=professional
*/
export default async function handler(req: VercelRequest, res: VercelResponse) {
const { format = 'json', theme = 'professional' } = req.query;
try {
// Fetch resume data
const response = await fetch(`${process.env.NEXT_PUBLIC_URL}/resume.json`);
const resume = await response.json();
if (format === 'json') {
return res.json(resume);
}
if (format === 'html') {
const html = await resumeToHTML(resume, theme as string);
res.setHeader('Content-Type', 'text/html');
return res.send(html);
}
if (format === 'pdf') {
const pdf = await resumeToPDF(resume, theme as string);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename=resume.pdf');
return res.send(pdf);
}
return res.status(400).json({ error: 'Invalid format' });
} catch (error) {
return res.status(500).json({ error: error.message });
}
}vercel.json Configuration
{
"buildCommand": "npm run build",
"outputDirectory": ".next",
"framework": "nextjs",
"rewrites": [
{
"source": "/resume.pdf",
"destination": "/api/resume?format=pdf"
},
{
"source": "/resume.html",
"destination": "/api/resume?format=html"
}
],
"headers": [
{
"source": "/api/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "s-maxage=3600, stale-while-revalidate"
}
]
}
],
"env": {
"NEXT_PUBLIC_URL": "@next-public-url"
}
}Netlify Deployment
Static Site Generation
# netlify.toml
[build]
command = "npm run build"
publish = "dist"
[build.environment]
NODE_VERSION = "20"
[[redirects]]
from = "/resume.pdf"
to = "/.netlify/functions/generate-pdf"
status = 200
[[redirects]]
from = "/resume/:theme"
to = "/.netlify/functions/render-theme"
status = 200
query = {theme = ":theme"}
[[headers]]
for = "/resume.json"
[headers.values]
Cache-Control = "public, max-age=3600"
Content-Type = "application/json"Netlify Functions
// netlify/functions/generate-pdf.js
const { resumeToPDF } = require('resume-cli');
const fs = require('fs');
exports.handler = async (event, context) => {
try {
const resume = JSON.parse(fs.readFileSync('./resume.json', 'utf8'));
const theme = event.queryStringParameters?.theme || 'professional';
const pdf = await resumeToPDF(resume, theme);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename=resume.pdf',
},
body: pdf.toString('base64'),
isBase64Encoded: true,
};
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({ error: error.message }),
};
}
};// netlify/functions/render-theme.js
const { resumeToHTML } = require('resume-cli');
const fs = require('fs');
exports.handler = async (event, context) => {
try {
const resume = JSON.parse(fs.readFileSync('./resume.json', 'utf8'));
const theme = event.queryStringParameters?.theme || 'professional';
const html = await resumeToHTML(resume, theme);
return {
statusCode: 200,
headers: {
'Content-Type': 'text/html',
},
body: html,
};
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({ error: error.message }),
};
}
};Netlify CLI Deploy
# Install Netlify CLI
npm install -g netlify-cli
# Login
netlify login
# Initialize site
netlify init
# Build and deploy
netlify build
netlify deploy --prod
# Continuous deployment (link to GitHub)
netlify link
# Netlify will auto-deploy on every push to mainBest Practices
Security Considerations
- Never commit API keys - Use environment variables
- Validate all resume data before processing
- Sanitize HTML output to prevent XSS
- Use HTTPS for all API calls
- Rate limit public endpoints
- Authenticate webhook endpoints
// Example: Secure webhook endpoint
import crypto from 'crypto';
function verifyWebhookSignature(payload, signature, secret) {
const hmac = crypto.createHmac('sha256', secret);
const digest = hmac.update(payload).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest));
}
app.post('/webhook', (req, res) => {
const signature = req.headers['x-webhook-signature'];
if (
!verifyWebhookSignature(req.body, signature, process.env.WEBHOOK_SECRET)
) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process webhook
});Performance Optimization
- Cache resume data (CDN, Redis, local storage)
- Lazy load large sections
- Optimize images (compress, WebP, responsive)
- Minify JSON and HTML outputs
- Use serverless for PDF generation (scales automatically)
// Example: Redis caching
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function getCachedResume(username) {
const cached = await redis.get(`resume:${username}`);
if (cached) return JSON.parse(cached);
const response = await fetch(`https://jsonresume.org/${username}.json`);
const resume = await response.json();
await redis.setex(`resume:${username}`, 3600, JSON.stringify(resume));
return resume;
}Error Handling
// Example: Robust error handling
async function fetchResume(username) {
try {
const response = await fetch(`https://jsonresume.org/${username}.json`, {
timeout: 5000,
});
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Resume not found for user: ${username}`);
}
throw new Error(`Failed to fetch resume: ${response.statusText}`);
}
const resume = await response.json();
// Validate schema
const validation = validateResume(resume);
if (!validation.valid) {
throw new Error(`Invalid resume schema: ${validation.errors.join(', ')}`);
}
return resume;
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('Request timeout - please try again');
}
console.error('Failed to fetch resume:', error);
throw error;
}
}Resources
- JSON Resume Schema
- Official CLI Documentation
- Theme Gallery
- API Reference
- GitHub Actions Documentation
- Vercel Documentation
- Netlify Documentation