# QA técnico de un sitio Astro + Sanity con Claude Code: 22 hallazgos, build 4× más rápido y Bunny Stream blindado

> **Title tag (SEO, 53 car.):** QA de un sitio Astro + Sanity con Claude Code | Menta

**El build daba verde.** Terminamos de migrar mentaexperts.com de Webflow a **Astro + Sanity**. 216 páginas, bilingüe, todo estático. `astro check` limpio, `astro build` limpio, el sitio se veía bien.

Auditamos el código igual, con **Claude Code**, en siete ejes: bugs, accesibilidad, rendimiento, SEO, seguridad, duplicación y deuda técnica. Aparecieron 22 problemas que ninguna herramienta automática marcaba. El build pasó de 6m36s a 1m38s, y de paso descubrimos que los videos de **Bunny Stream** estaban abiertos a que cualquiera consumiera ancho de banda facturado.

Esto es lo que encontramos, cómo lo encontramos, y por qué un build verde no significa nada.

---

## Por qué auditar algo que ya funciona

El sitio estaba terminado. Cero errores de tipos, cero warnings, build exitoso, las páginas se veían idénticas al diseño. Por cualquier criterio automático, listo para producción.

Decidimos auditarlo de todas formas, entero: bugs, accesibilidad, rendimiento, SEO, seguridad, duplicación. No una pasada rápida, sino revisar cada archivo y —esto resultó ser lo importante— **medir el output real en vez de confiar en que el código dice lo correcto**.

Aparecieron 22 problemas. Ninguno lo detectaba el compilador. La mayoría llevaba semanas en el repositorio.

Vale la pena entender por qué. TypeScript verifica que los tipos encajen. El build verifica que el código compile. **Ninguno de los dos verifica que el resultado sea correcto.** Un `href` puede apuntar a una ruta que no existe y compilar perfecto. Una plantilla puede emitir siete `<h1>` y ser HTML válido. Un archivo puede pedir la misma información mil veces y terminar sin errores.

Esa distinción —código válido contra resultado correcto— es la que atraviesa todo lo que sigue.

---

## 1. Lo que un build verde no ve

### El favicon que daba 404 en las 215 páginas

El layout base tenía esto:

```html
<link rel="icon" href="/images/favicon_menta.png" />
```

El archivo se llama `favicon.png`. Sin el `_menta`.

Es un string. Astro no lo valida, TypeScript no lo mira, el build ni se entera. El sitio no tuvo favicon durante toda la migración y nadie lo notó, porque en desarrollo el navegador muestra un ícono genérico y uno deja de verlo.

Lo encontramos con un script de veinte líneas: recorrer los HTML generados, extraer cada `href` y `src` que empiece con `/`, y comprobar contra los archivos reales del build. **Un destino roto en todo el sitio.** Ese.

De paso apareció que `webclip.png` existía sin que nadie lo referenciara: faltaba el `apple-touch-icon`.

### Hasta siete `<h1>` en un mismo artículo

Los 45 artículos del blog venían migrados desde el rich text de Webflow. El renderizador de Portable Text tenía definidos `h2`, `h3`, `blockquote` y `normal`. Nada más.

¿Qué pasa con los `h1` que traía el contenido? Caen al renderizador por defecto y salen como `<h1>` crudos —sin clase del design system— **encima** del `<h1>` del título que ya pone la plantilla.

El peor caso:

```
/blog/book-summary-the-psychology-of-selling/  → 7 <h1>

  H1: Book summary The Psychology of Selling   ← duplica el título
  H1: 1. Sales Motivation
  H1: 2. General Sales Tips and Notes
  ... y cuatro más
```

Doce artículos afectados, por dos idiomas. Contando el HTML generado: **18 `<h1>` sin clase, 17 `<h4>` y 2 tablas** que caían al renderizador por defecto y salían con la tipografía default del navegador.

La solución no fue borrarlos sino degradarlos a `h2`: quedan al mismo nivel que el resto de las secciones, entran en la tabla de contenidos, y el título vuelve a ser el único `h1` de la página.

