Skip to content

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.

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

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.

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

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,
}),
  • label and help accept 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 a label, the capitalized key serves as one: price shows 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.
  • default is 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 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 collection of services and a home page that features some of them:

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 # computes and prints the diff, pushes nothing
npx menestrel sync # diff, confirmations, then push

The 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, --yes refuses 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' in collection() or singleton() for a renamed collection, --rename services.title=name at sync time for a field. The server migrates the values in a background job.
  • Switching a field between text and textarea keeps the values, same representation. Any other type change is treated as a removal plus an addition, with the guardrails above.
  • Turning localized from 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.

  • Collection, singleton and field keys are lowercase: ^[a-z][a-z0-9_]{0,63}$. id is reserved; slug is reserved for the slug field type.
  • slug and seo live at the root only, never inside a group or a repeater.
  • 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.