Le CMS · 5

Le convertisseur
et ses arbitrages

La récursion qui transforme le JSON en HTML tient en vingt lignes. Tout le reste est du jugement technique : l'ordre du <head>, l'arbitrage sur le CSS, la construction des images, et neuf contrôles qui peuvent refuser de publier.

Contrôles bloquants
9
aucun n'est un avertissement
HTML produit
−61 %
816 Ko → 315 Ko
Attributs style=
0
contre 136 sur l'ancien
Compilation
43 ms
26 pages, deux langues
§1

Le cœur tient en vingt lignes

La conversion elle-même est une fonction récursive triviale : descendre l'arbre, appeler le composant correspondant à chaque nœud, concaténer.

Tout le reste du convertisseur est du jugement — l'ordre du <head>, l'arbitrage sur le CSS, la construction des images, les contrôles de refus. C'est là que se joue la qualité du code produit, pas dans la récursion.

def rendre(noeud, ctx):
    composant = REGISTRE[noeud.type]
    return composant(noeud, ctx)

# chaque composant décide de sa balise, et rend ses enfants
def section_html(n, ctx):
    id_titre = ctx.identifiant("t")
    titre = titre_html(n, ctx, id_titre=id_titre)
    return (f'<section aria-labelledby="{id_titre}">'
            f'{titre}{ctx.enfants(n)}</section>')

Un <section> sans nom accessible n'est pas une région. Sans aria-labelledby pointant sur son propre titre, un lecteur d'écran la traite comme un simple <div>. C'est un point mal connu, et il rend inutile la moitié des <section> du web. Le convertisseur le pose systématiquement.

W3C — WAI-ARIA Authoring Practices, landmark regions
§2

L'ordre du <head> n'est pas arbitraire

Le navigateur lance un scanner de préchargement qui lit le HTML en avance pour commencer à télécharger avant même d'avoir construit le DOM. Ce scanner est bloqué par tout script synchrone.

Un <script> placé haut dans le <head> arrête donc la découverte de tout ce qui suit — y compris la feuille de style et la police. L'ordre ci-dessous est celui que le convertisseur applique, et chaque rang a un motif.

 1. <meta charset>            DOIT tenir dans les 1024 premiers
                              octets. Sinon le navigateur redémarre
                              l'analyse et jette tout le travail.

 2. <meta viewport>           conditionne la mise en page ; après le
                              CSS, elle serait calculée fausse.

 3. <meta color-scheme>       avant tout style, pour éviter le flash
                              d'inversion sur Samsung Internet.

 4. <title>                   tôt : c'est ce que l'onglet affiche
                              pendant le chargement.

 5. preload des polices       sinon elles ne sont découvertes que
                              dans le CSS, donc bien trop tard.

 6. feuille de style          voir §3 — l'arbitrage est mesuré.

 7. le reste des métadonnées  description, canonique, Open Graph :
                              aucune n'affecte le rendu.

 8. JSON-LD                   en dernier : le robot lit tout le
                              document, il n'est pas pressé.
Le contrôle du charset, sur les octets réels

La règle des 1024 octets ne se vérifie pas en lisant le code : elle dépend de la longueur réelle du titre, des préchargements, de tout ce qui précède. Le convertisseur la vérifie donc sur les octets produits.

tete_octets = html[:1024].encode("utf-8")
if b"charset" not in tete_octets:
    raise ErreurTete(
        "<meta charset> au-delà des 1024 premiers octets : "
        "le navigateur redémarrera l'analyse du document.")

Le préchargement d'une police exige crossorigin. Sans cet attribut, la police est téléchargée deux fois : une fois par le préchargement, une fois par le CSS — les deux requêtes n'étant pas considérées comme équivalentes. C'est une des erreurs de performance les plus répandues, et elle coûte le double du poids de la police.

§3

CSS en ligne ou externe : un arbitrage mesuré

La littérature de performance recommande d'inliner le CSS critique pour supprimer une requête du chemin critique. Elle ajoute deux réserves que la plupart des articles omettent.

Le convertisseur tranche sur une mesure, pas sur un principe.

RéserveConséquence ici
Le CSS en ligne n'est jamais mis en cachesur 13 pages, il serait retéléchargé 13 fois — soit 12 × 28 Ko gaspillés dès la deuxième page
Au-delà d'environ 14 Ko compressés (la charge du premier aller-retour), l'inlining retarde la découverte du reste du documentnotre feuille fait 28 Ko — au-dessus du seuil

Décision : feuille externe, avec préchargement pour compenser la requête supplémentaire. Sur un site d'une seule page, la décision inverse serait la bonne — c'est pourquoi elle est prise sur une mesure et non sur une règle générale.

§4

La balise image, et ses quatre garanties

Chaque garantie est adossée à un défaut mesuré, pas à une bonne pratique de principe.

1 · width et height toujours présents

Sans eux, le navigateur ne connaît pas le rapport d'aspect avant le téléchargement : il réserve zéro pixel, puis pousse tout le contenu quand l'image arrive. Première cause de décalage de mise en page (CLS), et entièrement évitable ici puisque les dimensions sont dans la médiathèque.

2 · Chaîne AVIF → WebP → JPEG, dans cet ordre

L'ordre des <source> est normatif : le premier type reconnu gagne. Un src AVIF nu est une image cassée sur Safari 16.3 et antérieur.

3 · Une seule image prioritaire par page

fetchpriority="high" sur trois images équivaut à ne le mettre nulle part : la priorité est relative. Le convertisseur compte, et refuse la seconde.

Sur l'ancien site, ce contrôle relève 6 pages avec des images prioritaires concurrentes.

4 · Jamais de loading="lazy" au-dessus de la ligne de flottaison

