Content model

How the Contentful model is kept as code: domain-sliced migrations, the vendored schema, fragments, transforms and the dictionary.

2026-11-07

The spine

The content model's source of truth is packages/model — not the Contentful web app. Code owns the model's shape, and Contentful holds the editor-owned values. A hand edit to the model in Contentful is drift, to be restated as a migration, never adopted.

The site's space is 12gd2cvfgb6b ("Euro League"), read from CONTENTFUL_SPACE_ID; the model scripts refuse to run without it rather than fall back to a default. Never point them at the Publishing Hub template's own space, qpz2vl40uleh.

The pipeline

The model flows one direction, each stage feeding the next:

migrations  →  vendored schema.graphql  →  generated types  →  curated fragments  →  transforms  →  app
(domain-       (introspected,             (src/__generated__,   (per-domain            (Contentful
 sliced)        committed)                 committed,            *.graphql)             → props)
                                           drift-gated)
  1. Migrations apply the model to a Contentful environment, sliced by domain (see Domains & ownership).
  2. sync-schema introspects the environment and vendors a deterministic schema.graphql — the type SHAPE, which is the contract.
  3. Codegen generates the TypeScript types into src/__generated__ (committed and drift-gated).
  4. Fragments are curated per component surface — the reviewed statement "this surface consumes exactly these fields".
  5. Transforms turn Contentful responses into the props ghost components consume.
  6. The app composes page-level queries by spreading fragments, and imports everything Contentful-shaped from @v4/model.

The one-way rule

Apps import only from @v4/model — never contentful, contentful-management, or a raw GraphQL endpoint. The package barrel (src/index.ts) re-exports the generated types, the graphql() document helper, the typed client (createModelClient), the webhook payload guards, the dictionary, and every composed domain surface. New code should import from the domain entry point (@v4/model/core, @v4/model/content, @v4/model/video, @v4/model/basketball, @v4/model/dictionary), so each domain's surface stays separate from the root. (package.json still lists a ./football export with no source behind it — a leftover from the template, where football is a domain.)

Generated files are never hand-edited

