Content model
How the Contentful model is kept as code: domain-sliced migrations, the vendored schema, fragments, transforms and the dictionary.
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)
- Migrations apply the model to a Contentful environment, sliced by domain (see Domains & ownership).
sync-schemaintrospects the environment and vendors a deterministicschema.graphql— the type SHAPE, which is the contract.- Codegen generates the TypeScript types into
src/__generated__(committed and drift-gated). - Fragments are curated per component surface — the reviewed statement "this surface consumes exactly these fields".
- Transforms turn Contentful responses into the props ghost components consume.
- 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(fromsync-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.cjsandmigrations/video/0000-baseline-video.cjs(fromscripts/generate-composed-baseline.mjs, run once when the template'snew-clubgenerated 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-schemaintrospects this space's own environment, so the vendoredschema.graphqlcontains 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 own0001-*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 whycontent/holds both0001-baseline-content.cjsand0001-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, anddeleteContentTypeland the release after the code that stopped depending on them, and each must carry a// contract: <reason>marker orlint-migrations.mjsfails the PR. - The born-required exception.
.required(true)chained onto a field created in the samecreateFieldstatement 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
developenvironment (model-apply-develop.yml) and ephemeralmodel-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
.graphqledit, runpnpm --filter @v4/model generate; codegen validates every field name against the vendoredschema.graphqland refuses guesses. Import the types from@v4/model, not from codegen paths. - Collections always carry an explicit
limitsized 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), thenpnpm --filter @v4/model push-dictionaryseeds 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-dictionaryregeneratessrc/__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 taggedcontent:dictionaryKey;createDictionary(locale)is the synchronous snapshot fallback) — a typedDictionaryKeyNameunion (the GraphQL schema owns the plain nameDictionaryKey), resolving the requested locale → the default dictionary locale (DEFAULT_DICTIONARY_LOCALE,en-UShere) → the vocabulary default. The app callsconfigureDictionary({ client })once, inapps/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
slugor notitlecannot be rendered or linked, so the transform returnsundefinedrather than a half-view — and the composing surfaceflatMaps those away (slide ? [slide] : []). Downstream code never asks "but what if the title is null?"; by the time it holds anArticleCardView, the title is astring. nullcollapses toundefined. Optional fields becomevalue ?? undefined, so a view has one absence value, not two. The types readsummary?: string | undefined, neverstring | null.- Booleans are decided, not passed through.
isRestrictedToMembersOnly === trueturns a nullable tri-state into a realbooleanthe component can branch on. - Images are data, never markup. A card view carries
{ url, alt, width, height, objectPosition }PLUS a rawimageRef— built byimageRefFrom(core/transforms/image-source.ts): the native asset, the pixel focal point and any provider descriptor (ImageRef.raw;nativeis absent for a provider-only image) — that the render site resolves to a real CDN crop through the pluggable@v4/imageprovider — 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
Intlwork the app does with the request locale); the editor's focal point is turned into a CSSobject-positionstring, 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
__typenameas a bare stub with nosys. 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 blockfirst and returnsunsupportedfor a stub. - Builders that can decline.
articlePromoView,embedView,wscWidgetViewand the script embed returnundefined/ fall tounsupportedwhen 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.
statsanddownloadLinksare JSON columns;statEntries/downloadEntrieswalk 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.
resolveThemeColourSettranslates an authoredtheme.themeId(colourTheme1–6) to the design system'scolourSet1–6; an absent theme leaves the fieldundefinedso 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/0036archives the Theme entries for 4–6, so a block still linking to one gets a nulltheme(createModelClientlogs the partial response) and keeps its default too.THEME_COLOUR_SETSkeeps 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
scrollercomposes accordion and download sections; anembednarrows on the inner__typename(Image,PullQuoteEmbed,SocialEmbed,Gallery,Video) into aContentEmbedItemView. 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.