Full text · 85 KB · 11 files

Web Audit Kit, in one block

The whole documentation as plain text — an executable site auditor: around 30 checks over what a page validator never looks at. Read it here or paste it into an AI assistant. Nothing to download: no archive from a site you do not know yet.

Run it: the repository on GitHub

This page is generated from the same files the repository ships, so it cannot drift out of sync. Use GitHub when you want to run it; use this when you want to read it or hand it to a model.

# Web Audit Kit
# https://climentmedia.com/agents/web-audit-kit/copy/
# Source: https://github.com/Manucliment/web-audit-kit
# Everything below is the full documentation, in one block, so it can be
# read or pasted into an AI assistant without downloading anything.

================================================================
FILE: README.md
================================================================

# Web Audit Kit

Un **auditor ejecutable** y el **checklist** que lo acompaña, para construir, revisar y
escalar sitios web estáticos sin reinventar la rueda cada vez.

No es un lint de HTML ni un medidor de rendimiento — de eso ya hay. Es lo que **falta**
entre un validador de páginas y una auditoría de SEO: **la comprobación del sitio como
sistema**.

## El problema que resuelve

Teníamos un estándar de página de 265 líneas y un gate de integridad de 5 comprobaciones.
Entre los dos no había nada que validara el sitio **completo**. Todo lo que se coló era
de sistema, no de página:

| Se coló | Por qué nadie lo vio |
|---|---|
| `llms.txt` con **11 URLs a 404 de 28** | No es HTML: un barrido de `href=` no lo cubre |
| `AGENTS.md` afirmando el posicionamiento **retirado meses antes** | Ninguna herramienta compara mensaje entre superficies |
| **Cero cabeceras `Cache-Control`** | Las auditorías de página no miran la entrega |
| Una página indexable **huérfana y a medio publicar** | Estaba bien formada. Solo no debía existir |
| **14 meta descriptions** por encima del corte, una de 276 caracteres | Ninguna estaba "mal", solo truncada |

**Ninguna página estaba mal por separado.**

Y la segunda mitad: un estándar es un documento que alguien tiene que **acordarse** de
leer. **Lo que no falla solo, no se cumple.**

## Qué hay aquí

```
CHECKLIST.md          El checklist completo, en dos mitades:
                        Parte A - lo que comprueba la maquina
                        Parte B - lo que solo puede juzgar una persona
audit.sh              El auditor. ~30 comprobaciones en 6 bloques
audit.conf.example    Toda la particularidad del sitio. El script no se toca
docs/
  01-estructura.md            Carriles por prioridad de negocio · PASO 0 · migraciones
  02-plantillas.md            Tipos de pagina y que lleva cada uno · el generador
  03-estilos.md               Tokens en dos capas · contraste · la libreria
  04-ficheros-maquina.md      sitemap · llms.txt · AGENTS.md · robots
  05-rendimiento-y-entrega.md Animaciones · cache · cabeceras · que NO hacer
  06-deploy-y-verificacion.md El gate · como mirar sin engañarse
```

## Uso

```bash
cp audit.sh audit.conf.example TU-PROYECTO/
cd TU-PROYECTO && mv audit.conf.example audit.conf
# editar audit.conf: URL base, marca, carpetas excluidas, rutas muertas
bash audit.sh            # local
bash audit.sh --live     # + entrega real en produccion
```

Verde = `EXIT 0`. Sin dependencias: bash, grep, sed, find y curl. Sin `jq`, sin Node, sin
Python.

## La salida

```
[ ok ]   pasa
[FAIL]   rompe el deploy
[warn]   mirar, no bloquea
[skip]   no se pudo comprobar Y POR QUE   <- nunca se salta en silencio
```

⚠️ **Y un guardia anti-vacío.** Si el barrido de páginas devuelve cero ficheros, el script
**aborta** en vez de reportar todo en verde. Lo lleva porque su primera versión tenía
justo ese fallo: media auditoría daba `[ ok ]` sin haber mirado un solo fichero.
**Iterar sobre una lista vacía no falla, "pasa"** — y es la forma más silenciosa de que
una auditoría mienta.

## La regla de mantenimiento

Cuando algo se rompa y **no estuviera en el checklist**:

1. Arreglarlo.
2. **Añadir la comprobación al auditor** si una máquina puede detectarlo.
3. Si no, a la Parte B **con el caso real que la originó**.

Un checklist que no crece con los fallos se queda obsoleto en un mes.

---

