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.
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.
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.
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éserve | Conséquence ici |
|---|---|
| Le CSS en ligne n'est jamais mis en cache | sur 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 document | notre 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.
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.
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.
}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ément | Ancien site | Produit | Motif |
|---|---|---|---|
| <article> | 131 | 190 | un contenu autonome et redistribuable |
| aria-label / labelledby | 65 | 195 | sans nom, une région n'est pas une région |
| style= | 136 | 0 | toute valeur vient d'un jeton |
| text-indent:-9999px | 14 | 0 | masquage hors écran, illisible au clavier |
| Poids total (13 pages) | 816 Ko | 315 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]}")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.
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ôle | Motif |
|---|---|
| propriété non déclarée, composant inconnu | une faute de frappe ferait disparaître du contenu en silence |
taille de texte sans composante rem | le texte ne grossirait pas au zoom (WCAG 1.4.4, F94) |
| contraste sous 4,5:1 | calculé sur les valeurs réelles (WCAG 1.4.3) |
image sans texte alternatif, sans width/height | décalage de mise en page, et image muette |
zéro ou deux h1, saut de niveau | vérifié sur les octets produits (WCAG 1.3.1) |
| deux images prioritaires | la priorité est relative — deux « high » s'annulent |
| clé absente d'une traduction | cette langue seule n'est pas compilée |
| règle CSS qui casse un moteur | voir la page Navigateurs |
<meta charset> au-delà de 1024 octets | le 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.