Le CMS · 4

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.

Composants
25
le vocabulaire complet
Sectionnants
6
seuls à décaler les titres
Vocabulaires fermés
5
propriétés à liste finie
Niveau de titre
calculé
jamais choisi par le rédacteur
§1

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.

§2

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.

§3

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.

La profondeur dans l'arbre, à gauche, détermine le niveau des titres produits, à droite. Le rédacteur ne choisit que l'imbrication.
La profondeur dans l'arbre, à gauche, détermine le niveau des titres produits, à droite. Le rédacteur ne choisit que l'imbrication.
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.

§4

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

https://www.w3.org/TR/wai-aria-practices/#aria_lh_region

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

https://web.dev/articles/preload-scanner

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

§5

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 admisesNombre
fondblanc · clair · sombre3
dispositiongrille_2 · grille_3 · grille_4 · liste · pile · rangee6
style (bouton)clair · plein · sombre3
style (liste)cochee · filets · nue · puces4
role (paragraphe)chapo · mention · secondaire3
§6

É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.

§7

É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.

ApprocheCe que le rédacteur écritCe 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.