diff --git a/AGENTS.md b/AGENTS.md index bfd4d53..7c8cbb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/ @@ -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. @@ -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) → +fetchExperience(experienceOptions, clientOptions, resolveOptions) → ``` -`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). @@ -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. @@ -119,9 +156,9 @@ React's RSC compilation requires a `'use client'` directive at the top of files Each `defineComponent(...)` 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? @@ -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 diff --git a/README.md b/README.md index d34ac79..70e7ae9 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ A renderer SDK for Contentful's **Experience Orchestration (ExO)**. You bring a design system; the SDK takes the Experience payload from the Experience Delivery API (XDA) and renders it. ```sh -npm install @contentful/experiences-react @contentful/experience-delivery +npm install @contentful/experiences-react ``` -That's the only SDK package customers install — it re-exports everything you need (resolver, types, renderer, design utilities). The `@contentful/experiences-core` and `@contentful/experiences-design` packages are workspace-internal implementation details. +That's the only SDK package customers install — it re-exports everything you need (resolver, types, renderer, design utilities, and the experience delivery client). The `@contentful/experiences-core`, `@contentful/experiences-design`, and `@contentful/experiences-client` packages are workspace-internal implementation details. A Svelte adapter (`@contentful/experiences-svelte`) ships in parallel with the same public-API shape; see [`packages/adapter-svelte`](./packages/adapter-svelte). The rest of this README focuses on React. @@ -16,7 +16,7 @@ A Svelte adapter (`@contentful/experiences-svelte`) ships in parallel with the s ## Getting started — the simple path -Three steps: register your components, fetch + resolve, render. The minimal page is **three functional lines**. +Three steps: register your components, fetch + resolve, render. The minimal page is one `fetchExperience` call feeding one ``. ### 1. Register your components and (optional) templates @@ -62,22 +62,33 @@ export const experienceConfig: Config = { components, templates }; ```tsx // app/[slug]/page.tsx (Next.js App Router) -import { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; -import { resolveExperience, ServerExperienceRenderer } from '@contentful/experiences-react'; +import { notFound } from 'next/navigation'; +import { + fetchExperience, + NotFoundError, + ServerExperienceRenderer, +} from '@contentful/experiences-react'; import { experienceConfig } from '@/lib/experience-config'; -const client = new ContentfulViewDeliveryClient({ token: process.env.CDA_TOKEN! }); - export default async function ExperiencePage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; - const payload = await client.view.getExperience(process.env.SPACE_ID!, 'master', slug); - const experience = await resolveExperience(payload, experienceConfig); - - return ; + try { + const experience = await fetchExperience( + { spaceId: process.env.SPACE_ID!, environmentId: 'master', experienceId: slug }, + { accessToken: process.env.CDA_TOKEN! }, + { config: experienceConfig } + ); + return ; + } catch (err) { + if (err instanceof NotFoundError) notFound(); + throw err; + } } ``` -That's it. The renderer walks the payload, resolves design properties to plain scalars, runs any `resolveData` hooks (in parallel), and dispatches each node to your registered React component. +`fetchExperience` fetches the payload from the Experience Delivery API and resolves it in one call — the renderer walks the payload, resolves design properties to plain scalars, runs any `resolveData` hooks (in parallel), and dispatches each node to your registered React component. + +The signature is three grouped params: **what to fetch** (space/env/experience), **how to fetch** (auth), **how to resolve** (component config + per-render context). Each grouping evolves independently — future personalization params, digital-property identifiers, and transport options fit their respective group without cross-cutting the signature. A working version is at [`examples/nextjs/app/[slug]/page.tsx`](./examples/nextjs/app/[slug]/page.tsx). @@ -92,10 +103,15 @@ A full working advanced route is at [`examples/nextjs/app/advanced/[slug]/page.t ```tsx // app/advanced/[slug]/page.tsx import { headers } from 'next/headers'; -import { ServerExperienceRenderer, resolveExperience } from '@contentful/experiences-react'; +import { notFound } from 'next/navigation'; +import { + fetchExperience, + NotFoundError, + ServerExperienceRenderer, +} from '@contentful/experiences-react'; import { detectViewportFromUserAgent } from '@/lib/detect-viewport'; -import { experienceConfig } from '@/lib/experience-config'; +import { advancedExperienceConfig } from '@/lib/experience-config-advanced'; export default async function AdvancedPage({ params, @@ -104,7 +120,7 @@ export default async function AdvancedPage({ params: Promise<{ slug: string }>; searchParams: Promise>; }) { - const { slug } = await params; + const { slug: experienceId } = await params; const sp = await searchParams; const previewMode = sp.preview === 'true'; const locale = (sp.locale as string) ?? 'en-US'; @@ -113,24 +129,32 @@ export default async function AdvancedPage({ const userAgent = (await headers()).get('user-agent') ?? ''; const initialViewportId = detectViewportFromUserAgent(userAgent); - const payload = await client.view.getExperience(process.env.SPACE_ID!, 'master', slug, { - locale, - preview: previewMode ? 'true' : undefined, - }); - - // 2. Per-page metadata flows into every resolveData hook. - const experience = await resolveExperience(payload, experienceConfig, { - experience: { isPreview: previewMode, metadata: { slug, locale } }, - }); - - return ( - - ); + try { + // 2. Per-page metadata flows into every resolveData hook via context. + const experience = await fetchExperience( + { spaceId: process.env.SPACE_ID!, environmentId: 'master', experienceId, locale }, + { + accessToken: process.env.CDA_TOKEN!, + host: previewMode ? 'https://preview.xdn.contentful.com' : 'https://xdn.contentful.com', + }, + { + config: advancedExperienceConfig, + context: { isPreview: previewMode, metadata: { slug: experienceId, locale } }, + } + ); + + return ( + + ); + } catch (err) { + if (err instanceof NotFoundError) notFound(); + throw err; + } } ``` @@ -159,6 +183,64 @@ button: defineComponent({ ## API reference +### `fetchExperience(experienceOptions, clientOptions, resolveOptions)` + +Async. Fetches an Experience from the Experience Delivery API and resolves it in one call — equivalent to fetching the payload yourself and then calling `resolveExperience`. Returns a `PortableRenderPlan`. + +Three positional args map to three concerns that evolve independently: + +| Arg | Type | Purpose | +| ------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `experienceOptions` | `{ spaceId, environmentId, experienceId, locale? }` | Which Experience to fetch. Future digital-property identifiers widen this type. | +| `clientOptions` | `{ accessToken, host? }` **or** `{ client }` | How to fetch. Discriminated union — pass creds inline or bring your own `ContentfulViewDeliveryClient`. | +| `resolveOptions` | `{ config, context? }` | How to resolve. `context` flows into every `resolveData` hook as `ctx.experience`. | + +`host` is a full base-URL string (e.g. `'https://preview.xdn.contentful.com'` for the preview API). When omitted, the delivery client's default endpoint is used. + +```ts +// Inline credentials — client created internally +const plan = await fetchExperience( + { spaceId: '...', environmentId: 'master', experienceId: slug, locale: 'en-US' }, + { accessToken: process.env.CDA_TOKEN!, host: 'https://preview.xdn.contentful.com' }, + { config: experienceConfig, context: { isPreview: true, metadata: { slug } } } +); + +// Pre-created client — useful when you manage the client lifecycle yourself +import { createClient } from '@contentful/experiences-react'; +const client = createClient({ accessToken: process.env.CDA_TOKEN! }); +const plan = await fetchExperience( + { spaceId, environmentId, experienceId }, + { client }, + { config: experienceConfig } +); +``` + +**Error handling.** The underlying delivery client throws `NotFoundError` (re-exported from the adapter) when the Experience ID doesn't exist, plus `UnauthorizedError`, `ForbiddenError`, etc. on other 4xx/5xx responses. An Experience with no published nodes is **not** a 404 — it resolves to a valid `PortableRenderPlan` with `nodes: []` (draft / unpublished / empty-locale content). Route the missing-experience case to your framework's 404 idiom: + +```ts +try { + const experience = await fetchExperience(/* … */); + return ; +} catch (err) { + if (err instanceof NotFoundError) notFound(); + throw err; +} +``` + +### `createClient(options)` + +Functional constructor over `ContentfulViewDeliveryClient` for the SDK's option shape — maps `accessToken → token` and `host → baseUrl`, passes everything else through. Prefer this over `new ContentfulViewDeliveryClient({...})` so field names stay consistent with `fetchExperience`'s inline-creds path. + +```ts +import { createClient } from '@contentful/experiences-react'; + +const client = createClient({ + accessToken: process.env.CDA_TOKEN!, + host: 'https://preview.xdn.contentful.com', // optional — overrides the default endpoint + // headers, timeoutInSeconds, maxRetries, fetch, logging, etc. all pass through +}); +``` + ### `resolveExperience(payload, config, opts?)` Async. Walks the payload, classifies properties, runs every component's `resolveData` in parallel, and returns a `PortableRenderPlan` ready to hand to a renderer. @@ -175,7 +257,7 @@ SSR-friendly renderer. No reactive subscriptions; the active viewport is resolve | Prop | Type | Required | Default | Description | | ------------------- | --------------------------------------------- | -------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------ | -| `experience` | `PortableRenderPlan \| null \| undefined` | yes | — | The resolved plan from `resolveExperience`. Renders nothing if null/undefined. | +| `experience` | `PortableRenderPlan` | yes | — | The resolved plan from `fetchExperience` or `resolveExperience`. An empty-nodes plan renders nothing. | | `config` | `Config` | yes | — | Same registry passed to `resolveExperience`. Looked up at render time for dispatch. | | `initialViewportId` | `string` | no | First viewport id | Seeds the active viewport. Typically derived from User-Agent server-side. | | `context` | `Partial` | no | `{ isPreview: false, metadata: {} }` | Shallow-merged into the render-time `experience` context customer components receive. | @@ -293,6 +375,7 @@ This is an Nx monorepo. Customers install only the framework adapter; the rest i | ------------------------------------------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------- | | [`packages/core`](./packages/core) | `@contentful/experiences-core` | **Internal.** Runtime-neutral types + `resolveExperience`. | | [`packages/design`](./packages/design) | `@contentful/experiences-design` | **Internal.** Viewport math (`getValueForViewport`, `resolveDesignProperties`, `toCssMediaQuery`). | +| [`packages/client`](./packages/client) | `@contentful/experiences-client` | **Internal.** Experience delivery client + `fetchExperience`. | | [`packages/adapter-react`](./packages/adapter-react) | `@contentful/experiences-react` | **Customer-facing.** React renderer + re-exports of everything else. | | [`packages/adapter-svelte`](./packages/adapter-svelte) | `@contentful/experiences-svelte` | **Customer-facing.** Svelte 5 renderer with the same public API shape. | diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md index 5790948..1b1f24d 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -4,10 +4,9 @@ A Next.js 15 App Router app demonstrating `@contentful/experiences-react` render ## What it shows -- **Server-side fetch** via `@contentful/experience-delivery`'s `ContentfulViewDeliveryClient`. The response is structurally compatible with our `ExperiencePayload` — pass it straight to `resolveExperience`, no normalization. -- **Runtime-neutral plan resolution** with `resolveExperience` (re-exported from `@contentful/experiences-react`) — one async step walks the tree, classifies props, and runs any component-declared `resolveData` hooks in parallel. +- **Server-side fetch + resolve** via `fetchExperience` (re-exported from `@contentful/experiences-react`) — one async call fetches the payload from the Experience Delivery API, walks the tree, classifies props, and runs any component-declared `resolveData` hooks in parallel. - **SSR rendering** with `ServerExperienceRenderer` from `@contentful/experiences-react`. -- **Three-line page** — `fetch` → `resolveExperience` → ``. Preview mode, viewport seeding, and metadata are all optional advanced features (see [`opts`](#optional-resolveexperience-opts) below); the minimal app needs none of them. +- **Minimal page** — `fetchExperience` → ``, wrapped in a try/catch that routes `NotFoundError` to Next's `notFound()`. Preview mode, viewport seeding, and metadata are all optional advanced features; the minimal app needs none of them. - **`defineComponent` authoring** — each component file declares its props and renderer in one place; no casts, no boilerplate map wrappers. ## Run it @@ -28,19 +27,29 @@ Then visit `http://localhost:3000/` — the slug becomes the Expe The example ships two side-by-side routes so you can see what each SDK option buys you. They render the same Experience id; only the SDK plumbing changes. -| Route | Page | Config | Demonstrates | -| ------------------ | ---------------------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `/[slug]` | [`app/[slug]/page.tsx`](./app/[slug]/page.tsx) | `experience-config.tsx` | The minimum: `fetch` → `resolveExperience(payload, config)` → ``. 3 lines. | -| `/advanced/[slug]` | [`app/advanced/[slug]/page.tsx`](./app/advanced/[slug]/page.tsx) | `experience-config-advanced.tsx` | Preview mode via `?preview=true`, UA → `initialViewportId`, async `resolveData` with external fetch. | +| Route | Page | Config | Demonstrates | +| ------------------ | ---------------------------------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `/[slug]` | [`app/[slug]/page.tsx`](./app/[slug]/page.tsx) | `experience-config.tsx` | The minimum: `fetchExperience` → `` with `NotFoundError` routed to Next's `notFound()`. | +| `/advanced/[slug]` | [`app/advanced/[slug]/page.tsx`](./app/advanced/[slug]/page.tsx) | `experience-config-advanced.tsx` | Preview mode via `?preview=true`, UA → `initialViewportId`, async `resolveData` with external fetch. | -The minimal `[slug]/page.tsx` is three functional lines: +The minimal `[slug]/page.tsx`: ```tsx -const { payload } = await fetchExperience(experienceId); -const experience = await resolveExperience(payload, experienceConfig); -return ; +try { + const experience = await fetchExperience( + { spaceId, environmentId, experienceId }, + { accessToken }, + { config: experienceConfig } + ); + return ; +} catch (err) { + if (err instanceof NotFoundError) notFound(); + throw err; +} ``` +`NotFoundError` is thrown by the delivery client on 404 (Experience ID doesn't exist). An empty-nodes payload (draft / unpublished / empty locale) is **not** a 404 — it resolves to a plan with `nodes: []` and renders as an empty page. + Slug-to-ID mapping is left to the customer — see the SDK roadmap in [`AGENTS.md`](../../AGENTS.md) for the longer-term direction. ## File map @@ -50,7 +59,7 @@ examples/nextjs/ ├── app/ │ ├── layout.tsx # root layout │ ├── page.tsx # index — links to both demo routes -│ ├── [slug]/page.tsx # SIMPLE — 3-line page +│ ├── [slug]/page.tsx # SIMPLE — fetch + render + 404 handling │ └── advanced/[slug]/page.tsx # ADVANCED — preview, UA seeding, async resolveData ├── components/ # plain design-system components — no SDK imports │ ├── Button.tsx @@ -58,7 +67,6 @@ examples/nextjs/ │ ├── Page.tsx # used as the page-level template │ └── Text.tsx └── lib/ - ├── delivery-client.ts # ContentfulViewDeliveryClient factory ├── detect-viewport.ts # User-Agent → viewport id (used by the advanced route) ├── experience-config.tsx # the integration layer for /[slug] └── experience-config-advanced.tsx # the integration layer for /advanced/[slug] — async fetch + metadata-aware @@ -155,31 +163,43 @@ priceTag: defineComponent({ }), ``` -The Next.js page calls `resolveExperience` once before rendering: +The route calls `fetchExperience` once — it handles the API call and resolution in one step: ```ts -const experience = await resolveExperience(payload, experienceConfig); +const experience = await fetchExperience( + { spaceId: process.env.SPACE_ID!, environmentId: 'master', experienceId: slug }, + { accessToken: process.env.CDA_TOKEN! }, + { config: experienceConfig } +); ``` Resolvers run in parallel across nodes. Viewport resolution stays at render time, so client-side viewport changes never re-trigger `resolveData`. -#### Optional `resolveExperience` opts +#### Optional `context` -The third argument lets you pass per-render context that flows into every -component's `resolveData` (and into the rendered components as `experience.*`). +The `context` option (on `resolveOptions`, the third arg) passes per-render context into every component's `resolveData` hook (and into the rendered components as `experience.*`). Default is `{ isPreview: false, metadata: {} }` — fine for production. Add fields when: -- **Preview mode**: `{ experience: { isPreview: true } }` — `MissingComponent` - renders a visible red box; some customer components might branch on this. -- **Per-page metadata**: `{ experience: { metadata: { slug, locale } } }` — - available to every `resolveData` for URL building, locale-aware lookups, etc. +- **Preview mode**: `{ isPreview: true }` — `MissingComponent` renders a visible + red box; some customer components might branch on this. Set `host` to the + preview endpoint on `clientOptions` to also hit the preview API. +- **Per-page metadata**: `{ metadata: { slug, locale } }` — available to every + `resolveData` for URL building, locale-aware lookups, etc. ```ts -const experience = await resolveExperience(payload, experienceConfig, { - experience: { isPreview, metadata: { slug, locale } }, -}); +const experience = await fetchExperience( + { spaceId: process.env.SPACE_ID!, environmentId: 'master', experienceId: slug, locale }, + { + accessToken: process.env.CDA_TOKEN!, + host: previewMode ? 'https://preview.xdn.contentful.com' : 'https://xdn.contentful.com', + }, + { + config: experienceConfig, + context: { isPreview: previewMode, metadata: { slug, locale } }, + } +); ``` Pair with `` (User-Agent diff --git a/examples/nextjs/app/[slug]/page.tsx b/examples/nextjs/app/[slug]/page.tsx index 198fc66..1cfa4fd 100644 --- a/examples/nextjs/app/[slug]/page.tsx +++ b/examples/nextjs/app/[slug]/page.tsx @@ -1,7 +1,10 @@ import { notFound } from 'next/navigation'; -import { ServerExperienceRenderer, resolveExperience } from '@contentful/experiences-react'; +import { + NotFoundError, + ServerExperienceRenderer, + fetchExperience, +} from '@contentful/experiences-react'; -import { fetchExperience } from '@/lib/delivery-client'; import { experienceConfig } from '@/lib/experience-config'; interface PageProps { @@ -11,10 +14,20 @@ interface PageProps { export default async function ExperiencePage({ params }: PageProps) { const { slug: experienceId } = await params; - const { payload } = await fetchExperience(experienceId); - if (!payload.nodes.length) notFound(); + try { + const experience = await fetchExperience( + { + spaceId: process.env.SPACE_ID ?? '', + environmentId: process.env.ENVIRONMENT_ID ?? 'master', + experienceId, + }, + { accessToken: process.env.CDA_TOKEN! }, + { config: experienceConfig } + ); - const experience = await resolveExperience(payload, experienceConfig); - - return ; + return ; + } catch (err) { + if (err instanceof NotFoundError) notFound(); + throw err; + } } diff --git a/examples/nextjs/app/advanced/[slug]/page.tsx b/examples/nextjs/app/advanced/[slug]/page.tsx index aed22d8..617ff67 100644 --- a/examples/nextjs/app/advanced/[slug]/page.tsx +++ b/examples/nextjs/app/advanced/[slug]/page.tsx @@ -1,8 +1,11 @@ import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; -import { ServerExperienceRenderer, resolveExperience } from '@contentful/experiences-react'; +import { + NotFoundError, + ServerExperienceRenderer, + fetchExperience, +} from '@contentful/experiences-react'; -import { fetchExperience } from '@/lib/delivery-client'; import { detectViewportFromUserAgent } from '@/lib/detect-viewport'; import { advancedExperienceConfig } from '@/lib/experience-config-advanced'; @@ -15,8 +18,8 @@ interface PageProps { * Advanced version of the [slug] route. Demonstrates three SDK features the * minimal three-line page in `app/[slug]/page.tsx` doesn't reach for: * - * 1. **Preview mode + per-page metadata** via the `opts` arg of - * `resolveExperience`. `?preview=true` flips `MissingComponent` from + * 1. **Preview mode + per-page metadata** via the `context` arg of + * `fetchExperience`. `?preview=true` flips `MissingComponent` from * "silent null" to "visible red box"; metadata flows into every * `resolveData` hook. * 2. **User-Agent → viewport seeding** via `initialViewportId` so SSR @@ -38,22 +41,37 @@ export default async function AdvancedExperiencePage({ params, searchParams }: P const userAgent = (await headers()).get('user-agent') ?? ''; const initialViewportId = detectViewportFromUserAgent(userAgent); - const { payload } = await fetchExperience(experienceId, { preview: previewMode, locale }); - if (!payload.nodes.length) notFound(); + try { + const experience = await fetchExperience( + { + spaceId: process.env.SPACE_ID ?? '', + environmentId: process.env.ENVIRONMENT_ID ?? 'master', + experienceId, + locale, + }, + { + accessToken: process.env.CDA_TOKEN!, + host: previewMode ? 'https://preview.xdn.contentful.com' : 'https://xdn.contentful.com', + }, + { + config: advancedExperienceConfig, + context: { + isPreview: previewMode, + metadata: { slug: experienceId, locale }, + }, + } + ); - const experience = await resolveExperience(payload, advancedExperienceConfig, { - experience: { - isPreview: previewMode, - metadata: { slug: experienceId, locale }, - }, - }); - - return ( - - ); + return ( + + ); + } catch (err) { + if (err instanceof NotFoundError) notFound(); + throw err; + } } diff --git a/examples/nextjs/lib/delivery-client.ts b/examples/nextjs/lib/delivery-client.ts deleted file mode 100644 index a97de8c..0000000 --- a/examples/nextjs/lib/delivery-client.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Server-side fetch of an Experience via @contentful/experience-delivery. - * Requires SPACE_ID and CDA_TOKEN in the environment. - * - * The delivery client's `GetExperienceViewResponse` is structurally - * compatible with our `ExperiencePayload`, so we hand it straight to - * `buildPlan` without normalization. - */ - -import 'server-only'; - -import { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; -import type { ExperiencePayload } from '@contentful/experiences-react'; - -const experienceClient = new ContentfulViewDeliveryClient({ token: process.env.CDA_TOKEN! }); - -export interface FetchExperienceResult { - payload: ExperiencePayload; -} - -/** - * The delivery client returns a `GetExperienceViewResponse` whose shape is - * structurally compatible with our `ExperiencePayload`. No normalization - * step needed — pass it straight to `buildPlan`. - */ -export async function fetchExperience( - experienceId: string, - options: { locale?: string; preview?: boolean } = {} -): Promise { - const spaceId = process.env.SPACE_ID || ''; - const environmentId = process.env.ENVIRONMENT_ID ?? 'master'; - - const response = await experienceClient.view.getExperience(spaceId, environmentId, experienceId, { - locale: options.locale, - preview: options.preview ? 'true' : undefined, - }); - - return { payload: response }; -} diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index b2db7e6..a52d2aa 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@contentful/experiences-react": "*", - "@contentful/experience-delivery": "1.0.0-dev.3", "next": "^15.0.0", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/examples/sveltekit/README.md b/examples/sveltekit/README.md index 20676ea..67810df 100644 --- a/examples/sveltekit/README.md +++ b/examples/sveltekit/README.md @@ -4,8 +4,7 @@ A SvelteKit 2 + Svelte 5 app demonstrating `@contentful/experiences-svelte` rend ## What it shows -- **Server-side fetch** via `@contentful/experience-delivery`'s `ContentfulViewDeliveryClient` (same client as the React example). -- **Runtime-neutral plan resolution** with `resolveExperience` re-exported from `@contentful/experiences-svelte` — proves the resolver is genuinely framework-agnostic. +- **Server-side fetch + resolve** via `fetchExperience` re-exported from `@contentful/experiences-svelte` — proves the fetch + resolver pipeline is genuinely framework-agnostic. - **SSR rendering** with `ServerExperienceRenderer` from `@contentful/experiences-svelte`. - **Hydration-safe viewport seeding** — User-Agent parsed on the server in `+page.server.ts`, passed as `initialViewportId`. - **`defineComponent` authoring** — same pattern as the React example, with `component: SvelteComponent` instead of `render: () => ReactNode`. @@ -41,7 +40,6 @@ examples/sveltekit/ │ │ ├── Header.svelte │ │ ├── Page.svelte # used as the page-level template │ │ └── Text.svelte -│ ├── delivery-client.ts │ ├── detect-viewport.ts │ └── experience-config.ts # integration layer (components + templates → experienceConfig) ├── svelte.config.js @@ -55,6 +53,6 @@ Identical to the Next.js example: 1. **Design-system components** stay portable — no `@contentful/*` imports. 2. **`experience-config.ts`** is the wiring layer that maps Contentful component-type IDs to your design-system components. -3. **Routes** call `fetchExperience` → `resolveExperience` → ``. +3. **Routes** call `fetchExperience(experienceOptions, clientOptions, resolveOptions)` → ``, wrapped in a try/catch that routes `NotFoundError` to SvelteKit's `error(404, ...)`. The only Svelte-specific divergence: a customer component opts into slots via the `slot: Snippet<[string]>` dispatcher prop, calling `{@render slot('children')}` to render a named slot. Compare to the React adapter where each slot becomes its own named prop. See [`packages/adapter-svelte/README.md`](../../packages/adapter-svelte/README.md) for the full Svelte API surface. diff --git a/examples/sveltekit/package.json b/examples/sveltekit/package.json index e9b269d..f72b048 100644 --- a/examples/sveltekit/package.json +++ b/examples/sveltekit/package.json @@ -11,8 +11,7 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { - "@contentful/experiences-svelte": "*", - "@contentful/experience-delivery": "1.0.0-dev.3" + "@contentful/experiences-svelte": "*" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.3.0", diff --git a/examples/sveltekit/src/lib/delivery-client.ts b/examples/sveltekit/src/lib/delivery-client.ts deleted file mode 100644 index 4c483cb..0000000 --- a/examples/sveltekit/src/lib/delivery-client.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Server-side fetch of an Experience via @contentful/experience-delivery. - * Requires SPACE_ID and CDA_TOKEN in the environment (.env file). - * - * The delivery client's `GetExperienceViewResponse` is structurally - * compatible with our `ExperiencePayload`, so we hand it straight to - * `resolveExperience` without normalization. - * - * Env access goes through `$env/static/private` (SvelteKit's typed - * server-only env import) instead of `process.env`. SvelteKit doesn't - * auto-populate `process.env` from `.env` like Next.js does — it exposes - * server-only vars via this module so they can never leak to the client - * bundle. The values are inlined at build time. - */ - -import { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; -import type { ExperiencePayload } from '@contentful/experiences-svelte'; - -import { CDA_TOKEN, ENVIRONMENT_ID, SPACE_ID } from '$env/static/private'; - -const experienceClient = new ContentfulViewDeliveryClient({ token: CDA_TOKEN }); - -export interface FetchExperienceResult { - payload: ExperiencePayload; -} - -export async function fetchExperience( - experienceId: string, - options: { locale?: string; preview?: boolean } = {} -): Promise { - const response = await experienceClient.view.getExperience( - SPACE_ID, - ENVIRONMENT_ID || 'master', - experienceId, - { - locale: options.locale, - preview: options.preview ? 'true' : undefined, - } - ); - - return { payload: response }; -} diff --git a/examples/sveltekit/src/routes/[slug]/+page.server.ts b/examples/sveltekit/src/routes/[slug]/+page.server.ts index 66518d8..f5412f6 100644 --- a/examples/sveltekit/src/routes/[slug]/+page.server.ts +++ b/examples/sveltekit/src/routes/[slug]/+page.server.ts @@ -1,7 +1,7 @@ import { error } from '@sveltejs/kit'; -import { resolveExperience } from '@contentful/experiences-svelte'; -import { fetchExperience } from '$lib/delivery-client.js'; +import { NotFoundError, fetchExperience } from '@contentful/experiences-svelte'; +import { CDA_TOKEN, ENVIRONMENT_ID, SPACE_ID } from '$env/static/private'; import { detectViewportFromUserAgent } from '$lib/detect-viewport.js'; import { experienceConfig } from '$lib/experience-config.js'; @@ -12,12 +12,26 @@ export const load: PageServerLoad = async ({ params, url, request }) => { url.searchParams.get('preview') === 'true' || url.searchParams.get('preview') === '1'; const initialViewportId = detectViewportFromUserAgent(request.headers.get('user-agent') ?? ''); - const { payload } = await fetchExperience(params.slug, { preview: previewMode }); - if (!payload.nodes.length) error(404, 'Experience not found'); + try { + const experience = await fetchExperience( + { + spaceId: SPACE_ID, + environmentId: ENVIRONMENT_ID || 'master', + experienceId: params.slug, + }, + { + accessToken: CDA_TOKEN, + host: previewMode ? 'https://preview.xdn.contentful.com' : 'https://xdn.contentful.com', + }, + { + config: experienceConfig, + context: { isPreview: previewMode, metadata: { slug: params.slug } }, + } + ); - const experience = await resolveExperience(payload, experienceConfig, { - experience: { isPreview: previewMode, metadata: { slug: params.slug } }, - }); - - return { experience, previewMode, slug: params.slug, initialViewportId }; + return { experience, previewMode, slug: params.slug, initialViewportId }; + } catch (err) { + if (err instanceof NotFoundError) error(404, 'Experience not found'); + throw err; + } }; diff --git a/package-lock.json b/package-lock.json index 229a002..403a25c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,6 @@ "name": "@contentful/experiences-example-nextjs", "version": "0.0.0", "dependencies": { - "@contentful/experience-delivery": "1.0.0-dev.3", "@contentful/experiences-react": "*", "next": "^15.0.0", "react": "^19.0.0", @@ -64,7 +63,6 @@ "name": "@contentful/experiences-example-sveltekit", "version": "0.0.0", "dependencies": { - "@contentful/experience-delivery": "1.0.0-dev.3", "@contentful/experiences-svelte": "*" }, "devDependencies": { @@ -150,6 +148,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2137,6 +2136,10 @@ "node": ">=18.0.0" } }, + "node_modules/@contentful/experiences-client": { + "resolved": "packages/client", + "link": true + }, "node_modules/@contentful/experiences-core": { "resolved": "packages/core", "link": true @@ -2249,6 +2252,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2272,6 +2276,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -4539,6 +4544,7 @@ "integrity": "sha512-JXHbsDwRes1Wgyof3q5ApzzpbCWvinKXMQCiV67TFO6xlZPYLoK0fq3xQMqSicDMgCtFGqLkrQXkseOcASdZ8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", @@ -4644,6 +4650,7 @@ "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", "debug": "^4.3.7", @@ -4880,6 +4887,7 @@ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4904,6 +4912,7 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4960,6 +4969,7 @@ "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", @@ -5067,6 +5077,7 @@ "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -5329,6 +5340,7 @@ "integrity": "sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -5342,6 +5354,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5763,6 +5776,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -6273,6 +6287,7 @@ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -6778,6 +6793,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6842,6 +6858,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9462,6 +9479,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@emnapi/core": "1.4.5", "@emnapi/runtime": "1.4.5", @@ -10103,6 +10121,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -10265,6 +10284,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10274,6 +10294,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11343,6 +11364,7 @@ "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -11856,6 +11878,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11987,6 +12010,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -12527,6 +12551,7 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -13060,6 +13085,7 @@ "version": "0.4.0", "license": "MIT", "dependencies": { + "@contentful/experiences-client": "*", "@contentful/experiences-core": "*", "@contentful/experiences-design": "*" }, @@ -13078,6 +13104,7 @@ "version": "0.2.0", "license": "MIT", "dependencies": { + "@contentful/experiences-client": "*", "@contentful/experiences-core": "*", "@contentful/experiences-design": "*" }, @@ -13095,6 +13122,15 @@ "svelte": "^5.0.0" } }, + "packages/client": { + "name": "@contentful/experiences-client", + "version": "0.0.0-determined-by-semantic-release", + "license": "MIT", + "dependencies": { + "@contentful/experience-delivery": "1.0.0-dev.3", + "@contentful/experiences-core": "*" + } + }, "packages/core": { "name": "@contentful/experiences-core", "version": "0.4.0", diff --git a/packages/adapter-react/README.md b/packages/adapter-react/README.md index bf64022..d77fed4 100644 --- a/packages/adapter-react/README.md +++ b/packages/adapter-react/README.md @@ -5,10 +5,10 @@ The React adapter for the Contentful Experiences SDK suite. Renders Experience payloads from the Experience Delivery API (XDA) using customer-supplied React components. ```sh -npm install @contentful/experiences-react @contentful/experience-delivery +npm install @contentful/experiences-react ``` -This is the **only SDK package customers install** — it re-exports everything from `@contentful/experiences-core` and `@contentful/experiences-design` that consumers need. The other packages are workspace-internal. +This is the **only SDK package customers install** — it re-exports everything from `@contentful/experiences-core`, `@contentful/experiences-design`, and `@contentful/experiences-client` that consumers need. The other packages are workspace-internal. --- @@ -21,6 +21,17 @@ defineComponent(config); // Type-narrowing identity for component-type co defineTemplate(config); // Same shape, for page-level template wrappers ``` +### Fetching + +```ts +fetchExperience(experienceOptions, clientOptions, resolveOptions) // Async — fetches from XDA + resolves in one call +createClient(options) // Functional constructor matching the SDK's option shape +ContentfulViewDeliveryClient // Re-exported delivery client for advanced use cases +NotFoundError // Thrown when the Experience ID doesn't exist +ContentfulViewDelivery // Full error namespace from the delivery client +type ExperienceOptions, ClientOptions, ResolveOptions, CreateClientOptions +``` + ### Resolver ```ts @@ -59,10 +70,12 @@ getValueForViewport, getViewportIndex, resolveDesignProperties, toCssMediaQuery ## Quick reference ```tsx +import { notFound } from 'next/navigation'; import { defineComponent, defineTemplate, - resolveExperience, + fetchExperience, + NotFoundError, ServerExperienceRenderer, type Config, type Components, @@ -82,8 +95,17 @@ const components: Components = { const experienceConfig: Config = { components }; // In a server component: -const experience = await resolveExperience(payload, experienceConfig); -return ; +try { + const experience = await fetchExperience( + { spaceId: process.env.SPACE_ID!, environmentId: 'master', experienceId: slug }, + { accessToken: process.env.CDA_TOKEN! }, + { config: experienceConfig } + ); + return ; +} catch (err) { + if (err instanceof NotFoundError) notFound(); + throw err; +} ``` For the full getting-started walkthrough, the merge-precedence rules, viewport handling, and design rationale, see the [root README](../../README.md) and [`AGENTS.md`](../../AGENTS.md). diff --git a/packages/adapter-react/package.json b/packages/adapter-react/package.json index 2ee4488..60ce4b0 100644 --- a/packages/adapter-react/package.json +++ b/packages/adapter-react/package.json @@ -30,6 +30,7 @@ "directory": "packages/adapter-react" }, "dependencies": { + "@contentful/experiences-client": "*", "@contentful/experiences-core": "*", "@contentful/experiences-design": "*" }, diff --git a/packages/adapter-react/src/index.ts b/packages/adapter-react/src/index.ts index bb304e6..42478dc 100644 --- a/packages/adapter-react/src/index.ts +++ b/packages/adapter-react/src/index.ts @@ -87,3 +87,18 @@ export { resolveDesignProperties, toCssMediaQuery, } from '@contentful/experiences-design'; + +// ─── Delivery client + fetchExperience ──────────────────────────────────── +export { + ContentfulViewDelivery, + ContentfulViewDeliveryClient, + NotFoundError, + createClient, + fetchExperience, +} from '@contentful/experiences-client'; +export type { + ClientOptions, + CreateClientOptions, + ExperienceOptions, + ResolveOptions, +} from '@contentful/experiences-client'; diff --git a/packages/adapter-svelte/package.json b/packages/adapter-svelte/package.json index 9527fd1..cdfe371 100644 --- a/packages/adapter-svelte/package.json +++ b/packages/adapter-svelte/package.json @@ -35,6 +35,7 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { + "@contentful/experiences-client": "*", "@contentful/experiences-core": "*", "@contentful/experiences-design": "*" }, diff --git a/packages/adapter-svelte/src/index.ts b/packages/adapter-svelte/src/index.ts index 893c596..e8d166a 100644 --- a/packages/adapter-svelte/src/index.ts +++ b/packages/adapter-svelte/src/index.ts @@ -91,3 +91,18 @@ export { resolveDesignProperties, toCssMediaQuery, } from '@contentful/experiences-design'; + +// ─── Delivery client + fetchExperience ──────────────────────────────────── +export { + ContentfulViewDelivery, + ContentfulViewDeliveryClient, + NotFoundError, + createClient, + fetchExperience, +} from '@contentful/experiences-client'; +export type { + ClientOptions, + CreateClientOptions, + ExperienceOptions, + ResolveOptions, +} from '@contentful/experiences-client'; diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000..eea2e50 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,113 @@ +# @contentful/experiences-client + +> **Internal package.** Not published separately. Consumed via the framework adapter (`@contentful/experiences-react`, `@contentful/experiences-svelte`, etc.). + +Isolates `@contentful/experience-delivery` — the generated experience delivery client — so that `@contentful/experiences-core` stays zero-dep and framework adapters that don't need network access don't pull it in transitively. + +--- + +## What's in here + +### `fetchExperience(experienceOptions, clientOptions, resolveOptions)` + +The primary fetch + resolve entry point. Fetches an Experience payload from the Experience Delivery API and resolves it into a `PortableRenderPlan` in one call. + +Three positional args group by concern: + +| Arg | Type | Purpose | +| ------------------- | --------------------------------------------------- | ------------------------------------------------- | +| `experienceOptions` | `{ spaceId, environmentId, experienceId, locale? }` | Which Experience to fetch. | +| `clientOptions` | `{ accessToken, host? }` **or** `{ client }` | How to fetch — inline creds or a pre-made client. | +| `resolveOptions` | `{ config, context? }` | How to resolve — component registry + context. | + +```ts +import { fetchExperience } from '@contentful/experiences-react'; // or experiences-svelte + +// Inline credentials — client created internally +const plan = await fetchExperience( + { spaceId: '...', environmentId: 'master', experienceId: slug, locale: 'en-US' }, + { + accessToken: process.env.CDA_TOKEN!, + host: 'https://preview.xdn.contentful.com', // optional — omit for default endpoint + }, + { + config: experienceConfig, + context: { isPreview: false, metadata: { slug } }, // flows into resolveData hooks + } +); + +// Pre-created client — useful when you manage the client lifecycle yourself +import { createClient } from '@contentful/experiences-react'; +const client = createClient({ accessToken: process.env.CDA_TOKEN! }); +const plan = await fetchExperience( + { spaceId, environmentId, experienceId }, + { client }, + { config: experienceConfig } +); +``` + +Returns `PortableRenderPlan`. An empty-nodes payload (draft / unpublished / empty locale) resolves to a valid plan with `nodes: []` — it is not a 404. For the missing-experience case, catch `NotFoundError` (re-exported below). + +### `createClient(options)` + +Functional constructor over `ContentfulViewDeliveryClient` matching the SDK's option shape. Maps `accessToken → token` and `host → baseUrl`; passes everything else through unchanged. Prefer over `new ContentfulViewDeliveryClient({...})` so field names stay consistent with `fetchExperience`'s inline-creds path. + +```ts +import { createClient } from '@contentful/experiences-react'; + +const client = createClient({ + accessToken: process.env.CDA_TOKEN!, + host: 'https://preview.xdn.contentful.com', // optional + // headers, timeoutInSeconds, maxRetries, fetch, logging pass through +}); +``` + +### `NotFoundError` + +Re-exported from `@contentful/experience-delivery` as a value + type. Thrown by the underlying delivery client on 404 responses. Route it to your framework's 404 idiom: + +```ts +import { fetchExperience, NotFoundError } from '@contentful/experiences-react'; + +try { + const experience = await fetchExperience(/* … */); + // … +} catch (err) { + if (err instanceof NotFoundError) notFound(); // Next.js + throw err; +} +``` + +The full delivery-client error namespace is also re-exported as `ContentfulViewDelivery` (`UnauthorizedError`, `ForbiddenError`, `ConflictError`, `UnprocessableEntityError`, `InternalServerError`, `ContentfulViewDeliveryError`, `ContentfulViewDeliveryTimeoutError`). + +### `ContentfulViewDeliveryClient` + +Re-exported directly from `@contentful/experience-delivery`. Exposed for advanced use cases where you want full control over the client (custom base URL, request options, reuse across calls). Most consumers should prefer `createClient` (above). + +```ts +import { ContentfulViewDeliveryClient } from '@contentful/experiences-react'; + +const client = new ContentfulViewDeliveryClient({ + token: process.env.CDA_TOKEN!, + baseUrl: 'https://xdn.contentful.com', // default delivery endpoint +}); +``` + +--- + +## Why a separate package? + +`@contentful/experiences-core` is intentionally zero-dep and runtime-neutral — it must stay importable without pulling in any network or platform code. The experience delivery client is large (~3,000 generated files) and only needed when doing server-side fetching. Isolating it here means: + +- Core stays lean and usable in any environment (edge, SSR, test fixtures). +- Future adapters that render from local fixtures or a custom fetch path don't pay the delivery client's weight. +- The delivery client version can be bumped in one place. + +--- + +## Package conventions + +- Do not import `@contentful/experience-delivery` from anywhere except this package. +- Re-export only what framework adapters need to surface to customers. +- Keep `fetchExperience` thin — fetch + cast + resolve. Business logic belongs in `packages/core`. +- Name mappings between SDK options and delivery-client options live in `create-client.ts` — one place to change. diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..aa68e16 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,36 @@ +{ + "name": "@contentful/experiences-client", + "version": "0.0.0-determined-by-semantic-release", + "description": "Delivery client + fetchExperience for Contentful Experiences", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/contentful/experiences.git", + "directory": "packages/client" + }, + "dependencies": { + "@contentful/experience-delivery": "1.0.0-dev.3", + "@contentful/experiences-core": "*" + } +} diff --git a/packages/client/project.json b/packages/client/project.json new file mode 100644 index 0000000..4786a8c --- /dev/null +++ b/packages/client/project.json @@ -0,0 +1,28 @@ +{ + "name": "client", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/client/src", + "projectType": "library", + "tags": ["scope:runtime-neutral", "layer:client"], + "targets": { + "build": { + "cache": true, + "dependsOn": ["^build"], + "outputs": ["{projectRoot}/dist"], + "executor": "nx:run-commands", + "options": { + "cwd": "packages/client", + "command": "tsup" + } + }, + "test": { + "cache": true, + "dependsOn": ["^build"], + "executor": "nx:run-commands", + "options": { + "cwd": "packages/client", + "command": "vitest run" + } + } + } +} diff --git a/packages/client/src/create-client.test.ts b/packages/client/src/create-client.test.ts new file mode 100644 index 0000000..c647061 --- /dev/null +++ b/packages/client/src/create-client.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; +import { createClient } from './create-client.js'; + +vi.mock('@contentful/experience-delivery', () => ({ + ContentfulViewDeliveryClient: vi.fn().mockImplementation((options) => ({ _options: options })), +})); + +describe('createClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('maps accessToken → token and passes host through as baseUrl', () => { + createClient({ accessToken: 'token-123', host: 'https://preview.xdn.contentful.com' }); + + expect(ContentfulViewDeliveryClient).toHaveBeenCalledWith({ + token: 'token-123', + baseUrl: 'https://preview.xdn.contentful.com', + }); + }); + + it('omits baseUrl when host is not provided', () => { + createClient({ accessToken: 'token-123' }); + + expect(ContentfulViewDeliveryClient).toHaveBeenCalledWith({ + token: 'token-123', + baseUrl: undefined, + }); + }); + + it('passes through additional client options', () => { + createClient({ + accessToken: 'token-123', + host: 'https://xdn.contentful.com', + headers: { 'x-custom': 'value' }, + timeoutInSeconds: 30, + maxRetries: 5, + }); + + expect(ContentfulViewDeliveryClient).toHaveBeenCalledWith({ + token: 'token-123', + baseUrl: 'https://xdn.contentful.com', + headers: { 'x-custom': 'value' }, + timeoutInSeconds: 30, + maxRetries: 5, + }); + }); + + it('returns a ContentfulViewDeliveryClient instance', () => { + const client = createClient({ accessToken: 'token-123' }); + + expect(client).toBeDefined(); + expect(ContentfulViewDeliveryClient).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/client/src/create-client.ts b/packages/client/src/create-client.ts new file mode 100644 index 0000000..b9a699a --- /dev/null +++ b/packages/client/src/create-client.ts @@ -0,0 +1,15 @@ +import { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; + +export type CreateClientOptions = { + accessToken: string; + host?: string; +} & Omit; + +export function createClient(options: CreateClientOptions): ContentfulViewDeliveryClient { + const { accessToken, host, ...rest } = options; + return new ContentfulViewDeliveryClient({ + ...rest, + token: accessToken, + baseUrl: host, + }); +} diff --git a/packages/client/src/fetch-experience.test.ts b/packages/client/src/fetch-experience.test.ts new file mode 100644 index 0000000..583a97e --- /dev/null +++ b/packages/client/src/fetch-experience.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; +import { fetchExperience } from './fetch-experience.js'; + +const { mockGetExperience, mockPayload, mockPlan } = vi.hoisted(() => { + const mockPayload = { + sys: { id: 'exp-1' }, + viewports: [{ id: 'default', query: '*' }], + nodes: [{ sys: { urn: 'urn:ctfl:component:hero' }, content: {}, design: {}, slots: {} }], + errors: [], + }; + + const mockPlan = { + viewports: mockPayload.viewports, + nodes: [], + }; + + const mockGetExperience = vi.fn().mockResolvedValue(mockPayload); + + return { mockGetExperience, mockPayload, mockPlan }; +}); + +vi.mock('@contentful/experiences-core', () => ({ + resolveExperience: vi.fn().mockResolvedValue(mockPlan), +})); + +vi.mock('@contentful/experience-delivery', () => ({ + ContentfulViewDeliveryClient: vi.fn().mockImplementation(() => ({ + view: { + getExperience: mockGetExperience, + }, + })), +})); + +const experienceOptions = { + spaceId: 'space-1', + environmentId: 'master', + experienceId: 'exp-1', +}; + +const resolveOptions = { + config: { components: {} }, +}; + +describe('fetchExperience', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetExperience.mockResolvedValue(mockPayload); + }); + + describe('inline credentials', () => { + it('constructs client without baseUrl when host is not provided', async () => { + await fetchExperience(experienceOptions, { accessToken: 'token-123' }, resolveOptions); + + expect(ContentfulViewDeliveryClient).toHaveBeenCalledWith({ + token: 'token-123', + baseUrl: undefined, + }); + }); + + it('constructs client with provided host', async () => { + await fetchExperience( + experienceOptions, + { accessToken: 'token-123', host: 'https://preview.xdn.contentful.com' }, + resolveOptions + ); + + expect(ContentfulViewDeliveryClient).toHaveBeenCalledWith({ + token: 'token-123', + baseUrl: 'https://preview.xdn.contentful.com', + }); + }); + + it('calls getExperience with spaceId, environmentId, experienceId, locale', async () => { + await fetchExperience( + { ...experienceOptions, locale: 'en-US' }, + { accessToken: 'token-123' }, + resolveOptions + ); + + expect(mockGetExperience).toHaveBeenCalledWith('space-1', 'master', 'exp-1', { + locale: 'en-US', + }); + }); + }); + + describe('pre-created client', () => { + it('uses provided client directly without constructing a new one', async () => { + const client = new ContentfulViewDeliveryClient({ token: 'token-123' }); + vi.mocked(ContentfulViewDeliveryClient).mockClear(); + + await fetchExperience(experienceOptions, { client }, resolveOptions); + + expect(ContentfulViewDeliveryClient).not.toHaveBeenCalled(); + expect(mockGetExperience).toHaveBeenCalledWith('space-1', 'master', 'exp-1', { + locale: undefined, + }); + }); + }); + + describe('return value', () => { + it('passes empty-nodes payloads through to the resolver', async () => { + const emptyPayload = { ...mockPayload, nodes: [] }; + mockGetExperience.mockResolvedValue(emptyPayload); + const { resolveExperience } = await import('@contentful/experiences-core'); + + const result = await fetchExperience( + experienceOptions, + { accessToken: 'token-123' }, + resolveOptions + ); + + expect(resolveExperience).toHaveBeenCalledWith( + emptyPayload, + resolveOptions.config, + expect.anything() + ); + expect(result).toEqual(mockPlan); + }); + + it('returns resolved PortableRenderPlan on success', async () => { + const result = await fetchExperience( + experienceOptions, + { accessToken: 'token-123' }, + resolveOptions + ); + + expect(result).toEqual(mockPlan); + }); + + it('propagates NotFoundError from the delivery client to the caller', async () => { + class MockNotFoundError extends Error {} + mockGetExperience.mockRejectedValue(new MockNotFoundError('experience not found')); + + await expect( + fetchExperience(experienceOptions, { accessToken: 'token-123' }, resolveOptions) + ).rejects.toThrow('experience not found'); + }); + }); +}); diff --git a/packages/client/src/fetch-experience.ts b/packages/client/src/fetch-experience.ts new file mode 100644 index 0000000..ce53d98 --- /dev/null +++ b/packages/client/src/fetch-experience.ts @@ -0,0 +1,46 @@ +import type { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; +import { resolveExperience } from '@contentful/experiences-core'; +import type { + ExperiencePayload, + PortableRenderPlan, + ResolveExperienceOptions, + ResolverConfig, +} from '@contentful/experiences-core'; +import { createClient } from './create-client.js'; + +export type ExperienceOptions = { + spaceId: string; + environmentId: string; + experienceId: string; + locale?: string; +}; + +export type ClientOptions = + | { accessToken: string; host?: string } + | { client: ContentfulViewDeliveryClient }; + +export type ResolveOptions = { + config: ResolverConfig; + context?: ResolveExperienceOptions['experience']; +}; + +export async function fetchExperience( + experienceOptions: ExperienceOptions, + clientOptions: ClientOptions, + resolveOptions: ResolveOptions +): Promise { + const { spaceId, environmentId, experienceId, locale } = experienceOptions; + const { config, context } = resolveOptions; + + const client = + 'client' in clientOptions + ? clientOptions.client + : createClient({ accessToken: clientOptions.accessToken, host: clientOptions.host }); + + // Response from the experience delivery client is structurally compatible with ExperiencePayload (superset) + const payload = (await client.view.getExperience(spaceId, environmentId, experienceId, { + locale, + })) as unknown as ExperiencePayload; + + return resolveExperience(payload, config, { experience: context }); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..c37aeeb --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,12 @@ +import { ContentfulViewDelivery } from '@contentful/experience-delivery'; + +export { + ContentfulViewDelivery, + ContentfulViewDeliveryClient, +} from '@contentful/experience-delivery'; +export const NotFoundError = ContentfulViewDelivery.NotFoundError; +export type NotFoundError = InstanceType; +export { createClient } from './create-client.js'; +export type { CreateClientOptions } from './create-client.js'; +export { fetchExperience } from './fetch-experience.js'; +export type { ExperienceOptions, ClientOptions, ResolveOptions } from './fetch-experience.js'; diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..24fcdff --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "vitest.config.ts", "tsup.config.ts"] +} diff --git a/packages/client/tsconfig.lib.json b/packages/client/tsconfig.lib.json new file mode 100644 index 0000000..6f37ea2 --- /dev/null +++ b/packages/client/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "vitest.config.ts", "tsup.config.ts"] +} diff --git a/packages/client/tsup.config.ts b/packages/client/tsup.config.ts new file mode 100644 index 0000000..e59a519 --- /dev/null +++ b/packages/client/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/**/*.ts', '!src/**/*.test.ts'], + format: ['esm'], + dts: true, + clean: true, + sourcemap: true, + target: 'es2022', + outDir: 'dist', + tsconfig: 'tsconfig.lib.json', + bundle: false, + external: [/^@contentful\//], +}); diff --git a/packages/client/vitest.config.ts b/packages/client/vitest.config.ts new file mode 100644 index 0000000..5cf3b04 --- /dev/null +++ b/packages/client/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.{test,spec}.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**/*.ts'], + exclude: ['**/*.test.ts', '**/*.spec.ts', '**/*.d.ts'], + }, + }, +});