Un <link rel="canonical"> puede estar en el código fuente y no valer nada. Pasa cuando algo que no
pertenece al <head> aparece antes que él: un <img> de seguimiento, un <div>, un <iframe> o
una palabra suelta que dejó un plugin. El navegador cierra el head en ese punto y abre el body, aunque
el </head> siga más abajo, y todo lo que venía detrás pasa a estar en el body. Google lo explica en
su página sobre metadatos válidos: si usas un elemento no válido en el head, ignora todo lo que
aparece después.
Nadie lo ve, porque al abrir el código fuente el canonical está donde debe. La 0.19.0 trae la regla
que lo detecta, META-OUTSIDE-HEAD. Es high y del nivel gratuito, y el catálogo pasa a 66 reglas.
Vigila los cinco metadatos que importan para el posicionamiento: el canonical, el meta robots, el
hreflang, el título y la descripción.
Lo que dice el hallazgo
El hallazgo nombra lo que cerró el head, con el atributo que lo identifica:
<img src="https://…">, <div id="…"> o el texto que sobraba. En un WordPress casi siempre es un
plugin, y ese atributo dice cuál. Saber que el canonical no vale sirve de poco si después hay que
abrir el HTML a mano para encontrar el culpable.
La regla también mira las páginas que el rastreo ve como noindex, a diferencia de casi todas las
demás. El motivo es que ese noindex puede ser una de las etiquetas perdidas. Si lo es, Google no lo
ve y la página que nosotros damos por excluida puede estar indexada.
Seguir el estándar, no una lista
La versión ingenua de esta regla es una lista: en el head solo caben title, meta, link,
script, style, base, noscript y template, y cualquier otra cosa lo cierra. Así lo
resume Google, pero hay casos en los que la lista se equivoca, y son de los que más aparecen.
El píxel de Meta es el primero. Su código de instalación pone un <noscript> con un <img> dentro, y
mucha gente lo pega en el head. Con la lista, ese <img> cerraría el head en todas las páginas que lo
instalan así, y no tienen ningún problema. El estándar HTML trata el contenido de <noscript> como texto cuando el
navegador ejecuta JavaScript, así que ese <img> no cierra nada. Lo mismo vale para el contenido de
<template>, <script> y <style>.
El segundo es menos intuitivo. Un <link> o un <meta> que aparece entre </head> y <body> no se
queda fuera: el estándar lo devuelve al head. Lo que abre el body en esa zona es otro tipo de
elemento, o un texto que no sea espacio en blanco.
Y luego está el caso que más me preocupaba: WordPress imprimiendo un aviso de PHP antes del documento,
algo como <br /><b>Warning</b>: Undefined variable…. Ese <br> aparece antes incluso que <html>,
el head no llega a abrirse y todos los metadatos de la página caen en el body. Ahí no llega ningún
selector, así que el motor repasa los bytes anteriores a <html> antes de empezar.
Un oráculo para el parser
El motor lee el HTML con lol_html, que va en streaming y no construye el árbol del documento. Eso lo hace rápido, pero también significa que las reglas del estándar sobre dónde acaba el head las tuve que escribir yo. Hacía falta algo con qué comparar.
En el node_modules de esta misma web está parse5, un parser que implementa el estándar WHATWG
completo, el mismo que siguen los navegadores. Escribí un script que le pasa una carpeta de HTML y apunta qué
metadatos quedan fuera del head según él, y lo comparé fichero a fichero con lo que decía la regla.
El corpus tenía dos partes. La primera, 26 casos escritos a mano para que fueran difíciles: etiquetas
en mayúsculas, un suelto, un <svg> con su propio <title>, cierres sueltos como </p>,
un elemento personalizado, texto antes del doctype o el aviso de PHP. La segunda, las portadas de 81
sitios reales: los dominios externos que enlaza colorvivo.com y los 29 WordPress del hub de ColorVivo,
con una sola petición a cada uno.
Coinciden en todos los ficheros menos uno. Es un </br> suelto dentro del head. El estándar lo
convierte en un <br> y abre el body, pero lol_html no avisa de las etiquetas de cierre que no abren
nada, así que el motor no lo ve. Es un fallo de los que se callan, no de los que acusan en falso, y
queda escrito en el changelog.
El oráculo también encontró algo que yo no había previsto. La primera versión del repaso previo daba
un falso positivo en decoracion2.com. Su plantilla empieza con los comentarios condicionales de
Internet Explorer, <!--[if IE 8]><html class="ie8">, y el primer <html del fichero está dentro de
un comentario. Mi código buscaba ese <html y cortaba ahí, partiendo el comentario por la mitad. Lo
reescribí para que avance desde el principio saltando comentarios enteros, y el test que lo comprueba
usa esa cabecera tal cual.
Ninguna de las 81 portadas tenía el defecto, y tampoco las 600 páginas de colorvivo que rastreé ni especialmundial.com. La regla está comprobada contra los falsos positivos, pero todavía no ha encontrado un caso real. Cuando lo encuentre, lo contaré.
Lo que cuesta
La primera versión registraba el manejador con el selector *, que es lo más sencillo: se llama
para cada elemento de la página y vuelve enseguida si el head ya se cerró. Lo medí parseando cuatro
portadas reales, de 400 a 545 KB, alternando la 0.18.0 y la versión nueva. El parseo subía entre un
8 y un 13 %. Aunque cada llamada vuelva en la primera comprobación, sigue siendo una llamada por cada
elemento del body.
Basta con que lol_html solo invoque el manejador en head *, html > *, que son los descendientes del
head y los hijos directos de <html>, y con mirar el texto solo dentro del head. Con eso el coste
bajó a entre un 3 y un 7 %, unos 0,03 milisegundos por página. La medición se hizo con el Mac muy
cargado, así que tómala como orden de magnitud. El banco de regresión que corre antes de cada
publicación dio 68.519 elementos por segundo, en la línea de las últimas versiones.
Lo comprobé todo en rojo antes de publicar, rompiendo cada pieza y viendo fallar su test: el texto
suelto, el <noscript>, la marca de orden de bytes que algunos editores ponen al principio del
fichero, el selector restringido, el repaso previo, el filtro de páginas noindex y el cableado del
motor. El CI pasó en Linux, Windows y macOS.