mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Phase 5: every page of the site, the resume PDF and RSS
Landing, About, Now, Resume (with a generated PDF), Work and case studies, Inquiries and the sent page, Library and the Shelf, Workshop and craft pages, Tabletop, Blog with topics and posts, RSS, the Store, product lines, the order form and the 404, all rendered from the content service's notes. Schema: experience previousRoles, skills stack core items, page stats, make tags and deck favorite. Icon names from notes map to brand icons in one place, and HTML comments are dropped when Markdown renders. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* The printable resume. Built from the same ResumeData as the /resume page,
|
||||
* laid out as one plain column so applicant tracking systems read it in order.
|
||||
* Paper colors: ink on white, with the brand gold and teal darkened for print.
|
||||
*/
|
||||
import PDFDocument from "pdfkit";
|
||||
import cinzelBold from "../assets/pdf-fonts/Cinzel-Bold.ttf?inline";
|
||||
import cinzelSemi from "../assets/pdf-fonts/Cinzel-SemiBold.ttf?inline";
|
||||
import montReg from "../assets/pdf-fonts/Montserrat-Regular.ttf?inline";
|
||||
import montMed from "../assets/pdf-fonts/Montserrat-Medium.ttf?inline";
|
||||
import montSemi from "../assets/pdf-fonts/Montserrat-SemiBold.ttf?inline";
|
||||
import { month, period, type ResumeData } from "./resume.ts";
|
||||
|
||||
const fromDataUrl = (u: string) => Buffer.from(u.slice(u.indexOf(",") + 1), "base64");
|
||||
const FONTS = {
|
||||
title: fromDataUrl(cinzelBold),
|
||||
heading: fromDataUrl(cinzelSemi),
|
||||
body: fromDataUrl(montReg),
|
||||
medium: fromDataUrl(montMed),
|
||||
strong: fromDataUrl(montSemi),
|
||||
};
|
||||
|
||||
const INK = "#131c2b";
|
||||
const MUTED = "#566173";
|
||||
const GOLD = "#a8842f";
|
||||
const TEAL = "#0f7f78";
|
||||
const RULE = "#d9cfb8";
|
||||
|
||||
const PAGE = { size: "LETTER" as const, margin: 50 };
|
||||
const SITE = "gregkilmer.com";
|
||||
|
||||
export function renderResumePdf(r: ResumeData): Promise<Buffer> {
|
||||
const doc = new PDFDocument({
|
||||
size: PAGE.size,
|
||||
margins: { top: PAGE.margin, bottom: PAGE.margin + 14, left: PAGE.margin, right: PAGE.margin },
|
||||
bufferPages: true,
|
||||
info: { Title: `${r.name} – Resume`, Author: r.name, Subject: r.headline, Keywords: "resume" },
|
||||
lang: "en-US",
|
||||
displayTitle: true,
|
||||
});
|
||||
for (const [name, data] of Object.entries(FONTS)) doc.registerFont(name, data);
|
||||
|
||||
const left = doc.page.margins.left;
|
||||
const width = doc.page.width - left - doc.page.margins.right;
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on("data", (c: Buffer) => chunks.push(c));
|
||||
const done = new Promise<Buffer>((resolve, reject) => {
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
doc.on("error", reject);
|
||||
});
|
||||
|
||||
/** Starts a new page when fewer than `need` points remain. */
|
||||
const room = (need: number) => {
|
||||
if (doc.y + need > doc.page.height - doc.page.margins.bottom) doc.addPage();
|
||||
};
|
||||
|
||||
const diamond = (x: number, y: number, s: number, color = GOLD) => {
|
||||
doc.save().moveTo(x, y - s).lineTo(x + s, y).lineTo(x, y + s).lineTo(x - s, y).closePath().fill(color).restore();
|
||||
};
|
||||
|
||||
/** A thin gold rule with a small star at its center. */
|
||||
const starRule = (y: number) => {
|
||||
const mid = left + width / 2;
|
||||
doc.save().lineWidth(0.6).strokeColor(GOLD);
|
||||
doc.moveTo(left, y).lineTo(mid - 10, y).stroke();
|
||||
doc.moveTo(mid + 10, y).lineTo(left + width, y).stroke();
|
||||
doc.restore();
|
||||
diamond(mid, y, 4);
|
||||
};
|
||||
|
||||
const section = (title: string) => {
|
||||
room(60);
|
||||
doc.moveDown(0.75);
|
||||
const y = doc.y;
|
||||
doc.font("heading").fontSize(11).fillColor(GOLD).text(title.toUpperCase(), left, y, { characterSpacing: 1.6 });
|
||||
const ruleY = doc.y + 2;
|
||||
doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(left, ruleY).lineTo(left + width, ruleY).stroke().restore();
|
||||
doc.y = ruleY + 8;
|
||||
};
|
||||
|
||||
const bullet = (text: string, indent = 12) => {
|
||||
doc.font("body").fontSize(9.5);
|
||||
const h = doc.heightOfString(text, { width: width - indent, lineGap: 1.5 });
|
||||
room(h + 4);
|
||||
const y = doc.y;
|
||||
diamond(left + 3.5, y + 6, 2.2);
|
||||
doc.fillColor(INK).text(text, left + indent, y, { width: width - indent, lineGap: 1.5 });
|
||||
doc.moveDown(0.15);
|
||||
};
|
||||
|
||||
// Header
|
||||
doc.font("title").fontSize(28).fillColor(INK).text(r.name.toUpperCase(), left, doc.y, { characterSpacing: 2 });
|
||||
if (r.headline) doc.font("medium").fontSize(11).fillColor(TEAL).text(r.headline, { width });
|
||||
// Only the professional profiles belong on paper; the hobby links stay on the site.
|
||||
const profiles = r.socials.filter((s) => /github|gitea|linkedin/i.test(`${s.label} ${s.url}`));
|
||||
const contact = [r.location, SITE, ...profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, ""))].filter(Boolean).join(" · ");
|
||||
doc.moveDown(0.3).font("body").fontSize(9).fillColor(MUTED).text(contact, { width });
|
||||
if (r.availability) doc.font("medium").fontSize(9).fillColor(INK).text(r.availability, { width });
|
||||
doc.moveDown(0.7);
|
||||
starRule(doc.y);
|
||||
doc.moveDown(0.6);
|
||||
|
||||
if (r.summary) {
|
||||
doc.font("body").fontSize(10).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), left, doc.y, { width, lineGap: 2 });
|
||||
}
|
||||
|
||||
if (r.stats.length) {
|
||||
section("Highlights");
|
||||
const cols = Math.min(r.stats.length, 4);
|
||||
const colW = width / cols;
|
||||
let top = doc.y;
|
||||
r.stats.forEach((st, i) => {
|
||||
if (i > 0 && i % cols === 0) top = doc.y + 6;
|
||||
const x = left + (i % cols) * colW;
|
||||
doc.font("title").fontSize(16).fillColor(GOLD).text(st.value, x, top, { width: colW - 10 });
|
||||
doc.font("strong").fontSize(9).fillColor(INK).text(st.label, x, doc.y, { width: colW - 10 });
|
||||
if (st.detail) doc.font("body").fontSize(8.5).fillColor(MUTED).text(st.detail, x, doc.y, { width: colW - 10 });
|
||||
});
|
||||
doc.x = left;
|
||||
}
|
||||
|
||||
if (r.roles.length) {
|
||||
section("Experience");
|
||||
r.roles.forEach((role, i) => {
|
||||
const d = role.data;
|
||||
if (i > 0) doc.moveDown(0.6);
|
||||
room(64);
|
||||
const y = doc.y;
|
||||
const when = period(role);
|
||||
doc.font("medium").fontSize(9).fillColor(GOLD);
|
||||
const whenW = doc.widthOfString(when);
|
||||
doc.text(when, left + width - whenW, y + 1.5, { lineBreak: false });
|
||||
doc.font("strong").fontSize(11).fillColor(INK).text(d.role, left, y, { width: width - whenW - 16 });
|
||||
const at = [d.company, d.location].filter(Boolean).join(" · ");
|
||||
doc.font("medium").fontSize(9.5).fillColor(TEAL).text(at, left, doc.y, { width });
|
||||
if (d.previousRoles?.length) {
|
||||
doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${d.previousRoles.join(", ")} at ${d.company}`, { width });
|
||||
}
|
||||
if (d.focus) doc.moveDown(0.2).font("body").fontSize(9).fillColor(MUTED).text(d.focus, { width, lineGap: 1 });
|
||||
doc.moveDown(0.3);
|
||||
for (const b of d.bullets ?? []) bullet(b);
|
||||
if (d.tech?.length) {
|
||||
doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.tech.join(" · "), left + 12, doc.y, { width: width - 12 });
|
||||
}
|
||||
doc.x = left;
|
||||
});
|
||||
}
|
||||
|
||||
if (r.skills?.stack.length) {
|
||||
section("Skills");
|
||||
for (const g of r.skills.stack) {
|
||||
const items = g.items.join(", ");
|
||||
doc.font("body").fontSize(9.5);
|
||||
room(doc.heightOfString(items, { width: width - 110 }) + 4);
|
||||
const y = doc.y;
|
||||
doc.font("strong").fontSize(9.5).fillColor(INK).text(g.label, left, y, { width: 100 });
|
||||
const afterLabel = doc.y;
|
||||
doc.font("body").fontSize(9.5).fillColor(INK).text(items, left + 110, y, { width: width - 110, lineGap: 1 });
|
||||
doc.y = Math.max(doc.y, afterLabel) + 3;
|
||||
}
|
||||
if (r.skills.core?.length) {
|
||||
room(30);
|
||||
const y = doc.y;
|
||||
doc.font("strong").fontSize(9.5).fillColor(INK).text("Strengths", left, y, { width: 100 });
|
||||
doc.font("body").fontSize(9.5).fillColor(INK).text(r.skills.core.join(", "), left + 110, y, { width: width - 110, lineGap: 1 });
|
||||
}
|
||||
doc.x = left;
|
||||
}
|
||||
|
||||
if (r.education?.degrees.length) {
|
||||
section("Education");
|
||||
for (const dg of r.education.degrees) {
|
||||
room(40);
|
||||
const y = doc.y;
|
||||
doc.font("medium").fontSize(9).fillColor(GOLD);
|
||||
const w = doc.widthOfString(dg.period);
|
||||
doc.text(dg.period, left + width - w, y + 1.5, { lineBreak: false });
|
||||
doc.font("strong").fontSize(10.5).fillColor(INK).text(dg.credential, left, y, { width: width - w - 16 });
|
||||
doc.font("medium").fontSize(9.5).fillColor(TEAL).text(dg.institution, left, doc.y, { width });
|
||||
if (dg.detail) doc.font("body").fontSize(9).fillColor(MUTED).text(dg.detail, { width });
|
||||
doc.moveDown(0.4);
|
||||
}
|
||||
}
|
||||
|
||||
if (r.education?.certifications?.length) {
|
||||
section("Certifications");
|
||||
for (const c of r.education.certifications) {
|
||||
room(20);
|
||||
const line = [c.issuer, c.year].filter(Boolean).join(" · ");
|
||||
doc.font("strong").fontSize(9.5).fillColor(INK).text(c.name, left, doc.y, { continued: !!line });
|
||||
if (line) doc.font("body").fillColor(MUTED).text(` ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer on every page.
|
||||
const range = doc.bufferedPageRange();
|
||||
const updated = r.updated ? `Updated ${month(r.updated.slice(0, 7))}` : "";
|
||||
for (let i = range.start; i < range.start + range.count; i++) {
|
||||
doc.switchToPage(i);
|
||||
const y = doc.page.height - PAGE.margin + 4;
|
||||
const bottom = doc.page.margins.bottom;
|
||||
doc.page.margins.bottom = 0;
|
||||
doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(left, y - 8).lineTo(left + width, y - 8).stroke().restore();
|
||||
doc.font("body").fontSize(8).fillColor(MUTED);
|
||||
doc.text([`${SITE}/resume`, updated].filter(Boolean).join(" · "), left, y, { lineBreak: false });
|
||||
if (range.count > 1) {
|
||||
const label = `${i - range.start + 1} of ${range.count}`;
|
||||
doc.text(label, left + width - doc.widthOfString(label), y, { lineBreak: false });
|
||||
}
|
||||
doc.page.margins.bottom = bottom;
|
||||
}
|
||||
|
||||
doc.end();
|
||||
return done;
|
||||
}
|
||||
Reference in New Issue
Block a user