Modéliser le contenu
Le schéma de ton site vit dans un fichier, cms.config.ts, à la racine du projet Astro. C’est la source de vérité : l’admin génère ses formulaires à partir de lui, le serveur valide chaque contenu contre lui, le loader en dérive les types de tes collections. Un schéma dans le repo se relit en PR, se revert avec git et voyage avec le code qui le consomme.
Le squelette du fichier
Section intitulée « Le squelette du fichier »import { collection, defineConfig, fields, singleton } from '@menestrel/fields';
export default defineConfig({ project: 'mon-site', locales: { default: 'fr', others: ['en'] }, collections: { /* ... */ }, singletons: { /* ... */ },});project: le slug du projet côté serveur, vérifié contre le token au moment du sync.locales: les langues de contenu, libres (fr,en,pt-BR…). Chaque champ décide ensuite s’il se traduit.collections: des ensembles d’entrées de même forme, chacune avec un slug et donc une URL (services, articles, réalisations).singletons: des contenus uniques, sans slug (page d’accueil, coordonnées).
Le fichier est un module pur : aucune E/S, une compilation déterministe vers un JSON canonique dont le checksum permet au CLI de détecter « aucun changement » sans rien pousser. Chaque règle violée produit une erreur avec le chemin exact du champ fautif (collections.services.fields.slug) et un code stable.
Collections et singletons
Section intitulée « Collections et singletons »Une collection porte exactement un champ slug : c’est lui qui fabrique les URLs et nomme les entrées dans les fichiers de contenu. Le raccourci slugFrom: 'titre' le déclare en une ligne ; il équivaut à fields.slug({ from: 'titre' }) sous la clé réservée slug, dérivé du champ texte source et modifiable dans l’admin. Un singleton n’a jamais de slug : il n’existe qu’en un exemplaire, sa page a déjà son adresse.
Les quinze types de champs
Section intitulée « Les quinze types de champs »| Type | Pour quoi faire |
|---|---|
text |
Texte court d’une ligne, 320 caractères max par défaut. |
textarea |
Texte long sans mise en forme, 20 000 caractères max par défaut. |
richtext |
Texte mis en forme : titres H2/H3, gras, italique, listes, liens, images, vidéos. |
number |
Nombre entier ou décimal, avec bornes et unité d’affichage (€, m²). |
boolean |
Interrupteur oui/non. |
select |
Choix dans une liste fermée, simple ou multiple. |
date |
Date seule, stockée YYYY-MM-DD. |
datetime |
Date et heure, stockées en UTC. |
image |
Image de la médiathèque, servie avec ses variantes redimensionnées. |
file |
Fichier téléchargeable (PDF, archive), types MIME restreignables. |
relation |
Lien vers une ou plusieurs entrées d’une collection. |
group |
Sous-champs regroupés visuellement dans le formulaire. |
repeater |
Liste ordonnée de blocs de même forme. |
slug |
Adresse de la page, dérivée d’un champ texte de la collection. |
seo |
Groupe préfabriqué : titre, description, image de partage, masquage moteurs. |
Le détail de chaque type (options, valeur stockée, rendu côté Astro) est dans la référence des champs.
Les options communes
Section intitulée « Les options communes »titre: fields.text({ label: { fr: 'Titre', en: 'Title' }, help: { fr: 'Affiché en haut de page.', en: 'Shown at the top of the page.' }, required: true, localized: true,}),labelethelpacceptent une chaîne simple ou un objet{ fr, en }. C’est la langue de l’interface d’admin, toujours fr et en, indépendante des locales de contenu. Sanslabel, la clé capitalisée sert de libellé :prixdevient « Prix ».required(faux par défaut) s’évalue par locale : publier la version anglaise exige les champs requis en anglais, pas en français.localized(faux par défaut) : un champ non traduit garde une seule valeur pour toutes les langues.defaultest typé par champ. Il est appliqué à la création d’une entrée dans l’admin, jamais injecté en lecture : une valeur absente reste absente.
Trois exceptions voulues : boolean refuse required (un interrupteur a toujours un état), group et repeater refusent localized (la traduction se déclare sous-champ par sous-champ), et slug est toujours requis, sa localisation héritée du champ source.
Locales et traduction
Section intitulée « Locales et traduction »Les locales se déclarent une fois, en BCP 47 léger (fr, en, pt-BR). La traduction se décide ensuite champ par champ : un prix ou une photo restent communs à toutes les langues, un titre se traduit. Ajouter une locale plus tard est un changement additif. En retirer une la désactive sans effacer les traductions, qui reviennent si tu la réactives ; seule la locale par défaut ne se retire pas. La limite de locales de ton plan est vérifiée au sync, pas à la compilation.
Exemple complet : un site vitrine
Section intitulée « Exemple complet : un site vitrine »Une collection de services et une page d’accueil qui en met certains en avant :
import { collection, defineConfig, fields, singleton } from '@menestrel/fields';
export default defineConfig({ project: 'atelier-morel', locales: { default: 'fr', others: ['en'] },
collections: { services: collection({ label: { fr: 'Services', en: 'Services' }, slugFrom: 'titre', fields: { titre: fields.text({ label: 'Titre', localized: true, required: true, max: 120 }), resume: fields.textarea({ label: { fr: 'Résumé', en: 'Summary' }, help: { fr: 'Affiché sur les cartes de la liste.', en: 'Shown on the list cards.' }, localized: true, max: 300, rows: 3, }), contenu: fields.richtext({ label: 'Contenu', localized: true }), visuel: fields.image({ label: 'Visuel', required: true }), prix: fields.number({ label: { fr: 'Prix à partir de', en: 'Starting price' }, unit: '€', min: 0, }), seo: fields.seo(), }, }), },
singletons: { accueil: singleton({ label: { fr: "Page d'accueil", en: 'Home page' }, fields: { titre: fields.text({ label: 'Titre', localized: true, required: true }), introduction: fields.textarea({ label: 'Introduction', localized: true, rows: 4 }), services_en_avant: fields.relation({ label: { fr: 'Services mis en avant', en: 'Featured services' }, to: 'services', many: true, }), temoignages: fields.repeater({ label: { fr: 'Témoignages', en: 'Testimonials' }, itemLabel: 'auteur', max: 6, fields: { auteur: fields.text({ label: 'Auteur', required: true, max: 80 }), citation: fields.textarea({ label: 'Citation', localized: true, required: true, rows: 3 }), }, }), seo: fields.seo(), }, }), },});Synchroniser : menestrel sync
Section intitulée « Synchroniser : menestrel sync »npx menestrel sync --dry-run # calcule et affiche le diff, ne pousse riennpx menestrel sync # diff, confirmations, puis pousseLe CLI compile la config, la compare au schéma du serveur et affiche un diff classé : ajouts, changements cosmétiques, avertissements, suppressions. Chaque sync accepté crée une nouvelle version du schéma. L’historique est append-only : une version poussée n’est jamais réécrite, et chaque snapshot publié embarque la version qui l’a produit.
Ce que ce modèle garantit :
- Une suppression n’efface rien tout de suite. Le champ supprimé passe en tombstone, ses données restent en base 30 jours. Re-déclare la même clé avec le même type pendant cette fenêtre : le champ revient, données comprises.
- Chaque suppression se confirme une par une en interactif (défaut : non). En CI,
--yesrefuse les suppressions sans--allow-deletions: un push distrait ne détruit rien. - Un renommage n’est jamais deviné. Sans déclaration, une clé renommée vaut suppression plus création. Déclare-le :
renamedFrom: 'ancienne_cle'danscollection()ousingleton()pour une collection renommée,--rename services.titre=nomau moment du sync pour un champ. Le serveur migre les valeurs en tâche de fond. - Passer un champ de
textàtextarea(ou l’inverse) conserve les valeurs, même représentation. Tout autre changement de type est traité comme suppression plus création, avec les garde-fous ci-dessus. - Passer
localizedde faux à vrai est un enrichissement : la valeur existante devient celle de la langue par défaut.
Les options complètes de la commande (--rename, --json, codes de sortie) sont dans la référence CLI.
Les règles que le compilateur fait respecter
Section intitulée « Les règles que le compilateur fait respecter »- Clés de collections, singletons et champs en minuscules :
^[a-z][a-z0-9_]{0,63}$.idest réservée ;slugest réservée au champ de typeslug. slugetseovivent à la racine uniquement, jamais dans ungroupou unrepeater.- Trois niveaux maximum de conteneurs imbriqués : au-delà, le formulaire devient illisible pour l’éditeur.
- 200 champs maximum par collection (sous-champs compris), 100 collections et singletons par projet.
Chaque violation sort à la compilation, avant tout appel réseau : le serveur ne voit jamais un schéma invalide.