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 awesomecvThe 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.
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 awesomecvThis 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
.entriesdivs 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
detailedbut ship nodetailed.texand you get a warning naming it a packaging bug; an author asking forstyle="timelin"getsunknown 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 pandasDataFrameas a table regardless.
entries/ 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:
vitae.templates.<style>in document metadata — an author’s overrideentries/<style>.<ext>beside the document — a per-document tweak_extensions/**/entries/<style>.<ext>— your extension- 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.