Un CMS pour développeurs : le schéma dans le code, le contenu hors du dépôt
La plupart des CMS vous font cliquer votre modèle de contenu dans une interface. Le mettre en TypeScript change la revue de code, les migrations et le travail multi-sites, et vous coûte quelque chose de réel.
Il y a une ligne de partage qui traverse tous les CMS, et le côté où se range un outil dit à peu près tout de qui il a été pensé pour.
D’un côté, le modèle de contenu vit dans l’interface d’administration. Vous vous connectez, cliquez sur « ajouter un type de contenu », ajoutez des champs par un formulaire, et la forme de votre contenu est une ligne dans la base de l’éditeur. Contentful, Storyblok, Directus et l’interface de Strapi fonctionnent ainsi.
De l’autre, le modèle est un fichier de votre dépôt. Vous l’écrivez, le committez, le faites relire en demande de fusion. Keystatic, Tina et nous faisons cela.
Les deux marchent. Ils échouent différemment, et c’est sur cette différence qu’il faut choisir.
Ce que coûte un modèle cliqué
Il n’est pas dans la revue de code. Un collègue ajoute un champ un jeudi après-midi. Personne ne l’a relu, personne ne peut le voir dans une comparaison, et le premier signe est une construction qui échoue parce qu’un gabarit attend une forme qui a changé. Il n’existe pas de git log pour « quand ce champ est-il apparu et pourquoi ».
Il ne se branche pas. Votre branche de fonctionnalité ajoute un champ. Le modèle est partagé, donc soit vous ajoutez le champ en production trop tôt et livrez du code qui l’ignore, soit vous l’ajoutez à la fusion et la branche ne peut pas être testée. Les éditeurs vendent des « environnements » comme réponse, ce qui est un palier payant recréant mal les branches.
Il ne se copie pas. Vingt sites clients partagent l’essentiel de leur structure. Avec un modèle cliqué, le site vingt-et-un veut dire tout recliquer. Pas d’import, pas de paquet partagé, aucun moyen d’améliorer le motif une fois et de l’appliquer partout.
Personne ne peut relire l’ensemble. Demandez « quel est notre modèle de contenu » et la réponse est un écran qu’on fait défiler. Il n’y a pas d’objet à lire, pas de fichier à ouvrir dans le train.
Ce que coûte un modèle en code
Pour être juste, cette direction a de vrais inconvénients, et ils ne sont pas toujours reconnus.
Un non-développeur ne peut pas le modifier. Si votre client veut un champ de plus, il ne peut pas l’ajouter. C’est une demande de support qui vous revient, et un vendredi c’est franchement un inconvénient. Les outils où un chef de projet peut ajouter un champ sans déploiement résolvent un vrai problème.
Il faut une étape de synchronisation. Le fichier doit atteindre le serveur pour que l’administration sache afficher les formulaires. C’est une commande à lancer, un jeton à détenir, et une chose de plus qui peut être périmée.
Les migrations deviennent votre problème. Renommez un champ et le contenu existant doit suivre. Un modèle cliqué offre en général une interface pour ça. En code, il vous faut une histoire de migration, et si l’outil n’en fournit pas, vous écrivez des scripts.
À quoi ça ressemble en pratique
import { collection, defineConfig, fields, singleton } from '@menestrel/fields';
export default defineConfig({
project: 'atelier-morel',
locales: { default: 'fr', others: ['en'] },
collections: {
services: collection({
label: { fr: 'Prestations', en: 'Services' },
slugFrom: 'title',
fields: {
title: fields.text({ required: true, localized: true, max: 120 }),
summary: fields.textarea({ localized: true, rows: 3 }),
price: fields.number({ unit: '€', min: 0 }),
photo: fields.image({ required: true }),
seo: fields.seo(),
},
}),
},
singletons: {
contact: singleton({
label: { fr: 'Coordonnées', en: 'Contact details' },
fields: {
phone: fields.tel(),
email: fields.email(),
address: fields.textarea(),
},
}),
},
});
Quatre choses découlent du fait que ce soit un fichier.
Il se compare. Une demande de fusion qui ajoute un champ montre exactement cela, relisible en dix secondes.
Il se branche. Le modèle voyage avec le code qui l’utilise. Une branche qui ajoute un champ et le gabarit qui le consomme est un seul changement cohérent.
Il se compose. Les champs communs deviennent un module partagé importé par vingt sites. Améliorez le motif une fois, appliquez-le partout.
Il type. Le loader déduit le schéma de la collection à partir du modèle, donc entry.data.price est un nombre dans votre éditeur sans que vous l’ayez redit en Zod. La plus grande source de dérive, déclarer la forme deux fois, disparaît.
La question des migrations, franchement
Renommer un champ est l’endroit où le schéma en code devient inconfortable, parce que le fichier change instantanément et le contenu non.
L’approche qui fonctionne consiste à traiter cela comme une migration de base de données : par ajout uniquement, avec une migration explicite pour tout ce qui est destructeur. Ajouter un champ est sans danger et s’applique immédiatement. Supprimer ou renommer est une différence que le système détecte et refuse d’appliquer en silence, parce que l’alternative est du contenu qui disparaît sans que personne ne l’ait décidé.
Ce qu’il faut tester avant de s’engager sur un outil à schéma en code : renommez un champ contenant des données, et regardez ce qui se passe. Si la réponse est « les anciennes données ont disparu », c’est rédhibitoire.
L’étape de synchronisation, et comment ne pas la détester
L’objection qui vient le plus vite est la synchronisation : si le modèle est un fichier, quelque chose doit le pousser vers le serveur, et cette étape peut être oubliée.
L’objection est recevable, et la réponse tient dans le comportement de cette synchronisation plutôt que dans le fait de prétendre qu’elle n’existe pas.
Elle doit être idempotente et bon marché. La lancer sans changement doit être sans effet, et détectable sans aller-retour. Compiler le modèle vers une forme canonique et comparer une empreinte suffit : même empreinte, rien à pousser, on sort. Cela ne coûte rien de la lancer à chaque déploiement, donc mettez-la dans votre pipeline et cessez d’y penser.
Elle doit refuser l’ambiguïté. Si l’écart entre le fichier et le serveur contient quelque chose de destructeur, le bon comportement est de s’arrêter en disant quel champ et ce qui serait perdu, pas de deviner.
Les erreurs doivent désigner le champ. Un modèle qui ne compile pas doit dire collections.services.fields.slug et un code d’erreur stable, pas « configuration invalide ».
Une fois ces trois points tenus, la synchronisation cesse d’être une corvée. Elle devient l’équivalent d’une migration de base dans un déploiement normal : automatique, ennuyeuse, et de temps en temps ce qui vous empêche de livrer une bêtise.
Ce que ça change au démarrage d’un projet
L’endroit où le schéma en code paie le plus visiblement est le site numéro vingt-et-un.
Avec un modèle cliqué, un nouveau site client veut dire ouvrir l’administration et recréer la structure : types de contenu, champs, libellés, textes d’aide, validations, le tout à la main, en espérant se souvenir des améliorations faites sur le site dix-neuf.
Avec un fichier, c’est une copie, quelques modifications et une synchronisation. Mieux : les parties qui se répètent vraiment, un groupe de référencement, un bloc de contact, des horaires, peuvent vivre dans un module partagé que chaque site importe. Améliorer le motif partagé améliore tous les sites qui adoptent la nouvelle version, à votre rythme, visible dans une comparaison.
Ce n’est pas une petite économie. Sur un parc, c’est la différence entre chaque nouveau client qui est une installation neuve, et chaque nouveau client qui est une variation sur quelque chose que vous possédez déjà.
Où le contenu ne doit pas vivre
L’autre moitié de l’argument, et celle qui surprend le plus : le modèle appartient à votre dépôt, le contenu non.
Les CMS git y mettent les deux, ce qui est cohérent et présente de vrais avantages, détaillés dans le comparatif. Notre position est que ce sont deux choses de nature différente.
Le modèle est une décision technique avec des conséquences sur le code. Il appartient à la revue, aux branches, à l’historique.
Le contenu, ce sont les mots de votre client. Ils changent à son rythme, ils sont écrits par des gens qui ne verront jamais un dépôt, et leur historique devrait être une liste de publications plutôt qu’une liste de commits mêlée à vos remaniements. Retrouver la grille tarifaire du mois dernier ne devrait pas supposer de l’archéologie dans quinze jours de votre propre travail.
Il y a aussi un argument pratique : les photos. Du contenu dans un dépôt veut dire des images dans un dépôt, et un site vitrine peut accumuler plusieurs centaines de mégaoctets d’images mortes en un an, que chaque clone télécharge pour toujours.
L’obligation que cela crée est un export qui fonctionne réellement. Si le contenu n’est pas dans votre dépôt, il vous faut une commande qui le rende dans un format que votre outil suivant sait lire. Le nôtre produit du markdown et du JSON au format des content collections, sur tous les plans y compris le gratuit. Tout CMS hébergé incapable de vous montrer cela sur demande retient votre contenu en otage, quoi qu’en dise sa communication.
Quel côté choisir
Choisissez un modèle cliqué si des non-développeurs doivent changer la structure, si vous exploitez un seul grand site avec une équipe éditoriale, ou si ce sont les autres fonctionnalités du CMS que vous achetez réellement.
Choisissez le schéma en code si vous entretenez plusieurs sites, si vous voulez le modèle dans la revue, ou si la dérive entre « ce que dit le CMS » et « ce qu’attendent les gabarits » vous a déjà mordu.
Pour une agence qui exploite des sites Astro, le second est en général le bon, et le facteur décisif est rarement l’élégance. C’est que le site vingt-et-un prend un après-midi au lieu d’une semaine, parce que le modèle est un fichier qu’on copie plutôt qu’un formulaire qu’on remplit à nouveau.
Pour le voir avant de décider, npm create menestrel@latest met un projet debout en une commande, et la référence des champs documente les quinze types.