Creating CV templates

A guide for template authors

A CV template is an ordinary Quarto format extension that additionally ships a set of entry listing handlers — small pandoc templates describing how one section of a CV should look. The vitae-entries filter does everything else: finding the data, renaming columns, and calling your handler once per listing.

This means adding a new CV design requires no Lua and no changes to the filter. If you can write the LaTeX or Typst for one entry, you can ship a template.

How the pieces fit

  author writes                 vitae-entries filter          your template
  ─────────────                 ────────────────────          ─────────────
  a table, or a cell    ──►     find the table                entries/detailed.tex
  that prints data              apply `fields` renaming  ──►  entries/brief.tex
  + `style: detailed`           hand off to a handler         entries/skills.tex

Your extension contributes two things: a Quarto format (the document class, fonts, title block) and a directory of handlers for the listing styles that format supports.

Anatomy of an extension

_extensions/awesomecv/
├── _extension.yml
├── awesome-cv.cls          # the document class
├── awesome-cv.tex          # the pandoc template for the whole document
├── fonts/
├── _extensions/            # ← the embedded curriculum filters
│   └── quarto-vitae/
│       └── vitae-entries/
└── entries/                # ← the listing handlers
    ├── detailed.tex
    ├── brief.tex
    └── skills.tex

Embedding the filter

Quarto will not resolve one extension from another by name alone, so a template distributed on its own has to carry its own copy of the filter. This is what quarto add --embed is for. From the root of your template repository:

quarto add quarto-vitae/curriculum --embed awesomecv

The first argument is the curriculum repository; the --embed argument is the directory name of your extension under _extensions/. The copy lands inside your extension, as shown above, and filters: [vitae-entries] then resolves from anywhere in that tree. You do not need to reference it by path.

Commit the embedded copy. It is part of your extension as distributed, and without it your format will not render for anyone else.

ImportantEmbed before you reference

Add the filter before you write filters: [vitae-entries] in your _extension.yml. Quarto validates the target extension while installing into it, so a reference to a filter that is not there yet makes the install fail with the very Failed to resolve referenced filter error that embedding is meant to cure. This affects only the first add; later updates are fine.

To pick up a new release of curriculum, re-embed with the same pair of arguments:

quarto update quarto-vitae/curriculum --embed awesomecv

This is a clean replace rather than an overlay, so files dropped upstream are removed instead of lingering. Commit the result and cut a release of your template.

Note what this means for the people using your template: updating your template refreshes the copy of curriculum inside it, so they need no separate step, and they have no way to update the filter themselves. A fix in curriculum reaches your users only once you re-embed and release. Nothing yet warns about a stale embedded copy, so keeping it current is your responsibility rather than theirs.

_extension.yml

title: Awesome CV
author: Your Name
version: 0.1.0
quarto-required: ">=1.7.0"
contributes:
  formats:
    pdf:
      pdf-engine: xelatex
      template: awesome-cv.tex
      format-resources:
        - awesome-cv.cls
        - fonts
      filters:
        - vitae-entries
      df-print: kable
      vitae:
        styles: [detailed, brief, skills]

The vitae-specific parts:

filters: [vitae-entries]
Runs the filter, resolved from the embedded copy added above. Without it, your .entries divs pass through untouched.
vitae.styles
The listing styles your format implements. This is not how styles are discovered — the filter finds handlers by filename. It is how the filter tells a missing handler from a typo by the author. Declare detailed but ship no detailed.tex and you get a warning naming it a packaging bug; an author asking for style="timelin" gets unknown style 'timelin'; this format provides detailed, brief, skills. Skip it and both fail quietly.
df-print: kable
Optional but recommended. Lets a knitr cell print a data frame and still yield a table, so authors need no knitr::kable() call. It is a format option — Quarto ignores it as a cell option — so opting in is your job, not the author’s. It has no effect on the Jupyter engine, which renders a pandas DataFrame as a table regardless.
Noteentries/ does not go in format-resources

Resources are copied next to the rendered document after pandoc runs, so a handler listed there resolves only on the second render — working locally forever while failing in CI. The filter reads _extensions/**/entries/ directly, so just leave the directory where it is.

Entry listing handlers

A handler is a pandoc template named after the style, with an extension matching the target format:

Target format Handler filename
LaTeX / PDF entries/brief.tex
Typst entries/brief.typ
HTML entries/brief.html
anything else entries/brief.md

