diff --git a/.nxignore b/.nxignore
index 660936a..261d783 100644
--- a/.nxignore
+++ b/.nxignore
@@ -1,5 +1,7 @@
-# Examples are local testbeds for the SDK packages, not part of the
-# Nx project graph. They stay linked via npm workspaces (so they consume
-# the local SDK packages by symlink) but Nx commands like `nx run-many`,
-# `nx graph`, `nx affected`, and `nx release` ignore them entirely.
+# Examples and test-apps are local testbeds for the SDK packages, not part
+# of the Nx project graph. They stay linked via npm workspaces (so they
+# consume the local SDK packages by symlink) but Nx commands like
+# `nx run-many`, `nx graph`, `nx affected`, and `nx release` ignore them
+# entirely.
examples
+test-apps
diff --git a/AGENTS.md b/AGENTS.md
index b1e0ca9..4c5f762 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,11 +35,16 @@ experiences/
│ ├── client/ # @contentful/experiences-client (internal)
│ ├── adapter-react/ # @contentful/experiences-react (customer-facing)
│ └── adapter-svelte/ # @contentful/experiences-svelte (customer-facing)
-└── examples/
- ├── nextjs/ # Next.js 15 example app
- └── sveltekit/ # SvelteKit 2 example app (1:1 parity with nextjs)
+├── examples/ # Customer-facing example apps
+│ ├── nextjs/ # Next.js 15 example (external developers run this)
+│ └── sveltekit/ # SvelteKit 2 example (1:1 parity with nextjs)
+└── test-apps/ # Internal testing
+ ├── nextjs/ # Next.js scratchpad
+ └── sveltekit/ # SvelteKit scratchpad
```
+**`examples/` vs `test-apps/`.** `examples/` is the stable, external-facing surface — every commit to it should keep the customer-facing "clone + bootstrap + run" flow working. `test-apps/` is where you experiment: try new component patterns, break things, prototype features. Don't reach into `examples/` when you just need a place to poke — copy your changes into `test-apps/` first, iterate there, then port back deliberately.
+
### Package roles
| Folder | npm name | Audience |
@@ -357,11 +362,19 @@ npx nx run-many -t build --skip-nx-cache
### Run the example app
```sh
-cd examples/nextjs
-cp .env.example .env.local # fill in SPACE_ID + CDA_TOKEN
-npm run dev # http://localhost:3000/
+# 1. Seed the demo Experience into your target space (one-time).
+cd examples/scripts
+cp .env.example .env # fill in SPACE_ID, ENVIRONMENT_ID, CMA_TOKEN
+npm run bootstrap # prints experienceId (default: `landing`)
+
+# 2. Run the app.
+cd ../nextjs
+cp .env.example .env.local # fill in SPACE_ID, ENVIRONMENT_ID, CDA_TOKEN
+npm run dev # http://localhost:3000/landing
```
+The bootstrap script (`examples/scripts/bootstrap-example.ts`) provisions everything the demo Experience references — ContentType, entries, assets, design tokens, ComponentTypes, Template, DataAssemblies, Experience — via the experiences management API (currently `contentful-management@12.6.0-dev.4`). Idempotent per resource; safe to re-run against a half-seeded env. See `examples/scripts/README.md` for details.
+
### Add a new framework adapter
`packages/adapter-svelte` is the canonical example of "framework that isn't React" — copy from there for non-React frameworks (different build tool, peer dep, etc.); copy from `packages/adapter-react` for "framework like React" (JSX-ish + tsup).
diff --git a/README.md b/README.md
index a9f9f47..314577f 100644
--- a/README.md
+++ b/README.md
@@ -335,16 +335,26 @@ Runnable apps for both frameworks live in [`examples/`](./examples). They regist
| [`examples/nextjs`](./examples/nextjs) | Next.js 15 (App Router) | Simple + advanced routes (preview, UA→viewport, async `resolveData`), design tokens, styling hooks |
| [`examples/sveltekit`](./examples/sveltekit) | SvelteKit 2 + Svelte 5 | 1:1 parity with the Next.js app; hydration-safe viewport seeding via `+page.server.ts` |
+Both examples render the same demo Experience. To run them you first seed that Experience into your Contentful space with the one-time bootstrap script — the script uses the experiences management API to provision the ContentType, entries, assets, design tokens, ComponentTypes, template, DataAssemblies, and the Experience itself.
+
```sh
npm install --ignore-scripts
-npm run build # build the SDK packages
+npm run build # build the SDK packages
+
+# 1. Seed the demo Experience into your Contentful space (one-time).
+cd examples/scripts
+cp .env.example .env # fill in SPACE_ID, ENVIRONMENT_ID, CMA_TOKEN
+npm run bootstrap # prints the experienceId at the end (default: `landing`)
-cd examples/nextjs # or examples/sveltekit
-cp .env.example .env.local # sveltekit uses .env; fill in SPACE_ID + CDA_TOKEN
+# 2. Run one of the example apps against the seeded space.
+cd ../nextjs # or ../sveltekit
+cp .env.example .env.local # sveltekit uses .env; fill in SPACE_ID, ENVIRONMENT_ID, CDA_TOKEN
npm run dev
```
-Then visit `/`. See each example's README for its file map and route-by-route walkthrough.
+Then visit `/landing` (or whichever experienceId the bootstrap printed). See each example's README for its file map and route-by-route walkthrough.
+
+**Tokens.** `CMA_TOKEN` is a Personal Access Token that only the bootstrap script sees. `CDA_TOKEN` is a Content Delivery API token — this is what the running app uses at runtime. `CPA_TOKEN` is a Content Preview API token, only needed if you want to exercise `?preview=true` routes; see each example's README for details.
---
diff --git a/examples/nextjs/.env.example b/examples/nextjs/.env.example
index 2bddab2..5c18231 100644
--- a/examples/nextjs/.env.example
+++ b/examples/nextjs/.env.example
@@ -1,5 +1,16 @@
# Copy this file to .env.local and fill in real values from a Contentful space.
+#
+# Bootstrap the demo Experience into your space first with:
+# cd examples/scripts && npm run bootstrap
+# The script prints the experienceId to hit at the end (default: `landing`).
SPACE_ID=
ENVIRONMENT_ID=master
+
+# Content Delivery API token — used by every route by default.
CDA_TOKEN=
+
+# Content Preview API token — required only for the /advanced route with
+# ?preview=true (that path hits preview.xdn.contentful.com, which rejects
+# CDA tokens). Leave blank if you don't need preview mode.
+CPA_TOKEN=
diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md
index 949ecea..45225f2 100644
--- a/examples/nextjs/README.md
+++ b/examples/nextjs/README.md
@@ -13,26 +13,67 @@ A Next.js 15 App Router app demonstrating `@contentful/experiences-react` render
## Run it
+The example is a real integration against Contentful, not a mock. You need:
+
+1. **A Contentful space** with the demo content model + Experience seeded into it (a one-time step below), and
+2. **Tokens** for the paths you want to hit — different Contentful APIs use different tokens.
+
+The [`examples/scripts/bootstrap-example.ts`](../scripts/bootstrap-example.ts) script does the seeding via the management API. See [`examples/scripts/README.md`](../scripts/README.md) for what it provisions.
+
+### 1. Seed the demo Experience (one-time)
+
```sh
# From the repo root:
-npm install --ignore-scripts
-npm run build # builds the SDK packages
+npm install
+npm run build # build the SDK packages
+
+cd examples/scripts
+cp .env.example .env # fill in SPACE_ID, ENVIRONMENT_ID, CMA_TOKEN
+npm run bootstrap # prints the experienceId at the end (default: `landing`)
+```
-cd examples/nextjs
-cp .env.example .env.local # fill in SPACE_ID + CDA_TOKEN
+### 2a. Run the basic route (`/landing`)
+
+```sh
+cd ../nextjs
+cp .env.example .env.local # fill in SPACE_ID, ENVIRONMENT_ID, CDA_TOKEN
npm run dev
```
-Then visit `http://localhost:3000/`. The slug becomes the Experience ID passed to `client.view.getExperience`.
+Visit `http://localhost:3000/landing`. This route is the minimal three-line integration described below — `fetchExperience` → ``. It reads from the Content Delivery API using `CDA_TOKEN`.
+
+### 2b. Run the advanced route (`/advanced/landing?preview=true`)
+
+The advanced route can be reached at `http://localhost:3000/advanced/landing` (no query params) using the same `CDA_TOKEN` — you'll get the enrichment + viewport-seeding demo.
+
+To exercise **preview mode** as well (the "Advanced demo (preview)" button on the index page), you also need a **Content Preview API token** — preview requests hit `preview.xdn.contentful.com`.
+
+Add it to `.env.local`:
+
+```
+CPA_TOKEN=... # Content Preview API token, from Settings → API keys in your space
+```
+
+Then visit `http://localhost:3000/advanced/landing?preview=true&locale=en-US`.
+
+### Tokens summary
+
+| Token | API | Used by | Required? |
+| ----------- | ------------------ | ------------------------------------------------ | -------------------------------------- |
+| `CMA_TOKEN` | Content Management | The bootstrap script (one-time seed) | Yes, to run bootstrap |
+| `CDA_TOKEN` | Content Delivery | The example app for `/landing` and `/advanced/*` | Yes, to run the app |
+| `CPA_TOKEN` | Content Preview | The example app when `?preview=true` | Only for preview mode on `/advanced/*` |
## Two routes, same data
The example ships two side-by-side routes so you can see what each SDK option gives you. They render the same Experience id; only the SDK setup changes.
-| Route | Page | Config | Demonstrates |
-| ------------------ | ---------------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
-| `/[slug]` | [`app/[slug]/page.tsx`](./app/[slug]/page.tsx) | `experience-config.tsx` | The minimum: `fetchExperience` into `` 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`, User-Agent to `initialViewportId`, async `resolveData` with external fetch. |
+| Route | Try it locally | Config | Demonstrates |
+| ------------------ | ------------------------------------------------------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `/[slug]` | `http://localhost:3000/landing` | `experience-config.tsx` | The minimum: `fetchExperience` into `` with `NotFoundError` routed to Next's `notFound()`. |
+| `/advanced/[slug]` | `http://localhost:3000/advanced/landing?preview=true&locale=en-US` | `experience-config-advanced.tsx` | Preview mode via `?preview=true` (needs `CPA_TOKEN`), User-Agent to `initialViewportId`, async `resolveData`. |
+
+Source: [`app/[slug]/page.tsx`](./app/[slug]/page.tsx), [`app/advanced/[slug]/page.tsx`](./app/advanced/[slug]/page.tsx).
The minimal `[slug]/page.tsx`:
@@ -227,7 +268,3 @@ export const experienceConfig: Config = { components, templates };
If the payload references a template id that isn't registered, the renderer
warns once and renders the nodes unwrapped, the same graceful-degradation
behavior as missing components.
-
-## Where the live preview / editor support fits
-
-Live preview (postMessage from the Contentful editor iframe) lands in a separate increment with a client-component wrapper that uses `ClientExperienceRenderer` and a `useMessagingClient`-style hook. SSR and interactive editor mode are mutually exclusive: the editor mode requires `'use client'` so the message listener can attach.
diff --git a/examples/nextjs/app/advanced/[slug]/page.tsx b/examples/nextjs/app/advanced/[slug]/page.tsx
index 617ff67..1763dd5 100644
--- a/examples/nextjs/app/advanced/[slug]/page.tsx
+++ b/examples/nextjs/app/advanced/[slug]/page.tsx
@@ -50,7 +50,12 @@ export default async function AdvancedExperiencePage({ params, searchParams }: P
locale,
},
{
- accessToken: process.env.CDA_TOKEN!,
+ // Preview mode reads from the CPA endpoint, which needs a Content
+ // Preview token — the CDA token is rejected by that host. If preview
+ // mode is on but CPA_TOKEN is unset, we still call preview but the
+ // request will 401; document this in .env.example.
+ accessToken:
+ previewMode && process.env.CPA_TOKEN ? process.env.CPA_TOKEN : process.env.CDA_TOKEN!,
host: previewMode ? 'https://preview.xdn.contentful.com' : 'https://xdn.contentful.com',
},
{
diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx
index a019c68..ded731c 100644
--- a/examples/nextjs/app/page.tsx
+++ b/examples/nextjs/app/page.tsx
@@ -69,16 +69,17 @@ export default function HomePage() {
-
+
Simple demo
-
+
Advanced demo (preview)
- Replace demo in either URL with a real Experience id from your space.
+ landing is the id the bootstrap script (examples/scripts) seeds by
+ default. Replace it in the URL with any other Experience id from your space.
);
diff --git a/examples/nextjs/components/Card.tsx b/examples/nextjs/components/Card.tsx
new file mode 100644
index 0000000..baa1115
--- /dev/null
+++ b/examples/nextjs/components/Card.tsx
@@ -0,0 +1,70 @@
+'use client';
+
+import type { CSSProperties } from 'react';
+
+import { toCss, useDesignValues } from '@contentful/experiences-react';
+
+export interface CardProps {
+ title?: string;
+ teaser?: string;
+ ctaLabel?: string;
+ ctaUrl?: string;
+ image?: string;
+}
+
+/**
+ * Compact card: image + title + teaser + CTA. Content properties come from
+ * a `Card from Promotion` DataAssembly binding.
+ */
+export function Card({ title, teaser, ctaLabel, ctaUrl, image }: CardProps) {
+ const design = useDesignValues<{
+ backgroundColor?: string;
+ color?: string;
+ }>();
+
+ const style: CSSProperties = {
+ display: 'flex',
+ flexDirection: 'column',
+ borderRadius: '0.5rem',
+ overflow: 'hidden',
+ boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
+ ...toCss(design),
+ };
+
+ return (
+
+ {image && (
+
+ )}
+
+ {title &&
{title} }
+ {teaser &&
{teaser}
}
+ {ctaLabel && ctaUrl && (
+
+ {ctaLabel}
+
+ )}
+
+
+ );
+}
diff --git a/examples/nextjs/components/HeroPlain.tsx b/examples/nextjs/components/HeroPlain.tsx
new file mode 100644
index 0000000..143ab9a
--- /dev/null
+++ b/examples/nextjs/components/HeroPlain.tsx
@@ -0,0 +1,65 @@
+'use client';
+
+import type { CSSProperties } from 'react';
+
+import { toCss, useDesignValues } from '@contentful/experiences-react';
+
+export interface HeroPlainProps {
+ title?: string;
+ body?: unknown;
+ ctaLabel?: string;
+ ctaUrl?: string;
+ image?: string;
+}
+
+/**
+ * Composed hero: title + optional body + CTA + hero image. All content
+ * properties come from a `Hero from Promotion` DataAssembly binding — this
+ * component just lays them out.
+ */
+export function HeroPlain({ title, ctaLabel, ctaUrl, image }: HeroPlainProps) {
+ const design = useDesignValues<{
+ backgroundColor?: string;
+ color?: string;
+ }>();
+
+ const style: CSSProperties = {
+ display: 'grid',
+ gridTemplateColumns: image ? '1fr 1fr' : '1fr',
+ alignItems: 'center',
+ gap: '2rem',
+ padding: '4rem 2rem',
+ ...toCss(design),
+ };
+
+ return (
+
+
+ {title &&
{title} }
+ {ctaLabel && ctaUrl && (
+
+ {ctaLabel}
+
+ )}
+
+ {image && (
+
+ )}
+
+ );
+}
diff --git a/examples/nextjs/components/Page.tsx b/examples/nextjs/components/Page.tsx
index 4ca31fc..aa19947 100644
--- a/examples/nextjs/components/Page.tsx
+++ b/examples/nextjs/components/Page.tsx
@@ -5,15 +5,11 @@ export interface PageProps {
children?: ReactNode;
}
-/** Page-level template: wraps all top-level nodes in the outer page chrome. */
export function Page({ children }: PageProps) {
const wrapper: CSSProperties = {
- maxWidth: 1024,
- margin: '0 auto',
- padding: '48px 24px',
display: 'flex',
flexDirection: 'column',
- gap: 48,
+ gap: 16,
};
return {children} ;
}
diff --git a/examples/nextjs/lib/design-tokens.ts b/examples/nextjs/lib/design-tokens.ts
index e07647e..82c005a 100644
--- a/examples/nextjs/lib/design-tokens.ts
+++ b/examples/nextjs/lib/design-tokens.ts
@@ -10,6 +10,8 @@ export const designTokens: Record = {
'color.none': 'transparent', // resolves "no background" to a real value
'color.white': '#ffffff',
'color.text': '#1f2937',
+ 'color.primary': '#0f172a', // hero background
+ 'color.primaryText': '#f8fafc', // hero foreground
'fontSize.sm': '14px',
'fontSize.md': '16px',
diff --git a/examples/nextjs/lib/experience-config-advanced.tsx b/examples/nextjs/lib/experience-config-advanced.tsx
index 0a67cb1..00ab091 100644
--- a/examples/nextjs/lib/experience-config-advanced.tsx
+++ b/examples/nextjs/lib/experience-config-advanced.tsx
@@ -1,49 +1,71 @@
/**
- * Advanced config: async `resolveData` on `Button` (fake fetch + a localized
- * URL from `experience.metadata`), on top of the simple `./experience-config`.
+ * Advanced config: async `resolveData` on `card` (fake enrichment fetch +
+ * a metadata-aware CTA rewrite), plus preview-mode + design-token wiring
+ * — layered on top of the simple `./experience-config`.
+ *
+ * Compared to `./experience-config`, the differences are:
+ * 1. `card` is registered with `defineComponent({ resolveData, component })`
+ * so we can demonstrate async enrichment (a stand-in for "fetch a badge
+ * from a catalog service") and use per-page metadata (locale, slug) to
+ * rewrite the CTA URL.
+ * 2. Everything else — primitives + hero-plain — is identical to the
+ * simple config.
*/
import {
defineComponent,
type Components,
type Config,
+ type ResolveToken,
type Templates,
} from '@contentful/experiences-react';
-import { Button, type ButtonProps } from '@/components/Button';
+import { Button } from '@/components/Button';
+import { Card, type CardProps } from '@/components/Card';
import { Heading } from '@/components/Heading';
+import { HeroPlain } from '@/components/HeroPlain';
import { Image } from '@/components/Image';
import { Page } from '@/components/Page';
import { RichText } from '@/components/RichText';
import { Section } from '@/components/Section';
import { Text } from '@/components/Text';
+import { designTokens } from '@/lib/design-tokens';
-// Stand-in for an async enrichment fetch; resolvers run in parallel per node.
-async function fetchButtonEnrichment(label: string): Promise<{ formattedLabel: string }> {
+// Stand-in for an async enrichment fetch — a catalog lookup, a personalization
+// service call, or anything else you'd want to do off the critical render path.
+// Resolvers run in parallel across nodes, so slow ones don't block others.
+async function fetchCardEnrichment(title: string): Promise<{ badge: string }> {
await new Promise((resolve) => setTimeout(resolve, 50));
- return { formattedLabel: label.toUpperCase() };
+ return { badge: `Featured: ${title}` };
}
const components: Components = {
Section,
Heading,
RichText,
- Image,
Text,
+ Button,
+ Image,
+ 'hero-plain': HeroPlain,
- // defineComponent narrows the resolveData ctx + return type to ButtonProps.
- Button: defineComponent({
+ // defineComponent narrows the resolveData ctx + return type to CardProps.
+ card: defineComponent({
resolveData: async ({ content, experience }) => {
- const rawLabel = (content.label as string) ?? 'Button';
- const { formattedLabel } = await fetchButtonEnrichment(rawLabel);
+ const rawTitle = (content.title as string) ?? 'Untitled';
+ const { badge } = await fetchCardEnrichment(rawTitle);
const locale = (experience.metadata.locale as string) ?? 'en-US';
const slug = (experience.metadata.slug as string) ?? '';
+ // Rewrite the CTA to a locale-aware localized route (fake — for demo).
+ const originalUrl = (content.ctaUrl as string) ?? '';
+ const ctaUrl = originalUrl.startsWith('http')
+ ? originalUrl
+ : `/${locale}/${slug}${originalUrl}`;
return {
- label: formattedLabel,
- url: `/${locale}/${slug}`,
+ title: `${badge}`,
+ ctaUrl,
};
},
- component: Button,
+ component: Card,
}),
};
@@ -51,4 +73,6 @@ const templates: Templates = {
page: { component: Page, defaults: { title: 'Featured (advanced)' } },
};
-export const advancedExperienceConfig: Config = { components, templates };
+const resolveToken: ResolveToken = (token) => designTokens[token.value];
+
+export const advancedExperienceConfig: Config = { components, templates, resolveToken };
diff --git a/examples/nextjs/lib/experience-config.tsx b/examples/nextjs/lib/experience-config.tsx
index dcbf8e4..68f193d 100644
--- a/examples/nextjs/lib/experience-config.tsx
+++ b/examples/nextjs/lib/experience-config.tsx
@@ -12,7 +12,9 @@ import {
} from '@contentful/experiences-react';
import { Button } from '@/components/Button';
+import { Card } from '@/components/Card';
import { Heading } from '@/components/Heading';
+import { HeroPlain } from '@/components/HeroPlain';
import { Image } from '@/components/Image';
import { Page } from '@/components/Page';
import { RichText } from '@/components/RichText';
@@ -27,6 +29,8 @@ const components: Components = {
Text,
Button,
Image,
+ 'hero-plain': HeroPlain,
+ card: Card,
};
const templates: Templates = {
diff --git a/examples/scripts/.env.example b/examples/scripts/.env.example
new file mode 100644
index 0000000..06d0c8b
--- /dev/null
+++ b/examples/scripts/.env.example
@@ -0,0 +1,9 @@
+# Copy this file to .env (in this directory) and fill in real values.
+#
+# CMA_TOKEN needs write access to the space + environment above. Create a
+# Personal Access Token (CFPAT) at https://app.contentful.com/account/profile/cma_tokens
+# — make sure you're in the org that owns the target space.
+
+SPACE_ID=
+ENVIRONMENT_ID=master
+CMA_TOKEN=
diff --git a/examples/scripts/README.md b/examples/scripts/README.md
new file mode 100644
index 0000000..e382e46
--- /dev/null
+++ b/examples/scripts/README.md
@@ -0,0 +1,52 @@
+# examples/scripts
+
+One-time setup scripts for the customer-facing example apps in [`../nextjs`](../nextjs) and [`../sveltekit`](../sveltekit).
+
+## bootstrap-example.ts
+
+Seeds the demo Experience into a Contentful space + environment via the experiences management API. After it succeeds, the example apps can fetch and render the seeded Experience by id.
+
+### Run it
+
+```sh
+cp .env.example .env
+# Fill in:
+# SPACE_ID — id of the space you're seeding into
+# ENVIRONMENT_ID — id of the environment (usually `master`)
+# CMA_TOKEN — Personal Access Token (CFPAT-...) with write access
+# to that space. Create one at
+# https://app.contentful.com/account/profile/cma_tokens
+# from within the org that owns the space.
+
+npm install # from the repo root, if you haven't already
+npm run bootstrap
+```
+
+The script prints the resulting experienceId at the end — paste it into the example app's `.env.local` (or hit `/landing` directly if you left the fixture unchanged).
+
+### What it seeds
+
+The demo is a minimal `landing` Experience — one hero + two cards — that exercises the ExO composition pattern end-to-end. It provisions:
+
+| Step | Resource type | Count | Notes |
+| ---- | ------------- | ----- | --------------------------------------------------------------------------------------- |
+| 1 | ContentType | 1 | `promotion` (title, teaser, body, ctaLabel, ctaUrl, image) |
+| 2 | Asset | 3 | hero background + 2 card images, read from `fixture/assets/` and uploaded to your space |
+| 3 | Entry | 3 | 3 `promotion` entries (hero + 2 cards) |
+| 4 | DesignToken | 15 | color/size/fontSize/fontWeight tokens referenced by ComponentTypes |
+| 5 | ComponentType | 8 | Section, Heading, RichText, Text, Button, Image (primitives) + hero-plain + card |
+| 6 | Template | 1 | `page` (passthrough) |
+| 7 | DataAssembly | 2 | `Hero from Promotion` + `Card from Promotion` (map entry fields to CT props) |
+| 8 | (linkage) | 2 | Append DA links to hero-plain and card ComponentTypes, republish |
+| 9 | Experience | 1 | `landing` — hero + Section(card, card) |
+
+Each step is idempotent: if a resource with the fixture's id already exists, that step is skipped. Re-running against a half-seeded env picks up where a previous run left off.
+
+### Fixture
+
+The concrete data the script provisions lives in [`fixture/`](./fixture) — one TypeScript module per resource kind. If you want a different demo, edit those files rather than the bootstrap. `fixture/types.ts` documents each shape; every fixture module exports typed data the bootstrap imports directly.
+
+### Known limitations
+
+- **CMA is pinned to `12.6.0-dev.4`** — this is a dev build that exposes the ExO plain client (component types, templates, data assemblies, experiences). Newer stable versions of `contentful-management` don't ship these APIs yet.
+- **`/design_tokens` is called via raw `fetch()`** — the CMA dev build's plain client doesn't cover that endpoint yet. Customers setting up Experience Orchestration are encouraged to use the [Design System Import CLI tool](https://github.com/contentful/experience-design-system-sdk-public)
diff --git a/examples/scripts/bootstrap-example.ts b/examples/scripts/bootstrap-example.ts
new file mode 100644
index 0000000..19a388b
--- /dev/null
+++ b/examples/scripts/bootstrap-example.ts
@@ -0,0 +1,680 @@
+/**
+ * Bootstrap the ExO example into a caller-specified Contentful space/environment.
+ *
+ * Reads SPACE_ID / ENVIRONMENT_ID / CMA_TOKEN from env (dotenv-friendly — expects
+ * an .env in the CWD). Provisions the fixture in order:
+ *
+ * 1. ContentTypes (create + publish)
+ * 2. Assets (upload + processForAllLocales + publish)
+ * 3. Entries (create + publish, with tempId asset refs resolved)
+ * 4. Design tokens (PUT via raw HTTP — plain client doesn't cover this yet)
+ * 5. ComponentTypes (create + publish, referencing tokens by id)
+ * 6. Template (create + publish)
+ * 7. DataAssemblies (create + publish, with cross-fixture ids resolved)
+ * 8. Link DAs to CTs (append DA links to composed CTs, republish CTs)
+ * 9. Experience (create + publish, with dataAssembly + entry refs resolved)
+ *
+ * Idempotent per resource: if a resource with the fixture's id already exists,
+ * skip it. Re-running against a half-seeded env picks up where a previous run
+ * left off.
+ *
+ * Prints the resulting experienceId at the end.
+ *
+ * Run with:
+ * npm run bootstrap
+ */
+/* eslint-disable no-console */
+import { createClient, type PlainClientAPI } from 'contentful-management';
+import { readFileSync } from 'node:fs';
+import { dirname, resolve as resolvePath } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+import {
+ contentTypes,
+ assets,
+ entries,
+ designTokens,
+ componentTypes,
+ templates,
+ dataAssemblies,
+ dataAssemblyComponentTypeLinks,
+ experience,
+ type AssetFixture,
+ type EntryFixture,
+ type ContentTypeFixture,
+ type ComponentTypeFixture,
+ type TemplateFixture,
+ type DataAssemblyFixture,
+ type DesignTokenFixture,
+ type ExperienceFixture,
+ type ExperienceNode,
+ type TempId,
+} from './fixture/index.js';
+
+// --- Env ---------------------------------------------------------------------
+
+// Load a local .env if present, without adding dotenv as a hard dep.
+try {
+ const envPath = process.env.DOTENV_PATH || '.env';
+ const contents = readFileSync(envPath, 'utf8');
+ for (const line of contents.split('\n')) {
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
+ if (m && !process.env[m[1]!]) {
+ process.env[m[1]!] = m[2]!.replace(/^["']|["']$/g, '');
+ }
+ }
+} catch {
+ // no .env — assume env is set elsewhere
+}
+
+const { SPACE_ID, ENVIRONMENT_ID, CMA_TOKEN } = process.env;
+if (!SPACE_ID || !ENVIRONMENT_ID || !CMA_TOKEN) {
+ console.error(
+ 'Missing env — set SPACE_ID, ENVIRONMENT_ID, and CMA_TOKEN (either in the shell or in a local .env).'
+ );
+ process.exit(1);
+}
+
+const cma: PlainClientAPI = createClient(
+ { accessToken: CMA_TOKEN },
+ { type: 'plain', defaults: { spaceId: SPACE_ID, environmentId: ENVIRONMENT_ID } }
+);
+
+// --- Small helpers -----------------------------------------------------------
+
+const log = (msg: string) => console.log(msg);
+const step = (label: string) => log(`\n▸ ${label}`);
+
+// contentful-sdk-core wraps API errors as new Error() with .name = data.sys.id
+// (e.g. 'NotFound') and JSON-stringifies the payload into .message. Neither
+// .status nor .response is available. Check name; fall back to parsing the
+// JSON message for a status field so we don't miss anything.
+const isNotFound = (err: unknown): boolean => {
+ const e = err as { name?: string; message?: string };
+ if (e?.name === 'NotFound') return true;
+ if (typeof e?.message === 'string') {
+ try {
+ const parsed = JSON.parse(e.message) as { status?: number };
+ if (parsed?.status === 404) return true;
+ } catch {
+ /* not JSON — ignore */
+ }
+ }
+ return false;
+};
+
+// Registry of tempId → real Contentful sys.id, built as resources are created.
+const idMap = new Map();
+const remember = (tempId: TempId, realId: string) => idMap.set(tempId, realId);
+const resolveId = (tempId: TempId): string => {
+ const id = idMap.get(tempId);
+ if (!id) {
+ throw new Error(`Unresolved tempId: ${tempId} (no resource with that tempId was created)`);
+ }
+ return id;
+};
+
+// A stable Contentful sys.id derived from a fixture tempId — keeps the seed
+// idempotent since we can .get() by known id before .create()ing.
+const stableId = (tempId: TempId) => `demo-${tempId.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}`;
+
+// --- URN builders ------------------------------------------------------------
+
+const componentTypeUrn = (id: string) =>
+ `crn:contentful:::experience:spaces/$self/environments/$self/componentTypes/${id}`;
+const templateUrn = (id: string) =>
+ `crn:contentful:::experience:spaces/$self/environments/$self/templates/${id}`;
+const dataAssemblyUrn = (id: string) =>
+ `crn:contentful:::experience:spaces/$self/environments/$self/dataAssemblies/${id}`;
+const entryUrn = (id: string) =>
+ `crn:contentful:::content:spaces/$self/environments/$self/entries/${id}`;
+
+const SAME_SPACE_CONTENT_SOURCE = 'crn:contentful:::content:spaces/$self/environments/$self';
+
+// --- Raw CMA fetch (for endpoints the plain client doesn't expose) ----------
+
+const CMA_BASE = `https://api.contentful.com/spaces/${SPACE_ID}/environments/${ENVIRONMENT_ID}`;
+
+async function cmaFetch(path: string, init: RequestInit = {}): Promise {
+ return fetch(`${CMA_BASE}${path}`, {
+ ...init,
+ headers: {
+ Authorization: `Bearer ${CMA_TOKEN}`,
+ 'Content-Type': 'application/vnd.contentful.management.v1+json',
+ ...(init.headers ?? {}),
+ },
+ });
+}
+
+// --- Resource seeders --------------------------------------------------------
+
+async function seedContentType(fixture: ContentTypeFixture) {
+ try {
+ await cma.contentType.get({ contentTypeId: fixture.id });
+ log(` ✓ ContentType "${fixture.id}" already exists — skipping`);
+ return;
+ } catch (err) {
+ if (!isNotFound(err)) throw err;
+ }
+
+ const created = await cma.contentType.createWithId(
+ { contentTypeId: fixture.id },
+ {
+ name: fixture.name,
+ description: fixture.description ?? '',
+ displayField: fixture.displayField,
+ fields: fixture.fields.map((f) => ({
+ ...f,
+ required: 'required' in f && f.required ? true : false,
+ localized: 'localized' in f && f.localized ? true : false,
+ })) as never,
+ }
+ );
+ await cma.contentType.publish({ contentTypeId: fixture.id }, created);
+ log(` ✓ ContentType "${fixture.id}" created + published`);
+}
+
+async function seedAsset(fixture: AssetFixture) {
+ const assetId = stableId(fixture.tempId);
+ try {
+ const existing = await cma.asset.get({ assetId });
+ remember(fixture.tempId, existing.sys.id);
+ log(` ✓ Asset "${fixture.tempId}" already exists — skipping`);
+ return;
+ } catch (err) {
+ if (!isNotFound(err)) throw err;
+ }
+
+ // Bytes live on disk under fixture/assets/; resolved relative to this file
+ // so the script works from any CWD.
+ const absPath = resolvePath(__dirname, 'fixture', fixture.sourcePath);
+ log(` … reading ${fixture.sourcePath}`);
+ const bytes = readFileSync(absPath);
+ // Node's Buffer is a Uint8Array; the upload API accepts ArrayBuffer/Stream.
+ const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
+
+ const upload = await cma.upload.create({}, { file: arrayBuffer });
+
+ const asset = await cma.asset.createWithId(
+ { assetId },
+ {
+ fields: {
+ title: { 'en-US': fixture.title },
+ file: {
+ 'en-US': {
+ contentType: fixture.contentType,
+ fileName: fixture.fileName,
+ uploadFrom: { sys: { type: 'Link', linkType: 'Upload', id: upload.sys.id } },
+ },
+ },
+ } as never,
+ }
+ );
+ const processed = await cma.asset.processForAllLocales({}, asset);
+ // processForAllLocales returns the asset once processing is initiated; we
+ // need to wait for the file URL to appear before publishing.
+ const ready = await waitForAssetProcessed(processed.sys.id);
+ await cma.asset.publish({ assetId: ready.sys.id }, ready);
+
+ remember(fixture.tempId, ready.sys.id);
+ log(` ✓ Asset "${fixture.tempId}" → ${ready.sys.id}`);
+}
+
+async function waitForAssetProcessed(assetId: string, maxAttempts = 30) {
+ for (let i = 0; i < maxAttempts; i++) {
+ const a = await cma.asset.get({ assetId });
+ const file = (a.fields.file as Record | undefined)?.['en-US'];
+ if (file?.url) return a;
+ await new Promise((r) => setTimeout(r, 1000));
+ }
+ throw new Error(`Asset ${assetId} did not finish processing within ${maxAttempts}s`);
+}
+
+// Recursively walk an entry's field values and swap { $assetTempId } placeholders
+// for real Contentful Link->Asset payloads.
+function resolveAssetRefs(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(resolveAssetRefs);
+ if (value && typeof value === 'object') {
+ const obj = value as Record;
+ if (typeof obj.$assetTempId === 'string') {
+ return { sys: { type: 'Link', linkType: 'Asset', id: resolveId(obj.$assetTempId) } };
+ }
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, resolveAssetRefs(v)]));
+ }
+ return value;
+}
+
+async function seedEntry(fixture: EntryFixture) {
+ const entryId = stableId(fixture.tempId);
+ try {
+ const existing = await cma.entry.get({ entryId });
+ remember(fixture.tempId, existing.sys.id);
+ log(` ✓ Entry "${fixture.tempId}" already exists — skipping`);
+ return;
+ } catch (err) {
+ if (!isNotFound(err)) throw err;
+ }
+
+ const resolvedFields = resolveAssetRefs(fixture.fields) as Record;
+ const created = await cma.entry.createWithId(
+ { entryId, contentTypeId: fixture.contentTypeId },
+ { fields: resolvedFields as never }
+ );
+ await cma.entry.publish({ entryId: created.sys.id }, created);
+
+ remember(fixture.tempId, created.sys.id);
+ log(` ✓ Entry "${fixture.tempId}" → ${created.sys.id}`);
+}
+
+async function seedDesignToken(fixture: DesignTokenFixture) {
+ // Check existence first — an update needs the current version header, a
+ // create doesn't. Design tokens are content-addressable by name, so if it
+ // exists we can skip.
+ const existing = await cmaFetch(`/design_tokens/${fixture.id}`, { method: 'GET' });
+ if (existing.ok) {
+ log(` ✓ DesignToken "${fixture.id}" already exists — skipping`);
+ return;
+ }
+ if (existing.status !== 404) {
+ const body = await existing.text();
+ throw new Error(`Unexpected status ${existing.status} checking design_token: ${body}`);
+ }
+
+ const res = await cmaFetch(`/design_tokens/${fixture.id}`, {
+ method: 'PUT',
+ body: JSON.stringify({ name: fixture.id, type: fixture.type, metadata: {} }),
+ });
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`Failed to PUT design_token "${fixture.id}": ${res.status} ${body}`);
+ }
+ log(` ✓ DesignToken "${fixture.id}" (${fixture.type})`);
+}
+
+async function seedComponentType(fixture: ComponentTypeFixture) {
+ const componentTypeId = fixture.id;
+ try {
+ await cma.componentType.get({ componentTypeId });
+ log(` ✓ ComponentType "${fixture.id}" already exists — skipping`);
+ return;
+ } catch (err) {
+ if (!isNotFound(err)) throw err;
+ }
+
+ const created = await cma.componentType.upsert({ componentTypeId }, {
+ sys: { id: componentTypeId, type: 'ComponentType' },
+ name: fixture.name,
+ description: fixture.description ?? '',
+ viewports: [{ id: 'all-sizes', query: '*', displayName: 'All Sizes', previewSize: '100%' }],
+ contentProperties: (fixture.contentProperties ?? []).map((p) => ({
+ ...p,
+ required: p.required ?? false,
+ })),
+ designProperties: fixture.designProperties ?? [],
+ slots: (fixture.slots ?? []).map((s) => ({
+ ...s,
+ required: false,
+ validations: [],
+ })),
+ } as never);
+ await cma.componentType.publish({
+ componentTypeId,
+ version: created.sys.version,
+ });
+ log(` ✓ ComponentType "${fixture.id}" created + published`);
+}
+
+async function seedTemplate(fixture: TemplateFixture) {
+ const templateId = fixture.id;
+ let existing: Awaited> | null = null;
+ try {
+ existing = await cma.template.get({ templateId });
+ } catch (err) {
+ if (!isNotFound(err)) throw err;
+ }
+
+ const desiredSlots = (fixture.slots ?? []).map((s) => ({
+ ...s,
+ required: false,
+ validations: [],
+ }));
+ const desiredTree = fixture.componentTree ?? [];
+
+ const templateBody = {
+ name: fixture.name,
+ description: fixture.description ?? '',
+ viewports: [{ id: 'all-sizes', query: '*', displayName: 'All Sizes', previewSize: '100%' }],
+ contentProperties: (fixture.contentProperties ?? []).map((p) => ({
+ ...p,
+ required: p.required ?? false,
+ })),
+ designProperties: fixture.designProperties ?? [],
+ slots: desiredSlots,
+ componentTree: desiredTree,
+ // Composed (not Coded) — a Coded template requires an empty componentTree.
+ // The `page` template has a Slot node in its tree, so it must be composed.
+ metadata: {
+ tags: [],
+ annotations: {
+ Template: [
+ {
+ sys: {
+ id: 'Contentful:ComposedImplementation',
+ type: 'Link',
+ linkType: 'Annotation',
+ },
+ },
+ ],
+ },
+ },
+ };
+
+ if (!existing) {
+ const created = await cma.template.upsert({ templateId }, {
+ sys: { id: templateId, type: 'Template' },
+ ...templateBody,
+ } as never);
+ await cma.template.publish({ templateId, version: created.sys.version });
+ log(` ✓ Template "${fixture.id}" created + published`);
+ return;
+ }
+
+ // Always upsert on existing so any fixture edit (slots, tree, annotations,
+ // whatever) takes effect. If we're already published at the current version
+ // we can skip the network round-trip.
+ const isPublished =
+ !!existing.sys.publishedVersion && existing.sys.publishedVersion === existing.sys.version;
+ if (isPublished) {
+ // Compare desired vs. current to decide if we still need to write.
+ const currentTree = JSON.stringify(existing.componentTree ?? []);
+ const desiredTreeJson = JSON.stringify(desiredTree);
+ const currentSlotIds = (existing.slots ?? []).map((s) => s.id).sort();
+ const desiredSlotIds = desiredSlots.map((s) => s.id).sort();
+ const currentImpl = (
+ ((existing.metadata as { annotations?: { Template?: Array<{ sys: { id: string } }> } })
+ ?.annotations?.Template ?? []) as Array<{ sys: { id: string } }>
+ )
+ .map((a) => a.sys.id)
+ .sort()
+ .join(',');
+ const desiredImpl = 'Contentful:ComposedImplementation';
+ if (
+ currentTree === desiredTreeJson &&
+ JSON.stringify(currentSlotIds) === JSON.stringify(desiredSlotIds) &&
+ currentImpl === desiredImpl
+ ) {
+ log(` ✓ Template "${fixture.id}" already exists — skipping`);
+ return;
+ }
+ }
+
+ const updated = await cma.template.upsert({ templateId }, {
+ sys: { id: templateId, type: 'Template', version: existing.sys.version },
+ ...templateBody,
+ } as never);
+ await cma.template.publish({ templateId, version: updated.sys.version });
+ log(` ✓ Template "${fixture.id}" updated + published`);
+}
+
+async function seedDataAssembly(fixture: DataAssemblyFixture) {
+ // DataAssemblies use server-assigned ids. To be idempotent we tag by name.
+ const existing = await cma.dataAssembly.getMany({ query: { limit: 100 } });
+ const match = existing.items.find((da) => da.name === fixture.name);
+ if (match) {
+ remember(fixture.tempId, match.sys.id);
+ log(` ✓ DataAssembly "${fixture.name}" already exists — skipping`);
+ return;
+ }
+
+ const parameters: Record = {};
+ for (const [pid, pdef] of Object.entries(fixture.parameters)) {
+ parameters[pid] = {
+ name: pdef.name,
+ type: 'ResourceLink',
+ linkType: 'Contentful:Entry',
+ allowedResources: [
+ {
+ type: 'Contentful:Entry',
+ source: SAME_SPACE_CONTENT_SOURCE,
+ allowedTypes: pdef.allowedContentTypes,
+ },
+ ],
+ };
+ }
+
+ const created = await cma.dataAssembly.create({}, {
+ sys: {
+ type: 'DataAssembly',
+ dataType: fixture.dataType.map((p) => ({
+ ...p,
+ required: p.required ?? false,
+ })),
+ },
+ name: fixture.name,
+ description: fixture.description ?? '',
+ metadata: { tags: [] },
+ parameters,
+ resolvers: fixture.resolvers,
+ return: fixture.return,
+ } as never);
+ await cma.dataAssembly.publish({
+ dataAssemblyId: created.sys.id,
+ version: created.sys.version,
+ });
+
+ remember(fixture.tempId, created.sys.id);
+ log(` ✓ DataAssembly "${fixture.name}" → ${created.sys.id}`);
+}
+
+// After DAs are created, ComponentTypes that host DA-bound nodes need those
+// DAs listed on their `dataAssemblies` field, or Experience publish fails
+// with `DataAssemblyMembershipViolation`.
+async function linkDataAssembliesToComponentTypes() {
+ // Group DA links by target ComponentType so we do one update per CT.
+ const byComponentType = new Map();
+ for (const link of dataAssemblyComponentTypeLinks) {
+ const daId = resolveId(link.dataAssemblyTempId);
+ const arr = byComponentType.get(link.componentTypeId) ?? [];
+ arr.push(daId);
+ byComponentType.set(link.componentTypeId, arr);
+ }
+
+ for (const [componentTypeId, daIds] of byComponentType) {
+ const current = await cma.componentType.get({ componentTypeId });
+ const existingLinks = new Set(
+ (current.dataAssemblies ?? []).map((l) => l.sys.urn.split('/').pop()!)
+ );
+ const missing = daIds.filter((id) => !existingLinks.has(id));
+ if (missing.length === 0) {
+ log(` ✓ ComponentType "${componentTypeId}" already links all DAs — skipping`);
+ continue;
+ }
+
+ const newLinks = [
+ ...(current.dataAssemblies ?? []),
+ ...missing.map((id) => ({
+ sys: {
+ type: 'ResourceLink' as const,
+ linkType: 'Contentful:DataAssembly' as const,
+ urn: dataAssemblyUrn(id),
+ },
+ })),
+ ];
+ const updated = await cma.componentType.upsert({ componentTypeId }, {
+ sys: { id: componentTypeId, type: 'ComponentType', version: current.sys.version },
+ name: current.name,
+ description: current.description,
+ viewports: current.viewports,
+ contentProperties: current.contentProperties,
+ designProperties: current.designProperties,
+ slots: current.slots,
+ dataAssemblies: newLinks,
+ } as never);
+ await cma.componentType.publish({
+ componentTypeId,
+ version: updated.sys.version,
+ });
+ log(` ✓ ComponentType "${componentTypeId}" linked to ${missing.length} DA(s)`);
+ }
+}
+
+// Walk fixture nodes and swap tempIds for real ids in contentBindings.
+function resolveNode(node: ExperienceNode): unknown {
+ const out: Record = {
+ id: node.id,
+ nodeType: node.nodeType,
+ componentType: {
+ sys: {
+ type: 'ResourceLink',
+ linkType: 'Contentful:ComponentType',
+ urn: componentTypeUrn(node.componentTypeId),
+ },
+ },
+ };
+ if ('designProperties' in node && node.designProperties) {
+ out.designProperties = node.designProperties;
+ }
+ if ('contentProperties' in node && node.contentProperties) {
+ out.contentProperties = node.contentProperties;
+ }
+ if ('contentBindings' in node && node.contentBindings) {
+ const parameters: Record = {};
+ for (const [pid, ref] of Object.entries(node.contentBindings.parameters)) {
+ parameters[pid] = {
+ sys: {
+ type: 'ResourceLink',
+ linkType: 'Contentful:Entry',
+ urn: entryUrn(resolveId(ref.$entryTempId)),
+ },
+ };
+ }
+ out.contentBindings = {
+ sys: {
+ type: 'ResourceLink',
+ linkType: 'Contentful:DataAssembly',
+ urn: dataAssemblyUrn(resolveId(node.contentBindings.dataAssemblyTempId)),
+ },
+ parameters,
+ };
+ }
+ if (node.slots) {
+ const slots: Record = {};
+ for (const [slotName, children] of Object.entries(node.slots)) {
+ slots[slotName] = children.map(resolveNode);
+ }
+ out.slots = slots;
+ } else {
+ out.slots = {};
+ }
+ return out;
+}
+
+async function seedExperience(fixture: ExperienceFixture) {
+ const experienceId = fixture.id;
+ let existing: Awaited> | null = null;
+ try {
+ existing = await cma.experience.get({ experienceId });
+ } catch (err) {
+ if (!isNotFound(err)) throw err;
+ }
+
+ const slots: Record = {};
+ for (const [slotName, children] of Object.entries(fixture.slots)) {
+ slots[slotName] = children.map(resolveNode);
+ }
+
+ // Always upsert so we pick up template/DA changes from earlier steps in the
+ // run — the CMA rejects Experience publish with `DefiningEntityIsChanged` if
+ // any referenced entity has moved on since the Experience's last version.
+ // `template` is immutable after creation, so include it only on create.
+ const templateLink = {
+ sys: {
+ type: 'ResourceLink' as const,
+ linkType: 'Contentful:Template' as const,
+ urn: templateUrn(fixture.templateId),
+ },
+ };
+ const commonBody = {
+ name: fixture.name,
+ description: fixture.description ?? '',
+ viewports: fixture.viewports,
+ designProperties: {},
+ metadata: { tags: [], concepts: [] },
+ slots,
+ };
+ const upserted = await cma.experience.upsert(
+ { experienceId },
+ (existing
+ ? {
+ sys: { id: experienceId, type: 'Experience', version: existing.sys.version },
+ ...commonBody,
+ }
+ : {
+ sys: { id: experienceId, type: 'Experience' },
+ template: templateLink,
+ ...commonBody,
+ }) as never
+ );
+ log(
+ existing ? ` ✓ Experience "${fixture.id}" updated` : ` ✓ Experience "${fixture.id}" created`
+ );
+
+ const isPublished =
+ !!upserted.sys.publishedVersion && upserted.sys.publishedVersion === upserted.sys.version;
+ if (isPublished) {
+ log(` ✓ Experience "${fixture.id}" already published`);
+ } else {
+ await cma.experience.publish(
+ { experienceId: upserted.sys.id, version: upserted.sys.version },
+ { add: ['en-US'] }
+ );
+ log(` ✓ Experience "${fixture.id}" published`);
+ }
+ return experienceId;
+}
+
+// --- Orchestrator ------------------------------------------------------------
+
+async function main() {
+ log(`Bootstrapping ExO demo into space ${SPACE_ID} / env ${ENVIRONMENT_ID}\n`);
+
+ step('Step 1/9 — ContentTypes');
+ for (const ct of contentTypes) await seedContentType(ct);
+
+ step('Step 2/9 — Assets');
+ for (const a of assets) await seedAsset(a);
+
+ step('Step 3/9 — Entries');
+ for (const e of entries) await seedEntry(e);
+
+ step('Step 4/9 — Design tokens');
+ for (const t of designTokens) await seedDesignToken(t);
+
+ step('Step 5/9 — ComponentTypes');
+ for (const ct of componentTypes) await seedComponentType(ct);
+
+ step('Step 6/9 — Template');
+ for (const t of templates) await seedTemplate(t);
+
+ step('Step 7/9 — DataAssemblies');
+ for (const da of dataAssemblies) await seedDataAssembly(da);
+
+ step('Step 8/9 — Link DataAssemblies to composed ComponentTypes');
+ await linkDataAssembliesToComponentTypes();
+
+ step('Step 9/9 — Experience');
+ const experienceId = await seedExperience(experience);
+
+ log(`\n✅ Done.\n`);
+ log(` Experience id: ${experienceId}`);
+ log(
+ ` Set NEXT_PUBLIC_EXPERIENCE_ID=${experienceId} (or paste it into your .env.local) and run \`npm run dev\`.`
+ );
+}
+
+main().catch((err) => {
+ console.error('\n✗ Bootstrap failed:', err);
+ process.exit(1);
+});
diff --git a/examples/scripts/fixture/assets.ts b/examples/scripts/fixture/assets.ts
new file mode 100644
index 0000000..5e61579
--- /dev/null
+++ b/examples/scripts/fixture/assets.ts
@@ -0,0 +1,28 @@
+import type { AssetFixture } from './types.js';
+
+// Source files live next to this module under `assets/`. The bootstrap reads
+// them from disk and uploads to the caller's space — the demo has no runtime
+// dependency on any external CDN.
+export const assets: AssetFixture[] = [
+ {
+ tempId: 'asset:hero-bg',
+ title: 'Hero background — blue connections',
+ fileName: 'hero-bg.png',
+ contentType: 'image/png',
+ sourcePath: 'assets/hero-bg.png',
+ },
+ {
+ tempId: 'asset:card-on',
+ title: 'On case study image',
+ fileName: 'card-on.png',
+ contentType: 'image/png',
+ sourcePath: 'assets/card-on.png',
+ },
+ {
+ tempId: 'asset:card-guide',
+ title: 'Developer guide image',
+ fileName: 'card-guide.webp',
+ contentType: 'image/webp',
+ sourcePath: 'assets/card-guide.webp',
+ },
+];
diff --git a/examples/scripts/fixture/assets/card-guide.webp b/examples/scripts/fixture/assets/card-guide.webp
new file mode 100644
index 0000000..00c54c9
Binary files /dev/null and b/examples/scripts/fixture/assets/card-guide.webp differ
diff --git a/examples/scripts/fixture/assets/card-on.png b/examples/scripts/fixture/assets/card-on.png
new file mode 100644
index 0000000..8c6f6dc
Binary files /dev/null and b/examples/scripts/fixture/assets/card-on.png differ
diff --git a/examples/scripts/fixture/assets/hero-bg.png b/examples/scripts/fixture/assets/hero-bg.png
new file mode 100644
index 0000000..ef21924
Binary files /dev/null and b/examples/scripts/fixture/assets/hero-bg.png differ
diff --git a/examples/scripts/fixture/component-types.ts b/examples/scripts/fixture/component-types.ts
new file mode 100644
index 0000000..351db19
--- /dev/null
+++ b/examples/scripts/fixture/component-types.ts
@@ -0,0 +1,200 @@
+import type { ComponentTypeFixture } from './types.js';
+
+// Shared allowed-resource lists. The token strings must match the design-token
+// table in the example app (examples/nextjs/lib/design-tokens.ts).
+const COLOR_TOKENS = [
+ 'color.primary',
+ 'color.primaryText',
+ 'color.text',
+ 'color.white',
+ 'color.none',
+];
+const SIZE_TOKENS = ['size.xl', 'size.md', 'size.sm', 'size.none'];
+const FONT_SIZE_TOKENS = ['fontSize.3xl', 'fontSize.lg', 'fontSize.md', 'fontSize.sm'];
+const FONT_WEIGHT_TOKENS = ['fontWeight.bold', 'fontWeight.normal'];
+
+const colorProp = (id: string, name: string, description = '') => ({
+ id,
+ name,
+ description,
+ type: 'DTCG.Color' as const,
+ allowedResources: COLOR_TOKENS.map((value) => ({ type: 'DesignToken' as const, value })),
+});
+
+const sizeProp = (id: string, name: string, description = '') => ({
+ id,
+ name,
+ description,
+ type: 'DTCG.Dimension' as const,
+ allowedResources: SIZE_TOKENS.map((value) => ({ type: 'DesignToken' as const, value })),
+});
+
+// --- Design-system primitives ------------------------------------------------
+// Each is a shallow shape — enough to render, not a full copy of the source
+// space's schemas. The example app's design-system components read these design
+// values via useDesignValues() and (mostly) render them through toCss().
+
+const Section: ComponentTypeFixture = {
+ id: 'Section',
+ name: 'Section',
+ description: 'Layout primitive — flex/grid row or column of children',
+ contentProperties: [],
+ designProperties: [
+ {
+ id: 'direction',
+ name: 'Direction',
+ type: 'String',
+ validations: [{ regexp: { pattern: '^(row|column)$' } }],
+ },
+ {
+ id: 'columns',
+ name: 'Columns',
+ type: 'String',
+ validations: [{ regexp: { pattern: '^(auto|1|2|3|4)$' } }],
+ },
+ { id: 'itemAlign', name: 'Item align', type: 'String' },
+ colorProp('backgroundColor', 'Background color'),
+ sizeProp('gap', 'Gap'),
+ sizeProp('verticalSpacing', 'Vertical spacing'),
+ sizeProp('horizontalSpacing', 'Horizontal spacing'),
+ ],
+ slots: [{ id: 'children', name: 'Children' }],
+};
+
+const Heading: ComponentTypeFixture = {
+ id: 'Heading',
+ name: 'Heading',
+ description: 'Semantic HTML heading (h1–h6)',
+ contentProperties: [{ id: 'text', name: 'Text', type: 'String' }],
+ designProperties: [
+ {
+ id: 'as',
+ name: 'Semantic tag',
+ type: 'String',
+ validations: [{ regexp: { pattern: '^h[1-6]$' } }],
+ },
+ { id: 'align', name: 'Align', type: 'String' },
+ colorProp('color', 'Color'),
+ {
+ id: 'fontSize',
+ name: 'Font size',
+ type: 'DTCG.Dimension',
+ allowedResources: FONT_SIZE_TOKENS.map((value) => ({ type: 'DesignToken', value })),
+ },
+ {
+ id: 'fontWeight',
+ name: 'Font weight',
+ type: 'DTCG.FontWeight',
+ allowedResources: FONT_WEIGHT_TOKENS.map((value) => ({ type: 'DesignToken', value })),
+ },
+ ],
+};
+
+const RichText: ComponentTypeFixture = {
+ id: 'RichText',
+ name: 'Rich text',
+ description: 'Minimal rich-text renderer',
+ contentProperties: [{ id: 'document', name: 'Document', type: 'RichText' }],
+ designProperties: [
+ { id: 'align', name: 'Align', type: 'String' },
+ colorProp('color', 'Color'),
+ {
+ id: 'fontSize',
+ name: 'Font size',
+ type: 'DTCG.Dimension',
+ allowedResources: FONT_SIZE_TOKENS.map((value) => ({ type: 'DesignToken', value })),
+ },
+ ],
+};
+
+const Text: ComponentTypeFixture = {
+ id: 'Text',
+ name: 'Text',
+ description: 'Plain-text span',
+ contentProperties: [{ id: 'text', name: 'Text', type: 'String' }],
+ designProperties: [
+ { id: 'align', name: 'Align', type: 'String' },
+ colorProp('color', 'Color'),
+ {
+ id: 'fontSize',
+ name: 'Font size',
+ type: 'DTCG.Dimension',
+ allowedResources: FONT_SIZE_TOKENS.map((value) => ({ type: 'DesignToken', value })),
+ },
+ ],
+};
+
+const Button: ComponentTypeFixture = {
+ id: 'Button',
+ name: 'Button',
+ description: 'Link styled as a button (label + url)',
+ contentProperties: [
+ { id: 'label', name: 'Label', type: 'String' },
+ { id: 'url', name: 'URL', type: 'String' },
+ ],
+ designProperties: [
+ {
+ id: 'target',
+ name: 'Target',
+ type: 'String',
+ validations: [{ regexp: { pattern: '^_(self|blank)$' } }],
+ },
+ colorProp('backgroundColor', 'Background color'),
+ colorProp('color', 'Color'),
+ ],
+};
+
+const Image: ComponentTypeFixture = {
+ id: 'Image',
+ name: 'Image',
+ description: 'Image (src + alt)',
+ contentProperties: [
+ { id: 'src', name: 'Source URL', type: 'String' },
+ { id: 'alt', name: 'Alt text', type: 'String' },
+ ],
+ designProperties: [],
+};
+
+// --- Composed ComponentTypes -------------------------------------------------
+// These aren't primitives — they're editor-authored vocabulary the customer
+// maps entry fields ONTO via DataAssembly. Their contentProperties are what the
+// hero-assembly / card-assembly declare in their `return` blocks.
+
+const heroPlain: ComponentTypeFixture = {
+ id: 'hero-plain',
+ name: 'Hero: plain',
+ description: 'Full-width hero — title + body + CTA + image, sourced from a promotion entry',
+ contentProperties: [
+ { id: 'title', name: 'Title', type: 'String', required: true },
+ { id: 'body', name: 'Body', type: 'RichText' },
+ { id: 'ctaLabel', name: 'CTA label', type: 'String' },
+ { id: 'ctaUrl', name: 'CTA URL', type: 'String' },
+ { id: 'image', name: 'Image URL', type: 'String' },
+ ],
+ designProperties: [colorProp('backgroundColor', 'Background color'), colorProp('color', 'Color')],
+};
+
+const card: ComponentTypeFixture = {
+ id: 'card',
+ name: 'Card',
+ description: 'Compact card — image + title + teaser + CTA, sourced from a promotion entry',
+ contentProperties: [
+ { id: 'title', name: 'Title', type: 'String', required: true },
+ { id: 'teaser', name: 'Teaser', type: 'String' },
+ { id: 'ctaLabel', name: 'CTA label', type: 'String' },
+ { id: 'ctaUrl', name: 'CTA URL', type: 'String' },
+ { id: 'image', name: 'Image URL', type: 'String' },
+ ],
+ designProperties: [colorProp('backgroundColor', 'Background color'), colorProp('color', 'Color')],
+};
+
+export const componentTypes: ComponentTypeFixture[] = [
+ Section,
+ Heading,
+ RichText,
+ Text,
+ Button,
+ Image,
+ heroPlain,
+ card,
+];
diff --git a/examples/scripts/fixture/content-types.ts b/examples/scripts/fixture/content-types.ts
new file mode 100644
index 0000000..d7937a2
--- /dev/null
+++ b/examples/scripts/fixture/content-types.ts
@@ -0,0 +1,20 @@
+import type { ContentTypeFixture } from './types.js';
+
+export const contentTypes: ContentTypeFixture[] = [
+ {
+ id: 'promotion',
+ name: 'Promotion',
+ description:
+ 'A promotional card: title + short teaser + long-form body + CTA + hero image. One entry backs one node in the demo Experience.',
+ displayField: 'internalName',
+ fields: [
+ { id: 'internalName', name: 'Internal name', type: 'Symbol', required: true },
+ { id: 'title', name: 'Title', type: 'Symbol', required: true },
+ { id: 'teaser', name: 'Teaser', type: 'Symbol' },
+ { id: 'body', name: 'Body', type: 'RichText' },
+ { id: 'ctaLabel', name: 'CTA label', type: 'Symbol' },
+ { id: 'ctaUrl', name: 'CTA URL', type: 'Symbol' },
+ { id: 'image', name: 'Image', type: 'Link', linkType: 'Asset' },
+ ],
+ },
+];
diff --git a/examples/scripts/fixture/data-assemblies.ts b/examples/scripts/fixture/data-assemblies.ts
new file mode 100644
index 0000000..d0eeb06
--- /dev/null
+++ b/examples/scripts/fixture/data-assemblies.ts
@@ -0,0 +1,110 @@
+import type { DataAssemblyFixture } from './types.js';
+
+// Each DataAssembly is a small transform from an Entry to a ComponentType's
+// contentProperties. GraphQL is the resolver source; the query field naming
+// matches how Contentful's content graph auto-generates types (Promotion,
+// promotion.name, etc.).
+
+const heroAssembly: DataAssemblyFixture = {
+ tempId: 'assembly:hero',
+ name: 'Hero from Promotion',
+ description: 'Maps a promotion entry into the hero-plain ComponentType',
+ // dataType MUST mirror hero-plain's contentProperties.
+ dataType: [
+ { id: 'title', name: 'Title', type: 'String', required: true },
+ { id: 'ctaLabel', name: 'CTA label', type: 'String' },
+ { id: 'ctaUrl', name: 'CTA URL', type: 'String' },
+ { id: 'image', name: 'Image URL', type: 'String' },
+ ],
+ parameters: {
+ promo: {
+ name: 'Promotion entry',
+ linkType: 'Contentful:Entry',
+ allowedContentTypes: ['promotion'],
+ },
+ },
+ resolvers: {
+ promoNode: {
+ source: 'Contentful:GraphQL',
+ query: `query ($id: ID!) {
+ _node(id: $id) {
+ __typename
+ ... on Promotion {
+ title
+ ctaLabel
+ ctaUrl
+ image { url }
+ }
+ }
+}`,
+ parameters: { id: '$parameters/promo' },
+ },
+ },
+ return: {
+ title: { $from: '$resolvers/promoNode/_node/title' },
+ ctaLabel: { $from: '$resolvers/promoNode/_node/ctaLabel' },
+ ctaUrl: { $from: '$resolvers/promoNode/_node/ctaUrl' },
+ image: { $from: '$resolvers/promoNode/_node/image/url' },
+ },
+};
+
+const cardAssembly: DataAssemblyFixture = {
+ tempId: 'assembly:card',
+ name: 'Card from Promotion',
+ description: 'Maps a promotion entry into the card ComponentType',
+ // dataType MUST mirror card's contentProperties.
+ dataType: [
+ { id: 'title', name: 'Title', type: 'String', required: true },
+ { id: 'teaser', name: 'Teaser', type: 'String' },
+ { id: 'ctaLabel', name: 'CTA label', type: 'String' },
+ { id: 'ctaUrl', name: 'CTA URL', type: 'String' },
+ { id: 'image', name: 'Image URL', type: 'String' },
+ ],
+ parameters: {
+ promo: {
+ name: 'Promotion entry',
+ linkType: 'Contentful:Entry',
+ allowedContentTypes: ['promotion'],
+ },
+ },
+ resolvers: {
+ promoNode: {
+ source: 'Contentful:GraphQL',
+ query: `query ($id: ID!) {
+ _node(id: $id) {
+ __typename
+ ... on Promotion {
+ title
+ teaser
+ ctaLabel
+ ctaUrl
+ image { url }
+ }
+ }
+}`,
+ parameters: { id: '$parameters/promo' },
+ },
+ },
+ return: {
+ title: { $from: '$resolvers/promoNode/_node/title' },
+ teaser: { $from: '$resolvers/promoNode/_node/teaser' },
+ ctaLabel: { $from: '$resolvers/promoNode/_node/ctaLabel' },
+ ctaUrl: { $from: '$resolvers/promoNode/_node/ctaUrl' },
+ image: { $from: '$resolvers/promoNode/_node/image/url' },
+ },
+};
+
+export const dataAssemblies: DataAssemblyFixture[] = [heroAssembly, cardAssembly];
+
+// Which ComponentType each DataAssembly binds to. When publishing an Experience
+// that uses a DA on a ComponentType node, that ComponentType MUST list the DA
+// in its `dataAssemblies` array or publish fails with
+// `DataAssemblyMembershipViolation`. This mapping lets the bootstrap update the
+// ComponentType after the DA is created.
+export const dataAssemblyComponentTypeLinks: Array<{
+ dataAssemblyTempId: string;
+ componentTypeId: string;
+}> = [
+ { dataAssemblyTempId: 'assembly:hero', componentTypeId: 'hero-plain' },
+ { dataAssemblyTempId: 'assembly:card', componentTypeId: 'card' },
+];
diff --git a/examples/scripts/fixture/design-tokens.ts b/examples/scripts/fixture/design-tokens.ts
new file mode 100644
index 0000000..d1f3991
--- /dev/null
+++ b/examples/scripts/fixture/design-tokens.ts
@@ -0,0 +1,43 @@
+// Design tokens are the abstract names ComponentTypes reference in
+// `allowedResources`. Their runtime CSS values are resolved by the example
+// app's own `resolveToken` (see examples/nextjs/lib/design-tokens.ts) — the
+// server just tracks that these ids exist and what DTCG type they belong to.
+
+export type DesignTokenType =
+ | 'DTCG.Color'
+ | 'DTCG.Dimension'
+ | 'DTCG.FontWeight'
+ | 'DTCG.FontFamily'
+ | 'DTCG.Duration'
+ | 'DTCG.CubicBezier'
+ | 'DTCG.Number';
+
+export type DesignTokenFixture = {
+ id: string;
+ type: DesignTokenType;
+};
+
+// Every token referenced anywhere in the fixture (allowedResources +
+// experience.designProperties). If you add a token to a ComponentType or
+// Experience node, add it here too or the CMA will reject the ComponentType.
+export const designTokens: DesignTokenFixture[] = [
+ // Colors
+ { id: 'color.primary', type: 'DTCG.Color' },
+ { id: 'color.primaryText', type: 'DTCG.Color' },
+ { id: 'color.text', type: 'DTCG.Color' },
+ { id: 'color.white', type: 'DTCG.Color' },
+ { id: 'color.none', type: 'DTCG.Color' },
+ // Sizes
+ { id: 'size.xl', type: 'DTCG.Dimension' },
+ { id: 'size.md', type: 'DTCG.Dimension' },
+ { id: 'size.sm', type: 'DTCG.Dimension' },
+ { id: 'size.none', type: 'DTCG.Dimension' },
+ // Font sizes
+ { id: 'fontSize.3xl', type: 'DTCG.Dimension' },
+ { id: 'fontSize.lg', type: 'DTCG.Dimension' },
+ { id: 'fontSize.md', type: 'DTCG.Dimension' },
+ { id: 'fontSize.sm', type: 'DTCG.Dimension' },
+ // Font weights
+ { id: 'fontWeight.bold', type: 'DTCG.FontWeight' },
+ { id: 'fontWeight.normal', type: 'DTCG.FontWeight' },
+];
diff --git a/examples/scripts/fixture/entries.ts b/examples/scripts/fixture/entries.ts
new file mode 100644
index 0000000..1fa945c
--- /dev/null
+++ b/examples/scripts/fixture/entries.ts
@@ -0,0 +1,98 @@
+import { assetRef, type EntryFixture } from './types.js';
+
+// Small helper for authoring rich text without importing @contentful/rich-text-types.
+const rt = (paragraphs: Array>) => ({
+ nodeType: 'document',
+ data: {},
+ content: paragraphs.map((spans) => ({
+ nodeType: 'paragraph',
+ data: {},
+ content: spans.map(({ text, bold, italic }) => ({
+ nodeType: 'text',
+ value: text,
+ data: {},
+ marks: [
+ ...(bold ? [{ type: 'bold' as const }] : []),
+ ...(italic ? [{ type: 'italic' as const }] : []),
+ ],
+ })),
+ })),
+});
+
+export const entries: EntryFixture[] = [
+ {
+ tempId: 'entry:hero',
+ contentTypeId: 'promotion',
+ fields: {
+ internalName: { 'en-US': 'Hero — Modernize your content stack' },
+ title: { 'en-US': 'Ready to modernize your content stack?' },
+ teaser: {
+ 'en-US': 'Unify your content operations and deliver experiences that convert.',
+ },
+ body: {
+ 'en-US': rt([
+ [
+ { text: 'The world’s leading brands trust Contentful to ' },
+ { text: 'unify their content operations', bold: true },
+ { text: ' and deliver experiences that convert.' },
+ ],
+ [
+ { text: 'Join thousands of teams who’ve left legacy CMS behind. ' },
+ { text: 'Your content infrastructure starts here.', italic: true },
+ ],
+ ]),
+ },
+ ctaLabel: { 'en-US': 'Book a demo' },
+ ctaUrl: { 'en-US': 'https://www.contentful.com/contact/sales/' },
+ image: { 'en-US': assetRef('asset:hero-bg') },
+ },
+ },
+ {
+ tempId: 'entry:card-on',
+ contentTypeId: 'promotion',
+ fields: {
+ internalName: { 'en-US': 'Card — On case study' },
+ title: { 'en-US': 'See how On runs on Contentful' },
+ teaser: {
+ 'en-US':
+ 'On, the Swiss performance brand, uses Contentful to power global digital experiences at speed.',
+ },
+ body: {
+ 'en-US': rt([
+ [
+ { text: 'On uses Contentful to power ' },
+ { text: 'global digital experiences at speed', bold: true },
+ { text: ' — across DTC, wholesale, and brand channels simultaneously.' },
+ ],
+ ]),
+ },
+ ctaLabel: { 'en-US': 'Read the On case study' },
+ ctaUrl: { 'en-US': 'https://www.contentful.com/case-studies/on/' },
+ image: { 'en-US': assetRef('asset:card-on') },
+ },
+ },
+ {
+ tempId: 'entry:card-guide',
+ contentTypeId: 'promotion',
+ fields: {
+ internalName: { 'en-US': 'Card — Developer guide' },
+ title: { 'en-US': 'Content as Infrastructure' },
+ teaser: {
+ 'en-US':
+ 'Contentful isn’t just a CMS — it’s a content infrastructure layer for developers.',
+ },
+ body: {
+ 'en-US': rt([
+ [
+ {
+ text: 'Contentful isn’t just a CMS — it’s a content infrastructure layer for developers building modern digital experiences.',
+ },
+ ],
+ ]),
+ },
+ ctaLabel: { 'en-US': 'Read the developer guide' },
+ ctaUrl: { 'en-US': 'https://www.contentful.com/developers/' },
+ image: { 'en-US': assetRef('asset:card-guide') },
+ },
+ },
+];
diff --git a/examples/scripts/fixture/experience.ts b/examples/scripts/fixture/experience.ts
new file mode 100644
index 0000000..dfed44b
--- /dev/null
+++ b/examples/scripts/fixture/experience.ts
@@ -0,0 +1,87 @@
+import type { ExperienceFixture, ExperienceNode, DesignValue } from './types.js';
+
+const tokenValue = (value: string): DesignValue => ({ type: 'DesignToken', value });
+const manualValue = (value: string | number | boolean): DesignValue => ({
+ type: 'ManualDesignValue',
+ value,
+});
+
+// Wrap a design value for the default viewport ('_').
+const atDefault = (value: DesignValue) => ({ _: value });
+
+const heroNode: ExperienceNode = {
+ id: 'node:hero',
+ nodeType: 'InlineFragment',
+ componentTypeId: 'hero-plain',
+ designProperties: {
+ backgroundColor: atDefault(tokenValue('color.primary')),
+ color: atDefault(tokenValue('color.primaryText')),
+ },
+ contentBindings: {
+ dataAssemblyTempId: 'assembly:hero',
+ parameters: {
+ promo: { $entryTempId: 'entry:hero' },
+ },
+ },
+};
+
+const cardOnNode: ExperienceNode = {
+ id: 'node:card-on',
+ nodeType: 'InlineFragment',
+ componentTypeId: 'card',
+ designProperties: {
+ backgroundColor: atDefault(tokenValue('color.white')),
+ color: atDefault(tokenValue('color.text')),
+ },
+ contentBindings: {
+ dataAssemblyTempId: 'assembly:card',
+ parameters: {
+ promo: { $entryTempId: 'entry:card-on' },
+ },
+ },
+};
+
+const cardGuideNode: ExperienceNode = {
+ id: 'node:card-guide',
+ nodeType: 'InlineFragment',
+ componentTypeId: 'card',
+ designProperties: {
+ backgroundColor: atDefault(tokenValue('color.white')),
+ color: atDefault(tokenValue('color.text')),
+ },
+ contentBindings: {
+ dataAssemblyTempId: 'assembly:card',
+ parameters: {
+ promo: { $entryTempId: 'entry:card-guide' },
+ },
+ },
+};
+
+const cardsContainerNode: ExperienceNode = {
+ id: 'node:cards',
+ nodeType: 'InlineFragment',
+ componentTypeId: 'Section',
+ designProperties: {
+ direction: atDefault(manualValue('row')),
+ columns: atDefault(manualValue('2')),
+ gap: atDefault(tokenValue('size.xl')),
+ verticalSpacing: atDefault(tokenValue('size.xl')),
+ horizontalSpacing: atDefault(tokenValue('size.sm')),
+ backgroundColor: atDefault(tokenValue('color.none')),
+ },
+ slots: {
+ children: [cardOnNode, cardGuideNode],
+ },
+};
+
+export const experience: ExperienceFixture = {
+ id: 'landing',
+ name: 'Landing (demo)',
+ description:
+ 'Minimal ExO demo — 1 hero + 2 cards, all bound via DataAssembly to promotion entries',
+ templateId: 'page',
+ viewports: [{ id: '_', query: '*', displayName: 'Default', previewSize: '1024px' }],
+ slots: {
+ content: [heroNode, cardsContainerNode],
+ },
+};
diff --git a/examples/scripts/fixture/index.ts b/examples/scripts/fixture/index.ts
new file mode 100644
index 0000000..636223d
--- /dev/null
+++ b/examples/scripts/fixture/index.ts
@@ -0,0 +1,9 @@
+export * from './types.js';
+export { contentTypes } from './content-types.js';
+export { assets } from './assets.js';
+export { entries } from './entries.js';
+export { designTokens, type DesignTokenFixture } from './design-tokens.js';
+export { componentTypes } from './component-types.js';
+export { templates } from './templates.js';
+export { dataAssemblies, dataAssemblyComponentTypeLinks } from './data-assemblies.js';
+export { experience } from './experience.js';
diff --git a/examples/scripts/fixture/templates.ts b/examples/scripts/fixture/templates.ts
new file mode 100644
index 0000000..6a1834f
--- /dev/null
+++ b/examples/scripts/fixture/templates.ts
@@ -0,0 +1,21 @@
+import type { TemplateFixture } from './types.js';
+
+// The `page` template declares a single "content" slot: an Experience using
+// this template puts its top-level nodes into `slots.content`, and the
+// template's componentTree tells the renderer where to drop them. No
+// template-level content/design props for the minimal demo.
+export const templates: TemplateFixture[] = [
+ {
+ id: 'page',
+ name: 'Page',
+ description: 'Passthrough page wrapper — renders the Experience content slot',
+ slots: [{ id: 'content', name: 'Content' }],
+ componentTree: [
+ {
+ id: 'page-content-slot',
+ nodeType: 'Slot',
+ slotId: 'content',
+ },
+ ],
+ },
+];
diff --git a/examples/scripts/fixture/types.ts b/examples/scripts/fixture/types.ts
new file mode 100644
index 0000000..e30f93b
--- /dev/null
+++ b/examples/scripts/fixture/types.ts
@@ -0,0 +1,203 @@
+// Fixture types — shared between the fixture modules and the bootstrap script.
+//
+// Everything here is expressed in terms of tempIds (opaque local strings the
+// bootstrap resolves to real Contentful sys.ids after each resource is created).
+// This keeps the fixture self-referential without baking any target-space ids in.
+
+export type TempId = string;
+
+// --- Content model ------------------------------------------------------------
+
+export type ContentTypeFixture = {
+ id: string;
+ name: string;
+ description?: string;
+ displayField?: string;
+ fields: ContentTypeField[];
+};
+
+export type ContentTypeField =
+ | {
+ id: string;
+ name: string;
+ type: 'Symbol' | 'Text' | 'Date' | 'RichText' | 'Object' | 'Boolean' | 'Integer' | 'Number';
+ required?: boolean;
+ localized?: boolean;
+ }
+ | {
+ id: string;
+ name: string;
+ type: 'Link';
+ linkType: 'Asset' | 'Entry';
+ required?: boolean;
+ localized?: boolean;
+ validations?: unknown[];
+ }
+ | {
+ id: string;
+ name: string;
+ type: 'Array';
+ items:
+ | { type: 'Link'; linkType: 'Asset' | 'Entry'; validations?: unknown[] }
+ | { type: 'Symbol'; validations?: unknown[] };
+ required?: boolean;
+ localized?: boolean;
+ };
+
+// --- Assets -------------------------------------------------------------------
+
+export type AssetFixture = {
+ tempId: TempId;
+ title: string;
+ fileName: string;
+ contentType: string;
+ // Path to the source file on disk, relative to the fixture/ directory.
+ // The bootstrap reads the bytes locally and uploads them to the caller's
+ // space — nothing gets fetched over HTTP, so the demo doesn't depend on
+ // any external CDN staying up.
+ sourcePath: string;
+};
+
+// --- Entries ------------------------------------------------------------------
+
+export type EntryFixture = {
+ tempId: TempId;
+ contentTypeId: string;
+ fields: Record;
+};
+
+// Placeholder value the bootstrap swaps for a real Link->Asset payload.
+export type AssetRef = { $assetTempId: TempId };
+export const assetRef = (tempId: TempId): AssetRef => ({ $assetTempId: tempId });
+
+// --- ComponentTypes -----------------------------------------------------------
+
+export type ComponentTypeFixture = {
+ id: string;
+ name: string;
+ description?: string;
+ contentProperties?: ContentPropertyDef[];
+ designProperties?: DesignPropertyDef[];
+ slots?: SlotDef[];
+};
+
+export type ContentPropertyDef = {
+ id: string;
+ name: string;
+ type: 'String' | 'RichText' | 'Boolean' | 'Number';
+ required?: boolean;
+};
+
+export type DesignPropertyDef = {
+ id: string;
+ name: string;
+ description?: string;
+ type: 'String' | 'DTCG.Color' | 'DTCG.Dimension' | 'DTCG.FontWeight' | 'Boolean' | 'Number';
+ validations?: unknown[];
+ allowedResources?: Array<{ type: 'DesignToken'; value: string }>;
+};
+
+export type SlotDef = { id: string; name: string; description?: string };
+
+// --- Templates ----------------------------------------------------------------
+
+export type TemplateFixture = {
+ id: string;
+ name: string;
+ description?: string;
+ contentProperties?: ContentPropertyDef[];
+ designProperties?: DesignPropertyDef[];
+ // Slot declarations — Experiences using this template put their top-level
+ // nodes into a slot with a matching id. `slots: [{ id: 'content' }]` on the
+ // template + a `componentTree` node with `{ nodeType: 'Slot', slotId: 'content' }`
+ // is the mechanism that says "render the Experience's `content` slot here."
+ slots?: SlotDef[];
+ componentTree?: TemplateTreeNode[];
+};
+
+export type TemplateTreeNode = {
+ id: string;
+ nodeType: 'Slot';
+ slotId: string;
+};
+
+// --- DataAssemblies -----------------------------------------------------------
+//
+// A DataAssembly declares:
+// 1. parameters — inputs (entries or values) the assembly is invoked with.
+// 2. resolvers — how to fetch data from those parameters (GraphQL against
+// the space's content graph, or nested DataAssembly).
+// 3. return — how to map resolver output onto the target ComponentType's
+// contentProperties by id.
+//
+// For the minimal demo we only use the GraphQL resolver flavor and one
+// parameter per assembly.
+
+export type DataAssemblyFixture = {
+ tempId: TempId;
+ name: string;
+ description?: string;
+ // dataType MUST mirror the ComponentType's contentProperties this assembly targets.
+ dataType: ContentPropertyDef[];
+ parameters: Record<
+ string,
+ {
+ name: string;
+ linkType: 'Contentful:Entry';
+ allowedContentTypes: string[];
+ }
+ >;
+ resolvers: Record<
+ string,
+ {
+ source: 'Contentful:GraphQL';
+ query: string;
+ parameters: Record; // e.g. { id: '$parameters/promo' }
+ }
+ >;
+ // Map from ComponentType contentProperty id -> resolver output path.
+ return: Record;
+};
+
+// --- Experience ---------------------------------------------------------------
+
+export type ExperienceFixture = {
+ id: string;
+ name: string;
+ description?: string;
+ templateId: string;
+ viewports: Array<{ id: string; query: string; displayName: string; previewSize: string }>;
+ slots: { content: ExperienceNode[] };
+};
+
+export type ExperienceNode = InlineFragmentNode | ContainerNode;
+
+// An InlineFragment node has its content sourced from a DataAssembly binding.
+export type InlineFragmentNode = {
+ id: string;
+ nodeType: 'InlineFragment';
+ componentTypeId: string;
+ designProperties?: Record;
+ contentBindings: {
+ dataAssemblyTempId: TempId;
+ parameters: Record;
+ };
+ slots?: Record;
+};
+
+// A Container node has inline contentProperties + designProperties and children in slots.
+export type ContainerNode = {
+ id: string;
+ nodeType: 'InlineFragment';
+ componentTypeId: string;
+ contentProperties?: Record;
+ designProperties?: Record;
+ slots?: Record;
+};
+
+// A design/content property value is keyed by viewport id ('_' = default).
+export type ViewportValue = Record;
+
+export type DesignValue =
+ | { type: 'ManualDesignValue'; value: string | number | boolean | null }
+ | { type: 'DesignToken'; value: string };
diff --git a/examples/scripts/package.json b/examples/scripts/package.json
new file mode 100644
index 0000000..a196e1f
--- /dev/null
+++ b/examples/scripts/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@contentful/experiences-example-scripts",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "description": "Bootstrap scripts for the customer-facing example apps",
+ "scripts": {
+ "bootstrap": "tsx bootstrap-example.ts",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "contentful-management": "12.6.0-dev.4"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "tsx": "^4.19.0",
+ "typescript": "^5.4.0"
+ }
+}
diff --git a/examples/scripts/tsconfig.json b/examples/scripts/tsconfig.json
new file mode 100644
index 0000000..799abbe
--- /dev/null
+++ b/examples/scripts/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "target": "es2022",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true
+ },
+ "include": ["**/*.ts"]
+}
diff --git a/examples/sveltekit/.env.example b/examples/sveltekit/.env.example
index 40490a6..4bbd046 100644
--- a/examples/sveltekit/.env.example
+++ b/examples/sveltekit/.env.example
@@ -1,3 +1,16 @@
+# Copy this file to .env and fill in real values from a Contentful space.
+#
+# Bootstrap the demo Experience into your space first with:
+# cd examples/scripts && npm run bootstrap
+# The script prints the experienceId to hit at the end (default: `landing`).
+
SPACE_ID=
ENVIRONMENT_ID=master
+
+# Content Delivery API token — used by every route by default.
CDA_TOKEN=
+
+# Content Preview API token — required only when hitting a route with
+# ?preview=true (that path uses preview.xdn.contentful.com, which rejects
+# CDA tokens). Leave blank if you don't need preview mode.
+CPA_TOKEN=
diff --git a/examples/sveltekit/README.md b/examples/sveltekit/README.md
index 7a9a5f2..59cf985 100644
--- a/examples/sveltekit/README.md
+++ b/examples/sveltekit/README.md
@@ -13,17 +13,41 @@ A SvelteKit 2 + Svelte 5 app demonstrating `@contentful/experiences-svelte` rend
## Run it
+The example is a real integration against Contentful, not a mock. You need a Contentful space with the demo content model + Experience seeded, plus a Content Delivery API token. The [`examples/scripts/bootstrap-example.ts`](../scripts/bootstrap-example.ts) script does the seeding via the management API — see [`examples/scripts/README.md`](../scripts/README.md) for what it provisions.
+
+### 1. Seed the demo Experience (one-time)
+
```sh
# From the repo root:
-npm install --ignore-scripts
-npm run build # builds the SDK packages
+npm install
+npm run build # build the SDK packages
+
+cd examples/scripts
+cp .env.example .env # fill in SPACE_ID, ENVIRONMENT_ID, CMA_TOKEN
+npm run bootstrap # prints the experienceId at the end (default: `landing`)
+```
+
+### 2. Run the app
-cd examples/sveltekit
-cp .env.example .env # fill in SPACE_ID + CDA_TOKEN
+```sh
+cd ../sveltekit
+cp .env.example .env # fill in SPACE_ID, ENVIRONMENT_ID, CDA_TOKEN
npm run dev
```
-Then visit `http://localhost:5173/`. The slug becomes the Experience ID passed to `client.view.getExperience`.
+Visit `http://localhost:5173/landing`. `landing` is the Experience id the bootstrap printed; any other Experience id in your space works too.
+
+### Optional: preview mode
+
+Add `CPA_TOKEN=...` (Content Preview API token from **Settings → API keys** in your space) to `.env`, then visit `http://localhost:5173/landing?preview=true`. The route reads from `preview.xdn.contentful.com`, which needs a preview token — a CDA token gets rejected there.
+
+### Tokens summary
+
+| Token | API | Used by | Required? |
+| ----------- | ------------------ | ------------------------------------ | --------------------- |
+| `CMA_TOKEN` | Content Management | The bootstrap script (one-time seed) | Yes, to run bootstrap |
+| `CDA_TOKEN` | Content Delivery | The example app | Yes, to run the app |
+| `CPA_TOKEN` | Content Preview | The example app when `?preview=true` | Only for preview mode |
## File map
diff --git a/examples/sveltekit/src/lib/components/Button.svelte b/examples/sveltekit/src/lib/components/Button.svelte
index 2240221..e22564f 100644
--- a/examples/sveltekit/src/lib/components/Button.svelte
+++ b/examples/sveltekit/src/lib/components/Button.svelte
@@ -1,53 +1,38 @@
-
{#if url}
-
- {text}
- {#if children}{@render children()}{/if}
-
+ {label}
{:else}
-
- {text}
- {#if children}{@render children()}{/if}
-
+ {label}
{/if}
diff --git a/examples/sveltekit/src/lib/components/Card.svelte b/examples/sveltekit/src/lib/components/Card.svelte
new file mode 100644
index 0000000..cbee722
--- /dev/null
+++ b/examples/sveltekit/src/lib/components/Card.svelte
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+ {#if image}
+
+ {/if}
+
+ {#if title}
+
{title}
+ {/if}
+ {#if teaser}
+
{teaser}
+ {/if}
+ {#if ctaLabel && ctaUrl}
+
+ {ctaLabel}
+
+ {/if}
+
+
diff --git a/examples/sveltekit/src/lib/components/Heading.svelte b/examples/sveltekit/src/lib/components/Heading.svelte
new file mode 100644
index 0000000..0f0fb4a
--- /dev/null
+++ b/examples/sveltekit/src/lib/components/Heading.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+{text ?? ''}
diff --git a/examples/sveltekit/src/lib/components/HeroPlain.svelte b/examples/sveltekit/src/lib/components/HeroPlain.svelte
new file mode 100644
index 0000000..56b4805
--- /dev/null
+++ b/examples/sveltekit/src/lib/components/HeroPlain.svelte
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+ {#if title}
+
{title}
+ {/if}
+ {#if ctaLabel && ctaUrl}
+
+ {ctaLabel}
+
+ {/if}
+
+ {#if image}
+
+ {/if}
+
diff --git a/examples/sveltekit/src/lib/components/Image.svelte b/examples/sveltekit/src/lib/components/Image.svelte
new file mode 100644
index 0000000..4e77d2d
--- /dev/null
+++ b/examples/sveltekit/src/lib/components/Image.svelte
@@ -0,0 +1,14 @@
+
+
+
+
+{#if src}
+
+{/if}
diff --git a/examples/sveltekit/src/lib/components/Page.svelte b/examples/sveltekit/src/lib/components/Page.svelte
index fe3d4c4..3378367 100644
--- a/examples/sveltekit/src/lib/components/Page.svelte
+++ b/examples/sveltekit/src/lib/components/Page.svelte
@@ -15,12 +15,10 @@
let { title, children }: PageProps = $props();
-
+
{#if title}
{title}
diff --git a/examples/sveltekit/src/lib/components/RichText.svelte b/examples/sveltekit/src/lib/components/RichText.svelte
new file mode 100644
index 0000000..f0e1277
--- /dev/null
+++ b/examples/sveltekit/src/lib/components/RichText.svelte
@@ -0,0 +1,55 @@
+
+
+
+
+{#if doc}
+
+ {#each doc.content as p}
+
+ {#each p.content as span}
+ {#if span.marks?.some((m) => m.type === 'bold')}{span.value}
+ {:else if span.marks?.some((m) => m.type === 'italic')}{span.value}
+ {:else}{span.value}{/if}
+ {/each}
+
+ {/each}
+
+{/if}
diff --git a/examples/sveltekit/src/lib/components/Section.svelte b/examples/sveltekit/src/lib/components/Section.svelte
new file mode 100644
index 0000000..c6c2459
--- /dev/null
+++ b/examples/sveltekit/src/lib/components/Section.svelte
@@ -0,0 +1,38 @@
+
+
+
+
+
+ {#if children}{@render children()}{/if}
+
diff --git a/examples/sveltekit/src/lib/components/Text.svelte b/examples/sveltekit/src/lib/components/Text.svelte
index de56880..afdc95f 100644
--- a/examples/sveltekit/src/lib/components/Text.svelte
+++ b/examples/sveltekit/src/lib/components/Text.svelte
@@ -1,17 +1,24 @@
-
- {value}
- {#if children}{@render children()}{/if}
-
+{text ?? ''}
diff --git a/examples/sveltekit/src/lib/design-tokens.ts b/examples/sveltekit/src/lib/design-tokens.ts
new file mode 100644
index 0000000..22d7d00
--- /dev/null
+++ b/examples/sveltekit/src/lib/design-tokens.ts
@@ -0,0 +1,27 @@
+// In-code token table for the example — `resolveToken` looks ids up here.
+// Mirrors examples/nextjs/lib/design-tokens.ts. A real integration would point
+// at CSS vars, a Tailwind theme, or a DTCG package.
+export const designTokens: Record = {
+ 'size.none': '0',
+ 'size.sm': '12px',
+ 'size.md': '24px',
+ 'size.lg': '40px',
+ 'size.xl': '64px',
+
+ 'color.none': 'transparent',
+ 'color.white': '#ffffff',
+ 'color.text': '#1f2937',
+ 'color.primary': '#0f172a',
+ 'color.primaryText': '#f8fafc',
+
+ 'fontSize.sm': '14px',
+ 'fontSize.md': '16px',
+ 'fontSize.lg': '20px',
+ 'fontSize.xl': '28px',
+ 'fontSize.2xl': '36px',
+ 'fontSize.3xl': '48px',
+
+ 'fontWeight.regular': '400',
+ 'fontWeight.medium': '500',
+ 'fontWeight.bold': '700',
+};
diff --git a/examples/sveltekit/src/lib/experience-config.ts b/examples/sveltekit/src/lib/experience-config.ts
index df7bd7e..d23c2b3 100644
--- a/examples/sveltekit/src/lib/experience-config.ts
+++ b/examples/sveltekit/src/lib/experience-config.ts
@@ -1,44 +1,41 @@
/**
- * Maps Contentful component/template ids to the app's components, and wires
- * `resolveToken`. Components read design via `getDesignValues()`.
+ * Maps Contentful component/template ids to the app's Svelte components,
+ * and wires `resolveToken`. Registry keys match the last URN segment of
+ * each node's `componentType` / `template`. Components read design via
+ * `getDesignValues()`.
*/
-import {
- defineComponent,
- type Components,
- type Config,
- type ResolveToken,
- type Templates,
-} from '@contentful/experiences-svelte';
+import type { Components, Config, ResolveToken, Templates } from '@contentful/experiences-svelte';
import Button from './components/Button.svelte';
-import Header, { type HeaderProps } from './components/Header.svelte';
+import Card from './components/Card.svelte';
+import Heading from './components/Heading.svelte';
+import HeroPlain from './components/HeroPlain.svelte';
+import Image from './components/Image.svelte';
import Page from './components/Page.svelte';
+import RichText from './components/RichText.svelte';
+import Section from './components/Section.svelte';
import Text from './components/Text.svelte';
+import { designTokens } from './design-tokens.js';
const components: Components = {
- button: Button,
- text: Text,
- header: defineComponent({
- component: Header,
- defaults: { text: 'Hello World' },
- }),
+ Section,
+ Heading,
+ RichText,
+ Text,
+ Button,
+ Image,
+ 'hero-plain': HeroPlain,
+ card: Card,
};
const templates: Templates = {
- hi: { component: Page, defaults: { title: 'Welcome' } },
- hero: { component: Page, defaults: { title: 'Featured' } },
+ page: Page,
};
-// Resolves opaque token ids to their underlying values — only you know what a
-// token id means. Returning undefined drops the key. A real app might use CSS
-// vars, a Tailwind theme, or a tokens package.
-const brandTokens: Record = {
- 'color.surface.hero': '#4f39f6',
- 'color.surface.subtle': '#f4f4f5',
- 'color.text.onPrimary': '#ffffff',
-};
-
-const resolveToken: ResolveToken = (token) => brandTokens[token.value];
+// Resolves opaque token ids (`size.xl`, `color.text`) to their underlying
+// values — the SDK doesn't know what a token id means, only you do. Returning
+// undefined drops the key.
+const resolveToken: ResolveToken = (token) => designTokens[token.value];
export const experienceConfig: Config = { components, templates, resolveToken };
diff --git a/examples/sveltekit/src/routes/+page.svelte b/examples/sveltekit/src/routes/+page.svelte
index e93afbf..bf2dcbd 100644
--- a/examples/sveltekit/src/routes/+page.svelte
+++ b/examples/sveltekit/src/routes/+page.svelte
@@ -12,7 +12,7 @@
View the demo experience
diff --git a/examples/sveltekit/src/routes/[slug]/+page.server.ts b/examples/sveltekit/src/routes/[slug]/+page.server.ts
index f5412f6..baddb2c 100644
--- a/examples/sveltekit/src/routes/[slug]/+page.server.ts
+++ b/examples/sveltekit/src/routes/[slug]/+page.server.ts
@@ -1,7 +1,7 @@
import { error } from '@sveltejs/kit';
import { NotFoundError, fetchExperience } from '@contentful/experiences-svelte';
-import { CDA_TOKEN, ENVIRONMENT_ID, SPACE_ID } from '$env/static/private';
+import { env } from '$env/dynamic/private';
import { detectViewportFromUserAgent } from '$lib/detect-viewport.js';
import { experienceConfig } from '$lib/experience-config.js';
@@ -15,12 +15,14 @@ export const load: PageServerLoad = async ({ params, url, request }) => {
try {
const experience = await fetchExperience(
{
- spaceId: SPACE_ID,
- environmentId: ENVIRONMENT_ID || 'master',
+ spaceId: env.SPACE_ID,
+ environmentId: env.ENVIRONMENT_ID || 'master',
experienceId: params.slug,
},
{
- accessToken: CDA_TOKEN,
+ // Preview mode reads from the CPA endpoint, which needs a Content
+ // Preview token — the CDA token is rejected by that host.
+ accessToken: previewMode && env.CPA_TOKEN ? env.CPA_TOKEN : env.CDA_TOKEN,
host: previewMode ? 'https://preview.xdn.contentful.com' : 'https://xdn.contentful.com',
},
{
diff --git a/examples/sveltekit/vite.config.ts b/examples/sveltekit/vite.config.ts
index 19f5bb3..24b925e 100644
--- a/examples/sveltekit/vite.config.ts
+++ b/examples/sveltekit/vite.config.ts
@@ -13,6 +13,7 @@ export default defineConfig({
'@contentful/experiences-svelte',
'@contentful/experiences-sdk-core',
'@contentful/experiences-design',
+ '@contentful/experiences-client',
],
},
server: {
diff --git a/package-lock.json b/package-lock.json
index 37174a8..0969ad0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,8 @@
"license": "MIT",
"workspaces": [
"packages/*",
- "examples/*"
+ "examples/*",
+ "test-apps/*"
],
"devDependencies": {
"@commitlint/cli": "^18.6.1",
@@ -59,6 +60,18 @@
"typescript": "^5.4.0"
}
},
+ "examples/scripts": {
+ "name": "@contentful/experiences-example-scripts",
+ "version": "0.0.0",
+ "dependencies": {
+ "contentful-management": "12.6.0-dev.4"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "tsx": "^4.19.0",
+ "typescript": "^5.4.0"
+ }
+ },
"examples/sveltekit": {
"name": "@contentful/experiences-example-sveltekit",
"version": "0.0.0",
@@ -161,6 +174,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -2173,6 +2187,10 @@
"resolved": "examples/nextjs",
"link": true
},
+ "node_modules/@contentful/experiences-example-scripts": {
+ "resolved": "examples/scripts",
+ "link": true
+ },
"node_modules/@contentful/experiences-example-sveltekit": {
"resolved": "examples/sveltekit",
"link": true
@@ -2189,6 +2207,23 @@
"resolved": "packages/adapter-svelte",
"link": true
},
+ "node_modules/@contentful/experiences-testapp-nextjs": {
+ "resolved": "test-apps/nextjs",
+ "link": true
+ },
+ "node_modules/@contentful/experiences-testapp-sveltekit": {
+ "resolved": "test-apps/sveltekit",
+ "link": true
+ },
+ "node_modules/@contentful/rich-text-types": {
+ "version": "16.8.5",
+ "resolved": "https://registry.npmjs.org/@contentful/rich-text-types/-/rich-text-types-16.8.5.tgz",
+ "integrity": "sha512-q18RJuJCOuYveGiCIjE5xLCQc5lZ3L2Qgxrlg/H2YEobDFqdtmklazRi1XwEWaK3tMg6yVXBzKKkQfLB4qW14A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz",
@@ -4373,7 +4408,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4521,6 +4555,7 @@
"integrity": "sha512-JXHbsDwRes1Wgyof3q5ApzzpbCWvinKXMQCiV67TFO6xlZPYLoK0fq3xQMqSicDMgCtFGqLkrQXkseOcASdZ8A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.9",
@@ -4626,6 +4661,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",
@@ -4862,6 +4898,7 @@
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4886,6 +4923,7 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4942,6 +4980,7 @@
"integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/types": "8.62.1",
@@ -5049,6 +5088,7 @@
"integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -5311,6 +5351,7 @@
"integrity": "sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"argparse": "^2.0.1"
},
@@ -5324,6 +5365,7 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5488,14 +5530,12 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/axios": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
- "dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
@@ -5745,6 +5785,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
@@ -5821,7 +5862,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -5831,6 +5871,22 @@
"node": ">= 0.4"
}
},
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -6119,7 +6175,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -6173,6 +6228,53 @@
"node": "^14.18.0 || >=16.10.0"
}
},
+ "node_modules/contentful-management": {
+ "version": "12.6.0-dev.4",
+ "resolved": "https://registry.npmjs.org/contentful-management/-/contentful-management-12.6.0-dev.4.tgz",
+ "integrity": "sha512-1ajUmSSR6WfcvOO9bJ7RrkQY1MZwORMU5TLBid51j+KRyRv2mVmvU4TugSaWZE0poddvDta92rUKK7nOMcGpFw==",
+ "license": "MIT",
+ "dependencies": {
+ "@contentful/rich-text-types": "^16.6.1",
+ "axios": "^1.15.0",
+ "contentful-sdk-core": "^9.4.4",
+ "fast-copy": "^3.0.0",
+ "globals": "^15.15.0",
+ "process": "^0.11.10"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/contentful-management/node_modules/globals": {
+ "version": "15.15.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
+ "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/contentful-sdk-core": {
+ "version": "9.4.5",
+ "resolved": "https://registry.npmjs.org/contentful-sdk-core/-/contentful-sdk-core-9.4.5.tgz",
+ "integrity": "sha512-8eGTMO11LXFAiosaiV38bjvW6cjoQFtNE38pNtVilZTXpmGFinClljihH0eriv11Ispd4q82TqtsXMJPYD/C+A==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-copy": "^3.0.2",
+ "lodash": "^4.17.23",
+ "process": "^0.11.10",
+ "qs": "^6.15.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-linux-x64-gnu": "^4.18.0"
+ }
+ },
"node_modules/conventional-changelog-angular": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz",
@@ -6255,6 +6357,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",
@@ -6487,7 +6590,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -6600,7 +6702,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -6701,7 +6802,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -6711,7 +6811,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -6721,7 +6820,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -6734,7 +6832,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -6753,6 +6850,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -6817,6 +6915,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",
@@ -7135,6 +7234,12 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
+ "node_modules/fast-copy": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz",
+ "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -7307,7 +7412,6 @@
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
- "dev": true,
"funding": [
{
"type": "individual",
@@ -7328,7 +7432,6 @@
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -7374,7 +7477,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -7427,7 +7529,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -7452,7 +7553,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -7603,7 +7703,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -7636,7 +7735,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -7649,7 +7747,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -7665,7 +7762,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -8306,6 +8402,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -8329,6 +8426,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -8755,7 +8853,6 @@
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/lodash.camelcase": {
@@ -9032,7 +9129,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -9096,7 +9192,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -9106,7 +9201,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@@ -9402,6 +9496,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@emnapi/core": "1.4.5",
"@emnapi/runtime": "1.4.5",
@@ -9665,6 +9760,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -10050,6 +10157,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
@@ -10156,11 +10264,19 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -10198,6 +10314,22 @@
"node": ">=6"
}
},
+ "node_modules/qs": {
+ "version": "6.15.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/quick-lru": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz",
@@ -10213,6 +10345,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"
}
@@ -10222,6 +10355,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"
},
@@ -10854,6 +10988,78 @@
"node": ">=8"
}
},
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -11277,6 +11483,7 @@
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -11748,91 +11955,595 @@
"node": ">= 12"
}
},
- "node_modules/type-check": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
- "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "node_modules/tsx": {
+ "version": "4.23.1",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
+ "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "prelude-ls": "^1.2.1"
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
},
"engines": {
- "node": ">= 0.8.0"
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
}
},
- "node_modules/type-detect": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
- "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
+ "node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
"dev": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
"engines": {
- "node": ">=4"
+ "node": ">=18"
}
},
- "node_modules/type-fest": {
- "version": "0.18.1",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz",
- "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==",
+ "node_modules/tsx/node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
"dev": true,
- "license": "(MIT OR CC0-1.0)",
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
"engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "node": ">=18"
}
},
- "node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "node_modules/tsx/node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
"engines": {
- "node": ">=14.17"
+ "node": ">=18"
}
},
- "node_modules/ufo": {
- "version": "1.6.4",
- "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
- "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/undici": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
- "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
+ "node_modules/tsx/node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
"engines": {
- "node": ">=20.18.1"
+ "node": ">=18"
}
},
- "node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/unicode-canonical-property-names-ecmascript": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
- "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==",
+ "node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
"engines": {
- "node": ">=4"
+ "node": ">=18"
}
},
- "node_modules/unicode-match-property-ecmascript": {
+ "node_modules/tsx/node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tsx/node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-detect": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
+ "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.18.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz",
+ "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
+ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+ "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unicode-canonical-property-names-ecmascript": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
+ "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-ecmascript": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
"integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
@@ -11931,6 +12642,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -12471,6 +13183,7 @@
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vitest/expect": "1.6.1",
"@vitest/runner": "1.6.1",
@@ -13027,6 +13740,38 @@
"dependencies": {
"@contentful/experiences-sdk-core": "0.5.2"
}
+ },
+ "test-apps/nextjs": {
+ "name": "@contentful/experiences-testapp-nextjs",
+ "version": "0.0.0",
+ "dependencies": {
+ "@contentful/experiences-react": "*",
+ "next": "^15.0.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "typescript": "^5.4.0"
+ }
+ },
+ "test-apps/sveltekit": {
+ "name": "@contentful/experiences-testapp-sveltekit",
+ "version": "0.0.0",
+ "dependencies": {
+ "@contentful/experiences-svelte": "*"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^3.3.0",
+ "@sveltejs/kit": "^2.8.0",
+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "typescript": "^5.4.0",
+ "vite": "^5.4.0"
+ }
}
}
}
diff --git a/package.json b/package.json
index 2a99004..933dead 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,8 @@
},
"workspaces": [
"packages/*",
- "examples/*"
+ "examples/*",
+ "test-apps/*"
],
"scripts": {
"build": "nx run-many -t build",
diff --git a/test-apps/nextjs/.env.example b/test-apps/nextjs/.env.example
new file mode 100644
index 0000000..2bddab2
--- /dev/null
+++ b/test-apps/nextjs/.env.example
@@ -0,0 +1,5 @@
+# Copy this file to .env.local and fill in real values from a Contentful space.
+
+SPACE_ID=
+ENVIRONMENT_ID=master
+CDA_TOKEN=
diff --git a/test-apps/nextjs/README.md b/test-apps/nextjs/README.md
new file mode 100644
index 0000000..949ecea
--- /dev/null
+++ b/test-apps/nextjs/README.md
@@ -0,0 +1,233 @@
+# Next.js example: Contentful Experiences
+
+A Next.js 15 App Router app demonstrating `@contentful/experiences-react` rendering an Experience payload fetched from XDA.
+
+## What it shows
+
+- **Server-side fetch and 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`.
+- **Minimal page**: `fetchExperience` feeding ``, 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.
+- **Styling via `useDesignValues()` and `toCss()`**: components read their own design (spacing, color, typography, layout) from the hook; design is never injected as props. `Section`, `Heading`, `Text`, `Button`, `Image`, and `RichText` all follow this pattern.
+- **Design tokens**: `lib/experience-config.tsx` wires a `resolveToken` that maps token ids (`size.xl`, `color.text`, and so on) to CSS values from `lib/design-tokens.ts`.
+- **Component registration**: bare components for the common case, `defineComponent({...})` when a component needs `defaults` or `resolveData`.
+
+## Run it
+
+```sh
+# From the repo root:
+npm install --ignore-scripts
+npm run build # builds the SDK packages
+
+cd examples/nextjs
+cp .env.example .env.local # fill in SPACE_ID + CDA_TOKEN
+npm run dev
+```
+
+Then visit `http://localhost:3000/`. The slug becomes the Experience ID passed to `client.view.getExperience`.
+
+## Two routes, same data
+
+The example ships two side-by-side routes so you can see what each SDK option gives you. They render the same Experience id; only the SDK setup changes.
+
+| Route | Page | Config | Demonstrates |
+| ------------------ | ---------------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `/[slug]` | [`app/[slug]/page.tsx`](./app/[slug]/page.tsx) | `experience-config.tsx` | The minimum: `fetchExperience` into `` 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`, User-Agent to `initialViewportId`, async `resolveData` with external fetch. |
+
+The minimal `[slug]/page.tsx`:
+
+```tsx
+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, or empty locale) is **not** a 404; it resolves to a plan with `nodes: []` and renders as an empty page.
+
+Slug-to-ID mapping is up to you. See the SDK roadmap in [`AGENTS.md`](../../AGENTS.md) for the longer-term direction.
+
+## File map
+
+```
+examples/nextjs/
+├── app/
+│ ├── layout.tsx # root layout
+│ ├── page.tsx # index; links to both demo routes
+│ ├── [slug]/page.tsx # SIMPLE: fetch + render + 404 handling
+│ └── advanced/[slug]/page.tsx # ADVANCED: preview, UA seeding, async resolveData
+├── components/ # design-system components; read design via useDesignValues()
+│ ├── Section.tsx # flex/grid layout primitive
+│ ├── Heading.tsx
+│ ├── Text.tsx
+│ ├── RichText.tsx # minimal rich-text renderer
+│ ├── Image.tsx
+│ ├── Button.tsx
+│ └── Page.tsx # used as the page-level template
+└── lib/
+ ├── design-tokens.ts # token id to CSS value table (used by resolveToken)
+ ├── detect-viewport.ts # User-Agent to 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
+```
+
+## Integration pattern
+
+The example separates **two layers**:
+
+1. **Design-system components** (`components/Section.tsx`, `components/Heading.tsx`, …) receive their **content** props (`text`, `label`, `src`) and read design themselves via `useDesignValues()`. They import nothing SDK-shaped beyond that hook, and it returns `{}` outside a renderer, so they degrade gracefully.
+2. **The experience config** (`lib/experience-config.tsx`) is the integration layer: it maps each `componentTypeId` to a component (bare, or `defineComponent({...})` for `defaults` / `resolveData`), maps `templateId`s under `templates`, and wires `resolveToken`. It composes into the single `experienceConfig` object the renderer takes.
+
+Why split this way: SDK-shaped concerns (registration, defaults, async resolvers, token resolution) all live in one file you can scan to understand the whole integration surface.
+
+```tsx
+// components/Heading.tsx: content prop + design read from the hook
+'use client';
+import { toCss, useDesignValues } from '@contentful/experiences-react';
+
+export function Heading({ text }: { text?: string }) {
+ const design = useDesignValues<{ as?: 'h1' | 'h2' | 'h3' }>();
+ const Tag = design.as ?? 'h2'; // semantic key, read by name
+ return {text} ; // toCss keeps CSS-shaped keys
+}
+```
+
+```tsx
+// lib/experience-config.tsx: adapter layer
+import { defineComponent, type Config, type ResolveToken } from '@contentful/experiences-react';
+import { Heading } from '@/components/Heading';
+import { Page } from '@/components/Page';
+import { designTokens } from '@/lib/design-tokens';
+
+const components = {
+ Heading: defineComponent<{ text?: string }>({
+ defaults: { text: 'Untitled' },
+ component: Heading,
+ }),
+ // ... other component types (bare or config) ...
+};
+
+const templates = { page: Page };
+
+const resolveToken: ResolveToken = (token) => designTokens[token.value];
+
+export const experienceConfig: Config = { components, templates, resolveToken };
+```
+
+### Merge precedence
+
+The component receives a flat set of props composed of (last-wins):
+
+1. `defaults` (componentConfig.defaults, fallback values)
+2. `contentProperties` (editorial values from the payload)
+3. `resolveData()` (return value of componentConfig.resolveData, see below)
+4. slot props (each named slot becomes a pre-rendered React subtree)
+
+Design values are **not** included here; they're read via `useDesignValues()`. So a payload like:
+
+```json
+{
+ "componentType": { "sys": { "urn": ".../componentTypes/Button" } },
+ "contentProperties": { "label": "Click me", "url": "example.com/go" },
+ "designProperties": { "target": { "type": "ManualDesignValue", "value": "_self" } }
+}
+```
+
+reaches your `Button` as `{ label: 'Click me', url: 'https://example.com/go' }` (after its `resolveData` runs), while `useDesignValues()` returns `{ target: '_self' }`.
+
+### `resolveData`: sync or async transforms
+
+Each entry can declare a `resolveData` hook that derives final props from the
+raw inputs. Useful for reshaping editorial fields, fetching enrichment, or
+localizing URLs. The result is merged in **after** content but **before**
+slots.
+
+```tsx
+PriceTag: defineComponent({
+ resolveData: async ({ content }) => ({
+ formattedPrice: await formatPriceFromCatalog(content.sku),
+ }),
+ component: PriceTag,
+}),
+```
+
+The route calls `fetchExperience` once, and it handles the API call and resolution in one step:
+
+```ts
+const experience = await fetchExperience(
+ { spaceId: process.env.SPACE_ID!, environmentId: 'master', experienceId: slug },
+ { accessToken: process.env.CDA_TOKEN! },
+ { config: experienceConfig }
+);
+```
+
+Resolvers run in parallel across nodes. Viewport resolution stays at render
+time, so client-side viewport changes never re-trigger `resolveData`.
+
+#### Optional `context`
+
+The `context` option (on `resolveOptions`, the third arg) passes per-render context into every component's `resolveData` hook (and to components via `useExperience()`).
+Default is `{ isPreview: false, metadata: {} }`, which is fine for production. Add
+fields when:
+
+- **Preview mode**: `{ isPreview: true }`. `MissingComponent` renders a visible
+ red box, and your own components can branch on it too. 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, and so on.
+
+```ts
+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
+parsed on the server) when you want SSR output to match the device's expected
+viewport. Otherwise the renderer defaults to `viewports[0]`.
+
+### `defineTemplate`: page-level wrappers
+
+When a payload carries `sys.template`, the SDK looks up a matching id under
+`Config.templates` and wraps the rendered nodes with the template's component.
+Templates use the same `defaults` / `resolveData` shape as components; the
+only structural difference is that the component always receives a fixed
+`children: ReactNode` (the rendered experience) alongside its declared props.
+
+```tsx
+import { defineTemplate } from '@contentful/experiences-react';
+import { Page } from './Page';
+
+const templates = {
+ // bare component, or defineTemplate({...}) for defaults / resolveData
+ page: defineTemplate({
+ defaults: { title: 'Welcome' },
+ component: Page, // Page receives { title, children }
+ }),
+};
+
+export const experienceConfig: Config = { components, templates };
+```
+
+If the payload references a template id that isn't registered, the renderer
+warns once and renders the nodes unwrapped, the same graceful-degradation
+behavior as missing components.
+
+## Where the live preview / editor support fits
+
+Live preview (postMessage from the Contentful editor iframe) lands in a separate increment with a client-component wrapper that uses `ClientExperienceRenderer` and a `useMessagingClient`-style hook. SSR and interactive editor mode are mutually exclusive: the editor mode requires `'use client'` so the message listener can attach.
diff --git a/test-apps/nextjs/app/[slug]/page.tsx b/test-apps/nextjs/app/[slug]/page.tsx
new file mode 100644
index 0000000..1cfa4fd
--- /dev/null
+++ b/test-apps/nextjs/app/[slug]/page.tsx
@@ -0,0 +1,33 @@
+import { notFound } from 'next/navigation';
+import {
+ NotFoundError,
+ ServerExperienceRenderer,
+ fetchExperience,
+} from '@contentful/experiences-react';
+
+import { experienceConfig } from '@/lib/experience-config';
+
+interface PageProps {
+ params: Promise<{ slug: string }>;
+}
+
+export default async function ExperiencePage({ params }: PageProps) {
+ const { slug: experienceId } = await params;
+
+ 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 ;
+ } catch (err) {
+ if (err instanceof NotFoundError) notFound();
+ throw err;
+ }
+}
diff --git a/test-apps/nextjs/app/advanced/[slug]/page.tsx b/test-apps/nextjs/app/advanced/[slug]/page.tsx
new file mode 100644
index 0000000..617ff67
--- /dev/null
+++ b/test-apps/nextjs/app/advanced/[slug]/page.tsx
@@ -0,0 +1,77 @@
+import { headers } from 'next/headers';
+import { notFound } from 'next/navigation';
+import {
+ NotFoundError,
+ ServerExperienceRenderer,
+ fetchExperience,
+} from '@contentful/experiences-react';
+
+import { detectViewportFromUserAgent } from '@/lib/detect-viewport';
+import { advancedExperienceConfig } from '@/lib/experience-config-advanced';
+
+interface PageProps {
+ params: Promise<{ slug: string }>;
+ searchParams?: Promise>;
+}
+
+/**
+ * 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 `context` arg of
+ * `fetchExperience`. `?preview=true` flips `MissingComponent` from
+ * "silent null" to "visible red box"; metadata flows into every
+ * `resolveData` hook.
+ * 2. **User-Agent → viewport seeding** via `initialViewportId` so SSR
+ * renders at the device's expected viewport (avoids hydration drift on
+ * the client renderer's first paint).
+ * 3. **Async `resolveData` with external fetch** — the advanced config
+ * `lib/experience-config-advanced.tsx` does a fake catalog fetch on the
+ * `button` component and uppercases the editorial text. The SDK runs
+ * resolvers in parallel across nodes, so the slow resolver doesn't
+ * block the others.
+ */
+export default async function AdvancedExperiencePage({ params, searchParams }: PageProps) {
+ const { slug: experienceId } = await params;
+ const sp = (await searchParams) ?? {};
+
+ const previewMode = sp.preview === 'true' || sp.preview === '1';
+ const locale = typeof sp.locale === 'string' ? sp.locale : 'en-US';
+
+ const userAgent = (await headers()).get('user-agent') ?? '';
+ const initialViewportId = detectViewportFromUserAgent(userAgent);
+
+ try {
+ const experience = await fetchExperience(
+ {
+ spaceId: process.env.SPACE_ID ?? '',
+ environmentId: process.env.ENVIRONMENT_ID ?? 'master',
+ experienceId,
+ locale,
+ },
+ {
+ accessToken: process.env.CDA_TOKEN!,
+ host: previewMode ? 'https://preview.xdn.contentful.com' : 'https://xdn.contentful.com',
+ },
+ {
+ config: advancedExperienceConfig,
+ context: {
+ isPreview: previewMode,
+ metadata: { slug: experienceId, locale },
+ },
+ }
+ );
+
+ return (
+
+ );
+ } catch (err) {
+ if (err instanceof NotFoundError) notFound();
+ throw err;
+ }
+}
diff --git a/test-apps/nextjs/app/layout.tsx b/test-apps/nextjs/app/layout.tsx
new file mode 100644
index 0000000..a918505
--- /dev/null
+++ b/test-apps/nextjs/app/layout.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react';
+
+export const metadata = {
+ title: 'Contentful Experiences — Next.js example',
+ description: 'Demonstrates @contentful/experiences-react with the Next.js App Router.',
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/test-apps/nextjs/app/page.tsx b/test-apps/nextjs/app/page.tsx
new file mode 100644
index 0000000..a019c68
--- /dev/null
+++ b/test-apps/nextjs/app/page.tsx
@@ -0,0 +1,85 @@
+import Link from 'next/link';
+
+const linkStyle = {
+ display: 'inline-block',
+ padding: '10px 16px',
+ borderRadius: 8,
+ background: '#4f39f6',
+ color: '#ffffff',
+ textDecoration: 'none',
+ fontWeight: 500,
+};
+
+const secondaryLinkStyle = {
+ ...linkStyle,
+ background: '#ffffff',
+ color: '#1f2937',
+ border: '1px solid #d1d5db',
+};
+
+export default function HomePage() {
+ return (
+
+ Contentful Experiences — Next.js example
+
+ This app demonstrates rendering a Contentful Experience payload with{' '}
+ @contentful/experiences-react in a Next.js App Router server component.
+
+
+ Two routes, same data
+
+ Both routes render the same Experience id. Compare to see what each SDK option buys you.
+
+
+
+
+
+ /[slug]
+ {' '}
+ — minimal three-line page. fetch →{' '}
+ resolveExperience(payload, config) →{' '}
+ <ServerExperienceRenderer>. No preview mode, no UA seeding, no
+ metadata.
+
+
+
+ /advanced/[slug]
+ {' '}
+ — same render, opts dialed up: preview mode via ?preview=true, User-Agent →{' '}
+ initialViewportId for hydration-safe SSR, async resolveData with
+ external fetch + per-page metadata.
+
+
+
+
+
+ Simple demo
+
+
+ Advanced demo (preview)
+
+
+
+
+ Replace demo in either URL with a real Experience id from your space.
+
+
+ );
+}
diff --git a/test-apps/nextjs/components/Button.tsx b/test-apps/nextjs/components/Button.tsx
new file mode 100644
index 0000000..a670660
--- /dev/null
+++ b/test-apps/nextjs/components/Button.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import type { CSSProperties } from 'react';
+
+import { useDesignValues } from '@contentful/experiences-react';
+
+export interface ButtonProps {
+ label?: string;
+ url?: string | null;
+}
+
+export function Button({ label, url }: ButtonProps) {
+ const design = useDesignValues();
+ const target = (design.target as string | undefined) ?? '_self';
+
+ const style: CSSProperties = {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 6,
+ padding: '12px 18px',
+ borderRadius: 8,
+ background: (design.backgroundColor as string) ?? '#4f39f6',
+ color: (design.color as string) ?? '#ffffff',
+ fontWeight: 500,
+ border: 'none',
+ textDecoration: 'none',
+ cursor: 'pointer',
+ };
+
+ const content = <>{label ?? 'Button'}>;
+
+ if (url) {
+ return (
+
+ {content}
+
+ );
+ }
+ return (
+
+ {content}
+
+ );
+}
diff --git a/test-apps/nextjs/components/Heading.tsx b/test-apps/nextjs/components/Heading.tsx
new file mode 100644
index 0000000..341615b
--- /dev/null
+++ b/test-apps/nextjs/components/Heading.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import type { CSSProperties, ReactNode } from 'react';
+
+import { toCss, useDesignValues } from '@contentful/experiences-react';
+
+export type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
+
+export interface HeadingProps {
+ text?: string;
+ children?: ReactNode;
+}
+
+interface HeadingDesign {
+ as?: HeadingTag;
+ align?: CSSProperties['textAlign'];
+ fontSize?: string;
+ fontWeight?: string;
+}
+
+/**
+ * `useDesignValues()` types the design bag (like `useState()`).
+ * `as` picks the tag (semantic); `toCss` keeps the CSS-shaped keys and drops
+ * `as`; `align` is this design system's shorthand for `text-align`, mapped by name.
+ */
+export function Heading({ text, children }: HeadingProps) {
+ const design = useDesignValues();
+ const Tag = design.as ?? 'h2';
+
+ const style: CSSProperties = {
+ margin: 0,
+ color: '#1f2937',
+ textAlign: design.align,
+ ...toCss(design),
+ };
+
+ return (
+
+ {text}
+ {children}
+
+ );
+}
diff --git a/test-apps/nextjs/components/Image.tsx b/test-apps/nextjs/components/Image.tsx
new file mode 100644
index 0000000..5770c07
--- /dev/null
+++ b/test-apps/nextjs/components/Image.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import type { CSSProperties } from 'react';
+
+import { useDesignValues } from '@contentful/experiences-react';
+
+export interface ImageProps {
+ src?: string;
+ alt?: string | null;
+}
+
+export function Image({ src, alt }: ImageProps) {
+ const design = useDesignValues();
+ if (!src) return null;
+
+ const style: CSSProperties = {
+ display: 'block',
+ width: '100%',
+ height: 'auto',
+ objectFit: 'cover',
+ borderRadius: (design.radius as string) ?? undefined,
+ };
+
+ return ;
+}
diff --git a/test-apps/nextjs/components/Page.tsx b/test-apps/nextjs/components/Page.tsx
new file mode 100644
index 0000000..4ca31fc
--- /dev/null
+++ b/test-apps/nextjs/components/Page.tsx
@@ -0,0 +1,19 @@
+import type { CSSProperties, ReactNode } from 'react';
+
+export interface PageProps {
+ title?: string;
+ children?: ReactNode;
+}
+
+/** Page-level template: wraps all top-level nodes in the outer page chrome. */
+export function Page({ children }: PageProps) {
+ const wrapper: CSSProperties = {
+ maxWidth: 1024,
+ margin: '0 auto',
+ padding: '48px 24px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 48,
+ };
+ return {children} ;
+}
diff --git a/test-apps/nextjs/components/RichText.tsx b/test-apps/nextjs/components/RichText.tsx
new file mode 100644
index 0000000..664edf8
--- /dev/null
+++ b/test-apps/nextjs/components/RichText.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { Fragment, type CSSProperties, type ReactNode } from 'react';
+
+import { useDesignValues } from '@contentful/experiences-react';
+
+// Minimal rich-text renderer (paragraphs + bold/italic) so the example stays
+// dependency-free; a real app would use @contentful/rich-text-react-renderer.
+
+interface Mark {
+ type: string;
+}
+interface RichTextNode {
+ nodeType: string;
+ value?: string;
+ marks?: Mark[];
+ content?: RichTextNode[];
+}
+
+// XDA wraps the document as `{ __typename, document }`; inner can be null.
+export interface RichTextProps {
+ document?: {
+ document?: RichTextNode | null;
+ } | null;
+}
+
+function renderNode(node: RichTextNode, key: number): ReactNode {
+ switch (node.nodeType) {
+ case 'document':
+ return {(node.content ?? []).map(renderNode)} ;
+ case 'paragraph':
+ return (
+
+ {(node.content ?? []).map(renderNode)}
+
+ );
+ case 'text': {
+ let el: ReactNode = node.value ?? '';
+ for (const mark of node.marks ?? []) {
+ if (mark.type === 'bold') el = {el} ;
+ if (mark.type === 'italic') el = {el} ;
+ }
+ return {el} ;
+ }
+ default:
+ return {(node.content ?? []).map(renderNode)} ;
+ }
+}
+
+export function RichText({ document }: RichTextProps) {
+ const design = useDesignValues();
+ const doc = document?.document;
+ if (!doc) return null;
+
+ const style: CSSProperties = {
+ color: '#4b5563',
+ lineHeight: 1.6,
+ fontSize: (design.fontSize as string) ?? undefined,
+ textAlign: (design.align as CSSProperties['textAlign']) ?? undefined,
+ };
+
+ return {renderNode(doc, 0)}
;
+}
diff --git a/test-apps/nextjs/components/Section.tsx b/test-apps/nextjs/components/Section.tsx
new file mode 100644
index 0000000..f27c242
--- /dev/null
+++ b/test-apps/nextjs/components/Section.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import type { CSSProperties, ReactNode } from 'react';
+
+import { useDesignValues } from '@contentful/experiences-react';
+
+export interface SectionProps {
+ children?: ReactNode;
+}
+
+type Align = 'start' | 'center' | 'end' | 'stretch';
+
+const ALIGN: Record = {
+ start: 'flex-start',
+ center: 'center',
+ end: 'flex-end',
+ stretch: 'stretch',
+};
+
+/**
+ * Flex/grid layout primitive. Reads its semantic design keys (`direction`,
+ * `ratio`, …) by name off `useDesignValues()`; token-valued keys arrive
+ * already resolved to CSS by `resolveToken`.
+ */
+export function Section({ children }: SectionProps) {
+ const design = useDesignValues();
+
+ const direction = (design.direction as 'row' | 'column') ?? 'column';
+ const reverse = design.reverse === true;
+ const ratio = design.ratio as string | undefined;
+ const itemAlign = (design.itemAlign as Align | undefined) ?? 'stretch';
+
+ const flexDirection =
+ `${direction}${reverse ? '-reverse' : ''}` as CSSProperties['flexDirection'];
+
+ const style: CSSProperties = {
+ display: 'flex',
+ flexDirection,
+ alignItems: ALIGN[itemAlign],
+ gap: (design.gap as string) ?? undefined,
+ paddingBlock: (design.verticalSpacing as string) ?? undefined,
+ paddingInline: (design.horizontalSpacing as string) ?? undefined,
+ backgroundColor: (design.backgroundColor as string) ?? undefined,
+ color: (design.color as string) ?? undefined,
+ borderRadius: (design.radius as string) ?? undefined,
+ };
+
+ // A colon ratio ("1:2:1") lays children out in grid tracks; else flex.
+ if (ratio && ratio.includes(':')) {
+ const tracks = ratio
+ .split(':')
+ .map((n) => `${Number(n) || 1}fr`)
+ .join(' ');
+ style.display = 'grid';
+ style.gridTemplateColumns = direction === 'column' ? undefined : tracks;
+ style.gridTemplateRows = direction === 'column' ? tracks : undefined;
+ delete style.flexDirection;
+ delete style.alignItems;
+ }
+
+ return {children}
;
+}
diff --git a/test-apps/nextjs/components/Text.tsx b/test-apps/nextjs/components/Text.tsx
new file mode 100644
index 0000000..20adf23
--- /dev/null
+++ b/test-apps/nextjs/components/Text.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import type { CSSProperties, ReactNode } from 'react';
+
+import { useDesignValues } from '@contentful/experiences-react';
+
+export interface TextProps {
+ text?: string | null;
+ children?: ReactNode;
+}
+
+export function Text({ text, children }: TextProps) {
+ const design = useDesignValues();
+ if (!text && !children) return null;
+
+ const style: CSSProperties = {
+ margin: 0,
+ color: '#4b5563',
+ lineHeight: 1.5,
+ fontSize: (design.fontSize as string) ?? '16px',
+ textAlign: (design.align as CSSProperties['textAlign']) ?? undefined,
+ };
+
+ return (
+
+ {text}
+ {children}
+
+ );
+}
diff --git a/test-apps/nextjs/lib/design-tokens.ts b/test-apps/nextjs/lib/design-tokens.ts
new file mode 100644
index 0000000..e07647e
--- /dev/null
+++ b/test-apps/nextjs/lib/design-tokens.ts
@@ -0,0 +1,24 @@
+// In-code token table for the example — `resolveToken` looks ids up here.
+// A real integration would point at CSS vars, a Tailwind theme, or a DTCG package.
+export const designTokens: Record = {
+ 'size.none': '0',
+ 'size.sm': '12px',
+ 'size.md': '24px',
+ 'size.lg': '40px',
+ 'size.xl': '64px',
+
+ 'color.none': 'transparent', // resolves "no background" to a real value
+ 'color.white': '#ffffff',
+ 'color.text': '#1f2937',
+
+ 'fontSize.sm': '14px',
+ 'fontSize.md': '16px',
+ 'fontSize.lg': '20px',
+ 'fontSize.xl': '28px',
+ 'fontSize.2xl': '36px',
+ 'fontSize.3xl': '48px',
+
+ 'fontWeight.regular': '400',
+ 'fontWeight.medium': '500',
+ 'fontWeight.bold': '700',
+};
diff --git a/test-apps/nextjs/lib/detect-viewport.ts b/test-apps/nextjs/lib/detect-viewport.ts
new file mode 100644
index 0000000..4377e50
--- /dev/null
+++ b/test-apps/nextjs/lib/detect-viewport.ts
@@ -0,0 +1,19 @@
+/**
+ * Detect a viewport id from a User-Agent string for SSR seeding.
+ *
+ * The returned id seeds `ServerExperienceRenderer`'s `initialViewportId` so
+ * the server-rendered output matches the device's expected viewport. The
+ * client renderer uses the same id as its first paint, then transitions to
+ * live `matchMedia` matching as the window resizes — avoids hydration drift.
+ */
+
+export function detectViewportFromUserAgent(userAgent: string): string {
+ const ua = userAgent.toLowerCase();
+ if (/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/.test(ua)) {
+ return 'mobile';
+ }
+ if (/ipad|tablet|kindle|playbook|silk/.test(ua)) {
+ return 'tablet';
+ }
+ return 'desktop';
+}
diff --git a/test-apps/nextjs/lib/experience-config-advanced.tsx b/test-apps/nextjs/lib/experience-config-advanced.tsx
new file mode 100644
index 0000000..0a67cb1
--- /dev/null
+++ b/test-apps/nextjs/lib/experience-config-advanced.tsx
@@ -0,0 +1,54 @@
+/**
+ * Advanced config: async `resolveData` on `Button` (fake fetch + a localized
+ * URL from `experience.metadata`), on top of the simple `./experience-config`.
+ */
+
+import {
+ defineComponent,
+ type Components,
+ type Config,
+ type Templates,
+} from '@contentful/experiences-react';
+
+import { Button, type ButtonProps } from '@/components/Button';
+import { Heading } from '@/components/Heading';
+import { Image } from '@/components/Image';
+import { Page } from '@/components/Page';
+import { RichText } from '@/components/RichText';
+import { Section } from '@/components/Section';
+import { Text } from '@/components/Text';
+
+// Stand-in for an async enrichment fetch; resolvers run in parallel per node.
+async function fetchButtonEnrichment(label: string): Promise<{ formattedLabel: string }> {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ return { formattedLabel: label.toUpperCase() };
+}
+
+const components: Components = {
+ Section,
+ Heading,
+ RichText,
+ Image,
+ Text,
+
+ // defineComponent narrows the resolveData ctx + return type to ButtonProps.
+ Button: defineComponent({
+ resolveData: async ({ content, experience }) => {
+ const rawLabel = (content.label as string) ?? 'Button';
+ const { formattedLabel } = await fetchButtonEnrichment(rawLabel);
+ const locale = (experience.metadata.locale as string) ?? 'en-US';
+ const slug = (experience.metadata.slug as string) ?? '';
+ return {
+ label: formattedLabel,
+ url: `/${locale}/${slug}`,
+ };
+ },
+ component: Button,
+ }),
+};
+
+const templates: Templates = {
+ page: { component: Page, defaults: { title: 'Featured (advanced)' } },
+};
+
+export const advancedExperienceConfig: Config = { components, templates };
diff --git a/test-apps/nextjs/lib/experience-config.tsx b/test-apps/nextjs/lib/experience-config.tsx
new file mode 100644
index 0000000..dcbf8e4
--- /dev/null
+++ b/test-apps/nextjs/lib/experience-config.tsx
@@ -0,0 +1,42 @@
+/**
+ * Maps Contentful component/template ids to the app's components, and wires
+ * `resolveToken`. Registry keys match the last URN segment of each node's
+ * `componentType` / `template`. Components read design via `useDesignValues()`.
+ */
+
+import {
+ type Components,
+ type Config,
+ type ResolveToken,
+ type Templates,
+} from '@contentful/experiences-react';
+
+import { Button } from '@/components/Button';
+import { Heading } from '@/components/Heading';
+import { Image } from '@/components/Image';
+import { Page } from '@/components/Page';
+import { RichText } from '@/components/RichText';
+import { Section } from '@/components/Section';
+import { Text } from '@/components/Text';
+import { designTokens } from '@/lib/design-tokens';
+
+const components: Components = {
+ Section,
+ Heading,
+ RichText,
+ Text,
+ Button,
+ Image,
+};
+
+const templates: Templates = {
+ page: Page,
+};
+
+// Resolves opaque token ids (`size.xl`, `color.text`) to their underlying
+// values — the SDK doesn't know what a token id means, only you do. Returning
+// undefined drops the key. A real app might use CSS vars or a tokens package,
+// e.g. `(token) => `var(--${token.value.replaceAll('.', '-')})``.
+const resolveToken: ResolveToken = (token) => designTokens[token.value];
+
+export const experienceConfig: Config = { components, templates, resolveToken };
diff --git a/test-apps/nextjs/next.config.mjs b/test-apps/nextjs/next.config.mjs
new file mode 100644
index 0000000..e80c325
--- /dev/null
+++ b/test-apps/nextjs/next.config.mjs
@@ -0,0 +1,15 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ // Allow Next to compile workspace packages from source rather than expecting
+ // pre-built dist artifacts in node_modules. Useful for local development of
+ // the SDK packages alongside this example.
+ transpilePackages: [
+ '@contentful/experiences-react',
+ // Workspace-internal deps — Next still needs to compile their source
+ // even though the customer's package.json only lists experiences-react.
+ '@contentful/experiences-sdk-core',
+ '@contentful/experiences-design',
+ ],
+};
+
+export default nextConfig;
diff --git a/test-apps/nextjs/package.json b/test-apps/nextjs/package.json
new file mode 100644
index 0000000..f990885
--- /dev/null
+++ b/test-apps/nextjs/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@contentful/experiences-testapp-nextjs",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Internal Next.js scratchpad for iterating on @contentful/experiences-* — not customer-facing. See examples/nextjs for the stable external example.",
+ "type": "module",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@contentful/experiences-react": "*",
+ "next": "^15.0.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "typescript": "^5.4.0"
+ }
+}
diff --git a/test-apps/nextjs/public/favicon.ico b/test-apps/nextjs/public/favicon.ico
new file mode 100644
index 0000000..49b2422
Binary files /dev/null and b/test-apps/nextjs/public/favicon.ico differ
diff --git a/test-apps/nextjs/tsconfig.json b/test-apps/nextjs/tsconfig.json
new file mode 100644
index 0000000..0c66908
--- /dev/null
+++ b/test-apps/nextjs/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/test-apps/sveltekit/.env.example b/test-apps/sveltekit/.env.example
new file mode 100644
index 0000000..40490a6
--- /dev/null
+++ b/test-apps/sveltekit/.env.example
@@ -0,0 +1,3 @@
+SPACE_ID=
+ENVIRONMENT_ID=master
+CDA_TOKEN=
diff --git a/test-apps/sveltekit/.gitignore b/test-apps/sveltekit/.gitignore
new file mode 100644
index 0000000..538f076
--- /dev/null
+++ b/test-apps/sveltekit/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+.svelte-kit/
+build/
+.env
+.env.local
+.env.*.local
+.DS_Store
diff --git a/test-apps/sveltekit/README.md b/test-apps/sveltekit/README.md
new file mode 100644
index 0000000..7a9a5f2
--- /dev/null
+++ b/test-apps/sveltekit/README.md
@@ -0,0 +1,60 @@
+# SvelteKit example: Contentful Experiences
+
+A SvelteKit 2 + Svelte 5 app demonstrating `@contentful/experiences-svelte` rendering an Experience payload fetched from XDA. Mirrors `examples/nextjs/` 1:1 in registered components, slugs, and visual output; the only thing that changes between the two apps is the framework-specific setup.
+
+## What it shows
+
+- **Server-side fetch and resolve** via `fetchExperience` re-exported from `@contentful/experiences-svelte`, which proves the fetch and 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`.
+- **Styling via `getDesignValues()` and `toCss()`**: components read their own design inside a `$derived` (reactive across viewport changes); design is never injected as props.
+- **Design tokens**: `experience-config.ts` wires a `resolveToken` mapping token ids to CSS values.
+- **Component registration**: bare Svelte components for the common case, `defineComponent({ component, ... })` when a component needs `defaults` or `resolveData`.
+
+## Run it
+
+```sh
+# From the repo root:
+npm install --ignore-scripts
+npm run build # builds the SDK packages
+
+cd examples/sveltekit
+cp .env.example .env # fill in SPACE_ID + CDA_TOKEN
+npm run dev
+```
+
+Then visit `http://localhost:5173/`. The slug becomes the Experience ID passed to `client.view.getExperience`.
+
+## File map
+
+```
+examples/sveltekit/
+├── src/
+│ ├── app.html # SvelteKit HTML shell
+│ ├── routes/
+│ │ ├── +layout.svelte # root layout
+│ │ ├── +page.svelte # index
+│ │ ├── [slug]/+page.server.ts # dynamic Experience load (server)
+│ │ └── [slug]/+page.svelte # dynamic Experience render
+│ └── lib/
+│ ├── components/ # plain design-system components; no SDK imports
+│ │ ├── Button.svelte
+│ │ ├── Header.svelte
+│ │ ├── Page.svelte # used as the page-level template
+│ │ └── Text.svelte
+│ ├── detect-viewport.ts
+│ └── experience-config.ts # integration layer (maps components + templates into experienceConfig)
+├── svelte.config.js
+├── vite.config.ts
+└── tsconfig.json
+```
+
+## Integration pattern
+
+Identical to the Next.js example:
+
+1. **Design-system components** stay portable, with 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(experienceOptions, clientOptions, resolveOptions)` and pass the result to ``, wrapped in a try/catch that routes `NotFoundError` to SvelteKit's `error(404, ...)`.
+
+The only Svelte-specific difference is slots: the default `children` slot is passed as a `children` Snippet prop (render it with `{@render children()}`), and any additional named slots are reachable via `getContentfulComponent().slots` and rendered through the exported ` `. Compare to the React adapter, where each slot becomes its own named React-node prop. See [`packages/adapter-svelte/README.md`](../../packages/adapter-svelte/README.md) for the full Svelte API surface.
diff --git a/test-apps/sveltekit/package.json b/test-apps/sveltekit/package.json
new file mode 100644
index 0000000..d48bb39
--- /dev/null
+++ b/test-apps/sveltekit/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@contentful/experiences-testapp-sveltekit",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Internal SvelteKit scratchpad for iterating on @contentful/experiences-svelte — not customer-facing. See examples/sveltekit for the stable external example.",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
+ },
+ "dependencies": {
+ "@contentful/experiences-svelte": "*"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^3.3.0",
+ "@sveltejs/kit": "^2.8.0",
+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "typescript": "^5.4.0",
+ "vite": "^5.4.0"
+ }
+}
diff --git a/test-apps/sveltekit/src/app.d.ts b/test-apps/sveltekit/src/app.d.ts
new file mode 100644
index 0000000..a6911e5
--- /dev/null
+++ b/test-apps/sveltekit/src/app.d.ts
@@ -0,0 +1,5 @@
+declare global {
+ namespace App {}
+}
+
+export {};
diff --git a/test-apps/sveltekit/src/app.html b/test-apps/sveltekit/src/app.html
new file mode 100644
index 0000000..973e1e4
--- /dev/null
+++ b/test-apps/sveltekit/src/app.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Contentful Experiences — SvelteKit example
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/test-apps/sveltekit/src/lib/components/Button.svelte b/test-apps/sveltekit/src/lib/components/Button.svelte
new file mode 100644
index 0000000..2240221
--- /dev/null
+++ b/test-apps/sveltekit/src/lib/components/Button.svelte
@@ -0,0 +1,53 @@
+
+
+
+
+
+{#if url}
+
+ {text}
+ {#if children}{@render children()}{/if}
+
+{:else}
+
+ {text}
+ {#if children}{@render children()}{/if}
+
+{/if}
diff --git a/examples/sveltekit/src/lib/components/Header.svelte b/test-apps/sveltekit/src/lib/components/Header.svelte
similarity index 100%
rename from examples/sveltekit/src/lib/components/Header.svelte
rename to test-apps/sveltekit/src/lib/components/Header.svelte
diff --git a/test-apps/sveltekit/src/lib/components/Page.svelte b/test-apps/sveltekit/src/lib/components/Page.svelte
new file mode 100644
index 0000000..fe3d4c4
--- /dev/null
+++ b/test-apps/sveltekit/src/lib/components/Page.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ {#if title}
+
+ {title}
+
+ {/if}
+ {@render children()}
+
diff --git a/test-apps/sveltekit/src/lib/components/Text.svelte b/test-apps/sveltekit/src/lib/components/Text.svelte
new file mode 100644
index 0000000..de56880
--- /dev/null
+++ b/test-apps/sveltekit/src/lib/components/Text.svelte
@@ -0,0 +1,17 @@
+
+
+
+
+
+ {value}
+ {#if children}{@render children()}{/if}
+
diff --git a/test-apps/sveltekit/src/lib/detect-viewport.ts b/test-apps/sveltekit/src/lib/detect-viewport.ts
new file mode 100644
index 0000000..4377e50
--- /dev/null
+++ b/test-apps/sveltekit/src/lib/detect-viewport.ts
@@ -0,0 +1,19 @@
+/**
+ * Detect a viewport id from a User-Agent string for SSR seeding.
+ *
+ * The returned id seeds `ServerExperienceRenderer`'s `initialViewportId` so
+ * the server-rendered output matches the device's expected viewport. The
+ * client renderer uses the same id as its first paint, then transitions to
+ * live `matchMedia` matching as the window resizes — avoids hydration drift.
+ */
+
+export function detectViewportFromUserAgent(userAgent: string): string {
+ const ua = userAgent.toLowerCase();
+ if (/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/.test(ua)) {
+ return 'mobile';
+ }
+ if (/ipad|tablet|kindle|playbook|silk/.test(ua)) {
+ return 'tablet';
+ }
+ return 'desktop';
+}
diff --git a/test-apps/sveltekit/src/lib/experience-config.ts b/test-apps/sveltekit/src/lib/experience-config.ts
new file mode 100644
index 0000000..df7bd7e
--- /dev/null
+++ b/test-apps/sveltekit/src/lib/experience-config.ts
@@ -0,0 +1,44 @@
+/**
+ * Maps Contentful component/template ids to the app's components, and wires
+ * `resolveToken`. Components read design via `getDesignValues()`.
+ */
+
+import {
+ defineComponent,
+ type Components,
+ type Config,
+ type ResolveToken,
+ type Templates,
+} from '@contentful/experiences-svelte';
+
+import Button from './components/Button.svelte';
+import Header, { type HeaderProps } from './components/Header.svelte';
+import Page from './components/Page.svelte';
+import Text from './components/Text.svelte';
+
+const components: Components = {
+ button: Button,
+ text: Text,
+ header: defineComponent({
+ component: Header,
+ defaults: { text: 'Hello World' },
+ }),
+};
+
+const templates: Templates = {
+ hi: { component: Page, defaults: { title: 'Welcome' } },
+ hero: { component: Page, defaults: { title: 'Featured' } },
+};
+
+// Resolves opaque token ids to their underlying values — only you know what a
+// token id means. Returning undefined drops the key. A real app might use CSS
+// vars, a Tailwind theme, or a tokens package.
+const brandTokens: Record = {
+ 'color.surface.hero': '#4f39f6',
+ 'color.surface.subtle': '#f4f4f5',
+ 'color.text.onPrimary': '#ffffff',
+};
+
+const resolveToken: ResolveToken = (token) => brandTokens[token.value];
+
+export const experienceConfig: Config = { components, templates, resolveToken };
diff --git a/test-apps/sveltekit/src/routes/+layout.svelte b/test-apps/sveltekit/src/routes/+layout.svelte
new file mode 100644
index 0000000..2ccd9ab
--- /dev/null
+++ b/test-apps/sveltekit/src/routes/+layout.svelte
@@ -0,0 +1,5 @@
+
+
+{@render children()}
diff --git a/test-apps/sveltekit/src/routes/+page.svelte b/test-apps/sveltekit/src/routes/+page.svelte
new file mode 100644
index 0000000..e93afbf
--- /dev/null
+++ b/test-apps/sveltekit/src/routes/+page.svelte
@@ -0,0 +1,21 @@
+
+ Contentful Experiences — SvelteKit example
+
+ This app demonstrates rendering a Contentful Experience payload with
+ @contentful/experiences-svelte in a SvelteKit server load function.
+
+
+ Without CDA_TOKEN set in .env, the app uses a built-in mock payload so it
+ works out of the box.
+
+
+
+ View the demo experience
+
+
+
diff --git a/test-apps/sveltekit/src/routes/[slug]/+page.server.ts b/test-apps/sveltekit/src/routes/[slug]/+page.server.ts
new file mode 100644
index 0000000..f5412f6
--- /dev/null
+++ b/test-apps/sveltekit/src/routes/[slug]/+page.server.ts
@@ -0,0 +1,37 @@
+import { error } from '@sveltejs/kit';
+
+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';
+
+import type { PageServerLoad } from './$types.js';
+
+export const load: PageServerLoad = async ({ params, url, request }) => {
+ const previewMode =
+ url.searchParams.get('preview') === 'true' || url.searchParams.get('preview') === '1';
+ const initialViewportId = detectViewportFromUserAgent(request.headers.get('user-agent') ?? '');
+
+ 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 };
+ } catch (err) {
+ if (err instanceof NotFoundError) error(404, 'Experience not found');
+ throw err;
+ }
+};
diff --git a/test-apps/sveltekit/src/routes/[slug]/+page.svelte b/test-apps/sveltekit/src/routes/[slug]/+page.svelte
new file mode 100644
index 0000000..871acf2
--- /dev/null
+++ b/test-apps/sveltekit/src/routes/[slug]/+page.svelte
@@ -0,0 +1,14 @@
+
+
+
diff --git a/test-apps/sveltekit/svelte.config.js b/test-apps/sveltekit/svelte.config.js
new file mode 100644
index 0000000..5853b9e
--- /dev/null
+++ b/test-apps/sveltekit/svelte.config.js
@@ -0,0 +1,12 @@
+import adapter from '@sveltejs/adapter-auto';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ adapter: adapter(),
+ },
+};
+
+export default config;
diff --git a/test-apps/sveltekit/tsconfig.json b/test-apps/sveltekit/tsconfig.json
new file mode 100644
index 0000000..4344710
--- /dev/null
+++ b/test-apps/sveltekit/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+}
diff --git a/test-apps/sveltekit/vite.config.ts b/test-apps/sveltekit/vite.config.ts
new file mode 100644
index 0000000..19f5bb3
--- /dev/null
+++ b/test-apps/sveltekit/vite.config.ts
@@ -0,0 +1,23 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ // Force SvelteKit's SSR loader to bundle our workspace SDK packages instead
+ // of letting Node ESM resolve them directly. Their compiled dist/ files use
+ // extensionless relative imports (e.g. `./viewport`), which Vite happily
+ // resolves but Node's strict ESM loader rejects. This is the SvelteKit
+ // equivalent of Next.js's `transpilePackages` option.
+ ssr: {
+ noExternal: [
+ '@contentful/experiences-svelte',
+ '@contentful/experiences-sdk-core',
+ '@contentful/experiences-design',
+ ],
+ },
+ server: {
+ fs: {
+ allow: ['../../packages', '../..'],
+ },
+ },
+});