From 54f91ba1b19d3a64a983032ca91e882ed520473b Mon Sep 17 00:00:00 2001 From: Lisa White Date: Fri, 10 Jul 2026 15:12:55 -0600 Subject: [PATCH 01/14] feat(client): scaffold packages/client with ContentfulViewDeliveryClient re-export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates the packages/client workspace package (@contentful/experiences-client). Replaces the ExperienceClient class approach from PR #27 — re-exports ContentfulViewDeliveryClient directly from @contentful/experience-delivery so consumers have full client access without a separate install. Co-Authored-By: Claude Sonnet 4.6 --- packages/client/package.json | 30 ++++++++++++++++++++++++++++++ packages/client/src/index.ts | 3 +++ packages/client/tsconfig.json | 8 ++++++++ packages/client/tsconfig.lib.json | 16 ++++++++++++++++ packages/client/tsup.config.ts | 14 ++++++++++++++ packages/client/vitest.config.ts | 15 +++++++++++++++ 6 files changed, 86 insertions(+) create mode 100644 packages/client/package.json create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/tsconfig.json create mode 100644 packages/client/tsconfig.lib.json create mode 100644 packages/client/tsup.config.ts create mode 100644 packages/client/vitest.config.ts diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..c416219 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,30 @@ +{ + "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/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..2fbdda9 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,3 @@ +export { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; +export { fetchExperience } from './fetch-experience.js'; +export type { FetchExperienceOptions } 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..37f353c --- /dev/null +++ b/packages/client/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "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'], + }, + }, +}); From 2c9c8e6a049ef8a26f1e30b54f53907656565340 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Fri, 10 Jul 2026 15:15:17 -0600 Subject: [PATCH 02/14] feat(client): add fetchExperience with flat discriminated union signature fetchExperience accepts either inline credentials ({ accessToken, preview?, spaceId, environmentId, experienceId }) or a pre-created ContentfulViewDeliveryClient ({ client, spaceId, environmentId, experienceId }). Inline path constructs the client internally with XDN or XPA base URL based on the preview flag. Pre-created client path is pass-through, enabling reuse across calls. When personalization is passed, dispatches to getExperienceWithOverrides and opts into sourceMap automatically. Returns PortableRenderPlan | null (null when payload has no nodes). Co-Authored-By: Claude Sonnet 4.6 --- packages/client/src/fetch-experience.test.ts | 137 +++++++++++++++++++ packages/client/src/fetch-experience.ts | 73 ++++++++++ 2 files changed, 210 insertions(+) create mode 100644 packages/client/src/fetch-experience.test.ts create mode 100644 packages/client/src/fetch-experience.ts diff --git a/packages/client/src/fetch-experience.test.ts b/packages/client/src/fetch-experience.test.ts new file mode 100644 index 0000000..f4dc8f1 --- /dev/null +++ b/packages/client/src/fetch-experience.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; +import { fetchExperience } from './fetch-experience.js'; + +const { mockGetExperience, mockGetExperienceWithOverrides, 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); + const mockGetExperienceWithOverrides = vi.fn().mockResolvedValue(mockPayload); + + return { mockGetExperience, mockGetExperienceWithOverrides, mockPayload, mockPlan }; + }, +); + +vi.mock('@contentful/experiences-core', () => ({ + resolveExperience: vi.fn().mockResolvedValue(mockPlan), +})); + +vi.mock('@contentful/experience-delivery', () => ({ + ContentfulViewDeliveryClient: vi.fn().mockImplementation(() => ({ + view: { + getExperience: mockGetExperience, + getExperienceWithOverrides: mockGetExperienceWithOverrides, + }, + })), +})); + +const baseOptions = { + spaceId: 'space-1', + environmentId: 'master', + experienceId: 'exp-1', + config: { components: {} }, +}; + +describe('fetchExperience', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetExperience.mockResolvedValue(mockPayload); + mockGetExperienceWithOverrides.mockResolvedValue(mockPayload); + }); + + describe('inline credentials', () => { + it('constructs client with XDN base URL by default', async () => { + await fetchExperience({ ...baseOptions, accessToken: 'token-123' }); + + expect(ContentfulViewDeliveryClient).toHaveBeenCalledWith({ + token: 'token-123', + baseUrl: 'https://xdn.contentful.com', + }); + }); + + it('constructs client with XPA base URL when preview is true', async () => { + await fetchExperience({ ...baseOptions, accessToken: 'token-123', preview: true }); + + expect(ContentfulViewDeliveryClient).toHaveBeenCalledWith({ + token: 'token-123', + baseUrl: 'https://preview.xdn.contentful.com', + }); + }); + + it('calls getExperience with spaceId, environmentId, experienceId, locale', async () => { + await fetchExperience({ ...baseOptions, accessToken: 'token-123', locale: 'en-US' }); + + 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({ ...baseOptions, client }); + + expect(ContentfulViewDeliveryClient).not.toHaveBeenCalled(); + expect(mockGetExperience).toHaveBeenCalledWith('space-1', 'master', 'exp-1', { + locale: undefined, + }); + }); + }); + + describe('personalization', () => { + it('calls getExperienceWithOverrides with sourceMap extension', async () => { + await fetchExperience({ + ...baseOptions, + accessToken: 'token-123', + personalization: { audienceIds: ['visitor-eu'] }, + }); + + expect(mockGetExperienceWithOverrides).toHaveBeenCalledWith( + 'space-1', + 'master', + 'exp-1', + expect.objectContaining({ + extensions: expect.objectContaining({ sourceMap: {} }), + }), + ); + expect(mockGetExperience).not.toHaveBeenCalled(); + }); + + it('calls getExperience when personalization is absent', async () => { + await fetchExperience({ ...baseOptions, accessToken: 'token-123' }); + + expect(mockGetExperience).toHaveBeenCalled(); + expect(mockGetExperienceWithOverrides).not.toHaveBeenCalled(); + }); + }); + + describe('return value', () => { + it('returns null when payload has no nodes', async () => { + mockGetExperience.mockResolvedValue({ ...mockPayload, nodes: [] }); + + const result = await fetchExperience({ ...baseOptions, accessToken: 'token-123' }); + + expect(result).toBeNull(); + }); + + it('returns resolved PortableRenderPlan on success', async () => { + const result = await fetchExperience({ ...baseOptions, accessToken: 'token-123' }); + + expect(result).toEqual(mockPlan); + }); + }); +}); diff --git a/packages/client/src/fetch-experience.ts b/packages/client/src/fetch-experience.ts new file mode 100644 index 0000000..2754c57 --- /dev/null +++ b/packages/client/src/fetch-experience.ts @@ -0,0 +1,73 @@ +import { + ContentfulViewDeliveryClient, + type GetExperienceWithOverridesViewRequest, +} from '@contentful/experience-delivery'; +import { resolveExperience } from '@contentful/experiences-core'; +import type { + ExperiencePayload, + PortableRenderPlan, + ResolveExperienceOptions, + ResolverConfig, +} from '@contentful/experiences-core'; + +const XDN_BASE_URL = 'https://xdn.contentful.com'; +const XPA_BASE_URL = 'https://preview.xdn.contentful.com'; + +export type FetchExperienceOptions = { + spaceId: string; + environmentId: string; + experienceId: string; + locale?: string; + personalization?: { + audienceIds?: string[]; + userTraits?: Record; + variantOverrides?: Record; + }; + config: ResolverConfig; + context?: ResolveExperienceOptions['experience']; +} & ( + | { accessToken: string; preview?: boolean } + | { client: ContentfulViewDeliveryClient } +); + +export async function fetchExperience( + options: FetchExperienceOptions, +): Promise { + const { spaceId, environmentId, experienceId, locale, personalization, config, context } = + options; + + const client = + 'client' in options + ? options.client + : new ContentfulViewDeliveryClient({ + token: options.accessToken, + baseUrl: options.preview ? XPA_BASE_URL : XDN_BASE_URL, + }); + + // Fern response is structurally compatible with ExperiencePayload (superset) + let payload: ExperiencePayload; + + if (personalization) { + const request: GetExperienceWithOverridesViewRequest = { + locale, + extensions: { + // Opt into sourceMap data automatically when personalization is requested + sourceMap: {}, + }, + }; + payload = (await client.view.getExperienceWithOverrides( + spaceId, + environmentId, + experienceId, + request, + )) as unknown as ExperiencePayload; + } else { + payload = (await client.view.getExperience(spaceId, environmentId, experienceId, { + locale, + })) as unknown as ExperiencePayload; + } + + if (!payload?.nodes?.length) return null; + + return resolveExperience(payload, config, { experience: context }); +} From 82c46ec5bfcf75ba619e55d48cc8a640bc81a2e6 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Fri, 10 Jul 2026 15:16:02 -0600 Subject: [PATCH 03/14] feat(adapters): re-export ContentfulViewDeliveryClient and fetchExperience Both @contentful/experiences-react and @contentful/experiences-svelte now re-export ContentfulViewDeliveryClient and fetchExperience from @contentful/experiences-client, so consumers install only the framework adapter and never touch @contentful/experience-delivery or @contentful/experiences-client directly. Co-Authored-By: Claude Sonnet 4.6 --- packages/adapter-react/package.json | 1 + packages/adapter-react/src/index.ts | 4 ++++ packages/adapter-svelte/package.json | 1 + packages/adapter-svelte/src/index.ts | 4 ++++ 4 files changed, 10 insertions(+) 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..930043e 100644 --- a/packages/adapter-react/src/index.ts +++ b/packages/adapter-react/src/index.ts @@ -87,3 +87,7 @@ export { resolveDesignProperties, toCssMediaQuery, } from '@contentful/experiences-design'; + +// ─── Delivery client + fetchExperience ──────────────────────────────────── +export { ContentfulViewDeliveryClient, fetchExperience } from '@contentful/experiences-client'; +export type { FetchExperienceOptions } 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..3311fd7 100644 --- a/packages/adapter-svelte/src/index.ts +++ b/packages/adapter-svelte/src/index.ts @@ -91,3 +91,7 @@ export { resolveDesignProperties, toCssMediaQuery, } from '@contentful/experiences-design'; + +// ─── Delivery client + fetchExperience ──────────────────────────────────── +export { ContentfulViewDeliveryClient, fetchExperience } from '@contentful/experiences-client'; +export type { FetchExperienceOptions } from '@contentful/experiences-client'; From 02afbdc6db0eb9230588348a3b932a1d6736bf40 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Fri, 10 Jul 2026 15:17:44 -0600 Subject: [PATCH 04/14] feat(examples): migrate to fetchExperience from adapter; drop @contentful/experience-delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both example apps now import fetchExperience and ContentfulViewDeliveryClient from their framework adapter rather than @contentful/experience-delivery directly. The separate resolveExperience call is removed — fetch and resolve happen in one step via fetchExperience. delivery-client.ts in each example wraps the SDK call with a thin page-level helper that reads env vars and passes the config. Co-Authored-By: Claude Sonnet 4.6 --- examples/nextjs/app/[slug]/page.tsx | 10 ++-- examples/nextjs/app/advanced/[slug]/page.tsx | 18 +++--- examples/nextjs/lib/delivery-client.ts | 51 +++++++---------- examples/nextjs/package.json | 1 - examples/sveltekit/package.json | 3 +- examples/sveltekit/src/lib/delivery-client.ts | 55 +++++++------------ .../src/routes/[slug]/+page.server.ts | 12 ++-- 7 files changed, 58 insertions(+), 92 deletions(-) diff --git a/examples/nextjs/app/[slug]/page.tsx b/examples/nextjs/app/[slug]/page.tsx index 198fc66..5bf3570 100644 --- a/examples/nextjs/app/[slug]/page.tsx +++ b/examples/nextjs/app/[slug]/page.tsx @@ -1,7 +1,7 @@ import { notFound } from 'next/navigation'; -import { ServerExperienceRenderer, resolveExperience } from '@contentful/experiences-react'; +import { ServerExperienceRenderer } from '@contentful/experiences-react'; -import { fetchExperience } from '@/lib/delivery-client'; +import { fetchExperiencePage } from '@/lib/delivery-client'; import { experienceConfig } from '@/lib/experience-config'; interface PageProps { @@ -11,10 +11,8 @@ interface PageProps { export default async function ExperiencePage({ params }: PageProps) { const { slug: experienceId } = await params; - const { payload } = await fetchExperience(experienceId); - if (!payload.nodes.length) notFound(); - - const experience = await resolveExperience(payload, experienceConfig); + const experience = await fetchExperiencePage(experienceId, experienceConfig); + if (!experience) notFound(); return ; } diff --git a/examples/nextjs/app/advanced/[slug]/page.tsx b/examples/nextjs/app/advanced/[slug]/page.tsx index aed22d8..30cd725 100644 --- a/examples/nextjs/app/advanced/[slug]/page.tsx +++ b/examples/nextjs/app/advanced/[slug]/page.tsx @@ -1,8 +1,8 @@ import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; -import { ServerExperienceRenderer, resolveExperience } from '@contentful/experiences-react'; +import { ServerExperienceRenderer } from '@contentful/experiences-react'; -import { fetchExperience } from '@/lib/delivery-client'; +import { fetchExperiencePage } from '@/lib/delivery-client'; import { detectViewportFromUserAgent } from '@/lib/detect-viewport'; import { advancedExperienceConfig } from '@/lib/experience-config-advanced'; @@ -15,8 +15,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 + * `fetchExperiencePage`. `?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,15 +38,15 @@ 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(); - - const experience = await resolveExperience(payload, advancedExperienceConfig, { - experience: { + const experience = await fetchExperiencePage(experienceId, advancedExperienceConfig, { + preview: previewMode, + locale, + context: { isPreview: previewMode, metadata: { slug: experienceId, locale }, }, }); + if (!experience) notFound(); return ( { - const spaceId = process.env.SPACE_ID || ''; - const environmentId = process.env.ENVIRONMENT_ID ?? 'master'; - - const response = await experienceClient.view.getExperience(spaceId, environmentId, experienceId, { + config: Config, + options: { + locale?: string; + preview?: boolean; + context?: ResolveExperienceOptions['experience']; + } = {}, +): Promise { + return fetchExperience({ + accessToken: process.env.CDA_TOKEN!, + preview: options.preview, + spaceId, + environmentId, + experienceId, locale: options.locale, - preview: options.preview ? 'true' : undefined, + context: options.context, + config, }); - - 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/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 index 4c483cb..447b1f0 100644 --- a/examples/sveltekit/src/lib/delivery-client.ts +++ b/examples/sveltekit/src/lib/delivery-client.ts @@ -1,42 +1,25 @@ -/** - * 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 { fetchExperience } from '@contentful/experiences-svelte'; +import type { PortableRenderPlan, ResolveExperienceOptions, Config } 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( +export async function fetchExperiencePage( experienceId: string, - options: { locale?: string; preview?: boolean } = {} -): Promise { - const response = await experienceClient.view.getExperience( - SPACE_ID, - ENVIRONMENT_ID || 'master', + config: Config, + options: { + locale?: string; + preview?: boolean; + context?: ResolveExperienceOptions['experience']; + } = {}, +): Promise { + return fetchExperience({ + accessToken: CDA_TOKEN, + preview: options.preview, + spaceId: SPACE_ID, + environmentId: ENVIRONMENT_ID || 'master', experienceId, - { - locale: options.locale, - preview: options.preview ? 'true' : undefined, - } - ); - - return { payload: response }; + locale: options.locale, + context: options.context, + config, + }); } diff --git a/examples/sveltekit/src/routes/[slug]/+page.server.ts b/examples/sveltekit/src/routes/[slug]/+page.server.ts index 66518d8..e438fdb 100644 --- a/examples/sveltekit/src/routes/[slug]/+page.server.ts +++ b/examples/sveltekit/src/routes/[slug]/+page.server.ts @@ -1,7 +1,6 @@ import { error } from '@sveltejs/kit'; -import { resolveExperience } from '@contentful/experiences-svelte'; -import { fetchExperience } from '$lib/delivery-client.js'; +import { fetchExperiencePage } from '$lib/delivery-client.js'; import { detectViewportFromUserAgent } from '$lib/detect-viewport.js'; import { experienceConfig } from '$lib/experience-config.js'; @@ -12,12 +11,11 @@ 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'); - - const experience = await resolveExperience(payload, experienceConfig, { - experience: { isPreview: previewMode, metadata: { slug: params.slug } }, + const experience = await fetchExperiencePage(params.slug, experienceConfig, { + preview: previewMode, + context: { isPreview: previewMode, metadata: { slug: params.slug } }, }); + if (!experience) error(404, 'Experience not found'); return { experience, previewMode, slug: params.slug, initialViewportId }; }; From f82617a80d996cbf5b2b5d3fa9cf31bf96bb5f15 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Fri, 10 Jul 2026 15:42:50 -0600 Subject: [PATCH 05/14] feat(examples): inline fetchExperience directly into page files; drop delivery-client wrappers Co-Authored-By: Claude Sonnet 4.6 --- examples/nextjs/app/[slug]/page.tsx | 11 ++++++-- examples/nextjs/app/advanced/[slug]/page.tsx | 12 +++++--- examples/nextjs/lib/delivery-client.ts | 28 ------------------- examples/sveltekit/src/lib/delivery-client.ts | 25 ----------------- .../src/routes/[slug]/+page.server.ts | 10 +++++-- 5 files changed, 24 insertions(+), 62 deletions(-) delete mode 100644 examples/nextjs/lib/delivery-client.ts delete mode 100644 examples/sveltekit/src/lib/delivery-client.ts diff --git a/examples/nextjs/app/[slug]/page.tsx b/examples/nextjs/app/[slug]/page.tsx index 5bf3570..98d6bc8 100644 --- a/examples/nextjs/app/[slug]/page.tsx +++ b/examples/nextjs/app/[slug]/page.tsx @@ -1,7 +1,6 @@ import { notFound } from 'next/navigation'; -import { ServerExperienceRenderer } from '@contentful/experiences-react'; +import { ServerExperienceRenderer, fetchExperience } from '@contentful/experiences-react'; -import { fetchExperiencePage } from '@/lib/delivery-client'; import { experienceConfig } from '@/lib/experience-config'; interface PageProps { @@ -11,7 +10,13 @@ interface PageProps { export default async function ExperiencePage({ params }: PageProps) { const { slug: experienceId } = await params; - const experience = await fetchExperiencePage(experienceId, experienceConfig); + const experience = await fetchExperience({ + accessToken: process.env.CDA_TOKEN!, + spaceId: process.env.SPACE_ID ?? '', + environmentId: process.env.ENVIRONMENT_ID ?? 'master', + experienceId, + config: experienceConfig, + }); if (!experience) notFound(); return ; diff --git a/examples/nextjs/app/advanced/[slug]/page.tsx b/examples/nextjs/app/advanced/[slug]/page.tsx index 30cd725..e09a249 100644 --- a/examples/nextjs/app/advanced/[slug]/page.tsx +++ b/examples/nextjs/app/advanced/[slug]/page.tsx @@ -1,8 +1,7 @@ import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; -import { ServerExperienceRenderer } from '@contentful/experiences-react'; +import { ServerExperienceRenderer, fetchExperience } from '@contentful/experiences-react'; -import { fetchExperiencePage } from '@/lib/delivery-client'; import { detectViewportFromUserAgent } from '@/lib/detect-viewport'; import { advancedExperienceConfig } from '@/lib/experience-config-advanced'; @@ -16,7 +15,7 @@ interface PageProps { * minimal three-line page in `app/[slug]/page.tsx` doesn't reach for: * * 1. **Preview mode + per-page metadata** via the `context` arg of - * `fetchExperiencePage`. `?preview=true` flips `MissingComponent` from + * `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,13 +37,18 @@ export default async function AdvancedExperiencePage({ params, searchParams }: P const userAgent = (await headers()).get('user-agent') ?? ''; const initialViewportId = detectViewportFromUserAgent(userAgent); - const experience = await fetchExperiencePage(experienceId, advancedExperienceConfig, { + const experience = await fetchExperience({ + accessToken: process.env.CDA_TOKEN!, preview: previewMode, + spaceId: process.env.SPACE_ID ?? '', + environmentId: process.env.ENVIRONMENT_ID ?? 'master', + experienceId, locale, context: { isPreview: previewMode, metadata: { slug: experienceId, locale }, }, + config: advancedExperienceConfig, }); if (!experience) notFound(); diff --git a/examples/nextjs/lib/delivery-client.ts b/examples/nextjs/lib/delivery-client.ts deleted file mode 100644 index 81f7f11..0000000 --- a/examples/nextjs/lib/delivery-client.ts +++ /dev/null @@ -1,28 +0,0 @@ -import 'server-only'; - -import { fetchExperience } from '@contentful/experiences-react'; -import type { PortableRenderPlan, ResolveExperienceOptions, Config } from '@contentful/experiences-react'; - -const spaceId = process.env.SPACE_ID ?? ''; -const environmentId = process.env.ENVIRONMENT_ID ?? 'master'; - -export async function fetchExperiencePage( - experienceId: string, - config: Config, - options: { - locale?: string; - preview?: boolean; - context?: ResolveExperienceOptions['experience']; - } = {}, -): Promise { - return fetchExperience({ - accessToken: process.env.CDA_TOKEN!, - preview: options.preview, - spaceId, - environmentId, - experienceId, - locale: options.locale, - context: options.context, - config, - }); -} diff --git a/examples/sveltekit/src/lib/delivery-client.ts b/examples/sveltekit/src/lib/delivery-client.ts deleted file mode 100644 index 447b1f0..0000000 --- a/examples/sveltekit/src/lib/delivery-client.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { fetchExperience } from '@contentful/experiences-svelte'; -import type { PortableRenderPlan, ResolveExperienceOptions, Config } from '@contentful/experiences-svelte'; - -import { CDA_TOKEN, ENVIRONMENT_ID, SPACE_ID } from '$env/static/private'; - -export async function fetchExperiencePage( - experienceId: string, - config: Config, - options: { - locale?: string; - preview?: boolean; - context?: ResolveExperienceOptions['experience']; - } = {}, -): Promise { - return fetchExperience({ - accessToken: CDA_TOKEN, - preview: options.preview, - spaceId: SPACE_ID, - environmentId: ENVIRONMENT_ID || 'master', - experienceId, - locale: options.locale, - context: options.context, - config, - }); -} diff --git a/examples/sveltekit/src/routes/[slug]/+page.server.ts b/examples/sveltekit/src/routes/[slug]/+page.server.ts index e438fdb..0e8f748 100644 --- a/examples/sveltekit/src/routes/[slug]/+page.server.ts +++ b/examples/sveltekit/src/routes/[slug]/+page.server.ts @@ -1,6 +1,7 @@ import { error } from '@sveltejs/kit'; -import { fetchExperiencePage } from '$lib/delivery-client.js'; +import { 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'; @@ -11,9 +12,14 @@ 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 experience = await fetchExperiencePage(params.slug, experienceConfig, { + const experience = await fetchExperience({ + accessToken: CDA_TOKEN, preview: previewMode, + spaceId: SPACE_ID, + environmentId: ENVIRONMENT_ID || 'master', + experienceId: params.slug, context: { isPreview: previewMode, metadata: { slug: params.slug } }, + config: experienceConfig, }); if (!experience) error(404, 'Experience not found'); From d6e0b7be6c3e84e9f2bde8d7ce6572195e622199 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Fri, 10 Jul 2026 15:58:51 -0600 Subject: [PATCH 06/14] chore: update lock file for @contentful/experiences-client; apply prettier Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 40 +++++++++++++++++++- packages/client/package.json | 10 ++++- packages/client/src/fetch-experience.test.ts | 4 +- packages/client/src/fetch-experience.ts | 9 ++--- packages/client/tsconfig.lib.json | 7 +--- 5 files changed, 52 insertions(+), 18 deletions(-) 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/client/package.json b/packages/client/package.json index c416219..aa68e16 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -16,8 +16,14 @@ }, "./package.json": "./package.json" }, - "files": ["dist", "README.md", "CHANGELOG.md"], - "publishConfig": { "access": "public" }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "publishConfig": { + "access": "public" + }, "repository": { "type": "git", "url": "https://github.com/contentful/experiences.git", diff --git a/packages/client/src/fetch-experience.test.ts b/packages/client/src/fetch-experience.test.ts index f4dc8f1..f633b41 100644 --- a/packages/client/src/fetch-experience.test.ts +++ b/packages/client/src/fetch-experience.test.ts @@ -20,7 +20,7 @@ const { mockGetExperience, mockGetExperienceWithOverrides, mockPayload, mockPlan const mockGetExperienceWithOverrides = vi.fn().mockResolvedValue(mockPayload); return { mockGetExperience, mockGetExperienceWithOverrides, mockPayload, mockPlan }; - }, + } ); vi.mock('@contentful/experiences-core', () => ({ @@ -106,7 +106,7 @@ describe('fetchExperience', () => { 'exp-1', expect.objectContaining({ extensions: expect.objectContaining({ sourceMap: {} }), - }), + }) ); expect(mockGetExperience).not.toHaveBeenCalled(); }); diff --git a/packages/client/src/fetch-experience.ts b/packages/client/src/fetch-experience.ts index 2754c57..3461a35 100644 --- a/packages/client/src/fetch-experience.ts +++ b/packages/client/src/fetch-experience.ts @@ -25,13 +25,10 @@ export type FetchExperienceOptions = { }; config: ResolverConfig; context?: ResolveExperienceOptions['experience']; -} & ( - | { accessToken: string; preview?: boolean } - | { client: ContentfulViewDeliveryClient } -); +} & ({ accessToken: string; preview?: boolean } | { client: ContentfulViewDeliveryClient }); export async function fetchExperience( - options: FetchExperienceOptions, + options: FetchExperienceOptions ): Promise { const { spaceId, environmentId, experienceId, locale, personalization, config, context } = options; @@ -59,7 +56,7 @@ export async function fetchExperience( spaceId, environmentId, experienceId, - request, + request )) as unknown as ExperiencePayload; } else { payload = (await client.view.getExperience(spaceId, environmentId, experienceId, { diff --git a/packages/client/tsconfig.lib.json b/packages/client/tsconfig.lib.json index 37f353c..6f37ea2 100644 --- a/packages/client/tsconfig.lib.json +++ b/packages/client/tsconfig.lib.json @@ -7,10 +7,5 @@ "types": ["node"] }, "include": ["src/**/*.ts"], - "exclude": [ - "**/*.test.ts", - "**/*.spec.ts", - "vitest.config.ts", - "tsup.config.ts" - ] + "exclude": ["**/*.test.ts", "**/*.spec.ts", "vitest.config.ts", "tsup.config.ts"] } From ec4cb50b0c5a8593385a34281c9c4cdb70895884 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Fri, 10 Jul 2026 16:09:00 -0600 Subject: [PATCH 07/14] fix(client): add project.json for NX build graph; use ContentfulViewDelivery namespace for request type Co-Authored-By: Claude Sonnet 4.6 --- packages/client/project.json | 28 +++++++++++++++++++++++++ packages/client/src/fetch-experience.ts | 4 ++-- 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 packages/client/project.json 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/fetch-experience.ts b/packages/client/src/fetch-experience.ts index 3461a35..3816089 100644 --- a/packages/client/src/fetch-experience.ts +++ b/packages/client/src/fetch-experience.ts @@ -1,6 +1,6 @@ import { ContentfulViewDeliveryClient, - type GetExperienceWithOverridesViewRequest, + type ContentfulViewDelivery, } from '@contentful/experience-delivery'; import { resolveExperience } from '@contentful/experiences-core'; import type { @@ -45,7 +45,7 @@ export async function fetchExperience( let payload: ExperiencePayload; if (personalization) { - const request: GetExperienceWithOverridesViewRequest = { + const request: ContentfulViewDelivery.GetExperienceWithOverridesViewRequest = { locale, extensions: { // Opt into sourceMap data automatically when personalization is requested From 4637f6fd7b9b980833a8983c12cb25e1b424a634 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Fri, 10 Jul 2026 16:30:25 -0600 Subject: [PATCH 08/14] refactor(client): remove personalization placeholder; call getExperience unconditionally Co-Authored-By: Claude Sonnet 4.6 --- packages/client/src/fetch-experience.test.ts | 66 +++++--------------- packages/client/src/fetch-experience.ts | 39 ++---------- 2 files changed, 23 insertions(+), 82 deletions(-) diff --git a/packages/client/src/fetch-experience.test.ts b/packages/client/src/fetch-experience.test.ts index f633b41..fca56ff 100644 --- a/packages/client/src/fetch-experience.test.ts +++ b/packages/client/src/fetch-experience.test.ts @@ -2,26 +2,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; import { fetchExperience } from './fetch-experience.js'; -const { mockGetExperience, mockGetExperienceWithOverrides, 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); - const mockGetExperienceWithOverrides = vi.fn().mockResolvedValue(mockPayload); - - return { mockGetExperience, mockGetExperienceWithOverrides, mockPayload, mockPlan }; - } -); +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), @@ -31,7 +28,6 @@ vi.mock('@contentful/experience-delivery', () => ({ ContentfulViewDeliveryClient: vi.fn().mockImplementation(() => ({ view: { getExperience: mockGetExperience, - getExperienceWithOverrides: mockGetExperienceWithOverrides, }, })), })); @@ -47,7 +43,6 @@ describe('fetchExperience', () => { beforeEach(() => { vi.clearAllMocks(); mockGetExperience.mockResolvedValue(mockPayload); - mockGetExperienceWithOverrides.mockResolvedValue(mockPayload); }); describe('inline credentials', () => { @@ -92,33 +87,6 @@ describe('fetchExperience', () => { }); }); - describe('personalization', () => { - it('calls getExperienceWithOverrides with sourceMap extension', async () => { - await fetchExperience({ - ...baseOptions, - accessToken: 'token-123', - personalization: { audienceIds: ['visitor-eu'] }, - }); - - expect(mockGetExperienceWithOverrides).toHaveBeenCalledWith( - 'space-1', - 'master', - 'exp-1', - expect.objectContaining({ - extensions: expect.objectContaining({ sourceMap: {} }), - }) - ); - expect(mockGetExperience).not.toHaveBeenCalled(); - }); - - it('calls getExperience when personalization is absent', async () => { - await fetchExperience({ ...baseOptions, accessToken: 'token-123' }); - - expect(mockGetExperience).toHaveBeenCalled(); - expect(mockGetExperienceWithOverrides).not.toHaveBeenCalled(); - }); - }); - describe('return value', () => { it('returns null when payload has no nodes', async () => { mockGetExperience.mockResolvedValue({ ...mockPayload, nodes: [] }); diff --git a/packages/client/src/fetch-experience.ts b/packages/client/src/fetch-experience.ts index 3816089..fb1f8b9 100644 --- a/packages/client/src/fetch-experience.ts +++ b/packages/client/src/fetch-experience.ts @@ -1,7 +1,4 @@ -import { - ContentfulViewDeliveryClient, - type ContentfulViewDelivery, -} from '@contentful/experience-delivery'; +import { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; import { resolveExperience } from '@contentful/experiences-core'; import type { ExperiencePayload, @@ -18,11 +15,6 @@ export type FetchExperienceOptions = { environmentId: string; experienceId: string; locale?: string; - personalization?: { - audienceIds?: string[]; - userTraits?: Record; - variantOverrides?: Record; - }; config: ResolverConfig; context?: ResolveExperienceOptions['experience']; } & ({ accessToken: string; preview?: boolean } | { client: ContentfulViewDeliveryClient }); @@ -30,8 +22,7 @@ export type FetchExperienceOptions = { export async function fetchExperience( options: FetchExperienceOptions ): Promise { - const { spaceId, environmentId, experienceId, locale, personalization, config, context } = - options; + const { spaceId, environmentId, experienceId, locale, config, context } = options; const client = 'client' in options @@ -41,28 +32,10 @@ export async function fetchExperience( baseUrl: options.preview ? XPA_BASE_URL : XDN_BASE_URL, }); - // Fern response is structurally compatible with ExperiencePayload (superset) - let payload: ExperiencePayload; - - if (personalization) { - const request: ContentfulViewDelivery.GetExperienceWithOverridesViewRequest = { - locale, - extensions: { - // Opt into sourceMap data automatically when personalization is requested - sourceMap: {}, - }, - }; - payload = (await client.view.getExperienceWithOverrides( - spaceId, - environmentId, - experienceId, - request - )) as unknown as ExperiencePayload; - } else { - payload = (await client.view.getExperience(spaceId, environmentId, experienceId, { - locale, - })) as unknown as ExperiencePayload; - } + // 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; if (!payload?.nodes?.length) return null; From c3a066703aaffe407bd74dd8c7d4728f5e1e42bc Mon Sep 17 00:00:00 2001 From: Lisa White Date: Fri, 10 Jul 2026 16:38:35 -0600 Subject: [PATCH 09/14] docs: update READMEs and AGENTS for fetchExperience + packages/client Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 27 +++++++----- README.md | 72 ++++++++++++++++++++++---------- examples/nextjs/README.md | 54 ++++++++++++++++-------- examples/sveltekit/README.md | 6 +-- packages/adapter-react/README.md | 22 ++++++++-- packages/client/README.md | 71 +++++++++++++++++++++++++++++++ 6 files changed, 194 insertions(+), 58 deletions(-) create mode 100644 packages/client/README.md diff --git a/AGENTS.md b/AGENTS.md index bfd4d53..8371ffe 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,12 @@ 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(options) → ``` -`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. Customers who want to manage the delivery client themselves can import `ContentfulViewDeliveryClient` from the adapter and call `resolveExperience` directly. + +`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). @@ -119,9 +123,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 +156,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..958483f 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. @@ -62,22 +62,24 @@ 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 { fetchExperience, 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); + const experience = await fetchExperience({ + accessToken: process.env.CDA_TOKEN!, + spaceId: process.env.SPACE_ID!, + environmentId: 'master', + experienceId: slug, + config: experienceConfig, + }); return ; } ``` -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. A working version is at [`examples/nextjs/app/[slug]/page.tsx`](./examples/nextjs/app/[slug]/page.tsx). @@ -92,10 +94,10 @@ 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 { fetchExperience, 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 +106,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,22 +115,24 @@ 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, { + // 2. Per-page metadata flows into every resolveData hook via context. + const experience = await fetchExperience({ + accessToken: process.env.CDA_TOKEN!, + preview: previewMode, + spaceId: process.env.SPACE_ID!, + environmentId: 'master', + experienceId, 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 } }, + context: { isPreview: previewMode, metadata: { slug: experienceId, locale } }, + config: advancedExperienceConfig, }); return ( ); } @@ -159,6 +163,31 @@ button: defineComponent({ ## API reference +### `fetchExperience(options)` + +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 | null` (null when the payload has no nodes). + +Pass either an `accessToken` (client created internally) or a pre-created `ContentfulViewDeliveryClient`: + +```ts +// Inline credentials — client created internally +const plan = await fetchExperience({ + accessToken: process.env.CDA_TOKEN!, + preview: false, // true → fetches from the preview API endpoint + spaceId: '...', + environmentId: 'master', + experienceId: slug, + config: experienceConfig, + // locale?: string + // context?: Partial — flows into resolveData hooks +}); + +// Pre-created client — useful when you manage the client yourself +import { ContentfulViewDeliveryClient } from '@contentful/experiences-react'; +const client = new ContentfulViewDeliveryClient({ token: process.env.CDA_TOKEN! }); +const plan = await fetchExperience({ client, spaceId, environmentId, experienceId, config }); +``` + ### `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. @@ -293,6 +322,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..f4222e1 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. +- **Three-line page** — `fetchExperience` → ``. 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 @@ -30,14 +29,20 @@ The example ships two side-by-side routes so you can see what each SDK option bu | Route | Page | Config | Demonstrates | | ------------------ | ---------------------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `/[slug]` | [`app/[slug]/page.tsx`](./app/[slug]/page.tsx) | `experience-config.tsx` | The minimum: `fetch` → `resolveExperience(payload, config)` → ``. 3 lines. | +| `/[slug]` | [`app/[slug]/page.tsx`](./app/[slug]/page.tsx) | `experience-config.tsx` | The minimum: `fetchExperience` → ``. 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. | The minimal `[slug]/page.tsx` is three functional lines: ```tsx -const { payload } = await fetchExperience(experienceId); -const experience = await resolveExperience(payload, experienceConfig); +const experience = await fetchExperience({ + accessToken, + spaceId, + environmentId, + experienceId, + config: experienceConfig, +}); +if (!experience) notFound(); return ; ``` @@ -58,7 +63,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,30 +159,44 @@ 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({ + accessToken: process.env.CDA_TOKEN!, + spaceId: process.env.SPACE_ID!, + environmentId: 'master', + experienceId: slug, + 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 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. Pass `preview: true` + at the top level to also hit the preview API endpoint. +- **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({ + accessToken: process.env.CDA_TOKEN!, + preview: previewMode, + spaceId: process.env.SPACE_ID!, + environmentId: 'master', + experienceId: slug, + locale, + context: { isPreview: previewMode, metadata: { slug, locale } }, + config: experienceConfig, }); ``` diff --git a/examples/sveltekit/README.md b/examples/sveltekit/README.md index 20676ea..5524583 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` → ``. 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/packages/adapter-react/README.md b/packages/adapter-react/README.md index bf64022..e0409c4 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,14 @@ defineComponent(config); // Type-narrowing identity for component-type co defineTemplate(config); // Same shape, for page-level template wrappers ``` +### Fetching + +```ts +fetchExperience(options) // Async — fetches from Experience Delivery API + resolves in one call +ContentfulViewDeliveryClient // Re-exported delivery client for advanced use cases +type FetchExperienceOptions +``` + ### Resolver ```ts @@ -62,7 +70,7 @@ getValueForViewport, getViewportIndex, resolveDesignProperties, toCssMediaQuery import { defineComponent, defineTemplate, - resolveExperience, + fetchExperience, ServerExperienceRenderer, type Config, type Components, @@ -82,7 +90,13 @@ const components: Components = { const experienceConfig: Config = { components }; // In a server component: -const experience = await resolveExperience(payload, experienceConfig); +const experience = await fetchExperience({ + accessToken: process.env.CDA_TOKEN!, + spaceId: process.env.SPACE_ID!, + environmentId: 'master', + experienceId: slug, + config: experienceConfig, +}); return ; ``` diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000..749d95a --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,71 @@ +# @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(options)` + +The primary fetch + resolve entry point. Fetches an Experience payload from the Experience Delivery API and resolves it into a `PortableRenderPlan` in one call. + +```ts +import { fetchExperience } from '@contentful/experiences-react'; // or experiences-svelte + +// Inline credentials — client created internally +const plan = await fetchExperience({ + accessToken: process.env.CDA_TOKEN!, + preview: false, // true → hits the preview API endpoint + spaceId: '...', + environmentId: 'master', + experienceId: slug, + config: experienceConfig, + locale: 'en-US', // optional + context: { + // optional — flows into resolveData hooks as ctx.experience + isPreview: false, + metadata: { slug }, + }, +}); + +// Pre-created client — useful when you manage the client lifecycle yourself +import { ContentfulViewDeliveryClient } from '@contentful/experiences-react'; +const client = new ContentfulViewDeliveryClient({ token: process.env.CDA_TOKEN! }); +const plan = await fetchExperience({ client, spaceId, environmentId, experienceId, config }); +``` + +Returns `PortableRenderPlan | null`. Returns `null` when the fetched payload has no nodes. + +### `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). + +```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`. From 1fd8aa0cbf1695431ae830cee1f1cf854c4c4935 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Fri, 10 Jul 2026 16:52:11 -0600 Subject: [PATCH 10/14] chore: fix prettier formatting on AGENTS.md Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8371ffe..9dc8064 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,13 +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. | +| 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. | +| `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. From 0b913f410c3182380dd84b0a03e9a111b98513fc Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 13 Jul 2026 14:44:47 -0600 Subject: [PATCH 11/14] refactor(client): split fetchExperience into 3 positional args; replace preview with host Signature is now `fetchExperience(experienceOptions, clientOptions, resolveOptions)`, with `ClientOptions` a discriminated union of `{ accessToken, host? }` or `{ client }`. `host` is a full base URL string that's passed through as `baseUrl`; XPA vs XDN endpoint selection is now the caller's concern. Defaults to XDN when omitted. Co-Authored-By: Claude Opus 4.7 --- examples/nextjs/app/[slug]/page.tsx | 16 ++++---- examples/nextjs/app/advanced/[slug]/page.tsx | 30 +++++++++------ .../src/routes/[slug]/+page.server.ts | 24 +++++++----- packages/adapter-react/src/index.ts | 6 ++- packages/adapter-svelte/src/index.ts | 6 ++- packages/client/src/fetch-experience.test.ts | 37 ++++++++++++++----- packages/client/src/fetch-experience.ts | 29 ++++++++++----- packages/client/src/index.ts | 2 +- 8 files changed, 100 insertions(+), 50 deletions(-) diff --git a/examples/nextjs/app/[slug]/page.tsx b/examples/nextjs/app/[slug]/page.tsx index 98d6bc8..ab98afa 100644 --- a/examples/nextjs/app/[slug]/page.tsx +++ b/examples/nextjs/app/[slug]/page.tsx @@ -10,13 +10,15 @@ interface PageProps { export default async function ExperiencePage({ params }: PageProps) { const { slug: experienceId } = await params; - const experience = await fetchExperience({ - accessToken: process.env.CDA_TOKEN!, - spaceId: process.env.SPACE_ID ?? '', - environmentId: process.env.ENVIRONMENT_ID ?? 'master', - experienceId, - config: experienceConfig, - }); + const experience = await fetchExperience( + { + spaceId: process.env.SPACE_ID ?? '', + environmentId: process.env.ENVIRONMENT_ID ?? 'master', + experienceId, + }, + { accessToken: process.env.CDA_TOKEN! }, + { config: experienceConfig } + ); if (!experience) notFound(); return ; diff --git a/examples/nextjs/app/advanced/[slug]/page.tsx b/examples/nextjs/app/advanced/[slug]/page.tsx index e09a249..2337531 100644 --- a/examples/nextjs/app/advanced/[slug]/page.tsx +++ b/examples/nextjs/app/advanced/[slug]/page.tsx @@ -37,19 +37,25 @@ export default async function AdvancedExperiencePage({ params, searchParams }: P const userAgent = (await headers()).get('user-agent') ?? ''; const initialViewportId = detectViewportFromUserAgent(userAgent); - const experience = await fetchExperience({ - accessToken: process.env.CDA_TOKEN!, - preview: previewMode, - spaceId: process.env.SPACE_ID ?? '', - environmentId: process.env.ENVIRONMENT_ID ?? 'master', - experienceId, - locale, - context: { - isPreview: previewMode, - metadata: { slug: experienceId, locale }, + const experience = await fetchExperience( + { + spaceId: process.env.SPACE_ID ?? '', + environmentId: process.env.ENVIRONMENT_ID ?? 'master', + experienceId, + locale, }, - config: advancedExperienceConfig, - }); + { + 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 }, + }, + } + ); if (!experience) notFound(); return ( diff --git a/examples/sveltekit/src/routes/[slug]/+page.server.ts b/examples/sveltekit/src/routes/[slug]/+page.server.ts index 0e8f748..457ac64 100644 --- a/examples/sveltekit/src/routes/[slug]/+page.server.ts +++ b/examples/sveltekit/src/routes/[slug]/+page.server.ts @@ -12,15 +12,21 @@ 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 experience = await fetchExperience({ - accessToken: CDA_TOKEN, - preview: previewMode, - spaceId: SPACE_ID, - environmentId: ENVIRONMENT_ID || 'master', - experienceId: params.slug, - context: { isPreview: previewMode, metadata: { slug: params.slug } }, - config: experienceConfig, - }); + 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 } }, + } + ); if (!experience) error(404, 'Experience not found'); return { experience, previewMode, slug: params.slug, initialViewportId }; diff --git a/packages/adapter-react/src/index.ts b/packages/adapter-react/src/index.ts index 930043e..eb280d0 100644 --- a/packages/adapter-react/src/index.ts +++ b/packages/adapter-react/src/index.ts @@ -90,4 +90,8 @@ export { // ─── Delivery client + fetchExperience ──────────────────────────────────── export { ContentfulViewDeliveryClient, fetchExperience } from '@contentful/experiences-client'; -export type { FetchExperienceOptions } from '@contentful/experiences-client'; +export type { + ExperienceOptions, + ClientOptions, + ResolveOptions, +} from '@contentful/experiences-client'; diff --git a/packages/adapter-svelte/src/index.ts b/packages/adapter-svelte/src/index.ts index 3311fd7..dc226f9 100644 --- a/packages/adapter-svelte/src/index.ts +++ b/packages/adapter-svelte/src/index.ts @@ -94,4 +94,8 @@ export { // ─── Delivery client + fetchExperience ──────────────────────────────────── export { ContentfulViewDeliveryClient, fetchExperience } from '@contentful/experiences-client'; -export type { FetchExperienceOptions } from '@contentful/experiences-client'; +export type { + ExperienceOptions, + ClientOptions, + ResolveOptions, +} from '@contentful/experiences-client'; diff --git a/packages/client/src/fetch-experience.test.ts b/packages/client/src/fetch-experience.test.ts index fca56ff..dc0d5d7 100644 --- a/packages/client/src/fetch-experience.test.ts +++ b/packages/client/src/fetch-experience.test.ts @@ -32,10 +32,13 @@ vi.mock('@contentful/experience-delivery', () => ({ })), })); -const baseOptions = { +const experienceOptions = { spaceId: 'space-1', environmentId: 'master', experienceId: 'exp-1', +}; + +const resolveOptions = { config: { components: {} }, }; @@ -46,8 +49,8 @@ describe('fetchExperience', () => { }); describe('inline credentials', () => { - it('constructs client with XDN base URL by default', async () => { - await fetchExperience({ ...baseOptions, accessToken: 'token-123' }); + it('constructs client with default XDN host when host is not provided', async () => { + await fetchExperience(experienceOptions, { accessToken: 'token-123' }, resolveOptions); expect(ContentfulViewDeliveryClient).toHaveBeenCalledWith({ token: 'token-123', @@ -55,8 +58,12 @@ describe('fetchExperience', () => { }); }); - it('constructs client with XPA base URL when preview is true', async () => { - await fetchExperience({ ...baseOptions, accessToken: 'token-123', preview: true }); + 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', @@ -65,7 +72,11 @@ describe('fetchExperience', () => { }); it('calls getExperience with spaceId, environmentId, experienceId, locale', async () => { - await fetchExperience({ ...baseOptions, accessToken: 'token-123', locale: 'en-US' }); + await fetchExperience( + { ...experienceOptions, locale: 'en-US' }, + { accessToken: 'token-123' }, + resolveOptions + ); expect(mockGetExperience).toHaveBeenCalledWith('space-1', 'master', 'exp-1', { locale: 'en-US', @@ -78,7 +89,7 @@ describe('fetchExperience', () => { const client = new ContentfulViewDeliveryClient({ token: 'token-123' }); vi.mocked(ContentfulViewDeliveryClient).mockClear(); - await fetchExperience({ ...baseOptions, client }); + await fetchExperience(experienceOptions, { client }, resolveOptions); expect(ContentfulViewDeliveryClient).not.toHaveBeenCalled(); expect(mockGetExperience).toHaveBeenCalledWith('space-1', 'master', 'exp-1', { @@ -91,13 +102,21 @@ describe('fetchExperience', () => { it('returns null when payload has no nodes', async () => { mockGetExperience.mockResolvedValue({ ...mockPayload, nodes: [] }); - const result = await fetchExperience({ ...baseOptions, accessToken: 'token-123' }); + const result = await fetchExperience( + experienceOptions, + { accessToken: 'token-123' }, + resolveOptions + ); expect(result).toBeNull(); }); it('returns resolved PortableRenderPlan on success', async () => { - const result = await fetchExperience({ ...baseOptions, accessToken: 'token-123' }); + const result = await fetchExperience( + experienceOptions, + { accessToken: 'token-123' }, + resolveOptions + ); expect(result).toEqual(mockPlan); }); diff --git a/packages/client/src/fetch-experience.ts b/packages/client/src/fetch-experience.ts index fb1f8b9..85e1539 100644 --- a/packages/client/src/fetch-experience.ts +++ b/packages/client/src/fetch-experience.ts @@ -7,29 +7,38 @@ import type { ResolverConfig, } from '@contentful/experiences-core'; -const XDN_BASE_URL = 'https://xdn.contentful.com'; -const XPA_BASE_URL = 'https://preview.xdn.contentful.com'; +const DEFAULT_HOST = 'https://xdn.contentful.com'; -export type FetchExperienceOptions = { +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']; -} & ({ accessToken: string; preview?: boolean } | { client: ContentfulViewDeliveryClient }); +}; export async function fetchExperience( - options: FetchExperienceOptions + experienceOptions: ExperienceOptions, + clientOptions: ClientOptions, + resolveOptions: ResolveOptions ): Promise { - const { spaceId, environmentId, experienceId, locale, config, context } = options; + const { spaceId, environmentId, experienceId, locale } = experienceOptions; + const { config, context } = resolveOptions; const client = - 'client' in options - ? options.client + 'client' in clientOptions + ? clientOptions.client : new ContentfulViewDeliveryClient({ - token: options.accessToken, - baseUrl: options.preview ? XPA_BASE_URL : XDN_BASE_URL, + token: clientOptions.accessToken, + baseUrl: clientOptions.host ?? DEFAULT_HOST, }); // Response from the experience delivery client is structurally compatible with ExperiencePayload (superset) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 2fbdda9..7e9fd1a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,3 @@ export { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; export { fetchExperience } from './fetch-experience.js'; -export type { FetchExperienceOptions } from './fetch-experience.js'; +export type { ExperienceOptions, ClientOptions, ResolveOptions } from './fetch-experience.js'; From 4f9e0dadb044a46c71cffea98402b64b9f5dd7b5 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 13 Jul 2026 14:59:54 -0600 Subject: [PATCH 12/14] feat(client): add createClient helper mapping accessToken/host to token/baseUrl createClient is a functional constructor over ContentfulViewDeliveryClient that maps the SDK's option names (accessToken, host) to the delivery client's underlying names (token, baseUrl). Other options pass through unchanged. fetchExperience's inline-creds branch now routes through createClient, making it the single source of truth for the name mapping. Co-Authored-By: Claude Opus 4.7 --- packages/adapter-react/src/index.ts | 9 +++- packages/adapter-svelte/src/index.ts | 9 +++- packages/client/src/create-client.test.ts | 56 ++++++++++++++++++++ packages/client/src/create-client.ts | 15 ++++++ packages/client/src/fetch-experience.test.ts | 4 +- packages/client/src/fetch-experience.ts | 10 ++-- packages/client/src/index.ts | 2 + 7 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 packages/client/src/create-client.test.ts create mode 100644 packages/client/src/create-client.ts diff --git a/packages/adapter-react/src/index.ts b/packages/adapter-react/src/index.ts index eb280d0..cea9123 100644 --- a/packages/adapter-react/src/index.ts +++ b/packages/adapter-react/src/index.ts @@ -89,9 +89,14 @@ export { } from '@contentful/experiences-design'; // ─── Delivery client + fetchExperience ──────────────────────────────────── -export { ContentfulViewDeliveryClient, fetchExperience } from '@contentful/experiences-client'; +export { + ContentfulViewDeliveryClient, + createClient, + fetchExperience, +} from '@contentful/experiences-client'; export type { - ExperienceOptions, ClientOptions, + CreateClientOptions, + ExperienceOptions, ResolveOptions, } from '@contentful/experiences-client'; diff --git a/packages/adapter-svelte/src/index.ts b/packages/adapter-svelte/src/index.ts index dc226f9..299339c 100644 --- a/packages/adapter-svelte/src/index.ts +++ b/packages/adapter-svelte/src/index.ts @@ -93,9 +93,14 @@ export { } from '@contentful/experiences-design'; // ─── Delivery client + fetchExperience ──────────────────────────────────── -export { ContentfulViewDeliveryClient, fetchExperience } from '@contentful/experiences-client'; +export { + ContentfulViewDeliveryClient, + createClient, + fetchExperience, +} from '@contentful/experiences-client'; export type { - ExperienceOptions, ClientOptions, + CreateClientOptions, + ExperienceOptions, ResolveOptions, } from '@contentful/experiences-client'; 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 index dc0d5d7..07aab3f 100644 --- a/packages/client/src/fetch-experience.test.ts +++ b/packages/client/src/fetch-experience.test.ts @@ -49,12 +49,12 @@ describe('fetchExperience', () => { }); describe('inline credentials', () => { - it('constructs client with default XDN host when host is not provided', async () => { + 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: 'https://xdn.contentful.com', + baseUrl: undefined, }); }); diff --git a/packages/client/src/fetch-experience.ts b/packages/client/src/fetch-experience.ts index 85e1539..f435af0 100644 --- a/packages/client/src/fetch-experience.ts +++ b/packages/client/src/fetch-experience.ts @@ -1,4 +1,4 @@ -import { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; +import type { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; import { resolveExperience } from '@contentful/experiences-core'; import type { ExperiencePayload, @@ -6,8 +6,7 @@ import type { ResolveExperienceOptions, ResolverConfig, } from '@contentful/experiences-core'; - -const DEFAULT_HOST = 'https://xdn.contentful.com'; +import { createClient } from './create-client.js'; export type ExperienceOptions = { spaceId: string; @@ -36,10 +35,7 @@ export async function fetchExperience( const client = 'client' in clientOptions ? clientOptions.client - : new ContentfulViewDeliveryClient({ - token: clientOptions.accessToken, - baseUrl: clientOptions.host ?? DEFAULT_HOST, - }); + : 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, { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7e9fd1a..883fd58 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,5 @@ export { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; +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'; From 134b886ab68d056c5988f6e83e1e064ed2496d04 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 13 Jul 2026 15:16:08 -0600 Subject: [PATCH 13/14] refactor(client): let empty-nodes payloads flow to the resolver; expose NotFoundError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchExperience no longer treats an empty-nodes payload as "not found" — the resolver handles empty nodes gracefully and returns a valid PortableRenderPlan. Return type narrows from `PortableRenderPlan | null` to `PortableRenderPlan`. To keep the missing-experience case as a proper 404 (not a 500), NotFoundError is now re-exported from the client + adapter packages. Example call sites wrap fetchExperience in try/catch and route NotFoundError to their framework's 404 idiom (notFound() in Next, error(404) in SvelteKit). Co-Authored-By: Claude Opus 4.7 --- examples/nextjs/app/[slug]/page.tsx | 32 ++++++---- examples/nextjs/app/advanced/[slug]/page.tsx | 64 +++++++++++-------- .../src/routes/[slug]/+page.server.ts | 40 ++++++------ packages/adapter-react/src/index.ts | 2 + packages/adapter-svelte/src/index.ts | 2 + packages/client/src/fetch-experience.test.ts | 22 ++++++- packages/client/src/fetch-experience.ts | 4 +- packages/client/src/index.ts | 9 ++- 8 files changed, 110 insertions(+), 65 deletions(-) diff --git a/examples/nextjs/app/[slug]/page.tsx b/examples/nextjs/app/[slug]/page.tsx index ab98afa..1cfa4fd 100644 --- a/examples/nextjs/app/[slug]/page.tsx +++ b/examples/nextjs/app/[slug]/page.tsx @@ -1,5 +1,9 @@ import { notFound } from 'next/navigation'; -import { ServerExperienceRenderer, fetchExperience } from '@contentful/experiences-react'; +import { + NotFoundError, + ServerExperienceRenderer, + fetchExperience, +} from '@contentful/experiences-react'; import { experienceConfig } from '@/lib/experience-config'; @@ -10,16 +14,20 @@ interface PageProps { export default async function ExperiencePage({ params }: PageProps) { const { slug: experienceId } = await params; - const experience = await fetchExperience( - { - spaceId: process.env.SPACE_ID ?? '', - environmentId: process.env.ENVIRONMENT_ID ?? 'master', - experienceId, - }, - { accessToken: process.env.CDA_TOKEN! }, - { config: experienceConfig } - ); - if (!experience) 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 } + ); - 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 2337531..617ff67 100644 --- a/examples/nextjs/app/advanced/[slug]/page.tsx +++ b/examples/nextjs/app/advanced/[slug]/page.tsx @@ -1,6 +1,10 @@ import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; -import { ServerExperienceRenderer, fetchExperience } from '@contentful/experiences-react'; +import { + NotFoundError, + ServerExperienceRenderer, + fetchExperience, +} from '@contentful/experiences-react'; import { detectViewportFromUserAgent } from '@/lib/detect-viewport'; import { advancedExperienceConfig } from '@/lib/experience-config-advanced'; @@ -37,33 +41,37 @@ export default async function AdvancedExperiencePage({ params, searchParams }: P const userAgent = (await headers()).get('user-agent') ?? ''; const initialViewportId = detectViewportFromUserAgent(userAgent); - 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 }, + try { + const experience = await fetchExperience( + { + spaceId: process.env.SPACE_ID ?? '', + environmentId: process.env.ENVIRONMENT_ID ?? 'master', + experienceId, + locale, }, - } - ); - if (!experience) notFound(); + { + 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 ( - - ); + return ( + + ); + } catch (err) { + if (err instanceof NotFoundError) notFound(); + throw err; + } } diff --git a/examples/sveltekit/src/routes/[slug]/+page.server.ts b/examples/sveltekit/src/routes/[slug]/+page.server.ts index 457ac64..f5412f6 100644 --- a/examples/sveltekit/src/routes/[slug]/+page.server.ts +++ b/examples/sveltekit/src/routes/[slug]/+page.server.ts @@ -1,6 +1,6 @@ import { error } from '@sveltejs/kit'; -import { fetchExperience } from '@contentful/experiences-svelte'; +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,22 +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 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 } }, - } - ); - if (!experience) 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 } }, + } + ); - 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/packages/adapter-react/src/index.ts b/packages/adapter-react/src/index.ts index cea9123..42478dc 100644 --- a/packages/adapter-react/src/index.ts +++ b/packages/adapter-react/src/index.ts @@ -90,7 +90,9 @@ export { // ─── Delivery client + fetchExperience ──────────────────────────────────── export { + ContentfulViewDelivery, ContentfulViewDeliveryClient, + NotFoundError, createClient, fetchExperience, } from '@contentful/experiences-client'; diff --git a/packages/adapter-svelte/src/index.ts b/packages/adapter-svelte/src/index.ts index 299339c..e8d166a 100644 --- a/packages/adapter-svelte/src/index.ts +++ b/packages/adapter-svelte/src/index.ts @@ -94,7 +94,9 @@ export { // ─── Delivery client + fetchExperience ──────────────────────────────────── export { + ContentfulViewDelivery, ContentfulViewDeliveryClient, + NotFoundError, createClient, fetchExperience, } from '@contentful/experiences-client'; diff --git a/packages/client/src/fetch-experience.test.ts b/packages/client/src/fetch-experience.test.ts index 07aab3f..583a97e 100644 --- a/packages/client/src/fetch-experience.test.ts +++ b/packages/client/src/fetch-experience.test.ts @@ -99,8 +99,10 @@ describe('fetchExperience', () => { }); describe('return value', () => { - it('returns null when payload has no nodes', async () => { - mockGetExperience.mockResolvedValue({ ...mockPayload, nodes: [] }); + 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, @@ -108,7 +110,12 @@ describe('fetchExperience', () => { resolveOptions ); - expect(result).toBeNull(); + expect(resolveExperience).toHaveBeenCalledWith( + emptyPayload, + resolveOptions.config, + expect.anything() + ); + expect(result).toEqual(mockPlan); }); it('returns resolved PortableRenderPlan on success', async () => { @@ -120,5 +127,14 @@ describe('fetchExperience', () => { 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 index f435af0..ce53d98 100644 --- a/packages/client/src/fetch-experience.ts +++ b/packages/client/src/fetch-experience.ts @@ -28,7 +28,7 @@ export async function fetchExperience( experienceOptions: ExperienceOptions, clientOptions: ClientOptions, resolveOptions: ResolveOptions -): Promise { +): Promise { const { spaceId, environmentId, experienceId, locale } = experienceOptions; const { config, context } = resolveOptions; @@ -42,7 +42,5 @@ export async function fetchExperience( locale, })) as unknown as ExperiencePayload; - if (!payload?.nodes?.length) return null; - return resolveExperience(payload, config, { experience: context }); } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 883fd58..c37aeeb 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,4 +1,11 @@ -export { ContentfulViewDeliveryClient } from '@contentful/experience-delivery'; +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'; From da77fc46c27fdbe721bffa5e8d067766c0e7eb39 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 13 Jul 2026 15:24:39 -0600 Subject: [PATCH 14/14] docs: update READMEs and AGENTS for new fetchExperience signature Covers the three review-driven changes: - 3 positional args (experienceOptions, clientOptions, resolveOptions) - createClient helper + host string in place of preview boolean - Empty-nodes payloads pass through; NotFoundError re-exported for the missing-experience case Adds design-decision entries in AGENTS.md capturing the *why* behind each choice so future contributors don't re-litigate them. Co-Authored-By: Claude Opus 4.7 --- AGENTS.md | 37 +++++++- README.md | 151 +++++++++++++++++++++---------- examples/nextjs/README.md | 76 ++++++++-------- examples/sveltekit/README.md | 2 +- packages/adapter-react/README.md | 28 ++++-- packages/client/README.md | 80 ++++++++++++---- 6 files changed, 256 insertions(+), 118 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9dc8064..7c8cbb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,10 +61,12 @@ Future framework adapters slot in under the same naming pattern: `packages/adapt The customer pipeline is three steps: ``` -fetchExperience(options) → +fetchExperience(experienceOptions, clientOptions, resolveOptions) → ``` -`fetchExperience` is the **single async entry** for most customers — it fetches the payload from the Experience Delivery API and calls `resolveExperience` internally. Customers who want to manage the delivery client themselves can import `ContentfulViewDeliveryClient` from the adapter and call `resolveExperience` directly. +`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**: @@ -90,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. diff --git a/README.md b/README.md index 958483f..70e7ae9 100644 --- a/README.md +++ b/README.md @@ -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,25 +62,34 @@ export const experienceConfig: Config = { components, templates }; ```tsx // app/[slug]/page.tsx (Next.js App Router) -import { fetchExperience, ServerExperienceRenderer } from '@contentful/experiences-react'; +import { notFound } from 'next/navigation'; +import { + fetchExperience, + NotFoundError, + ServerExperienceRenderer, +} from '@contentful/experiences-react'; import { experienceConfig } from '@/lib/experience-config'; export default async function ExperiencePage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; - const experience = await fetchExperience({ - accessToken: process.env.CDA_TOKEN!, - spaceId: process.env.SPACE_ID!, - environmentId: 'master', - experienceId: slug, - config: 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; + } } ``` `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). --- @@ -94,7 +103,12 @@ 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 { fetchExperience, ServerExperienceRenderer } from '@contentful/experiences-react'; +import { notFound } from 'next/navigation'; +import { + fetchExperience, + NotFoundError, + ServerExperienceRenderer, +} from '@contentful/experiences-react'; import { detectViewportFromUserAgent } from '@/lib/detect-viewport'; import { advancedExperienceConfig } from '@/lib/experience-config-advanced'; @@ -115,26 +129,32 @@ export default async function AdvancedPage({ const userAgent = (await headers()).get('user-agent') ?? ''; const initialViewportId = detectViewportFromUserAgent(userAgent); - // 2. Per-page metadata flows into every resolveData hook via context. - const experience = await fetchExperience({ - accessToken: process.env.CDA_TOKEN!, - preview: previewMode, - spaceId: process.env.SPACE_ID!, - environmentId: 'master', - experienceId, - locale, - context: { isPreview: previewMode, metadata: { slug: experienceId, locale } }, - config: advancedExperienceConfig, - }); - - 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; + } } ``` @@ -163,29 +183,62 @@ button: defineComponent({ ## API reference -### `fetchExperience(options)` +### `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 | null` (null when the payload has no nodes). +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`. -Pass either an `accessToken` (client created internally) or a pre-created `ContentfulViewDeliveryClient`: +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({ +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!, - preview: false, // true → fetches from the preview API endpoint - spaceId: '...', - environmentId: 'master', - experienceId: slug, - config: experienceConfig, - // locale?: string - // context?: Partial — flows into resolveData hooks + host: 'https://preview.xdn.contentful.com', // optional — overrides the default endpoint + // headers, timeoutInSeconds, maxRetries, fetch, logging, etc. all pass through }); - -// Pre-created client — useful when you manage the client yourself -import { ContentfulViewDeliveryClient } from '@contentful/experiences-react'; -const client = new ContentfulViewDeliveryClient({ token: process.env.CDA_TOKEN! }); -const plan = await fetchExperience({ client, spaceId, environmentId, experienceId, config }); ``` ### `resolveExperience(payload, config, opts?)` @@ -204,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. | diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md index f4222e1..1b1f24d 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -6,7 +6,7 @@ A Next.js 15 App Router app demonstrating `@contentful/experiences-react` render - **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** — `fetchExperience` → ``. Preview mode, viewport seeding, and metadata are all optional advanced features; 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 @@ -27,25 +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: `fetchExperience` → ``. 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 experience = await fetchExperience({ - accessToken, - spaceId, - environmentId, - experienceId, - config: experienceConfig, -}); -if (!experience) notFound(); -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 @@ -55,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 @@ -162,13 +166,11 @@ priceTag: defineComponent({ The route calls `fetchExperience` once — it handles the API call and resolution in one step: ```ts -const experience = await fetchExperience({ - accessToken: process.env.CDA_TOKEN!, - spaceId: process.env.SPACE_ID!, - environmentId: 'master', - experienceId: slug, - config: 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 @@ -176,28 +178,28 @@ time, so client-side viewport changes never re-trigger `resolveData`. #### Optional `context` -The `context` option passes per-render context into every component's `resolveData` -hook (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**: `{ isPreview: true }` — `MissingComponent` renders a visible - red box; some customer components might branch on this. Pass `preview: true` - at the top level to also hit the preview API endpoint. + 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 fetchExperience({ - accessToken: process.env.CDA_TOKEN!, - preview: previewMode, - spaceId: process.env.SPACE_ID!, - environmentId: 'master', - experienceId: slug, - locale, - context: { isPreview: previewMode, metadata: { slug, locale } }, - config: experienceConfig, -}); +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/sveltekit/README.md b/examples/sveltekit/README.md index 5524583..67810df 100644 --- a/examples/sveltekit/README.md +++ b/examples/sveltekit/README.md @@ -53,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` → ``. +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/packages/adapter-react/README.md b/packages/adapter-react/README.md index e0409c4..d77fed4 100644 --- a/packages/adapter-react/README.md +++ b/packages/adapter-react/README.md @@ -24,9 +24,12 @@ defineTemplate(config); // Same shape, for page-level template wrappers ### Fetching ```ts -fetchExperience(options) // Async — fetches from Experience Delivery API + resolves in one call +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 -type FetchExperienceOptions +NotFoundError // Thrown when the Experience ID doesn't exist +ContentfulViewDelivery // Full error namespace from the delivery client +type ExperienceOptions, ClientOptions, ResolveOptions, CreateClientOptions ``` ### Resolver @@ -67,10 +70,12 @@ getValueForViewport, getViewportIndex, resolveDesignProperties, toCssMediaQuery ## Quick reference ```tsx +import { notFound } from 'next/navigation'; import { defineComponent, defineTemplate, fetchExperience, + NotFoundError, ServerExperienceRenderer, type Config, type Components, @@ -90,14 +95,17 @@ const components: Components = { const experienceConfig: Config = { components }; // In a server component: -const experience = await fetchExperience({ - accessToken: process.env.CDA_TOKEN!, - spaceId: process.env.SPACE_ID!, - environmentId: 'master', - experienceId: slug, - config: 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/client/README.md b/packages/client/README.md index 749d95a..eea2e50 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -8,40 +8,81 @@ Isolates `@contentful/experience-delivery` — the generated experience delivery ## What's in here -### `fetchExperience(options)` +### `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({ - accessToken: process.env.CDA_TOKEN!, - preview: false, // true → hits the preview API endpoint - spaceId: '...', - environmentId: 'master', - experienceId: slug, - config: experienceConfig, - locale: 'en-US', // optional - context: { - // optional — flows into resolveData hooks as ctx.experience - isPreview: false, - metadata: { slug }, +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 { ContentfulViewDeliveryClient } from '@contentful/experiences-react'; -const client = new ContentfulViewDeliveryClient({ token: process.env.CDA_TOKEN! }); -const plan = await fetchExperience({ client, spaceId, environmentId, experienceId, config }); +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; +} ``` -Returns `PortableRenderPlan | null`. Returns `null` when the fetched payload has no nodes. +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). +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'; @@ -69,3 +110,4 @@ const client = new ContentfulViewDeliveryClient({ - 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.