Mesure publiée par web.dev : différer l'image du hero dégrade le LCP de 13 % sur ordinateur et 15 % sur mobile.

web.dev — LCP and lazy loading

Le piège de l'attribut sizes

C'est sizes, et non srcset, qui décide de la variante téléchargée. Le navigateur choisit avant d'appliquer le CSS : il ne peut donc pas deviner qu'une image occupera 45 % de la largeur.

Un sizes faux coûte des octets à chaque visite sans rien changer à l'écran — le défaut est invisible à l'œil et ne se voit que dans l'onglet réseau. Sur l'ancien site, un 100vw au lieu de 45vw faisait télécharger la variante 1920 px pour une image affichée à 641 px.

# Largeurs MESURÉES dans le navigateur, pas estimées.
SIZES = {
    "hero":        "100vw",
    "etape__fig":  "(min-width:1024px) 45vw, 100vw",  # 2 col.
    "carte__fig":  "(min-width:1024px) 30vw, 100vw",  # 3 col.
    "grp__fig":    "(min-width:1024px) 23vw, 45vw",   # 4 col.
}
§5

Ce que le balisage produit garantit

Le convertisseur n'émet pas de <div> par défaut. Chaque composant choisit la balise que son rôle impose, et pose les attributs d'accessibilité que cette balise exige.

Le tableau compare le résultat mesuré sur les mêmes 13 pages.

ÉlémentAncien siteProduitMotif
<article>131190un contenu autonome et redistribuable
aria-label / labelledby65195sans nom, une région n'est pas une région
style=1360toute valeur vient d'un jeton
text-indent:-9999px140masquage hors écran, illisible au clavier
Poids total (13 pages)816 Ko315 Ko−61 %
Le double contrôle de hiérarchie, et pourquoi il existe

document.py contrôle la hiérarchie sur l'arbre. navigateurs.py la recontrôle sur les octets produits. La redondance est délibérée, et elle a une histoire.

Le contrôle sur l'arbre a laissé passer une page à deux <h1> — parce qu'il ne comptait que les types figurant dans sa propre liste de sectionnants, et que le second h1 venait d'un composant absent de cette liste.

Un contrôle sur le produit fini n'a pas d'angle mort de ce genre : il compte des balises, pas des intentions.

def verifier_html(html, page=""):
    corps = html.split("<body", 1)[-1]
    corps = re.sub(r"<(script|style)\b.*?</\1>", "", corps,
                   flags=re.S | re.I)

    h1 = re.findall(r"<h1\b[^>]*>(.*?)</h1>", corps, re.S)
    if len(h1) == 0:
        fautes.append("aucun h1 : la page n'annonce pas "
                      "son sujet dans son contenu.")
    elif len(h1) > 1:
        fautes.append(f"{len(h1)} h1 sur la page…")

    niveaux = [int(x) for x in re.findall(r"<h([1-6])\b", corps)]
    for i in range(1, len(niveaux)):
        if niveaux[i] > niveaux[i - 1] + 1:
            fautes.append(f"saut de h{niveaux[i-1]} "
                          f"à h{niveaux[i]}")
§6

Les budgets, et les chiffres écartés

Un budget qu'on ne mesure pas n'est pas un budget. Ces contrôles s'exécutent sur le HTML réellement produit.

Chaque seuil porte sa source. Les seuils sans source sont absents — plusieurs chiffres très répandus dans la littérature SEO n'ont pas résisté à la vérification, et les inscrire aurait donné une fausse assurance.

La limite de 2 Mo, et le mythe des 15 Mo

Googlebot récupère les 2 premiers Mo d'un fichier HTML, puis interrompt le téléchargement — la suite n'est pas indexée.

Le chiffre de 15 Mo, très répandu, concerne l'infrastructure générale de crawl de Google, pas Googlebot sur du HTML.

Point décisif, presque toujours omis : la limite s'applique aux données décompressées. Brotli n'agrandit pas ce budget.

Le nombre de nœuds DOM, et une limite assumée

Lighthouse avertit au-delà de 1 400 nœuds et signale un problème au-delà de 3 000. Ce n'est pas un facteur de classement : c'est un indicateur de coût de rendu.

Deux pages dépassent le seuil d'avertissement : partenaires (962) et annuaire (804) restent en dessous, mais l'annuaire porte 64 fiches. C'est une limite acceptée et documentée, pas un défaut masqué — réduire le nombre de nœuds impliquerait de paginer un annuaire que les visiteurs parcourent en une fois.

§7

Ce qui arrête la compilation

Aucun de ces contrôles n'est un avertissement. Ils produisent un arrêt, un message qui nomme la faute, et aucun fichier écrit.

ContrôleMotif
propriété non déclarée, composant inconnuune faute de frappe ferait disparaître du contenu en silence
taille de texte sans composante remle texte ne grossirait pas au zoom (WCAG 1.4.4, F94)
contraste sous 4,5:1calculé sur les valeurs réelles (WCAG 1.4.3)
image sans texte alternatif, sans width/heightdécalage de mise en page, et image muette
zéro ou deux h1, saut de niveauvérifié sur les octets produits (WCAG 1.3.1)
deux images prioritairesla priorité est relative — deux « high » s'annulent
clé absente d'une traductioncette langue seule n'est pas compilée
règle CSS qui casse un moteurvoir la page Navigateurs
<meta charset> au-delà de 1024 octetsle navigateur redémarrerait l'analyse

La preuve que ces contrôles ne sont pas complaisants. Appliqués à l'ancien site, ils relèvent 75 problèmes : 28 de compatibilité navigateur, 27 d'accessibilité, 14 d'ordre du <head>, 6 d'images prioritaires. Sur le nouveau site : zéro. Un contrôle qui ne trouve jamais rien ne prouve rien.