Files
santifer--career-ops/build-cv-latex.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

310 lines
11 KiB
JavaScript

#!/usr/bin/env node
import { readFile, writeFile, stat } from 'fs/promises';
import { existsSync } from 'fs';
import { resolve, dirname, basename, join } from 'path';
import { fileURLToPath } from 'url';
import { tmpdir } from 'os';
import { escapeLatex, sanitizeUrl } from './lib/latex-escape.mjs';
import { resolveTemplate } from './cv-templates.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const TEMPLATE_PATH = resolve(__dirname, 'templates', 'cv-template.tex');
const PLACEHOLDER_RE = /\{\{[A-Z_]+\}\}/g;
function buildEducation(entries) {
if (!Array.isArray(entries) || entries.length === 0) return '';
const blocks = [];
for (const e of entries) {
if (!e) continue;
let block = ` \\resumeSubheading\n {${escapeLatex(e.institution)}}{${escapeLatex(e.location)}}\n {${escapeLatex(e.degree)}}{${escapeLatex(e.dates)}}`;
if (Array.isArray(e.coursework) && e.coursework.length > 0) {
const courses = e.coursework.map(c => escapeLatex(c)).join(', ');
block += `\n \\resumeItemListStart\n \\resumeItem{\\textbf{Coursework:} ${courses}}\n \\resumeItemListEnd`;
}
blocks.push(block);
}
return blocks.join('\n\n');
}
function buildExperience(entries) {
if (!Array.isArray(entries) || entries.length === 0) return '';
const blocks = [];
for (const e of entries) {
if (!e) continue;
const bullets = Array.isArray(e.bullets) ? e.bullets.map(b => ` \\resumeItem{${escapeLatex(b)}}`).join('\n') : '';
blocks.push(` \\resumeSubheading\n {${escapeLatex(e.company)}}{${escapeLatex(e.dates)}}\n {${escapeLatex(e.role)}}{${escapeLatex(e.location)}}\n \\resumeItemListStart\n${bullets}\n \\resumeItemListEnd`);
}
return blocks.join('\n\n');
}
function buildProjects(entries) {
if (!Array.isArray(entries) || entries.length === 0) return '';
const blocks = [];
for (const e of entries) {
if (!e) continue;
const context = e.context ? ` \\emph{$|$ ${escapeLatex(e.context)}}` : '';
const bullets = Array.isArray(e.bullets) ? e.bullets.map(b => ` \\resumeItem{${escapeLatex(b)}}`).join('\n') : '';
blocks.push(` \\resumeProjectHeading\n {\\textbf{${escapeLatex(e.name)}}${context}}{${escapeLatex(e.dates)}}\n \\resumeItemListStart\n${bullets}\n \\resumeItemListEnd`);
}
return blocks.join('\n\n');
}
function buildSkills(categories) {
if (!Array.isArray(categories) || categories.length === 0) return '';
return categories.map(c => {
if (!c) return '';
const items = Array.isArray(c.items) ? c.items.join(', ') : (c.items || '');
return ` \\textbf{${escapeLatex(c.category)}}{: ${escapeLatex(items)}} \\\\`;
}).filter(Boolean).join('\n');
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help')) {
console.error('Usage:');
console.error(' node build-cv-latex.mjs <input.json> <output.tex>');
console.error(' node build-cv-latex.mjs --test');
process.exit(1);
}
if (args.includes('--test')) {
await runSelfTest();
return;
}
const [inputPath, outputPath] = args;
if (!inputPath || !outputPath) {
console.error('Usage: node build-cv-latex.mjs <input.json> <output.tex>');
process.exit(1);
}
const absInput = resolve(inputPath);
const absOutput = resolve(outputPath);
const outDir = dirname(absOutput);
if (!existsSync(absInput)) {
console.error(`Input file not found: ${absInput}`);
process.exit(1);
}
let payload;
try {
const raw = await readFile(absInput, 'utf-8');
payload = JSON.parse(raw);
} catch (err) {
console.error(`Failed to parse input JSON: ${err.message}`);
process.exit(1);
}
// Honor a selected .tex template variant (cv.template default or --template=<name>),
// falling back to the base cv-template.tex when no variant exists.
const texName = (process.argv.find((a) => a.startsWith('--template=')) || '').split('=')[1];
let TEMPLATE_PATH_RESOLVED;
try {
TEMPLATE_PATH_RESOLVED = resolveTemplate('cv', texName, { format: 'tex', fallback: true });
} catch {
TEMPLATE_PATH_RESOLVED = TEMPLATE_PATH;
}
if (!existsSync(TEMPLATE_PATH_RESOLVED)) {
console.error(`Template not found: ${TEMPLATE_PATH_RESOLVED}`);
process.exit(1);
}
let template = await readFile(TEMPLATE_PATH_RESOLVED, 'utf-8');
const emailUrl = sanitizeUrl(payload.email?.url || '');
const emailDisplay = payload.email?.display || emailUrl;
const linkedinUrl = sanitizeUrl(payload.linkedin?.url || '');
const linkedinDisplay = payload.linkedin?.display || '';
const githubUrl = sanitizeUrl(payload.github?.url || '');
const githubDisplay = payload.github?.display || '';
const substitutions = {
NAME: escapeLatex(payload.name || ''),
CONTACT_LINE: escapeLatex(payload.contact_line || ''),
EMAIL_URL: emailUrl,
EMAIL_DISPLAY: escapeLatex(emailDisplay),
LINKEDIN_URL: linkedinUrl,
LINKEDIN_DISPLAY: escapeLatex(linkedinDisplay),
GITHUB_URL: githubUrl,
GITHUB_DISPLAY: escapeLatex(githubDisplay),
EDUCATION: buildEducation(payload.education),
EXPERIENCE: buildExperience(payload.experience),
PROJECTS: buildProjects(payload.projects),
SKILLS: buildSkills(payload.skills),
};
for (const [key, value] of Object.entries(substitutions)) {
template = template.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
}
const unresolved = template.match(PLACEHOLDER_RE);
if (unresolved) {
console.error(`Unresolved placeholders: ${[...new Set(unresolved)].join(', ')}`);
process.exit(1);
}
if (!existsSync(outDir)) {
const { mkdirSync } = await import('fs');
mkdirSync(outDir, { recursive: true });
}
await writeFile(absOutput, template, 'utf-8');
const fileInfo = await stat(absOutput);
const sizeKB = (fileInfo.size / 1024).toFixed(1);
const report = {
file: basename(absOutput),
path: absOutput,
sizeKB: parseFloat(sizeKB),
counts: {
educationEntries: (payload.education || []).length,
experienceEntries: (payload.experience || []).length,
projectEntries: (payload.projects || []).length,
skillCategories: (payload.skills || []).length,
totalBullets: (() => {
const ex = Array.isArray(payload.experience) ? payload.experience.flatMap(e => Array.isArray(e?.bullets) ? e.bullets : []) : [];
const pr = Array.isArray(payload.projects) ? payload.projects.flatMap(p => Array.isArray(p?.bullets) ? p.bullets : []) : [];
return ex.length + pr.length;
})(),
},
valid: true,
};
console.log(JSON.stringify(report, null, 2));
process.exit(0);
}
async function runSelfTest() {
const sample = {
name: 'Test Candidate',
contact_line: 'City, State | +1 234 567 8900',
email: { url: 'test@example.com', display: 'test@example.com' },
linkedin: { url: 'https://linkedin.com/in/test', display: 'linkedin.com/in/test' },
github: { url: 'https://github.com/test', display: 'github.com/test' },
education: [{
institution: 'Test University',
location: 'City, State',
degree: 'Bachelor of Science in Testing',
dates: '2020 - 2024',
coursework: ['Data Structures', 'Algorithms', 'Machine Learning'],
}],
experience: [{
company: 'Test Corp',
role: 'Test Engineer',
location: 'Remote',
dates: 'June 2024 - Present',
bullets: [
'Built automated testing pipelines with CI/CD integration',
'Reduced regression test time by 60% through parallel execution',
],
}],
projects: [{
name: 'Test Project',
context: 'Python, FastAPI, Docker',
dates: '2024',
bullets: [
'Built a REST API with automated test coverage exceeding 90%',
],
}],
skills: [
{ category: 'Languages', items: 'Python, JavaScript, TypeScript' },
{ category: 'Frameworks', items: 'FastAPI, React, PyTorch' },
],
};
const testOutput = join(tmpdir(), 'build-cv-latex-test.tex');
const raw = JSON.stringify(sample, null, 2);
const tmpInput = join(tmpdir(), 'build-cv-latex-test-input.json');
await writeFile(tmpInput, raw, 'utf-8');
const absInput = resolve(tmpInput);
const absOutput = resolve(testOutput);
if (!existsSync(TEMPLATE_PATH)) {
console.error(`Self-test failed: template not found at ${TEMPLATE_PATH}`);
process.exit(1);
}
let template = await readFile(TEMPLATE_PATH, 'utf-8');
const emailUrl = sanitizeUrl(sample.email?.url || '');
const emailDisplay = sample.email?.display || emailUrl;
const linkedinUrl = sanitizeUrl(sample.linkedin?.url || '');
const linkedinDisplay = sample.linkedin?.display || '';
const githubUrl = sanitizeUrl(sample.github?.url || '');
const githubDisplay = sample.github?.display || '';
const substitutions = {
NAME: escapeLatex(sample.name),
CONTACT_LINE: escapeLatex(sample.contact_line),
EMAIL_URL: emailUrl,
EMAIL_DISPLAY: escapeLatex(emailDisplay),
LINKEDIN_URL: linkedinUrl,
LINKEDIN_DISPLAY: escapeLatex(linkedinDisplay),
GITHUB_URL: githubUrl,
GITHUB_DISPLAY: escapeLatex(githubDisplay),
EDUCATION: buildEducation(sample.education),
EXPERIENCE: buildExperience(sample.experience),
PROJECTS: buildProjects(sample.projects),
SKILLS: buildSkills(sample.skills),
};
for (const [key, value] of Object.entries(substitutions)) {
template = template.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
}
const unresolved = template.match(PLACEHOLDER_RE);
if (unresolved) {
console.error(`Self-test failed: unresolved placeholders: ${[...new Set(unresolved)].join(', ')}`);
process.exit(1);
}
const outDir = dirname(absOutput);
if (!existsSync(outDir)) {
const { mkdirSync } = await import('fs');
mkdirSync(outDir, { recursive: true });
}
await writeFile(absOutput, template, 'utf-8');
const fileInfo = await stat(absOutput);
const sizeKB = (fileInfo.size / 1024).toFixed(1);
const report = {
status: 'self-test-passed',
file: basename(absOutput),
path: absOutput,
sizeKB: parseFloat(sizeKB),
counts: {
educationEntries: sample.education.length,
experienceEntries: sample.experience.length,
projectEntries: sample.projects.length,
skillCategories: sample.skills.length,
totalBullets: (() => {
const ex = Array.isArray(sample.experience) ? sample.experience.flatMap(e => Array.isArray(e?.bullets) ? e.bullets : []) : [];
const pr = Array.isArray(sample.projects) ? sample.projects.flatMap(p => Array.isArray(p?.bullets) ? p.bullets : []) : [];
return ex.length + pr.length;
})(),
},
};
console.log(JSON.stringify(report, null, 2));
await import('fs/promises').then(fs =>
Promise.all([
fs.rm(tmpInput).catch(() => {}),
fs.rm(testOutput).catch(() => {}),
])
);
process.exit(0);
}
main();