The Overengineered Resume with Zola, JSON Resume, Weasyprint, and Nix

Author
David Reed
Published
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:

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") %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{{ resume.basics.name }}</title>
</head>
<body>
    <main>
        <h1>
            {{ resume.basics.name }} <span></span> <span>{{ resume.basics.label }}</span>
        </h1>
        <dl>
            {% if resume.basics.email %}
            <dt>email</dt>
            <dd>
                <code>{{ resume.basics.email }}</code>
            </dd>
            {% endif %}
            {% if resume.basics.url %}
            <dt>web</dt>
            <dd><code>{{ resume.basics.url }}</code></dd>
            {% endif %}
            {% if resume.basics.profiles %}
            {% for network in resume.basics.profiles %}
            <dt>{{ network.network | lower }}</dt>
            <dd><code>{{ network.username }}</code></dd>
            {% endfor %}
            {% endif %}
        </dl>

        {{ resume.basics.summary | markdown | safe }}
        <!-- Continues ...-->

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 <nixpkgs> {} }:
  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:

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!