Note, November 2026: the site now publishes far less data. The mechanism is the same; the work-experience examples only illustrate the idea.
Keeping a CV up to date is tedious. You update the PDF, then remember you need to reflect the changes on the website too, and at some point both are out of sync and you’re not sure which one is correct.
My solution: a single TOML file as source of truth. It produces the PDF through Typst, and an export with only the public fields feeds this site through Astro.
The architecture
cv_data.toml
│
├──→ Typst compiler ──→ cv.pdf
│
└──→ allowlist export ──→ cv_public.json
│
└──→ pull request, reviewed by hand
│
└──→ Astro loader + strict schema
│
└──→ the pages of this site
The site doesn’t read the TOML: it only ever receives the generated JSON.
The TOML file
cv_data.toml has shared data and per-language blocks. A sample entry, with made-up data:
[[en.experience]]
company = "Example Company Ltd."
position = "Frontend Developer"
date = "January 2020 - Present"
notes = "A note that must not leave this file"
Adding a language starts with a new block: the Typst templates read the language as data, and on the site it comes down to registering it and translating the interface labels.
The export: an allowlist, not a filter
The easy approach is to remove what shouldn’t be public. The problem is that it fails silently: any field added to the TOML next year is public by default.
So the export does the opposite. It builds a new document and copies only the fields it names. A simplified example, to illustrate the idea:
# Illustrative example, not the actual script
def export_experience(entry):
return {
"company": entry["company"],
"position": entry["position"],
"date": entry["date"],
}
The notes field isn’t named, so it doesn’t leave. And if the result fails its checks, the script exits with an error and writes no file: better no export than publishing something by accident.
The Astro content loader
The site imports the JSON as a local file. There’s no network or token at build time:
// Simplified to illustrate the pattern
import cvPublic from './data/cv_public.json';
const cvLang = defineCollection({
loader: {
name: 'cv-lang-loader',
async load({ store, parseData }) {
for (const lang of ['es', 'en', 'gl'] as const) {
const data = await parseData({ id: lang, data: cvPublic[lang] });
store.set({ id: lang, data });
}
},
},
schema: z.object({ /* ... */ }).strict(),
});
Two details in there matter, and I had both wrong at first:
parseData: a custom loader writes whatever you give tostore.set. If you skipparseData, the schema in the same file validates nothing..strict(): by default Zod silently drops unknown keys. Strict mode fails the build instead, so a new field in the JSON has to be added to the schema on purpose.
Then, in any Astro page:
---
const entry = await getEntry('cvLang', lang);
const { experience } = entry.data;
---
{experience.map(item => <ExperienceCard {item} />)}
The site is completely static. Everything is resolved at compile time.
Updating: manual on purpose
edit cv_data.toml
│
└──→ run the export ──→ diff of cv_public.json ──→ pull request
│
└──→ merge ──→ the site rebuilds
I could automate this, but it would mean keeping a token in CI with write access to the site’s repository. A CV doesn’t change often enough to justify that secret, and running one command is not much of a cost.
The manual step also gives me a last check: reading the diff before merging it.
Why Typst
I could have used LaTeX. But Typst has a much cleaner syntax, compiles in milliseconds, and allows separating data from templates naturally. No packages to install manually, no auxiliary files to clean up.
// Typst: concise and readable
#let entity = (item, metadata) => [
#text(weight: "bold")[#item.company]
#text(style: "italic")[#item.position]
]
And since everything is plain text — TOML and .typ files — the CV lives in Git like any other code project. Change history, branches, diffs.
Result
Updating the CV means editing one file, and the PDF and the site both come from it. The site shows only what I chose to export, and a mistake stops the export or the build before it gets online.