FR EN

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.

7 min de lecture13 sectionsChapitre 7 / 22

Un fichier par page et par langue

text text
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/.

La contrainte de nommage

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 :

src/content/pages/fr/home.json json
{
  "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" } }
      }
    ]
  }
}
SectionRôleObligatoire
metaTitre, description, image de partage. Alimente <title>, la meta description, Open Graph.oui
blocksLes champs simples, groupés par bloc : blocks.hero.title.oui
collectionsLes 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

json json
{
  "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

json json
{
  "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.

Pourquoi si peu

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

json json
{
  "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 de src/media/, pas un chemin. Minuscules, chiffres, traits d'union, extension jpg, png ou webp.
  • 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

json json
{
  "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éValeursDéfaut
sizexs · sm · base · lg · xl · 2xl · 3xlbase
weightthin · light · regular · medium · semibold · boldregular
italictrue · falsefalse
alignleft · center · rightleft
colorprimary · secondary · muted · accent · inverseprimary

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.

json json
"collections": {
  "avis": [
    { "id": "a-001", "quote": { … }, "author": { … } },
    { "id": "a-002", "quote": { … }, "author": { … } }
  ]
}
La règle des identifiants

Format imposé : une lettre minuscule, un tiret, au moins trois chiffresa-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.

src/content/site.json json
{
  "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

ChampContrainteCe qui se passe sinon
meta.title60 caractères au plusBuild en échec, publication refusée (422)
meta.description160 caractères au plusidem
meta.ogImagefacultatif
text.valuechaîne, peut être vide
style.*valeur de l'enumBuild en échec — aucun repli silencieux
image.altnon videBuild en échec — pas de alt vide accepté
image.srcfichier de src/media, liste blanchePublication refusée (422)
image.width/heightentiers positifsBuild en échec
video.provideryoutube ou vimeoBuild en échec
video.videoId11 caractères (YouTube), chiffres (Vimeo)Publication refusée (422)
item.id^[a-z]-\d{3,}$, unique dans sa listeBuild ou publication en échec
fichier entier100 Ko au plusPublication 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.

schema.ts src/content/config.ts (build)   et routes/save.ts (écriture)
src/content/config.ts ts
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 };
Ce que ça garantit

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 :

  1. il faut un composant de rendu, un geste d'édition dans l'overlay, et une classe CSS si le champ a des variantes ;
  2. l'assainissement serveur doit savoir quoi en faire ;
  3. 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.