Saltar al contenido
Development

El generador de tarjetas OG que no pudo correr donde se lo construí para correr

Por Victor Da Luz
astrocloudflareimagesdev-logastro-tools

Tenía tres PNGs cargados a mano como reemplazo de las tarjetas sociales de este sitio: una tarjeta por defecto de ventana de terminal, una tarjeta de Deep Cut Atlas, y nada para el blog. Ninguna plantilla sobrevivía en el repo, solo imágenes exportadas desde la herramienta de diseño que fuera que hubiera usado el día que las hice. Cada vez que quisiera ajustar el texto o publicar una nueva app insignia, iba a tener que volver a esa herramienta y reexportar a mano.

La solución se suponía simple: construir un paquete compartido de generación de tarjetas una vez, apuntar cada sitio hacia él, listo. En su mayoría lo fue. La parte que no lo fue me enseñó algo sobre dónde corre realmente el código durante el propio build de Astro.

Construyendo el generador

La pieza compartida, @vdaluz/astro-og-cards, envuelve tres librerías: satori convierte un string de HTML/CSS en un SVG, satori-html adapta un string plano de HTML a la forma de objeto que Satori necesita, y algo más rasteriza ese SVG a un PNG. Recurrí primero a @resvg/resvg-js, ya que es el emparejamiento habitual.

Falló. No una excepción normal, un panic de Rust no capturable, en el momento en que el markup de origen tenía un box-shadow o text-shadow. El diseño de mi tarjeta por defecto tiene ambos, así que esto no era un caso extremo, era cada tarjeta real que quería generar. Lo aislé hasta la salida de filtro feDropShadow/feGaussianBlur de Satori específicamente. Cambié resvg-js por sharp, misma entrada, funcionó de inmediato, brillo incluido.

Aparecieron dos trampas más antes de confiar en el pipeline: la fuente monoespaciada del sistema de macOS es una fuente variable, y el parser de fuentes integrado de Satori no puede leer esas, así que tuve que empaquetar un peso estático en su lugar. Y leer esa fuente empaquetada desde un archivo hermano en tiempo de ejecución, usando cualquier ruta calculada en relación con la ubicación del propio módulo, se rompía en el momento en que el bundler de un consumidor real (Vite) reubicaba el módulo compilado en un archivo de chunk distinto. La solución ahí fue simplemente incrustar la fuente como base64 directo en el JS, así no hay ninguna ruta de archivo que pueda fallar.

La tarjeta que necesitaba una cara

La tarjeta de Deep Cut Atlas no es una tarjeta de texto simple, tiene una ilustración de globo de disco de vinilo dorado. Encontré el SVG de origen de esa ilustración ya presente en el repo como el ícono de la app, lo cual significó que no tuve que redibujar nada, solo rasterizarlo una vez en tiempo de build y ponerlo como imagen. La recreación salió lo bastante cercana al original que una comparación lado a lado fue realmente la única forma de dar el visto bueno, evaluar a partir de una descripción no habría dicho nada útil.

Los posts del blog recibieron un tratamiento similar: una tarjeta con título por defecto, y cuando un post tiene una imagen destacada, esa foto se convierte en el fondo de la tarjeta con una superposición de gradiente para que el título siga siendo legible. Ninguno de mis posts actuales tiene todavía una imagen destacada configurada, ya que ese campo se usa sobre todo en el blog hermano. Construí la variante con foto de todas formas. Este tooling no está limitado a lo que un sitio en particular esté usando hoy.

Dónde falló de verdad

Al conectar las tres tarjetas al sitio, construí endpoints dedicados de Astro. src/pages/og/blog/[...slug].png.ts, prerender = true, llamar al generador, devolver el PNG. Este es el patrón estándar de Astro para generación de imágenes en tiempo de build y es exactamente lo que había usado para validar el paquete compartido en sí.

astro build falló:

Error: No such module "dist/server/.prerender/chunks/sharp".

El adaptador de Cloudflare de Astro no prerenderiza rutas en Node plano. Las corre dentro de un sandbox de Workers simulado con Miniflare, para calzar con lo que producción realmente hace. Esa es la decisión correcta en términos de precisión, pero significa que ningún addon nativo puede correr ahí, punto. sharp es un binario compilado. Ningún flag de compatibilidad arregla eso, porque el problema no es un shim de JS faltante, es que un isolate de V8 no puede ejecutar código nativo en absoluto.

Mi validación anterior no había detectado esto porque había probado el paquete contra una app de scratch con output: 'static' plano, que prerenderiza en Node normal. El hueco quedó invisible hasta que corrí el build real sobre el sitio real.

La solución fue dejar de intentar generar imágenes desde dentro del build de Astro por completo. Moví la generación a un script independiente que corre antes del build, escribe PNGs directo en public/, y lo conecté como el hook prebuild de npm para que se dispare automáticamente. Las páginas del sitio ya solo referencian /og/blog/<slug>.png como una ruta estática normal, así que nada del enrutamiento de URLs tuvo que cambiar, solo cómo llegaba ahí el archivo.

Ese script se topó con su propia versión de la misma clase de problema: Node se niega a quitar tipos de TypeScript para cualquier cosa que viva dentro de node_modules, lo cual bloqueaba importar el paquete compartido (publicado como TypeScript crudo, sin compilar) desde un script plano. Correr el script a través de tsx en vez de node plano lo esquivó, ya que tsx usa esbuild para la transformación y no carga con esa restricción.

Qué es distinto ahora

Los tres PNGs cargados a mano ya no existen. En su lugar: tres funciones de plantilla, un script de generación, y un paso de build que regenera cada tarjeta automáticamente, incluyendo una por post de blog en ambos idiomas. La etiqueta dorada de la tarjeta de Deep Cut Atlas ahora se extrae directo de las cadenas de traducción propias del sitio en vez de lo que fuera que hubiera escrito en una herramienta de diseño hace meses, así que ya no puede desincronizarse del texto real de la página.

La parte que señalaría para cualquiera que construya algo parecido: verificar el mecanismo contra el objetivo real, no contra un sustituto estructuralmente más simple. Una app de scratch con salida estática fue una buena forma de comprobar que las plantillas de tarjetas renderizaban correctamente, y lo hizo. Nunca podría haber revelado que el objetivo de despliegue real corre rutas prerenderizadas en un lugar al que ningún addon nativo puede seguirlo.

Lecturas relacionadas