Aller au contenu

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.

cms.config.ts
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.

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.

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 (, ).
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.

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,
}),
  • label et help acceptent 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. Sans label, la clé capitalisée sert de libellé : prix devient « 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.
  • default est 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.

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.

Une collection de services et une page d’accueil qui en met certains en avant :

cms.config.ts
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(),
},
}),
},
});
Fenêtre de terminal
npx menestrel sync --dry-run # calcule et affiche le diff, ne pousse rien
npx menestrel sync # diff, confirmations, puis pousse

Le 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, --yes refuse 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' dans collection() ou singleton() pour une collection renommée, --rename services.titre=nom au 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 localized de 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.

  • Clés de collections, singletons et champs en minuscules : ^[a-z][a-z0-9_]{0,63}$. id est réservée ; slug est réservée au champ de type slug.
  • slug et seo vivent à la racine uniquement, jamais dans un group ou un repeater.
  • 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.