Saltar al contenido

· · Herramientas  · 9 min read

Cómo Valido un Post de Astro Antes de Publicarlo

Publicar un post estático en Astro está a un merge de estar en vivo — justo por eso los bugs silenciosos son peligrosos. Un lang incorrecto, un canonical que da 404, un link entre idiomas que se filtró: nada de eso se ve en tu editor, solo en el HTML compilado. Aquí está el checklist que corro sobre dist/ antes de cada post, scripts incluidos.

Publicar un post estático en Astro está a un merge de estar en vivo — justo por eso los bugs silenciosos son peligrosos. Un lang incorrecto, un canonical que da 404, un link entre idiomas que se filtró: nada de eso se ve en tu editor, solo en el HTML compilado. Aquí está el checklist que corro sobre dist/ antes de cada post, scripts incluidos.

En un sitio estático con Astro, publicar está a un git merge de estar en vivo. Sin ambiente de staging, sin rollout gradual — corre el build, el CDN hace el swap, y sesenta segundos después tu post ya está frente a todo el mundo. Esa velocidad es todo el atractivo. También es la trampa: no hay una ventana en la que un humano note que algo se ve raro antes de que lo note el mundo.

Y los bugs que más dañan a un blog en Astro son justo los que no puedes ver mientras escribes. Un post que renderiza <html lang="es"> cuando está escrito en inglés. Una etiqueta canonical apuntando a una URL que da 404. Un alternate de hreflang a una página que no existe. Un widget de “posts relacionados” mostrando calladito el idioma equivocado. Nada de esto aparece en tu Markdown, en tu editor, ni siquiera en tu servidor de desarrollo de la forma en que detectarías un typo — viven en el HTML compilado, y fallan en silencio. Así que antes de publicar cualquier post, corro un checklist corto contra el output de dist/. Aquí está completo, con los scripts reales.

Por qué revisas el output del build, no el source

Tu archivo .mdx no es lo que se publica. Astro lo procesa a través de MDX, los componentes de tu theme, plugins de remark/rehype y (en mi caso) un compresor de HTML. Lo que termina en dist/ es el producto de todo eso — y ahí es donde se ensamblan de verdad los meta tags, los datos estructurados, el canonical y el hreflang, muchas veces a partir de archivos de configuración que están tres carpetas lejos del post que estás editando.

Ese es el cambio mental: no estás revisando tu redacción, estás auditando un artefacto generado. Así que el primer paso siempre es producirlo:

bun run build   # genera dist/ exactamente como se va a desplegar

Todo lo de abajo corre contra esa carpeta.

Check 1: verifica el lang renderizado — no confíes en el frontmatter

Mi blog deriva el idioma de una página a partir de su URL, no de su frontmatter. Así que un post con locale: en que termina sirviéndose desde la ruta equivocada renderiza <html lang="es"> — contenido en inglés etiquetado como español ante cualquier buscador o lector de pantalla. Ya envié ese bug exacto. Es invisible a menos que revises la etiqueta ya compilada:

grep -oE '<html[^>]*lang="[^"]*"' dist/en/my-post/index.html
# → <html ... lang="en">   ✅   (cualquier otra cosa en un post en inglés es el bug)

La regla: verifica el atributo en el output, no la intención en el source. El frontmatter es lo que pediste; la etiqueta renderizada es lo que se publicó.

Check 2: que canonical y hreflang de verdad resuelvan

Un canonical o hreflang que apunta a una URL que da 404 es peor que no tener ninguno — puede desindexar la página o invalidar todo el cluster de idioma. Saco las etiquetas reales del archivo compilado, sin que me importe el orden de los atributos:

POST=dist/en/my-post/index.html
grep -oiE '<link [^>]*canonical[^>]*>' "$POST"    # ¿autorreferencia con la URL correcta?
grep -oiE '<link [^>]*alternate[^>]*>' "$POST"     # hreflang puesto — cada href debe dar 200

Para contenido que solo existe en un idioma, los alternates deben autorreferenciarse únicamente. Si un post que solo existe en inglés anunciara un alternate en español, ese alternate daría 404, y un cluster roto se ignora por completo. (Entro a fondo en por qué en el post de configuración de SEO en Astro — canonicals y hreflang son las dos cosas más fáciles de arruinar de forma sutil y silenciosa.)