### 92 de 214 URLs del sitemap sin hreflang

Esta es la más cara de las tres, y la más instructiva.

El sitio es bilingüe con slugs propios por idioma: `/services/brand-design` en inglés, `/es/servicios/diseno-de-marca` en español. No es un prefijo, es otro slug. Así que el `hreflang` del sitemap hay que armarlo a mano trayendo los pares reales desde el CMS.

Ese mapa vivía en `astro.config.mjs` y tenía un comentario propio que decía:

```js
// Debe reflejar STATIC_PATHS_ES de apps/web/src/lib/sanity.ts
```

Se desincronizaron igual. Cuando se agregó el blog, entró en `sanity.ts` y no en el config. Resultado: los 45 artículos en inglés, los 45 en español y los dos índices salían del sitemap sin par de idioma. **92 de 214 URLs declarando que no tienen versión alternativa.**

La lección no es "pon un comentario". El comentario estaba. La lección es que **un comentario no es un mecanismo**: si dos lugares tienen que coincidir, o comparten la fuente o se van a separar.

---

## 2. Seis minutos y medio de build, con el CPU al 2%

El build tardaba **6 minutos 36 segundos** para 216 páginas. Casi dos segundos por página, en un sitio estático sin imágenes procesadas en build.

El dato que cierra el diagnóstico es el otro:

```
pnpm build   9.28s user  1.44s system  2% cpu  6:38.74 total
```

**2% de CPU.** No estaba compilando: estaba esperando.

El shell del sitio —layout, navbar, footer, sección CTA— pedía los mismos tres documentos en cada página:

| Origen | Consultas por página |
|---|---|
| BaseLayout | `getSiteSettings` |
| Navbar | `getServices` + `getIndustries` |
| Footer | `getServices` + `getIndustries` + `getSiteSettings` |
| CtaSection | `getSiteSettings` |

Seis a siete consultas idénticas × 216 páginas ≈ **1.400 viajes al CMS devolviendo exactamente lo mismo**. Y como el cliente estaba con `useCdn: false` —correcto, para no servir contenido viejo recién publicado— cada una salía a la red de verdad.

El arreglo son once líneas:

```ts
function once<T>(fn: () => Promise<T>): () => Promise<T> {
  let cached: Promise<T> | undefined;
  return () => (cached ??= fn());
}

export const getServices = once(
  (): Promise<Service[]> => sanity.fetch(`*[_type == "service"] | order(order asc){...}`),
);
```

Se cachea **la promesa**, no el resultado: si dos páginas se renderizan en paralelo comparten el request en vuelo en vez de disparar dos. El caché vive en el módulo, así que dura lo que dura el proceso —cada build arranca limpio y `useCdn: false` sigue garantizando contenido fresco.

Aplicado a las 22 consultas sin parámetros:

```
antes:  6m 36s   (~1.85s por página)
después: 1m 38s   (3-5ms por página)
```

**Cuatro veces más rápido.** El tiempo de renderizado por página cayó de 1.850 ms a 4 ms. No cambió una sola línea de markup.

---

## 3. El `<div>` que parecía un botón

Los acordeones de FAQ del sitio se veían y se comportaban como acordeones. Hacías clic, se abrían.

El markup era este:

```html
<div class="faq_question">
  <div class="text-size-medium">¿Cuánto cuesta un proyecto web?</div>
  ...
</div>
```

Sin `role`. Sin `tabindex`. Sin `aria-expanded`. Sin manejo de teclado.

Con mouse funciona. Con teclado no existe: el `Tab` pasa de largo, `Enter` no hace nada. Para un lector de pantalla es un `<div>` con texto adentro; no anuncia que es interactivo ni si está abierto o cerrado.

Estaba en **ocho páginas**, incluida la home.

Lo interesante es que el sitio ya tenía el patrón correcto implementado. El megamenú usaba `role="button"`, `tabindex="0"`, manejo de Enter y Espacio, `Escape` para cerrar y gestión de foco. Alguien lo hizo bien una vez y no se replicó.

