You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

244 lines
7.9 KiB

#!/usr/bin/env node
// =============================================================================
// Module contract (praxis: Contract-First Modules · The Docstring Is the Contract)
//
// provides — parse the canonical résumé (resume.org, RESUME_SCHEMA: 1) into a
// deterministic JSON mirror (resume.json). One concern: org -> json.
// requires — Node >=18 (readFileSync, writeFileSync, JSON). No dependencies:
// the schema is small and self-owned, so a maintained org parser
// (orga / uniorg) would be more liability than a ~120-line reader.
// tests must show —
// * every `**` item heading with an :ID: becomes one object carrying its id;
// * drawer keys map to lowercased fields; tagged bullets (`- k :: v`) route
// into named arrays; plain bullets become `bullets`;
// * skills categories and the profile drawer + bio prose are captured;
// * output is byte-stable (sorted-nothing, fixed key order, 2-space, \n) so
// the CI canary (regenerate + git diff) only trips on real drift.
//
// Flow is ONE-WAY: resume.org -> resume.json. Never hand-edit resume.json.
// Usage: node scripts/org-to-json.mjs [resume.org] [resume.json]
// With --check it regenerates in memory and exits non-zero if the file on disk
// differs (the drift canary), without writing.
// =============================================================================
import { readFileSync, writeFileSync } from 'node:fs';
const GENERATED_NOTE =
'GENERATED FILE — do not edit. Source of truth: resume.org. ' +
'Regenerate: node scripts/org-to-json.mjs';
// Section registry — the fixed schema the parser understands.
// `fields` are optional scalar drawer props (included only when present);
// `tagged` routes `- k :: v` bullets into named arrays; `list: false` means
// the item has no free-form bullets array (education is all tagged).
const SECTIONS = {
profile: { kind: 'profile' },
experience: {
kind: 'items',
fields: ['role', 'company', 'dates', 'subtitle', 'location'],
tagged: { tech: 'tech' },
list: true,
},
projects: {
kind: 'items',
fields: ['name', 'timeline', 'subtitle'],
tagged: { tech: 'tech' },
list: true,
},
education: {
kind: 'items',
fields: ['school', 'gpa', 'graduation', 'subtitle'],
tagged: { degree: 'degrees', honor: 'honors', course: 'coursework' },
list: false,
},
organizations: {
kind: 'items',
fields: ['role', 'org', 'dates'],
list: true,
},
publications: { kind: 'items', fields: ['title', 'status'], list: true },
certifications: {
kind: 'items',
fields: ['name', 'issuer', 'date'],
list: false,
},
skills: { kind: 'skills' },
};
const slug = (s) => s.trim().toLowerCase();
const isHeading = (l) => /^\*+\s/.test(l);
const headingLevel = (l) => (l.match(/^(\*+)\s/) || [, ''])[1].length;
const headingText = (l) => l.replace(/^\*+\s+/, '').trim();
// Read a :PROPERTIES: ... :END: drawer starting at `start`; return {props, next}.
function readDrawer(lines, start) {
const props = {};
let i = start + 1;
for (; i < lines.length; i++) {
const t = lines[i].trim();
if (t === ':END:') return { props, next: i + 1 };
const m = t.match(/^:([A-Za-z0-9_]+):\s*(.*)$/);
if (m) props[m[1].toLowerCase()] = m[2].trim();
}
throw new Error(`Unterminated :PROPERTIES: drawer at line ${start + 1}`);
}
// Build one item object from its heading, drawer props, and bullet lines.
function makeItem(cfg, props, bullets) {
const item = {};
if (props.id) item.id = props.id;
for (const f of cfg.fields) if (props[f] != null) item[f] = props[f];
const tagged = cfg.tagged || {};
for (const key of Object.values(tagged)) item[key] = [];
const plain = [];
for (const raw of bullets) {
const m = raw.match(/^([A-Za-z0-9_]+)\s*::\s*(.*)$/);
if (m && tagged[m[1].toLowerCase()]) {
item[tagged[m[1].toLowerCase()]].push(m[2].trim());
} else {
plain.push(raw);
}
}
// Drop empty tagged arrays so optional groups (e.g. tech) don't add noise.
for (const key of Object.values(tagged))
if (item[key].length === 0) delete item[key];
if (cfg.list) item.bullets = plain;
return item;
}
function parse(text) {
const lines = text.split(/\r?\n/);
const out = {
profile: {},
experience: [],
projects: [],
education: [],
organizations: [],
publications: [],
certifications: [],
skills: {},
};
const bioLines = [];
let section = null; // {key, cfg}
let itemProps = null; // drawer of the open item
let itemBullets = null; // bullets of the open item
let skillCat = null; // {key, list}
const flushItem = () => {
if (section && section.cfg.kind === 'items' && itemProps !== null) {
out[section.key].push(makeItem(section.cfg, itemProps, itemBullets));
}
itemProps = null;
itemBullets = null;
};
const flushSkill = () => {
if (skillCat) out.skills[skillCat.key] = skillCat.list;
skillCat = null;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const t = line.trim();
if (t.startsWith('#') || t === '') continue; // comments, keywords, blanks
if (t === ':PROPERTIES:') {
const { props, next } = readDrawer(lines, i);
i = next - 1;
if (section && section.cfg.kind === 'items' && itemProps !== null) {
Object.assign(itemProps, props);
} else if (section && section.key === 'profile') {
Object.assign(out.profile, props);
}
// file-level drawer (no section yet) is metadata — ignored.
continue;
}
if (isHeading(line)) {
const level = headingLevel(line);
const label = headingText(line);
if (level === 1) {
flushItem();
flushSkill();
const key = slug(label);
section = SECTIONS[key] ? { key, cfg: SECTIONS[key] } : null;
if (!section)
process.stderr.write(`warn: unknown section "${label}"\n`);
} else if (level === 2 && section) {
if (section.cfg.kind === 'items') {
flushItem();
itemProps = {};
itemBullets = [];
} else if (section.cfg.kind === 'skills') {
flushSkill();
skillCat = { key: slug(label), list: [] };
}
}
continue;
}
if (t.startsWith('- ')) {
const val = t.slice(2).trim();
if (section && section.cfg.kind === 'items' && itemBullets)
itemBullets.push(val);
else if (section && section.cfg.kind === 'skills' && skillCat)
skillCat.list.push(val);
continue;
}
// free prose — only meaningful inside Profile (the bio).
if (section && section.key === 'profile') bioLines.push(t);
}
flushItem();
flushSkill();
if (bioLines.length) out.profile.bio = bioLines.join(' ');
return out;
}
// Stable serialization: fixed top-level key order, 2-space indent, trailing \n.
function serialize(data) {
const ordered = {
$generated: GENERATED_NOTE,
profile: data.profile,
experience: data.experience,
projects: data.projects,
education: data.education,
organizations: data.organizations,
publications: data.publications,
certifications: data.certifications,
skills: data.skills,
};
return JSON.stringify(ordered, null, 2) + '\n';
}
function main() {
const args = process.argv.slice(2).filter((a) => a !== '--check');
const check = process.argv.includes('--check');
const src = args[0] || 'resume.org';
const dst = args[1] || 'resume.json';
const json = serialize(parse(readFileSync(src, 'utf8')));
if (check) {
let current = '';
try {
current = readFileSync(dst, 'utf8');
} catch {
/* missing file counts as drift */
}
if (current !== json) {
process.stderr.write(
`DRIFT: ${dst} is out of sync with ${src}. Run: node scripts/org-to-json.mjs\n`,
);
process.exit(1);
}
process.stdout.write(`ok: ${dst} matches ${src}\n`);
return;
}
writeFileSync(dst, json);
process.stdout.write(`wrote ${dst} from ${src}\n`);
}
main();