Extraído del trabajo real sobre [climentmedia.com](https://climentmedia.com).
Cada línea marcada con ⚠️ existe porque algo se rompió — y casi todas, en silencio.


================================================================
FILE: CHECKLIST.md
================================================================

# CHECKLIST DE WEB Y SEO

Para **revisar y escalar cualquier proyecto web** con la estrategia que usamos en
climentmedia.com. No es teoría: cada línea existe porque algo se rompió, y casi todas
las que llevan ⚠️ se rompieron **en silencio**.

**Cómo se usa.** El checklist tiene dos mitades y hay que entender por qué:

| | Quién lo comprueba | Cuándo |
|---|---|---|
| **Parte A — mecánica** | `audit.sh`, **automático** | Antes de cada deploy. Falla o pasa |
| **Parte B — criterio** | Una persona (o un agente leyendo esto) | Antes de escribir cada página |

> **La lección que originó este documento:** teníamos un estándar de página de 265
> líneas y un gate de 5 comprobaciones. Entre los dos no había nada que validara el
> sitio **como sistema**, y todos los fallos que se colaron eran de sistema: un fichero
> `llms.txt` con 11 URLs a 404, un `AGENTS.md` afirmando un posicionamiento retirado,
> cero cabeceras de caché. **Ninguna página estaba mal por separado.**
>
> Y la segunda mitad de la lección: un documento hay que acordarse de leerlo. **Lo que
> no falla solo, no se cumple.** Por eso la parte A es un script.

---

# PARTE A — Lo que comprueba `audit.sh`

Ejecutar: `bash audit.sh` (local) · `bash audit.sh --live` (+ producción).
Verde = `EXIT 0`. **Sin excepciones silenciadas:** si algo no se puede comprobar, el
script escribe `[skip]` **con el motivo**, nunca lo omite.

## S0 · Guardia anti-vacío
- [ ] **El barrido encuentra páginas.** Si devuelve cero, el script aborta.
      ⚠️ Sin este guardia, un fallo en el listado convierte media auditoría en `[ ok ]`
      falsos: **iterar sobre una lista vacía no falla, "pasa"**. Ocurrió con la primera
      versión de este mismo auditor.

## S1 · Ficheros para máquinas
`sitemap.xml` · `llms.txt` · `AGENTS.md` · `robots.txt`
⚠️ **Se rompen sin avisar porque NO son HTML**: un `grep` de `href=` no los cubre. Por
eso sobrevivieron intactos a una reestructuración que sí arregló las 39 páginas.

- [ ] `sitemap.xml` bien formado.
- [ ] **Toda URL del sitemap existe.**
- [ ] **Toda página indexable está EN el sitemap.** La mitad que casi nadie comprueba:
      se vigila que el sitemap no mienta, no que esté completo.
- [ ] Ninguna URL del sitemap lleva `noindex` (se contradicen).
- [ ] **Toda URL de `llms.txt` y `AGENTS.md` resuelve a 200.**
- [ ] Esos ficheros son **texto plano**: cero entidades HTML sin decodificar.
      Un `&amp;` ahí se lee literal.
- [ ] `robots.txt` declara el sitemap con URL **absoluta**.
- [ ] **Ninguna superficie cita una ruta que ya solo existe como 301.**

## S2 · Meta por página
- [ ] `<title>` presente, único, y **con un término**, no solo la marca.
      ⚠️ Medido: un `<title>` de solo marca dio **87 impresiones y 0 clics**.
- [ ] `<meta description>` presente y **≤ 165 caracteres decodificados**.
      ⚠️ Google corta cerca de 155-160. Una de 276 perdía justo el gancho.
      Se mide **decodificado**: Google ve `&`, no `&amp;`.
- [ ] `<link rel="canonical">` en toda página indexable.
- [ ] Open Graph completo (`og:title`, `og:description`, `og:url`, `og:image`).
- [ ] **Exactamente un `<h1>`.**
- [ ] `<html lang>`.
- [ ] **Cero entidades HTML dentro del JSON-LD.** Dentro de un `<script>` el navegador
      no las decodifica: Google lee `don&rsquo;t` literal.

## S3 · Grafo de enlaces
- [ ] Cero enlaces internos rotos.
- [ ] **Cero huérfanas:** ninguna página indexable con 0 enlaces entrantes.

## S4 · Integridad y codificación
- [ ] Sin mojibake en lo desplegable.
- [ ] Etiquetas de sección equilibradas (canario de edición destructiva).

## S5 · CSS y rendimiento
- [ ] Toda página carga su hoja de estilos **y el prefijo resuelve**.
      ⚠️ Un prefijo mal deja la página **sin estilo, en silencio**.
- [ ] **Las animaciones solo tocan `transform` y `opacity`.**
- [ ] **Toda animación tiene su salida en `prefers-reduced-motion`.**

## S6 · Entrega (`--live`)
- [ ] Toda URL del sitemap responde 200 en producción.
- [ ] El HTML y los assets envían `Cache-Control`.
- [ ] Compresión activa y cabeceras de seguridad básicas.
- [ ] Los ficheros para máquinas, **verificados en vivo**.

---

# PARTE B — Lo que ninguna máquina puede juzgar

## B1 · PASO 0: ¿esta página debe existir?
**Antes de escribir una sola línea.** Si falla cualquiera de las tres, no se escribe:

1. **¿A qué carril pertenece?** Si no cabe en ninguno, o sobra o falta un carril.
2. **¿Qué justifica su existencia?** Depende del tipo — ver abajo.
3. **¿Quién la enlaza desde el cuerpo?** Mínimo dos. **El nav no cuenta.**

### La pregunta 2 tiene dos respuestas válidas, y confundirlas cuesta caro

| Tipo de página | Qué la justifica | Qué NO vale |
|---|---|---|
| **Compite en búsqueda** (guías, comparativas, landings de término) | **Volumen MEDIDO** del mercado real. No estimado, no intuido | "Creo que la gente busca esto" |
| **Catálogo de producto** (una ficha por cosa que existe de verdad) | Que **el producto exista y esté verificado**, y que la ficha esté enlazada desde su hub | Publicar ficha de algo que no existe todavía |

⚠️ **Añadido el 2-ago-2026, y salió de aplicarme la regla a mí mismo.** La primera versión
exigía volumen medido a **toda** página. Al ir a publicar una ficha de producto propio, el
PASO 0 la bloqueó — y bloquearla era incorrecto: una ficha de catálogo no compite por un
término, existe porque el producto existe. Con la regla mal escrita, la salida fácil habría
sido **inventarse un volumen** para pasar el filtro. Un checklist demasiado rígido no
produce disciplina: produce que la gente lo esquive.

> **Pero la ficha de producto no da barra libre:** sigue necesitando los enlaces entrantes
> (pregunta 3), el estado honesto, y **no puede reclamar un término que no ha medido** — su
> `<title>` describe lo que la cosa hace, sin fingir que ocupa un hueco de búsqueda.

> **Por qué existe este paso.** El contrato anterior definía **tipos de página**. Con
> seis tipos válidos, cada pieza encontraba dónde encajar y entraba — sin que nada
> obligara a preguntar *si la web la necesitaba*. Resultado auditado: 36 páginas en 7
> directorios hermanos, un home con 9 H2 y 4 CTAs, 9 landings con cero enlaces de
> cuerpo. **Un contrato que solo valida la FORMA acaba aprobando cualquier contenido
> bien formado.**

## B2 · Estructura: por prioridad de negocio, no por tipo de contenido
- [ ] Los carriles son **las prioridades del negocio**. Si P1 es el producto, `/producto/`
      es un carril; `/guias/` `/comparativas/` `/notas/` **no son tres carriles, son
      etiquetas de uno**.
- [ ] **Una sola taxonomía de contenido.** Cuatro carpetas hermanas con una página cada
      una no es una taxonomía, es un blog mal ordenado.
- [ ] **Nada de páginas para cosas que no existen.** Un producto "planned" no tiene
      landing; sale como línea en su hub.

## B3 · Keywords: medir, no intuir
- [ ] Volumen del **mercado real**, no el global. ⚠️ `agencia de marketing digital` son
      50.000 globales y **5.000** en el nuestro: decidir por el global es decidir sobre
      un mercado al que no vendemos.
- [ ] Mirar la **tendencia interanual**, no solo el volumen. Una pieza apuntaba a un
      término que caía **90%**.
- [ ] ⚠️ **Buscar en TODOS los ficheros de datos antes de decir que un término no
      existe.** Un cero de `grep` es un no-match, no una ausencia. Se declaró
      inexistente un término que sí estaba, en otro fichero, y casi se reescribe el plan.

## B4 · Honestidad (lo que más caro sale)
- [ ] **Estados reales.** Nada dice "Live" si no está verificado en runtime.
- [ ] **Cero métricas inventadas.** Si no se puede citar del sitio, no se afirma.
- [ ] **El posicionamiento vigente, en TODAS las superficies.** ⚠️ Al cambiar el eje del
      mensaje, las 30 páginas se actualizaron y `AGENTS.md` no: siguió ocho veces con el
      mensaje retirado, en el fichero que se presenta como *"la fuente de verdad"* para
      los motores de respuesta.
- [ ] Si el sitio declara "sin cookies ni analítica", **que siga siendo cierto** después
      de cada cambio.

## B5 · Contenido
- [ ] Una idea por página. Si el H1 y los H2 dicen cosas distintas, son dos páginas.
- [ ] La respuesta **arriba**, no al final.
- [ ] Escrito por quien hace el trabajo, con detalle que solo tiene quien lo hace.
- [ ] **Nada de formularios** si la política del sitio es contacto directo.

## B6 · Cómo mirar sin engañarse
⚠️ Todas estas ya produjeron un diagnóstico falso:

- [ ] **Mirar, no solo consultar.** Un chequeo del DOM puede pasar con la pantalla rota.
- [ ] **No leer colores de una captura.** Usar `getComputedStyle`.
- [ ] **Comprobar el ancho real antes de creerse una captura móvil.** Chrome headless en
      Windows clampa a ~500px: pides 375, compone a 504, y la imagen es un **recorte**.
- [ ] ⚠️ **`--virtual-time-budget` congela las animaciones cerca de t=0.** El hero sale
      en blanco aunque la página esté perfecta. Para ver el estado final:
      `--force-prefers-reduced-motion`.
- [ ] ⚠️ **Antes de creer que un cambio propio rompió algo, renderizar lo de ANTES con
      las mismas banderas.** Si sale igual de roto, el roto es el método.
- [ ] **Si un barrido dice que varias cosas están rotas a la vez, sospechar del barrido.**
      Un sistema que lleva semanas corriendo no se rompe en bloque; un patrón nuevo sí.
- [ ] **Post-deploy, cache-bust antes de creerse nada.**

## B7 · Deploy
- [ ] `audit.sh` en verde. **No se despliega en rojo.**
- [ ] ⚠️ **El CSS nuevo va en la MISMA tanda que la página que lo usa.**
- [ ] Estado limpio commiteado antes de cualquier edición programática.
- [ ] En servidor compartido: backup, validar sobre copia, y **comprobar que los vecinos
      siguen vivos** después de recargar.
- [ ] Dueño y permisos de los ficheros subidos, consistentes con el resto.

## B8 · Medición
- [ ] **Live ≠ rankeando.** Una reestructuración tarda semanas en consolidar.
- [ ] Ventana de **~60 días** antes de concluir si un ángulo funciona.
- [ ] Medir también si las **IA nos citan**, no solo el buscador.
- [ ] ⚠️ **Sin datos de Search Console no hay diagnóstico de SEO**, solo hipótesis. Decirlo
      en vez de rellenar con proxies.

---

## Cuando el checklist falle

Si algo se rompe y **no estaba en esta lista**, la tarea no es solo arreglarlo:

1. Arreglarlo.
2. **Añadir la comprobación a `audit.sh`** si una máquina puede detectarlo.
3. Si no, añadir la línea a la Parte B **con el caso real que la originó**.

Un checklist que no crece con los fallos se queda obsoleto en un mes.


================================================================
FILE: docs/00-pagina-de-referencia.md
================================================================

# 00 · La página de referencia

**Empieza por aquí.** El resto de documentos explican reglas; este enseña **una página
correcta entera**, anotada. Cuando dudes de cómo se hace algo, cópialo de aquí.

Todo lo de abajo está **sacado de una página real en producción**, no inventado para el
ejemplo: `climentmedia.com/ads-assistant/meta-ads/`.

---

## El `<head>` completo, línea a línea

```html
<!DOCTYPE html>
<html lang="en">                                    <!-- ✅ lang SIEMPRE -->
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <title>Meta Ads MCP: what it actually gets you, and the options</title>
```
> ✅ **El título lleva EL TÉRMINO primero** (`Meta Ads MCP`), luego la promesa. 58
> caracteres.
> ❌ `SEO Ops - Climent Media` — solo marca. Medido en un caso real: **87 impresiones,
> 0 clics**.

```html
  <meta name="description" content="A Meta Ads MCP lets an AI assistant talk to your ad account. Some only read; some can change campaigns and spend. What each option does, and how to pick.">
```
> ✅ **152 caracteres.** Define el término en la primera frase, mete la tensión en la
> segunda (*"some only read; some can change campaigns and spend"*) y cierra con la
> promesa. **Cabe entera en el resultado de búsqueda.**
> ❌ Una de 276 en la misma web perdía el gancho: se cortaba justo antes de la frase que
> convencía.
> **Se mide decodificado:** Google ve `&`, no `&amp;`.

```html
  <link rel="canonical" href="https://climentmedia.com/ads-assistant/meta-ads/">
  <meta property="og:type" content="website">
  <meta property="og:site_name" content="Climent Media">
  <meta property="og:title" content="Meta Ads MCP: what it actually gets you, and the options">
  <meta property="og:description" content="A Meta Ads MCP lets an AI assistant...">
  <meta property="og:url" content="https://climentmedia.com/ads-assistant/meta-ads/">
  <meta property="og:image" content="https://climentmedia.com/assets/og-assistant-climent-ads-assistant.jpg">
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:title" content="...">
  <meta name="twitter:description" content="...">
  <meta name="twitter:image" content="...">
```
> ✅ Canonical **absoluto** y coincidiendo con la URL real. Open Graph **completo**: sin
> `og:image` la página se comparte como un rectángulo gris.
> ⚠️ **Imagen propia por página.** Cuando todas comparten la genérica, once páginas
> distintas se ven idénticas en LinkedIn o Slack.

```html
  <link rel="stylesheet" href="../../styles.css?v=4e11e91ee6">
  <link rel="stylesheet" href="../../components.css?v=7ac47bafeb">
```
> ✅ **El prefijo corresponde a la profundidad.** Esta página está a dos niveles → `../../`.
> ⚠️ Un prefijo mal deja la página **sin estilo y sin fallar**. Si el acento sale en negro,
> es el prefijo.

```html
  <script type="application/ld+json">{"@context":"https://schema.org","@type":"TechArticle",
    "headline":"...","description":"...",
    "author":{"@type":"Person","name":"Manuel Climent","jobTitle":"Founder",
      "worksFor":{"@type":"Organization","name":"Climent Media"},
      "sameAs":["https://github.com/...","https://www.linkedin.com/in/..."]},
    "publisher":{"@type":"Organization","name":"Climent Media","url":"https://climentmedia.com/"},
    "datePublished":"2026-07-31","dateModified":"2026-07-31",
    "mainEntityOfPage":"https://climentmedia.com/ads-assistant/meta-ads/",
    "inLanguage":"en","about":["meta ads mcp","paid advertising","AI advertising tools"]}</script>
  <script type="application/ld+json">{"@type":"BreadcrumbList", ...}</script>
  <script type="application/ld+json">{"@type":"FAQPage", ...}</script>
```
> ✅ **Tres bloques, cada uno con su trabajo:** el tipo de la página, la miga de pan, y el
> FAQ. `author` con `sameAs` es lo que conecta el contenido con una persona real.
> ❌ **NO poner `Article` y `TechArticle` a la vez.** Uno solo.
> ⚠️ **Cero entidades HTML dentro.** Dentro de un `<script>` el navegador no las
> decodifica: Google leería `don&rsquo;t` literal.

---

## El cuerpo

```html
<article class="lp">
  <div class="lp-hero">
    <p class="lp-cat">Meta Ads</p>              <!-- eyebrow: donde estoy -->
    <h1>Meta Ads and your AI assistant</h1>     <!-- UNO, y solo uno -->
    <p class="lp-tagline">Everyone is searching for a Meta Ads MCP.
       Worth knowing what one actually does before you install it.</p>
  </div>

  <div class="lp-lead">                          <!-- LA CAPSULA -->
    <p>An MCP server is the plumbing that lets an AI assistant like Claude or
       ChatGPT talk to your Meta ad account. Some are read-only; others can also
       edit campaigns and spend money. The difference is not how clever the AI is,
       it is what the AI is allowed to do without asking you first.</p>
  </div>
```
> ✅ **La cápsula es lo más importante de la página.** Un párrafo, 2-4 frases, que
> **responde la pregunta entera sin necesitar contexto**. Es lo que una IA cita literal y
> lo que lee quien no va a bajar más.
> **Va arriba, no al final.** La respuesta primero.

```html
  <h2>What it actually gets you</h2>
  <ul class="tpl-list">...</ul>

  <h2>The options</h2>
  <div class="cmp-wrap"><table class="cmp">
    <thead><tr><th>Option</th><th>Can it write?</th><th>Best for</th></tr></thead>
    <tbody>
      <tr><td>Meta's official MCP</td><td>Yes</td><td>Teams that want the vendor path</td></tr>
      <tr><td>Ryze AI</td><td>Yes, plus an autonomous agent layer</td><td>Teams that want the agent to act on its own</td></tr>
      <tr><td>Climent Ads Assistant</td><td>Proposes, then waits for you</td><td>Client work, where being wrong is expensive</td></tr>
    </tbody>
  </table></div>
```
> ✅ **La tabla nombra a los competidores con lo que cada uno ES de verdad**, incluido lo
> que hacen mejor. Una comparativa donde ganas en todo no la cree nadie — y las IA
> citan la que parece honesta.
> ✅ `.cmp-wrap` con `overflow-x: auto`: **la tabla scrollea dentro de sí misma**, la
> página nunca scrollea en horizontal.

```html
  <div class="faq">
    <div class="faq-item">
      <p class="faq-q">Do I need an MCP server to use AI on my Meta ads?</p>
      <p class="faq-a">No. An MCP is one way to connect an assistant to your account...</p>
    </div>
  </div>
</article>
```
> ✅ **3-5 preguntas, las que se hacen de verdad**, y la primera palabra de la respuesta ya
> responde (*"No."*). Cuadra con el `FAQPage` del JSON-LD.

---

## Las dos familias de componentes

⚠️ **Corregido el 2-ago:** la primera versión de esta documentación decía que la librería
eran los `.tpl-*`. **Era inexacta**, y es justo el fallo contra el que avisa el capítulo de
estilos: describir un sistema que el código no tiene. Medido sobre las 39 páginas:

| Familia | Dónde | Para qué |
|---|---|---|
| **`.lp-*` · `.cmp` · `.offer` · `.faq`** | **31 páginas** | La maqueta de una **página hoja**: hero, cápsula, tabla comparativa, oferta, FAQ |
| **`.tpl-list`** (24) · **`.tpl-badge`** (14) · **`.tpl-grid`/`.tpl-card`** (5) · **`.tpl-steps`** (1) | transversal | Bloques **compartidos** que se usan dentro de cualquier tipo |

**La regla sigue igual:** una tarjeta, una rejilla, una lista. Lo que cambia es que hay
**dos capas** — la maqueta del tipo de página y los bloques compartidos — y conviene
saber cuál estás tocando.

---

## Cómo verificar esta página

```bash
bash audit.sh          # S2 completo cae sobre esta pagina
bash audit.sh --live   # + que responde 200 y con cache
```

Y lo que el auditor **no** puede juzgar, del checklist Parte B:
- ¿La cápsula responde la pregunta **sin contexto**?
- ¿La tabla es honesta con los competidores?
- ¿Hay **dos enlaces de cuerpo** apuntando aquí desde otras páginas?
- ¿El término tiene **volumen medido** en el mercado real?


================================================================
FILE: docs/01-estructura.md
================================================================

# 01 · Estructura del sitio

## La regla que lo decide todo

> **La web se organiza por PRIORIDAD DE NEGOCIO, no por tipo de contenido.**

Suena obvio y casi nadie lo hace, porque organizar por tipo es más cómodo: hay un sitio
evidente donde meter cada pieza. Ese es justamente el problema.

**Qué pasa si organizas por tipo.** Acabas con `/guias/`, `/comparativas/`, `/notas/`,
`/recursos/` como carpetas hermanas. Cada pieza nueva encuentra su hueco y entra. Nadie
pregunta nunca si la web necesitaba esa página. Auditado en un caso real: **36 páginas en
7 directorios hermanos**, un home con **9 H2 y 4 CTAs** compitiendo, **9 landings con cero
enlaces de cuerpo**, y dos de esas taxonomías con **una sola página cada una**.

El diagnóstico del dueño fue: *"no estamos creando una web, estás montando todo como un
blog"*. Tenía razón, y el fallo no era de ejecución: **era del contrato**.

## Cómo se definen los carriles

Un carril = una prioridad del negocio. Se decide preguntando *"¿qué tiene que pasar para
que esto funcione?"*, no *"¿qué contenido tengo?"*.

Ejemplo real (climentmedia.com):

| Carril | Prioridad | Por qué es un carril y no una etiqueta |
|---|---|---|
| `/ads-assistant/` | **P1** — el producto | Es lo que se vende. Sus 6 hijas son capacidades reales **y** términos cabecera a la vez |
| `/agents/` | **P2** — otros agentes | Otra línea de negocio, otro comprador |
| `/agency/` + `/es/` | **P3** — servicio | La gente busca "agencia", no el nombre del rol |
| `/learn/` | Contenido | **UNA** taxonomía. El tipo (guía / comparativa / nota) es una **etiqueta**, no una carpeta |
| `/roadmap/` | Confianza | Build-in-public: qué está verificado y qué solo afirmado |

**Las dos versiones de idioma NO son gemelas.** `/agency/` y `/es/` atacan keywords
distintas en mercados distintos: **no llevan `hreflang` entre ellas**. Marcarlas como
traducciones una de otra le dice a Google que elija una y descarte la otra.

## Reglas duras

1. **Si una página no cabe en ningún carril, o sobra o falta un carril.** No se crea una
   carpeta nueva "provisional": eso es exactamente cómo se llega a siete.
2. **Una sola taxonomía de contenido.** Si tienes cuatro carpetas hermanas con una página
   cada una, no tienes una taxonomía.
3. **Nada de páginas para cosas que no existen.** Un producto en estado "planned" **no
   tiene landing**: sale como una línea en el hub de su carril. Publicar una landing de
   algo inexistente es la forma más barata de perder credibilidad.
4. **El home enlaza a los cuatro carriles.** Es el enlace interno más fuerte que existe;
   un carril sin él nace cojo. Esto lo comprueba `audit.sh`.

## PASO 0 — antes de crear CUALQUIER página

Las tres preguntas. Si falla una, **no se escribe**:

1. **¿A qué carril pertenece?**
2. **¿Qué término ocupa, con volumen MEDIDO?** Del mercado real, no el global.
3. **¿Quién la enlaza desde el cuerpo?** Mínimo dos páginas. **El nav no cuenta**, porque
   el nav enlaza a todo y por tanto no señala nada.

> **Un contrato que solo valida la FORMA acaba aprobando cualquier contenido bien
> formado.** El PASO 0 es lo único que valida el FONDO.

## Cómo se cambia la estructura sin romperla

Cuando una ruta desaparece:

1. **Redirección 301 uno a uno**, a la página equivalente. Nada de mandar todo al home:
   una 301 al home le dice a Google que el contenido ya no existe.
2. **Un solo salto.** Nada de cadenas.
3. Las redirecciones van en **su propio fichero**, importado con una línea. Así se
   revierte quitando esa línea.
4. **Barrer TODAS las superficies**, no solo el HTML:
   - las páginas (`href`)
   - `sitemap.xml`, `llms.txt`, `AGENTS.md` ⚠️ **no son HTML: un grep de `href=` no los
     coge, y por eso sobreviven a la migración con URLs muertas dentro**
   - la documentación del proyecto y **las skills o rutinas automáticas, incluidos sus
     ficheros de `references/`** ⚠️ arreglar el índice y no lo que el índice manda leer
     deja instrucciones muertas vivas: una rutina que las sigue **no falla, crea páginas
     huérfanas en rutas 301, en silencio**
   - los READMEs de repos públicos que enlazan al sitio
5. **Verificar cada 301 con `curl`** después de recargar, y comprobar que los vecinos del
   servidor compartido siguen vivos.


================================================================
FILE: docs/02-plantillas.md
================================================================

# 02 · Plantillas por tipo de página

## La regla

> **Ninguna página se escribe a mano. Si algo no cabe en un tipo, se AMPLÍA el tipo.**

Es la regla que impide volver a "un blog". En el momento en que se improvisa una maqueta
"solo para esta página", el sistema deja de ser un sistema.

En climentmedia.com **34 de 39 páginas** salen de un generador. Las 5 que no, con motivo
escrito: el home (tipo único), las dos del lead magnet (flujo propio), el design system y
el 404.

## Los tipos

### `TplHub` — hub de carril
`/ads-assistant/` · `/agents/` · `/learn/` · `/roadmap/`

| Lleva | Por qué |
|---|---|
| H1 + entradilla | Qué es este carril |
| **Cápsula** (2-4 frases, párrafo único) | Lo que una IA puede citar entero sin recortar |
| Rejilla de tarjetas a las hijas | La señal interna del carril |
| JSON-LD `CollectionPage` + `ItemList` + `BreadcrumbList` | |

**NO lleva:** FAQ (va en las hojas), ni CTA de venta agresivo. Un hub orienta.

### `TplDoc` — página hoja
`/ads-assistant/<slug>/` · `/agents/<slug>/` · `/agency/` · `/es/`

| Lleva | Por qué |
|---|---|
| H1 con **el término**, no la marca | ⚠️ Un `<title>` de solo marca: 87 impresiones, **0 clics** |
| Cápsula arriba | La respuesta primero, no al final |
| Cuerpo con H2 reales | |
| Tabla de opciones cuando hay comparación | Incluidos los competidores, con lo que cada uno es de verdad |
| FAQ (3-5) | JSON-LD `FAQPage` |
| **Mínimo 2 enlaces de cuerpo** entrantes | PASO 0 |
| JSON-LD `TechArticle` o `Service` + `BreadcrumbList` | **Uno solo**, no los dos |

### Home
Tipo único. **Máximo 5 secciones.** Enlaza a los cuatro carriles. Una sola idea dominante
y **un CTA principal**. ⚠️ Nueve H2 y cuatro CTAs compitiendo fue el síntoma que disparó
la reconstrucción entera.

### Legales y páginas de sistema
`privacy` · `terms` · `data-deletion` · `404` · páginas de gracias
- `noindex, follow`, **fuera del sitemap**, y **coherentes entre sí**.
- ⚠️ Una página a medio publicar —con canonical y Open Graph completos pero fuera del
  sitemap y sin enlaces— es indexable por accidente. O entra en un carril, o `noindex`.

## Componentes: dos capas, una de cada

| Capa | Clases | Alcance |
|---|---|---|
| **Maqueta del tipo de página** | `.lp-*` · `.cmp` · `.offer` · `.faq` | 31 páginas hoja |
| **Bloques compartidos** | `.tpl-list` · `.tpl-badge` · `.tpl-grid` · `.tpl-card` · `.tpl-steps` | transversales |

**Una** tarjeta, **una** rejilla, **una** lista. Ver el ejemplo anotado en
[`00-pagina-de-referencia.md`](00-pagina-de-referencia.md).

⚠️ En un solo día llegaron a convivir `.platform-card`, `.ag-card` y `.feat-list` haciendo
lo mismo con tres nombres, creadas por la misma persona en la misma sesión — cada uno
parecía "un caso especial". **Ninguno lo era.** Si hace falta una variante, es un
**modificador** del componente existente, no un componente nuevo.

⚠️ **Y el CSS muerto se borra.** Al eliminar una sección del sitio quedaron **20 reglas
huérfanas** con cero usos. No falla nunca: solo pesa y describe una estructura que ya no
existe, confundiendo a quien lo lea después. El auditor lo detecta (S5.4).

## El generador

Un script, specs en JSON, cero dependencias en runtime.

```
_p1_spec.json        → carril P1: hub + hijas
_agency_spec.json    → carril P3 (EN y ES)
_assistants_spec.json→ los agentes
_brand_logos.json    → logos oficiales de plataforma
        ↓
   generador  →  34 páginas + sitemap.xml + llms.txt
```

**Reglas del generador, todas aprendidas a golpes:**

1. ⚠️ **Reconstruye `sitemap.xml` y `llms.txt` ENTEROS.** Nunca editarlos a mano: lo que
   añadas ahí **se borra en el siguiente regenerado**. Ya pasó: una página se añadió a
   mano al sitemap y desapareció.
2. ⚠️ **El contenido va en el spec, no en el HTML.** El HTML se reescribe entero.
3. ⚠️ **`llms.txt` y el sitemap tienen que salir del MISMO criterio.** Cuando divergieron,
   `llms.txt` publicó **11 URLs a 404** durante dos días — una forma de URL que no había
   existido nunca y que ninguna 301 cubría.
4. ⚠️ **Los prefijos de ruta por profundidad son un campo explícito**, y hay que pasar
   **todas** las hojas de estilo. Cuando una se dejó sin prefijo, las páginas nuevas
   salieron **sin estilo, en silencio**.
5. ⚠️ **Solo ASCII y entidades HTML en el fuente del generador** si el intérprete lee el
   fichero en la codificación del sistema. Un guion largo literal se convierte en basura.
6. **Los estados salen del spec y no pueden prometer más de lo verificado.**

## Verificación del generador

Después de tocarlo:

```bash
# 1. regenerar
# 2. si NO se esperaban cambios de contenido, el diff debe estar vacio:
git status --short
# 3. y siempre:
bash audit.sh
```

Un diff vacío tras regenerar **prueba que el cambio no alteró la salida** — es la forma
más barata de verificar una refactorización del generador.


================================================================
FILE: docs/03-estilos.md
================================================================

# 03 · Estilos, tokens y librería

## La arquitectura de tokens: dos capas

```
Capa 1 — paleta cruda, como TRIPLETE RGB     --accent-rgb: 255, 92, 0;
Capa 2 — alias semánticos                    --accent: rgb(var(--accent-rgb));
                                             --border: rgba(var(--white-rgb), 0.09);
```

**Por qué tripletes y no `#hex` o `rgb()` cerrado.** Porque permite componer la opacidad
**en el punto de uso**: `rgba(var(--accent-rgb), .35)`.

⚠️ **El fallo que esto evita, medido:** sin tripletes, cada variante translúcida es un
número mágico escrito a mano. Llegaron a existir **56**. Un cambio de marca los habría
dejado obsoletos **en silencio** — porque no fallan, solo quedan mal. Un color mal no
rompe el build.

Regla: **si un color aparece con alpha, el token es el triplete.** Nunca se escribe un
`rgba(255, 92, 0, .35)` literal.

## La regla de los dos naranjas

Existen dos, **no son intercambiables**, y cada uno tiene su trabajo escrito:

| Token | Valor | Para qué | Contraste sobre el lienzo |
|---|---|---|---|
| `--accent` | `#ff5c00` | Marca: superficies, botones, **texto display grande**, iconos | 6,5:1 — AA de sobra (grande solo pide 3:1) |
| `--accent-bright` | `#ff7a26` | Naranja como **texto pequeño**: eyebrows, labels, enlaces legales | **7,6:1 — AAA** |

El de marca da 6,5:1: pasa AA, pero **se cae de AAA justo donde el texto va a 12px, en
mayúsculas y con tracking** — que es exactamente donde se usa. De ahí los dos.

> **Generalizable:** cuando un color de marca no llega a AAA en texto pequeño, la solución
> no es bajar el estándar ni cambiar la marca: es **un segundo token con su trabajo
> definido**. Lo que no vale es tener dos naranjas y que nadie sepa cuál usar — eso no es
> un sistema, es una divergencia.

## Reglas de color

- [ ] **Todo color con alpha sale de un triplete.** Cero `rgba()` literales.
- [ ] **Todo token tiene su trabajo escrito al lado.** Un token sin uso definido acaba
      usándose para lo que no es.
- [ ] **Contraste verificado con `getComputedStyle`, nunca leído de una captura.**
      ⚠️ Un delta que parecía "verde" en una imagen resultó ser gris neutro.
- [ ] ⚠️ **Un fondo con degradado o alpha rompe los medidores de contraste automáticos**:
      calculan contra el color declarado, no contra lo que se ve. Ahí se mide a mano.

## Escalas

Radios, espaciado y tipografía **como escala completa, no valores sueltos**.

⚠️ **Trampa real:** la documentación mandaba usar tres tokens de radio que **no existían
en la hoja del sitio** — vivían solo en el bundle del design system, que el sitio no
carga. La documentación describía un sistema que el código no tenía. **Si un token está
documentado, tiene que existir donde se usa**, y eso se comprueba.

## La librería de componentes

**Una** tarjeta, **una** rejilla, **un** sistema de pasos:

| Componente | Para |
|---|---|
| `.tpl-grid` | Toda rejilla de tarjetas |
| `.tpl-card` | Toda tarjeta |
| `.tpl-steps` | Secuencias numeradas |
| `.tpl-list` | Listas con marca |
| `.tpl-badge` + `.st-*` | Estados |

⚠️ Llegaron a convivir `.platform-card`, `.ag-card` y `.feat-list` haciendo lo mismo con
tres nombres — creados por la misma persona en la misma sesión, sin mala fe: cada uno
parecía "un caso especial". **Ninguno lo era.**

**Antes de crear un componente:** ¿es de verdad un patrón nuevo, o un **modificador** de
uno que existe? La respuesta casi siempre es la segunda.

## Organización de las hojas

| Fichero | Qué lleva |
|---|---|
| `styles.css` | Tokens, base, nav, footer, animaciones, componentes de plantilla |
| `components.css` | Componentes de directorio y estado |

⚠️ **Las hojas compartidas viven en la RAÍZ.** `components.css` estuvo dentro de una
carpeta de sección y, al borrarse esa carpeta en una reestructuración, **el sitio entero
se habría quedado sin estilos**. Una hoja que carga todo el sitio no puede colgar de una
sección.

## Verificación visual

- [ ] Contraste con `getComputedStyle` en todos los pares texto/fondo.
- [ ] Sin desbordamiento horizontal en móvil.
      ⚠️ **Falsos positivos frecuentes:** elementos dentro de un ancestro con scroll, o
      dentro de un `<details>` cerrado, reportan anchos fantasma. Comprobar el ancestro y
      saltarse los `details` cerrados.
- [ ] ⚠️ **`text-transform: uppercase` rompe las búsquedas sensibles a mayúsculas en
      `innerText`.** Normalizar antes de comparar.
- [ ] **El CSS nuevo se despliega en la MISMA tanda que la página que lo usa.** El gate de
      integridad no detecta esto.


================================================================
FILE: docs/04-ficheros-maquina.md
================================================================

# 04 · Los ficheros para máquinas

`sitemap.xml` · `llms.txt` · `AGENTS.md` · `robots.txt`

## Por qué tienen su propio capítulo

> ⚠️ **Se rompen sin avisar, porque NO son HTML.**

Un barrido de `href="..."` no los cubre. Un validador de HTML no los mira. Ninguna página
falla si están mal. Y sin embargo **son la cara del sitio para Google y para las IA**.

Caso real, medido: una reestructuración arregló las 39 páginas del sitio, los enlaces
internos y las 34 redirecciones. `llms.txt` se quedó con **11 URLs a 404 de 28** durante
dos días, y sin listar **ninguna** de las 12 piezas de contenido — justo las que ganan
citas de IA, invisibles en el único fichero que existe para eso. `AGENTS.md` se quedó
además con **el posicionamiento anterior**, ocho veces, en el documento que se presenta a
sí mismo como *"la fuente de verdad"*.

**Nadie lo detectó al desplegar. Lo detectó una rutina de auditoría una semana después.**

## `sitemap.xml`

- **Generado, nunca a mano.** ⚠️ Lo que se añade a mano se borra en el siguiente
  regenerado. Ya pasó.
- Solo indexables. Ninguna con `noindex`.
- **Y al revés: toda indexable tiene que estar dentro.** Es la mitad que casi nadie
  comprueba — se vigila que el sitemap no mienta, no que esté completo.

## `llms.txt`

La lista curada de páginas reales para los rastreadores de IA.

- **Sale del MISMO criterio que el sitemap.** ⚠️ Cuando divergieron, publicó una forma de
  URL que **no había existido nunca** y que ninguna redirección cubría.
- **Texto plano:** cero entidades HTML. Un `&amp;` se lee literal.
- Con descripción de una línea por página: es lo que la IA cita.

## `AGENTS.md`

Guía legible por máquina para agentes y motores de respuesta.

⚠️ **Es el que más deriva, porque es el único que no se genera.** Lleva las tres cosas que
caducan más rápido:

1. **URLs** → se rompen al reestructurar.
2. **Estados de producto** → se quedan atrás (llegó a decir "Live" de algo que era Beta).
3. **El posicionamiento** → lo peor. Si cambia el mensaje y este fichero no, **estás
   sirviendo la versión antigua a las IA como si fuera canónica**.

**Regla:** si cambia el posicionamiento, `AGENTS.md` se revisa **en la misma tanda** que
las páginas. No en la siguiente.

Debe llevar, explícito: qué es el producto · la distinción central · los estados reales ·
lo que **no** se puede afirmar · cómo responder preguntas · **y una nota diciendo qué
afirmaciones antiguas ya no aplican**, porque los modelos han leído las versiones viejas.

## `robots.txt`

- `Sitemap:` con **URL absoluta**.
- Si la estrategia incluye citas de IA, **permitir explícitamente los rastreadores de IA**,
  uno por uno y por su nombre. Un `Allow: /` genérico funciona, pero nombrarlos documenta
  la decisión para quien lo lea después.

## Barrido de control

Ninguna línea debe salir:

```bash
for f in llms.txt AGENTS.md; do
  for u in $(curl -s https://TU-SITIO/$f | grep -oE 'https://TU-SITIO[^ )*`"]+' | sed 's/[.,]$//' | sort -u); do
    echo "$(curl -s -o /dev/null -w '%{http_code}' $u) $u"
  done
done | grep -v '^200'
```

Está dentro de `audit.sh` (S1 y S6.6). **Y también en el runbook de deploy**, porque un
fichero para máquinas roto no se ve mirando la web.


================================================================
FILE: docs/05-rendimiento-y-entrega.md
================================================================

# 05 · Rendimiento y entrega

## Animaciones: la regla de una línea

> **Solo `transform` y `opacity`.** Todo lo demás repinta en el hilo principal.

Chrome compone `transform` y `opacity` en la GPU. `box-shadow`, `width`, `height`,
`top/left`, `margin` y los colores **no**: cada fotograma vuelve al hilo principal.

⚠️ **Caso real:** un punto de estado animaba `box-shadow` para hacer un halo. Salía **dos
veces en cada página** del sitio, repintando sin parar. No aparece como error en ningún
sitio: solo gasta batería y da tirones en móviles lentos.

**El arreglo:** el halo pasa a un pseudo-elemento que anima `transform: scale()` +
`opacity`. Visualmente idéntico — verificado congelando ambas versiones a cuatro tiempos
distintos con `animation-delay` negativo y `animation-play-state: paused`, y comparándolas
lado a lado.

## Toda animación necesita su salida

```css
@media (prefers-reduced-motion: reduce) {
  .lo-que-anima { animation: none; }
}
```

⚠️ **Y hay que comprobar que el selector es el que anima de verdad.** Al mover un pulso de
`.punto` a `.punto::after`, la regla vieja (`.punto { animation: none }`) **dejó de
aplicar sin avisar**, y encima el elemento se quedaba congelado en su estado natural: un
círculo permanente. En ese caso la salida correcta no es `animation: none` sino
`display: none`.

## Above the fold: nunca depender de JavaScript

⚠️ **El fallo más caro y menos evidente de todos.**

Una animación de entrada con `IntersectionObserver` deja el elemento en `opacity: 0`
**hasta que carga y ejecuta el JavaScript**. Above the fold eso cuesta:

- **Speed Index de 3,9 s en móvil** frente a 0,4 s en escritorio.
- El insight de LCP señalando el párrafo que dice qué hace la empresa.
- Y si el JS falla o tarda, **el hero está en blanco**.

**Regla:**

| Dónde | Cómo se anima |
|---|---|
| **Above the fold** | CSS puro (`animation` con `fill-mode: both`). Cero JS |
| **Below the fold** | `IntersectionObserver`, que ahí sí tiene sentido |

## No forzar reflow en handlers de scroll

Leer `window.scrollY` o `offsetTop` **dentro** del handler fuerza un reflow síncrono en
cada evento. Dentro de un `requestAnimationFrame` el layout ya está hecho.

Y **solo tocar el DOM cuando el estado cambia de verdad**, no en cada fotograma.

## Cabeceras de entrega

### `Cache-Control`
⚠️ **Comprobar que se envía alguna.** Un sitio puede servir `ETag` y `Last-Modified` y
**no enviar ni un `Cache-Control`**: cada visitante que vuelve revalida cada fichero, una
petición por asset para acabar en un 304 vacío. Pasó, y no se ve en ninguna auditoría de
página.

| Qué | TTL |
|---|---|
| CSS y JS | **1 día**, si los assets NO llevan huella en el nombre |
| Imágenes y fuentes | 30 días |
| HTML | `no-cache` — revalida, y con ETag la respuesta es un 304 vacío |

> ⚠️ **No subir CSS/JS a un año sin versionar los assets primero.** Si se llaman
> `styles.css` y no `styles.<hash>.css`, un año de caché deja al visitante recurrente con
> el CSS viejo y el HTML nuevo. Un día es el equilibrio: mata el "sin caché" y se cura
> solo.

### Compresión
⚠️ **Al comprobarla hay que PEDIRLA** (`Accept-Encoding`), o el servidor no la manda y
parece que no está configurada. Falso negativo fácil.

### Seguridad
`X-Content-Type-Options` · `Referrer-Policy` · `X-Frame-Options` · `Cross-Origin-Opener-Policy`
— gratis y reversibles al instante. Antes de poner `X-Frame-Options: DENY`, comprobar que
**nada del sitio se embebe a sí mismo**.

**Dos que conviene pensar dos veces:**

- **HSTS** tiene cola: durante su `max-age` los navegadores que ya entraron **se niegan a
  hablar por HTTP**, y eso no se deshace quitando una línea.
- **CSP** ⚠️ si las páginas llevan datos estructurados en `<script>` inline, el
  comportamiento no es uniforme entre navegadores. Arriesgar el JSON-LD de todo el sitio
  por una auditoría que ni puntúa no sale a cuenta. Si se quiere, va en su propio
  despliegue y se verifica página a página.

## Qué NO hacer

- **Minificar el CSS** si eso borra los comentarios que documentan los tokens y no hay
  build. El ahorro tras comprimir es marginal; la pérdida de documentación no.
- **Inlinear el CSS crítico** en un sitio generado: es una trampa de mantenimiento — el
  crítico se queda obsoleto en el primer cambio y nadie se entera.
- **Perseguir el 100/100.** Un 98 en móvil con la estructura correcta vale infinitamente
  más que un 100 con la estructura mal.


================================================================
FILE: docs/06-deploy-y-verificacion.md
================================================================

# 06 · Deploy y verificación

## El gate

```bash
bash audit.sh          # local
bash audit.sh --live   # + produccion, despues de desplegar
```

**En rojo no se despliega.** Sin excepciones silenciadas: si algo no se puede comprobar,
el script escribe `[skip]` **con el motivo**.

## Secuencia

1. **Estado limpio commiteado.** ⚠️ Antes de cualquier edición programática o con regex de
   un fichero desplegado. Si algo se rompe, se recupera al instante.
2. **Regenerar** si el cambio toca specs o generador.
3. **`git status`** — ¿cambió solo lo que esperabas? Si toca ficheros que no esperabas,
   **parar y mirar**, no aceptar.
4. **`audit.sh`** en verde.
5. **Desplegar.**
6. ⚠️ **Corregir dueño y permisos.** Al subir por `tar | ssh`, los ficheros llegan con el
   UID de origen, no con el del servidor web. Se sirven igual, pero deja el árbol
   inconsistente.
7. **`audit.sh --live`.**
8. **Comprobar los vecinos** si el servidor es compartido: otros dominios, las
   redirecciones antiguas, `www` → apex.

## Cómo mirar sin engañarse

⚠️ Todas estas produjeron un diagnóstico falso en su momento. No son teoría.

### Capturas de pantalla
- **Mirar, no solo consultar.** Un chequeo del DOM puede pasar con la pantalla rota: un
  `[hidden]` bien puesto y el elemento visible igual porque un `display:flex` lo anulaba.
- **No leer colores de una captura.** Usar `getComputedStyle`.
- ⚠️ **Chrome headless en Windows clampa el ancho a ~500px.** Pides 375, la página se
  compone a 504, y **la PNG es un recorte**: texto cortado y controles "desaparecidos" que
  están fuera del corte. Se inventan defectos así. **Imprimir `innerWidth` y comprobar que
  coincide con lo pedido.**
- ⚠️ **`--virtual-time-budget` congela las animaciones cerca de t=0.** El hero sale en
  blanco aunque la página esté perfecta; lo único visible es lo que no tiene retardo.
  - Para el **estado final**: `--force-prefers-reduced-motion`.
  - Para ver una animación **a mitad**: página de prueba con `animation-delay` negativo y
    `animation-play-state: paused` — permite además comparar la versión vieja y la nueva
    lado a lado.
- ⚠️ **El panel de navegador puede no componer frames** (`innerWidth` = 0). Entonces **NO
  se ha mirado**: o se renderiza con un Chrome local verificando el ancho, o se dice que
  no está verificado. Lo que no vale es medir el DOM y reportarlo como si se hubiera visto.

### La regla que las resume

> ⚠️ **Antes de creer que un cambio propio rompió algo, renderizar lo de ANTES con las
> mismas banderas.** Si sale igual de roto, **el roto es el método de captura**.

### Barridos y grep
- ⚠️ **Un barrido con un patrón inventado mide tu hipótesis, no el hecho.** Antes de
  barrer, **abrir dos ficheros** y ver cómo se escribe eso de verdad.
- ⚠️ **Todo cero de `grep` es un no-match, no una ausencia.** Antes de reportarlo, abrir
  uno de los que salieron en cero y comprobarlo a mano.
- ⚠️ **Si el barrido dice que varias cosas están rotas a la vez, el sospechoso es el
  barrido.** Un sistema que lleva semanas corriendo no se rompe en bloque; un patrón nuevo
  sí falla en bloque.
- ⚠️ **Cuidado con `sed '/A/,/B/p'`**: no busca el cierre en la misma línea que la
  apertura. Con un bloque de una sola línea, el rango se come el resto del documento.
- ⚠️ **Y con los bucles sobre resultados con espacios.** Un `data:` URI con un SVG dentro
  se parte en veinte trozos y genera veinte falsos positivos.

### Post-deploy
⚠️ **Cache-bust antes de creerse nada.** Con caché activa, estás mirando la versión
anterior.

## Cuando algo falla dos o tres veces

**Parar y rediagnosticar** desde código, logs y contratos. No iterar a ciegas. Si un
arreglo no entra a la tercera, el modelo mental está mal, no el arreglo.

## Cuando el checklist no lo cogió

1. Arreglarlo.
2. **Añadir la comprobación al auditor** si una máquina puede detectarlo.
3. Si no, a la parte de criterio **con el caso real que la originó**.

⚠️ **Y auditar el auditor.** La primera versión de este script tenía un fallo en el
listado de páginas y devolvía **cero ficheros**: media auditoría reportaba `[ ok ]` sin
haber mirado nada. **Iterar sobre una lista vacía no falla, "pasa".** Por eso lleva un
guardia que aborta si el barrido sale vacío — y por eso conviene, la primera vez, romper
algo a propósito y comprobar que el auditor lo caza.


================================================================
FILE: audit.conf.example
================================================================

# Configuracion de _audit.sh para climentmedia.com.
# Para auditar OTRO proyecto: copia _audit.sh y este fichero, y cambia estos valores.
# El script no se toca nunca; toda la particularidad del sitio vive aqui.

# URL base sin barra final.
CONF_BASE_URL="https://ejemplo.com"

# Marca, para detectar <title> que son solo marca y no llevan termino.
# (mkt.climentmedia.com tenia <title>Climent Media</title>: 87 impresiones, 0 clics.)
BRAND="Tu Marca"

# Carpetas de trabajo que NO se despliegan. Se excluyen de todos los barridos.
EXCLUDE_DIRS="ds-bundle _cowork _post-images _seo .design-sync .git node_modules _deploy downloads"

# Rutas que ya solo existen como redireccion 301. Ninguna superficie -- ni el
# sitemap, ni llms.txt, ni AGENTS.md -- puede volver a citarlas: publicar un
# enlace propio hacia una redireccion es deuda que se hereda.
DEAD_PREFIXES=""


================================================================
FILE: audit.sh
================================================================

#!/usr/bin/env bash
# =============================================================================
# _audit.sh - AUDITOR DE SITIO (no de pagina).
#
# Por que existe: teniamos un estandar de PAGINA (skill web-page-standard, 265
# lineas) y un gate de integridad de 5 comprobaciones (_check.sh). Entre los dos
# no habia nada que validara el sitio COMO SISTEMA. Todos los fallos que se nos
# colaron el 31-jul y el 1-ago eran de sistema, no de pagina:
#
#   - llms.txt publicaba 11 URLs a 404 y no listaba ninguna pieza de /learn/
#   - AGENTS.md afirmaba un posicionamiento retirado hacia meses
#   - el sitio no enviaba ni una cabecera Cache-Control
#   - una pagina indexable sin enlaces entrantes ni noindex
#
# Ninguna pagina estaba mal por separado. Por eso este fichero es EJECUTABLE:
# un documento hay que acordarse de leerlo, un script falla solo.
#
# USO
#   bash _audit.sh              # auditoria local (ficheros en disco)
#   bash _audit.sh --live       # + entrega real en produccion (necesita red)
#   bash _audit.sh --root DIR --url https://ejemplo.com
#
# PORTABLE: sin jq, sin node, sin python. Solo bash, grep, sed, find y curl.
# Para otro proyecto: copia el fichero y ajusta _audit.conf (ver abajo).
#
# SALIDA: [ ok ] pasa · [FAIL] rompe el build · [warn] mirar, no bloquea ·
#         [skip] no se pudo comprobar Y POR QUE (nunca se salta en silencio).
# =============================================================================
set -u

# ---------- configuracion ----------
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$SELF_DIR"
BASE_URL=""
LIVE=0
# Defecto GENERICO a proposito: este script lo comparten todas las webs, y el
# defecto de antes arrastraba carpetas de climentmedia (ds-bundle, _cowork,
# .design-sync) dentro de un script compartido. Las 6 webs declaran el suyo en
# su .conf, asi que este defecto solo lo ve una web NUEVA -- y a una web nueva
# no se le pueden excluir carpetas que no tiene.
EXCLUDE_DIRS=".git node_modules dist build vendor"
DEAD_PREFIXES=""
BRAND=""

while [ $# -gt 0 ]; do
  case "$1" in
    --live) LIVE=1; shift ;;
    --root) ROOT="$2"; shift 2 ;;
    --url)  BASE_URL="$2"; shift 2 ;;
    *) echo "argumento desconocido: $1"; exit 2 ;;
  esac
done

# ---------------------------------------------------------------------------
#  GUARDA: este script vive en la SKILL, no dentro de cada web.
# ---------------------------------------------------------------------------
#  19-ago-2026. Antes vivia COPIADO en cada repo -4 copias, 3 versiones-, y por
#  eso `ROOT` cae por defecto en la carpeta del propio script: ahi eso era
#  correcto. Ahora ya no: sin --root mediria la skill y diria cosas de ella,
#  en silencio y con aspecto de informe. Un instrumento que mide el arbol
#  equivocado sin quejarse es peor que uno que no corre.
if [ "$ROOT" = "$SELF_DIR" ]; then
  echo "audit.sh: falta --root." >&2
  echo "  Este script vive en la skill y NO mide la carpeta en la que esta." >&2
  echo "  Uso:  bash \"$0\" --root /ruta/al/repo" >&2
  exit 2
fi

# El .conf permite reusar este script en cualquier proyecto sin editarlo.
# Se aceptan los DOS nombres: el kit publicado usa `audit.conf` y los repos
# nuestros `_audit.conf`. Antes solo valia el segundo, y el kit de fuera no
# encontraba su propia configuracion.
for c in "$ROOT/audit.conf" "$ROOT/_audit.conf"; do [ -f "$c" ] && . "$c" && break; done
[ -n "$BASE_URL" ] || BASE_URL="${CONF_BASE_URL:-}"

FAIL=0; WARN=0; NCHECK=0
bad()  { echo "  [FAIL] $1"; FAIL=$((FAIL+1)); }
warn() { echo "  [warn] $1"; WARN=$((WARN+1)); }
ok()   { echo "  [ ok ] $1"; }
skip() { echo "  [skip] $1"; }
sec()  { echo; echo "-- $1"; }

# Lista de paginas HTML desplegables (excluye las carpetas de trabajo).
# OJO: la primera version usaba `eval find ... -not -path "..."` y devolvia CERO
# paginas en Git Bash. Consecuencia: S2, S3, S4.2 y S5.1 iteraban sobre nada y
# reportaban [ ok ] sin haber comprobado ni un fichero. Es EXACTAMENTE el fallo
# que este auditor existe para impedir -- una comprobacion que pasa porque no
# comprobo nada -- y por eso abajo hay un guardia que revienta si salen 0.
page_list() {
  find "$ROOT" -name '*.html' -type f 2>/dev/null | sed "s|^$ROOT/||" | while read -r p; do
    keep=1
    for d in $EXCLUDE_DIRS; do
      case "$p" in "$d"/*) keep=0; break ;; esac
    done
    [ "$keep" = 1 ] && echo "$p"
  done | sort
}

# ---------------------------------------------------------------------------
#  resolver_ruta · LAS DOS CONVENCIONES DE URL, EN UN SOLO SITIO
# ---------------------------------------------------------------------------
#  19-ago-2026. Nuestras webs sirven las URLs de dos formas distintas, y hasta
#  hoy el auditor solo conocia una:
#
#     dir-barra       /contacto  ->  contacto/index.html   (bcmadrid, cm, nora)
#     plano-sin-ext   /contacto  ->  contacto.html         (kine, mobanho)
#
#  `conformidad.conf` ya nombraba las dos por web (`modo = ...`) y el auditor no
#  se habia enterado: la primera vez que se le corrio a kine marco 363 FALLOS en
#  un sitio correcto -310 de ellos "enlace roto"-. Es la tercera vez que este
#  fichero paga el mismo error: un supuesto de convencion que nadie declaro.
#  *Si el barrido dice que todo esta roto a la vez, el roto es el barrido.*
#
#  NO se pregunta cual es la convencion: se acepta la que EXISTA. Asi no hay que
#  declararla en dos sitios y no pueden discrepar. Si no existe ninguna, se
#  devuelve la PRIMERA para que el mensaje de error diga que se esperaba.
#
#  Recibe y devuelve rutas ABSOLUTAS. La usan url_to_file (sitemap, ficheros
#  para maquinas) y el grafo de enlaces: un solo sitio, una sola regla.
resolver_ruta() {
  local c="$1" ultimo cand1 cand2
  case "$c" in
    */) cand1="${c}index.html"; cand2="${c%/}.html" ;;
    *)  ultimo="${c##*/}"
        case "$ultimo" in
          *.*) echo "$c"; return ;;
        esac
        cand1="$c/index.html"; cand2="$c.html" ;;
  esac
  [ -f "$cand1" ] && { echo "$cand1"; return; }
  [ -f "$cand2" ] && { echo "$cand2"; return; }
  echo "$cand1"
}

# La INVERSA de resolver_ruta: de un fichero en disco a las URLs con las que se
# puede estar publicando. Existe por el mismo motivo y en la misma pareja: S1.3
# mapeaba fichero -> URL conociendo UNA sola convencion, y por eso acusaba a
# kine de tener 15 paginas «fuera del sitemap» que estan perfectamente dentro
# (contacto.html se publica como /contacto). Las dos funciones son las dos
# direcciones de la MISMA regla, y por eso viven juntas.
urls_de_fichero() {
  local p="$1" base sinbarra
  base="$(echo "$p" | sed 's|index\.html$||')"
  echo "$BASE_URL/$base"                       # /carpeta/   o  /pagina.html
  sinbarra="${base%/}"
  echo "$BASE_URL/$sinbarra"                   # /carpeta    o  /pagina.html
  case "$base" in
    *.html) echo "$BASE_URL/${base%.html}" ;;  # /pagina      (convencion plana)
  esac
}

# URL absoluta -> ruta de fichero en disco, RELATIVA a ROOT.
# La convencion (carpeta/index.html o fichero plano) la decide `resolver_ruta`,
# que es el UNICO sitio donde esa regla esta escrita.
url_to_file() {
  local u="${1#$BASE_URL}"; u="${u%%\#*}"; u="${u%%\?*}"
  u="${u#/}"
  [ -z "$u" ] && { echo "index.html"; return; }
  local abs; abs="$(resolver_ruta "$ROOT/$u")"
  echo "${abs#$ROOT/}"
}

# Texto de <title> sin el sufijo de marca.
title_of() {
  sed -n 's/.*<title>\(.*\)<\/title>.*/\1/p' "$1" | head -1
}

# El contenido de un <pre> es TEXTO, no marcado: un href dentro de un ejemplo
# de codigo no es un enlace. Sin esto, una pagina que inlinea documentacion
# (las de /copy/) reporta como rotos los href de sus propios ejemplos, y hasta
# fragmentos de regex sueltos. Es el mismo error que el chequeo de mojibake:
# tratar codigo fuente inlineado como si fuera markup.
# awk y no `sed '/<pre/,/<\/pre>/d'`: sed no cierra el rango en la misma linea,
# asi que un <pre> de una linea se comeria el resto del documento.
strip_pre() {
  awk '{
    line=$0
    while (match(line, /<pre[^>]*>/)) {
      pre=substr(line,1,RSTART-1); rest=substr(line,RSTART+RLENGTH)
      if (match(rest, /<\/pre>/)) { line = pre substr(rest,RSTART+RLENGTH); continue }
      else { print pre; inpre=1; line=""; break }
    }
    if (inpre) { if (match(line, /<\/pre>/)) { inpre=0; line=substr(line,RSTART+RLENGTH) } else next }
    print line
  }' "$1"
}

echo "==========================================================="
echo " AUDITORIA DE SITIO: $ROOT"
[ -n "$BASE_URL" ] && echo " URL base: $BASE_URL"
[ "$LIVE" -eq 1 ] && echo " modo: LOCAL + PRODUCCION" || echo " modo: LOCAL (usa --live para comprobar la entrega)"
echo "==========================================================="

PAGES="$(page_list)"
NPAGES=$(echo "$PAGES" | grep -c . )
echo "  paginas desplegables encontradas: $NPAGES"
# GUARDIA ANTI-VACIO. Sin esto, un fallo en page_list convierte media auditoria
# en [ ok ] falsos: iterar sobre una lista vacia no falla, "pasa". Ya ocurrio.
if [ "$NPAGES" -lt 1 ]; then
  echo "  [FAIL] S0 el barrido de paginas devolvio CERO ficheros."
  echo "         No es que el sitio este limpio: es que no se ha mirado nada."
  echo "         Revisa ROOT y EXCLUDE_DIRS en _audit.conf antes de creerte nada."
  exit 1
fi

# =============================================================================
sec "S1 · FICHEROS PARA MAQUINAS (sitemap · llms.txt · AGENTS.md · robots.txt)"
# Se rompen sin avisar porque NO son HTML: un grep de href= no los cubre.
# =============================================================================

SM="$ROOT/sitemap.xml"
if [ ! -f "$SM" ]; then
  bad "S1.1 no hay sitemap.xml"
  SM_URLS=""
else
  head -1 "$SM" | grep -q '<?xml' && grep -q '<urlset' "$SM" \
    && ok "S1.1 sitemap.xml bien formado" || bad "S1.1 sitemap.xml malformado"
  SM_URLS=$(grep -oE '<loc>[^<]+' "$SM" | sed 's/<loc>//')
  NSM=$(echo "$SM_URLS" | grep -c .)
  MISS=0
  for u in $SM_URLS; do
    f="$(url_to_file "$u")"
    [ -f "$ROOT/$f" ] || { bad "S1.2 sitemap apunta a algo que no existe en disco: $u  (esperaba $f)"; MISS=1; }
  done
  [ "$MISS" -eq 0 ] && ok "S1.2 las $NSM URLs del sitemap existen en disco"

  # S1.3 toda pagina indexable tiene que estar EN el sitemap.
  # Es la mitad que casi nadie comprueba: se vigila que el sitemap no mienta,
  # no que este completo. Una pagina real fuera del sitemap es una pagina que
  # Google puede tardar meses en encontrar.
  # ⚠️ ESTE CHEQUEO TRAIA UN SUPUESTO SIN DECLARAR: que `carpeta/index.html` se
  #    indexa como `/carpeta/` CON barra. Al pasar el sitio a URLs sin barra
  #    -que es la forma que este cliente ya tiene indexada- marco 78 fallos en un
  #    sitio correcto. Es la misma clase de fallo que la trampa 2 de CLAUDE.md,
  #    con los enlaces relativos. Ahora se comparan las dos formas.
  #    *Si el barrido dice que todo esta roto a la vez, el roto es el barrido.*
  FALTAN=0
  for p in $PAGES; do
    grep -q 'name="robots"[^>]*noindex' "$ROOT/$p" && continue
    ENCONTRADA=0
    for u in $(urls_de_fichero "$p"); do
      echo "$SM_URLS" | grep -qxF "$u" && { ENCONTRADA=1; break; }
    done
    if [ "$ENCONTRADA" -eq 0 ]; then
      bad "S1.3 indexable pero FUERA del sitemap: $p  (o la anades, o le pones noindex)"
      FALTAN=1
    fi
  done
  [ "$FALTAN" -eq 0 ] && ok "S1.3 toda pagina indexable esta en el sitemap"

  # S1.4 y ninguna del sitemap puede ser noindex (se contradicen).
  CONTRA=0
  for u in $SM_URLS; do
    f="$ROOT/$(url_to_file "$u")"
    [ -f "$f" ] || continue
    grep -q 'name="robots"[^>]*noindex' "$f" && { bad "S1.4 en el sitemap Y con noindex (se contradicen): $u"; CONTRA=1; }
  done
  [ "$CONTRA" -eq 0 ] && ok "S1.4 ninguna URL del sitemap lleva noindex"
fi

for MF in llms.txt AGENTS.md; do
  if [ ! -f "$ROOT/$MF" ]; then
    skip "S1.5 no hay $MF en el repo (si el sitio lo sirve, esta descuadrado)"
    continue
  fi
  # El backtick y el asterisco se excluyen: son sintaxis de markdown, no de la URL.
  URLS=$(grep -oE 'https?://[^ )"<>*`]+' "$ROOT/$MF" | sed 's/[.,:]$//' | sort -u)
  MISSM=0; NEXT=0
  for u in $URLS; do
    case "$u" in
      "$BASE_URL"*)
        f="$(url_to_file "$u")"
        [ -f "$ROOT/$f" ] || { bad "S1.5 $MF apunta a algo que no existe: $u  (esperaba $f)"; MISSM=1; } ;;
      *) NEXT=$((NEXT+1)) ;;
    esac
  done
  NIN=$(echo "$URLS" | grep -c "^$BASE_URL")
  [ "$MISSM" -eq 0 ] && ok "S1.5 las $NIN URLs propias de $MF existen ($NEXT externas, no comprobadas en local)"
  # S1.6 son texto plano: una entidad HTML se lee literal.
  if grep -qE '&(amp|mdash|ndash|middot|rarr|larr|quot|iquest|aacute|eacute|iacute|oacute|uacute|ntilde);' "$ROOT/$MF"; then
    bad "S1.6 $MF tiene entidades HTML sin decodificar (es texto plano: se leen literales)"
  else
    ok "S1.6 $MF sin entidades HTML crudas"
  fi
