Chapitre 07Construire
Le modèle de contenu
Trois types de champs, pas un de plus. Un fichier JSON par page et par langue, validé par le même schéma au build et à l'écriture.
Un fichier par page et par langue
src/content/pages/
├── fr/
│ ├── home.json
│ ├── services.json
│ └── contact.json
└── en/
├── home.json
├── services.json
└── contact.json
Le nom du fichier devient le segment d'URL : services.json répond à
/fr/services/. Le fichier home.json est un cas particulier : il
répond à la racine de sa langue, /fr/.
Minuscules, chiffres et traits d'union uniquement — c'est la liste blanche qu'applique la
fonction d'écriture : src/content/pages/{langue}/{page}.json. Un
Mon_Fichier.json serait construit par Astro mais refusé à la publication.
La forme d'une page
Trois sections, toujours les mêmes :
{
"meta": {
"title": "Boulangerie Martin — Accueil",
"description": "Pain au levain cuit chaque matin, à Nantes."
},
"blocks": {
"hero": {
"title": {
"type": "text",
"value": "Le pain au levain, tous les matins",
"style": { "size": "3xl", "weight": "bold", "align": "center", "color": "primary" }
},
"intro": {
"type": "richtext",
"value": "Nous <strong>pétrissons</strong> chaque nuit. <a href=\"/contact\">Passez nous voir</a>."
},
"visual": {
"type": "media",
"kind": "image",
"src": "fournil-au-petit-matin.webp",
"alt": "Le fournil au petit matin",
"width": 1600,
"height": 900
}
}
},
"collections": {
"avis": [
{
"id": "a-001",
"quote": { "type": "text", "value": "Le meilleur pain de la ville.", "style": { "size": "lg", "italic": true } },
"author": { "type": "text", "value": "Claire D.", "style": { "size": "sm", "color": "muted" } }
}
]
}
}| Section | Rôle | Obligatoire |
|---|---|---|
meta | Titre, description, image de partage. Alimente <title>, la meta description, Open Graph. | oui |
blocks | Les champs simples, groupés par bloc : blocks.hero.title. | oui |
collections | Les listes : témoignages, services, réalisations. | non |
blocks a exactement deux niveaux : un nom de bloc, puis un nom de
champ. Ce n'est pas une limitation arbitraire — c'est ce qui rend un chemin lisible dans
l'attribut data-cms et vérifiable au build.
Les trois types de champs
text — le cas courant
{
"type": "text",
"value": "Le pain au levain, tous les matins",
"style": {
"size": "3xl",
"weight": "bold",
"italic": false,
"align": "center",
"color": "primary"
}
}
Titres, labels, paragraphes sans mise en forme interne. Le champ style est
facultatif dans le fichier : chaque clé a une valeur par défaut
(base, regular, false, left,
primary).
richtext — quand il faut de l'emphase ou un lien
{
"type": "richtext",
"value": "Un accompagnement <strong>complet</strong>. <a href=\"/contact\">Parlons-en</a>."
}Pas d'objet style : la mise en forme est dans le balisage. Balises autorisées :
strong, em, a[href], br, ul,
ol, li. Sept balises, un seul attribut. Tout le
reste est retiré — côté navigateur au collage, et côté serveur avant écriture.
Chaque balise autorisée est une décision de mise en page rendue au client. Un
<h2> autorisé en richtext, et la hiérarchie des titres — donc le
référencement — devient modifiable par accident. La liste courte est la frontière du
chapitre 1, appliquée au balisage.
media — image
{
"type": "media",
"kind": "image",
"src": "fournil-au-petit-matin.webp",
"alt": "Le fournil au petit matin",
"width": 1600,
"height": 900
}src— un nom de fichier desrc/media/, pas un chemin. Minuscules, chiffres, traits d'union, extensionjpg,pngouwebp.alt— obligatoire, non vide. Libellé « Description de l'image » côté client.width/height— entiers positifs, calculés côté serveur à l'envoi.
media — vidéo
{
"type": "media",
"kind": "video",
"provider": "youtube",
"videoId": "aqz-KE-bpKQ",
"title": "Une nuit au fournil"
}
Aucun fichier vidéo n'est jamais téléversé. Un fichier lourd dans Git casse
le dépôt et les builds. Le client colle un lien, l'outil en extrait le fournisseur et
l'identifiant. provider vaut youtube ou vimeo ;
poster est facultatif.
Les tokens de style
Cinq axes, des listes fermées. Ces valeurs viennent d'un fichier unique dont le schéma Zod tire ses enums et dont la barre d'outils de l'overlay construit ses boutons : un bouton proposant une valeur que le build refuserait est impossible par construction.
| Clé | Valeurs | Défaut |
|---|---|---|
size | xs · sm · base · lg · xl · 2xl · 3xl | base |
weight | thin · light · regular · medium · semibold · bold | regular |
italic | true · false | false |
align | left · center · right | left |
color | primary · secondary · muted · accent · inverse | primary |
Chaque valeur devient une classe (cms-size-3xl, cms-color-accent…)
qui pointe vers une variable CSS de la charte du site. Aucune valeur en dur nulle part —
voir Charte et styles.
Les listes
Une liste est un tableau d'items sous collections. Chaque item porte un
id et des champs comme ailleurs.
"collections": {
"avis": [
{ "id": "a-001", "quote": { … }, "author": { … } },
{ "id": "a-002", "quote": { … }, "author": { … } }
]
}
Format imposé : une lettre minuscule, un tiret, au moins trois chiffres —
a-001, t-014. Un identifiant est stable et jamais
réattribué, même après suppression : c'est la clé qui relie le DOM au JSON. Le
réutiliser rattacherait les modifications d'un élément disparu à un élément neuf. La
fonction d'écriture refuse tout doublon dans une même liste.
Un tableau vide est valide : une page dont le client a retiré tous les témoignages reste une page. Les items se réconcilient par identifiant, pas par position — une traduction peut donc ranger ses témoignages dans un autre ordre sans que les textes ne glissent d'un item à l'autre.
La configuration du site
src/content/site.json porte ce qui est commun à toutes les pages. Il est validé
au build par le même schéma que le reste, mais il est hors de la liste blanche
d'écriture : le client ne peut pas le modifier depuis l'overlay.
{
"name": "Boulangerie Martin",
"locale": "fr",
"navigation": [
{ "label": "Accueil", "href": "/" },
{ "label": "Nous contacter", "href": "mailto:contact@boulangerie-martin.fr" }
],
"contact": {
"email": "contact@boulangerie-martin.fr",
"phone": "02 40 00 00 00",
"address": "3 rue du Four, 44000 Nantes"
},
"footer": { "legal": "© Boulangerie Martin — Tous droits réservés" }
}C'est de la structure — navigation, coordonnées, mentions — donc du ressort du développeur. La frontière est posée dès la livraison, et elle est appliquée par le code, pas seulement annoncée.
Les contraintes, en un tableau
| Champ | Contrainte | Ce qui se passe sinon |
|---|---|---|
meta.title | 60 caractères au plus | Build en échec, publication refusée (422) |
meta.description | 160 caractères au plus | idem |
meta.ogImage | facultatif | — |
text.value | chaîne, peut être vide | — |
style.* | valeur de l'enum | Build en échec — aucun repli silencieux |
image.alt | non vide | Build en échec — pas de alt vide accepté |
image.src | fichier de src/media, liste blanche | Publication refusée (422) |
image.width/height | entiers positifs | Build en échec |
video.provider | youtube ou vimeo | Build en échec |
video.videoId | 11 caractères (YouTube), chiffres (Vimeo) | Publication refusée (422) |
item.id | ^[a-z]-\d{3,}$, unique dans sa liste | Build ou publication en échec |
| fichier entier | 100 Ko au plus | Publication refusée (413) |
Un seul schéma, deux usages
Le schéma Zod vit dans inline-core/src/schema.ts, un fichier qui n'importe
pas astro:content. C'est la condition pour que la fonction
d'écriture — qui s'exécute hors du contexte Astro — puisse importer le
même objet que le build.
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { pageSchema } from 'inline-core/schema';
const pages = defineCollection({
loader: glob({ pattern: '**/*.json', base: './src/content/pages' }),
schema: pageSchema,
});
export const collections = { pages };Un contenu qui ferait échouer le build ne peut pas entrer dans le dépôt : la fonction le refuse avant l'écriture. Il n'existe pas d'état où le site ne construit plus à cause d'une publication du client.
Étendre le modèle
Ajouter un type de champ (un tableau de données, un fichier PDF, un bloc de code) se fait dans
schema.ts, donc dans le paquet partagé, donc pour tous les sites. Trois
conséquences à peser avant :
- il faut un composant de rendu, un geste d'édition dans l'overlay, et une classe CSS si le champ a des variantes ;
- l'assainissement serveur doit savoir quoi en faire ;
- toute modification qui invaliderait du contenu déjà publié est une version majeure, même si le code compile — c'est le contenu du client qui casse, pas le nôtre.
Poser ces champs dans une page : Ajouter une page.