Code generation

Every generator this repo runs: its inputs, what it writes, the command that runs it and the skill that drives it.

2026-11-07

Overview

Large parts of this repo are generated, not hand-written: the token contract (Ghost's and the Euro League club's), the icon set, the Contentful GraphQL types, the Competition Engine API types, the localised dictionary maps, the model baseline, the Contentful app lock, the ticket vocabulary and the app's route shims. This page is the single catalogue of every generator — what it reads, what it writes, the exact command, and the Claude skill that drives it where one exists.

Generated files are an OUTPUT, never a source

The rule holds everywhere in the repo: a generated file is the compiled projection of an upstream source, and is never hand-edited. Editing one is always a mistake — the next regeneration silently overwrites it, and in the meantime the file disagrees with the source it claims to derive from.

Each generator therefore has a single source of truth UPSTREAM of its output:

Layer Source of truth Generated output (never edit)
Tokens Token Studio tokens.json packages/tokens/src/__generated__/tokens.ts
Club tokens Euro League Token Studio tokens.json (pushed straight to the repo) packages/tokens-euro-league/src/__generated__/
Icons Figma icon pages and node lists (Ghost Core, ELB Core, ELB Content) packages/tokens/src/icons/*.tsx
Model schema The Contentful space (introspected) packages/model/schema.graphql
Model types schema.graphql + curated *.graphql fragments packages/model/src/__generated__/
Dictionary src/dictionary/keys.ts + Contentful labels packages/model/src/__generated__/dictionary/
Model baseline packages/model/baseline/ export (generated once, when this repo was cut) migrations/core/0001-baseline-core.cjs, migrations/<domain>/0000-baseline-*.cjs
Contentful apps packages/model/apps/registry.json + the orgs' app definitions packages/model/apps/apps.lock.json
Engine API types packages/competition-engine/spec/openapi.yaml (vendored at a pinned sha) packages/competition-engine/src/__generated__/api.d.ts
App page types app *.graphql queries + model fragments apps/web · content-app · video-app · basketball-app __generated__/
Route shims club.config.ts + routes.json + composition.json apps/web/src/app/[locale]/**/{page,loading,layout}.tsx (the generated ones)
Ticket vocabulary domains.json + .claude/manifest.json packages/tickets/src/__generated__/vocab.ts (+ cms-types.json, figma-frames.json)

Every one of these carries a // Generated … do not edit header (or is written under an __generated__/ directory whose name says the same thing).

Drift gates enforce it

Regeneration is deterministic — same input in, same bytes out — so CI can prove a generated file is current simply by regenerating it and asserting the tree is clean. Every layer has such a gate:

  • Tokens — v4-tokens check parses and validates tokens.json with a non-zero exit on errors; build reports a contract diff against the previous build's sidecar.
  • Model — pnpm --filter @v4/model drift re-syncs the schema, regenerates the model types and the web / content-app / video-app page types, then git diff --exit-codes the lot. The dictionary maps are deliberately NOT regenerated or gated (labels are editorial copy Contentful owns), and @v4/basketball-app is not in this gate — it has its own drift.
  • Apps — each app's own drift script regenerates and git diff --exit-codes src/__generated__.
  • Competition Engine — pnpm --filter @v4/competition-engine verify regenerates api.d.ts and diffs it; check-spec confirms the vendored spec matches its pinned sha.
  • Contentful apps — apps.lock.json is generated by sync-apps and drift-gated; sync-apps --check is the PR gate.
  • Tickets — pnpm --filter @v4/tickets drift regenerates vocab.ts and diffs it, then runs the ticket lints.
  • Composition — pnpm compose -- --check regenerates the shims + composition manifest in memory and diffs against disk.

If a drift gate is red, the fix is to run the generator and commit its output — never to reconcile the file by hand.

Every generator at a glance