done

if [ -f "$ROOT/robots.txt" ]; then
  grep -qi "^Sitemap: https\?://" "$ROOT/robots.txt" \
    && ok "S1.7 robots.txt declara el sitemap con URL absoluta" \
    || bad "S1.7 robots.txt no declara Sitemap: con URL absoluta"
else
  bad "S1.7 no hay robots.txt"
fi

# S1.8 ninguna superficie puede citar una ruta que ya solo existe como 301.
if [ -n "$DEAD_PREFIXES" ]; then
  DP=0
  for pre in $DEAD_PREFIXES; do
    for MF in sitemap.xml llms.txt AGENTS.md; do
      [ -f "$ROOT/$MF" ] || continue
      n=$(grep -c "$BASE_URL$pre" "$ROOT/$MF" 2>/dev/null)
      [ "$n" -gt 0 ] && { bad "S1.8 $MF cita $n vez/veces la ruta muerta $pre"; DP=1; }
    done
  done
  [ "$DP" -eq 0 ] && ok "S1.8 ningun fichero para maquinas cita rutas muertas"
else
  skip "S1.8 sin DEAD_PREFIXES configurado en _audit.conf (no se puede comprobar)"
fi

# =============================================================================
sec "S2 · META POR PAGINA"
# =============================================================================
T_MISS=0; T_BRAND=0; D_MISS=0; D_LONG=0; D_SHORT=0; C_MISS=0; OG_MISS=0; H1_BAD=0; LANG_BAD=0; LD_ENT=0
TITLES_FILE="$(mktemp 2>/dev/null || echo "$ROOT/.audit-titles.tmp")"
: > "$TITLES_FILE"