Revisar archivo por archivo no escala, y los links muertos más traicioneros son los que introduces tú mismo enlazando entre posts — incluyendo un link a un post que todavía no está mergeado. Así que mantengo un script de ~40 líneas que recorre cada archivo HTML en dist/, resuelve cada <a href> interno y cada hreflang contra los archivos reales en disco, y reporta cualquier cosa que no resuelva:

// check-links.mjs — recorre dist/, marca links internos + hreflang que no resuelven a un archivo
import { readdirSync, readFileSync, existsSync, statSync } from 'fs';
import { join } from 'path';

const dist = process.argv[2] || 'dist';
const origin = 'https://heroweb.dev';

function walk(dir) {
  let out = [];
  for (const e of readdirSync(dir, { withFileTypes: true })) {
    const p = join(dir, e.name);
    if (e.isDirectory()) out = out.concat(walk(p));
    else if (e.name.endsWith('.html')) out.push(p);
  }
  return out;
}

// una URL "resuelve" si mapea a un archivo real: /foo → dist/foo/index.html | dist/foo.html
function resolves(path) {
  const rel = path.split(/[?#]/)[0].replace(/^\//, '').replace(/\/$/, '');
  if (rel === '') return existsSync(join(dist, 'index.html'));
  return [join(dist, rel, 'index.html'), join(dist, rel + '.html'), join(dist, rel)]
    .some((c) => existsSync(c) && statSync(c).isFile());
}

const dead = new Set();
for (const f of walk(dist)) {
  const html = readFileSync(f, 'utf8');
  const page = f.replace(dist + '/', '');
  for (const m of html.matchAll(/<a[^>]*href="([^"]+)"/g)) {
    let h = m[1].startsWith(origin) ? m[1].slice(origin.length) || '/' : m[1];
    if (!h.startsWith('/') || h.startsWith('//')) continue;   // salta externos
    if (h.split(/[?#]/)[0] === '') continue;                  // salta fragmentos # puros
    if (!resolves(h)) dead.add(`${h}   (link on ${page})`);
  }
}
console.log(dead.size ? [...dead].join('\n') : 'NONE');
node check-links.mjs
# --- dead <a> links --- NONE   ✅

Sin dependencias, sin librería de crawler, sin levantar un servidor — solo lee archivos. Corre en una fracción de segundo y ya me ha atrapado links muertos reales, incluyendo links entre posts donde el destino vivía en una rama que todavía no había mergeado. (La versión completa que uso también junta los hrefs de hreflang y cada destino /en/* que emite la navegación, para revisar de un vistazo el ruteo de idiomas.)

Los widgets de “posts relacionados” y “últimos posts” jalan de toda tu colección a menos que los filtres. En un sitio bilingüe eso significa que un post en inglés puede aparecer como tarjeta “relacionada” en una página en español — un link válido, así que el crawler no lo va a marcar, pero está mal para el lector y para las señales de idioma. Reviso el output del widget directamente:

# en una página en español, debe haber cero links a /en/<post> desde las tarjetas de posts
grep -oiE 'href="/en/[a-z0-9-]+"' dist/some-spanish-post/index.html

El único link a /en/ que le corresponde a una página en español es el selector de idioma. Cualquier otro es una fuga.

Check 5: datos estructurados que de verdad parseen

Emito JSON-LD de BlogPosting en cada post — y un bloque de JSON-LD malformado es peor que no tener ninguno, porque puede suprimir el rich result por completo. Así que parseo cada bloque ld+json del build y reviso los campos requeridos, en vez de confiar en que “se veía bien”:

// saca cada <script type="application/ld+json"> y hazle JSON.parse; marca lo inválido o incompleto
const re = /<script type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
let m;
while ((m = re.exec(html))) {
  try {
    const o = JSON.parse(m[1]);                          // truena si el JSON está malformado
    if (o['@type'] === 'BlogPosting') {
      for (const req of ['headline', 'datePublished', 'author', 'image', 'mainEntityOfPage'])
        if (!o[req]) console.log(`⚠ BlogPosting missing ${req}`);
    }
  } catch (e) {
    console.log(`✗ invalid JSON-LD: ${e.message}`);
  }
}

Después, una sola vez, pego una URL en vivo en el Rich Results Test de Google para la confirmación visual. El parseo local atrapa los fallos de “ni siquiera parsea” antes de que lleguen a publicarse.

Check 6: lo básico de accesibilidad — un solo h1, alt en cada imagen, un atributo lang

Las ganancias baratas de a11y también son señales de SEO, y es fácil regresarlas con un cambio de theme. Un solo recorrido de dist/ cubre las tres que más muerden:

for (const f of walk('dist')) {
  const html = readFileSync(f, 'utf8');
  const h1 = (html.match(/<h1[\s>]/gi) || []).length;
  if (h1 !== 1) console.log(`h1=${h1}  ${f}`);                  // se quiere exactamente uno
  for (const img of html.match(/<img\b[^>]*>/gi) || [])
    if (!/\salt=/i.test(img)) console.log(`img without alt  ${f}`);
  if (!/<html[^>]*\slang=/i.test(html)) console.log(`no lang  ${f}`);
}

Correr esto es como encontré un post con dos etiquetas <h1> (el Markdown abría con # Título, duplicando el título que el template ya renderizaba) y otro con una imagen puesta como ![](…) — un alt vacío. Ambos invisibles en el source, ambos un fix de una línea en cuanto la auditoría los señala.

Bonus: reproduce errores de build crípticos antes de confiar en ellos

Un hábito adyacente al mismo instinto. Cuando un build avienta algo críptico — como el error de chunkToString que me tocó migrando este sitio a Astro 6 — lo reproduzco en un git worktree desechable (un checkout aislado que comparte el repo pero no el working tree) antes de actuar sobre él. Aísla el cambio, confirma causa y efecto, y me deja capturar el texto exacto del error en vez de una paráfrasis. Mismo principio que todo lo de arriba: verifica contra la realidad, no confíes en la historia que traes en la cabeza.

El checklist, en un solo lugar

Corre después de bun run build, antes de mergear:

  • El lang renderizado coincide con el idioma del post — grep al <html lang> ya compilado, no confíes en el frontmatter
  • Canonical se autorreferencia con la URL correcta (sin typo hardcodeado apuntando a un 404)
  • hreflang — todos los alternates resuelven; contenido de un solo idioma se autorreferencia únicamente
  • Sin links internos muertos — corre el crawler de dist/
  • Sin fugas entre idiomas en los widgets de “relacionados” / “últimos”
  • JSON-LD parsea y tiene sus campos requeridos
  • a11y — exactamente un <h1>, alt en cada <img>, lang presente
  • (bonus) reproduce cualquier error críptico de build en un git worktree desechable

Esto es CI para un blog estático de una sola persona

Nada de esto es un framework de testing. No hay fixtures, no hay test runner, no hay mocking — es grep y un script pequeño de Node que lee archivos, corrido después de bun run build y antes del merge. Tiempo total: menos de un minuto. Para un blog de una sola persona que despliega directo a producción, ese minuto es tu CI, y está apuntando exactamente a donde está el riesgo: no a tu redacción, que ya releíste, sino al HTML generado que nunca ves.

El hilo conductor con el resto de lo que he escrito sobre este stack — el analytics que murió en silencio, el salto de versión que rompió el theme vendorizado — es que los bugs caros son los que no avientan ningún error. Publicar sin mirar el build es confiar en que cada capa entre tu Markdown y el CDN hizo exactamente lo que asumiste. Sesenta segundos de grep te dicen si lo hizo.

¿Te sirvió este artículo?

Recibe nuevos artículos sobre desarrollo web, IA y SEO directo en tu correo. Sin spam, cancela cuando quieras.

Sin spam. Al suscribirte aceptas la política de privacidad .

Volver al blog

Related Posts

View All Posts »
Cómo dejo a mi agente de IA programando de madrugada — y qué se publica de verdad

Cómo dejo a mi agente de IA programando de madrugada — y qué se publica de verdad

La versión honesta del desarrollo con IA de madrugada: el agente abre pull requests, un humano hace el merge. Lo que amanece listo son borradores para revisar y correcciones que están listas cuando una verificación se pone en verde — jamás un push automático a producción. Este es el montaje que lo convierte en apalancamiento real y no en un riesgo.