@v4/template-config

The site's typed configuration, club.config.ts: defineClubConfig, env markers, feature modules, and the compose and sync generators.

2026-11-07Status: Implemented (layer 1)

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.tokens is empty; the club tokens come from @v4/tokens-euro-league, wired in apps/web/src/theme/tokens.css.ts (see @v4/tokens-euro-league). theme.chrome.colourWay: "default" picks the light header (inverse is 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: baseUrl from COMPETITION_ENGINE_BASE_URL (default http://localhost:8080), competitions { euroleague: "E", eurocup: "U", nextgen: "J" }.
  • Features, each declared only when its variable is set (so CI and verify build with none): WSC stories (NEXT_PUBLIC_WSC_API_KEY), the ELTV livestream (NEXT_PUBLIC_VESPER_API_KEY), ECAL calendar (NEXT_PUBLIC_ECAL_API_KEY), GTM analytics (NEXT_PUBLIC_GTM_CONTAINER_ID), OneTrust consent (NEXT_PUBLIC_ONETRUST_DOMAIN_SCRIPT_ID) and Cortex auth (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 — default is the default competition's slug (Site Settings → Default Competition), whose pages live at the root, so the proxy can send /euroleague/about straight to /en/about. capabilities says 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>) and switcher (a row in the header's competition switcher). The model reads it through sectionCapabilities in @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 }, displays keyed by section slug (widgetId, category, optional teamCategory, teamNames, logo). See @v4/ecal.
  • features.stories (provider: "wsc", apiKey, environment — prod or uat, from NEXT_PUBLIC_WSC_ENVIRONMENT, default prod — and allowHorizontalStories) and features.livestream (provider: "vesper", apiKey, realm, baseUrl, environment, watchUrl, optional loginUrl / accessUrl) — a blank key is refused; omit the feature to turn it off.
  • football is 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.server name the variables without holding values; a secret under NEXT_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.object rebuilds objects and would strip the getter). See Env markers.
  • Features default to absent — every integration is { provider, …ids } | false, and false means unmounted, unrendered and unimported. See Features & domains.
  • Compile-time cross-gates — locales.supported is typed against the model's DictionaryLocale union and theme.tokens must be a real ThemeTokens, both pinned by @ts-expect-error tests.
  • 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 from club.config.ts, and CI fails on drift (--check). domains gates which route packages compose (content always; video was the first bolt-on), and DEP_WIRING injects each route's deps:

    • client — the draft-aware Contentful client (contentClient);
    • deliveryClient — the delivery-only client passed AS the route's client, 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, for generateStaticParams (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 (under cacheComponents a route that reads runtime data needs one to prerender a static shell). A route that names a boundary in its routes.json entry gets none in the shim; instead one generated layout at that segment holds it — news/layout.tsx for every News route with params, so a navigation within News keeps the current page on screen.

  • /sync-upstream (scripts/sync-upstream.ts, root pnpm sync-upstream) — offers the template's changes since .claude/manifest.json#templateCommit as a pull request on this repo. It diffs templateCommit..template/<ref> (the template git 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 root package.json and lockfile, migrations/renamed.json, the docs app, and the ticket board's config, pushed record and caches. Its helpers live in scripts/clone-migrations.ts (ticket config merges, new env keys, and clientSpecTicketExcludes, which leaves a ticket file marked clientSpec: true alone) and scripts/tickets-manifest.ts. The steps are in Take template updates.

  • /add-feature (scripts/add-feature.ts, root pnpm 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 the apps/web wiring, 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.