for p in $PAGES; do
  f="$ROOT/$p"
  # Una pagina noindex no compite en buscadores ni se comparte: exigirle
  # canonical, Open Graph o un title con termino es ruido, y un auditor
  # ruidoso se acaba ignorando. Se le sigue exigiendo lo estructural.
  INDEXABLE=1
  grep -q 'name="robots"[^>]*noindex' "$f" && INDEXABLE=0
  # --- title
  t="$(title_of "$f")"
  if [ -z "$t" ]; then bad "S2.1 sin <title>: $p"; T_MISS=1
  else
    echo "$t" >> "$TITLES_FILE"
    # title que es solo la marca = 0 clics con impresiones (ya medido en mkt.)
    if [ -n "$BRAND" ]; then
        core="$(echo "$t" | sed "s/[[:space:]]*[-|&][a-z]*;*[[:space:]]*$BRAND[[:space:]]*$//I" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
        # 19-ago-2026 · ANTES ERA `-lt 12`, UN SUELO ARBITRARIO QUE ACUSABA A
        # TITULOS PERFECTAMENTE BUENOS: «Contacto» son 8 y «A propos» 8, y los
        # dos son terminos reales, no marca. Lo que el check quiere decir es
        # otra cosa, y ahora lo dice: el titulo no puede ser SOLO la marca.
        # Se compara con la marca en vez de medir longitud, que era medir un
        # sintoma parecido en vez del hecho.
        core_min="$(echo "$core" | tr '[:upper:]' '[:lower:]')"
        brand_min="$(echo "$BRAND" | tr '[:upper:]' '[:lower:]')"
        if [ "$INDEXABLE" -eq 1 ] && { [ "${#core}" -lt 3 ] || [ "$core_min" = "$brand_min" ]; }; then
          bad "S2.1 <title> sin termino, solo marca ('$t'): $p"; T_BRAND=1
        fi
    fi
  fi
  # --- description
  d="$(sed -n 's/.*<meta name="description" content="\([^"]*\)".*/\1/p' "$f" | head -1)"
  if [ -z "$d" ]; then bad "S2.2 sin meta description: $p"; D_MISS=1
  else
    # Se mide el texto DECODIFICADO: Google ve "&" y "—", no "&amp;" ni
    # "&mdash;". Contando el crudo se recortarian frases que caben.
    # CUALQUIER entidad cuenta como UN caracter: &amp; se ve como "&" y &euro;
    # como "EUR". Una lista escrita a mano siempre se queda corta -- esta no
    # tenia &euro; y acusaba de larga una description que cabia (20-ago-2026).
    dd="$(echo "$d" | sed -e 's/&#\?[a-zA-Z0-9]{1,7};/X/g')"
    n=${#dd}
    if [ "$n" -gt 165 ]; then bad "S2.2 description de $n caracteres (Google corta ~155-160, se pierde el gancho): $p"; D_LONG=1
    elif [ "$n" -lt 110 ]; then warn "S2.2 description corta ($n car., desaprovecha espacio): $p"; D_SHORT=1; fi
  fi
  # --- canonical
  [ "$INDEXABLE" -eq 1 ] && { grep -q 'rel="canonical"' "$f" || { bad "S2.3 sin canonical: $p"; C_MISS=1; }; }
  # --- Open Graph (solo indexables: una noindex no se comparte)
  if [ "$INDEXABLE" -eq 1 ]; then
    for og in 'og:title' 'og:description' 'og:url' 'og:image'; do
      grep -q "property=\"$og\"" "$f" || { bad "S2.4 falta $og: $p"; OG_MISS=1; }
    done
    # Y que la imagen EXISTA, no solo que la etiqueta este. Comprobar la
    # etiqueta y no el fichero es como se publica una pagina que al compartirse
    # sale como un rectangulo gris: el HTML es correcto y el 404 es del asset.
    ogimg="$(grep -o 'property="og:image" content="[^"]*"' "$f" | sed 's/.*content="//;s/"//' | head -1)"
    if [ -n "$ogimg" ]; then
      ogp="${ogimg#$BASE_URL/}"
      case "$ogimg" in
        "$BASE_URL"*) [ -f "$ROOT/$ogp" ] || { bad "S2.9 og:image declarada pero el fichero NO existe ($ogp): $p"; OG_MISS=1; } ;;
      esac
    fi
  fi
  # --- S2.10 · el zoom no se bloquea  (regla AC-A15 del estandar) ------------
  # `user-scalable=no` y `maximum-scale=1` impiden ampliar la pagina. Para quien
  # no ve bien, eso no es una molestia: es no poder usar la web. Se medible en
  # disco y hasta hoy no lo miraba NADIE -- una de las 45 reglas huerfanas.
  grep -qE '<meta[^>]+name="viewport"[^>]+(user-scalable=no|maximum-scale=1(\.0)?[",])' "$f" \
    && { bad "S2.10 el zoom esta bloqueado (user-scalable=no o maximum-scale=1): $p"; ZOOM_BAD=1; }

  # --- S2.11 · el telefono es un enlace tel:  (regla TP-15) ------------------
  # Un telefono escrito en texto y no enlazado obliga a copiarlo a mano en un
  # movil, que es donde se llama. Solo se exige si la pagina lo ENSENA.
  #
  # 🔴 LA PRIMERA VERSION MIRABA EL FICHERO ENTERO Y ACUSO A 29 PAGINAS DE NORA.
  #    El numero estaba dentro del JSON-LD ("telephone":"+34..."), donde tiene
  #    que ser texto plano: un tel: ahi seria incorrecto. Se mira solo lo que el
  #    visitante VE -- fuera <script> y fuera <head>-, que es el mismo error de
  #    "tratar texto inlineado como si fuera marcado" que ya costo falsos
  #    positivos en S3.1, en el JSON-LD y en el mojibake.
  VISIBLE="$(sed -e '/<script/,/<\/script>/d' -e '/<head/,/<\/head>/d' "$f" 2>/dev/null)"
  if printf '%s' "$VISIBLE" | grep -qE '(\+34|\+32|\+351)[0-9 ]{6,}' && ! grep -q 'href="tel:' "$f"; then
    bad "S2.11 ensena un telefono y no hay ningun href=\"tel:\": $p"; TEL_BAD=1
  fi

  # --- S2.12 · Article XOR TechArticle  (regla WPS-25) -----------------------
  # Declarar los dos @type en la misma pagina es decirle a Google dos cosas
  # distintas sobre que es. Se elige uno.
  if grep -q '"@type"[^}]*"Article"' "$f" && grep -q '"@type"[^}]*"TechArticle"' "$f"; then
    bad "S2.12 declara Article Y TechArticle a la vez (elige uno): $p"; TIPO_BAD=1
  fi
  # --- un solo H1
  nh1=$(grep -oE '<h1[ >]' "$f" | wc -l | tr -d ' ')
  [ "$nh1" -eq 1 ] || { bad "S2.5 tiene $nh1 <h1> (debe ser exactamente 1): $p"; H1_BAD=1; }
  # --- lang
  grep -qE '<html[^>]+lang="' "$f" || { bad "S2.6 <html> sin lang: $p"; LANG_BAD=1; }
  # --- entidades crudas dentro del JSON-LD: Google lee "&iquest;Sois" literal
  if grep -q 'application/ld+json' "$f"; then
    # OJO con `sed -n '/A/,/B/p'`: NO busca el cierre en la misma linea que la
    # apertura, asi que un JSON-LD de una sola linea hacia que el rango se
    # comiera el resto del documento. Daba 14 falsos positivos: las entidades
    # estaban en <p> del cuerpo, donde son CORRECTAS. Con awk se recorta el
    # bloque de verdad, valga en una linea o en varias.
    # Y pasa antes por strip_pre, como S3.1: una pagina que inlinea
    # documentacion cita ejemplos de JSON-LD dentro de un <pre>, y el awk los
    # tomaba por bloques reales. Mismo error que con los href y con el mojibake:
    # tratar texto inlineado como si fuera marcado.
    if strip_pre "$f" | awk '/application\/ld\+json/ { inld=1 }
            inld {
              l=$0
              if (match(l, /application\/ld\+json"?>/)) l=substr(l, RSTART+RLENGTH)
              if (match(l, /<\/script>/)) { print substr(l,1,RSTART-1); inld=0 } else print l
            }' | grep -qE '&(amp|mdash|ndash|middot|rarr|larr|rsquo|lsquo|iquest|aacute|eacute|iacute|oacute|uacute|ntilde);'; then
      bad "S2.7 entidades HTML dentro del JSON-LD (Google las lee literales): $p"; LD_ENT=1
    fi
  fi
done
[ "$T_MISS" -eq 0 ] && [ "$T_BRAND" -eq 0 ] && ok "S2.1 todas con <title> y con termino, no solo marca"
[ "${ZOOM_BAD:-0}" -eq 0 ] && ok "S2.10 ninguna bloquea el zoom"
[ "${TEL_BAD:-0}" -eq 0 ] && ok "S2.11 todo telefono visible es un enlace tel:"
[ "${TIPO_BAD:-0}" -eq 0 ] && ok "S2.12 ninguna declara Article y TechArticle a la vez"
[ "$D_MISS" -eq 0 ] && [ "$D_LONG" -eq 0 ] && ok "S2.2 todas con description dentro de limite"
[ "$C_MISS" -eq 0 ] && ok "S2.3 todas con canonical"
[ "$OG_MISS" -eq 0 ] && ok "S2.4 todas con Open Graph completo"
[ "$H1_BAD" -eq 0 ] && ok "S2.5 todas con exactamente un <h1>"
[ "$LANG_BAD" -eq 0 ] && ok "S2.6 todas con lang"
[ "$LD_ENT" -eq 0 ] && ok "S2.7 JSON-LD sin entidades crudas"

DUP="$(sort "$TITLES_FILE" | uniq -d)"
if [ -n "$DUP" ]; then
  echo "$DUP" | while read -r x; do echo "  [FAIL] S2.8 <title> duplicado: $x"; done
  FAIL=$((FAIL+1))
else
  ok "S2.8 ningun <title> duplicado"
fi
rm -f "$TITLES_FILE"

# =============================================================================
sec "S3 · GRAFO DE ENLACES"
# =============================================================================

BROKEN=0
for p in $PAGES; do
  d="$(dirname "$ROOT/$p")"
  # while-read y no `for`: un href puede contener espacios (el favicon es un
  # data: URI con SVG dentro) y el for lo partia en trozos, generando 17 falsos
  # positivos por pagina. data:, javascript: y las absolutas se descartan antes.
  # ⚠️ `cmd | while read` corre el bucle en una SUBSHELL: los `bad` de dentro
  # imprimian pero NO incrementaban el contador del padre, y BROKEN volvia a 0
  # al salir. Resultado: 21 fallos impresos, "FAIL: 2" en el resumen y un
  # "[ ok ] cero enlaces rotos" JUSTO DEBAJO de los 21. Un resumen que
  # contradice lo que acaba de imprimir es peor que no tener resumen.
  # Con `done < <(...)` el bucle corre en el shell padre y las cuentas valen.
  while IFS= read -r h; do
    tgt="${h%%#*}"; tgt="${tgt%%\?*}"
    [ -z "$tgt" ] && continue
    # Tercer sitio donde vivia la regla de resolucion, y el unico que quedaba:
    # probaba "$d/$tgt" y, si fallaba, "$ROOT$tgt", ninguno de los dos con la
    # convencion de fichero plano. Por eso kine daba 310 "enlaces rotos" hacia
    # paginas que existen (/a-propos -> a-propos.html). Ahora despacha por si la
    # ruta es absoluta o relativa y deja la CONVENCION a resolver_ruta, que es
    # el unico sitio donde esa regla esta escrita.
    case "$tgt" in
      /*) cand="$(resolver_ruta "$ROOT$tgt")" ;;
      *)  cand="$(resolver_ruta "$d/$tgt")" ;;
    esac
    [ -e "$cand" ] || { bad "S3.1 enlace roto en $p -> $h"; BROKEN=1; }
  done < <(strip_pre "$ROOT/$p" | grep -oE 'href="[^"#?][^"]*"' | sed 's/href="//;s/"$//' \
           | grep -vE '^(https?:|mailto:|tel:|data:|javascript:|//|#)' | sort -u)
done
[ "$BROKEN" -eq 0 ] && ok "S3.1 cero enlaces internos rotos"

# S3.2 huerfanas: indexables sin ni un enlace entrante desde el cuerpo de otra pagina.
# Una pagina que solo cuelga del nav no tiene senal interna; y una sin NADA es
# invisible salvo por el sitemap.
# Se RESUELVE cada enlace relativo a su pagina de origen antes de comparar.
# ⚠️ La primera version comparaba la ruta completa ("agents/x/") contra el href
# tal cual, asi que solo veia los enlaces escritos desde la raiz. Un enlace
# vecino (href="x/") o hacia arriba (href="../x/") no contaba, y daba huerfana
# una pagina con dos enlaces entrantes reales. Peor todavia: en sentido
# contrario CALLABA, porque bastaba un enlace desde la home para dar por buena
# cualquier pagina. Un check de huerfanas que no resuelve rutas no sirve.
LINKMAP="$(mktemp 2>/dev/null || echo "$ROOT/.audit-links.tmp")"
: > "$LINKMAP"
for p in $PAGES; do
  d="$(dirname "$ROOT/$p")"
  strip_pre "$ROOT/$p" | grep -ohE 'href="[^"#?][^"]*"' | sed 's/href="//;s/"$//' \
    | grep -vE '^(https?:|mailto:|tel:|data:|javascript:|//|#)' \
    | while IFS= read -r h; do
        tgt="${h%%#*}"; tgt="${tgt%%\?*}"
        [ -z "$tgt" ] && continue
        case "$tgt" in
          /*) cand="$ROOT$tgt" ;;
          *)  cand="$d/$tgt" ;;
        esac
          # ⚠ Aqui solo se resolvia "algo/" -> "algo/index.html". Con URLs SIN
          #    barra final -la forma que este cliente ya tiene indexada- "/contacto"
          #    quedaba como directorio y nadie contaba su enlace entrante: 40
          #    huerfanas en un sitio bien enlazado. Se aceptan LAS DOS.
          # La misma regla que el sitemap, y por eso la misma funcion: aqui vivia
          # duplicada -solo la mitad, la de la barra final- y por eso el grafo de
          # enlaces de kine daba 310 rotos que no lo estaban.
          cand="$(resolver_ruta "$cand")"
        r="$(realpath -m --relative-to="$ROOT" "$cand" 2>/dev/null)"
        [ -n "$r" ] && echo "$r|$p"
      done >> "$LINKMAP"
done
ORPH=0
for p in $PAGES; do
  grep -q 'name="robots"[^>]*noindex' "$ROOT/$p" && continue
  [ "$p" = "index.html" ] && continue
  n=$(grep -c "^$p|" "$LINKMAP" 2>/dev/null)
  self=$(grep -c "^$p|$p$" "$LINKMAP" 2>/dev/null)
  n=$((n - self))
  if [ "$n" -lt 1 ]; then bad "S3.2 huerfana (0 enlaces entrantes, indexable): $p"; ORPH=1
  elif [ "$n" -lt 2 ]; then warn "S3.2 solo $n enlace entrante (el PASO 0 pide 2): $p"; fi
done
rm -f "$LINKMAP"
[ "$ORPH" -eq 0 ] && ok "S3.2 ninguna pagina indexable esta huerfana"

# =============================================================================
sec "S4 · INTEGRIDAD Y CODIFICACION"
# =============================================================================
# Se escanea SOLO lo que se despliega. Los documentos del repo (CLAUDE.md y
# companyia) quedan fuera a proposito: uno de ellos DOCUMENTA el patron de
# mojibake, y se marcaba a si mismo como corrupto. Un auditor que da falsos
# positivos se acaba ignorando, que es peor que no tenerlo.
DEPLOYED="$PAGES"
for f in styles.css components.css script.js llms.txt robots.txt sitemap.xml AGENTS.md; do
  [ -f "$ROOT/$f" ] && DEPLOYED="$DEPLOYED
$f"
done
# Las paginas /copy/ quedan fuera: inlinean codigo fuente verbatim y pueden
# contener el propio patron de mojibake (audit.sh lo lleva dentro para
# detectarlo). Un fichero que DOCUMENTA el patron se marca a si mismo.
MOJI=""
for f in $DEPLOYED; do
  case "$f" in */copy/index.html) continue ;; esac
    # 19-ago-2026 · LA LISTA ERA DE 8 SECUENCIAS Y SE DEJABA FUERA LA MITAD DE
    # LAS VOCALES. Tenia `é` (e) y `ñ` (n) pero NO `í` (i) ni `á` `ó` `ú`,
    # asi que el mojibake de «clinica» y «politica» -las dos palabras mas
    # frecuentes de nuestras webs de cliente- pasaba en VERDE. Lo encontro el
    # banco de pruebas el dia que se escribio, no una web rota.
    # NO se usa un patron ancho tipo `Ã.`: en portugues `Ã` mayuscula es LEGITIMA
    # (IRMAOS, ORGAOS) y marcaria a loja.mobanho.com entera. La lista explicita
    # cubre es/fr/pt y no tiene falsos positivos; hay un caso verde que lo prueba.
    grep -qE 'á|é|í|ó|ú|ñ|ç|ã|õ|â|ê|ô|à |è|Â|â€|€|»|«|�' "$ROOT/$f" 2>/dev/null && MOJI="$MOJI $f"
done
[ -n "$MOJI" ] && bad "S4.1 mojibake en:$MOJI" || ok "S4.1 sin mojibake en lo desplegable"

# 🔴 18-ago-2026 · SE CONTABAN LAS ETIQUETAS DENTRO DE LOS COMENTARIOS.
#    Un comentario HTML que EXPLICA la regla hacia fallar a la pagina que la
#    cumplia: 3 abren, 2 cierran, y la tercera estaba en prosa dentro de un
#    comentario. Es la trampa §43 en este fichero: cuanto mejor documentas un
#    check, mas facil es que se acuse a si mismo. Se quitan los comentarios
#    antes de contar, igual que hacen qa-maestro.pl y audit-vs-spec.pl.
UNB=0
for p in $PAGES; do
  sincom=$(perl -0777 -pe 's/<!--.*?-->//gs' "$ROOT/$p")
  o=$(printf '%s' "$sincom" | grep -oE '<section' | wc -l | tr -d ' ')
  c=$(printf '%s' "$sincom" | grep -oE '</section>' | wc -l | tr -d ' ')
  [ "$o" = "$c" ] || { bad "S4.2 <section> descuadrado en $p ($o abren, $c cierran)"; UNB=1; }
done
[ "$UNB" -eq 0 ] && ok "S4.2 <section> equilibrado en todas"

# =============================================================================
sec "S5 · CSS Y RENDIMIENTO"
# =============================================================================
CSSMISS=0
for p in $PAGES; do
  grep -q 'rel="stylesheet"' "$ROOT/$p" || { bad "S5.1 sin hoja de estilos (saldria SIN ESTILO): $p"; CSSMISS=1; continue; }
  for href in $(grep -oE '<link rel="stylesheet" href="[^"]+"' "$ROOT/$p" | sed 's/.*href="//;s/"//'); do
    case "$href" in http*|//*) continue ;; esac
    # Dos convenciones VALIDAS, no una:
    #   ../styles.css  -> relativa a la pagina  (nuestro sitio)
    #   /styles.css    -> relativa a la RAIZ    (sitios servidos desde su dominio)
    # La primera version solo entendia la relativa y marcaba 81 fallos en un
    # sitio correcto. El auditor traia un supuesto que nunca declaro.
    # TERCERA convencion, y la que faltaba: el SELLO DE CACHE. 07-trampas.md §15
    # obliga a servir el CSS y el JS con `?v=...` —si no, el navegador sirve la
    # hoja vieja y un getComputedStyle miente—. Este auditor resolvia el href
    # entero contra el disco, asi que `styles.css?v=7` no existia como fichero y
    # marcaba FAIL. En el sitio de prueba del 18-ago-2026 salieron 12 FAIL, los
    # 12 falsos: la skill exigia el sello y su propio auditor lo castigaba.
    # No se habia visto nunca porque ninguno de los 5 repos sellaba su CSS.
    href="${href%%[?]*}"
    href="${href%%[#]*}"
    case "$href" in
      /*) t="$ROOT$href" ;;
      *)  t="$(dirname "$ROOT/$p")/$href" ;;
    esac
    [ -f "$t" ] || { bad "S5.1 hoja de estilos con prefijo mal (no resuelve): $p -> $href"; CSSMISS=1; }
  done
done
[ "$CSSMISS" -eq 0 ] && ok "S5.1 todas cargan sus hojas de estilo y resuelven"

# S5.2 solo transform y opacity se componen en GPU. Animar box-shadow, width,
# height, top/left o colores repinta en el hilo principal sin parar.
NOCOMP=0
for css in $(find "$ROOT" -maxdepth 2 -name '*.css' 2>/dev/null | grep -vE "/($(echo $EXCLUDE_DIRS | tr ' ' '|'))/"); do
  # La propiedad puede ir tras "0% {" en la misma linea, no solo al principio.
  # Anclado a ^ se escapaba `0% { box-shadow: ... }` -- que es justo como se
  # escriben los keyframes cortos, y como estaba el fallo real de components.css.
  hits=$(awk '/@keyframes/,/^}/' "$css" | grep -nE '(^|[{;[:space:]])(box-shadow|width|height|top|left|right|bottom|margin|padding|background-color|filter)[[:space:]]*:' | head -5)
  [ -n "$hits" ] && { bad "S5.2 @keyframes anima propiedades no compuestas en $(basename $css): $(echo "$hits" | tr '\n' ' ' | cut -c1-120)"; NOCOMP=1; }
done
[ "$NOCOMP" -eq 0 ] && ok "S5.2 las animaciones solo tocan transform/opacity"

# S5.3 toda animacion necesita su salida de reduced-motion.
RM=0
for css in $(find "$ROOT" -maxdepth 2 -name '*.css' 2>/dev/null | grep -vE "/($(echo $EXCLUDE_DIRS | tr ' ' '|'))/"); do
  nk=$(grep -c '@keyframes' "$css")
  [ "$nk" -eq 0 ] && continue
  if grep -q 'prefers-reduced-motion' "$css"; then
    ok "S5.3 $(basename $css) tiene salida de prefers-reduced-motion ($nk animaciones)"
  else
    bad "S5.3 $(basename $css) define $nk @keyframes y NO tiene bloque prefers-reduced-motion"; RM=1
  fi
done
[ "$RM" -eq 0 ] || true

# S5.4 CSS muerto: clases definidas y usadas en CERO paginas.
# Anadido el 2-ago tras encontrar 20 reglas huerfanas de un directorio borrado
# semanas antes. CSS muerto no falla nunca: solo pesa, y sobre todo confunde a
# quien lo lea despues -- describe una estructura que ya no existe.
# Es `warn` y no `bad`: hay clases legitimas que solo aparecen desde JS.
# Se construye UNA sola vez el inventario de clases usadas, en lugar de hacer
# un grep por clase y pagina (eran ~7.800 greps y tardaba minutos).
USEDCLS="$(for p in $PAGES; do grep -ohE 'class="[^"]*"' "$ROOT/$p"; done \
  | sed 's/class="//;s/"$//' | tr ' ' '\n' | sort -u)"
QQ="\"'"
JSCLS="$(cat "$ROOT"/*.js 2>/dev/null | grep -ohE "[$QQ][a-zA-Z][a-zA-Z0-9_-]*[$QQ]" | tr -d "$QQ" | sort -u)"
DEADCSS=""; NDEAD=0
for cls in $(find "$ROOT" -maxdepth 1 -name '*.css' -exec grep -ohE '^\.[a-zA-Z][a-zA-Z0-9_-]*' {} \; | sed 's/^\.//' | sort -u); do
  echo "$USEDCLS" | grep -qx "$cls" && continue
  echo "$JSCLS"   | grep -qx "$cls" && continue   # la anade el JS en runtime
  # Y puede estar DORMIDA, no muerta: el generador la emite solo para ciertos
  # specs (p.ej. un aviso que sale unicamente si el item tiene ese campo).
  # Sin esta linea el check pide borrar CSS que hace falta -- el mismo error de
  # barrido que ya ha costado tres diagnosticos falsos en este proyecto.
  grep -qs "\b$cls\b" "$ROOT"/*.ps1 "$ROOT"/*.py "$ROOT"/*.sh 2>/dev/null && continue
  NDEAD=$((NDEAD+1)); DEADCSS="$DEADCSS .$cls"
done
if [ "$NDEAD" -gt 0 ]; then
  warn "S5.4 $NDEAD clase(s) definidas y sin uso en ninguna pagina:$(echo $DEADCSS | cut -c1-160)"
else
  ok "S5.4 sin CSS muerto"
fi

# =============================================================================
if [ "$LIVE" -eq 1 ]; then
sec "S6 · ENTREGA EN PRODUCCION"
  if [ -z "$BASE_URL" ]; then
    skip "S6 sin --url ni CONF_BASE_URL: no se puede comprobar produccion"
  else
    # S6.0 · Disco vs produccion. Se separa A PROPOSITO de S6.1.
    # ⚠️ Antes S6.1 recorria el sitemap de DISCO contra produccion, asi que una
    # pagina legitimamente en staging ponia --live en ROJO. Un gate que se pone
    # rojo por trabajo pendiente de desplegar ensena a ignorarlo, que es el peor
    # resultado posible para un gate. Ahora:
    #   S6.0 = que hay en disco y aun no en produccion  -> aviso, no fallo
    #   S6.1 = que sirve produccion de verdad           -> fallo si algo rompe
    SM_LIVE="$(curl -s "$BASE_URL/sitemap.xml" | grep -oE '<loc>[^<]+' | sed 's/<loc>//')"
    PEND=""
    for u in $SM_URLS; do echo "$SM_LIVE" | grep -qx "$u" || PEND="$PEND $u"; done
    if [ -n "$PEND" ]; then
      warn "S6.0 en disco y aun NO en produccion (pendiente de desplegar):$PEND"
    else
      ok "S6.0 disco y produccion coinciden: nada pendiente de desplegar"
    fi

    L200=0; NL=0
    for u in $SM_LIVE; do
      NL=$((NL+1))
      c=$(curl -s -o /dev/null -w "%{http_code}" "$u")
      [ "$c" = "200" ] || { bad "S6.1 $c en $u"; L200=1; }
    done
    [ "$L200" -eq 0 ] && ok "S6.1 las $NL URLs del sitemap EN VIVO responden 200"

    # Hay que PEDIR la compresion o el servidor no la manda, y el aviso saldria
    # siempre aunque este bien configurada.
    H="$(curl -s -D - -o /dev/null -H 'Accept-Encoding: gzip, br' "$BASE_URL/")"
    echo "$H" | grep -qi '^cache-control:' && ok "S6.2 el HTML envia Cache-Control" || bad "S6.2 el HTML NO envia Cache-Control (cada visita recurrente revalida todo)"
    echo "$H" | grep -qi '^content-encoding:' && ok "S6.3 compresion activa" || warn "S6.3 sin Content-Encoding (comprueba que el server comprime)"
    for hh in x-content-type-options referrer-policy; do
      echo "$H" | grep -qi "^$hh:" && ok "S6.4 cabecera $hh presente" || warn "S6.4 falta la cabecera $hh"
    done

    CSSU="$(grep -oE '<link rel="stylesheet" href="[^"]+"' "$ROOT/index.html" | head -1 | sed 's/.*href="//;s/"//')"
    if [ -n "$CSSU" ]; then
      HC="$(curl -s -D - -o /dev/null "$BASE_URL/$CSSU")"
      echo "$HC" | grep -qi '^cache-control:' && ok "S6.5 los assets envian Cache-Control" || bad "S6.5 los assets NO envian Cache-Control"
    fi

    for MF in llms.txt AGENTS.md; do
      curl -s -f -o /dev/null "$BASE_URL/$MF" 2>/dev/null || { warn "S6.6 $BASE_URL/$MF no responde (¿sin desplegar?)"; continue; }
      LM=0
      for u in $(curl -s "$BASE_URL/$MF" | grep -oE "$BASE_URL[^ )\"<>*\`]+" | sed 's/[.,:]$//' | sort -u); do
        c=$(curl -s -o /dev/null -w "%{http_code}" "$u")
        [ "$c" = "200" ] || { bad "S6.6 $MF EN VIVO apunta a $c: $u"; LM=1; }
      done
      [ "$LM" -eq 0 ] && ok "S6.6 todas las URLs de $MF en vivo responden 200"
    done
  fi
else
  sec "S6 · ENTREGA EN PRODUCCION"
  skip "S6 no ejecutado (pasa --live para comprobar cache, cabeceras y URLs en vivo)"
fi

# =============================================================================
echo
echo "==========================================================="
echo " FAIL: $FAIL   ·   warn: $WARN"
if [ "$FAIL" -eq 0 ]; then
  echo " == VERDE: el sitio pasa la auditoria =="
  echo "==========================================================="
  exit 0
else
  echo " == ROJO: $FAIL fallo(s). No desplegar sin arreglarlos. =="
  echo "==========================================================="
  exit 1
fi