Como el markup del acordeón estaba duplicado en ocho archivos y el comportamiento vivía completo en un solo `site.js`, aplicamos la mejora ahí:

```js
function makeDisclosure(trigger, panel, onActivate) {
  trigger.setAttribute("role", "button");
  trigger.setAttribute("tabindex", "0");
  trigger.setAttribute("aria-expanded", "false");
  if (panel) {
    if (!panel.id) panel.id = "disclosure-panel-" + ++SEQ;
    trigger.setAttribute("aria-controls", panel.id);
  }
  trigger.addEventListener("keydown", function (e) {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      onActivate();
    }
  });
}
```

Un lugar, ocho páginas arregladas, cero riesgo de que se escape un archivo. Más un anillo de `:focus-visible`, porque el outline por defecto del navegador es invisible sobre fondo oscuro.

---

## 4. Variables de CSS que no existían

El selector de idioma tenía esto:

```css
.lang-toggle_link {
  color: var(--text-color--gray, #9c9c9c);
}
.lang-toggle_link.is-active {
  color: var(--color--menta, #3bbfad);
}
```

Se ve como código que usa el design system. No lo es. **Ninguna de las dos variables está definida en ningún CSS del proyecto.** Siempre gana el fallback: son colores hardcodeados con disfraz.

Y el disfraz es el problema. Un `#3bbfad` suelto se ve raro en una revisión y alguien pregunta. Un `var(--color--menta, #3bbfad)` parece correcto y nadie lo mira dos veces. El día que cambie el verde de marca, ese punto no se entera.

Lo detectamos comparando conjuntos: extraer todas las variables *usadas* en el CSS y en los componentes, extraer todas las *definidas*, y restar. Dos huérfanas.

En el mismo barrido apareció que el verde de marca estaba escrito literal en cinco reglas del CSS propio, teniendo el token `--_primitives---colors--menta` con exactamente ese valor.

Y algo peor: **125 `<div>` de estrellas decorativas copiados a mano en once archivos**, cada uno con su `rgba()` y sus píxeles inline:

```html
<div class="menta-star" style="top:3%;left:2%;width:2px;height:2px;background:rgba(255,255,255,0.6);"></div>
```

Doce estrellas únicas, repetidas diez veces. Cambiar la densidad significaba editar once archivos. Ahora es un componente y el tamaño y el color los resuelve el CSS.

---

## 5. 8.422 líneas para 13 páginas

El hallazgo estructural. El sitio es bilingüe y cada página existía **dos veces como archivo**: `index.astro` y `es/index.astro`, `about-us.astro` y `es/sobre-nosotros.astro`, y así.

No eran dos archivos distintos. Era el mismo archivo copiado con las palabras cambiadas:

| Página | Líneas | Difieren | Idéntico |
|---|---|---|---|
| `index.astro` | 659 | 85 | **87%** |
| `portfolio.astro` | 223 | 26 | **88%** |
| `about-us.astro` | 436 | 65 | 85% |
| `industries/[slug].astro` | 487 | 98 | **80%** |

Trece pares: **8.422 líneas repartidas en 26 archivos**, de las cuales alrededor de 3.400 eran duplicación pura.

El costo no es el espacio en disco. Es que **todo arreglo visual hay que hacerlo dos veces**, y si te olvidas de una el otro idioma queda mal sin que nada avise. Ya nos había pasado: el bug de los 92 hreflang fue exactamente eso.

La solución es la obvia —un componente compartido que recibe `locale`, dos rutas de ocho líneas— pero aplicarla a 26 archivos a mano es justo el tipo de tarea donde se cuelan errores silenciosos. Así que escribimos una herramienta que alinea los dos archivos línea a línea, extrae los nodos de texto que cambian a un diccionario, y emite el markup único.

Y ahí empieza la parte interesante.

---

## 6. Cómo verificar un refactor que toca 8.000 líneas

Esta es la sección que más nos sirvió a nosotros.

