EN FR

Chapter 07Build

The content model

Three field types, not one more. One JSON file per page and per language, validated by the same schema at build time and on write.

6 min read13 sectionsChapter 7 / 22

One file per page and per language

text text
src/content/pages/
├── fr/
│   ├── home.json
│   ├── services.json
│   └── contact.json
└── en/
    ├── home.json
    ├── services.json
    └── contact.json

The file name becomes the URL segment: services.json answers at /en/services/. home.json is a special case: it answers at the root of its language, /en/.

The naming constraint

Lowercase letters, digits and hyphens only — that is the whitelist the write function applies: src/content/pages/{language}/{page}.json. A My_File.json would be built by Astro but refused when publishing.

The shape of a page

Three sections, always the same:

src/content/pages/en/home.json json
{
  "meta": {
    "title": "Martin Bakery — Home",
    "description": "Sourdough bread baked every morning, in Nantes."
  },
  "blocks": {
    "hero": {
      "title": {
        "type": "text",
        "value": "Sourdough bread, every morning",
        "style": { "size": "3xl", "weight": "bold", "align": "center", "color": "primary" }
      },
      "intro": {
        "type": "richtext",
        "value": "We <strong>knead</strong> every night. <a href=\"/contact\">Come and see us</a>."
      },
      "visual": {
        "type": "media",
        "kind": "image",
        "src": "bakehouse-at-dawn.webp",
        "alt": "The bakehouse at dawn",
        "width": 1600,
        "height": 900
      }
    }
  },
  "collections": {
    "reviews": [
      {
        "id": "a-001",
        "quote": { "type": "text", "value": "The best bread in town.", "style": { "size": "lg", "italic": true } },
        "author": { "type": "text", "value": "Claire D.", "style": { "size": "sm", "color": "muted" } }
      }
    ]
  }
}
SectionRoleRequired
metaTitle, description, share image. Feeds <title>, the meta description, Open Graph.yes
blocksSimple fields, grouped by block: blocks.hero.title.yes
collectionsLists: testimonials, services, case studies.no

blocks has exactly two levels: a block name, then a field name. That is not arbitrary — it is what makes a path readable in the data-cms attribute and checkable at build time.

The three field types

text — the common case

json json
{
  "type": "text",
  "value": "Sourdough bread, every morning",
  "style": {
    "size": "3xl",
    "weight": "bold",
    "italic": false,
    "align": "center",
    "color": "primary"
  }
}

Headings, labels, paragraphs without inline formatting. The style object is optional in the file: every key has a default (base, regular, false, left, primary).

json json
{
  "type": "richtext",
  "value": "A <strong>complete</strong> service. <a href=\"/contact\">Let's talk</a>."
}

No style object: formatting lives in the markup. Allowed tags:

strong, em, a[href], br, ul, ol, li. Seven tags, one attribute. Everything else is stripped — in the browser on paste, and on the server before writing.

Why so few

Every allowed tag is a layout decision handed to the client. Allow <h2> in richtext and the heading hierarchy — therefore the SEO — becomes changeable by accident. The short list is the boundary from chapter 1, applied to markup.

media — image

json json
{
  "type": "media",
  "kind": "image",
  "src": "bakehouse-at-dawn.webp",
  "alt": "The bakehouse at dawn",
  "width": 1600,
  "height": 900
}
  • src — a file name from src/media/, not a path. Lowercase, digits, hyphens, extension jpg, png or webp.
  • alt — required, non-empty. Labelled “Image description” on the client side.
  • width / height — positive integers, computed server-side on upload.

media — video

json json
{
  "type": "media",
  "kind": "video",
  "provider": "youtube",
  "videoId": "aqz-KE-bpKQ",
  "title": "A night in the bakehouse"
}

No video file is ever uploaded. A heavy file in Git breaks the repository and the builds. The client pastes a link, the tool extracts the provider and the id. provider is youtube or vimeo; poster is optional.