Ship one per format you support. A format with no handler for a requested style falls back to a plain generic layout, so nothing breaks — it just looks unstyled.

Resolution order

The filter takes the first that exists:

  1. vitae.templates.<style> in document metadata — an author’s override
  2. entries/<style>.<ext> beside the document — a per-document tweak
  3. _extensions/**/entries/<style>.<ext>your extension
  4. the generic fallback shipped with vitae-entries

Authors can therefore override any handler you ship without forking your extension, and you should treat that as a feature.

The template context

Two variables are in scope:

entries
The listing, one item per row of the author’s data.
style
The style name, useful when several handlers share an include.

Each entry’s keys are the column names of the author’s table, after any fields renaming. There is no fixed schema — the filter has no vocabulary of its own, so a column named grade is available as $entries.grade$ with no configuration anywhere.

A minimal handler:

$for(entries)$
\cventry{$entries.what$}{$entries.with$}{$entries.where$}{$entries.when$}{}
$endfor$

Four behaviours worth knowing

Missing fields are empty, not errors. An undefined variable renders as the empty string, $if()$ on it is false, and $for()$ over it iterates zero times. You do not need to guard against columns the author omitted.

A field may be a string or a list. A grid-table cell containing a bullet list, several columns merged with {why: [a, b]}, or collapse all produce lists. $for()$ handles both — it iterates a plain string exactly once — so looping is always safe:

$if(entries.why)$\begin{cvitems}
$for(entries.why)$  \item $entries.why$
$endfor$\end{cvitems}$endif$

Values are already rendered and escaped. The filter converts each cell through pandoc into your target format, so **bold** arrives as \textbf{bold} and 100% as 100\%. Do not escape again, and do not wrap values in anything that would re-interpret them.

Use $sep$ for separators, rather than emitting a trailing comma:

$for(entries.why)$$entries.why$$sep$, $endfor$

Comments are $-- like this and are worth using — handlers are read by people adapting them.

A worked example

Awesome CV’s \cvhonor has a fixed-width, right-aligned location column that suits short values only. Its brief handler prefers where, falling back to with:

$-- \cvhonor{<position>}{<title>}{<location>}{<date>}
\begin{cvhonors}
$for(entries)$
  \cvhonor{}{$entries.what$}{$if(entries.where)$$entries.where$$else$$entries.with$$endif$}{$entries.when$}
$endfor$
\end{cvhonors}

This is the intended way to handle a design that does not map cleanly onto the conventional fields: decide it in the handler, where the constraint actually lives, rather than asking authors to reshape their data per template.

Conventional fields

The filter implements no field names. These five are a convention among template authors, and that is precisely where their value lies: an author’s table can feed a LaTeX CV and a Typst one, or move between designs, without being rewritten. Honour them where they make sense.

Field Meaning Typical content
what the thing itself degree, job title, award, skill group
when when it happened 2018–2022, 1903
with the associated body university, employer, funder
where the place city, country
why elaboration achievements, description — often a list

They are deliberately semantic-free so one vocabulary covers education, employment, awards and service alike. what and when are near-universal; support them in every handler. why should be looped, not printed, since it is so often a list.

Adding fields of your own is fine and needs no permission — a column reaches your template under its own name. Prefer a new name over overloading an existing one: if your design shows a grade, take $entries.grade$ rather than expecting authors to smuggle it through where.

Naming styles

Name a style for its shape, not for the section it happens to suit.

detailed, brief and skills describe layouts. education and employment would describe sections, which is a mistake: the same layout serves education, employment and service, while one CV’s education section may want a different shape from another’s. Sections are the author’s business; shapes are yours.

Reuse these names where the shape matches, so authors can switch designs without editing every div:

detailed
A multi-line entry with a heading, subheading, date, place, and a bulleted elaboration. The workhorse.
brief
One line per entry. Titles, dates, and little else.
skills
Two columns — a category and its members.

Introduce a new name only when the shape is genuinely new (publications, timeline), and declare it in vitae.styles so authors get a useful error rather than an unstyled fallback.

What authors can hand you

Worth knowing, since it determines what reaches your handler:

Option Effect
style which handler to use (default detailed)
fields rename columns, {what: Degree}; the default is the identity
collapse merge consecutive rows differing only in the named column
input read printed data instead of a rendered table (csv)
as markdown to re-parse your output, useful for HTML handlers

Checklist