§1 Dónde vive todo
Un sitio = una carpeta en /var/www/kxklod/<sitio>/, que nginx sirve al instante y sin caché
en kxklod.loadsavedelete.com/<sitio>/. Eso es dev. El subdominio propio lo cablea Nikolai
en Cloudflare. El hub linkea a cada sitio (§7).
§2 Estructura de la carpeta
Siempre lo mismo: index.html de una sola página con secciones ancladas,
style.css y script.js linkeados con ?v=N para bustear caché,
video/, og.jpg de 1200×630, logo en avif + png, y su .gitignore.
§3 El <head> estándar
Se copia de un sitio que ya esté bien y se adapta. En orden: charset y viewport; title y
description reales, no placeholder; favicon SVG inline; Open Graph completo; Twitter card
summary_large_image; las fuentes auto-hosteadas — nunca Google Fonts, su CSS bloquea el
render 2–3 s en mobile; y el style.css?v=N.
Es lo que audita el ítem HTML falta…
§4 Diseño: propio por sitio, toolkit compartido
El look es único de cada sitio (paleta de 4–6 colores, display + body + mono, un elemento firma
memorable; inspiración en httpster.net antes de definir nada). Lo que se reutiliza es el script.js,
siempre vanilla y sin build: initReveal(), el modal de video, la nav sticky, el i18n por
localStorage. Piso de accesibilidad innegociable: prefers-reduced-motion,
:focus-visible, alt, HTML semántico.
La trampa grande: las secciones data-driven (renderizadas desde un array en el JS) son
invisibles para los crawlers de IA, que no ejecutan JavaScript. Ya no se acepta sin más: se mitiga con
el resumen estático de §13.
§4.1 — Temas (lo que ves acá arriba, el chip “lente”): un sitio con varios temas no se hace agregando
temas, se hace escribiendo bien el CSS la primera vez. Las variables van por rol: --ink es
la superficie y --paper la tinta — nunca “oscuro” y “claro” —, y así el tema claro se da
vuelta sin tocar una sola regla. En el CSS no se escribe un color: se pide un rol, y un color crudo suelto
(sobre todo en el JS) se queda con el tema viejo puesto. Cada paleta se valida a WCAG
(~/panel-contraste.py, que las lee del propio style.css): corrió sobre el tema que ya
estaba en prod y lo reprobó. Lo que no se tematiza es el estado: los tres aspectos de señal siguen
siendo distinguibles en las 6 lentes y con la palabra al lado — un monocromo lindo que te esconda un servicio
caído no sirve para nada.
Principio “moderno primero”: solo tecnología moderna; la compatibilidad con lo viejo se sacrifica
cuando cuesta performance — y al decidir, se estima cuánto cuesta (KB, ms, PSI) en vez de suponerlo.
El peso en disco no es la restricción; las métricas y el SEO sí.
§5 Pipeline de assets
La sección más larga, y donde se gana la velocidad.
- Imágenes → AVIF, siempre.
avifenc -q 55-72 --speed 5. Nada de jpg/png sueltos; la
única excepción es og.jpg, porque algunos scrapers viejos de redes no leen avif.
- Resolución = lo que se muestra (tope ~2× para retina). El original grande se guarda
fuera del webroot. Píxeles que no se ven son peso regalado.
- Video self-hosted, nunca YouTube/Vimeo — por marca (te pueden dar de baja la cuenta), por
performance y porque si un país bloquea YT los embeds se rompen. AV1 primero + H.264 de fallback, con
codecs declarado en ambos <source> o el fallback no dispara.
Audio AAC, nunca Opus (Safari no lo lee en MP4 y se quedaría mudo).
- Encodear no es publicar: no hay AV1 hasta que el
<source> está cableado.
Y el que se sale del modal de video del toolkit se sale del AV1: los sitios que lo sirven son
exactamente los que usan el modal; el que se arma un player propio queda afuera del patrón y del
test-av1.js a la vez (mccat, 2026-07-13). Un .av1.mp4 que pesa casi lo mismo
que el H.264 no es un encode fallado: el CRF adaptativo gasta el ahorro en calidad hasta llenar
el budget de 25 MiB.
- Sprite sheets AVIF para grillas de más de ~50 celdas: 100 thumbs = 1 archivo = 1 request. Es lo que
hace entrar un catálogo de 15k títulos en el límite de 20.000 archivos de Pages. Se particiona
por cómo se ve, no por qué es: la pregunta correcta no es cuántas imágenes hay, sino cuáles se
descargan siempre juntas.
- Las capturas del origen no vienen todas del mismo tamaño (filmiki: 264 resoluciones distintas).
Un resize ciego alarga las caras hasta un 100%: hay que encajar y rellenar, nunca estirar. Medí las
dimensiones reales del lote antes de encodear.
Lo auditan imágenes pesadas, solo-avif
y resolución sobrada. Los aprendizajes medidos están en el Apéndice A.
§6 QA antes de dar algo por hecho
El orden del control: curl por el 200 → ~/sanity.sh (todas las refs internas
responden) → ~/validar.sh (los ítems de arriba) → Playwright → Lighthouse mobile,
apuntando a 90+ en las cuatro categorías.
El chromium del sistema está roto (crashea con SIGTRAP en headless): se usa siempre el chromium
pineado de Playwright, en ~/browser-tools/. El gate previo al deploy es
preflight-sitio.js <url> — cero 404, favicon, robots, description, title, viewport, lang, sin
errores JS — y se corre dos veces: contra dev antes de subir, y contra el *.pages.dev antes
del switch de DNS.
404.html es obligatorio, y es el caso general de lo del robots.txt: sin él Pages contesta
200 con el index entero a cualquier URL inexistente. Eso genera soft-404 y, peor,
envenena el caché: si la URL cae bajo una regla immutable, el edge cachea la página como si
fuera el archivo, por un año. Le pasó a filmiki: tres hojas de sprites devolvían text/html y esas
fichas quedaban vacías en prod, idénticas en dev. Y al verificarlo, ojo: sondear una URL inexistente la
envenena — para probar el 404 usá siempre una ruta nueva.
§7 Integración con el hub
Agregar la fila del sitio en /var/www/kxklod/index.html siguiendo el patrón split-flap
existente, y actualizar el contador del pie.
§8 git — y §8.1: los commits son PÚBLICOS
Un repo por sitio, rama main. nginx bloquea /.git/, así que el repo no se puede
clonar por HTTP.
§8.1 es la regla que más caro sale olvidar. Este panel no tiene autenticación y su DNS no pasa por el
proxy de Cloudflare: cualquiera que abra la URL ve los mensajes de commit, el autor, la rama, y puede navegar
el árbol completo de archivos de cualquier commit de la historia vía snapshots. El
noindex pide no indexar, no impide leer. Consecuencias: nada de secretos en los mensajes ni en los
archivos —y no alcanza con borrarlos después, porque el snapshot sirve cualquier commit viejo: si pasó, hay que
rotar la credencial. Los secretos viven fuera de /var/www, en
~/.cloudflare.env y ~/.zabbix.env con permisos 600.
§9 Trampas de esta VM
/tmp es un tmpfs chico (~987 MB): descargas y transcodes grandes van bajo
/home o /var. Si se llena, fallan hasta los echo.
- Lo que se sube desde la app del celular aterriza en
/tmp, no en la carpeta del sitio.
- No hay
pip: python por python3 -m o binarios en ~/.local/bin.
node y npm sí están.
§10 Visitas y topología de red
El TLS no lo termina esta VM: hay un nginx reverse proxy en el router (192.168.3.1) que termina HTTPS
y nos manda HTTP plano con X-Forwarded-For. Por eso existe realip.conf: sin él el
access.log registraría al proxy en vez del visitante. Las visitas se leen con ~/visitas.sh
(“visita” = IP única por día; el grueso del tráfico crudo son bots, nunca contar líneas del log a lo bruto).
Desde la LAN el dominio público no responde: no hay NAT loopback.
§11 Cloudflare Pages: deploy, caché, dominios
Dev es esta VM; prod es Pages, bajo el dominio propio del sitio. Nada se toca directo en prod: se
edita en dev, se commitea, y cuando gusta se puja. El deploy siempre sale de
git archive HEAD — commit = deploy.
La caché es política fija, no se sube y baja el TTL: el HTML siempre revalida y las carpetas de assets
van immutable por un año, con ?v= para bustear. La trampa verificada: Pages
concatena los headers de todas las reglas que matchean en vez de pisarlos, así que en la regla
específica hay que des-setear primero con ! Cache-Control.
§12 Restricción por país (Argentina)
Hoy está en simulacro: nginx registra quién caería, pero no bloquea a nadie. Motivo del plan: el
origen está expuesto (el dominio no pasa por el proxy de Cloudflare) y hay bots crawleándolo.
La traba para activarlo: el ~95% del tráfico entra por el proxy 172.16.0.1, que pisa el
X-Forwarded-For con su propia IP privada — la IP real del visitante se pierde y no se puede
geo-filtrar. Antes de encender el bloqueo hay que arreglar eso, o decidir qué pasa con ese tráfico.
§13 SEO avanzado + IA-friendly
El principio que ordena todo lo demás: estos sitios promocionan productos y trabajo humano. El diseño
lindo se amerita por el esfuerzo que hay detrás, pero la clave es ser encontrable — por buscadores
clásicos y por asistentes de IA, que hoy son vía principal de descubrimiento: si no estás en esa respuesta, no
existís. Doble audiencia sobre el mismo HTML: lindo para el humano, estructurado para la máquina que lo trae.
Regla de oro: los crawlers de IA no ejecutan JavaScript. Todo lo que solo existe en
script.js es invisible para ellos. De ahí sale el resto:
- 1. Contenido crítico en HTML estático. Los datos pueden seguir en el JS, pero el HTML debe traer
al menos un resumen indexable. El patrón de referencia es aurora: sus 62 puestos se pre-renderizan en
HTML y el JS los re-renderiza localizados, sin cambio visual.
- 2. JSON-LD (schema.org) en el
<head>, con el tipo que corresponda. Estándar
consolidado: es lo que buscadores e IAs consumen directo.
- 3. robots.txt con política explícita de bots de IA. La política es permitir: queremos que
las IAs nos conozcan y nos citen.
- 4. llms.txt — honestidad: es una propuesta, no un estándar, y ningún crawler grande
confirmó consumirlo. Se hace porque cuesta un archivo. Las piezas probadas son la 1, 2, 3 y 7.
- 5. canonical al dominio de prod, para que dev no compita con prod en el índice.
- 6. sitemap.xml solo donde aporta (catálogos multi-vista).
- 7. Feed Atom para novedades fechadas: es la versión push-friendly del llms.txt, y es estándar
desde 2005.
La trampa de robots.txt que nos mordió (RFC 9309): un grupo con User-agent nombrado
reemplaza al grupo *, no lo hereda. Si le escribís User-agent: GPTBot +
Allow: /, GPTBot ignora todos los Disallow que pusiste en *. Bien usado
es una herramienta: en kino y arjlover el catálogo está cerrado para todos y abierto para los bots de IA,
que es justo el material que queremos que citen.
Estado: §13 completa y verificada en prod en los 16 sitios (2026-07-11).
argentinafilma es el sitio modelo — el único con el set completo, incluido el feed Atom: copiar de ahí.
Los 3 catálogos ya son indexables (2026-07-13): filmiki, arjlover y kino tienen su catálogo
estático por letra + sitemap.xml. Un bot pasó de ver 44 palabras a 173.921 en kino y
71.308 en arjlover. Lo que enseñó: el display:flex en las filas cuesta 620 ms de
layout (perf 88) — sin flex y con content-visibility, kino sirve 659 filas con perf 100;
y el rojo de marca de los dos sitios no pasa contraste como texto (hay que usar la variante
oscura). Y no alcanza con generarlo: hay que linkearlo desde el pie (si el bot ve algo que el
visitante no ve, es cloaking) y corregir el llms.txt, que decía que la lista completa
no estaba en HTML.
✓ Checklist de un sitio nuevo
- Carpeta con index / style / script, y
<head> completo (§3) con canonical.
- Diseño propio + toolkit (§4). Assets en avif; resolución ≈ la de render; originales fuera del webroot (§5).
og.jpg 1200×630.
- SEO/IA (§13): HTML estático, JSON-LD, robots.txt con política de bots de IA, llms.txt.
sanity.sh ✓ y validar.sh ✓ · PageSpeed mobile 90+ en las 4 categorías.
- Gate en el hub + contador actualizado (§7) ·
git init + commit + entrada en la bitácora.
A Apéndice — aprendizajes MEDIDOS
Regla meta: medí, no supongas. Nada de esto era predecible por teoría; todo cambió al probarlo.
- AVIF comprime por calidad, no por píxeles. Bajar un thumb a 0,56× los píxeles bajó solo 9%
los bytes. Para bajar peso de verdad hay que tocar el cuantizador.
- Ítems por sheet = múltiplo de thumbs por ítem, y sheets chicos (30 celdas) para que el scroll baje
solo lo visible.
- El
?v= debe incluir lo que cambia los píxeles (resolución, cuantizador), no solo la
lista de archivos — si no, el browser sigue sirviendo el sheet viejo immutable.
- El CLS suele ser algo que se corre al entrar contenido async (el footer saltando 0,14 al poblarse
la grilla). Y se mide con throttling: sin throttling da 0 y te miente.
- Render chunked para listas grandes: tandas de ~40 con
setTimeout, nunca
requestAnimationFrame (se throttlea en tabs no visibles y en headless). Medido en kino:
Perf 66→78, TBT 680→310 ms.