Nota de noviembre de 2026: desde entonces la web publica muchos menos datos. El mecanismo es el mismo; los ejemplos con experiencia laboral solo ilustran la idea.
Mantener un CV actualizado es tedioso. Modificas el PDF, luego recuerdas que tienes que reflejarlo también en la web, y en algún momento los dos se desincronizan y ya no sabes cuál es la versión buena.
Mi solución: un único archivo TOML como fuente de verdad. Genera el PDF con Typst, y un export con solo los campos públicos alimenta esta web con Astro.
La arquitectura
cv_data.toml
│
├──→ Typst compiler ──→ cv.pdf
│
└──→ export con lista blanca ──→ cv_public.json
│
└──→ pull request, revisado a mano
│
└──→ loader de Astro + esquema estricto
│
└──→ las páginas de este sitio
La web no lee el TOML: solo recibe el JSON generado.
El archivo TOML
cv_data.toml tiene datos compartidos y bloques por idioma. Una entrada de ejemplo, con datos inventados:
[[es.experience]]
company = "Empresa de Ejemplo S.L."
position = "Desarrolladora Frontend"
date = "Enero 2020 - Actualidad"
notes = "Un apunte que no debe salir de aquí"
Añadir un idioma empieza por un bloque nuevo: las plantillas Typst leen el idioma como un dato más, y en la web basta con registrarlo y traducir las etiquetas de la interfaz.
El export: una lista blanca, no un filtro
Lo fácil es quitar lo que no debe ser público. El problema es que falla en silencio: cualquier campo que se añada al TOML el año que viene es público por defecto.
Por eso el export hace lo contrario. Construye un documento nuevo y copia solo los campos que nombra. Un ejemplo simplificado, para ilustrar la idea:
# Ejemplo ilustrativo, no es el script real
def export_experience(entry):
return {
"company": entry["company"],
"position": entry["position"],
"date": entry["date"],
}
El campo notes no aparece, así que no sale. Y si el resultado no pasa las comprobaciones, el script termina con error y no escribe ningún archivo: es mejor quedarse sin export que publicar algo por accidente.
El content loader de Astro
La web importa el JSON como un archivo local. En el build no hay red ni tokens:
// Simplificado para ilustrar el 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(),
});
Ahí hay dos detalles importantes, y al principio tenía mal los dos:
parseData: un loader personalizado escribe lo que le pases astore.set. Si te saltasparseData, el esquema del mismo archivo no valida nada..strict(): por defecto, Zod descarta en silencio las claves desconocidas. El modo estricto hace fallar el build, así que un campo nuevo en el JSON hay que añadirlo al esquema a propósito.
Luego, en cualquier página Astro:
---
const entry = await getEntry('cvLang', lang);
const { experience } = entry.data;
---
{experience.map(item => <ExperienceCard {item} />)}
La web es completamente estática. Todo se resuelve en el momento de compilar.
Actualizar: manual a propósito
editar cv_data.toml
│
└──→ ejecutar el export ──→ diff de cv_public.json ──→ pull request
│
└──→ merge ──→ se reconstruye la web
Podría automatizarlo, pero significaría guardar en CI un token con permiso de escritura en el repositorio de la web. Un CV no cambia tan a menudo como para justificar ese secreto, y ejecutar un comando no cuesta mucho.
El paso manual me da además una última revisión: leer el diff antes de fusionarlo.
Por qué Typst
Podría haber usado LaTeX. Pero Typst tiene una sintaxis mucho más limpia, compila en milisegundos, y permite separar datos de plantilla de forma natural. No hay paquetes que instalar manualmente ni archivos auxiliares que limpiar.
// Typst: conciso y legible
#let entity = (item, metadata) => [
#text(weight: "bold")[#item.company]
#text(style: "italic")[#item.position]
]
Y al estar todo en texto plano — TOML y .typ — el CV vive en Git como cualquier otro proyecto de código. Historial de cambios, ramas, diffs.
Resultado
Actualizar el CV es editar un archivo, y de él salen tanto el PDF como la web. La web muestra solo lo que he decidido exportar, y un error detiene el export o el build antes de llegar a publicarse.