Three artefacts are machine-produced and guarded by both the local drift check and the CI PR gate:

  • schema.graphql (from sync-schema)
  • src/__generated__/ (from codegen), EXCEPT the dictionary — see below
  • the composed baselines the repo was generated with — migrations/core/0001-baseline-core.cjs, migrations/content/0001-baseline-content.cjs and migrations/video/0000-baseline-video.cjs (from scripts/generate-composed-baseline.mjs, run once when the template's new-club generated this repo)

The generated dictionary maps (src/__generated__/dictionary/<locale>.ts) are committed and deliberately not gated. A label is editorial copy Contentful owns, so gating it made an editor's label edit a red build on whatever merge came next — and because the pull read the CMA, an unpublished draft could redden CI for a value nobody could see. The structural half (key, contentfulTitle) is locked in the CMS instead, by core/0003-lock-dictionary-key-fields.cjs.

The local pre-push check is pnpm --filter @v4/model drift — it re-syncs the schema, regenerates, and diffs (it no longer pulls the dictionary). It hits the network, so it is separate from pnpm verify; run it whenever you touch the model.

After the migrations, the runner (packages/model/scripts/migrate.mjs) also installs and binds this club's Contentful apps through sync-apps unless --skip-apps is passed — see Migrations & the contract.

Domains & ownership

The model is not one flat schema — it is composed from domains, the same domains the app composes (see Domains & bolt-ons). packages/model/migrations/ has one directory per domain, and domains.json maps every content type to exactly one owning domain.

This repo's tree:

packages/model/migrations/
  core/      0001-baseline-core … 0003     BASELINE — chrome, link, siteSettings
  content/   0001-baseline-content … 0007  BASELINE — articles, pages, editorial blocks
  video/     0000-baseline-video … 0005    bolt-on — video entries, landings, categories
  club/      0001-… 0063, text-tags-planner.cjs
                                           Euro League's own: competitions, seasons,
                                           teams, players, venues, games, sponsors …
  renamed.json                             migrations moved after they were applied

club/ is flat (NNNN-kebab-name.cjs, one sequence), and it is the only directory this project adds migrations to: the domain directories are the template's sequence, arriving in /sync-upstream pull requests, and a file of ours there collides with the template's next number (see Take template updates). renamed.json records a migration moved after it ran (the ledger keys a migration by its path, so a moved file would otherwise run again).

Composition, not superset

Each environment applies the baseline (core + content, always) plus the bolt-on domains the site is composed of (video), in canonical order (the domains.json declaration order), followed by migrations/club/. The runner records which domains an environment carries in the modelMigrationState ledger. This is resolved by the shared resolveComposition function — the one definition of "the composition" that the runner, the lint and compose-club all consume.

Because composition is real, everything downstream is per-composition:

  • Schema — sync-schema introspects this space's own environment, so the vendored schema.graphql contains only the domains it applied. This space has none of the template's football, gamification or retail types; editors never see irrelevant content models; GraphQL complexity budgets are not spent on them.
  • Codegen — runs against that vendored schema. A fragment referencing a domain the site does not apply fails codegen, extending the "codegen refuses guesses" guarantee to composition mistakes.
  • Fragments and transforms live in domain folders with domain entry points (@v4/model/content, and a bolt-on's own subpath), so a composed app imports only what its features enable.

Ownership is enforced

domains.json is both the ownership map and the prune manifest. lint-migrations.mjs enforces the ownership boundary:

  • A domain's migrations create and delete only types it owns.
  • A cross-domain edit needs a // cross-domain: marker and must stay expand-only.
  • The only lint-exempt file is the generated core baseline (core/0001-*); a domain's own 0001-* is fully linted.
  • Numbers are unique per directory, with one exception: a generated baseline (NNNN-baseline-*.cjs) may share its number and always runs first (scripts/migration-order.mjs) — which is why content/ holds both 0001-baseline-content.cjs and 0001-news-listing-content-blocks-limit.cjs.

A minimal domain is legal. video's data reads come from the platform API via @v4/media, so its src/video/ holds two fragments (video-landing-settings.graphql, video-category-card.graphql) and one transform (video-category-card.ts), exported at @v4/model/video.

Basketball: a domain with no types of its own

basketball is this project's own domain (19 Oct 2026): it exists in domains.json so compose mounts @v4/basketball-app's routes, but it declares no content types and requires nothing. The sports types it reads — competition, season, team, fixture, player, venue, game sides, season sponsors — are this site's own, created in migrations/club/ (0030, 0035, 0038, 0057, 0059, 0063 among others). Their fragments live in packages/model/src/basketball/ (eight fragments, plus the hero-slide and season-sponsor transforms) and are exported at @v4/model/basketball. Most basketball data does not come from Contentful at all — see Basketball and the Competition Engine.

The map names domains the site does not use

domains.json is shared with the template, so it keeps an entry for every template domain — football, gamification, retail, hospitality, ads and app included — so that /add-feature could compose one in. Each of those has an empty template block, and none has a migration or src directory here. The baselines are per-domain (core, content, video), sized to exactly the domains this site applies.

Migrations & the deploy contract

Migrations are the only way the model changes. They are numbered per-directory, applied by a runner that tracks state in an in-environment ledger, and governed by two hard rules: expand/contract compatibility and Actions validate, the deploy pipeline mutates.

The runner

node packages/model/scripts/migrate.mjs --env=<environment> [--until=<migration>] [--assume-baseline] [--dry] [--skip-apps]

--env is always explicit. The runner applies the composition's domains in canonical order (resolved from composition.json against domains.json), then migrations/club/, each in filename order — tracking applied versions in a modelMigrationState entry in that environment (the migrations-table equivalent). --until stops after a named migration, for a data step that has to run between two. After migrating, the runner syncs the environment's Contentful apps (sync-apps: installs and field bindings) unless --skip-apps is passed.

Migration files are NNNN-kebab-name.cjs, sequenced per directory; the runner fails on a duplicate number, except that a generated baseline (NNNN-baseline-*.cjs) may share its number and runs first (scripts/migration-order.mjs). This project's own migrations go in migrations/club/ only — never in a domain directory, whose numbers belong to the template — and never edit core/.

Apply a migration to develop by merging its PR, not ahead of it: the drift gate compares every PR's vendored schema with develop's, so a migration run early reddens every other open PR.

The protected-environment guard

The runner refuses production-facing environments — master, production, staging, staging-release — unless both CI=true and ALLOW_PROTECTED_ENV=1 are set. Prod mutation belongs to the deploy pipeline, never a laptop, and the guard is never weakened.

Expand / contract, always

Every migration must be backwards-compatible with currently deployed code:

  • Add optional first. New fields are optional until the code that reads them ships.
  • Destructive ops ship one release later. deleteField, changeFieldId, required(true) on an existing field, and deleteContentType land the release after the code that stopped depending on them, and each must carry a // contract: <reason> marker or lint-migrations.mjs fails the PR.
  • The born-required exception. .required(true) chained onto a field created in the same createField statement is expand-safe — a brand-new type has no entries — and needs no marker.

Actions validate, the deploy pipeline mutates

The mutation boundary is strict:

  • GitHub Actions validate. They mutate only the develop environment (model-apply-develop.yml) and ephemeral model-gate-* clones (model-pr-gate.yml, short-lived copies of develop) — never anything production-facing.
  • The deploy pipeline mutates production-facing environments, exclusively, in a fixed order: export artifact → migrate → build → deploy. The migrate step runs inside that pipeline with the CI guard satisfied.

The drift gate is total

pnpm --filter @v4/model drift catches everything that touches the model — including the runner's own ledger bootstrap, which grows the schema when it first creates modelMigrationState in a new environment. After any ledger bootstrap in a new environment class, resync so the vendored schema matches reality. The vendored schema is deterministic by design (sorted, descriptions and directive definitions stripped) so a long-lived environment serving cached docstrings does not flake the diff against a freshly copied environment — the type shape is the contract, and nothing else.

Fragments, queries & the dictionary

A fragment is the reviewed statement "this surface consumes exactly these fields". Its types are generated, its queries live in the app, and the same code-owns-shape discipline extends to the dictionary of localised UI copy.

Fragments are curated

Fragments live in packages/model/src/<domain>/fragments/*.graphql, one per component surface, named {Surface}Fields (ArticleCardFields, LinkFields). The rules:

  • Prune, never select-all. Every selected field must be consumed by a transform or component. A field nobody reads is a review error, not future-proofing — GraphQL complexity limits are real, so treat fragment size as a budget.
  • Types are generated, never hand-written. After any .graphql edit, run pnpm --filter @v4/model generate; codegen validates every field name against the vendored schema.graphql and refuses guesses. Import the types from @v4/model, not from codegen paths.
  • Collections always carry an explicit limit sized to the surface — the API default is 100 and counts against the complexity budget.

Queries live in the app

Page-level queries live in the app (apps/web, and the *-app route packages), never in @v4/model — composed by spreading @v4/model fragments. An app never writes a raw field selection for a model-owned surface; if the fields are not in a fragment yet, extend the fragment (reviewed) rather than selecting inline.

The new-field flow is therefore one reviewed change:

migration  →  sync-schema + generate  →  fragment + transform together

Never a fragment for a field no migration has shipped.

Transforms turn responses into props

Transforms are the last stage before the app: they take a Contentful response shaped by a fragment and emit the plain-data props a ghost component consumes ({ url, alt, width, height, … }), never markup. The focal-point transform is a worked example: the object-position maths lives once, in the pure @v4/image core (objectPositionFromNormalised), and both the CMS-image transform (packages/model/src/core/transforms/image-source.ts) and the platform-video views (packages/media/src/views.ts) call it — see Media and video architecture.

The dictionary — vocabulary in code, labels in Contentful

For localised UI copy, code owns the words and Contentful the wording. packages/model/src/dictionary/keys.ts owns the key vocabulary and its default en-GB copy; Contentful owns the localised labels editors maintain.

  • Add a key — edit keys.ts (reviewed), then pnpm --filter @v4/model push-dictionary seeds the entry (create + publish, key + default label). Push never updates or deletes existing entries; labels belong to editors, and orphans are reported, not removed.
  • Pull labels — pnpm --filter @v4/model sync-dictionary regenerates src/__generated__/dictionary/<locale>.ts (en-GB, en-US, fr-FR, it-IT here), committed and deliberately not drift-gated: labels are editorial copy Contentful owns, so an editor's edit must not redden the next unrelated build.
  • Consume via await getDictionary(locale) from @v4/model/dictionary (labels are read from Contentful per request and tagged content:dictionaryKey; createDictionary(locale) is the synchronous snapshot fallback) — a typed DictionaryKeyName union (the GraphQL schema owns the plain name DictionaryKey), resolving the requested locale → the default dictionary locale (DEFAULT_DICTIONARY_LOCALE, en-US here) → the vocabulary default. The app calls configureDictionary({ client }) once, in apps/web/src/lib/content.ts. A missing key is a type error, never a runtime miss. Interpolation is typed against the default copy — params are required when the copy has {placeholders} and refused when it does not.

Resolve dictionary copy server-first — in RSC or transforms — and pass the resulting strings as props to client components.

Transforms & normalised views

A fragment states which fields a surface consumes; a transform is what turns the response into something a component can render without defending itself. It takes the fragment result — every field nullable, because that is how Contentful GraphQL shapes it — and returns a normalised view: a plain, serialisable object that is required where it matters and has already absorbed the nullability at one boundary.

Where they live

Transforms live beside their fragments, one file per surface, in each domain's transforms/ directory — packages/model/src/core/transforms/ (image-source.ts, link.ts, site-chrome.ts, error-page.ts, sponsor-geo.ts, …), src/content/transforms/ (the editorial surfaces below, plus public-tags.ts, category.ts, listing-team.ts, wsc-widget.ts, video-source.ts), src/video/transforms/ and src/basketball/transforms/ (hero slides, season sponsors). They are re-exported from @v4/model and the domain subpaths alongside the view types they return:

Transform Returns Surface
articleCardFromFragment ArticleCardView The card view (news carousels, hero slides, promos)
articlePageFromFragment ArticlePageView The full news/video/gallery article page
contentPageFromFragment ContentPageView The generic editor-composed page
homePageFromFragment HomePageView The homepage (hero + blocks)
newsListingFromFragment NewsListingView The news / category listing header
normaliseRichText NormalisedRichText The one central rich-text normaliser
objectPositionFromFocalPoint string | undefined Focal-point → CSS object-position (in core/transforms/image-source.ts, re-exported by content/transforms/focal-point.ts)
contentBlockView ContentBlockView One page block → one renderable view (see §6)

Apps never write a raw field selection and never reshape a raw response inline — they call a transform and render its view. If a view is missing a field a surface needs, that is a reviewed change to the fragment and the transform together, not an ad-hoc read.

The pattern: absorb nullability at one boundary

Contentful's GraphQL returns almost everything as T | null — a required editorial field is still nullable in the schema, a linked entry may resolve to null, a collection may hold null items. A component that consumed the raw shape would carry that defence in every prop access. The transform does it once, at the edge, so the view it hands on is honest about what is actually present.

articleCardFromFragment is the worked example:

export const articleCardFromFragment = (
  fragment: ArticleCardFieldsFragment,
): ArticleCardView | undefined => {
  if (fragment.slug == null || fragment.articleTitle == null) return undefined;
  const imageUrl = fragment.image?.image?.url;
  return {
    id: fragment.sys.id,
    slug: fragment.slug,
    title: fragment.articleTitle,
    summary: fragment.summary ?? undefined,
    // …
    image: typeof imageUrl === "string" ? { url: imageUrl /* … */ } : undefined,
    membersOnly: fragment.isRestrictedToMembersOnly === true,
  };
};

