Animation-heavy personal portfolio on Next.js: a custom cursor, a page-transition loader, and canvas backgrounds.
canvas-animationscss-modulesdark-themehandcodednextjspersonal-websiteportfolioreact
1const fs = require("fs");
2const path = require("path");
3const { chromium } = require("playwright");
4const { marked } = require("marked");
5const matter = require("gray-matter");
6
7const RESUME_DIR = __dirname;
8const CONTENT_PATH = path.join(RESUME_DIR, "content.md");
9const TEMPLATE_PATH = path.join(RESUME_DIR, "template.html");
10const OUTPUT_PATH = path.join(
11 RESUME_DIR,
12 "..",
13 "public",
14 "static",
15 "pdfs",
16 "resume-isaac-bythewood.pdf"
17);
18
19function renderMarkdownBody(markdown) {
20 const renderer = new marked.Renderer();
21
22 renderer.heading = function ({ tokens, depth }) {
23 const text = this.parser.parseInline(tokens);
24 if (depth === 2) {
25 const note = text.includes("Experience") || text.includes("Projects")
26 ? ' <span class="section-note">— See LinkedIn for more</span>'
27 : "";
28 return `<h2>${text}${note}</h2>`;
29 }
30 if (depth === 3) {
31 return `<h3>${text}</h3>`;
32 }
33 return `<h${depth}>${text}</h${depth}>`;
34 };
35
36 renderer.paragraph = function ({ tokens }) {
37 const text = this.parser.parseInline(tokens);
38 if (
39 text.match(
40 /^[A-Z].*\u2014\s*(JANUARY|FEBRUARY|MARCH|APRIL|MAY|JUNE|JULY|AUGUST|SEPTEMBER|OCTOBER|NOVEMBER|DECEMBER)/i
41 )
42 ) {
43 return `<div class="meta">${text}</div>`;
44 }
45 return `<p>${text}</p>`;
46 };
47
48 marked.setOptions({ renderer });
49
50 // Parse markdown, then wrap each h3 + meta + ul group in an entry div
51 let html = marked.parse(markdown);
52 html = html.replace(
53 /(<h3>[\s\S]*?)(?=<h3>|<h2>|$)/g,
54 '<div class="entry">$1</div>'
55 );
56 return html;
57}
58
59function buildHtml(frontmatter, body) {
60 let template = fs.readFileSync(TEMPLATE_PATH, "utf-8");
61
62 const markdownHtml = renderMarkdownBody(body);
63
64 const linksHtml = frontmatter.links
65 .map((l) => `<a href="${l.url}">${l.label}</a>`)
66 .join("\n");
67
68 const skillsHtml = frontmatter.skills
69 .map((s) => `<li>${s}</li>`)
70 .join("\n");
71
72 const techHtml = frontmatter.technologies
73 .map((t) => `<li>${t}</li>`)
74 .join("\n");
75
76 const eduHtml = frontmatter.education
77 .map(
78 (e) =>
79 `<div class="education-entry"><span class="school">${e.institution},</span> ${e.years}<br>${e.degree}</div>`
80 )
81 .join("\n");
82
83 template = template
84 .replace(/\{\{name\}\}/g, frontmatter.name)
85 .replace(/\{\{title\}\}/g, frontmatter.title)
86 .replace(/\{\{summary\}\}/g, frontmatter.summary)
87 .replace(/\{\{location\}\}/g, frontmatter.location)
88 .replace(/\{\{phone\}\}/g, frontmatter.phone)
89 .replace(/\{\{email\}\}/g, frontmatter.email)
90 .replace(/\{\{links\}\}/g, linksHtml)
91 .replace(/\{\{skills\}\}/g, skillsHtml)
92 .replace(/\{\{technologies\}\}/g, techHtml)
93 .replace(/\{\{education\}\}/g, eduHtml)
94 .replace(/\{\{body\}\}/g, markdownHtml);
95
96 return template;
97}
98
99async function generatePdf() {
100 const raw = fs.readFileSync(CONTENT_PATH, "utf-8");
101 const { data: frontmatter, content: body } = matter(raw);
102 const html = buildHtml(frontmatter, body);
103
104 const launchOptions = {};
105
106 // Use system Chromium if PLAYWRIGHT_CHROMIUM_PATH is set (e.g. in Alpine Docker)
107 if (process.env.PLAYWRIGHT_CHROMIUM_PATH) {
108 launchOptions.executablePath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
109 }
110
111 const browser = await chromium.launch(launchOptions);
112 const page = await browser.newPage({
113 viewport: { width: 816, height: 1056 },
114 });
115 await page.setContent(html, { waitUntil: "networkidle" });
116
117 await page.pdf({
118 path: OUTPUT_PATH,
119 format: "Letter",
120 printBackground: true,
121 margin: { top: 0, right: 0, bottom: 0, left: 0 },
122 });
123
124 await browser.close();
125 console.log(`Resume PDF generated: ${OUTPUT_PATH}`);
126}
127
128generatePdf().catch((err) => {
129 console.error("Failed to generate resume:", err);
130 process.exit(1);
131});