Cuando alguien comparte un enlace en WhatsApp, LinkedIn, X o Slack, lo primero que se ve no es la página: es su imagen. Hasta hace poco, todas las páginas de este sitio mostraban la misma, la imagen general de la marca. Un servicio, una demo del Lab y un artículo se veían iguales en un chat.
Ahora cada página pública tiene la suya, de 1200 × 630, con el logo, el título, la descripción, el tipo de página y el dominio del sitio (DEC-029). Se genera al compilar, como un archivo estático más: no cuesta nada en tiempo de ejecución, no toca la CSP y no cambia Lighthouse. Te cuento cómo, y por qué la imagen sale del HTML ya construido y no de una lista de páginas.
La imagen sale del HTML, no de un catálogo
La opción obvia era un endpoint og/[...ruta].png.ts con un catálogo de páginas. La descarté. Los títulos de las páginas fijas viven en sus vistas, así que el catálogo los repetiría, y tarde o temprano una imagen diría algo distinto a su página. Además, las colecciones de Astro no cargan en Vitest, así que no habría podido probarlo.
Lo que hice fue una integración de Astro que corre al terminar el build. Para entonces, Base.astro ya escribió en cada página sus etiquetas og:*: título, descripción, idioma y URL. La integración recorre los HTML de dist/, lee esas etiquetas y arma una tarjeta por página:
for (const file of await htmlFiles(out)) {
const head = readHead(await readFile(file, 'utf8'))
const page = head && cardFor(head)
if (page) cards.set(page.image, page.card)
}
Así la imagen dice siempre lo mismo que la página, en su idioma. Un servicio, un proyecto, una demo o un artículo nuevo tiene su imagen sin que yo toque ninguna lista, y si cambio un título, la imagen cambia en el siguiente build.
¿Quién decide qué páginas llevan imagen propia? Base.astro, con una regla corta: toda página pública con dirección propia.
const ownImage = !isPrivate && hasCanonical
const ogImage = new URL(ownImage ? ogImagePath(Astro.url.pathname) : site.media.ogImage, Astro.site)
/admin/ y la 404 conservan la imagen general. La ruta de cada imagen repite la de su página: /servicios/paginas-web/ apunta a /og/servicios/paginas-web.png, y el inicio, a /og/index.png.
Qué muestra cada tarjeta
Arriba del título va una etiqueta pequeña con el tipo de página: «Servicio», «Proyecto», «Demo del Lab» o, en un artículo, «Artículo, 6 min de lectura». El tiempo de lectura no lo calcula la imagen: lo lee de twitter:label1 y twitter:data1, dos etiquetas que la página del artículo ya escribe para que Slack y X lo muestren en la vista previa. Es el mismo texto que aparece bajo el título del artículo. Y los datos estructurados del artículo (BlogPosting) usan su propia imagen, no la general.
El diseño vive en template.ts como un árbol de elementos con estilos de flexbox, sin React. Siempre usa el tema oscuro, como la versión neón del logo. La letra del título se achica según el largo, para que una página de una sola palabra se lea bien en miniatura; el título se corta en tres líneas y la descripción en dos, con «…».
Satori dibuja, sharp comprime
Satori convierte ese árbol en un SVG, con el texto como trazos, y sharp lo pasa a PNG:
async function draw(tree: unknown): Promise<Buffer> {
// Satori's types expect a React element; the tree has the same shape.
const svg = await satori(tree as Parameters<typeof satori>[0], { ...OG_SIZE, fonts })
return sharp(Buffer.from(svg)).png(PNG_OPTIONS).toBuffer()
}
Para el PNG consideré resvg, pero habría sido otra dependencia nativa, y sharp ya estaba instalado porque Astro lo usa. Además, sharp guarda el PNG con paleta, que lo deja en menos de la mitad: cada imagen pesa unos 55 KB. Va sin difuminado (dither: 0), porque sobre el resplandor violeta del fondo dibujaba una mancha de ruido visible.
Las versiones siguen la regla de los 14 días (DEC-002): Satori 0.63.2 y sharp 0.35.5, la misma que ya traía Astro, ahora como dependencia directa.
Las fuentes: instancias estáticas, no variables
Aquí estuvo la sorpresa. El sitio usa las fuentes variables de Unbounded e Instrument Sans, y los títulos van en peso 600. Pero Satori dibuja una fuente variable solo en su peso por defecto, 400: con las fuentes que ya tenía instaladas, todo habría salido en 400.
La solución fue usar las instancias estáticas de las mismas fuentes, de @fontsource: Unbounded en 600 e Instrument Sans en 400 y 600, en latín y latín extendido, para que entren tildes, eñes, signos de apertura y comillas. Se leen de node_modules, sin red. El sitio sigue con las variables; las estáticas solo las usa el build.
Una caché para no dibujar dos veces
Dibujar todas las imágenes tardaba unos 2,7 s la primera vez, con 52 imágenes cuando lo medí. No es mucho, pero se paga en cada build y casi siempre para nada: la mayoría de las páginas no cambió.
Por eso cada imagen se guarda en node_modules/.astro/og-images/, con un nombre que es el hash de todo lo que la forma: el árbol de elementos (texto, diseño y dominio) y lo que cambia los píxeles sin cambiar el árbol.
const salt = JSON.stringify({
fonts: FONT_FILES.map((f, i) => [f.file, fonts[i]?.data.byteLength]),
satori: satoriVersion,
sharp: sharp.versions,
png: PNG_OPTIONS,
})
Si cambio un título, solo se dibuja esa imagen; si actualizo Satori, todas. Con la caché llena, el paso tarda 0,1 s. Las imágenes que nadie usa en 30 días se borran solas, y si alguna vez quiero empezar de cero, basta con borrar la carpeta.
Lo que revisan las pruebas
Una imagen rota en una vista previa no la ve quien hizo el sitio: la ve quien recibe el enlace. Por eso hay dos redes.
La primera es scripts/check-links.mjs, que ya revisaba los enlaces internos del build y ahora también las URL de og:url, og:image y twitter:image. De paso se corrigió la 404: escribía og:url con /404/, una dirección que no existe. Ahora no lo escribe, igual que no tiene canónica.
La segunda es e2e/og-images.e2e.ts. Para cada página pública del build comprueba que apunte a su propia imagen, en el mismo dominio que la página, y que el archivo exista y sea un PNG de 1200 × 630:
const file = join(DIST, image.pathname)
expect(existsSync(file), `${image.pathname} is in the build`).toBe(true)
expect(pngSize(file)).toEqual(OG_SIZE)
Después arma la tarjeta con la misma función que usó el build y exige que muestre el título y la descripción de la página, en su idioma. En los artículos, además, que el tiempo de lectura sea el que se ve bajo el título y que BlogPosting use la imagen propia. Y al revés: que cada imagen de /og/ pertenezca a una página, para que no queden huérfanas.
Para revisarlo tú
- La decisión, las versiones y lo que descarté:
docs/DECISIONES.md(DEC-029). - La integración, el dibujo y la caché:
integration.tsyrender.ts. - El diseño y sus textos:
template.tsymessages.ts. - Cómo cambiar el diseño:
docs/CONTENIDO.md, sección «Imágenes para compartir». - La prueba:
e2e/og-images.e2e.ts.
Y la forma más rápida de verlo: comparte cualquier página de este sitio en un chat, este artículo incluido, y mira su tarjeta. El panorama completo de cómo está construido el sitio está en Cómo está hecho, y la otra mitad de las etiquetas del <head>, en la CSP estricta.