@v4/contentful-contracts
Versioned JSON field contracts read by the site, plus what this repo holds of the Contentful apps: their registry and installs.
Overview
Some Contentful fields hold a JSON document written by one of our Contentful apps — the ticker's configuration, an image's geo rules, a download block's links. @v4/contentful-contracts holds one versioned contract per such field: the zod schema, the upgrade chain and the pure logic both sides need. The app uses it to validate and migrate what editors type; the site uses it to read the field without a hand-rolled guard.
Nothing in the package imports React, the app SDK or the DOM, which is what makes it usable from a Next server component and a Contentful iframe alike. It is raw TypeScript (no build), sideEffects: false, and its only dependency is zod.
What this repo has. The apps' source code (apps/contentful/*) and their kit (@v4/contentful-ui) live in the Publishing Hub template, not here; they are built and released from there. This repo has this package, because the site reads the fields the apps write; the registry of apps the Euro League space installs (packages/model/apps/registry.json, eleven apps), the generated lock of their definition ids (packages/model/apps/apps.lock.json), and sync-apps, which migrate.mjs runs to install and bind them in an environment. The rules are in .claude/rules/model/contentful-apps.md.
The core: @v4/contentful-contracts
const contract = defineFieldContract<T>({ name, version, schema, upgrades?, salvage? });
contract.migrate(value); // → { value, status, fromVersion }
contract.sanitize(value, fallback); // → { value: T, status, valid }
contract.isValid(value);
contract.hasUpgradePath(from);
nameis the registry slug of the app that writes the field;versionis the shape this bundle writes, carried on the document as a top-levelversion.upgrades[n]migrates a document at versionnton + 1.VersionStatussays where a stored document sits:current— the same version;upgraded— older, upgraded in memory through the chain;newer— written by a NEWER bundle; left byte-for-byte alone so an older reader never destroys keys it cannot see, and the editor shows a warning instead of editing blind;unknown— no usableversion(pre-versioning, hand-edited, or a gap in the chain); the schema decides whether it is usable.
- An ARRAY document (the downloads list) has nowhere to carry a
versionand is always read as current. salvageByShape(stored, fallback)is the default recovery when a migrated document still fails the schema: editor data is never thrown away — per key, the stored value is kept when it has the same JavaScript shape as the fallback's, and unknown keys are kept.
Other exports: the types FieldContract, FieldContractOptions, MigrationResult, SanitizeResult, VersionStatus.
The one rule: every schema that describes part of a persisted document must .passthrough(). sanitize() results are written back to the field, so a key the schema strips is a key permanently destroyed.
The field contracts
Each subpath is one field. The app slug is its entry in packages/model/apps/registry.json.
| Subpath | Field (content type → field) | App | Main exports |
|---|---|---|---|
./ticker |
siteSettings.tickerConfig |
ticker |
tickerContract, readTickerConfig, selectTickerItems; the path matcher (normalisePath, matchesPattern, isPathExcluded, resolvePageGroups, messageTargetsPath); the schedule (isWithinSchedule, messageScheduleState, countLiveMessages); defaults (createDefaultTickerConfig, DEFAULT_PAGE_GROUPS, UNIVERSAL_GROUP_ID, MIN_TICKER_ITEMS) |
./geo-rules |
image.geoRules, imageLink.geoRules |
image-geo-rules |
createGeoRulesContract (one per host type), geoRulesSchema, parseGeoRules, selectGeoRules, applyGeoToItem / applyGeoToGroup, isSponsorVisible, resolveAlternative, needsGeoResolution, describeGeoRules; countries (canonicalCountry, isKnownCountry, allCountries, searchCountries, DEPRECATED_COUNTRY_ALIASES) |
./downloads |
downloadBlock.downloadLinks |
downloads |
downloadsContract, downloadLinkSchema, readDownloadLinks, the title and file-info length limits. A bare, versionless array |
./social-channels |
socialChannels on footerBlock, profile, settingsAppSettings |
social-channels |
The ONE channel vocabulary (SOCIAL_CHANNELS, SOCIAL_CHANNEL_KEYS, SOCIAL_CHANNEL_ALIASES, canonicalChannel, channelName), socialChannelsContract, readSocialChannels |
./player-profile-details |
profile.customDetails |
player-profile-details |
playerProfileDetailsContract, readPlayerProfileDetails, PLAYER_PROFILE_DETAILS_VERSION; honour logos stored as real Asset links (assetLink), row ids (createRowId); upgrades the older versionless shape |
./social-embed |
socialEmbed.socialContent |
social-embed |
socialEmbedContract, socialEmbedFor, readSocialEmbed, isSupportedSocialUrl — an oEmbed-shaped document fetched through the site's oEmbed proxy (an installation parameter) |
./paths |
contentPage.fullPath, pagesPackage.fullPath |
path-generator |
pathFieldsSchema, joinPath, normaliseSegment, slugFor, resolveChain (cycle and missing-parent detection), fullPathsForChain, planCascade, MAX_PATH_DEPTH (25). The publish-time cascade runs the same functions |
./match-data |
fixture.matchData |
match-data |
Manual match state as the same normalised snapshot as provider data, plus a legacy Opta reader. It belongs to the football domain, which this site does not have, so it is inert here |
Two of these exist because copies drifted: the ticker's matcher.fixtures.json is the conformance suite both sides run (written after two copies of the matcher had diverged), and ./social-channels replaced three diverging channel lists, one of which spelt snap-chat.
Consumers in this repo
Only @v4/model depends on it (packages/model/package.json), in its transforms:
| File | Reads |
|---|---|
packages/model/src/core/transforms/site-chrome.ts |
readTickerConfig; parseGeoRules, selectGeoRules |
packages/model/src/core/transforms/sponsor-geo.ts |
applyGeoToItem, canonicalCountry, isKnownCountry, needsGeoResolution — footer sponsor geo |
packages/model/src/content/transforms/content-blocks.ts |
readSocialEmbed |
Changing a contract is changing what an app writes and what the site reads at once: bump version, add the upgrades step, and keep .passthrough(). Because the apps are released from the template, a contract change belongs in the template and reaches this repo through /sync-upstream (see Take template updates).
Scripts and tests
test / test:watch (vitest), lint, check-types — no build. Suites: field-contract.test.ts and one per subpath.