Nota de novembro de 2026: desde entón a web publica moitos menos datos. O mecanismo é o mesmo; os exemplos con experiencia laboral só ilustran a idea.
Manter un CV actualizado é tedioso. Modificas o PDF, logo lembras que tes que reflectilo tamén na web, e nalgún momento os dous desincronízanse e xa non sabes cal é a versión boa.
A miña solución: un único arquivo TOML como fonte de verdade. Xera o PDF con Typst, e un export con só os campos públicos alimenta esta web con Astro.
A arquitectura
cv_data.toml
│
├──→ Typst compiler ──→ cv.pdf
│
└──→ export con lista branca ──→ cv_public.json
│
└──→ pull request, revisado a man
│
└──→ loader de Astro + esquema estrito
│
└──→ as páxinas deste sitio
A web non le o TOML: só recibe o JSON xerado.
O arquivo TOML
cv_data.toml ten datos compartidos e bloques por idioma. Unha entrada de exemplo, con datos inventados:
[[gl.experience]]
company = "Empresa de Exemplo S.L."
position = "Desenvolvedora Frontend"
date = "Xaneiro 2020 - Actualidade"
notes = "Un apuntamento que non debe saír de aquí"
Engadir un idioma empeza por un bloque novo: as plantillas Typst len o idioma como un dato máis, e na web abonda con rexistralo e traducir as etiquetas da interface.
O export: unha lista branca, non un filtro
O doado é quitar o que non debe ser público. O problema é que falla en silencio: calquera campo que se engada ao TOML o ano que vén é público por defecto.
Por iso o export fai o contrario. Constrúe un documento novo e copia só os campos que nomea. Un exemplo simplificado, para ilustrar a idea:
# Exemplo ilustrativo, non é o script real
def export_experience(entry):
return {
"company": entry["company"],
"position": entry["position"],
"date": entry["date"],
}
O campo notes non aparece, así que non sae. E se o resultado non pasa as comprobacións, o script remata con erro e non escribe ningún arquivo: é mellor quedar sen export que publicar algo por accidente.
O content loader de Astro
A web importa o JSON como un arquivo local. No build non hai rede nin tokens:
// Simplificado para ilustrar o patrón
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(),
});
Aí hai dous detalles importantes, e ao principio tiña mal os dous:
parseData: un loader personalizado escribe o que lle pases astore.set. Se te saltasparseData, o esquema do mesmo arquivo non valida nada..strict(): por defecto, Zod descarta en silencio as claves descoñecidas. O modo estrito fai fallar o build, así que un campo novo no JSON hai que engadilo ao esquema a propósito.
Logo, en calquera páxina Astro:
---
const entry = await getEntry('cvLang', lang);
const { experience } = entry.data;
---
{experience.map(item => <ExperienceCard {item} />)}
A web é completamente estática. Todo se resolve no momento de compilar.
Actualizar: manual a propósito
editar cv_data.toml
│
└──→ executar o export ──→ diff de cv_public.json ──→ pull request
│
└──→ merge ──→ reconstrúese a web
Podería automatizalo, pero significaría gardar en CI un token con escritura no repositorio da web. Un CV non cambia tan a miúdo como para xustificar ese segredo, e executar un comando non custa moito.
O paso manual dáme ademais unha última revisión: ler o diff antes de fusionalo.
Por que Typst
Podería ter usado LaTeX. Pero Typst ten unha sintaxe moito máis limpa, compila en milisegundos, e permite separar datos de plantilla de forma natural. Non hai paquetes que instalar manualmente nin arquivos auxiliares que limpar.
// Typst: conciso e lexible
#let entity = (item, metadata) => [
#text(weight: "bold")[#item.company]
#text(style: "italic")[#item.position]
]
E ao estar todo en texto plano — TOML e arquivos .typ — o CV vive en Git como calquera outro proxecto de código. Historial de cambios, ramas, diffs.
Resultado
Actualizar o CV é editar un arquivo, e del saen tanto o PDF como a web. A web mostra só o que decidín exportar, e un erro detén o export ou o build antes de chegar a publicarse.