La herramienta funcionaba: `astro check` daba 0 errores, `astro build` generaba las 216 páginas. Por cualquier criterio automático, el refactor estaba bien.

No lo estaba.

El método que lo destapó es simple: **guardar el `dist/` antes del refactor, rebuildear, y comparar el output**. No los archivos crudos —el formato cambia por motivos irrelevantes— sino lo que importa: el texto visible con los tags eliminados, y cada `href` y `src`. Normalizando de antemano las diferencias que sí son intencionales.

```bash
# para cada una de las 216 páginas
diff <(normalizar "$ANTES/$pagina") <(normalizar "$DESPUES/$pagina")
```

Si el contenido y los links salen idénticos, el refactor es correcto. Si no, hay algo que revisar. No es una heurística: es el resultado real.

Encontró cuatro bugs que ni los tipos ni el build veían.

### Bug 1: la página en español enlazaba a rutas en inglés

```
/es/portafolio → href="/portfolio-projects/solest-corporate-website-rebranding"
                       ↑ debería ser /es/proyectos-de-portafolio/...
```

El extractor comparaba nodos de texto. Un `href` dentro de una expresión JavaScript no es un nodo de texto, así que no lo veía —y como en esa misma línea sí había texto que cambiaba, tampoco saltaba ninguna alarma. Emitía la versión inglesa en silencio.

### Bug 2: un componente convertido en string

En la home, la línea del CTA final estaba escrita distinto en cada idioma: `<CtaSection />` contra `<CtaSection locale={locale} />`. La herramienta la trató como texto localizable y la guardó en el diccionario:

```js
t72: es ? "<CtaSection locale={locale} />" : "<CtaSection />",
```

Inyectado con `set:html`, el navegador ve un elemento desconocido y no renderiza nada. **La sección CTA desaparecía de la home**, sin error de compilación ni de consola.

### Bug 3: la meta description mostrando el párrafo del hero

Las claves del diccionario se generaban a partir del texto. Dos frases distintas de la página de contacto empezaban igual —"Schedule a consultation to…"— y produjeron la misma clave. La segunda pisó a la primera.

Resultado: el `<meta name="description">` de `/contact` y `/es/contacto` mostraba el párrafo del hero, con los `<br />` incluidos.

### Bug 4: nueve páginas con el frontmatter entero sin traducir

El peor de los cuatro. La herramienta alineaba los frontmatter comparando cantidad de líneas; si no coincidía, descartaba y se quedaba con el inglés.

Los archivos en inglés tenían comentarios explicativos largos que los de español no repetían. **Nueve de las diez páginas no coincidían.** El diccionario completo de textos quedaba en inglés.

El arreglo fue alinear ignorando comentarios y líneas en blanco. Pero lo relevante es cómo apareció: no lo vio el compilador, lo vio la comparación del HTML generado.

### Fallar ruidosamente

Cada bug se arregló dos veces: el caso puntual, y la herramienta para que ese modo de fallo no pudiera repetirse en silencio.

```python
# Verificación: tras sustituir los textos capturados, lo que queda de la
# línea (el "esqueleto") tiene que ser idéntico en los dos idiomas. Si no,
# algo difiere y NO se capturó.
if esqueleto_en != esqueleto_es:
    sys.exit("ERROR: queda una diferencia sin capturar "
             "(la página ES saldría con el valor EN)")
```

Después de esos controles, la herramienta empezó a abortar en casos que antes pasaba por alto. Cada aborto era un bug que no se publicó.

**Resultado final: 0 de 216 páginas con diferencias de contenido o de links.** Las únicas diferencias que quedaron son dos, ambas buscadas: los apóstrofos ahora salen como `&#39;` (al venir de una variable, Astro los escapa; se renderiza idéntico) y la resolución de los videos del grid.

De paso, la comparación destapó dos bugs que **ya existían antes del refactor** y que nadie había visto: el mapa del sitio en español enlazaba a `/es/what-we-do` y `/es/industries` —rutas que no existen— y la meta description de contacto ya estaba mal.

---

