Le langage
dans lequel le site est écrit
25 composants, 19 valeurs fermées, et une règle : le rédacteur déclare une intention, jamais du balisage. Le niveau des titres est calculé depuis l'arbre — un saut de hiérarchie n'est pas détecté, il est inexprimable.
Un langage, pas un éditeur
Le cœur du CMS n'est ni une interface ni un thème : c'est un vocabulaire fini de 25 composants dans lequel le site entier est écrit.
La différence avec un éditeur visuel est de nature, pas de degré. Dans un éditeur, l'utilisateur produit du balisage — et peut donc produire du mauvais balisage. Ici, il déclare une intention, et le convertisseur décide du balisage. Les fautes ne sont pas détectées : elles sont inexprimables.
C'est aussi ce qui rend le système utilisable par un agent IA. Un agent qui produit du HTML libre produit du HTML plausible ; un agent qui produit ce JSON produit du HTML correct, parce que le seul HTML atteignable est celui que le convertisseur sait écrire.
{
"type": "section",
"ancre": "concept",
"titre": "@accueil.concept.titre",
"chapo": "@accueil.concept.chapo",
"fond": "clair",
"contenu": [
{ "type": "paragraphe", "texte": "@accueil.concept.p1" },
{ "type": "bouton", "texte": "@accueil.cta", "vers": "#pass" }
]
}
Ce que le rédacteur ne peut pas écrire dans cet exemple : un niveau de titre, une taille de police, une couleur hexadécimale, une classe CSS, une balise HTML. Aucun de ces éléments n'existe dans le langage.
La règle absolue
Une propriété qui n'est déclarée ni obligatoire ni facultative fait échouer la compilation.
Ce n'est pas une sévérité gratuite. Une faute de frappe tolérée ferait disparaître du contenu en silence — le cas le plus coûteux, parce qu'il ne se voit ni à la compilation ni à la relecture.
Une propriété absente de « proprietes_facultatives » ou « proprietes_obligatoires » fait ÉCHOUER la compilation. Ce n'est pas une tolérance : une faute de frappe ferait disparaître le contenu en silence.
$ python3 outils/valider.py page.json
✗ page.json
page › section : propriété « couleur » non reconnue.
Vouliez-vous « chapo » ?
page › section : « fond » vaut « bleu », attendu :
blanc, clair, sombre.
page › section › bouton : propriété obligatoire « vers »
absente.
3 faute(s)
Le message nomme la faute et la sortie. Une erreur qui n'indique pas les valeurs attendues oblige à lire le code source — c'est-à-dire à connaître le convertisseur pour écrire une page.
Le niveau de titre est calculé, jamais choisi
C'est le point central du langage. Le rédacteur écrit "titre": "…" dans une section ; le niveau HTML est déduit de la profondeur de cette section dans l'arbre.
Conséquence directe : un saut de hiérarchie (h1 → h3) devient inexprimable. Pas « détecté par un contrôle » — il n'existe aucune manière de l'écrire dans le JSON.
Taille et niveau sont indépendants. Un titre de niveau 3 peut être visuellement énorme : la taille vient d'un jeton, le niveau vient de la structure. C'est ce qui permet la liberté graphique sans jamais compromettre le plan du document.
L'algorithme, et sa subtilité
Seuls les types dits sectionnants font descendre d'un niveau : article, carte, etape, page, section, sous_section.
Un groupe ou une grille sont des conteneurs de mise en page : ils n'ouvrent pas de section et ne décalent donc pas les titres. Sans cette distinction, une grille à trois colonnes ferait descendre les titres d'un niveau pour une raison purement visuelle.
Subtilité supplémentaire : une section sans titre ne descend pas d'un niveau. Elle regroupe visuellement, mais n'introduit aucun palier dans le plan du document — c'est le comportement attendu, et il évite des niveaux fantômes.
SECTIONNANTS = {"page","section","sous_section",
"article","carte","etape"}
JAMAIS_H1 = {"appel"} # ajouté après le bug des deux h1
def descendre(n, niveau, chemin):
n.niveau = niveau
for e in n.enfants:
# Une section SANS TITRE ne descend pas d'un niveau.
sectionne = (e.type in SECTIONNANTS and
(e.props.get("titre") or e.type == "page"))
if e.type in JAMAIS_H1:
descendre(e, max(2, niveau + 1), chemin)
elif sectionne:
descendre(e, niveau + 1, chemin)
else:
descendre(e, niveau, chemin)Ce que Google en dit réellement
Plus nuancé que ce qu'affirment les guides SEO :
Ils nous aident à mieux comprendre le contenu. […] Ce n'est pas un facteur de classement critique.— John Mueller, Google, 2021
Le saut de niveau n'est donc pas une faute de référencement. C'est une faute d'accessibilité : WCAG 1.3.1, niveau A. Un lecteur d'écran annonce « titre niveau 3 » après un niveau 1, et l'utilisateur cherche la section intermédiaire qui n'existe pas.
La règle est appliquée pour la raison qui la justifie vraiment, pas pour celle qu'on répète. Il en va de même du H1 unique : Google a confirmé plusieurs fois que plusieurs H1 ne posent aucun problème. Le langage n'en impose qu'un parce que le titre principal d'un document est une notion éditoriale — pas parce qu'un robot le sanctionnerait.
Les 25 composants, un par un
Chaque dépliant donne le rôle du composant, ses propriétés, s'il accepte du contenu imbriqué, s'il ouvre une section — et la raison technique de son balisage.
Ces fiches sont engendrées depuis docs/langage.json, lui-même engendré depuis le code. Elles ne peuvent pas décrire un composant qui n'existe plus.
Structure — 4 composants
Ces composants portent l'architecture du document. Ce sont eux — et eux seuls — qui décident du niveau des titres.
page
parce que le squelette contrôle aussi ce qui vient AVANT et APRÈS <main> —
l'en-tête, le lien d'évitement, le pied de page.
Le h1 est normalement porté par le hero. Une page sans hero — un
annuaire, une liste — doit quand même annoncer son sujet : le titre de
page devient alors le h1 de sa première section.
Le mécanisme est signalé au contexte AVANT le rendu des enfants, pour que la première section titrée sache qu'elle porte le h1. Sans cela, la page n'aurait aucun h1 — ce que le contrôle final refuse, à juste titre : une page dont le sujet n'apparaît que dans <title> ne l'annonce ni au lecteur, ni au robot, ni au lecteur d'écran.
Obligatoires : titre
Facultatives : description_seo, image_partage, indexable, langue, prix, surtitre, titre_seo, type_page, url
Contenu imbriqué : oui · Ouvre une section : oui — les titres qu'il contient descendent d'un niveau
section
<section> reçoit un nom accessible via aria-labelledby pointant sur
son propre titre. Sans ce nom, une <section> n'est PAS annoncée comme
région par les lecteurs d'écran — elle est traitée comme un <div>.
C'est un point mal connu et il rend la moitié des <section> du web
inutiles.
Facultatives : ancre, chapo, fond, largeur, surtitre, surtitre_droite, titre, titre_accentue
Contenu imbriqué : oui · Ouvre une section : oui — les titres qu'il contient descendent d'un niveau
hero
sectionnant=False alors qu'il produit une <section> : le hero n'ouvre
pas un niveau sous la page, il EST l'en-tête de la page. Son titre est
donc le h1, au même niveau que la page elle-même.
Le titre n'est pas répété dans le hero : il est déclaré une seule fois, sur la page. Un titre saisi deux fois finit toujours par diverger.
L'image est en <img> réel, jamais en background-image CSS : le
scanner de préchargement ne lit QUE le HTML, jamais le CSS. Une image LCP
en fond CSS n'est découverte qu'après construction du CSSOM — plusieurs
centaines de millisecondes perdues.
Facultatives : alt_fond, ancre, image_fond, surtitre, surtitre_droite
Contenu imbriqué : oui · Ouvre une section : non
groupe
Ne porte AUCUNE sémantique : c'est un <div> assumé. Un <div> honnête
vaut mieux qu'une <section> décorative — une <section> sans nom
accessible ajoute du bruit au plan du document sans rien y apporter.
Facultatives : disposition, espace
Contenu imbriqué : oui · Ouvre une section : non
Texte — 6 composants
Le contenu rédactionnel. Aucun ne choisit sa taille : elle découle du rôle déclaré.
paragraphe
Obligatoires : texte
Facultatives : role
Contenu imbriqué : non · Ouvre une section : non
etiquette
<p> et non <span> : c'est un bloc de texte autonome. Et surtout,
le texte est stocké en casse NORMALE, la majuscule venant de
text-transform. Un lecteur d'écran qui reçoit « NOTRE MÉTHODE » en
capitales réelles peut l'épeler lettre par lettre ; avec
text-transform, il lit le texte source.
Obligatoires : texte
Contenu imbriqué : non · Ouvre une section : non
citation
<blockquote> + <cite>, sans balisage Review en données structurées :
Google interdit explicitement l'auto-attribution d'avis sur sa propre
entité, et le balisage Review posé par le site sur lui-même n'est plus
éligible aux résultats enrichis depuis 2019.
Obligatoires : texte
Facultatives : auteur, role_auteur
Contenu imbriqué : non · Ouvre une section : non
https://developers.google.com/search/blog/2019/09/making-review-rich-results-more-helpful
exergue
<blockquote> et non un <p> stylé : le texte EST une citation. Un
lecteur d'écran l'annonce alors comme telle, et le lecteur sait qu'il
entend des mots rapportés et non la voix du site.
Aucune donnée structurée Review n'accompagne ces citations : Google
n'admet pas qu'un site publie des avis sur lui-même, et ce balisage
n'est plus éligible aux résultats enrichis depuis 2019.
Obligatoires : texte
Facultatives : auteur
Contenu imbriqué : non · Ouvre une section : non
liste
Le style visuel (puces, numéros dessinés, filets) est une classe.
L'ordre a-t-il un sens ? → <ol>. C'est la seule question posée au
rédacteur, et elle est sémantique.
Obligatoires : items
Facultatives : ordonnee, style
Contenu imbriqué : non · Ouvre une section : non
chapitre
<article> : un chapitre garde son sens hors de la page — c'est le
critère de la spécification pour cet élément, et non « article de blog ».
Obligatoires : titre
Facultatives : etiquette, textes
Contenu imbriqué : non · Ouvre une section : oui — les titres qu'il contient descendent d'un niveau
Navigation — 3 composants
Tout ce qui mène ailleurs. Un bouton qui navigue est toujours un lien — jamais un <button>.
bouton
Toujours <a href>, jamais <button onclick> : Google ne suit que les
<a> porteurs d'un href. Un « bouton » de navigation en <button>
rend la destination invisible au robot ET inaccessible au clic milieu,
à l'ouverture en onglet, au partage.
nouvelle_fenetre ajoute rel="noopener" automatiquement : sans lui,
la page ouverte peut manipuler window.opener. Le rédacteur ne peut pas
l'oublier puisqu'il ne l'écrit pas.
Obligatoires : texte, vers
Facultatives : nouvelle_fenetre, style
Contenu imbriqué : non · Ouvre une section : non
https://developers.google.com/search/docs/crawling-indexing/links-crawlable
lien
Obligatoires : texte, vers
Facultatives : nouvelle_fenetre
Contenu imbriqué : non · Ouvre une section : non
fil
<nav aria-label> + <ol> : la position dans la hiérarchie du site est
une information ORDONNÉE — troisième niveau, pas « un des niveaux ».
Le dernier élément n'est pas un lien (on y est déjà) et porte
aria-current="page", ce qui permet à un lecteur d'écran d'annoncer
« page actuelle » au lieu de laisser croire à un lien mort.
Aucun balisage BreadcrumbList n'est émis ici : Google extrait le fil
d'Ariane du HTML sémantique. Le JSON-LD est ajouté par le compilateur au
niveau du document, où il peut être cohérent avec l'URL réelle.
Obligatoires : items
Contenu imbriqué : non · Ouvre une section : non
Média — 1 composants
Les images, produites avec leurs variantes et leurs dimensions.
image
Le texte alternatif n'est PAS déclaré ici : il vit dans la bibliothèque, avec la photo. Une image montre la même chose quelle que soit la page qui l'affiche, donc sa description n'a aucune raison d'être saisie plusieurs fois. Elle l'était : 43 alt dupliqués, dont un déjà divergent sur « t-camille-4 » — deux descriptions pour une seule photo.
Il reste REQUIS, mais au niveau de la bibliothèque : une photo sans alt
fait échouer la compilation. Une image décorative se déclare alt: ""
explicitement, parce que la différence entre « rien à dire » et « oubli »
ne se devine pas. Un alt manquant fait lire le nom du fichier par le
lecteur d'écran ; un alt vide fait sauter l'image proprement.
La propriété alt reste acceptée sur la page pour le seul cas légitime :
ajouter un contexte que la photo seule ne porte pas.
priorite: true sur l'image LCP produit fetchpriority="high" et
loading="eager". Une seule image par page peut le porter — le
convertisseur le vérifie, parce que trois images « prioritaires »
équivalent à aucune.
Obligatoires : source
Facultatives : alt, cadre, legende, priorite, vers
Contenu imbriqué : non · Ouvre une section : non
Blocs composés — 6 composants
Des motifs récurrents, encapsulés pour que leur balisage soit correct partout.
carte
<article> parce qu'une carte d'offre est autonome — elle garde son
sens sortie de la page (c'est le critère de la spécification HTML pour
<article>, et non « un article de blog »).
Obligatoires : titre
Facultatives : alt, bouton, description, etiquette, image, items, marque, prix, style_bouton, vers
Contenu imbriqué : non · Ouvre une section : oui — les titres qu'il contient descendent d'un niveau
https://html.spec.whatwg.org/multipage/sections.html#the-article-element
etape
Le numéro est décoratif et porte aria-hidden : il est déjà porté par
l'ordre du document. Un lecteur d'écran qui entend « zéro un » puis le
titre reçoit l'information deux fois.
Obligatoires : titre
Facultatives : alt, etiquette, image, numero, texte
Contenu imbriqué : non · Ouvre une section : oui — les titres qu'il contient descendent d'un niveau
moment
La citation est un vrai <blockquote> imbriqué dans l'<article> :
c'est bien une parole rapportée à l'intérieur d'un récit.
Obligatoires : titre
Facultatives : alt, citation, heure, image, texte
Contenu imbriqué : non · Ouvre une section : oui — les titres qu'il contient descendent d'un niveau
depliant
<details>/<summary> natifs, sans une ligne de JavaScript : le contenu
est dans le DOM et donc indexé, l'ouverture fonctionne sans script, la
navigation au clavier est fournie par le navigateur.
Aucun balisage FAQPage n'est émis. Google a retiré les résultats
enrichis FAQ pour la quasi-totalité des sites en août 2023, puis
complètement. Le balisage n'apporte plus rien et alourdit la page.
Obligatoires : items
Contenu imbriqué : non · Ouvre une section : non
https://developers.google.com/search/blog/2023/08/howto-faq-changes
deroule
<dl> et non une suite de <div> : c'est exactement une liste de
définitions — un terme (« 9 h 00 ») et sa description. Le balisage porte
la relation, donc un lecteur d'écran annonce l'heure AVEC son contenu au
lieu de lire deux blocs sans lien.
Chaque description est enveloppée dans un <div> : la spécification HTML
l'autorise explicitement pour grouper un <dt> avec ses <dd>, et c'est
ce qui permet à la grille de fonctionner sans casser la structure.
Obligatoires : etapes
Facultatives : legende
Contenu imbriqué : non · Ouvre une section : non
https://html.spec.whatwg.org/multipage/grouping-content.html#the-dl-element
colonnes_liste
Chaque colonne est nommée par aria-labelledby pointant sur son
étiquette. Sans ce nom, un lecteur d'écran annonce deux listes
indifférenciées : l'utilisateur entend « thé, café, collation » sans
savoir si c'est inclus ou pas — l'information la plus importante des deux.
Obligatoires : colonnes
Contenu imbriqué : non · Ouvre une section : non
Commerce — 2 composants
Les prix et leur présentation.
prix
Le montant est dans un <p> avec <strong>, pas dans un titre : un prix
n'est pas un titre de section et ne doit pas apparaître dans le plan du
document. C'est une faute fréquente — le prix est gros, donc on écrit un
<h2>, et le plan de la page se remplit de montants.
Obligatoires : montant
Facultatives : bouton, detail, vers
Contenu imbriqué : non · Ouvre une section : non
tableau_prix
Une vraie <table> avec <th scope> : c'est une donnée tabulaire
(intitulé ↔ prix). Les <div> en flex qui l'imitent — ce que faisait
l'ancien site — perdent la relation entre la cellule et son en-tête,
donc un lecteur d'écran annonce des prix sans dire de quoi.
WCAG 1.3.1.
Obligatoires : lignes
Facultatives : legende
Contenu imbriqué : non · Ouvre une section : non
Spécifiques — 3 composants
Des composants nés d'un besoin précis de ce site.
defilant
aria-hidden : c'est de la décoration typographique. Le texte y est
dupliqué pour la boucle visuelle — le laisser lisible ferait entendre
chaque mot deux fois et ferait compter les mots en double à l'analyse
de contenu.
prefers-reduced-motion arrête l'animation (voir la feuille de style) :
un mouvement continu et non contrôlable est un déclencheur reconnu de
troubles vestibulaires. WCAG 2.3.3.
Obligatoires : mots
Contenu imbriqué : non · Ouvre une section : non
annuaire
Deux décisions qui distinguent cette implémentation de l'ordinaire :
1. Les fiches sont toutes dans le HTML, filtrées côté navigateur. Un filtrage qui recharge la page, ou qui charge les fiches en JavaScript, rendrait la liste invisible aux robots. Ici les 89 fiches sont dans le document : indexables, lisibles sans script, imprimables.
2. Le filtre est un groupe de boutons aria-pressed, pas des liens.
Un lien change de page, un bouton change l'état de la page — et
aria-pressed fait annoncer « activé »/« désactivé » par le lecteur
d'écran. Des liens #maternite créeraient en plus autant d'URL
fantômes que de filtres, toutes affichant le même contenu.
Sans JavaScript, tous les boutons restent inertes et TOUTES les fiches s'affichent : la page reste complète et utilisable, simplement non filtrable. C'est la dégradation correcte.
Obligatoires : fiches
Facultatives : etiquette_filtres, filtres, recherche, vide
Contenu imbriqué : non · Ouvre une section : non
appel
Le <label> est VISIBLE, pas masqué hors écran comme sur l'ancien site.
Un placeholder n'est pas une étiquette : il disparaît dès la première frappe, ce qui laisse l'utilisateur sans indication de ce qu'il remplit — problème réel pour qui est interrompu en cours de saisie, et échec de WCAG 3.3.2 (« Étiquettes ou instructions »).
Le champ porte autocomplete="email" : le navigateur peut alors le
remplir seul. C'est un critère à part entière (WCAG 1.3.5) et un gain de
conversion direct sur mobile.
type="email" fait apparaître le clavier avec l'arobase sur mobile —
et déclenche la validation native, sans une ligne de JavaScript.
Obligatoires : titre
Facultatives : action, ancre, bouton, champ, chapo, etiquette, mention
Contenu imbriqué : oui · Ouvre une section : oui — les titres qu'il contient descendent d'un niveau
Les vocabulaires fermés
Certaines propriétés n'acceptent pas une valeur libre mais une valeur parmi une liste. Toute autre valeur arrête la compilation, en affichant les valeurs admises.
C'est le mécanisme qui rend un jeton utile : il remplace une valeur libre — où toute erreur est possible — par un choix fini dont chaque option est correcte par construction.
| Propriété | Valeurs admises | Nombre |
|---|---|---|
| fond | blanc · clair · sombre | 3 |
| disposition | grille_2 · grille_3 · grille_4 · liste · pile · rangee | 6 |
| style (bouton) | clair · plein · sombre | 3 |
| style (liste) | cochee · filets · nue · puces | 4 |
| role (paragraphe) | chapo · mention · secondaire | 3 |
Écrire une page — pour un humain ou un agent
La procédure est identique dans les deux cas, et tient en quatre étapes. C'est elle qui permet à un agent IA de produire un site complet sans jamais voir de HTML.
1. Lire docs/langage.json
→ la liste des composants, leurs propriétés, les valeurs
admises, les images disponibles, les jetons de design.
2. Écrire site/structure/ma-page.json
→ un arbre de composants. Les textes sont des références
@clé ; aucun mot n'est écrit ici.
3. Écrire les clés correspondantes dans site/textes/txt_fr.json
4. Valider python3 outils/valider.py site/structure/ma-page.json
→ retour immédiat, sans compiler, sans mobiliser la
médiathèque ni le rendu.
Pourquoi une validation séparée de la compilation. Un agent qui écrit une page se trompe presque toujours sur la même chose : un nom de composant, une propriété, une valeur fermée. La validation répond à cela en quelques millisecondes, sans charger les 37 images ni calculer les contrastes — donc sans aller-retour coûteux.
Ce que la validation ne vérifie pas
Par honnêteté, la limite doit être dite : valider.py vérifie le vocabulaire, pas le résultat.
Restent contrôlés à la compilation seule : les contrastes réels, la hiérarchie de titres effectivement produite, les budgets de poids et de nœuds DOM, et la cohérence entre les langues. Ces contrôles-là demandent le rendu complet.
Étendre le langage
Un langage à vocabulaire fini ne tient sa promesse que si les ajouts respectent la même discipline que le noyau. Un seul jeton mal conçu rouvre la porte à toutes les fautes que le reste ferme.
docs/EXTENSION.md régit tout ajout. La règle tient en une phrase.
Un nouveau jeton doit augmenter la liberté graphique en RÉDUISANT le nombre de choses fausses qu'il est possible d'écrire.
Ce n'est pas un jeu de mots. Un jeton bien conçu remplace une valeur libre par un choix fini dont chaque option est correcte. La liberté augmente pour le rédacteur, et l'espace des fautes se réduit en même temps.
Le test qu'un ajout doit passer
Pour chaque option du jeton envisagé, poser la question : cette option peut-elle produire un résultat fautif ? Si oui, l'option ne doit pas exister.
| Approche | Ce que le rédacteur écrit | Ce qui peut casser |
|---|---|---|
| Valeur libre | "taille": "4.5rem" | ne s'adapte pas ; casse sur mobile ; échappe au design system |
| Fausse contrainte | "taille": "clamp(2rem,8vw,5rem)" | pas de rem dans la borne basse → le texte rétrécit au zoom 200 % (WCAG 1.4.4) |
| Jeton | "taille": "xxl" | rien — la valeur est calculée et vérifiée |
La troisième colonne est le test. Si une option du jeton peut produire une page fautive, le jeton est mal conçu — il faut le redécouper, pas l'accepter.