Generator Reads Writes Command Skill
Token contract packages/tokens/tokens.json (Token Studio export) packages/tokens/src/__generated__/tokens.ts + .css + per-component contracts pnpm --filter @v4/tokens generate (sync-tokens to refresh) /generate-token-contracts, /extract-figma-tokens, /sync-figma-styles
Club token layer packages/tokens-euro-league/tokens.json packages/tokens-euro-league/src/__generated__/ (incl. competition-themes.ts) pnpm --filter @v4/tokens-euro-league generate /generate-token-contracts
Icons Ghost Core, ELB Core and ELB Content Figma files packages/tokens/src/icons/*.tsx + index.ts (129 icons) pnpm --filter @v4/tokens sync-icons —
Component scaffold a kebab component name packages/ghost-core/src/components/<name>/ skeleton + registrations pnpm --filter @v4/ghost-core new-component <kebab> /generate-from-figma
Precompiled styles each ghost package's built .css.ts precompiled component stylesheets in dist/ (ghost-core, -content, -video) the package build (precompile-styles.mjs) —
Model schema (vendor) the Contentful space/env (GraphQL introspection) packages/model/schema.graphql pnpm --filter @v4/model sync-schema /model-migration, /model-review
Model types schema.graphql + packages/model/src/**/*.graphql packages/model/src/__generated__/ (gql.ts, graphql.ts, index.ts) pnpm --filter @v4/model generate /model-fragment
Dictionary maps src/dictionary/keys.ts + Contentful dictionaryKey entries packages/model/src/__generated__/dictionary/<locale>.ts pnpm --filter @v4/model sync-dictionary /model-dictionary
Contentful app lock packages/model/apps/registry.json + the orgs' definitions packages/model/apps/apps.lock.json pnpm --filter @v4/model sync-apps -- --env=<env> [--apply] —
Engine API types packages/competition-engine/spec/openapi.yaml packages/competition-engine/src/__generated__/api.d.ts pnpm --filter @v4/competition-engine generate (sync-spec first) —
App page types app src/**/*.graphql + model fragments app src/__generated__/ (in apps/web, content-app, video-app, basketball-app) pnpm --filter @v4/web generate (and per app) —
Route shims + composition apps/web/club.config.ts + */routes.json + composition.json app src/app/[locale]/** (generated) + packages/model/composition.json pnpm compose /new-route, /add-feature
Ticket vocabulary domains.json + .claude/manifest.json packages/tickets/src/__generated__/vocab.ts pnpm --filter @v4/tickets gen-vocab —
Ticket CMS model / frames @v4/model migrations (or the live space) / the Figma files packages/tickets/src/__generated__/cms-types.json / figma-frames.json pnpm --filter @v4/tickets build-cms-model / sync-figma-frames /ticket-setup

The model baselines (0001-baseline-core.cjs and the bolt-ons' 0000-baseline-*.cjs) were generated once, when this repo was cut from the template, and are never regenerated here — see GraphQL and the model.

The three sections that follow document the main generators in detail: Tokens and icons, GraphQL and the model, and Components and shims.

Tokens and icons

Both the token contract and the icon set are generated by @v4/token-parser — the v4-tokens CLI (packages/token-parser/src/cli.ts, published as the v4-tokens bin). It has four commands: build, check, icons and coverage, plus a drift command that compares Figma file versions against the bridges. Options: -i/--input, -o/--out, -n/--name, --emit ts,css (build), -k/--file-key, --page (default - Icons), --filter, --nodes (icons), --registry (drift), -c/--components and --write (coverage).

The token contract

v4-tokens build reads the Token Studio tokens.json export, resolves it (references, and Token Studio modify extensions — brand Tints/Shades are a reference plus a transform, not literal hex), and emits the typed contract.

Reads: packages/tokens/tokens.json — the Ghost 2.0 Token Studio export (the ghost build; the club package runs the same command, below).

Writes (under packages/tokens/src/__generated__/):

File Contents
tokens.ts The full typed token tree — packages/tokens/src/__generated__/tokens.ts is the token source of truth in code
tokens.css The CSS custom-property layer (--ph__*)
components/<domain>/*.ts Per-component contract modules, sliced by namespace, under components/{core,content,football,gamification,video}/ (every Ghost namespace is generated, bought or not)
tokens.flat.json A flat sidecar baseline used to print a contract diff on the next build

Every generated file opens with // Generated by @v4/token-parser — do not edit by hand. The generated values reach components through ONE ingestion point: packages/ghost-core/src/theme/default.theme.ts reads tokens and builds the global contract, and every component theme binds contract paths (g.components.core.*, g.semantic.*, g.colourSets.*). A component theme never imports raw tokens (or a ./components/* module) itself — that freezes the generated literal beyond every club override (.claude/rules/frontend/theme-variant-patterns.md).

# regenerate the contract from the checked-in tokens.json (+ prettier)
pnpm --filter @v4/tokens generate

# refresh tokens.json from the Ghost export repo, then regenerate
pnpm --filter @v4/tokens sync-tokens

# validate only — non-zero exit on errors (the CI drift gate)
pnpm --filter @v4/tokens check

sync-tokens pulls the latest tokens.json from the ghost-web-tokens-v2 repository via gh api and then runs generate. generate runs the parser's built dist/ — a src/ fix in @v4/token-parser changes nothing until pnpm --filter @v4/token-parser build.

The club token layer (@v4/tokens-euro-league)

The club package is generated the same way, into its own tree:

Reads: packages/tokens-euro-league/tokens.json — the Euro League Token Studio export, which carries the EuroLeague base theme and the EuroCup and NextGen competition themes.

Writes (under packages/tokens-euro-league/src/__generated__/): tokens.ts, tokens.css, tokens.flat.json, components/, and competition-themes.ts (from scripts/generate-competition-themes.mjs — each competition's difference from the EuroLeague layer). The adapter (src/adapter.ts) projects the club's names onto the Ghost contract; the brand faces and the club's motion re-tune (src/fonts.ts, src/motion.ts) are hand-written.

pnpm --filter @v4/tokens-euro-league generate   # after every Token Studio push

Do not run the club package's sync-tokens. It pulls token.json from stadionHQ/elb-web-tokens, which is no longer the source: Token Studio now pushes tokens.json straight to this repo, and a sync overwrites it with an older palette. Run generate after each push instead.

⚡ Do it with Claude — /extract-figma-tokens extracts a component's Token Studio bindings from Ghost 2.0 Figma into its tokens/ directory, and /sync-figma-styles cross-references a component's wiring against Figma and refreshes the generated contract. This section explains what the underlying v4-tokens build produces.

Icons

v4-tokens icons discovers icon components in Figma, exports each as SVG, and generates a React icon component per master plus a barrel.

Reads: three Figma files via the Figma API (FIGMA_PAT required in the environment): Ghost – Core 2.0 (67J5FrDZn5HzYzf5hJgoNA), ELB – Web – Core (HIjezWNeRQSBGkxx8VqmjA) and ELB – Web – Content (9iz5GeR9syO68cmPr1ppE1).

sync-icons makes six icons invocations, in order:

  1. Ghost Core, the - Icons page.
  2. ELB Core, the - Icons page.
  3. ELB Core --nodes — the six plain social icons (Facebook, YouTube, X, Instagram, TikTok, WhatsApp), taken from instances.
  4. ELB Content --nodes — Feature and Share-Plain.
  5. Ghost Core --nodes — Chevron-Down and Chevron-Up.
  6. ELB Content --nodes — the competition marks (EuroLeague, EuroCup, NextGen).

A --nodes list is Name@nodeId,…, for icons that are not masters on an icons page. Later invocations write over earlier ones with the same name.

Writes (under packages/tokens/src/icons/): one icon-<name>.tsx per icon (129 today), icon.types.ts (written once if absent), and index.ts — the barrel, rebuilt to cover every icon present on disk, exported as @v4/tokens/icons. Each file opens with // Generated by `v4-tokens icons` from Figma "<node name>" — do not edit by hand.

pnpm --filter @v4/tokens sync-icons

Duplicate masters that collide on the same file base are skipped with a warning (flag to design), and SVG exports are fetched in batches of 50 with a pause between batches to respect Figma rate limits.

Coverage and drift (verification, not generation)

Two more v4-tokens commands support the token work without emitting product code:

  • v4-tokens coverage -c <components-dir> [--write] recomputes each component's token-binding coverage as node × property pairs (INSTANCE subtrees excluded) and, with --write, updates the coverage block in every tokens/_manifest.json.
  • v4-tokens drift -k <file-key> --registry <figma.json> compares the Figma file's latest version against the version stamped in each component's figma.json bridge. pnpm --filter @v4/tokens drift runs it against the Ghost Core file and the ghost-core registry.

The Figma scripts beside the CLI

packages/token-parser/scripts/ holds the scripts the client-fork workflow runs (.claude/rules/frontend/fork-refactor.md). The ELB files are forks of Ghost that PRESERVE the upstream node ids, so a bridge naming the wrong file resolves perfectly and measures the wrong design — these are what catch it:

Script What it does
check-bridges.mjs OFFLINE gate (no token, runs in scripts/verify.mjs): every bridge names a file this repo is built from, and every cache agrees with it
design-status.mjs The Design / Development status each component carries in the client file (--ready, --unbuilt)
fork-diff.mjs Diffs a component's bindings between the upstream and the client file, ranked by measured difference
design-drift.mjs Re-extracts and reports whether the CLIENT design moved since each cached extraction
extract-figma-bindings.mjs Walks a component's Figma tree and writes the extraction shape the coverage CLI reads, recording the fileKey
extract-colour-ways.mjs Extracts a component's colour-way variants

The file map these read is .claude/manifest.json (figma.files / figma.clientFiles).

GraphQL and the model

The @v4/model package is the content model as code. Its generation spine runs: introspect the space → vendor schema.graphql → generate TypeScript from the schema + curated fragments → (separately) generate the dictionary maps. Apps generate their own page-query types against the same vendored schema. Every output is committed; all but the dictionary maps are drift-gated.

Schema vendoring (sync-schema)

packages/model/scripts/sync-schema.mjs introspects the Contentful GraphQL schema for the space/environment and writes it to disk. It authenticates with CONTENTFUL_CMA_TOKEN alone — the GraphQL API wants a DELIVERY token, so the script derives an environment-scoped delivery key via the CMA api_keys endpoint, keeping CI to a single secret.

Reads: the Contentful space named by CONTENTFUL_SPACE_ID — for this site 12gd2cvfgb6b ("Euro League") — environment CONTENTFUL_MODEL_ENV (default develop), via introspection. The model scripts read the space from the environment or the root .env and REFUSE to run without it; there is no default space.

Writes: packages/model/schema.graphql — a deterministic SDL: lexicographicSortSchema applied, all descriptions stripped and every directive definition dropped, so a freshly cloned environment's schema and the source env's diff cleanly. Type SHAPE is the contract; never re-add docstrings or directive definitions.

pnpm --filter @v4/model sync-schema

Model types (generate)

pnpm --filter @v4/model generate runs graphql-codegen --config codegen.ts.

Reads: schema.graphql (the vendored schema) and the curated documents packages/model/src/**/*.graphql — one {Surface}Fields fragment per component surface, per domain: src/{core,content,video,basketball}/fragments/*.graphql (core 6, content 12, video 2, basketball 8).

Writes: packages/model/src/__generated__/ via the graphql-codegen client preset (fragmentMasking: false, enumsAsTypes: true) — gql.ts, graphql.ts and index.ts. Import types from @v4/model, never from the codegen paths.

Fragments are curated (prune, never select-all); their types are generated. Codegen validates every field name against the vendored schema and refuses guesses — a fragment for a field no migration has shipped will not compile.

⚡ Do it with Claude — /model-fragment authors a curated fragment and its transform for a content-type surface: an all-fields draft from the vendored schema, a pruning review, then codegen validation.

Dictionary maps (sync-dictionary)

The dictionary is split in two: packages/model/src/dictionary/keys.ts (hand-curated, reviewed) owns the key vocabulary and its default en-GB copy; Contentful owns the localised labels editors maintain.

packages/model/scripts/sync-dictionary.mjs has three modes: pull (default), push, and promote.

  • pull — reads all dictionaryKey entries from the environment, writes packages/model/src/__generated__/dictionary/<locale>.ts (en-GB, en-US, fr-FR, it-IT) plus index.ts. Committed, but deliberately not drift-gated: a label is editorial copy Contentful owns, so gating it turned every editor's label edit into a red build on an unrelated merge. Reports orphans (in the space, not in keys.ts) and unseeded keys (in keys.ts, no entry yet).
  • push — creates + publishes entries for unseeded keys (key + default label only). Never updates or deletes existing entries; refuses protected environments outside CI.
  • promote — copies labels env-to-env (--from / --to).
pnpm --filter @v4/model sync-dictionary   # pull → regenerate the maps
pnpm --filter @v4/model push-dictionary   # seed unseeded keys

The site does not depend on these maps for live copy: it calls await getDictionary(locale) from @v4/model/dictionary, a server-only read of the live labels, cached and tagged content:dictionaryKey, so a published label reaches the site without a deploy. createDictionary(locale) — the committed snapshot — is the synchronous fallback, for tests and when the fetch fails.

⚡ Do it with Claude — /model-dictionary adds and manages dictionary keys: vocabulary in code, labels in Contentful, seeding and env-to-env promotion.

App page types

Page-level GraphQL queries live in the apps, not in @v4/model — each app composes model fragments and runs its own codegen against the SAME vendored schema (packages/model/schema.graphql). None of them introspect Contentful.

Package Reads (documents) Writes Command
apps/web src/**/*.graphql + model/src/core/fragments/* + content sitemap.graphql apps/web/src/__generated__/ pnpm --filter @v4/web generate
@v4/content-app src/**/*.graphql + model/src/content/fragments/* + core link.graphql packages/content-app/src/__generated__/ pnpm --filter @v4/content-app generate
@v4/video-app src/**/*.graphql + model/src/video/fragments/* packages/video-app/src/__generated__/ pnpm --filter @v4/video-app generate
@v4/basketball-app src/**/*.graphql + model/src/basketball/fragments/* packages/basketball-app/src/__generated__/ pnpm --filter @v4/basketball-app generate

Each app's fragments glob is deliberately narrowed to its own domain, so no other domain's fragment ASTs land in that package's runtime gql.ts.

The model baseline

The baselines are the model as it stood when this repo was cut from the template, expressed in the contentful-migration DSL (content types, fields, editor interfaces), deprecated types and all. They were generated once, by packages/model/scripts/generate-composed-baseline.mjs from the vendored export in packages/model/baseline/ plus domains.json and composition.json, and are never regenerated or hand-edited here:

  • core/0001-baseline-core.cjs — the core baseline.
  • content/0001-baseline-content.cjs — cut before bolt-on baselines were numbered 0000, so it shares its number with the next content migration; the runner always runs a baseline first.
  • video/0000-baseline-video.cjs — numbered to sort before the video domain's own incremental migrations, which start at 0001.

generate-baseline.mjs is the template's tool for ITS single core baseline (core/0001-baseline.cjs); this repo does not have that file and does not run it.

The basketball domain is this project's own and has no baseline: its content types are created by our migrations in migrations/club/, which is also where every other migration of ours goes — never a domain directory, whose numbers belong to the template and arrive through /sync-upstream. A migration moved after it was applied is declared in migrations/renamed.json, so the ledger does not run it twice.

Contentful apps (sync-apps)

The Contentful apps themselves are built and released from the template; this repo installs them. packages/model/scripts/sync-apps.mjs reads the app registry (packages/model/apps/registry.json) and the organisations' app definitions, and writes packages/model/apps/apps.lock.json — the slug → definition id map, per organisation and release channel, that migrations resolve when they bind an app by slug. The lock is generated and drift-gated; migrate.mjs runs the sync after the migrations. Dry by default; --apply mutates, --check is the PR gate. Rules: .claude/rules/model/contentful-apps.md.

Competition Engine types

@v4/competition-engine vendors the engine's contract from stadionHQ/stadion-euroleague at a pinned commit (spec/openapi.yaml, with spec/SOURCE naming the file and commit). sync-spec refreshes it from main through gh (falling back to a local clone, COMPETITION_ENGINE_REPO_DIR); check-spec reports whether main has moved without writing. It generates src/__generated__/api.d.ts with openapi-typescript. verify regenerates and diffs it.

pnpm --filter @v4/competition-engine generate
pnpm --filter @v4/competition-engine verify

The one drift gate over all of it

pnpm --filter @v4/model drift

re-syncs the schema, regenerates the model types, regenerates the web, content-app and video-app page types, and git diff --exit-codes schema.graphql, the model __generated__ (excluding dictionary/), and those three apps' __generated__. @v4/basketball-app is gated by its own pnpm --filter @v4/basketball-app drift. A red gate means run the generator and commit — never hand-edit the output.

Components and shims

Two generators sit either end of the product: the component scaffold creates a new design-system component's skeleton and its registrations, and the composition generator wires this site's bought domains into the app as route shims. Neither writes product logic — the scaffold hands off to the Figma pipeline, and the shims delegate to per-route factories in the *-app packages.

Component scaffold (new-component)

packages/ghost-core/scripts/new-component.mjs is the mechanical registration scaffolder. It creates the component directory skeleton and inserts the registrations idempotently — the generative work stays with the Figma pipeline.

Reads: a kebab component name (validated ^[a-z][a-z0-9-]*$).

Writes / mutates:

Target What it does
packages/ghost-core/src/components/<name>/figma-screenshots/ created
packages/ghost-core/src/components/<name>/tokens/ created
packages/ghost-core/src/index.ts appends export * from "./components/<name>"; (alphabetical)
packages/ghost-core/src/init.css.ts appends the side-effect theme import (alphabetical, above the bootstrap block)
packages/ghost-core/src/init.css.ts (OWN_THEMES) inserts the camelCase theme name into the owner list (sorted)
packages/ghost-core/figma.json adds the bridge entry <name> → src/components/<name>/<name>.figma.json

The OWN_THEMES append is load-bearing: owner-keyed theme emission means a new component's theme must join that list or the package build fails check-theme-owners. The scaffold does it so a fresh component never trips the gate. It writes NO .component.tsx / .css.ts / .theme.ts — those, and the tests, stories and token extraction, are the pipeline's job.

pnpm --filter @v4/ghost-core new-component <kebab-name>

new-component exists only in @v4/ghost-core. The sibling packages — @v4/ghost-content, @v4/ghost-video and @v4/ghost-basketball, the most active in this repo — are scaffolded by /generate-from-figma --package=<name>, and their build runs check-dist, which fails when the package's OWN_THEMES list and its theme modules disagree. Every ghost package's build also precompiles its component stylesheets (packages/ghost-core/scripts/precompile-styles.mjs); ghost-core additionally runs check-theme-owners.mjs.

⚡ Do it with Claude — /generate-from-figma runs the full Ghost 2.0 Figma → component pipeline: token extraction, the typed vanilla-extract component, tests, stories and the quality/visual gates. The scaffold above is the mechanical first step it builds on; the design-to-code agents under .claude/agents/ drive the rest.

Composition — route shims (compose)

packages/template-config/scripts/compose-club.ts (run via pnpm compose, which is pnpm --filter @v4/template-config compose-club --) materialises the site's composition. It runs under tsx because it imports the app's raw-TS club.config.ts.

Reads: the app's club.config.ts (bought bolt-on domains — here content, video and basketball), each composed domain's routes.json (packages/content-app, packages/video-app, packages/basketball-app), and the current packages/model/composition.json. Domain names are validated through the model's own resolveComposition resolver, so unknown / quarantined / implied names fail loudly here.

Writes:

  1. packages/model/composition.json — the bought bolt-on domains in canonical order (the UNION across every resident app, keyed per app).
  2. Route shims — for every route a composed domain's *-app package registers, one generated apps/web/src/app/[locale]/<segment>/page.tsx wiring the route factory to the site's client/config/media/locale dependencies — 34 page shims (content 16, video 6, basketball 12), plus the generated loading.tsx files of the basketball data areas and the news layout.tsx. Each carries the // GENERATED by compose-club — do not edit header. A header-marked shim whose route is no longer composed is pruned; hand-written pages (no header) are never touched.
pnpm compose            # write the composition + shims
pnpm compose -- --check # regenerate in memory and diff against disk (CI drift gate)

The generator is deterministic — same config in, same bytes out — which is what lets --check be a gate. It statically verifies each route's factory exists in the package's exports map before emitting a shim, so a broken registry fails the compose rather than the app build.

⚡ Do it with Claude — /new-route adds a route to a route package and wires it through routes.json, codegen and the shims; /add-feature adds a bought domain to the site.

Where the two meet

The generators this project runs around the composition are pnpm compose, pnpm add-feature (buy another domain, then compose) and pnpm sync-upstream (offer the template's changes as a pull request — when one changes a route package's routes.json, run pnpm compose on its branch, or the compose drift gate fails). pnpm new-club, which cut this repo from the template and ran the composed baseline and the first compose, is not run here again. See Take template updates.