## 7. Lo que decidimos no hacer

Dos hallazgos quedaron abiertos a propósito. Los dos son de seguridad, y en los dos hacerlo mal es peor que no hacerlo.

**La CSP quedó en `Report-Only`.** Una Content-Security-Policy es una lista blanca de orígenes permitidos: la mejor defensa contra inyección de código. Nuestro sitio carga del CMS, del CDN de video, de embeds de YouTube y Vimeo, y del servicio de formularios. Si escribimos la lista de memoria y falta uno, **ese recurso deja de cargar sin ningún error visible**: la imagen no aparece, el formulario no envía, y te enteras cuando un cliente no puede contactarte.

En `Report-Only` el navegador no bloquea nada, solo anota lo que habría bloqueado. Dos semanas con tráfico real, se revisa el reporte, y recién ahí se activa con la lista validada contra lo que el sitio realmente usa.

**El HSTS quedó comentado.** Fuerza HTTPS en el dominio durante todo el `max-age`. Una vez que un navegador cachea ese header, **no hay forma de revertirlo desde el servidor**: hay que esperar a que expire. Si se activa antes de que el dominio definitivo esté sirviendo HTTPS de forma estable, cualquier subdominio sin certificado queda inaccesible por un año.

Las dos entraron al checklist de lanzamiento con el motivo escrito. Un pendiente documentado y con fecha es distinto de un pendiente olvidado.

---

## 8. Los videos estaban abiertos a cualquiera

El portfolio usa **Bunny Stream** para los videos de fondo. La auditoría de seguridad empezó por lo obvio —cabeceras, secretos en el repo, escapado de contenido del CMS— y terminó en algo que no habíamos pensado.

Un `curl` pelado contra la URL del MP4:

```bash
curl -sI "https://vz-xxxxxxxx-xxx.b-cdn.net/<guid>/play_720p.mp4"

HTTP/2 200
content-type: video/mp4
content-length: 3351317
```

Sin navegador, sin `Referer`, sin token. **200 y 3,3 MB.** Y todas las resoluciones abiertas, hasta 1080p a 6,9 MB.

Bunny factura por ancho de banda entregado. Cada descarga se paga, venga de donde venga.

### La CSP no protege esto

Vale aclararlo porque es un error común: una Content-Security-Policy es una instrucción que el navegador aplica **dentro de tus páginas**. Controla qué puede cargar tu sitio. No tiene forma de impedir que alguien pegue la URL del video en otro lado ni que un script la descargue en bucle. El bot ni pasa por tu web.

Lo que sí protege es la **hotlink protection** del Pull Zone: una lista blanca de hostnames que se compara contra el `Referer` de cada request, más el bloqueo de acceso directo para las peticiones que llegan sin `Referer` —exactamente como pide un bot.

### Dos detalles que cuestan una tarde

**Bunny compara el hostname de forma exacta, con el puerto incluido.** Lo medimos:

| `Referer` enviado | Respuesta |
|---|---|
| *(ninguno)* | **403** |
| `http://localhost:4300/` | **403** |
| `http://local.mentaexperts.com:4300/` | **403** |
| `http://local.mentaexperts.com/` | **200** |
| `https://mentaexperts.com/` | **200** |

Dos cosas ahí. Primera: **`localhost` no sirve como referrer** —Bunny lo rechaza por no ser un dominio válido—, así que activar la protección rompe los videos en desarrollo. Segunda: `local.mentaexperts.com` y `local.mentaexperts.com:4300` son entradas **distintas**; hay que agregar la que realmente manda el navegador.

La solución para desarrollo es apuntar un hostname real a `127.0.0.1` en `/etc/hosts`, agregarlo a la lista blanca, y decirle a Vite que lo acepte:

```js
// astro.config.mjs
server: { port: 4300, strictPort: true, host: true },
vite: {
  server: { allowedHosts: ["local.mentaexperts.com"] },
},
```

### El techo de gasto no está donde parece