Style tokens

Five axes, closed lists. These values come from a single file from which the Zod schema draws its enums and from which the overlay toolbar builds its buttons: a button offering a value the build would reject is impossible by construction.

KeyValuesDefault
sizexs · sm · base · lg · xl · 2xl · 3xlbase
weightthin · light · regular · medium · semibold · boldregular
italictrue · falsefalse
alignleft · center · rightleft
colorprimary · secondary · muted · accent · inverseprimary

Each value becomes a class (cms-size-3xl, cms-color-accent…) pointing at a CSS variable of the site theme. No hard-coded value anywhere — see Theme and styles.

Lists

A list is an array of items under collections. Each item carries an id and fields like anywhere else.

json json
"collections": {
  "reviews": [
    { "id": "a-001", "quote": { … }, "author": { … } },
    { "id": "a-002", "quote": { … }, "author": { … } }
  ]
}
The id rule

Enforced format: one lowercase letter, a hyphen, at least three digitsa-001, t-014. An id is stable and never reassigned, even after deletion: it is the key linking the DOM to the JSON. Reusing it would attach the edits of a deleted item to a brand new one. The write function rejects any duplicate within a list.

An empty array is valid: a page from which the client removed every testimonial is still a page. Items are reconciled by id, not by position — so a translation can order its testimonials differently without texts sliding from one item to another.

Site configuration

src/content/site.json holds what is common to every page. It is validated at build time by the same schema as the rest, but it is outside the write whitelist: the client cannot change it from the overlay.

src/content/site.json json
{
  "name": "Martin Bakery",
  "locale": "en",
  "navigation": [
    { "label": "Home", "href": "/" },
    { "label": "Contact us", "href": "mailto:contact@martin-bakery.com" }
  ],
  "contact": {
    "email": "contact@martin-bakery.com",
    "phone": "+33 2 40 00 00 00",
    "address": "3 rue du Four, 44000 Nantes"
  },
  "footer": { "legal": "© Martin Bakery — All rights reserved" }
}

This is structure — navigation, contact details, legal line — so it belongs to the developer. The boundary is set on delivery day, and it is enforced by the code, not merely announced.

The constraints, in one table

FieldConstraintOtherwise
meta.title60 characters maxBuild fails, publish refused (422)
meta.description160 characters maxsame
meta.ogImageoptional
text.valuestring, may be empty
style.*a value from the enumBuild fails — no silent fallback
image.altnon-emptyBuild fails — no empty alt accepted
image.srcfile in src/media, whitelistedPublish refused (422)
image.width/heightpositive integersBuild fails
video.provideryoutube or vimeoBuild fails
video.videoId11 characters (YouTube), digits (Vimeo)Publish refused (422)
item.id^[a-z]-\d{3,}$, unique in its listBuild or publish fails
whole file100 KB maxPublish refused (413)

One schema, two uses

The Zod schema lives in inline-core/src/schema.ts, a file that does not import astro:content. That is the condition for the write function — which runs outside the Astro context — to import the same object as the build.

schema.ts src/content/config.ts (build)   and routes/save.ts (write)
src/content/config.ts ts
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { pageSchema } from 'inline-core/schema';

const pages = defineCollection({
  loader: glob({ pattern: '**/*.json', base: './src/content/pages' }),
  schema: pageSchema,
});

export const collections = { pages };
What that guarantees

Content that would fail the build cannot enter the repository: the function refuses it before writing. There is no state in which the site stops building because of something the client published.

Extending the model

Adding a field type (a data table, a PDF, a code block) happens in schema.ts, therefore in the shared package, therefore for every site. Three consequences to weigh first:

  1. it needs a rendering component, an editing gesture in the overlay, and a CSS class if the field has variants;
  2. server-side sanitising must know what to do with it;
  3. any change that would invalidate already-published content is a major version, even if the code compiles — it is the client's content that breaks, not ours.

Placing these fields in a page: Adding a page.