Make the plain page (public/resume.html) a generated declared mirror so it and the terminal share one source, and enrich that source to the superset both need. - resume.org: merged to the superset — richer achievement-oriented bullets, per-item subtitle/location/tech tags, all projects (adds Terrapin Rocket, Robotics@UMD) and the full course list. - org-to-json parser: optional subtitle/location fields + `- tech :: X` tagged bullets; `list` flag so education stays bullet-free; empty tag arrays dropped. - scripts/build-resume-html.mjs + resume.template.html: the template owns the design (unchanged look); content is rendered from resume.json. Adds a global search box that filters every card and course pill across sections, with a no-results empty state. HTML-escaped; arXiv ids linkified. - resume:build now generates both mirrors; resume:check + CI canary verify both. Verified: yarn build + tsc clean; both canaries green; driven headless — design preserved, 17 cards render, search isolates by keyword/tech/course, no errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nt6ycjAMdUMqGTGtbKocrspull/59/head
parent
a8ec735c77
commit
2914f8dc02
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,248 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// =============================================================================
|
||||||
|
// Module contract (praxis: Contract-First Modules · Tangle Discipline)
|
||||||
|
//
|
||||||
|
// provides — render the plain résumé page (public/resume.html) from the
|
||||||
|
// resume.json mirror + resume.template.html. One concern: json ->
|
||||||
|
// styled HTML. The template owns the design (hand-maintained); the
|
||||||
|
// content is generated so the plain page and terminal share one
|
||||||
|
// source (Single Source of Truth).
|
||||||
|
// requires — resume.json (generated from resume.org), resume.template.html.
|
||||||
|
// tests must show —
|
||||||
|
// * every section renders one card per item with its bullets/tech/meta;
|
||||||
|
// * courses render as filterable pills; each card/pill has data-search;
|
||||||
|
// * output is byte-stable so the CI canary (regen + diff) only trips on
|
||||||
|
// real drift; text is HTML-escaped; arXiv ids linkify.
|
||||||
|
//
|
||||||
|
// Flow is ONE-WAY: resume.json -> public/resume.html. Never hand-edit the
|
||||||
|
// generated page; edit resume.org (content) or resume.template.html (design).
|
||||||
|
// Usage: node scripts/build-resume-html.mjs [--check]
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const SRC = 'resume.json';
|
||||||
|
const TEMPLATE = 'resume.template.html';
|
||||||
|
const OUT = 'public/resume.html';
|
||||||
|
|
||||||
|
const esc = (s = '') =>
|
||||||
|
String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
|
||||||
|
// arXiv ids become links; run after escaping (ids are digits/dots, escape-safe).
|
||||||
|
const linkifyArxiv = (s) =>
|
||||||
|
s.replace(
|
||||||
|
/arXiv:(\d{4}\.\d{4,5})/gi,
|
||||||
|
(_m, id) => `<a href="https://arxiv.org/abs/${id}">arXiv:${id}</a>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchKey = (...parts) =>
|
||||||
|
esc(
|
||||||
|
parts.flat().filter(Boolean).join(' ').toLowerCase().replace(/\s+/g, ' '),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tagList = (tech) =>
|
||||||
|
tech && tech.length
|
||||||
|
? `\n <ul class="tag-list">${tech
|
||||||
|
.map((t) => `<li class="tag">${esc(t)}</li>`)
|
||||||
|
.join('')}</ul>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const bulletList = (bullets) =>
|
||||||
|
bullets && bullets.length
|
||||||
|
? `\n <ul class="bullet-list">${bullets
|
||||||
|
.map((x) => `<li>${linkifyArxiv(esc(x))}</li>`)
|
||||||
|
.join('')}</ul>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const metaBlock = (lines) => {
|
||||||
|
const kept = lines.filter(Boolean);
|
||||||
|
if (!kept.length) return '';
|
||||||
|
return `\n <div class="card-meta">${kept
|
||||||
|
.map((l) => `<span class="meta-line">${esc(l)}</span>`)
|
||||||
|
.join('')}</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = ({ title, subtitle, meta = [], tech, bullets, search }) => `
|
||||||
|
<article class="card" data-search="${search}">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">${esc(title)}</h3>${
|
||||||
|
subtitle ? `\n <p class="card-subtitle">${esc(subtitle)}</p>` : ''
|
||||||
|
}
|
||||||
|
</div>${metaBlock(meta)}
|
||||||
|
</div>${tagList(tech)}${bulletList(bullets)}
|
||||||
|
</article>`;
|
||||||
|
|
||||||
|
const section = (id, heading, inner) =>
|
||||||
|
` <section id="${id}">\n <h2 class="section-title">${esc(
|
||||||
|
heading,
|
||||||
|
)}</h2>\n${inner}\n </section>`;
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
const {
|
||||||
|
profile,
|
||||||
|
experience,
|
||||||
|
projects,
|
||||||
|
organizations,
|
||||||
|
publications,
|
||||||
|
education,
|
||||||
|
} = data;
|
||||||
|
const edu = education[0] || {};
|
||||||
|
|
||||||
|
const eduCard = card({
|
||||||
|
title: edu.school,
|
||||||
|
subtitle: edu.subtitle,
|
||||||
|
meta: [`Expected Graduation · ${edu.graduation}`, `GPA · ${edu.gpa}`],
|
||||||
|
bullets: null,
|
||||||
|
search: searchKey(edu.school, edu.subtitle, edu.degrees, edu.honors),
|
||||||
|
});
|
||||||
|
const eduScholar =
|
||||||
|
edu.honors && edu.honors.length
|
||||||
|
? `\n <div class="scholarships">\n <p class="scholarships-title">Scholarships & Distinctions</p>\n <ul class="bullet-list">${edu.honors
|
||||||
|
.map((h) => `<li>${esc(h)}</li>`)
|
||||||
|
.join('')}</ul>\n </div>`
|
||||||
|
: '';
|
||||||
|
// inject scholarships before the closing </article> of the education card
|
||||||
|
const eduHtml = eduCard.replace(
|
||||||
|
'\n </article>',
|
||||||
|
`${eduScholar}\n </article>`,
|
||||||
|
);
|
||||||
|
const educationSection = section(
|
||||||
|
'education',
|
||||||
|
'Education',
|
||||||
|
` <div class="education-grid">${eduHtml}\n </div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const experienceSection = section(
|
||||||
|
'work',
|
||||||
|
'Work Experience',
|
||||||
|
experience
|
||||||
|
.map((e) =>
|
||||||
|
card({
|
||||||
|
title: `${e.role} · ${e.company}`,
|
||||||
|
subtitle: e.subtitle,
|
||||||
|
meta: [e.dates, e.location],
|
||||||
|
tech: e.tech,
|
||||||
|
bullets: e.bullets,
|
||||||
|
search: searchKey(
|
||||||
|
e.role,
|
||||||
|
e.company,
|
||||||
|
e.subtitle,
|
||||||
|
e.location,
|
||||||
|
e.tech,
|
||||||
|
e.bullets,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectsSection = section(
|
||||||
|
'projects',
|
||||||
|
'Projects',
|
||||||
|
projects
|
||||||
|
.map((p) =>
|
||||||
|
card({
|
||||||
|
title: p.name,
|
||||||
|
subtitle: p.subtitle,
|
||||||
|
meta: [p.timeline],
|
||||||
|
tech: p.tech,
|
||||||
|
bullets: p.bullets,
|
||||||
|
search: searchKey(p.name, p.subtitle, p.tech, p.bullets),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const orgsSection = section(
|
||||||
|
'organizations',
|
||||||
|
'Organizations',
|
||||||
|
organizations
|
||||||
|
.map((o) =>
|
||||||
|
card({
|
||||||
|
title: `${o.role} · ${o.org}`,
|
||||||
|
meta: [o.dates],
|
||||||
|
bullets: o.bullets,
|
||||||
|
search: searchKey(o.role, o.org, o.bullets),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pubsSection = section(
|
||||||
|
'publications',
|
||||||
|
'Publications',
|
||||||
|
publications
|
||||||
|
.map((p) =>
|
||||||
|
card({
|
||||||
|
title: p.title,
|
||||||
|
subtitle: p.status,
|
||||||
|
bullets: p.bullets,
|
||||||
|
search: searchKey(p.title, p.status, p.bullets),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pills = (edu.coursework || [])
|
||||||
|
.map(
|
||||||
|
(c) =>
|
||||||
|
` <span class="course-pill" data-search="${searchKey(
|
||||||
|
c,
|
||||||
|
)}">${esc(c)}</span>`,
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
const coursesSection = section(
|
||||||
|
'courses',
|
||||||
|
'Relevant Courses',
|
||||||
|
` <div class="courses-card">\n <div class="courses-grid" id="courses-grid">\n${pills}\n </div>\n </div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
educationSection,
|
||||||
|
experienceSection,
|
||||||
|
projectsSection,
|
||||||
|
orgsSection,
|
||||||
|
pubsSection,
|
||||||
|
coursesSection,
|
||||||
|
].join('\n\n');
|
||||||
|
|
||||||
|
const headerSub = `Dual Degree · ${(edu.degrees || []).join(' & ')} · GPA ${
|
||||||
|
edu.gpa
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return readFileSync(TEMPLATE, 'utf8')
|
||||||
|
.replace('{{NAME}}', esc(profile.name))
|
||||||
|
.replace('{{HEADER_SUB}}', esc(headerSub))
|
||||||
|
.replace('{{SECTIONS}}', sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const check = process.argv.includes('--check');
|
||||||
|
const html = render(JSON.parse(readFileSync(SRC, 'utf8')));
|
||||||
|
|
||||||
|
if (check) {
|
||||||
|
let current = '';
|
||||||
|
try {
|
||||||
|
current = readFileSync(OUT, 'utf8');
|
||||||
|
} catch {
|
||||||
|
/* missing counts as drift */
|
||||||
|
}
|
||||||
|
if (current !== html) {
|
||||||
|
process.stderr.write(
|
||||||
|
`DRIFT: ${OUT} is out of sync. Run: node scripts/build-resume-html.mjs\n`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.stdout.write(`ok: ${OUT} matches ${SRC}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeFileSync(OUT, html);
|
||||||
|
process.stdout.write(`wrote ${OUT} from ${SRC}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Loading…
Reference in new issue