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
256 lines
8.5 KiB
JavaScript
Executable File
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();
|