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.

2026-11-07

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/tokens is generated from tokens.json by @v4/token-parser and depends on nothing at runtime. @v4/tokens-euro-league sits beside it and is read only by the app's theme files.
  • The component packages depend on @v4/tokens and 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-app builds on @v4/content-app's shared page helpers, and @v4/basketball-app on both, plus @v4/competition-engine, @v4/media, @v4/image, @v4/wsc and @v4/ecal.
  • @v4/image is the PURE image-provider core (no SDK, React or Node): @v4/model, @v4/content-app, @v4/competition-engine and the provider packages (@v4/image-contentful, @v4/image-scoreplay, …) depend on it, so it is a leaf every layer can consume. apps/web depends on @v4/image plus the providers it enables and configures the resolver from club.config.images in apps/web/src/lib/images.ts, imported for its side effect by the app/[locale]/layout.tsx root layout.
  • apps/web sits 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-core and its siblings build with tsc, then scripts/precompile-styles.mjs compiles every component stylesheet to plain CSS and scripts/check-theme-owners.mjs checks the theme-owner lists. Only each package's init.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.ts or 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-engine and 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:

  1. imports components from the design-system packages (@v4/ghost-core, @v4/ghost-content, and the domain packages);
  2. mounts routes from @v4/content-app, @v4/video-app and @v4/basketball-app through generated shims that pnpm compose writes into apps/web/src/app/[locale]/**;
  3. reads content only through @v4/model fragments, and competition data only through @v4/competition-engine; and
  4. 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.