Modeling content
Your site’s schema lives in one file, cms.config.ts, at the root of the Astro project. It is the source of truth: the admin generates its forms from it, the server validates every piece of content against it, the loader derives your collection types from it. A schema in the repo gets reviewed in PRs, reverts with git, and travels with the code that consumes it.
The file skeleton
Section titled “The file skeleton”import { collection, defineConfig, fields, singleton } from '@menestrel/fields';
export default defineConfig({ project: 'my-site', locales: { default: 'en', others: ['fr'] }, collections: { /* ... */ }, singletons: { /* ... */ },});project: the project slug on the server, checked against the token at sync time.locales: the content languages, unconstrained (fr,en,pt-BR…). Each field then decides whether it gets translated.collections: sets of entries sharing one shape, each with a slug and therefore a URL (services, articles, case studies).singletons: one-off content with no slug (home page, contact details).
The file is a pure module: no I/O, a deterministic compilation to a canonical JSON whose checksum lets the CLI detect “no changes” without pushing anything. Every broken rule produces an error with the exact path of the offending field (collections.services.fields.slug) and a stable code.
Collections and singletons
Section titled “Collections and singletons”A collection carries exactly one slug field: it builds the URLs and names the entries in the content files. The slugFrom: 'title' shortcut declares it in one line; it is equivalent to fields.slug({ from: 'title' }) under the reserved slug key, derived from the source text field and editable in the admin. A singleton never has a slug: it exists once, its page already has an address.
The fifteen field types
Section titled “The fifteen field types”| Type | What it is for |
|---|---|
text |
Short single-line text, 320 characters max by default. |
textarea |
Long unformatted text, 20,000 characters max by default. |
richtext |
Formatted text: H2/H3 headings, bold, italic, lists, links, images, videos. |
number |
Integer or decimal number, with bounds and a display unit (€, m²). |
boolean |
A yes/no switch. |
select |
A pick from a closed list, single or multiple. |
date |
Date only, stored as YYYY-MM-DD. |
datetime |
Date and time, stored in UTC. |
image |
An image from the media library, served with its resized variants. |
file |
A downloadable file (PDF, archive), MIME types restrictable. |
relation |
A link to one or several entries of a collection. |
group |
Sub-fields grouped visually in the form. |
repeater |
An ordered list of same-shaped blocks. |
slug |
The page address, derived from a text field of the collection. |
seo |
A prefabricated group: title, description, share image, search engine opt-out. |
The details of each type (options, stored value, rendering on the Astro side) live in the fields reference.
Common options
Section titled “Common options”title: 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,}),labelandhelpaccept a bare string or an{ fr, en }object. This is the admin interface language, always fr and en, independent from the project’s content locales. Without alabel, the capitalized key serves as one:priceshows as “Price”.required(false by default) is evaluated per locale: publishing the English version requires the English values of required fields, not the French ones.localized(false by default): a non-translated field keeps a single value across all languages.defaultis typed per field. The admin applies it when an entry is created; it is never injected on read: an absent value stays absent.
Three deliberate exceptions: boolean rejects required (a switch always has a state), group and repeater reject localized (translation is declared sub-field by sub-field), and slug is always required, its localization inherited from the source field.
Locales and translation
Section titled “Locales and translation”Locales are declared once, in light BCP 47 (fr, en, pt-BR). Translation is then decided field by field: a price or a photo stays shared across languages, a title gets translated. Adding a locale later is an additive change. Removing one disables it without erasing the translations, which come back if you re-enable it; only the default locale cannot be removed. Your plan’s locale limit is checked at sync time, not at compile time.
A complete example: a small business site
Section titled “A complete example: a small business site”A collection of services and a home page that features some of them:
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(), }, }), },});Syncing: menestrel sync
Section titled “Syncing: menestrel sync”npx menestrel sync --dry-run # computes and prints the diff, pushes nothingnpx menestrel sync # diff, confirmations, then pushThe CLI compiles the config, compares it with the server schema and prints a classified diff: additions, cosmetic changes, warnings, deletions. Every accepted sync creates a new schema version. The history is append-only: a pushed version is never rewritten, and every published snapshot embeds the version that produced it.
What this model guarantees:
- A deletion erases nothing right away. The removed field becomes a tombstone; its data stays in the database for 30 days. Re-declare the same key with the same type within that window and the field comes back, data included.
- Each deletion is confirmed one by one in interactive mode (default: no). In CI,
--yesrefuses deletions without--allow-deletions: a distracted push destroys nothing. - A rename is never guessed. Undeclared, a renamed key counts as a removal plus an addition. Declare it:
renamedFrom: 'old_key'incollection()orsingleton()for a renamed collection,--rename services.title=nameat sync time for a field. The server migrates the values in a background job. - Switching a field between
textandtextareakeeps the values, same representation. Any other type change is treated as a removal plus an addition, with the guardrails above. - Turning
localizedfrom false to true is an enrichment: the existing value becomes the default language’s value.
The full command options (--rename, --json, exit codes) are in the CLI reference.
Rules the compiler enforces
Section titled “Rules the compiler enforces”- Collection, singleton and field keys are lowercase:
^[a-z][a-z0-9_]{0,63}$.idis reserved;slugis reserved for theslugfield type. slugandseolive at the root only, never inside agroupor arepeater.- Three levels of nested containers at most: beyond that, the form becomes unreadable for the editor.
- 200 fields max per collection (sub-fields included), 100 collections and singletons per project.
Every violation surfaces at compile time, before any network call: the server never sees an invalid schema.