Read off the shape of it:

  • Required-where-it-matters. A card with no slug or no title cannot be rendered or linked, so the transform returns undefined rather than a half-view — and the composing surface flatMaps those away (slide ? [slide] : []). Downstream code never asks "but what if the title is null?"; by the time it holds an ArticleCardView, the title is a string.
  • null collapses to undefined. Optional fields become value ?? undefined, so a view has one absence value, not two. The types read summary?: string | undefined, never string | null.
  • Booleans are decided, not passed through. isRestrictedToMembersOnly === true turns a nullable tri-state into a real boolean the component can branch on.
  • Images are data, never markup. A card view carries { url, alt, width, height, objectPosition } PLUS a raw imageRef — built by imageRefFrom (core/transforms/image-source.ts): the native asset, the pixel focal point and any provider descriptor (ImageRef.raw; native is absent for a provider-only image) — that the render site resolves to a real CDN crop through the pluggable @v4/image provider — the app builds the slot. Views stay serialisable, which is what lets them cross the RSC boundary as props.
  • Values that are the app's job stay raw. Dates are left ISO (formatting is Intl work the app does with the request locale); the editor's focal point is turned into a CSS object-position string, but only where the asset dimensions are present to compute it.

Junk-tolerant by construction

Some Contentful fields are untyped JSON (stats, downloadLinks, the article video blob, the Focal Point App's { focalPoint: { x, y } }). The transform validates their shape defensively and drops anything malformed rather than trusting it — objectPositionFromFocalPoint returns undefined for any missing or odd shape, so the consumer falls back to the browser default 50% 50%. A view never contains a value it has not checked.

The same boundary the media package draws

This is the model layer's face of a pattern the video platform integration draws independently. @v4/media's packages/media/src/views.ts normalises the video SDK's orval-generated, everything-nullable responses into required domain views (videoView, railView, playbackView, …) with exactly the same discipline — drop entries without an id or title, collapse null to undefined, decide the gated flag rather than pass it through. Two data sources, one rule: reshape the external nullable shape into a required domain view at a single boundary, and let everything past that boundary be honest. See @v4/media for the video side; the focal-point maths now lives once in the pure @v4/image core, which core/transforms/image-source.ts delegates to (@v4/image is SDK-free, so the model layer depends on it without a video-SDK layering violation).

Rich text is normalised too

normaliseRichText is the one central rich-text transform. Contentful returns rich text as { json, links } — a document plus separate entry/asset link maps — and the normaliser flattens the pair into a NormalisedRichText: { document, assetsById, entriesById }, links resolved into ReadonlyMaps keyed by id. App renderers then read the document and look linked assets up by id; they never touch the raw link maps. That view is what the content-app renderer consumes — see Rendering rich text.

Links to OTHER ENTRIES (entry-hyperlink) are resolved outside the page query: entryHyperlinkTargetIds lists the targets a document points at, a page fetches their routing keys in one follow-up query, and richTextEntryLinkHrefs turns them into hrefs through the same route table as a Link entry (entryHref).

Content blocks — the renderable block set

An editor-composed page (the homepage, a generic content page, a listing) carries a contentBlocks collection: an ordered, heterogeneous list of blocks — carousels, promos, text, stats, accordions, embeds. content-blocks.ts is the transform that maps that raw list to a discriminated union of renderable views the app can walk with a single switch. It is the busiest transform in the package (packages/model/src/content/transforms/content-blocks.ts) and the shape every block-composing surface shares.

One union, discriminated by kind

Each block member gets a view interface tagged with a literal kind, and they compose into ContentBlockView:

export type ContentBlockView =
  | ContentNewsCarouselView // kind: "newsCarousel"
  | ContentPromoCarouselView // kind: "promoCarousel"
  | ContentTextBlockView // kind: "textBlock"
  | ContentPromoView // kind: "promo"
  | ContentTextWithImageView // kind: "textWithImage"
  | ContentAccordionView // kind: "accordion"
  | ContentStatsView // kind: "stats"
  | ContentTableView // kind: "table"
  | ContentDownloadView // kind: "download"
  | ContentGatewayView // kind: "gateway"
  | ContentArticlePromoView // kind: "articlePromo"
  | ContentEmbedView // kind: "embed"
  | ContentTabbedCarouselView // kind: "tabbedCarousel"
  | ContentScrollerView // kind: "scroller"
  | ContentScriptEmbedView // kind: "scriptEmbed"
  | ContentWscWidgetView // kind: "wscWidget"
  | ContentUnsupportedView; // kind: "unsupported"

A page transform maps its raw items straight through the dispatcher, dropping nulls as it goes:

blocks: (fragment.contentBlocksCollection?.items ?? []).flatMap((block) =>
  block ? [contentBlockView(block)] : [],
),

The renderer then switches on kind and hands each view to its @v4/ghost-content block, skipping any member it cannot draw. Its server half, content-app's prepareBlocks, ends that chain in const _unrendered: ContentUnsupportedView = view, so a union member given no branch is a compile error rather than a silently skipped block: skipping stays a decision about one block with nothing to draw (an empty carousel, say), never a kind the renderer forgot. The discriminant is what lets one blocks array carry every block type while the component tree stays fully typed — no any, no runtime shape-sniffing.

contentBlockView dispatches on __typename

The dispatcher (contentBlockView) is a __typename-driven if/else, not an exhaustive switch — deliberately, because the block union carries every domain's members the schema allows, and this transform exists to not know about the ones this site does not render. Each recognised typename delegates to a per-block builder (newsCarouselView, promoView, accordionView, …); anything unrecognised falls through to { kind: "unsupported", typename }.

Two guards make that degradation safe rather than fatal:

  • The stub guard. A surface that does not select a member's fragment still receives its __typename as a bare stub with no sys. Dispatching on the name alone crashed the homepage the day an editor authored a block whose fragment the page did not carry, so the transform checks "sys" in block first and returns unsupported for a stub.
  • Builders that can decline. articlePromoView, embedView, wscWidgetView and the script embed return undefined / fall to unsupported when their required inner content is missing (a promo with no article, an embed with no item, a WSC widget with no valid clip type or source, an empty script). A block of a type the site does not render, or an incompletely authored one, degrades to nothing — the page renders the rest.

The union is the contract, and unsupported is a first-class member of it rather than a thrown error.

What each builder does

The per-block builders apply the same boundary discipline as every other transform (see Transforms & normalised views): null → undefined, images as { url, alt, objectPosition? } data (the block fragments select the asset width/height, so imageView derives the focal-point object-position — focal framing works on every block, not just cards), decided booleans (displayAsGrid === true). Three block-specific jobs are worth calling out:

  • Untyped JSON fields are parsed defensively. stats and downloadLinks are JSON columns; statEntries / downloadEntries walk them, keep only entries with the right-shaped { label, value } / { title, assetId }, and drop the rest. A malformed row never reaches a component.
  • Editor themes map to colour sets. resolveThemeColourSet translates an authored theme.themeId (colourTheme1–6) to the design system's colourSet1–6; an absent theme leaves the field undefined so the component keeps its default. This is how an editorial section theme reaches the colour-set axis every ghost-content block exposes. Colour Sets V2 keeps Colour theme 1–3: club/0036 archives the Theme entries for 4–6, so a block still linking to one gets a null theme (createModelClient logs the partial response) and keeps its default too. THEME_COLOUR_SETS keeps all six on purpose, so an unarchived theme takes effect again with no code change — except on the Text Block, which honours 1–3 only (TEXT_BLOCK_THEME_COLOUR_SETS).
  • Composed blocks recurse. A scroller composes accordion and download sections; an embed narrows on the inner __typename (Image, PullQuoteEmbed, SocialEmbed, Gallery, Video) into a ContentEmbedItemView. Filters for the card-driven blocks (newsCarousel, tabbedCarousel) are surfaced as { categoryId, tags } — the route resolves the cards with its own query, since pagination and card fetching stay app-owned.

How it connects to rich text and the ghost-content blocks

Blocks that carry a rich-text body — textBlock, accordion, table, download, textWithImage — put it on the view as a NormalisedRichText, built by the shared rich() helper over normaliseRichText. The renderer feeds that to the content-app RichText component (packages/content-app/src/lib/rich-text.tsx), which walks the normalised document to plain semantic HTML inside the block's ContentBlockRichText well — see Rendering rich text for the renderer, including how TableBlock lifts a rich-text table node into its own rows.

So the flow from CMS to pixels is: fragment → contentBlockView → a kind-tagged view → the matching @v4/ghost-content block, with rich-text bodies normalised on the way and unknown blocks degrading to unsupported. The component package never sees a Contentful shape; the model layer never sees a React node. content-blocks.ts is the seam between them.