El auto-recharge de la facturación **no es un límite**: recarga saldo cuando baja de cierto punto, que es lo contrario de lo que necesitas. El tope real es el **monthly bandwidth limit** del Pull Zone: cuando se alcanza, Bunny desactiva la zona y deja de cobrar.

Lo calculamos contra la tarifa más cara de nuestros mercados (Sudamérica, $0,045/GB) para que el techo se cumpla en el peor caso, y lo complementamos con límites por IP —que frenan a un abusador sin apagar el sitio para todos.

### Y de paso, la mitad del peso

Los previews del grid se veían dentro de cards de ~600 px y se servían en 720p. Bajarlos a 480p es indistinguible a ese tamaño y pesa la mitad. Los heroes de las páginas de detalle se quedaron en 720p: ahí sí se ven a ancho completo.

Un dato que apareció al medir y que conviene tener presente: el CDN manda `cache-control: max-age=2592000` —30 días—, así que el desarrollo local descarga cada video una vez al mes, no una vez por recarga. Recorrer el catálogo completo de 14 videos son 98 MB, menos de un centavo. **No hacía falta optimizar nada para desarrollo**, y medirlo evitó una complicación innecesaria.

---

## Los números

| | Antes | Después |
|---|---|---|
| Tiempo de build | 6m 36s | **1m 38s** |
| Renderizado por página | ~1.850 ms | **4 ms** |
| Consultas al CMS por build | ~1.400 | **22** |
| Imágenes con dimensiones declaradas | 0 | **19.614 de 19.615** |
| Peso de `public/images` | 7,9 MB | **3,2 MB** |
| Títulos que Google truncaba | 80 | **0** |
| Descripciones truncadas | 85 | **0** |
| `<h1>` por artículo del blog | hasta 7 | **1** |
| URLs del sitemap sin hreflang | 92 | **0** |
| Links internos rotos | 1 | **0** |
| Líneas en las 13 páginas duplicadas | 8.422 | **4.976** |

Aparte: los 7 JPG que quedaban pasaron a AVIF a la misma resolución (2,49 MB → 0,47 MB, −81%), se borraron 48 imágenes que ningún archivo referenciaba, y GSAP y SplitType salieron de tres CDN distintas —con GSAP corriendo el core en 3.15.0 y su plugin ScrollTrigger en 3.14.2, algo que la propia librería no soporta— a versiones fijadas en `package.json`.

---

## Lo que nos llevamos

**Un build verde dice que el código compila, no que el resultado sea correcto.** Los tres bugs de la primera sección —favicon, `<h1>`, hreflang— eran todos código perfectamente válido produciendo output equivocado. Ninguna herramienta de tipos los iba a encontrar.

**Mide el output, no leas el código.** La comparación del HTML generado encontró en minutos cuatro bugs que revisar los archivos a mano no había encontrado en horas. Si un refactor no debería cambiar el resultado, compruébalo: guarda el build, vuelve a compilar, compara.

**Un comentario no es un mecanismo.** El mapa de rutas duplicado tenía escrito "esto debe reflejar el otro archivo". Se separaron igual. Si dos lugares tienen que coincidir, o comparten la fuente o es cuestión de tiempo.

**Cuando algo falla, arregla también el modo de fallo.** Cada bug de la herramienta se corrigió dos veces: el caso puntual y la validación que impide que vuelva a pasar en silencio. Esa segunda parte es la que encontró los tres bugs siguientes.

**Fallar ruidosamente le gana a funcionar callado.** La versión de la herramienta que emitía inglés sin avisar era más "robusta" en apariencia: nunca se rompía. Era exactamente el problema.

**Lo que no mides, lo supones mal en las dos direcciones.** Creímos que el desarrollo local nos estaba costando plata en Bunny: eran centavos. Creímos que el sitio estaba razonablemente rápido: el build desperdiciaba cinco minutos esperando la misma respuesta mil cuatrocientas veces.

---

*Este artículo documenta la auditoría de mentaexperts.com, un sitio de 216 páginas en Astro + Sanity migrado desde Webflow, hecha con Claude Code en julio de 2026.*
