Monorepo and packages
How the pnpm and Turborepo monorepo is laid out, what each app and package owns, and how they depend on one another.
Repository layout
The Euro League site is one pnpm + Turborepo monorepo. Everything — the
app, its design system, the token pipeline, the content model, and
the tooling that enforces the conventions — lives together and is built through
one Turbo graph. Internal packages reference each other with workspace:*, so
there is no npm publishing and no version train: a change to a shared package and
the change that consumes it are a single PR and a single CI run.
Work is scoped with --filter, e.g. pnpm --filter @v4/ghost-core test.
Apps
The apps/ directory holds the things that run.
| Path | Package | Contents |
|---|---|---|
apps/web |
@v4/web |
The Euro League website — the Next.js app that is deployed (dev on :3000, next dev --webpack). |
apps/docs |
@v4/docs |
The docs app you are reading; serves apps/docs/content/, including this reference (dev on :4400). |
apps/storybook |
@v4/storybook |
The single Storybook — stories, the colour-set toolbar, fonts, and vitest browser tests (:6006). |
apps/e2e |
@v4/e2e |
Playwright e2e; six browser projects (desktop, ghost-s, webkit, firefox, tablet, laptop), boots or reuses Storybook on :6006. |
Packages
The packages/ directory holds everything the apps compose. Each package is
covered in the next section and has its own page under Packages; this is the map.
| Path | Package | Contents |
|---|---|---|
packages/ghost-core |
@v4/ghost-core |
Design-system core: the theme foundation plus 148 base components, with a React-free @v4/ghost-core/theme export. |
packages/ghost-content |
@v4/ghost-content |
72 content blocks — the editorial building blocks of a page and an article body. |
packages/ghost-video |
@v4/ghost-video |
27 video components and carousels. |
packages/ghost-basketball |
@v4/ghost-basketball |
139 basketball components (schedules, standings, brackets, player and team surfaces, Game Centre modules), from ELB – Web – Basketball. |
packages/token-parser |
@v4/token-parser |
The Token Studio engine: a tokens.json → TypeScript CLI (v4-tokens), plus the Figma bridge and fork scripts. |
packages/tokens |
@v4/tokens |
Generated Ghost token defaults and namespace contracts, plus the generated icon set. |
packages/tokens-euro-league |
@v4/tokens-euro-league |
ELB's token layer (ELB's Token Studio file, projected onto the Ghost contract), the EuroCup and NextGen competition themes, fonts and motion values. |
packages/model |
@v4/model |
Contentful model-as-code: migrations, vendored schema, generated types, curated fragments, transforms, dictionary. |
packages/template-config |
@v4/template-config |
defineClubConfig() — the typed per-club config — and the compose, add-feature and sync-upstream generators. |
packages/content-app |
@v4/content-app |
The content domain's routes as per-route factories, plus shared page helpers the other route packages import. |
packages/video-app |
@v4/video-app |
The video domain's routes as per-route factories. |
packages/basketball-app |
@v4/basketball-app |
The basketball domain's routes: teams, players, coaches, games (with the Game Centre), standings, stats, and the widgets other pages borrow. |
packages/competition-engine |
@v4/competition-engine |
The Competition Engine integration: vendored OpenAPI contract, generated types, cached server fetchers, views, Contentful merge, mocks. |
packages/media |
@v4/media |
The Stadion platform video SDK integration, the MediaPlayer (Brightcove, YouTube, Mux) and the VideoPreview. |
packages/image |
@v4/image |
The pure image core: ImageRef / ImageView contracts, focal-point maths, createResolver. |
packages/image-contentful, packages/image-scoreplay, packages/image-cloudinary |
@v4/image-* |
One package per image provider. The site's default is Scoreplay; Contentful's Images API is the fallback. |
packages/wsc |
@v4/wsc |
WSC Sports stories and clips — the only importer of the Blaze web SDK. |
packages/eltv |
@v4/eltv |
EuroLeague TV livestream — the only importer of the Vesper SDK. |
packages/ecal |
@v4/ecal |
ECAL "Add to calendar" — the only place ECAL's widget script is loaded. |
packages/analytics |
@v4/analytics |
Google Tag Manager, OneTrust consent and the tracking plan's events. |
packages/cortex-sso |
@v4/cortex-sso |
Cortex (EuroLeague ID) single sign-on over NextAuth. |
packages/code-of-conduct |
@v4/code-of-conduct |
The In-Arena Code of Conduct report at /en/code-of-conduct and its API routes. |
packages/contentful-contracts |
@v4/contentful-contracts |
Versioned JSON field contracts the Contentful apps write and the site reads. |
packages/tickets |
@v4/tickets |
Tickets as code: typed ticket files, lint, board preview and the one-way Jira push. |
packages/docs-mcp |
@v4/docs-mcp |
The house-documentation MCP server (search/read over the docs, rules, and CLAUDE.md). |
packages/eslint-config |
@v4/eslint-config |
Shared ESLint preset. |
packages/typescript-config |
@v4/typescript-config |
Shared TypeScript preset. |
The Contentful apps editors see in this space (the video picker, Mux video and the rest) are
not built here: they are built and released from the template and installed into the space by
@v4/model's sync-apps. This repo carries only the field contracts they write
(@v4/contentful-contracts).
The stack these packages are built with is on How this site is built.
Package responsibilities
Each package owns one concern. Reading them in dependency order — tokens, then components, then the model and the app layer — is the quickest way to see how the system fits together.
@v4/ghost-core
The design-system core. It holds the theme foundation (buildGlobalTheme,
createTheme, registerComponentTheme) and the base component library, all
rebuilt from the Ghost 2.0 Figma design system in vanilla-extract. It also
exposes a React-free subpath, @v4/ghost-core/theme, so token contracts and the
theme helpers can be imported from a .css.ts file or a sibling package without
pulling in React. There is no separate @v4/theme package — the theme layer
lives here.
@v4/ghost-content
A sibling component package built exactly like ghost-core: the editorial
content blocks that make up an article body. Its components bind the content
token namespace through the global contract (g.components.content.*), never a
raw token import.
@v4/ghost-video
The video component package — the video-specific building blocks and their
carousels. Consumed by the video route factories in @v4/video-app.
@v4/ghost-basketball
The basketball UI library: schedules, standings, brackets, player and team
surfaces and the Game Centre modules, built from ELB – Web – Basketball (new
UI, not a fork of Ghost). It binds g.components.basketball.* and
g.teams.teams.<club>.*, the basketball extension of the global surface
(packages/ghost-core/src/theme/basketball-surface.ts). It is this project's own
package: the template has no basketball. See @v4/ghost-basketball.
@v4/tokens
The generated tokens package. It owns the Ghost 2.0 baseline tokens.json, the
generated defaults (src/__generated__/tokens.ts — the token source of truth),
the generated namespace contracts under @v4/tokens/components/*, and the 130
generated icons exposed via @v4/tokens/icons (pulled from Ghost Core and the ELB
Core and Content files, competition marks included). Nothing here is hand-edited;
it is all produced by @v4/token-parser.
@v4/token-parser
The Token Studio engine. A self-contained CLI (v4-tokens) that parses a
tokens.json, resolves references, applies value modifiers, and emits typed
TypeScript and CSS custom properties. It knows nothing about the site — it takes a
tokens.json and flags and writes output. CI and each package
build script invoke it directly.
@v4/tokens-euro-league
ELB's token layer: the brand values every component's contract resolves to.
Token Studio pushes ELB's tokens.json straight into the package (never run
sync-tokens here); generate compiles it with v4-tokens build -n euro-league,
and an adapter (src/adapter.ts, src/aliases.ts) projects ELB's names onto the
Ghost contract. scripts/generate-competition-themes.mjs emits the EuroCup and
NextGen competition themes (src/competition-themes.ts). It also carries the
brand font faces (./fonts) and the motion values that differ from Ghost's
(src/motion.ts). See @v4/tokens-euro-league.
@v4/model
Contentful model-as-code. It owns the domain-sliced migrations, the vendored
schema.graphql, the generated GraphQL types, the curated per-domain fragments,
and the transforms, under the subpaths @v4/model/core, /content, /video,
/basketball and /dictionary. Apps import everything Contentful-shaped from
@v4/model and nothing from Contentful SDKs directly. (package.json still lists
a ./football export with no source behind it — a leftover from the template, where
football is a domain.)
@v4/template-config
The typed site configuration package. defineClubConfig() produces
apps/web/club.config.ts — env markers, feature modules, bought domains,
competition sections, the competitionEngine and images blocks,
theme.chrome.colourWay, locales.routing, and the cross-gates between them.
Its generators live in packages/template-config/scripts/: compose-club (run
as pnpm compose) writes the route shims, boundary layouts, model composition and
generated lib files; add-feature adds a template domain; sync-upstream brings
template changes into this repo as a PR (Take template updates);
new-club is the scaffolder that generated this repo from the template.
@v4/content-app
The content domain's routes, shipped as per-route factories
(createHomeRoute, createNewsListingRoute, createNewsListingBySlugRoute,
createNewsPathRoute — the news/[...path] catch-all that serves articles and
the competition news listings — and siblings). These are the page bodies the web
app mounts through generated shims. It also exports the shared helpers the other
route packages build on (./metadata, ./placeholder-page, ./site-sections,
./page-competition, ./wsc-rail, ./article-header-widgets,
./home-hero-widgets, ./cache, …). It is a raw-source
package (transpiled by the app) and owns its own Contentful page queries and
codegen.
@v4/video-app
The video domain's routes. It
exposes landing, detail, collection, playlist and category factories
(createVideoLandingRoute, createVideoCategoryRoute and siblings) built over
@v4/ghost-video; every route takes client, media and locale. It owns its
Contentful query and codegen exactly as content-app does.
@v4/basketball-app
This project's own basketball domain: the competition data areas (teams,
players, coaches, games with the Game Centre, standings, stats) as per-route
factories over @v4/ghost-basketball and @v4/competition-engine. It also
exports the API route factories the app mounts (./games-live, ./games-round,
./players-search, ./players-hub, ./stats-teams) and the widgets other pages
borrow (./article-widgets, ./home-hero, ./team-strip). See
Basketball and the Competition Engine.
@v4/competition-engine
The Competition Engine (stadionHQ/stadion-euroleague) integration — the only
importer of its contract. The OpenAPI spec is vendored at a pinned sha
(spec/openapi.yaml, sync-spec / check-spec), types are generated by
openapi-typescript and drift-gated, and calls go through openapi-fetch behind a
use cache boundary. Subpaths: ./views (normalised camelCase views, no
server-only), ./merge (the Contentful merge — editorial wins when set),
./tags, ./mocks.
Image packages
@v4/image is the pure core (no SDK, React or Node): the ImageRef /
ImageRequest / ImageView contracts, the focal-point maths and
createResolver. Each provider is its own package — @v4/image-contentful
(Images API focal crop), @v4/image-scoreplay (the site's default, Scoreplay's
dynamic-crop CDN) and @v4/image-cloudinary (present, not an app dependency). The
app configures the resolver from club.config.images in apps/web/src/lib/images.ts.
Integrations
Six packages wrap one third party each, so the SDK is imported in exactly one
place: @v4/wsc (WSC stories and clips), @v4/eltv (EuroLeague TV), @v4/ecal
(add to calendar), @v4/analytics (GTM and OneTrust), @v4/cortex-sso (EuroLeague
ID sign-in) and @v4/code-of-conduct (the In-Arena report). Each has a
server-safe root and a ./client entry, and each is off until its environment
variable is set. See Club integrations.
@v4/contentful-contracts
The versioned JSON field contracts (defineFieldContract) that the Contentful
apps write and the site reads — ticker, geo rules, downloads, social channels,
social embeds, player profile details and match data. One schema serves both
sides. The apps themselves are built and released from the template and installed
into this space by @v4/model's sync-apps.
@v4/tickets
Tickets as code: typed .ticket.ts files, their lint, the board preview and the
one-way Jira push. Jira (the ELBWEB board) is the source of truth once a ticket is
on the board; see .claude/rules/tickets/platform-and-client.md.
@v4/media
The Stadion platform video SDK integration — the only importer of the
@stadionhq-v3/platform-video-sdk-* packages. It provides configureMedia,
server fetchers, normalised views (@v4/media/views), the MediaPlayer
(Brightcove, YouTube and Mux) at @v4/media/player, the VideoPreview (a muted
inline HLS preview) at @v4/media/preview, and its pure helpers at
@v4/media/preview-source — a module with no directive, because a function
exported from a "use client" module is a client reference on the server. It sits
behind a private npm scope, so CI needs an NPM_TOKEN.
@v4/docs-mcp
The house-documentation MCP server: search and read over apps/docs/content,
.claude/rules, and CLAUDE.md. It is registered in .mcp.json as v4-docs.
@v4/eslint-config and @v4/typescript-config
The shared tooling presets. Every package extends these, so the strict
type-aware ESLint rules (no any, explicit module boundaries, zero warnings) and
the TypeScript settings are defined once.
Dependency flow
The packages form a directed graph: tokens feed components, components feed the
route factories, and the app composes the route factories. Everything internal is
linked with workspace:*, so the graph is resolved from the filesystem, not from
a registry.
workspace:*, not versions
Every internal dependency is declared as workspace:*:
// packages/video-app/package.json (internal dependencies)
{
"dependencies": {
"@v4/analytics": "workspace:*",
"@v4/content-app": "workspace:*",
"@v4/ghost-core": "workspace:*",
"@v4/ghost-video": "workspace:*",
"@v4/media": "workspace:*",
"@v4/model": "workspace:*",
"@v4/tokens": "workspace:*",
},
}
There is no npm publishing between internal packages and no version train. A
package always resolves to the copy on disk, so a change to @v4/ghost-core is
visible to every consumer in the same commit — and Turbo rebuilds only what the
change touched, keyed on inputs.
The flow, top to bottom
tokens.json ──▶ @v4/token-parser ──▶ @v4/tokens ──▶ @v4/ghost-core (+ theme)
│
@v4/ghost-content / -video / -basketball
│
@v4/content-app ◀── @v4/video-app ◀── @v4/basketball-app (route factories)
│ ▲
@v4/model ──▶ fragments/transforms ───────────────┤ @v4/competition-engine
@v4/media (video platform) ───────────────────────┤
▼
apps/web
@v4/tokensis generated fromtokens.jsonby@v4/token-parserand depends on nothing at runtime.@v4/tokens-euro-leaguesits beside it and is read only by the app's theme files.- The component packages depend on
@v4/tokensand on@v4/ghost-core/theme(for the theme helpers). - The route-factory packages depend on the component packages and
@v4/model, and on each other:@v4/video-appbuilds on@v4/content-app's shared page helpers, and@v4/basketball-appon both, plus@v4/competition-engine,@v4/media,@v4/image,@v4/wscand@v4/ecal. @v4/imageis the PURE image-provider core (no SDK, React or Node):@v4/model,@v4/content-app,@v4/competition-engineand the provider packages (@v4/image-contentful,@v4/image-scoreplay, …) depend on it, so it is a leaf every layer can consume.apps/webdepends on@v4/imageplus the providers it enables and configures the resolver fromclub.config.imagesinapps/web/src/lib/images.ts, imported for its side effect by theapp/[locale]/layout.tsxroot layout.apps/websits at the bottom and composes the route factories through generated shims.
Precompiled component packages, raw-source route packages
The two kinds of workspace package reach the app differently.
- Component packages ship a built
dist/.@v4/ghost-coreand its siblings build withtsc, thenscripts/precompile-styles.mjscompiles every component stylesheet to plain CSS andscripts/check-theme-owners.mjschecks the theme-owner lists. Only each package'sinit.css(and what it imports) stays raw, so the app's vanilla-extract unit can register the themes and emit ELB's theme. A.css.ts, a.theme.tsor anything they import must therefore never reach a package barrel — the precompile fails the build when one does. - Route and integration packages are raw source.
@v4/content-app,@v4/video-app,@v4/basketball-app,@v4/competition-engineand the integration packages export./src/*and own their own codegen; the app transpiles them in place.
Both kinds are listed in Next's transpilePackages:
// apps/web/next.config.ts
transpilePackages: [
"@v4/analytics",
"@v4/image",
"@v4/image-contentful",
"@v4/image-scoreplay",
"@v4/basketball-app",
"@v4/code-of-conduct",
"@v4/competition-engine",
"@v4/content-app",
"@v4/cortex-sso",
"@v4/ecal",
"@v4/eltv",
"@v4/ghost-video",
"@v4/ghost-basketball",
"@v4/media",
"@v4/video-app",
"@v4/wsc",
"@v4/ghost-core",
"@v4/ghost-content",
"@v4/tokens",
"@v4/tokens-euro-league",
"@v4/template-config",
],
Because the app reads a component package's dist/, a change to one needs that
package rebuilt (pnpm --filter @v4/ghost-core build), and a running next dev
then needs its .next cache cleared and a restart before it shows the change.
How the app consumes packages
apps/web never writes component markup or raw Contentful queries of its own.
It:
- imports components from the design-system packages (
@v4/ghost-core,@v4/ghost-content, and the domain packages); - mounts routes from
@v4/content-app,@v4/video-appand@v4/basketball-appthrough generated shims thatpnpm composewrites intoapps/web/src/app/[locale]/**; - reads content only through
@v4/modelfragments, and competition data only through@v4/competition-engine; and - emits ELB's theme in parts — the global layer once on
:root, then each package's component themes from the layouts of the routes that render them (see Theme emission).
Because every dependency is a workspace link and every override flows through the typed token contracts, ELB's re-skin or a per-instance override reaches all the way down the graph without any package having to republish.