resume/scripts/export.js
ilia 234a8866b1 Multi-resume profiles, ai-bw template, and README refresh
Resume data is loaded from resume/<slug>.yml via RESUME_NAME (default dobkin), with per-slug profile photos and webpack alias wiring. Export and preview honor the slug; package scripts add convenience dev/export targets. Add ai-bw layout and preview asset, cherepaha profile, and experience legend metadata on green and purple. When birth year is omitted, cool and material-dark themes show "Based in" instead of "Born" so birth.location can mean current location. README documents the new workflow and fixes export wording.

Made-with: Cursor
2026-04-18 15:00:51 -04:00

256 lines
8.5 KiB
JavaScript
Executable File

const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
function resolveChromeExecutable () {
const fromEnv = (process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH || '').trim();
if (fromEnv) {
try {
if (fs.existsSync(fromEnv)) return fromEnv;
} catch (_e) {
/* ignore */
}
console.warn(
'[export] Ignoring PUPPETEER_EXECUTABLE_PATH / CHROME_PATH (not an existing file): ' + fromEnv
);
}
const candidates = [];
if (process.platform === 'darwin') {
candidates.push(
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
'/Applications/Arc.app/Contents/MacOS/Arc',
'/Applications/Vivaldi.app/Contents/MacOS/Vivaldi',
'/Applications/Chromium.app/Contents/MacOS/Chromium'
);
} else if (process.platform === 'linux') {
candidates.push(
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
'/usr/bin/chromium-browser',
'/usr/bin/chromium',
'/usr/bin/microsoft-edge-stable',
'/snap/bin/chromium'
);
} else if (process.platform === 'win32') {
const programFiles = process.env['PROGRAMFILES'] || 'C:\\Program Files';
const programFilesX86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
candidates.push(
path.join(programFiles, 'Google/Chrome/Application/chrome.exe'),
path.join(programFilesX86, 'Google/Chrome/Application/chrome.exe'),
path.join(programFiles, 'Microsoft/Edge/Application/msedge.exe')
);
}
for (const p of candidates) {
try {
if (p && fs.existsSync(p)) return p;
} catch (_e) {
/* ignore */
}
}
return '';
}
const http = require('http');
const config = require('../config');
const { getResumeSlug } = require('./resumeSlug');
const devPort = process.env.PORT || config.dev.port;
const {
interval
} = require('rxjs');
const {
filter,
first,
mergeMap
} = require('rxjs/operators');
const fetchResponse = () => {
return new Promise((res, rej) => {
try {
const req = http.request(`http://localhost:${devPort}/#/`, response => res(response.statusCode));
req.on('error', (err) => rej(err));
req.end();
} catch (err) {
rej(err);
}
});
};
const waitForServerReachable = () => {
return interval(1000).pipe(
mergeMap(async () => {
try {
const statusCode = await fetchResponse();
if (statusCode === 200) return true;
} catch (err) {}
return false;
}),
filter(ok => !!ok)
);
};
const baseLaunchOpts = () => ({
headless: true,
args: ['--no-sandbox', '--font-render-hinting=none']
});
/**
* PDF export needs a headless Chromium engine to run the Vue app and call page.pdf().
* Try several launch strategies so a typical Mac (Edge-only, Arc-only, etc.) still works.
*/
async function launchPuppeteerBrowser () {
const base = baseLaunchOpts();
const attempts = [];
const resolved = resolveChromeExecutable();
if (resolved) {
attempts.push({ ...base, executablePath: resolved });
}
const preferredChannel = (process.env.PUPPETEER_CHANNEL || '').trim();
const channelOrder = preferredChannel
? [preferredChannel]
: ['msedge', 'chrome', 'chrome-beta', 'chrome-canary'];
for (const ch of channelOrder) {
attempts.push({ ...base, channel: ch });
}
let bundled = '';
try {
if (typeof puppeteer.executablePath === 'function') {
bundled = puppeteer.executablePath();
}
} catch (_e) {
bundled = '';
}
if (bundled && fs.existsSync(bundled) && bundled !== resolved) {
attempts.push({ ...base, executablePath: bundled });
}
attempts.push({ ...base });
const errors = [];
for (const opts of attempts) {
try {
const browser = await puppeteer.launch(opts);
const how = opts.executablePath
? ('executablePath: ' + opts.executablePath)
: (opts.channel ? ('channel: ' + opts.channel) : 'puppeteer default');
console.log('[export] Using browser — ' + how);
return browser;
} catch (e) {
errors.push(e && e.message ? e.message : String(e));
}
}
console.error('[export] All browser launch attempts failed:\n - ' + errors.join('\n - '));
throw new Error(
'No Chromium-based browser available for PDF export. Install Google Chrome or Microsoft Edge, ' +
'or run: npx puppeteer browsers install chrome'
);
}
/*
const timedOut = timeout => {
return new Promise(res => {
setTimeout(res, timeout);
});
};
*/
const convert = async () => {
await waitForServerReachable().pipe(
first()
).toPromise();
console.log('Connected to server ...');
console.log('Exporting ...');
try {
const fullDirectoryPath = path.join(__dirname, '../pdf/');
const dataSlug = getResumeSlug();
let directories = getResumesFromDirectories();
const resumeFilterRaw = (process.env.EXPORT_RESUME || process.argv[2] || '').trim();
const resumeFilter = resumeFilterRaw.replace(/\.vue$/i, '').toLowerCase();
if (resumeFilter) {
directories = directories.filter(d => d.name.toLowerCase() === resumeFilter);
if (directories.length === 0) {
console.error(
`No resume template "${resumeFilterRaw}". Expected a name like "green" (see src/resumes/*.vue).`
);
process.exit(1);
}
console.log('Resume filter: ' + directories.map(d => d.name).join(', '));
}
console.log('Resume data (RESUME_NAME): ' + dataSlug);
if (!fs.existsSync(fullDirectoryPath)) {
fs.mkdirSync(fullDirectoryPath);
}
const browser = await launchPuppeteerBrowser();
try {
for (const dir of directories) {
const page = await browser.newPage();
try {
await page.goto(`http://localhost:${devPort}/#/resume/` + dir.name, {
waitUntil: 'load',
timeout: 120000
});
try {
await page.evaluate(() => document.fonts.ready);
} catch (_err) {
/* ignore if fonts API missing */
}
await page.emulateMediaType('print');
await new Promise((r) => setTimeout(r, 300));
let pdfScale = 1;
const rawScale = process.env.PDF_SCALE;
if (rawScale !== undefined && String(rawScale).trim() !== '') {
const n = parseFloat(String(rawScale), 10);
if (Number.isFinite(n) && n >= 0.1 && n <= 2) {
pdfScale = n;
}
}
await page.pdf({
path: fullDirectoryPath + dir.name + '-' + dataSlug + '.pdf',
format: 'A4',
printBackground: true,
margin: { top: '0', bottom: '0', left: '0', right: '0' },
scale: pdfScale
});
} finally {
await page.close();
}
}
} finally {
await browser.close();
}
} catch (err) {
throw err instanceof Error ? err : new Error(String(err));
}
console.log('Finished exports.');
};
const getResumesFromDirectories = () => {
const directories = getDirectories();
return directories
.map(dir => {
const fileName = dir.replace('.vue', '');
return {
path: fileName,
name: fileName
};
});
};
const getDirectories = () => {
const srcpath = path.join(__dirname, '../src/resumes');
return fs.readdirSync(srcpath)
.filter(file => file !== 'resumes.js' && file !== 'template.vue' && file !== 'options.js');
};
convert();