Saltar al contenido
Development

Evitar que vuelva el vacío de posts sin imagen hero (una protección, no un recordatorio)

Por Victor Da Luz
astrocitoolingdev-logblog-manager

Hace un tiempo hice una tarea aburrida y satisfactoria: encontré 80 posts de blog en mi sitio que no tenían imagen hero y le di una a cada uno. Se sintió como progreso. No lo fue, en realidad. Una limpieza de una sola vez arregla el día de hoy y no promete nada sobre el mañana, y un par de meses después fui a revisar y encontré que el vacío había vuelto a abrirse en silencio. Cuatro posts publicados, sin imagen hero.

Esta es la historia del segundo arreglo, el que sí se sostiene.

Por qué el vacío seguía volviendo

Mi blog es un sitio de Astro. Los metadatos de los posts viven en el frontmatter, validados por un esquema de Zod. Esta es la línea relevante:

heroImage: z.string().optional(),

Optional. Esa palabra es todo el problema. Mi flujo de publicación verifica que los campos que un post tiene sean válidos, pero un campo opcional ausente es válido por definición. Así que un post sin imagen hero pasa todas las verificaciones sin problema, se confirma, se despliega, y sale en vivo viéndose un poco desnudo. Nada se queja nunca. La única forma en que me enteré fue yendo a contar.

El arreglo obvio es hacer el campo obligatorio:

heroImage: z.string(),  // tempting, wrong

No hice eso, y quiero explicar por qué, porque es la decisión de la que depende el resto del trabajo. Un campo obligatorio hace fallar el build para todo post sin imagen hero, incluyendo borradores que todavía estoy escribiendo, fechados para la semana próxima y lejos de estar listos. Quedaría bloqueado por una imagen antes de haber terminado la prosa. El esquema no puede distinguir “publicado y roto” de “todavía no terminado”. Necesitaba una verificación que sí pudiera.

La protección

La distinción que realmente me importa es: ¿este post está en vivo? Un post está en vivo cuando su pubDate es hoy o antes. Los posts con fecha futura son borradores y no son asunto de la protección. Ese único filtro es toda la idea.

const today = new Date().toISOString().slice(0, 10);

for (const file of files) {
  const { data } = matter(await readFile(join(BLOG_DIR, file), 'utf8'));
  const pubDate = String(data.pubDate ?? '').slice(0, 10);

  // Only enforce on published posts; drafts are exempt.
  if (!pubDate || pubDate > today) continue;

  const hero = typeof data.heroImage === 'string' ? data.heroImage.trim() : '';
  if (!hero) violations.push(file.replace(/\.md$/, ''));
}

Notar el .trim(). El esquema acepta un string vacío, así que heroImage: "" pasaría una verificación de presencia ingenua mientras está exactamente tan roto como no tener imagen hero en absoluto. Tratar lo vacío como ausente.

Corre como un hook de pre-commit y de nuevo en CI. Localmente detiene un post sin imagen hero antes de que se confirme. En CI es el respaldo para todo lo que se salte el hook. El mismo script en ambos lugares.

La parte que casi hice mal

Mi CI corre los hooks contra todo el repositorio, cada archivo, cada vez (pre-commit run --all-files). Es un buen valor por defecto y tiene un filo peligroso: una protección recién creada, apuntada a un corpus con violaciones preexistentes, falla el primer día. Ya tenía un hook esquivando esto. Mi verificación de palabras prohibidas se salta explícitamente en CI por esta misma razón, porque escanear todo el historial saca a la luz frases viejas con las que ya hice las paces.

Podría haber copiado eso y ponerle un SKIP a la protección nueva. No lo hice, y esto es lo que subrayaría para cualquiera que construya algo parecido. Saltarla en CI habría significado publicar una protección que no protege. Los cuatro posts rotos quedarían ahí, exentos para siempre, y la verificación solo atraparía errores nuevos mientras mentiría sobre el estado actual. Así que hice lo menos ingenioso: arreglé los cuatro posts primero, para que la protección pudiera correr contra todo sin excepciones y pasar honestamente. Una protección con un asterisco no es una protección.

El ayudante, y una sorpresa genuina

Para arreglar los cuatro (y para que fijar una imagen hero sea de una sola línea de acá en adelante), escribí un pequeño ayudante. Se le da un slug y una imagen, desde una URL o un archivo local, y descarga la foto, escribe el archivo webp hermano con la misma configuración de sharp que ya usa el build, e inserta la línea de frontmatter:

node scripts/set-hero-image.mjs <slug> <image-url-or-path>

A propósito lo mantuve como una edición de texto, no un parseo y reescritura. Hacer el viaje de ida y vuelta del frontmatter por una librería de YAML habría reacomodado el estilo de comillas y reordenado las claves en los cuatro archivos, enterrando un cambio de una línea en un diff ruidoso. Una inserción dirigida mantiene el diff a la única línea que cambió.

La sorpresa fue el conteo. Esperaba dos posts rotos y encontré cuatro. Uno de los dos extra ni siquiera estaba confirmado. Era un borrador sentado en mi árbol de trabajo, nunca subido, con un pubDate retroactivo que hacía que la protección lo tratara como en vivo. Mi primera reacción fue que la protección tenía un falso positivo. Mi segunda reacción, mejor, fue que la protección tenía razón y yo estaba equivocado: un post fechado en el pasado le está diciendo al mundo que está publicado, y si alguna vez lo confirmo, se publica sin imagen hero. La herramienta atrapó un error que todavía no había cometido. Ese es el buen tipo de molestia.

Lo que me llevo de esto

El patrón que sigo reaprendiendo: una checklist dice qué hay que hacer, una protección hace imposible confirmar lo incorrecto. También actualicé mi flujo de publicación para fijar una imagen hero como un paso, y eso es genuinamente útil, pero es una nota para mi yo futuro, y mi yo futuro es olvidadizo. La pieza que sostiene todo son las doce líneas que hacen fallar el build. Todo lo demás es conveniencia.

Y cuando se agrega una protección a una base de código que ya tiene violaciones, hay que resistir la tentación de saltarla. Limpiar las violaciones primero para que la protección pueda ser absoluta. El momento en que tiene una excepción es el momento en que deja de significar algo.

Lecturas relacionadas

Development

Retirando el entorno de staging

Un segundo contenedor, un monitor aparte, una tasa de fallos del 11% en el workflow, y cero evidencia de que alguna vez detectara algo que los deploys de producción no detectaran. La auditoría que terminó en un borrado.

Leer