Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

94 225 octets de CSS : quand la coloration syntaxique de Hugo fait sauter AMP

Ou : Comment un article sur les fuites des ORM a révélé une fuite dans ma feuille de style

Le 1er octobre, la validation AMP de mon site a refusé une page : 94 225 octets de CSS, pour une limite de 75 000. La page, c’était l’article sur l’injection SQL en Go, publié la veille. Le réflexe consiste à chercher la règle CSS ajoutée récemment, le composant trop gourmand, la police embarquée. Il n’y en avait aucune. La feuille de style du thème pesait exactement le même poids que la semaine précédente. Ce qui avait changé, c’était le contenu : quarante blocs de code, et la coloration syntaxique de Hugo, qui écrit par défaut chaque couleur directement dans le HTML. AMP compte ces octets-là aussi.

C’est un bogue intéressant parce qu’il ne vient ni du design ni du code du site. Il vient d’un article. Plus tu écris de code dans tes articles, plus tu t’en approches, et rien ne te prévient avant le jour où ça casse.

Limite CSS d’AMP : pourquoi la coloration syntaxique de Hugo la fait exploser

Le budget que personne ne voit grossir

AMP impose à chaque page un plafond de 75 000 octets de CSS. La documentation précise ce qui entre dans le compte : la feuille de style déclarée dans l’en-tête, et les styles inline, chaque attribut style étant lui-même limité à 1 000 octets1. La première partie se surveille facilement : c’est un fichier, il a une taille, elle bouge quand tu le modifies. La seconde dépend de chaque page, et donc de ce que tu y écris.

J’ai mesuré la page fautive dans les deux configurations, en comptant les octets de la feuille de style et ceux de chaque attribut style.

Couleurs dans le HTMLCouleurs en classes CSS
Feuille de style de l’en-tête43 283 octets43 283 octets
Attributs style1 259, soit 51 632 octets0
Total compté94 915 octets43 283 octets

Mon décompte, fait avec le script de la fin de cet article, diffère de quelques centaines d’octets des 94 225 annoncés par la validation, qui ne compte pas exactement de la même manière. L’ordre de grandeur, lui, ne se discute pas : la feuille de style tenait à l’aise sous la limite, et les attributs inline pesaient à eux seuls plus que toute la feuille de style du site.

Ce que Chroma écrit quand on ne lui demande rien

Hugo colore le code avec Chroma, et son réglage noClasses vaut true par défaut2 : au lieu de poser des classes CSS sur les mots-clés et les chaînes, Chroma écrit la couleur dans chaque élément.

1
2
3
4
5
<!-- noClasses = true (défaut) : la couleur voyage avec chaque jeton -->
<span style="color:#a6e22e">Where</span>

<!-- noClasses = false : une classe, la couleur reste dans la feuille de style -->
<span class="nx">Where</span>

Treize octets de style, ce n’est rien. Le problème est la multiplication. Sur cette page, color:#a6e22e revenait 302 fois. Et chaque numéro de ligne portait un attribut de 104 octets, white-space:pre et ses amis, répété sur les 215 lignes numérotées : 22 360 octets rien que pour dire « affiche ce chiffre en gris, et ne le laisse pas sélectionner ».

Le détail le plus vexant m’attendait dans le fichier CSS du thème. Il contenait déjà les couleurs Monokai sous forme de classes, héritées de la création du thème et jamais utilisées, puisque Chroma ne posait aucune classe. Le site payait donc deux fois ses couleurs : une fois dans la feuille de style, pour rien, et une fois dans chaque page, pour de bon.

Le correctif : une ligne de configuration

1
2
3
4
5
6
[markup.highlight]
  style = "monokai"
  lineNos = true
  lineNumbersInTable = true
  # Classes CSS au lieu d'attributs style : les styles inline comptent dans la limite AMP de 75 Ko
  noClasses = false

Avec noClasses = false, Chroma pose des classes et les couleurs Monokai déjà présentes s’appliquent enfin. Si ton thème ne les contient pas, Hugo sait les générer avec hugo gen chromastyles --style=monokai, à coller dans ta feuille de style2. Il manquait seulement la mise en forme des numéros de ligne et du tableau qui les sépare du code, autrefois portée par les attributs inline : 679 octets de règles .chroma. La page est retombée à 43 283 octets, et toutes les pages du site passent désormais sous 44 Ko.

Les deux pièges qui suivent

Le premier est une question de spécificité. Avec lineNumbersInTable = true, Chroma range les numéros de ligne et le code dans les deux cellules d’un tableau. Or le thème stylise tous les tableaux des articles avec div#main table td, qui ajoute un espacement intérieur et une bordure basse à chaque cellule. Ma règle .chroma .lntd, censée remettre ces cellules à zéro, perdait : un identifiant l’emporte sur n’importe quel nombre de classes3. Le code apparaissait décalé et souligné, comme un tableau de résultats sportifs.

