heartwood every commit a ring
3.7 KB raw
const fs = require("fs");
const path = require("path");
const { chromium } = require("playwright");
const { marked } = require("marked");
const matter = require("gray-matter");

const RESUME_DIR = __dirname;
const CONTENT_PATH = path.join(RESUME_DIR, "content.md");
const TEMPLATE_PATH = path.join(RESUME_DIR, "template.html");
const OUTPUT_PATH = path.join(
  RESUME_DIR,
  "..",
  "public",
  "static",
  "pdfs",
  "resume-isaac-bythewood.pdf"
);

function renderMarkdownBody(markdown) {
  const renderer = new marked.Renderer();

  renderer.heading = function ({ tokens, depth }) {
    const text = this.parser.parseInline(tokens);
    if (depth === 2) {
      const note = text.includes("Experience") || text.includes("Projects")
        ? ' <span class="section-note">— See LinkedIn for more</span>'
        : "";
      return `<h2>${text}${note}</h2>`;
    }
    if (depth === 3) {
      return `<h3>${text}</h3>`;
    }
    return `<h${depth}>${text}</h${depth}>`;
  };

  renderer.paragraph = function ({ tokens }) {
    const text = this.parser.parseInline(tokens);
    if (
      text.match(
        /^[A-Z].*\u2014\s*(JANUARY|FEBRUARY|MARCH|APRIL|MAY|JUNE|JULY|AUGUST|SEPTEMBER|OCTOBER|NOVEMBER|DECEMBER)/i
      )
    ) {
      return `<div class="meta">${text}</div>`;
    }
    return `<p>${text}</p>`;
  };

  marked.setOptions({ renderer });

  // Parse markdown, then wrap each h3 + meta + ul group in an entry div
  let html = marked.parse(markdown);
  html = html.replace(
    /(<h3>[\s\S]*?)(?=<h3>|<h2>|$)/g,
    '<div class="entry">$1</div>'
  );
  return html;
}

function buildHtml(frontmatter, body) {
  let template = fs.readFileSync(TEMPLATE_PATH, "utf-8");

  const markdownHtml = renderMarkdownBody(body);

  const linksHtml = frontmatter.links
    .map((l) => `<a href="${l.url}">${l.label}</a>`)
    .join("\n");

  const skillsHtml = frontmatter.skills
    .map((s) => `<li>${s}</li>`)
    .join("\n");

  const techHtml = frontmatter.technologies
    .map((t) => `<li>${t}</li>`)
    .join("\n");

  const eduHtml = frontmatter.education
    .map(
      (e) =>
        `<div class="education-entry"><span class="school">${e.institution},</span> ${e.years}<br>${e.degree}</div>`
    )
    .join("\n");

  template = template
    .replace(/\{\{name\}\}/g, frontmatter.name)
    .replace(/\{\{title\}\}/g, frontmatter.title)
    .replace(/\{\{summary\}\}/g, frontmatter.summary)
    .replace(/\{\{location\}\}/g, frontmatter.location)
    .replace(/\{\{phone\}\}/g, frontmatter.phone)
    .replace(/\{\{email\}\}/g, frontmatter.email)
    .replace(/\{\{links\}\}/g, linksHtml)
    .replace(/\{\{skills\}\}/g, skillsHtml)
    .replace(/\{\{technologies\}\}/g, techHtml)
    .replace(/\{\{education\}\}/g, eduHtml)
    .replace(/\{\{body\}\}/g, markdownHtml);

  return template;
}

async function generatePdf() {
  const raw = fs.readFileSync(CONTENT_PATH, "utf-8");
  const { data: frontmatter, content: body } = matter(raw);
  const html = buildHtml(frontmatter, body);

  const launchOptions = {};

  // Use system Chromium if PLAYWRIGHT_CHROMIUM_PATH is set (e.g. in Alpine Docker)
  if (process.env.PLAYWRIGHT_CHROMIUM_PATH) {
    launchOptions.executablePath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
  }

  const browser = await chromium.launch(launchOptions);
  const page = await browser.newPage({
    viewport: { width: 816, height: 1056 },
  });
  await page.setContent(html, { waitUntil: "networkidle" });

  await page.pdf({
    path: OUTPUT_PATH,
    format: "Letter",
    printBackground: true,
    margin: { top: 0, right: 0, bottom: 0, left: 0 },
  });

  await browser.close();
  console.log(`Resume PDF generated: ${OUTPUT_PATH}`);
}

generatePdf().catch((err) => {
  console.error("Failed to generate resume:", err);
  process.exit(1);
});