Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 49 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ experiences/
├── packages/
│ ├── core/ # @contentful/experiences-core (internal)
│ ├── design/ # @contentful/experiences-design (internal)
│ ├── client/ # @contentful/experiences-client (internal)
│ ├── adapter-react/ # @contentful/experiences-react (customer-facing)
│ └── adapter-svelte/ # @contentful/experiences-svelte (customer-facing)
└── examples/
Expand All @@ -41,12 +42,13 @@ experiences/

### Package roles

| Folder | npm name | Audience |
| ------------------------- | -------------------------------- | ------------------------------------------------------------------ |
| `packages/core` | `@contentful/experiences-core` | **Internal.** Runtime-neutral types + `resolveExperience`. |
| `packages/design` | `@contentful/experiences-design` | **Internal.** Pure viewport math. |
| `packages/adapter-react` | `@contentful/experiences-react` | **Customer-facing.** React renderer + re-exports of everything. |
| `packages/adapter-svelte` | `@contentful/experiences-svelte` | **Customer-facing.** Svelte 5 renderer + re-exports of everything. |
| Folder | npm name | Audience |
| ------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------- |
| `packages/core` | `@contentful/experiences-core` | **Internal.** Runtime-neutral types + `resolveExperience`. |
| `packages/design` | `@contentful/experiences-design` | **Internal.** Pure viewport math. |
| `packages/client` | `@contentful/experiences-client` | **Internal.** Experience delivery client + `fetchExperience`. Keeps the delivery dep isolated. |
| `packages/adapter-react` | `@contentful/experiences-react` | **Customer-facing.** React renderer + re-exports of everything. |
| `packages/adapter-svelte` | `@contentful/experiences-svelte` | **Customer-facing.** Svelte 5 renderer + re-exports of everything. |

**Customers install only the framework adapter for their stack.** The internal packages are workspace dependencies of the adapter — they get installed transitively, but customers never reach into them.