1
2
3
4
5
/* Perd : (0,2,0) contre (1,0,3) pour div#main table td */
.chroma .lntd { padding: 0; border: 0; }

/* Gagne : (1,2,1), même identifiant, plus de classes */
div#main .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }

Le second piège est le réflexe qui vient juste après : ajouter !important et passer à autre chose. AMP l’interdit en toutes lettres, parce que le framework a besoin de garder la main sur la taille de ses éléments4. Il faut donc gagner à la loyale, en reprenant le sélecteur du thème et en ajoutant des classes. C’est plus long à écrire et beaucoup plus honnête : dans six mois, la règle dira encore pourquoi elle gagne.

J’ai comparé des captures avant et après sur un article, une page de glossaire, un guide et une page de projet. Le rendu est identique, à la différence près que le budget CSS de la page a fondu de moitié.

Mesurer avant que le validateur ne le fasse

Search Console et les outils de validation te préviennent en général une fois la page en ligne. Le script qui suit te prévient avant, sur le site généré localement.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import sys
from html.parser import HTMLParser


class CSSBudget(HTMLParser):
    """Counts what AMP counts: the amp-custom stylesheet plus every style attribute."""

    def __init__(self):
        super().__init__()
        self.sheet, self.inline, self.in_sheet = 0, [], False

    def handle_starttag(self, tag, attrs):
        attrs = dict(attrs)
        self.in_sheet = tag == "style" and "amp-custom" in attrs
        if attrs.get("style"):  # quoted or not, the parser sees it
            self.inline.append(len(attrs["style"].encode()))

    def handle_endtag(self, tag):
        if tag == "style":
            self.in_sheet = False

    def handle_data(self, data):
        if self.in_sheet:
            self.sheet += len(data.encode())


p = CSSBudget()
p.feed(open(sys.argv[1], encoding="utf-8").read())
total = p.sheet + sum(p.inline)
print(f"stylesheet {p.sheet} B, {len(p.inline)} style attributes, {sum(p.inline)} B inline")
print(f"total {total} / 75000")

Le passage par un vrai analyseur HTML n’est pas une coquetterie. Mon premier essai reposait sur une expression régulière, et il a réussi deux erreurs à la fois. Hugo minifie le HTML et retire les guillemets autour des valeurs d’attribut quand il le peut : style=display:flex est du HTML valide, et une recherche limitée à style=" ne le voit pas. Élargie, la même recherche attrapait le mot style= partout où il apparaît dans le texte, y compris dans les blocs de code de cet article. L’analyseur de la bibliothèque standard ne connaît que les vrais attributs, avec ou sans guillemets.

DevOps Dave : Mon site AMP est validé, j’ai vérifié la page d’accueil et deux articles.

Security Sarah : Lesquels ?

DevOps Dave : Les plus récents. Des billets d’humeur, sans code.

Security Sarah : Donc tu as validé les pages qui ne risquent rien.

La limite d’AMP ne mesure pas ton thème, elle mesure ta page la plus lourde. Et ta page la plus lourde est presque toujours celle où tu as le plus travaillé. Il y a une certaine justice poétique à ce qu’un article sur les trous que l’ORM ne bouche pas ait révélé celui de ma feuille de style. Je l’ai bouché, sans ORM, et en une ligne.



  1. La documentation d’AMP sur le style et la mise en page l’écrit en trois phrases : « Each AMP page has a 75,000 byte CSS limit. Styles defined in the head of the document and inline count towards this limit. » Puis : « Each instance of an inline style has a 1,000 byte limit. » La limite était de 50 000 octets jusqu’à son relèvement en mars 2020, ce qui aurait fait échouer cette page bien plus tôt, et peut-être plus utilement. ↩︎

  2. La documentation de Hugo sur le réglage markup.highlight décrit noClasses comme le choix d’utiliser des styles inline plutôt qu’un fichier CSS, avec true comme valeur par défaut. Elle renvoie vers hugo gen chromastyles pour générer la feuille de style correspondant au thème de couleurs choisi. ↩︎ ↩︎

  3. La spécificité se lit comme un nombre à trois chiffres : identifiants, puis classes, puis éléments. div#main table td vaut (1,0,3), .chroma .lntd vaut (0,2,0), et la comparaison s’arrête au premier chiffre qui diffère. Le guide de MDN sur la spécificité le détaille, avec la précision qui compte ici : aucune quantité de classes ne compense un identifiant. ↩︎

  4. La page d’AMP sur les styles autorisés est formelle : « Use and reference to !important is not allowed. This is a necessary requirement to enable AMP to enforce its element sizing rules. » ↩︎