@v4/template-config
The site's typed configuration, club.config.ts: defineClubConfig, env markers, feature modules, and the compose and sync generators.
Overview
Implemented (layer 1) — packages/template-config (@v4/template-config). One typed
file, apps/web/club.config.ts, describes the Euro League site; the schema validates it at build
time, so misconfiguration fails the build with a named path instead of crashing a component at
runtime.
The package came with the template and is updated through /sync-upstream; club.config.ts
itself is this project's own and is never offered by a sync (see
Take template updates).
Why the config is shaped the way it is — the layers, env markers, feature modules and the cross-gates — is explained in Template config; this page is the package reference.
What apps/web/club.config.ts sets for this site:
- Identity: "EuroLeague Basketball", title template
%s | EuroLeague Basketball. - Theme:
theme.tokensis empty; the club tokens come from@v4/tokens-euro-league, wired inapps/web/src/theme/tokens.css.ts(see@v4/tokens-euro-league).theme.chrome.colourWay: "default"picks the light header (inverseis the black one). - Locales:
{ supported: ["en-US"], default: "en-US", routing: "segment" }— en-US is the space's only locale, and every address carries its locale segment (/en/news). - Domains:
["content", "video", "basketball"]. - Sections:
default: "euroleague"; Corporate has no data areas, no video home and no switcher row. - Competition Engine:
baseUrlfromCOMPETITION_ENGINE_BASE_URL(defaulthttp://localhost:8080), competitions{ euroleague: "E", eurocup: "U", nextgen: "J" }. - Features, each declared only when its variable is set (so CI and
verifybuild with none): WSCstories(NEXT_PUBLIC_WSC_API_KEY), the ELTVlivestream(NEXT_PUBLIC_VESPER_API_KEY), ECALcalendar(NEXT_PUBLIC_ECAL_API_KEY), GTManalytics(NEXT_PUBLIC_GTM_CONTAINER_ID), OneTrustconsent(NEXT_PUBLIC_ONETRUST_DOMAIN_SCRIPT_ID) and Cortexauth(CORTEX_SSO_BASE_URL).
The config also drives the app shell's metadata: title template, metadataBase, robots policy
and <html lang> all derive from it. To change any of them, edit the config, never the layout.
The API
import { defineClubConfig, env } from "@v4/template-config";
export default defineClubConfig({
identity: { name, shortName, sport, crest, titleTemplate, social }, // sport defaults to "Soccer"
theme: { tokens, colourSetDefault, chrome: { colourWay } }, // ThemeTokens; "default" | "inverse"
fonts: { heading, body },
locales: { supported, default, routing }, // ⊆ DictionaryLocale from @v4/model; "segment" | "none"
content: { spaceId, environment: env.server("CONTENTFUL_ENVIRONMENT"), … },
images: { defaultProvider, contentful: { proxyHost }, scoreplay, cloudinary }, // pluggable @v4/image; scoreplay/cloudinary: {…} | false
routes: { vocabulary, sitemap, staticParamsBudget },
seo: { baseUrl, allowRobots, ogImage },
football: { dataApi, dataProvider: "opta" | "stats-perform", leagueTable: { bands }, matchCentre: { panels, grouping } },
hosting: { platform, redis },
features: { search, liveMatch, broadcasters, gamification, video,
analytics, consent, errorTracking, ads, ticker, stories, livestream,
calendar, auth }, // each: {…} | false
domains: ["video", …], // the site's bolt-on route domains
sections: { default, capabilities: { [slug]: { sport?, video?, switcher? } } },
video: { apiBaseUrl, brightcoveAccountId, landingSlug }, // platform video (@v4/media)
competitionEngine: { baseUrl, competitions: { [slug]: "E" | "U" | "J" } }, // @v4/competition-engine
brands: [{ id, hostPattern, chrome }],
});
What the newer keys mean:
sections—defaultis the default competition's slug (Site Settings → Default Competition), whose pages live at the root, so the proxy can send/euroleague/aboutstraight to/en/about.capabilitiessays what each section HAS; a section not named has everything:sport(the data areas under/teams/<section>and the rest),video(a video home at/video/<section>) andswitcher(a row in the header's competition switcher). The model reads it throughsectionCapabilitiesin@v4/model/core.competitionEngine— the API origin and the section → engine competition code map, the fallback for a section whose CMS entry carries no code yet.features.calendar—{ provider: "ecal", apiKey, displays },displayskeyed by section slug (widgetId,category, optionalteamCategory,teamNames,logo). See@v4/ecal.features.stories(provider: "wsc",apiKey,environment—prodoruat, fromNEXT_PUBLIC_WSC_ENVIRONMENT, defaultprod— andallowHorizontalStories) andfeatures.livestream(provider: "vesper",apiKey,realm,baseUrl,environment,watchUrl, optionalloginUrl/accessUrl) — a blank key is refused; omit the feature to turn it off.footballis still in the schema (it defaults to{}); nothing in this repo reads it.
Exports
| Export | What it is |
|---|---|
defineClubConfig, ClubConfigError |
Validate a config; the error names every bad path |
env |
The env.public / env.server markers |
isLocalBaseUrl |
Whether a base URL points at a local host |
localeForSegment, localeSegment |
Locale ↔ path segment (en-US ↔ en) for routing: "segment" |
deriveImageHosts, deriveRobots |
The layer-2 derivations (below) |
| Types | ClubConfig, DefineClubConfigInput, ResolvedClubConfig, AuthAdapter, EnvMarker, ServerSecret, RemoteImagePattern, RobotsDerivation |
Guarantees
Each is explained in Template config; in short:
- Build-time validation — zod parses at module load; a bad value names its exact path
(
identity.titleTemplate: must include "%s"), and every missing env var is reported at once. - Env markers, not env reads —
env.public/env.servername the variables without holding values; a secret underNEXT_PUBLIC_is a build error, and a server value read in a browser context throws at first access. The accessor survives parsing because zod passes it through by reference (z.custom—z.objectrebuilds objects and would strip the getter). See Env markers. - Features default to absent — every integration is
{ provider, …ids } | false, andfalsemeans unmounted, unrendered and unimported. See Features & domains. - Compile-time cross-gates —
locales.supportedis typed against the model'sDictionaryLocaleunion andtheme.tokensmust be a realThemeTokens, both pinned by@ts-expect-errortests. - Auth is an adapter interface, not a provider enum, so the provider (here Cortex, through
@v4/cortex-sso) is swappable without a schema change.
Derivations (layer 2, first slice)
deriveImageHosts(config) computes next.config's images.remotePatterns from the enabled
integrations (content → ctfassets, each video provider → its thumbnail host, broadcasters →
the logo host, and each enabled image provider → its CDN host: Scoreplay
dynamic-crop-cdn.scoreplay.io, Cloudinary res.cloudinary.com, a Contentful proxy host), and deriveRobots(config) produces robots.ts from seo.baseUrl +
seo.allowRobots (deny-all is the safe default). Both are wired into apps/web, so the
allowlist can no longer drift from the integration list. Why
they are derived rather than hand-kept is in
What the config generates.
The generators this project runs
pnpm compose— the compose-club generator now lives in this package (scripts/compose-club.ts): it regenerates the app's route shims and the model composition manifest fromclub.config.ts, and CI fails on drift (--check).domainsgates which route packages compose (content always;videowas the first bolt-on), andDEP_WIRINGinjects each route's deps:client— the draft-aware Contentful client (contentClient);deliveryClient— the delivery-only client passed AS the route'sclient, so draft mode cannot make a page request-time; the basketball areas use it to stay cacheable;buildClient— the delivery-only client as a separate prop, forgenerateStaticParams(news/[...path]);locale(clubConfig.locales.default),media(lib/media),competitionEngine(lib/competition-engine);sportsData(lib/sports-data) — unused on this site.
Each shim wraps the route body in its own
<Suspense>boundary (undercacheComponentsa route that reads runtime data needs one to prerender a static shell). A route that names aboundaryin itsroutes.jsonentry gets none in the shim; instead one generated layout at that segment holds it —news/layout.tsxfor every News route with params, so a navigation within News keeps the current page on screen./sync-upstream(scripts/sync-upstream.ts, rootpnpm sync-upstream) — offers the template's changes since.claude/manifest.json#templateCommitas a pull request on this repo. It diffstemplateCommit..template/<ref>(thetemplategit remote) over the shared paths and the domains this site has, 3-way applies the result to a branch, and leaves conflicted hunks marked for review. It never offers this project's own files:apps/web/club.config.ts, the crest,.claude/manifest.json, the rootpackage.jsonand lockfile,migrations/renamed.json, the docs app, and the ticket board's config, pushed record and caches. Its helpers live inscripts/clone-migrations.ts(ticket config merges, new env keys, andclientSpecTicketExcludes, which leaves a ticket file markedclientSpec: truealone) andscripts/tickets-manifest.ts. The steps are in Take template updates./add-feature(scripts/add-feature.ts, rootpnpm add-feature) — brings a domain this site does not have yet in from the template: restores its files, the ownership-map block, the composition and theapps/webwiring, then re-baselines the model.
new-club (scripts/new-club.ts) is how this repo was cut from the template in the first place;
it is not run here. new-contentful-app scaffolds onto @v4/contentful-ui, which is not in this
repo — Contentful apps are built in the template.
Not yet built (later layers)
Middleware matchers computed from the config, the remaining feature-module mounting (search,
live match, broadcasters, ads and the rest) as their features land in apps/web, and multi-brand
chrome (brands is not set on this site). Sitemaps ship from the app itself
(apps/web/src/lib/sitemaps.ts) rather than as a config derivation.