https://ktema.org/articles/the-overengineered-resume/
David Reed About Articles Talks Projects
The Overengineered Resume with Zola, JSON Resume, Weasyprint, and Nix
Author
David Reed
Published
2023-11-09
Words
3578
Share
Links
Repo
Maintaining a resume is not the most interesting use of time.
Naturally, when I needed to bring my own up to date, I decided to
spend a great deal more time on it and overengineer the process.
I wanted a bunch of things that didn't necessarily fit together that
well:
* A split between content and presentation, so that I could
maintain my resume data separately from how it's rendered and
swap out different rendered formats.
* Version control. I wanted everything in Git, in text-based
formats that I can diff or process with scripts.
* Multiple output formats, at least in theory.
+ PDF rendering, since most career sites accept PDF uploads.
+ The option to publish my resume as a web page in the future.
* More visual flair and typesetting control than basic Google
Docs-style resume templates. (I know applicant-tracking systems
don't see visual flair, but it makes me happy).
* Straightforward text embedding in PDFs to ensure those ATSs do
see what they need.
* Not to use LaTeX. It's been years since I used LaTeX in anger,
and I'd prefer to stick with web technologies I know well.
Here's where I ended up. (And here's the actual output).
Building a Data-Driven Resume
I'm only aware of one standard for representing resume data: JSON
Resume. I always prefer to use standards where I can, although I'm
disappointed there isn't a stronger ecosystem around this one. (And
all the more that the major HR applications don't ingest it).
Because I wanted to avoid LaTeX, I decided to try templating my
resume data into either Markdown or HTML and CSS and then rendering
that content as a PDF. That also gave me the optionality I was
looking for on output formats; if I wanted a resume web page, I could
just publish the HTML with a different stylesheet.
I already use the static site generator Zola to build this website.
Zola's Tera template engine is very similar to Jinja2, making it
comfortable for me, and it's very fast.
The HTML-to-PDF tool space doesn't have a huge number of players in
it and most of the players seem to be eccentric in some respect. I've
played with this kind of rendering in the past and had some success
with Weasyprint, so I brought it back for this effort.
Here's how these components come together into a data-to-PDF
pipeline:
graph LR
zola{{Zola}}
json_resume(JSON Resume Data) --> zola
zola_template(Tera Template) --> zola
css(CSS)
zola --> html(HTML)
pdf(PDF)
weasyprint{{Weasyprint}}
css --> weasyprint
html --> weasyprint
weasyprint --> pdf
Zola
JSON Resume Data
Tera Template
CSS
HTML
PDF
Weasyprint
Resume data is defined in a resume.yaml file, using the JSON Resume
schema. I find it much more pleasant to author YAML than JSON, and
Zola supports both formats just fine. Here's the opening of my resume
in YAML:
# yaml-language-server: $schema=https://raw.githubusercontent.com/jsonresume/resume-schema/master/schema.json
basics:
name: David Reed
label: Technical architect, engineer, communicator
email: david@ktema.org
url: https://ktema.org
summary: |
I am a product-minded full-stack engineer and technical architect. I lead exceptional teams in building and scaling SaaS applications that shorten time-to-value for customers and maximize productivity for internal stakeholders. I've designed, shipped, and stewarded platforms that span CLI to cloud.
I'm passionate about delivering products that empower every role to do their most impactful work, from engineers to business users. I believe in async, distributed work and thrive in cross-functional teams. I strive to center compassion in everything I build.
The comment at the top instructs the YAML language server to use the
JSON Resume schema to validate the data, which means I get hints in
my editor where I've specified something invalid.
On the Zola side, I need a template. The template defines the
structure of my resume - how the data is converted into a readable,
formatted, attractive presentation. Here's the opening of the
template I developed (note that it uses the Jinja2-like Tera template
language):
{% set resume = load_data(path="content/resume.yaml") %}
{{ resume.basics.name }}
{{ resume.basics.name }} [?] {{ resume.basics.label }}
{% if resume.basics.email %}
- email
-
{{ resume.basics.email }}
{% endif %}
{% if resume.basics.url %}
- web
{{ resume.basics.url }}
{% endif %}
{% if resume.basics.profiles %}
{% for network in resume.basics.profiles %}
- {{ network.network | lower }}
{{ network.username }}
{% endfor %}
{% endif %}
{{ resume.basics.summary | markdown | safe }}
See the full template on GitHub.
Taking my cues from Simple.css, which I use for this site, I've
prioritized using standard semantic HTML tags and using CSS to style
them.
Note the critical Tera template tag at the head of the template:
{% set resume = load_data(path=page.extra.resume_data) %}
This loads my resume data from resume.yaml into the variable resume,
which the rest of my template then consumes to dynamically render my
resume into HTML.
I've also decided to treat most of the resume content, as specified
in YAML, as Markdown (| markdown | safe, in Tera). That means I can
style my highlights for each position, which I apply to call out
metrics and achievements in color.
I use only portions of the JSON Resume schema, and a couple of them I
use in a way that's a bit questionable. (The story of standardized
schemas, isn't it!) I've used the awards key in a fairly loose,
unstructured way. I've also misused the skill.keywords key: when the
word "break" is present as a keyword, the template starts a new
sub-list of skills. (It doesn't otherwise use keywords for anything).
The last element stitching all of this together is a Markdown content
file. Zola needs a content file in order to render the template into
a page. In this case, the content file's empty save for metadata in
its front matter, which defines the mapping between the data file and
the template.
title="Resume"
template="resume.html"
[extra]
resume_data="resume.yaml"
Because the template accepts the resume_data path as a parameter, I
could in fact render multiple resumes by creating multiple .md files
with different front matter.
At this point, I can render my resume. Once I have zola and
weasyprint installed via my package manager of choice (for more on
which see below), I do
$ zola build
$ weasyprint public/resume/index.html Resume-David-Reed.pdf
Voila - a PDF resume, beautifully rendered and ready for upload into
an applicant-tracking system that could not care less about how
snazzy it is.
That's nowhere near enough overengineering. Let's automate the whole
shebang. (Although you can stop here and still have a nice
data-driven resume, if automation is not your cup of tea).
Local Developer Experience
I already alluded to tooling setup, which is one of the key aspects
of the developer experience I want. I don't want to worry about
tools, activating virtual environments, launching a container, or any
other fiddling.
I also don't ever want to run commands manually to synchronize some
artifact A (here, a PDF) with some other artifact B (here, my data
file and template). It should be magical. Magic is what software
engineering is all about! Plus, a live preview function make the
authoring experience so much better.
I've been exploring NixOS lately, so rather than building a
Dockerfile, I set up my local environment using nix-shell and direnv
following these instructions. I created a shell.nix specifying my
dependencies:
{ pkgs ? import {} }:
pkgs.mkShell {
nativeBuildInputs = with pkgs.buildPackages; [
zola just python311Packages.weasyprint inotify-tools yq
];
}
and a .envrc containing
use_nix
Then, after I direnv allow, every time I cd into my resume project,
weasyprint and zola are magically available for me to use. (See the
link above for full setup details).
I don't like memorizing commands, either, so I threw in a justfile
with some useful abstractions:
filename := "resume.yaml"
build:
zola build
pdf: build
weasyprint public/resume/index.html \
Resume-$(cat resume.yaml | yq -r '.basics.name | split(" ") | join("-")').pdf
render: build pdf
xdg-open Resume-$(cat resume.yaml | yq -r '.basics.name | split(" ") | join("-")').pdf &disown
Here I use yq to dynamically generate the filename of my output PDF
from the resume data, which mostly just means I don't hard-code my
own name.
Now a just pdf creates my resume PDF, and a just render builds and
opens the PDF in my preferred viewer. With a little more shell magic,
I can add live previews:
watch:
#!/usr/bin/env sh
inotifywait -m -r . \
--exclude "(.*\\.pdf$)|public|justfile|\\.git" \
-e close_write,move,create,delete \
| while read -r directory events filename; do
just render
done
I derived most of this from a great Stack Exchange answer.
Now my workflow goes like this:
* I run just watch. The script watches the local directory for
changes.
* I edit my resume data.
* inotifywait catches the event and runs just render.
* Zola and Weasyprint run a rebuild of my resume PDF.
* My PDF viewer refreshes with the new content.
* When I'm done, I hit Ctrl-C or kill my terminal.
A full rebuild takes about a second on my machine, roughly nine
tenths of which is PDF rendering time. I'd love that to be faster
(the Zola HTML generation takes milliseconds!) but it works for now.
So that's my local development story more or less sorted out. I'm
relying on my editor's support for the YAML Language Server
(available in Visual Studio Code and in Vim/Neovim) to provide
validation and formatting of my YAML. I haven't configured precommit
checks of my YAML as I don't know of an appropriate tool that uses
the same YAML library as the language server.
Continuous Integration
Because my PDF is fundamentally a build product, I don't want to
commit it to source control. I also don't want to be responsible for
ensuring that a stored copy is up-to-date with my latest commit.
Enter GitHub Actions.
I added this workflow to my repo:
name: "Render Resume"
on:
push:
branches:
- main
jobs:
render:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: cachix/install-nix-action@v23
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: DeterminateSystems/magic-nix-cache-action@v2
- run: nix-shell --run "just pdf"
- uses: actions/upload-artifact@v3
with:
path: "*.pdf"
Because nix-shell grabs my shell.nix by default, I get the same
packages installed in CI that I use for local development. I could go
further and pin a specific set of package versions to guarantee
reproducibility. I've chosen not to do any pinning yet while I keep
rolling out uses for Nix on my local machines.
Once the PDF is rendered, I upload it as an artifact on this commit,
so that it's associated with all of its sources. I can grab the PDF
from my latest commit and upload it any time I submit a job
application.
If I wanted to use another distribution strategy, like including this
PDF as an asset in my website, I could build further automation
around that use case. That might be programmatically making a commit
to a different repo, using my resume repo as a submodule, or
something else entirely.
I could go further still and add rendering on branches, too. I might
let a branch represent a sector to which I want to apply, like
nonprofit. Then I can segregate tailorings of my resume for specific
job roles, and merge down global changes from main to keep everything
in sync.
The Result
It's still just a resume, but it makes my engineer brain happy.
If you'd like to indulge similar neuroses, you can clone a template
repository and start from there.
Have fun!
Copyright (c) 2024 David Reed