Files
santifer--career-ops/cv-templates.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

192 lines
6.2 KiB
JavaScript

#!/usr/bin/env node
// cv-templates.mjs — discover, resolve, and validate CV / cover-letter templates.
// Single source of truth for "which template file, and is it usable?".
// Backward-compatible: with no config and no named files, resolves the base
// templates/cv-template.html (name "standard"), identical to prior behavior.
import { readdirSync, readFileSync, existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import yaml from 'js-yaml';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_TEMPLATES_DIR = resolve(__dirname, 'templates');
const DEFAULT_PROFILE_PATH =
process.env.CAREER_OPS_PROFILE || resolve(__dirname, 'config', 'profile.yml');
export const KINDS = {
cv: {
prefix: 'cv-template',
profileKey: ['cv', 'template'],
required: ['NAME', 'EXPERIENCE', 'EDUCATION'],
},
cover: {
prefix: 'cover-letter-template',
profileKey: ['cover_letter', 'template'],
required: ['NAME', 'ROLE_TITLE', 'OPENING'],
},
};
export function prettify(name) {
return name
.split('-')
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
export function kebab(display) {
return String(display)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// The only template formats the resolver recognizes. `format` reaches path
// construction (fileFor) unmodified, so it must be allowlisted or a value like
// `--format=../../etc/passwd` would traverse out of the templates dir.
const VALID_FORMATS = new Set(['html', 'tex']);
function assertFormat(format) {
if (!VALID_FORMATS.has(format)) {
throw new Error(`Unsupported template format: ${format} (expected html or tex)`);
}
}
// filename → {name, format} | null. Base "cv-template.html" → name "standard";
// "cv-template.<name>.html" → that name. Only html/tex are recognized.
function parseFilename(prefix, file) {
const m = file.match(new RegExp(`^${prefix}(?:\\.([a-z0-9-]+))?\\.(html|tex)$`));
if (!m) return null;
return { name: m[1] || 'standard', format: m[2] };
}
export function parseMeta(path) {
let text;
try {
text = readFileSync(path, 'utf-8');
} catch {
return {};
}
const block = text.match(/<!--\s*career-ops-template\s*([\s\S]*?)-->/);
if (!block) return {};
const meta = {};
for (const line of block[1].split(/\r?\n/)) {
const kv = line.match(/^\s*([a-zA-Z_]+)\s*:\s*(.+?)\s*$/);
if (kv) meta[kv[1].toLowerCase()] = kv[2];
}
return meta;
}
export function listTemplates(kind, { dir = DEFAULT_TEMPLATES_DIR, format = 'html' } = {}) {
const cfg = KINDS[kind];
if (!cfg) throw new Error(`Unknown template kind: ${kind}`);
assertFormat(format);
if (!existsSync(dir)) return [];
const out = [];
for (const file of readdirSync(dir)) {
const parsed = parseFilename(cfg.prefix, file);
if (!parsed || parsed.format !== format) continue;
const path = resolve(dir, file);
const meta = parseMeta(path);
out.push({
name: parsed.name,
displayName: meta.name || prettify(parsed.name),
path,
format: parsed.format,
meta,
});
}
return out.sort((a, b) => a.name.localeCompare(b.name));
}
export function validateTemplate(path, kind) {
const cfg = KINDS[kind];
if (!cfg) throw new Error(`Unknown template kind: ${kind}`);
const text = readFileSync(path, 'utf-8');
const missing = cfg.required.filter((ph) => !text.includes(`{{${ph}}}`));
return { ok: missing.length === 0, missing };
}
export function loadProfileDefault(kind, { profilePath = DEFAULT_PROFILE_PATH } = {}) {
const cfg = KINDS[kind];
if (!cfg) throw new Error(`Unknown template kind: ${kind}`);
if (!existsSync(profilePath)) return null;
let doc;
try {
doc = yaml.load(readFileSync(profilePath, 'utf-8')) || {};
} catch {
return null;
}
let node = doc;
for (const key of cfg.profileKey) node = node?.[key];
return typeof node === 'string' && node.trim() ? node.trim() : null;
}
export function resolveTemplate(kind, name, opts = {}) {
const cfg = KINDS[kind];
if (!cfg) throw new Error(`Unknown template kind: ${kind}`);
const {
dir = DEFAULT_TEMPLATES_DIR,
format = 'html',
profilePath = DEFAULT_PROFILE_PATH,
fallback = false,
} = opts;
assertFormat(format);
const explicit = Boolean(name && String(name).trim());
let chosen = kebab(explicit ? name : loadProfileDefault(kind, { profilePath }) || 'standard');
const fileFor = (n) => (n === 'standard' ? `${cfg.prefix}.${format}` : `${cfg.prefix}.${n}.${format}`);
let path = resolve(dir, fileFor(chosen));
if (!existsSync(path)) {
if (fallback && chosen !== 'standard') {
chosen = 'standard';
path = resolve(dir, fileFor(chosen));
}
if (!existsSync(path)) {
throw new Error(`Template not found for kind=${kind} name=${chosen} (${fileFor(chosen)})`);
}
}
if (format === 'html') {
const v = validateTemplate(path, kind);
if (!v.ok) {
throw new Error(
`Template ${fileFor(chosen)} missing required placeholders: ${v.missing.map((m) => `{{${m}}}`).join(', ')}`
);
}
}
return path;
}
// ---- CLI ----
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
const argv = process.argv.slice(2);
const cmd = argv[0];
const kind = argv[1];
const flags = Object.fromEntries(
argv.filter((a) => a.startsWith('--')).map((a) => {
const [k, v] = a.replace(/^--/, '').split('=');
return [k, v ?? true];
})
);
const positionals = argv.slice(2).filter((a) => !a.startsWith('--'));
const format = flags.format || 'html';
try {
if (cmd === 'list') {
const items = listTemplates(kind, { format }).map(({ name, displayName }) => ({ name, displayName }));
process.stdout.write(JSON.stringify(items, null, 2) + '\n');
} else if (cmd === 'resolve') {
const name = positionals[0];
process.stdout.write(resolveTemplate(kind, name, { format, fallback: Boolean(flags.fallback) }) + '\n');
} else {
process.stderr.write('Usage: node cv-templates.mjs <list|resolve> <cv|cover> [name] [--format=html|tex] [--fallback]\n');
process.exit(2);
}
} catch (err) {
process.stderr.write(`${err.message}\n`);
process.exit(1);
}
}