Expand All @@ -59,10 +61,14 @@ Future framework adapters slot in under the same naming pattern: `packages/adapt
The customer pipeline is three steps:

```
fetch payload (delivery client) → resolveExperience(payload, config) → <ExperienceRenderer experience={…} />
fetchExperience(experienceOptions, clientOptions, resolveOptions) → <ExperienceRenderer experience={…} />
```

`resolveExperience` is the **single async entry**:
`fetchExperience` is the **single async entry** for most customers — it fetches the payload from the Experience Delivery API and calls `resolveExperience` internally. The three positional args group by concern: **which experience**, **how to fetch**, **how to resolve**. Each grouping evolves independently (personalization slots into arg 3; digital-property identifiers widen arg 1) — a shape chosen to avoid one flat options object growing unbounded.

Customers who want to manage the delivery client themselves have two paths: `createClient({ accessToken, host? })` (functional constructor, our option shape), or `new ContentfulViewDeliveryClient({ token, baseUrl? })` (underlying delivery client, its option shape). Either way, pass the resulting client as `{ client }` in `clientOptions`.

`resolveExperience` (called internally by `fetchExperience`) is the **resolve step**:

1. Walks the XDA payload's `nodes[]` recursively.
2. Extracts `componentTypeId` from each node's `componentType.sys.urn` (last slash-segment).
Expand All @@ -86,6 +92,37 @@ The React adapter then:

## Design decisions worth knowing

### Why three positional args on `fetchExperience` instead of one options object?

Earlier the SDK had a single flat options object with an `accessToken | client` discriminated union mixed in. As `fetchExperience` picked up more responsibilities (fetch + resolve, preview mode, per-render context) the object was already ~7 fields and had two more inbound (personalization params, digital-property identifiers from the channels RFC). Rather than let one bag balloon to a dozen fields with three different concerns, the signature is split into three grouped args:

1. `experienceOptions` — **which** experience (`spaceId`, `environmentId`, `experienceId`, `locale`). Digital-property identifiers widen this type when the channels RFC lands.
2. `clientOptions` — **how** to fetch. Discriminated union: `{ accessToken, host? }` OR `{ client }`. Kept intentionally as one arg (not split further) so users can move between inline creds and a pre-made client with only that arg changing.
3. `resolveOptions` — **how** to resolve (`config`, `context`). Personalization params (`audienceIds`, `userTraits`, etc.) go here.

Each grouping evolves without touching the others.

### Why `host: string` instead of `preview: boolean`?

Two reasons. (1) The SDK shouldn't own the URL constants for XDN vs XPA — those are Contentful platform concerns that can add non-prod endpoints (staging, EU-region, per-account) which a boolean can't express. (2) A raw base URL passes cleanly to `ContentfulViewDeliveryClient.Options.baseUrl` — no translation layer. Callers write `host: previewMode ? 'https://preview.xdn.contentful.com' : 'https://xdn.contentful.com'` at the call site; the SDK just passes through.

### Why `createClient` in addition to the raw `ContentfulViewDeliveryClient` constructor?

`createClient` is a value-added passthrough that maps the SDK's option names (`accessToken`, `host`) onto the underlying client's names (`token`, `baseUrl`). Everything else flows through unchanged. It exists so the "inline creds" and "bring your own client" paths of `fetchExperience` share exactly the same field names — users can move between them mechanically instead of relearning vocabulary.

`fetchExperience`'s own inline-creds branch routes through `createClient` — single source of truth for the name mapping. If we ever add auth flavors beyond bearer-token, this is where they'd land.

### Why an empty-nodes payload is NOT a "not found"

`fetchExperience` used to return `null` when the payload had `nodes: []`. That conflated two states the CMS considers distinct:

- **Experience doesn't exist** (404 from the delivery API) — the delivery client throws `NotFoundError`. Caller should route to their framework's 404 idiom.
- **Experience exists, empty content** (200 with `nodes: []`) — draft, unpublished, empty locale fallback, editor-in-progress. Legitimate CMS state; renders as an empty page.

The empty-nodes payload now flows straight through to `resolveExperience`, which handles it gracefully (no walker iterations, template still resolves if present, returns `{ viewports, nodes: [] }`). `fetchExperience`'s return type narrowed from `PortableRenderPlan | null` to `PortableRenderPlan`.

For the missing-experience case, `NotFoundError` is re-exported from the adapter (via `packages/client`) so example call sites can wrap `fetchExperience` in try/catch without adding `@contentful/experience-delivery` as a direct dep — preserving the invariant that customers install only the framework adapter.

### Why a single `resolveExperience` entry instead of `buildPlan` + `resolveExperience`?

Earlier the SDK had two functions. The customer page needed three lines of imports + four function calls + two passes of `componentMap`. We collapsed to one. The sync vs async distinction (tree-walking is synchronous; `resolveData` hooks are async) is implementation detail customers don't care about.
Expand Down Expand Up @@ -119,9 +156,9 @@ React's RSC compilation requires a `'use client'` directive at the top of files

Each `defineComponent<Props>(...)` is parameterized over the design-system component's prop type. No separate `ContentfulButtonProps` interface to keep in sync. The design system owns the contract; the integration layer adapts to it. When a Contentful payload field doesn't map 1:1, the customer either (a) renames at the render-fn call site, or (b) uses `resolveData` to reshape, or (c) extends the type at the map level (`type ButtonMapProps = ButtonProps & { testSlot?: ReactNode }`).

### Why is the delivery client (`@contentful/experience-delivery`) a customer dep, not bundled?
### Why is the delivery client (`@contentful/experience-delivery`) isolated in `packages/client`, not a direct dep of the adapters?

Three reasons. (1) Customers can pin it independently. (2) The renderer SDK can render any compatible payload — mocks, local fixtures, custom fetch paths — not just the official client. (3) The delivery client is large (~3,000 generated files); bundling it would dwarf our package's bundle budget.
Two reasons. (1) `packages/core` must stay zero-dep and runtime-neutral — pulling the delivery client into core would break that invariant for all current and future adapters. (2) The delivery client is large (~3,000 generated files); centralizing it in one internal package (`packages/client`) means adapters that don't need it (e.g. a future server-only adapter) won't pull it in transitively. Customers who want to manage the client themselves can import `ContentfulViewDeliveryClient` directly from the adapter and call `resolveExperience` with their own payload.

### Why two separate registries (`components` and `templates`) instead of one?

Expand Down Expand Up @@ -152,8 +189,9 @@ Conventional commits (`feat:`, `fix:`, `chore:`, `docs:`, etc.). Enforced via `c

### Package boundaries

- **`core` may not depend on `react` or any framework-specific package.** Enforced by code review (no module-boundary lint rule yet, but it should land).
- **`core` may not depend on `react`, the delivery client, or any framework-specific package.** Enforced by code review (no module-boundary lint rule yet, but it should land).
- **`design` may not depend on `core` for runtime; it imports types only.** This keeps `design` a pure-utility package usable in isolation.
- **`client` is the only package that may depend on `@contentful/experience-delivery`.** All delivery-client usage must go through `packages/client` — never import it directly from an adapter or from `core`.
- **The customer-facing adapter (`adapter-react`) is the ONLY package that re-exports everything**. Internal packages don't re-export from each other.

### File naming
Expand Down
Loading