From b4b01afb77bb359d57b5ee4125275a52b3abcdc6 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 12:40:02 -0600 Subject: [PATCH 01/19] chore: add test-apps/ scratchpad separate from customer-facing examples/ [AIS-246] Copy examples/{nextjs,sveltekit} to test-apps/{nextjs,sveltekit} so we have an internal place to iterate without mutating the customer-facing example apps. Test-app package names renamed to @contentful/experiences-testapp-* to avoid workspace collisions. examples/ untouched. Co-Authored-By: Claude Opus 4.7 --- AGENTS.md | 11 +- package-lock.json | 66 ++++- package.json | 3 +- test-apps/nextjs/.env.example | 5 + test-apps/nextjs/README.md | 233 ++++++++++++++++++ test-apps/nextjs/app/[slug]/page.tsx | 33 +++ test-apps/nextjs/app/advanced/[slug]/page.tsx | 77 ++++++ test-apps/nextjs/app/layout.tsx | 24 ++ test-apps/nextjs/app/page.tsx | 85 +++++++ test-apps/nextjs/components/Button.tsx | 49 ++++ test-apps/nextjs/components/Heading.tsx | 43 ++++ test-apps/nextjs/components/Image.tsx | 25 ++ test-apps/nextjs/components/Page.tsx | 19 ++ test-apps/nextjs/components/RichText.tsx | 63 +++++ test-apps/nextjs/components/Section.tsx | 62 +++++ test-apps/nextjs/components/Text.tsx | 30 +++ test-apps/nextjs/lib/design-tokens.ts | 24 ++ test-apps/nextjs/lib/detect-viewport.ts | 19 ++ .../nextjs/lib/experience-config-advanced.tsx | 54 ++++ test-apps/nextjs/lib/experience-config.tsx | 42 ++++ test-apps/nextjs/next.config.mjs | 15 ++ test-apps/nextjs/package.json | 25 ++ test-apps/nextjs/public/favicon.ico | Bin 0 -> 15406 bytes test-apps/nextjs/tsconfig.json | 23 ++ test-apps/sveltekit/.env.example | 3 + test-apps/sveltekit/.gitignore | 7 + test-apps/sveltekit/README.md | 60 +++++ test-apps/sveltekit/package.json | 25 ++ test-apps/sveltekit/src/app.d.ts | 5 + test-apps/sveltekit/src/app.html | 14 ++ .../src/lib/components/Button.svelte | 53 ++++ .../src/lib/components/Header.svelte | 51 ++++ .../sveltekit/src/lib/components/Page.svelte | 29 +++ .../sveltekit/src/lib/components/Text.svelte | 17 ++ .../sveltekit/src/lib/detect-viewport.ts | 19 ++ .../sveltekit/src/lib/experience-config.ts | 44 ++++ test-apps/sveltekit/src/routes/+layout.svelte | 5 + test-apps/sveltekit/src/routes/+page.svelte | 21 ++ .../src/routes/[slug]/+page.server.ts | 37 +++ .../sveltekit/src/routes/[slug]/+page.svelte | 14 ++ test-apps/sveltekit/svelte.config.js | 12 + test-apps/sveltekit/tsconfig.json | 14 ++ test-apps/sveltekit/vite.config.ts | 23 ++ 43 files changed, 1478 insertions(+), 5 deletions(-) create mode 100644 test-apps/nextjs/.env.example create mode 100644 test-apps/nextjs/README.md create mode 100644 test-apps/nextjs/app/[slug]/page.tsx create mode 100644 test-apps/nextjs/app/advanced/[slug]/page.tsx create mode 100644 test-apps/nextjs/app/layout.tsx create mode 100644 test-apps/nextjs/app/page.tsx create mode 100644 test-apps/nextjs/components/Button.tsx create mode 100644 test-apps/nextjs/components/Heading.tsx create mode 100644 test-apps/nextjs/components/Image.tsx create mode 100644 test-apps/nextjs/components/Page.tsx create mode 100644 test-apps/nextjs/components/RichText.tsx create mode 100644 test-apps/nextjs/components/Section.tsx create mode 100644 test-apps/nextjs/components/Text.tsx create mode 100644 test-apps/nextjs/lib/design-tokens.ts create mode 100644 test-apps/nextjs/lib/detect-viewport.ts create mode 100644 test-apps/nextjs/lib/experience-config-advanced.tsx create mode 100644 test-apps/nextjs/lib/experience-config.tsx create mode 100644 test-apps/nextjs/next.config.mjs create mode 100644 test-apps/nextjs/package.json create mode 100644 test-apps/nextjs/public/favicon.ico create mode 100644 test-apps/nextjs/tsconfig.json create mode 100644 test-apps/sveltekit/.env.example create mode 100644 test-apps/sveltekit/.gitignore create mode 100644 test-apps/sveltekit/README.md create mode 100644 test-apps/sveltekit/package.json create mode 100644 test-apps/sveltekit/src/app.d.ts create mode 100644 test-apps/sveltekit/src/app.html create mode 100644 test-apps/sveltekit/src/lib/components/Button.svelte create mode 100644 test-apps/sveltekit/src/lib/components/Header.svelte create mode 100644 test-apps/sveltekit/src/lib/components/Page.svelte create mode 100644 test-apps/sveltekit/src/lib/components/Text.svelte create mode 100644 test-apps/sveltekit/src/lib/detect-viewport.ts create mode 100644 test-apps/sveltekit/src/lib/experience-config.ts create mode 100644 test-apps/sveltekit/src/routes/+layout.svelte create mode 100644 test-apps/sveltekit/src/routes/+page.svelte create mode 100644 test-apps/sveltekit/src/routes/[slug]/+page.server.ts create mode 100644 test-apps/sveltekit/src/routes/[slug]/+page.svelte create mode 100644 test-apps/sveltekit/svelte.config.js create mode 100644 test-apps/sveltekit/tsconfig.json create mode 100644 test-apps/sveltekit/vite.config.ts diff --git a/AGENTS.md b/AGENTS.md index b1e0ca9..abad771 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 | diff --git a/package-lock.json b/package-lock.json index 37174a8..75962c1 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", @@ -161,6 +162,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2189,6 +2191,14 @@ "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/@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", @@ -4521,6 +4531,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 +4637,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 +4874,7 @@ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4886,6 +4899,7 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4942,6 +4956,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 +5064,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 +5327,7 @@ "integrity": "sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -5324,6 +5341,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5745,6 +5763,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -6255,6 +6274,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", @@ -6753,6 +6773,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6817,6 +6838,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", @@ -8306,6 +8328,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -8329,6 +8352,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -9402,6 +9426,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@emnapi/core": "1.4.5", "@emnapi/runtime": "1.4.5", @@ -10050,6 +10075,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -10213,6 +10239,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 +10249,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" }, @@ -11277,6 +11305,7 @@ "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -11790,6 +11819,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11931,6 +11961,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -12471,6 +12502,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 +13059,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 ( + + ); +} 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 {alt; +} 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 0000000000000000000000000000000000000000..49b2422737f5ea89f62b8818435f1cbf71ddcea6 GIT binary patch literal 15406 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 b;WzVGHX5kUP0D|7sT@DG|7ir8I|Ba$0AXpL literal 0 HcmV?d00001 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} + +{/if} diff --git a/test-apps/sveltekit/src/lib/components/Header.svelte b/test-apps/sveltekit/src/lib/components/Header.svelte new file mode 100644 index 0000000..eabd3a5 --- /dev/null +++ b/test-apps/sveltekit/src/lib/components/Header.svelte @@ -0,0 +1,51 @@ + + + + +{#if variant === 'h1'} +

{text}{#if children}{@render children()}{/if}

+{:else if variant === 'h2'} +

{text}{#if children}{@render children()}{/if}

+{:else} +

{text}{#if children}{@render children()}{/if}

+{/if} 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', '../..'], + }, + }, +}); From 95aca6bf3ad8c86568afca22f02299d878fcd629 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 12:40:10 -0600 Subject: [PATCH 02/19] feat: add fixture defining minimal ExO demo Experience [AIS-246] Fresh minimal fixture that a bootstrap script will provision into a caller's space. Shape: 1 promotion ContentType, 3 promotion entries, 3 image assets, 8 ComponentTypes (Section/Heading/RichText/Text/Button/Image + hero-plain + card), 1 page template, 2 DataAssemblies (hero/card), and 1 Experience composed of 1 hero + 2 cards. Uses tempId placeholders so the fixture is self-referential without baking target-space ids in. Co-Authored-By: Claude Opus 4.7 --- examples/scripts/fixture/assets.ts | 30 +++ examples/scripts/fixture/component-types.ts | 194 ++++++++++++++++++++ examples/scripts/fixture/content-types.ts | 19 ++ examples/scripts/fixture/data-assemblies.ts | 100 ++++++++++ examples/scripts/fixture/entries.ts | 100 ++++++++++ examples/scripts/fixture/experience.ts | 86 +++++++++ examples/scripts/fixture/index.ts | 8 + examples/scripts/fixture/templates.ts | 12 ++ examples/scripts/fixture/types.ts | 162 ++++++++++++++++ examples/scripts/tsconfig.json | 14 ++ 10 files changed, 725 insertions(+) create mode 100644 examples/scripts/fixture/assets.ts create mode 100644 examples/scripts/fixture/component-types.ts create mode 100644 examples/scripts/fixture/content-types.ts create mode 100644 examples/scripts/fixture/data-assemblies.ts create mode 100644 examples/scripts/fixture/entries.ts create mode 100644 examples/scripts/fixture/experience.ts create mode 100644 examples/scripts/fixture/index.ts create mode 100644 examples/scripts/fixture/templates.ts create mode 100644 examples/scripts/fixture/types.ts create mode 100644 examples/scripts/tsconfig.json diff --git a/examples/scripts/fixture/assets.ts b/examples/scripts/fixture/assets.ts new file mode 100644 index 0000000..340372a --- /dev/null +++ b/examples/scripts/fixture/assets.ts @@ -0,0 +1,30 @@ +import type { AssetFixture } from './types.js'; + +// Source URLs come from the shared demo test space (w65h2sqzlqap). The bootstrap +// downloads each and re-uploads it as an asset in the caller's target space. +export const assets: AssetFixture[] = [ + { + tempId: 'asset:hero-bg', + title: 'Hero background — blue connections', + fileName: 'hero-bg.png', + contentType: 'image/png', + sourceUrl: + 'https://images.ctfassets.net/w65h2sqzlqap/4pbxgJHy3kvCmS6Y7S0Dz6/d9158fd0e10cbb3731b2125e8fefd272/background-blue-connections.png', + }, + { + tempId: 'asset:card-on', + title: 'On case study image', + fileName: 'card-on.png', + contentType: 'image/png', + sourceUrl: + 'https://images.ctfassets.net/w65h2sqzlqap/1sY0RBemn8qu4chA0kHeqE/6a65a4b54d797ca87300e575e6085ac1/Generic-blue-cart.png', + }, + { + tempId: 'asset:card-guide', + title: 'Developer guide image', + fileName: 'card-guide.webp', + contentType: 'image/webp', + sourceUrl: + 'https://images.ctfassets.net/w65h2sqzlqap/5VeMf09DxYNyLPfalm7Lde/c928d2407fd8f4c4d3c92b356b237b50/build-faster.webp', + }, +]; diff --git a/examples/scripts/fixture/component-types.ts b/examples/scripts/fixture/component-types.ts new file mode 100644 index 0000000..ff84946 --- /dev/null +++ b/examples/scripts/fixture/component-types.ts @@ -0,0 +1,194 @@ +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..6078291 --- /dev/null +++ b/examples/scripts/fixture/content-types.ts @@ -0,0 +1,19 @@ +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..3520b5b --- /dev/null +++ b/examples/scripts/fixture/data-assemblies.ts @@ -0,0 +1,100 @@ +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: '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' }, + ], + 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 + body { document: json } + image { url } + } + } +}`, + parameters: { id: '$parameters/promo' }, + }, + }, + return: { + title: { $from: '$resolvers/promoNode/_node/title' }, + body: { $from: '$resolvers/promoNode/_node/body/document' }, + 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]; diff --git a/examples/scripts/fixture/entries.ts b/examples/scripts/fixture/entries.ts new file mode 100644 index 0000000..8874693 --- /dev/null +++ b/examples/scripts/fixture/entries.ts @@ -0,0 +1,100 @@ +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/request-demo/' }, + 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/customers/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..a22d2bf --- /dev/null +++ b/examples/scripts/fixture/experience.ts @@ -0,0 +1,86 @@ +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..8f7cf99 --- /dev/null +++ b/examples/scripts/fixture/index.ts @@ -0,0 +1,8 @@ +export * from './types.js'; +export { contentTypes } from './content-types.js'; +export { assets } from './assets.js'; +export { entries } from './entries.js'; +export { componentTypes } from './component-types.js'; +export { templates } from './templates.js'; +export { dataAssemblies } 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..4fa58aa --- /dev/null +++ b/examples/scripts/fixture/templates.ts @@ -0,0 +1,12 @@ +import type { TemplateFixture } from './types.js'; + +// The `page` template is a passthrough — the renderer just wraps the experience +// tree with the customer's Page component (see examples/nextjs/components/Page.tsx). +// No template-level content/design properties for the minimal demo. +export const templates: TemplateFixture[] = [ + { + id: 'page', + name: 'Page', + description: 'Passthrough page wrapper', + }, +]; diff --git a/examples/scripts/fixture/types.ts b/examples/scripts/fixture/types.ts new file mode 100644 index 0000000..5adffec --- /dev/null +++ b/examples/scripts/fixture/types.ts @@ -0,0 +1,162 @@ +// 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; + sourceUrl: 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[]; +}; + +// --- 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/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"] +} From c2e54b4187b47380332fd9d3d36534e0ca60cd48 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 13:00:55 -0600 Subject: [PATCH 03/19] feat: bootstrap script provisions demo Experience via CMA dev build [AIS-246] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/scripts/bootstrap-example.ts provisions the fixture into a caller- specified space + env in 9 steps: ContentTypes, Assets, Entries, Design tokens, ComponentTypes, Template, DataAssemblies, DA→ComponentType links, Experience. Uses contentful-management@12.6.0-dev.4 for the ExO surface (componentType / template / dataAssembly / experience plain clients) and raw HTTP for design_tokens (not yet in the plain client). Idempotent per resource. Verified end-to-end against f7tvosi12w6q / exo-sdk — Experience "landing" publishes cleanly. RichText body is temporarily dropped from the DataAssembly return mapping — the DA validator rejects our body→document GraphQL alias with "Field path _node.body not found." Symbol fields (title, ctaLabel, ctaUrl, image.url) work. Follow-up needed to figure out the RichText mapping shape. Co-Authored-By: Claude Opus 4.7 --- examples/scripts/bootstrap-example.ts | 609 ++++++++++++++++ examples/scripts/fixture/data-assemblies.ts | 16 +- examples/scripts/fixture/design-tokens.ts | 43 ++ examples/scripts/fixture/index.ts | 3 +- examples/scripts/package.json | 19 + package-lock.json | 731 +++++++++++++++++++- 6 files changed, 1392 insertions(+), 29 deletions(-) create mode 100644 examples/scripts/bootstrap-example.ts create mode 100644 examples/scripts/fixture/design-tokens.ts create mode 100644 examples/scripts/package.json diff --git a/examples/scripts/bootstrap-example.ts b/examples/scripts/bootstrap-example.ts new file mode 100644 index 0000000..9aba577 --- /dev/null +++ b/examples/scripts/bootstrap-example.ts @@ -0,0 +1,609 @@ +/** + * 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 { + 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; + } + + log(` … downloading ${fixture.sourceUrl}`); + const res = await fetch(fixture.sourceUrl); + if (!res.ok) throw new Error(`Failed to fetch ${fixture.sourceUrl}: ${res.status}`); + const arrayBuffer = await res.arrayBuffer(); + + 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; + try { + await cma.template.get({ templateId }); + log(` ✓ Template "${fixture.id}" already exists — skipping`); + return; + } catch (err) { + if (!isNotFound(err)) throw err; + } + + const created = await cma.template.upsert( + { templateId }, + { + sys: { id: templateId, type: 'Template' }, + 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 ?? [], + } as never + ); + await cma.template.publish({ templateId, version: created.sys.version }); + log(` ✓ Template "${fixture.id}" created + 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; + } + + // Create if missing. + if (!existing) { + const slots: Record = {}; + for (const [slotName, children] of Object.entries(fixture.slots)) { + slots[slotName] = children.map(resolveNode); + } + existing = await cma.experience.upsert( + { experienceId }, + { + sys: { id: experienceId, type: 'Experience' }, + template: { + sys: { + type: 'ResourceLink', + linkType: 'Contentful:Template', + urn: templateUrn(fixture.templateId), + }, + }, + name: fixture.name, + description: fixture.description ?? '', + viewports: fixture.viewports, + designProperties: {}, + metadata: { tags: [], concepts: [] }, + slots, + } as never + ); + log(` ✓ Experience "${fixture.id}" created`); + } else { + log(` ✓ Experience "${fixture.id}" already exists`); + } + + // Publish if not yet published. + const isPublished = + !!existing.sys.publishedVersion && existing.sys.publishedVersion === existing.sys.version; + if (isPublished) { + log(` ✓ Experience "${fixture.id}" already published`); + } else { + await cma.experience.publish( + { experienceId: existing.sys.id, version: existing.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/data-assemblies.ts b/examples/scripts/fixture/data-assemblies.ts index 3520b5b..d0eeb06 100644 --- a/examples/scripts/fixture/data-assemblies.ts +++ b/examples/scripts/fixture/data-assemblies.ts @@ -12,7 +12,6 @@ const heroAssembly: DataAssemblyFixture = { // dataType MUST mirror hero-plain's contentProperties. dataType: [ { 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' }, @@ -34,7 +33,6 @@ const heroAssembly: DataAssemblyFixture = { title ctaLabel ctaUrl - body { document: json } image { url } } } @@ -44,7 +42,6 @@ const heroAssembly: DataAssemblyFixture = { }, return: { title: { $from: '$resolvers/promoNode/_node/title' }, - body: { $from: '$resolvers/promoNode/_node/body/document' }, ctaLabel: { $from: '$resolvers/promoNode/_node/ctaLabel' }, ctaUrl: { $from: '$resolvers/promoNode/_node/ctaUrl' }, image: { $from: '$resolvers/promoNode/_node/image/url' }, @@ -98,3 +95,16 @@ const cardAssembly: DataAssemblyFixture = { }; 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/index.ts b/examples/scripts/fixture/index.ts index 8f7cf99..636223d 100644 --- a/examples/scripts/fixture/index.ts +++ b/examples/scripts/fixture/index.ts @@ -2,7 +2,8 @@ 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 } from './data-assemblies.js'; +export { dataAssemblies, dataAssemblyComponentTypeLinks } from './data-assemblies.js'; export { experience } from './experience.js'; 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/package-lock.json b/package-lock.json index 75962c1..0969ad0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,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", @@ -2175,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 @@ -2199,6 +2215,15 @@ "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", @@ -4383,7 +4408,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5506,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", @@ -5840,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", @@ -5850,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", @@ -6138,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" @@ -6192,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", @@ -6507,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" @@ -6620,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", @@ -6721,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" @@ -6731,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" @@ -6741,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" @@ -6754,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", @@ -7157,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", @@ -7329,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", @@ -7350,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", @@ -7396,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" @@ -7449,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", @@ -7474,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", @@ -7625,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" @@ -7658,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" @@ -7671,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" @@ -7687,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" @@ -8779,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": { @@ -9056,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" @@ -9120,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" @@ -9130,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" @@ -9690,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", @@ -10182,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" @@ -10224,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", @@ -10882,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", @@ -11777,6 +11955,509 @@ "node": ">= 12" } }, + "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": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "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": ">=18" + } + }, + "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", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "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": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "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": ">=18" + } + }, + "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": ">=18" + } + }, + "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", From 32c2e962cb68c8fb726dbe041ea66a307012ba64 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 13:08:40 -0600 Subject: [PATCH 04/19] docs: document the bootstrap-then-run flow across examples [AIS-246] Updates the Next.js + SvelteKit example READMEs, the top-level README's Examples section, and AGENTS.md "Run the example app" so the customer flow is clear: cd examples/scripts && npm run bootstrap; then run the app with a CDA token. Adds a scripts/README.md that explains what the bootstrap provisions and calls out known limitations (RichText body omission, CMA dev-build pin, design_tokens via raw HTTP). Adds scripts/.env.example and updates both example .env.example files with inline pointers to the bootstrap flow. Co-Authored-By: Claude Opus 4.7 --- AGENTS.md | 14 +++++++-- README.md | 18 ++++++++--- examples/nextjs/.env.example | 4 +++ examples/nextjs/README.md | 18 ++++++++--- examples/scripts/.env.example | 9 ++++++ examples/scripts/README.md | 53 +++++++++++++++++++++++++++++++++ examples/sveltekit/.env.example | 6 ++++ examples/sveltekit/README.md | 18 ++++++++--- 8 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 examples/scripts/.env.example create mode 100644 examples/scripts/README.md diff --git a/AGENTS.md b/AGENTS.md index abad771..4c5f762 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -362,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..d674b08 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. --- diff --git a/examples/nextjs/.env.example b/examples/nextjs/.env.example index 2bddab2..c7dff2d 100644 --- a/examples/nextjs/.env.example +++ b/examples/nextjs/.env.example @@ -1,4 +1,8 @@ # 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 diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md index 949ecea..bfb8959 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -13,17 +13,27 @@ A Next.js 15 App Router app demonstrating `@contentful/experiences-react` render ## Run it +You need a Contentful space seeded with the demo Experience before this app can render anything. The `examples/scripts/bootstrap-example.ts` script provisions everything (ContentType, entries, assets, design tokens, component types, template, data assemblies, and the Experience itself) via the management API — see [`examples/scripts/README.md`](../scripts/README.md) for the full breakdown. + ```sh # From the repo root: npm install --ignore-scripts -npm run build # builds the SDK packages +npm run build # build the SDK packages + +# 1. Seed the demo Experience into your Contentful space. +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 +# 2. Run the example against the seeded space. +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`. +Then visit `http://localhost:3000/landing` (or whichever experienceId the bootstrap printed). The slug becomes the Experience ID passed to `client.view.getExperience`. + +**Tokens.** `CMA_TOKEN` is a Personal Access Token used only by the one-time bootstrap; the example app itself never sees it. `CDA_TOKEN` is a Content Delivery API token scoped to the space — this is what the running app uses. ## Two routes, same data 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..588a3b6 --- /dev/null +++ b/examples/scripts/README.md @@ -0,0 +1,53 @@ +# examples/scripts + +One-time setup scripts for the customer-facing example apps in [`../nextjs`](../nextjs) and [`../sveltekit`](../sveltekit). Not published; not part of the SDK; not something the running example apps depend on. This directory only exists to make the examples runnable from a clean checkout. + +## 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, uploaded from the source demo space's public CDN | +| 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 + +- **RichText body is currently omitted from the DataAssembly return mapping.** The DA validator rejects our `body { document: json }` GraphQL alias with "Field path `_node.body` not found." Symbol fields (title, ctaLabel, ctaUrl, image.url) all work. Follow-up needed to figure out the right RichText mapping shape. +- **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, so the script talks to it directly. diff --git a/examples/sveltekit/.env.example b/examples/sveltekit/.env.example index 40490a6..e28aa63 100644 --- a/examples/sveltekit/.env.example +++ b/examples/sveltekit/.env.example @@ -1,3 +1,9 @@ +# 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 CDA_TOKEN= diff --git a/examples/sveltekit/README.md b/examples/sveltekit/README.md index 7a9a5f2..fbd53b3 100644 --- a/examples/sveltekit/README.md +++ b/examples/sveltekit/README.md @@ -13,17 +13,27 @@ A SvelteKit 2 + Svelte 5 app demonstrating `@contentful/experiences-svelte` rend ## Run it +You need a Contentful space seeded with the demo Experience before this app can render anything. The `examples/scripts/bootstrap-example.ts` script provisions everything (ContentType, entries, assets, design tokens, component types, template, data assemblies, and the Experience itself) via the management API — see [`examples/scripts/README.md`](../scripts/README.md) for the full breakdown. + ```sh # From the repo root: npm install --ignore-scripts -npm run build # builds the SDK packages +npm run build # build the SDK packages + +# 1. Seed the demo Experience into your Contentful space. +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/sveltekit -cp .env.example .env # fill in SPACE_ID + CDA_TOKEN +# 2. Run the example against the seeded space. +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`. +Then visit `http://localhost:5173/landing` (or whichever experienceId the bootstrap printed). The slug becomes the Experience ID passed to `client.view.getExperience`. + +**Tokens.** `CMA_TOKEN` is a Personal Access Token used only by the one-time bootstrap; the example app itself never sees it. `CDA_TOKEN` is a Content Delivery API token scoped to the space — this is what the running app uses. ## File map From 30233038090189feaed478fcdb158bdfbdc30b67 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 13:21:27 -0600 Subject: [PATCH 05/19] fix: seed page template with componentTree + composed annotation [AIS-246] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page template was being seeded without a componentTree, so the CDA wrapped the whole Experience in a Template-variant node whose slots.content never surfaced. The SDK's resolver skips Template-variant nodes (v1 scope), so the entire tree rendered empty. Two changes: 1. Fixture: the page template now declares slots: [{ id: 'content' }] plus a componentTree with a single Slot node pointing at that slot. That's what tells the resolver where in the template to drop the Experience's content-slot nodes. 2. Bootstrap: templates carrying a componentTree need the Contentful:ComposedImplementation annotation — the server-assigned default (Contentful:CodedImplementation) requires an empty tree and publish 422s otherwise. seedTemplate now sets that annotation on both create and update, and update-detection compares annotations so fixture edits actually re-publish. seedExperience always upserts on existing records so template/DA changes further up the run don't leave the Experience with a stale defining-entity version at publish time. Co-Authored-By: Claude Opus 4.7 --- examples/scripts/bootstrap-example.ts | 160 ++++++++++++++++++-------- examples/scripts/fixture/templates.ts | 17 ++- examples/scripts/fixture/types.ts | 12 ++ 3 files changed, 139 insertions(+), 50 deletions(-) diff --git a/examples/scripts/bootstrap-example.ts b/examples/scripts/bootstrap-example.ts index 9aba577..7fd33bc 100644 --- a/examples/scripts/bootstrap-example.ts +++ b/examples/scripts/bootstrap-example.ts @@ -325,30 +325,97 @@ async function seedComponentType(fixture: ComponentTypeFixture) { async function seedTemplate(fixture: TemplateFixture) { const templateId = fixture.id; + let existing: Awaited> | null = null; try { - await cma.template.get({ templateId }); - log(` ✓ Template "${fixture.id}" already exists — skipping`); - return; + existing = await cma.template.get({ templateId }); } catch (err) { if (!isNotFound(err)) throw err; } - const created = await cma.template.upsert( + 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' }, - 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 ?? [], + sys: { id: templateId, type: 'Template', version: existing.sys.version }, + ...templateBody, } as never ); - await cma.template.publish({ templateId, version: created.sys.version }); - log(` ✓ Template "${fixture.id}" created + published`); + await cma.template.publish({ templateId, version: updated.sys.version }); + log(` ✓ Template "${fixture.id}" updated + published`); } async function seedDataAssembly(fixture: DataAssemblyFixture) { @@ -519,44 +586,45 @@ async function seedExperience(fixture: ExperienceFixture) { if (!isNotFound(err)) throw err; } - // Create if missing. - if (!existing) { - const slots: Record = {}; - for (const [slotName, children] of Object.entries(fixture.slots)) { - slots[slotName] = children.map(resolveNode); - } - existing = await cma.experience.upsert( - { experienceId }, - { - sys: { id: experienceId, type: 'Experience' }, - template: { - sys: { - type: 'ResourceLink', - linkType: 'Contentful:Template', - urn: templateUrn(fixture.templateId), - }, - }, - name: fixture.name, - description: fixture.description ?? '', - viewports: fixture.viewports, - designProperties: {}, - metadata: { tags: [], concepts: [] }, - slots, - } as never - ); - log(` ✓ Experience "${fixture.id}" created`); - } else { - log(` ✓ Experience "${fixture.id}" already exists`); + const slots: Record = {}; + for (const [slotName, children] of Object.entries(fixture.slots)) { + slots[slotName] = children.map(resolveNode); } - // Publish if not yet published. + // 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 = - !!existing.sys.publishedVersion && existing.sys.publishedVersion === existing.sys.version; + !!upserted.sys.publishedVersion && upserted.sys.publishedVersion === upserted.sys.version; if (isPublished) { log(` ✓ Experience "${fixture.id}" already published`); } else { await cma.experience.publish( - { experienceId: existing.sys.id, version: existing.sys.version }, + { experienceId: upserted.sys.id, version: upserted.sys.version }, { add: ['en-US'] } ); log(` ✓ Experience "${fixture.id}" published`); diff --git a/examples/scripts/fixture/templates.ts b/examples/scripts/fixture/templates.ts index 4fa58aa..6a1834f 100644 --- a/examples/scripts/fixture/templates.ts +++ b/examples/scripts/fixture/templates.ts @@ -1,12 +1,21 @@ import type { TemplateFixture } from './types.js'; -// The `page` template is a passthrough — the renderer just wraps the experience -// tree with the customer's Page component (see examples/nextjs/components/Page.tsx). -// No template-level content/design properties for the minimal demo. +// 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', + 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 index 5adffec..a1fabcf 100644 --- a/examples/scripts/fixture/types.ts +++ b/examples/scripts/fixture/types.ts @@ -76,6 +76,18 @@ export type TemplateFixture = { 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 ----------------------------------------------------------- From 244c808d676414009a96f1d3dc64314f6f235707 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 13:21:36 -0600 Subject: [PATCH 06/19] feat: register hero-plain + card in Next.js example [AIS-246] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Next.js implementations for the two composed ComponentTypes the bootstrap fixture provisions: - components/HeroPlain.tsx — title + CTA + hero image, 2-column grid - components/Card.tsx — image + title + teaser + CTA in a shadowed panel Both read design values via useDesignValues() and consume tempate-bound content properties as regular React props. Wired into experience-config.tsx as `'hero-plain'` and `card` alongside the existing primitives. Also adds color.primary and color.primaryText to design-tokens.ts (the hero uses them as background + foreground). Verified: `npm run dev` against f7tvosi12w6q/exo-sdk renders /landing with the hero + 2 cards, all content resolved from Promotion entries via DataAssembly. Co-Authored-By: Claude Opus 4.7 --- examples/nextjs/components/Card.tsx | 66 +++++++++++++++++++++++ examples/nextjs/components/HeroPlain.tsx | 65 ++++++++++++++++++++++ examples/nextjs/lib/design-tokens.ts | 2 + examples/nextjs/lib/experience-config.tsx | 4 ++ 4 files changed, 137 insertions(+) create mode 100644 examples/nextjs/components/Card.tsx create mode 100644 examples/nextjs/components/HeroPlain.tsx diff --git a/examples/nextjs/components/Card.tsx b/examples/nextjs/components/Card.tsx new file mode 100644 index 0000000..1863662 --- /dev/null +++ b/examples/nextjs/components/Card.tsx @@ -0,0 +1,66 @@ +'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..cfd7d23 --- /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; // RichText document; omitted in the DA return for now + 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/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.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 = { From 01a0451e9f9acdc4d2f715f570388efd69a7ec39 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 13:36:54 -0600 Subject: [PATCH 07/19] fix: correct hero + On case study CTA URLs in demo fixture [AIS-246] Hero CTA now points at /contact/sales/ (not /request-demo/), and the On card CTA at /case-studies/on/ (not /customers/on/). Live entries in f7tvosi12w6q/exo-sdk were also patched + republished so the running demo reflects the fix. Co-Authored-By: Claude Opus 4.7 --- examples/scripts/fixture/entries.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/scripts/fixture/entries.ts b/examples/scripts/fixture/entries.ts index 8874693..ba97bb0 100644 --- a/examples/scripts/fixture/entries.ts +++ b/examples/scripts/fixture/entries.ts @@ -44,7 +44,7 @@ export const entries: EntryFixture[] = [ ]), }, ctaLabel: { 'en-US': 'Book a demo' }, - ctaUrl: { 'en-US': 'https://www.contentful.com/request-demo/' }, + ctaUrl: { 'en-US': 'https://www.contentful.com/contact/sales/' }, image: { 'en-US': assetRef('asset:hero-bg') }, }, }, @@ -68,7 +68,7 @@ export const entries: EntryFixture[] = [ ]), }, ctaLabel: { 'en-US': 'Read the On case study' }, - ctaUrl: { 'en-US': 'https://www.contentful.com/customers/on/' }, + ctaUrl: { 'en-US': 'https://www.contentful.com/case-studies/on/' }, image: { 'en-US': assetRef('asset:card-on') }, }, }, From dae3a8d2e6dd05ed9f416eb18fee9d927d8053b7 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 13:52:10 -0600 Subject: [PATCH 08/19] feat: register hero-plain + card in advanced Next.js config [AIS-246] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /advanced/[slug] route was crashing because its config only knew about the six primitives — hero-plain and card weren't registered, so the SDK's MissingComponent kicked in and hit an SSR bug in useExperience(). Registers hero-plain (bare) and card (with async resolveData) on the advanced config, adds resolveToken so design tokens flow the same way as the simple route, and drops the old Button-level resolveData demo (Button is an internal render detail of the composed CTs now). The card's resolveData does two things worth showing: 1. A stand-in async enrichment fetch — prepends "Featured:" to the title. 2. A metadata-aware CTA rewrite — non-http URLs get prefixed with locale + slug from experience.metadata. Verified: /advanced/landing renders end-to-end. Co-Authored-By: Claude Opus 4.7 --- .../nextjs/lib/experience-config-advanced.tsx | 54 +++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) 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 }; From 81f8560c8ac663f76c4a37f22b1a52f7dc1cc6f0 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 14:04:34 -0600 Subject: [PATCH 09/19] fix: point example landing pages at the seeded `landing` experience [AIS-246] The Next.js and SvelteKit example indexes had /demo hardcoded from before the fixture existed, which 404s against a freshly bootstrapped space (the seeded id is `landing`). Also updated the "replace demo with a real id" note on the Next.js index to reflect the new default. Co-Authored-By: Claude Opus 4.7 --- examples/nextjs/app/page.tsx | 8 +++++--- examples/sveltekit/src/routes/+page.svelte | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx index a019c68..0071fb5 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -69,16 +69,18 @@ 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/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 From a02c3f8b08c63c1b8cadcb6473992cc052a1ed44 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 14:11:52 -0600 Subject: [PATCH 10/19] feat: use CPA token for preview mode in example apps [AIS-246] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preview mode reads from preview.xdn.contentful.com, which requires a Content Preview token — the CDA token is rejected there. Both examples were hardcoded to always use CDA_TOKEN, so /advanced/[slug]?preview=true (and the "Advanced demo (preview)" button on the Next.js index) crashed with UnauthorizedError on any real space. Both routes now pick CPA_TOKEN when previewMode is on (falls back to CDA if CPA is unset — the request will 401 in that case, which is still an improvement over silently sending the wrong token to the wrong host). Adds CPA_TOKEN to both .env.example files with a note on when it's needed, and updates both READMEs to document the three tokens (CMA / CDA / CPA) and which route uses which. Also switches sveltekit to $env/dynamic/private so new env vars don't need a code change to be readable. Co-Authored-By: Claude Opus 4.7 --- examples/nextjs/.env.example | 7 +++++++ examples/nextjs/README.md | 6 +++++- examples/nextjs/app/advanced/[slug]/page.tsx | 9 ++++++++- examples/sveltekit/.env.example | 7 +++++++ examples/sveltekit/README.md | 6 +++++- examples/sveltekit/src/routes/[slug]/+page.server.ts | 11 +++++++---- 6 files changed, 39 insertions(+), 7 deletions(-) diff --git a/examples/nextjs/.env.example b/examples/nextjs/.env.example index c7dff2d..5c18231 100644 --- a/examples/nextjs/.env.example +++ b/examples/nextjs/.env.example @@ -6,4 +6,11 @@ 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 bfb8959..b3ddb28 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -33,7 +33,11 @@ npm run dev Then visit `http://localhost:3000/landing` (or whichever experienceId the bootstrap printed). The slug becomes the Experience ID passed to `client.view.getExperience`. -**Tokens.** `CMA_TOKEN` is a Personal Access Token used only by the one-time bootstrap; the example app itself never sees it. `CDA_TOKEN` is a Content Delivery API token scoped to the space — this is what the running app uses. +**Tokens.** + +- `CMA_TOKEN` — Personal Access Token used only by the one-time bootstrap; the example app itself never sees it. +- `CDA_TOKEN` — Content Delivery API token scoped to the space. This is what the running app uses for the simple route. +- `CPA_TOKEN` — Content Preview API token, only needed for the `/advanced/[slug]?preview=true` path (preview reads from a separate host that rejects CDA tokens). Leave blank if you don't need preview mode; the "Advanced demo (preview)" button on the index page requires it. ## Two routes, same data diff --git a/examples/nextjs/app/advanced/[slug]/page.tsx b/examples/nextjs/app/advanced/[slug]/page.tsx index 617ff67..2e376da 100644 --- a/examples/nextjs/app/advanced/[slug]/page.tsx +++ b/examples/nextjs/app/advanced/[slug]/page.tsx @@ -50,7 +50,14 @@ 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/sveltekit/.env.example b/examples/sveltekit/.env.example index e28aa63..4bbd046 100644 --- a/examples/sveltekit/.env.example +++ b/examples/sveltekit/.env.example @@ -6,4 +6,11 @@ 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 fbd53b3..639a55c 100644 --- a/examples/sveltekit/README.md +++ b/examples/sveltekit/README.md @@ -33,7 +33,11 @@ npm run dev Then visit `http://localhost:5173/landing` (or whichever experienceId the bootstrap printed). The slug becomes the Experience ID passed to `client.view.getExperience`. -**Tokens.** `CMA_TOKEN` is a Personal Access Token used only by the one-time bootstrap; the example app itself never sees it. `CDA_TOKEN` is a Content Delivery API token scoped to the space — this is what the running app uses. +**Tokens.** + +- `CMA_TOKEN` — Personal Access Token used only by the one-time bootstrap; the example app itself never sees it. +- `CDA_TOKEN` — Content Delivery API token scoped to the space. This is what the running app uses. +- `CPA_TOKEN` — Content Preview API token, only needed when hitting a route with `?preview=true` (preview reads from a separate host that rejects CDA tokens). Leave blank if you don't need preview mode. ## File map diff --git a/examples/sveltekit/src/routes/[slug]/+page.server.ts b/examples/sveltekit/src/routes/[slug]/+page.server.ts index f5412f6..1c80586 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,15 @@ 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', }, { From 3308fbffa5018e30e97ff2b469d4fc5d96f4262a Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 14:15:24 -0600 Subject: [PATCH 11/19] docs: split basic vs advanced/preview instructions in example READMEs [AIS-246] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old "Run it" section only walked through the basic /landing path; readers who then clicked the "Advanced demo (preview)" button on the index page got a 500 with no docs pointing at CPA_TOKEN. Restructures both example READMEs (and touches the top-level README) so: - Steps are numbered and labeled (1 = seed, 2a = basic, 2b = preview) and each step tells you exactly which token env var it needs. - The Next.js "Two routes, same data" table gets a "Try it locally" column with clickable URLs instead of just source-file links (source links moved into a caption below). - Tokens moved into a compact three-row summary table so it's clear which token each step needs, without repeating the guidance inline. - SvelteKit README gets the same shape — one main route + a "preview mode" subsection instead of burying it in a bullet. Verified preview mode end-to-end against f7tvosi12w6q/exo-sdk with a CPA token — /advanced/landing?preview=true returns 200 and renders the hero + 2 cards with async resolveData firing. Co-Authored-By: Claude Opus 4.7 --- README.md | 2 +- examples/nextjs/README.md | 49 +++++++++++++++++++++++++++--------- examples/sveltekit/README.md | 26 +++++++++++++------ 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index d674b08..314577f 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,7 @@ npm run dev 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. +**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/README.md b/examples/nextjs/README.md index b3ddb28..d149642 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -13,40 +13,65 @@ A Next.js 15 App Router app demonstrating `@contentful/experiences-react` render ## Run it -You need a Contentful space seeded with the demo Experience before this app can render anything. The `examples/scripts/bootstrap-example.ts` script provisions everything (ContentType, entries, assets, design tokens, component types, template, data assemblies, and the Experience itself) via the management API — see [`examples/scripts/README.md`](../scripts/README.md) for the full breakdown. +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. **Environment 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 # build the SDK packages -# 1. Seed the demo Experience into your Contentful space. 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 example against the seeded space. +### 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/landing` (or whichever experienceId the bootstrap printed). 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 also 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`, which rejects CDA tokens. + +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.** +### Tokens summary -- `CMA_TOKEN` — Personal Access Token used only by the one-time bootstrap; the example app itself never sees it. -- `CDA_TOKEN` — Content Delivery API token scoped to the space. This is what the running app uses for the simple route. -- `CPA_TOKEN` — Content Preview API token, only needed for the `/advanced/[slug]?preview=true` path (preview reads from a separate host that rejects CDA tokens). Leave blank if you don't need preview mode; the "Advanced demo (preview)" button on the index page requires it. +| 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`: diff --git a/examples/sveltekit/README.md b/examples/sveltekit/README.md index 639a55c..806bdf7 100644 --- a/examples/sveltekit/README.md +++ b/examples/sveltekit/README.md @@ -13,31 +13,41 @@ A SvelteKit 2 + Svelte 5 app demonstrating `@contentful/experiences-svelte` rend ## Run it -You need a Contentful space seeded with the demo Experience before this app can render anything. The `examples/scripts/bootstrap-example.ts` script provisions everything (ContentType, entries, assets, design tokens, component types, template, data assemblies, and the Experience itself) via the management API — see [`examples/scripts/README.md`](../scripts/README.md) for the full breakdown. +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 # build the SDK packages -# 1. Seed the demo Experience into your Contentful space. 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 -# 2. Run the example against the seeded space. +```sh cd ../sveltekit cp .env.example .env # fill in SPACE_ID, ENVIRONMENT_ID, CDA_TOKEN npm run dev ``` -Then visit `http://localhost:5173/landing` (or whichever experienceId the bootstrap printed). 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.** +### Tokens summary -- `CMA_TOKEN` — Personal Access Token used only by the one-time bootstrap; the example app itself never sees it. -- `CDA_TOKEN` — Content Delivery API token scoped to the space. This is what the running app uses. -- `CPA_TOKEN` — Content Preview API token, only needed when hitting a route with `?preview=true` (preview reads from a separate host that rejects CDA tokens). Leave blank if you don't need preview mode. +| 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 From f32a5740faaf574ef0e1086be8bba9f7714442d8 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 14:33:29 -0600 Subject: [PATCH 12/19] fix(sveltekit-example): add experiences-client to ssr.noExternal [AIS-246] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node's strict ESM loader rejects the extensionless relative imports in @contentful/experiences-client's dist (e.g. `from "./types"`), which comes in transitively via @contentful/experiences-svelte. The other three workspace packages were already listed in ssr.noExternal — this one was missed. Underlying dist-shape issue is a separate SDK-wide bug worth tracking (the source uses extensionless relative imports and tsup bundle:false emits them verbatim, which breaks any strict-ESM consumer). Contained here to unblock the sveltekit example. Co-Authored-By: Claude Opus 4.7 --- examples/sveltekit/vite.config.ts | 1 + 1 file changed, 1 insertion(+) 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: { From 34173cd11af18e8b0a9407615bf4fee577f9dff5 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 14:40:52 -0600 Subject: [PATCH 13/19] feat(sveltekit-example): port Next.js components + config for parity [AIS-246] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SvelteKit example was authored against a placeholder content model (button/text/header + hi/hero templates), so nothing rendered against the seeded landing Experience even though the SSR pipeline worked. Now it mirrors the Next.js example 1:1 in component vocabulary. Adds: - Section.svelte, Heading.svelte, RichText.svelte, Image.svelte - HeroPlain.svelte, Card.svelte (composed CTs) - design-tokens.ts (same 15 tokens as Next.js) Rewrites (props changed to match the seeded content model): - Text.svelte value → text - Button.svelte text → label, adds target from design values - experience-config.ts fully rewritten to register the 8 CTs and page Small tweak to Page.svelte so the max-width doesn't crush the multi-column card layout. Removes Header.svelte (superseded by Heading.svelte with the shape our seeded ComponentType expects). Verified: http://localhost:5173/landing renders the seeded Experience end-to-end against f7tvosi12w6q/exo-sdk — same content as the Next.js example, same three CTA URLs. Co-Authored-By: Claude Opus 4.7 --- .../src/lib/components/Button.svelte | 57 +++++++------------ .../sveltekit/src/lib/components/Card.svelte | 55 ++++++++++++++++++ .../src/lib/components/Header.svelte | 51 ----------------- .../src/lib/components/Heading.svelte | 27 +++++++++ .../src/lib/components/HeroPlain.svelte | 53 +++++++++++++++++ .../sveltekit/src/lib/components/Image.svelte | 14 +++++ .../sveltekit/src/lib/components/Page.svelte | 6 +- .../src/lib/components/RichText.svelte | 56 ++++++++++++++++++ .../src/lib/components/Section.svelte | 38 +++++++++++++ .../sveltekit/src/lib/components/Text.svelte | 25 +++++--- examples/sveltekit/src/lib/design-tokens.ts | 27 +++++++++ .../sveltekit/src/lib/experience-config.ts | 56 +++++++++--------- 12 files changed, 337 insertions(+), 128 deletions(-) create mode 100644 examples/sveltekit/src/lib/components/Card.svelte delete mode 100644 examples/sveltekit/src/lib/components/Header.svelte create mode 100644 examples/sveltekit/src/lib/components/Heading.svelte create mode 100644 examples/sveltekit/src/lib/components/HeroPlain.svelte create mode 100644 examples/sveltekit/src/lib/components/Image.svelte create mode 100644 examples/sveltekit/src/lib/components/RichText.svelte create mode 100644 examples/sveltekit/src/lib/components/Section.svelte create mode 100644 examples/sveltekit/src/lib/design-tokens.ts 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} - + {/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/Header.svelte b/examples/sveltekit/src/lib/components/Header.svelte deleted file mode 100644 index eabd3a5..0000000 --- a/examples/sveltekit/src/lib/components/Header.svelte +++ /dev/null @@ -1,51 +0,0 @@ - - - - -{#if variant === 'h1'} -

{text}{#if children}{@render children()}{/if}

-{:else if variant === 'h2'} -

{text}{#if children}{@render children()}{/if}

-{:else} -

{text}{#if children}{@render children()}{/if}

-{/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..f4e7be6 --- /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..a063fc9 --- /dev/null +++ b/examples/sveltekit/src/lib/components/RichText.svelte @@ -0,0 +1,56 @@ + + + + +{#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..eddb489 100644 --- a/examples/sveltekit/src/lib/experience-config.ts +++ b/examples/sveltekit/src/lib/experience-config.ts @@ -1,44 +1,44 @@ /** - * 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()`. + * + * 1:1 parity with examples/nextjs/lib/experience-config.tsx — same 8 + * ComponentType registrations, same 1 template, same design-token table. */ -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 }; From 89fa79d2761dad57f773e2d5061b81c28226e699 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 14:47:05 -0600 Subject: [PATCH 14/19] style: make Next.js Page template full-bleed so hero spans the viewport [AIS-246] The Page template was boxing everything into a 1024px max-width column, which crushed the hero (its background never reached the edge) and left awkward margins on the two-column card grid. SvelteKit's Page component was already a passthrough, so the two examples looked different for no good reason. Strip Page down to a bare flex-column, matching the SvelteKit shape. Each top-level node now handles its own containment: the hero renders edge-to-edge, the cards Section applies horizontal padding via its own design values. Co-Authored-By: Claude Opus 4.7 --- examples/nextjs/components/Page.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/examples/nextjs/components/Page.tsx b/examples/nextjs/components/Page.tsx index 4ca31fc..c1b2c1c 100644 --- a/examples/nextjs/components/Page.tsx +++ b/examples/nextjs/components/Page.tsx @@ -5,15 +5,18 @@ export interface PageProps { children?: ReactNode; } -/** Page-level template: wraps all top-level nodes in the outer page chrome. */ +/** + * Page-level template: passthrough wrapper. Each top-level node handles its + * own containment — the hero goes edge-to-edge, the cards Section applies + * its own horizontal padding via design values. Keeping the template thin + * matches the Svelte example and lets full-width heroes render without a + * fight against a max-width parent. + */ 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}
; } From 5d6fbd86a34b2a9ff47467a16a7f9914000f9cbf Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 15:00:51 -0600 Subject: [PATCH 15/19] fix(ci): exclude test-apps from Nx project graph via .nxignore [AIS-246] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing `.nxignore` already lists `examples` to keep them out of `nx run-many`, `nx graph`, `nx affected`, and `nx release`. test-apps serve the same role (internal scratchpads, not part of the SDK release surface) but weren't in the ignore list, so they got picked up by generic Nx detection and CI tried to `next build`/`vite build` them without the required env — the sveltekit one 401'd fetching from the seeded space and failed the build. Adding `test-apps` to `.nxignore` puts them back on the same footing as `examples`: npm workspaces still link them, but Nx skips them. Co-Authored-By: Claude Opus 4.7 --- .nxignore | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 From 8fa2bf8533ac39af7731502b927b409713705214 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Mon, 20 Jul 2026 15:03:40 -0600 Subject: [PATCH 16/19] style: run prettier --write on new example files [AIS-246] CI's format:check step flagged 13 files added on this branch that weren't prettier-formatted. `npm run format` applied the rewrites; behavior unchanged. Co-Authored-By: Claude Opus 4.7 --- examples/nextjs/README.md | 16 +-- examples/nextjs/app/advanced/[slug]/page.tsx | 4 +- examples/nextjs/app/page.tsx | 5 +- examples/nextjs/components/Card.tsx | 16 ++- examples/scripts/README.md | 22 +-- examples/scripts/bootstrap-example.ts | 128 +++++++++--------- examples/scripts/fixture/component-types.ts | 8 +- examples/scripts/fixture/content-types.ts | 3 +- examples/scripts/fixture/entries.ts | 6 +- examples/scripts/fixture/experience.ts | 3 +- examples/scripts/fixture/types.ts | 41 ++++-- examples/sveltekit/README.md | 10 +- .../src/routes/[slug]/+page.server.ts | 3 +- 13 files changed, 146 insertions(+), 119 deletions(-) diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md index d149642..4ddf18a 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -56,20 +56,20 @@ 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 | +| 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/*` | +| `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 | 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`. | +| 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). diff --git a/examples/nextjs/app/advanced/[slug]/page.tsx b/examples/nextjs/app/advanced/[slug]/page.tsx index 2e376da..1763dd5 100644 --- a/examples/nextjs/app/advanced/[slug]/page.tsx +++ b/examples/nextjs/app/advanced/[slug]/page.tsx @@ -55,9 +55,7 @@ export default async function AdvancedExperiencePage({ params, searchParams }: P // 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!, + 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 0071fb5..ded731c 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -78,9 +78,8 @@ export default function HomePage() {

- 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. + 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 index 1863662..baa1115 100644 --- a/examples/nextjs/components/Card.tsx +++ b/examples/nextjs/components/Card.tsx @@ -34,13 +34,17 @@ export function Card({ title, teaser, ctaLabel, ctaUrl, image }: CardProps) { return (
{image && ( - + )} -
+
{title &&

{title}

} {teaser &&

{teaser}

} {ctaLabel && ctaUrl && ( diff --git a/examples/scripts/README.md b/examples/scripts/README.md index 588a3b6..baac772 100644 --- a/examples/scripts/README.md +++ b/examples/scripts/README.md @@ -28,17 +28,17 @@ The script prints the resulting experienceId at the end — paste it into the ex 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, uploaded from the source demo space's public CDN | -| 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) | +| Step | Resource type | Count | Notes | +| ---- | ------------- | ----- | --------------------------------------------------------------------------------- | +| 1 | ContentType | 1 | `promotion` (title, teaser, body, ctaLabel, ctaUrl, image) | +| 2 | Asset | 3 | hero background + 2 card images, uploaded from the source demo space's public CDN | +| 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. diff --git a/examples/scripts/bootstrap-example.ts b/examples/scripts/bootstrap-example.ts index 7fd33bc..a190622 100644 --- a/examples/scripts/bootstrap-example.ts +++ b/examples/scripts/bootstrap-example.ts @@ -114,8 +114,7 @@ const resolveId = (tempId: TempId): string => { // 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()}`; +const stableId = (tempId: TempId) => `demo-${tempId.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}`; // --- URN builders ------------------------------------------------------------ @@ -297,25 +296,22 @@ async function seedComponentType(fixture: ComponentTypeFixture) { 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 - ); + 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, @@ -369,10 +365,10 @@ async function seedTemplate(fixture: TemplateFixture) { }; if (!existing) { - const created = await cma.template.upsert( - { templateId }, - { sys: { id: templateId, type: 'Template' }, ...templateBody } as never - ); + 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; @@ -407,13 +403,10 @@ async function seedTemplate(fixture: TemplateFixture) { } } - const updated = await cma.template.upsert( - { templateId }, - { - sys: { id: templateId, type: 'Template', version: existing.sys.version }, - ...templateBody, - } as never - ); + 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`); } @@ -444,24 +437,21 @@ async function seedDataAssembly(fixture: DataAssemblyFixture) { }; } - 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 - ); + 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, @@ -505,19 +495,16 @@ async function linkDataAssembliesToComponentTypes() { }, })), ]; - 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 - ); + 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, @@ -613,10 +600,19 @@ async function seedExperience(fixture: ExperienceFixture) { 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 + ? { + 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` ); - log(existing ? ` ✓ Experience "${fixture.id}" updated` : ` ✓ Experience "${fixture.id}" created`); const isPublished = !!upserted.sys.publishedVersion && upserted.sys.publishedVersion === upserted.sys.version; diff --git a/examples/scripts/fixture/component-types.ts b/examples/scripts/fixture/component-types.ts index ff84946..351db19 100644 --- a/examples/scripts/fixture/component-types.ts +++ b/examples/scripts/fixture/component-types.ts @@ -2,7 +2,13 @@ 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 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']; diff --git a/examples/scripts/fixture/content-types.ts b/examples/scripts/fixture/content-types.ts index 6078291..d7937a2 100644 --- a/examples/scripts/fixture/content-types.ts +++ b/examples/scripts/fixture/content-types.ts @@ -4,7 +4,8 @@ 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.', + 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 }, diff --git a/examples/scripts/fixture/entries.ts b/examples/scripts/fixture/entries.ts index ba97bb0..1fa945c 100644 --- a/examples/scripts/fixture/entries.ts +++ b/examples/scripts/fixture/entries.ts @@ -27,8 +27,7 @@ export const entries: EntryFixture[] = [ 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.', + 'en-US': 'Unify your content operations and deliver experiences that convert.', }, body: { 'en-US': rt([ @@ -86,8 +85,7 @@ export const entries: EntryFixture[] = [ 'en-US': rt([ [ { - text: - 'Contentful isn’t just a CMS — it’s a content infrastructure layer for developers building modern digital experiences.', + text: 'Contentful isn’t just a CMS — it’s a content infrastructure layer for developers building modern digital experiences.', }, ], ]), diff --git a/examples/scripts/fixture/experience.ts b/examples/scripts/fixture/experience.ts index a22d2bf..dfed44b 100644 --- a/examples/scripts/fixture/experience.ts +++ b/examples/scripts/fixture/experience.ts @@ -77,7 +77,8 @@ const cardsContainerNode: ExperienceNode = { export const experience: ExperienceFixture = { id: 'landing', name: 'Landing (demo)', - description: 'Minimal ExO demo — 1 hero + 2 cards, all bound via DataAssembly to promotion entries', + 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: { diff --git a/examples/scripts/fixture/types.ts b/examples/scripts/fixture/types.ts index a1fabcf..4a7b192 100644 --- a/examples/scripts/fixture/types.ts +++ b/examples/scripts/fixture/types.ts @@ -17,9 +17,32 @@ export type ContentTypeFixture = { }; 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 }; + | { + 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 ------------------------------------------------------------------- @@ -54,8 +77,12 @@ export type ComponentTypeFixture = { slots?: SlotDef[]; }; -export type ContentPropertyDef = - | { id: string; name: string; type: 'String' | 'RichText' | 'Boolean' | 'Number'; required?: boolean }; +export type ContentPropertyDef = { + id: string; + name: string; + type: 'String' | 'RichText' | 'Boolean' | 'Number'; + required?: boolean; +}; export type DesignPropertyDef = { id: string; @@ -139,9 +166,7 @@ export type ExperienceFixture = { slots: { content: ExperienceNode[] }; }; -export type ExperienceNode = - | InlineFragmentNode - | ContainerNode; +export type ExperienceNode = InlineFragmentNode | ContainerNode; // An InlineFragment node has its content sourced from a DataAssembly binding. export type InlineFragmentNode = { diff --git a/examples/sveltekit/README.md b/examples/sveltekit/README.md index 806bdf7..9a26bc0 100644 --- a/examples/sveltekit/README.md +++ b/examples/sveltekit/README.md @@ -43,11 +43,11 @@ Add `CPA_TOKEN=...` (Content Preview API token from **Settings → API keys** in ### 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 | +| 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/routes/[slug]/+page.server.ts b/examples/sveltekit/src/routes/[slug]/+page.server.ts index 1c80586..baddb2c 100644 --- a/examples/sveltekit/src/routes/[slug]/+page.server.ts +++ b/examples/sveltekit/src/routes/[slug]/+page.server.ts @@ -22,8 +22,7 @@ export const load: PageServerLoad = async ({ params, url, request }) => { { // 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, + accessToken: previewMode && env.CPA_TOKEN ? env.CPA_TOKEN : env.CDA_TOKEN, host: previewMode ? 'https://preview.xdn.contentful.com' : 'https://xdn.contentful.com', }, { From 614ca72b98308c116b4853f0102fb740982d22eb Mon Sep 17 00:00:00 2001 From: Lisa White Date: Tue, 21 Jul 2026 10:41:13 -0600 Subject: [PATCH 17/19] refactor: bundle demo assets locally instead of fetching from the source CDN [AIS-246] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bootstrap used to fetch three images from the shared demo test space's public CDN at seed time. That worked, but tied the demo to a URL we don't own — if those assets moved or the space got cleaned up, first-time bootstrap runs would 404. Ships the source bytes in-repo under examples/scripts/fixture/assets/ (hero-bg.png ~53 KB, card-on.png ~35 KB, card-guide.webp ~15 KB, ~103 KB total). AssetFixture.sourceUrl → sourcePath; seedAsset reads the bytes with readFileSync resolved relative to the script's own directory so the demo works from any CWD. Co-Authored-By: Claude Opus 4.7 --- examples/scripts/README.md | 2 +- examples/scripts/bootstrap-example.ts | 15 +++++++++++---- examples/scripts/fixture/assets.ts | 14 ++++++-------- examples/scripts/fixture/assets/card-guide.webp | Bin 0 -> 15074 bytes examples/scripts/fixture/assets/card-on.png | Bin 0 -> 36270 bytes examples/scripts/fixture/assets/hero-bg.png | Bin 0 -> 54190 bytes examples/scripts/fixture/types.ts | 6 +++++- 7 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 examples/scripts/fixture/assets/card-guide.webp create mode 100644 examples/scripts/fixture/assets/card-on.png create mode 100644 examples/scripts/fixture/assets/hero-bg.png diff --git a/examples/scripts/README.md b/examples/scripts/README.md index baac772..abc050e 100644 --- a/examples/scripts/README.md +++ b/examples/scripts/README.md @@ -31,7 +31,7 @@ The demo is a minimal `landing` Experience — one hero + two cards — that exe | Step | Resource type | Count | Notes | | ---- | ------------- | ----- | --------------------------------------------------------------------------------- | | 1 | ContentType | 1 | `promotion` (title, teaser, body, ctaLabel, ctaUrl, image) | -| 2 | Asset | 3 | hero background + 2 card images, uploaded from the source demo space's public CDN | +| 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 | diff --git a/examples/scripts/bootstrap-example.ts b/examples/scripts/bootstrap-example.ts index a190622..19a388b 100644 --- a/examples/scripts/bootstrap-example.ts +++ b/examples/scripts/bootstrap-example.ts @@ -26,6 +26,10 @@ /* 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, @@ -183,10 +187,13 @@ async function seedAsset(fixture: AssetFixture) { if (!isNotFound(err)) throw err; } - log(` … downloading ${fixture.sourceUrl}`); - const res = await fetch(fixture.sourceUrl); - if (!res.ok) throw new Error(`Failed to fetch ${fixture.sourceUrl}: ${res.status}`); - const arrayBuffer = await res.arrayBuffer(); + // 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 }); diff --git a/examples/scripts/fixture/assets.ts b/examples/scripts/fixture/assets.ts index 340372a..5e61579 100644 --- a/examples/scripts/fixture/assets.ts +++ b/examples/scripts/fixture/assets.ts @@ -1,30 +1,28 @@ import type { AssetFixture } from './types.js'; -// Source URLs come from the shared demo test space (w65h2sqzlqap). The bootstrap -// downloads each and re-uploads it as an asset in the caller's target space. +// 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', - sourceUrl: - 'https://images.ctfassets.net/w65h2sqzlqap/4pbxgJHy3kvCmS6Y7S0Dz6/d9158fd0e10cbb3731b2125e8fefd272/background-blue-connections.png', + sourcePath: 'assets/hero-bg.png', }, { tempId: 'asset:card-on', title: 'On case study image', fileName: 'card-on.png', contentType: 'image/png', - sourceUrl: - 'https://images.ctfassets.net/w65h2sqzlqap/1sY0RBemn8qu4chA0kHeqE/6a65a4b54d797ca87300e575e6085ac1/Generic-blue-cart.png', + sourcePath: 'assets/card-on.png', }, { tempId: 'asset:card-guide', title: 'Developer guide image', fileName: 'card-guide.webp', contentType: 'image/webp', - sourceUrl: - 'https://images.ctfassets.net/w65h2sqzlqap/5VeMf09DxYNyLPfalm7Lde/c928d2407fd8f4c4d3c92b356b237b50/build-faster.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 0000000000000000000000000000000000000000..00c54c94bb9af8017eb4330a8dd452f2509b83f8 GIT binary patch literal 15074 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%0AQY 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 literal 0 HcmV?d00001 diff --git a/examples/scripts/fixture/assets/hero-bg.png b/examples/scripts/fixture/assets/hero-bg.png new file mode 100644 index 0000000000000000000000000000000000000000..ef219245b2bfaab870df655378938bcc30c7b875 GIT binary patch literal 54190 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 OUY*f3IGL|Q2>BnYV?wV0 literal 0 HcmV?d00001 diff --git a/examples/scripts/fixture/types.ts b/examples/scripts/fixture/types.ts index 4a7b192..e30f93b 100644 --- a/examples/scripts/fixture/types.ts +++ b/examples/scripts/fixture/types.ts @@ -51,7 +51,11 @@ export type AssetFixture = { title: string; fileName: string; contentType: string; - sourceUrl: 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 ------------------------------------------------------------------ From 442ddd254ecb483383f20637b115fb1f70df6f35 Mon Sep 17 00:00:00 2001 From: Lisa White Date: Tue, 21 Jul 2026 10:55:32 -0600 Subject: [PATCH 18/19] chore: update docs and comments --- examples/nextjs/README.md | 11 +++++------ examples/nextjs/components/HeroPlain.tsx | 2 +- examples/nextjs/components/Page.tsx | 7 ------- examples/scripts/README.md | 5 ++--- examples/sveltekit/README.md | 2 +- .../sveltekit/src/lib/components/HeroPlain.svelte | 2 +- examples/sveltekit/src/lib/components/RichText.svelte | 1 - examples/sveltekit/src/lib/experience-config.ts | 3 --- 8 files changed, 10 insertions(+), 23 deletions(-) diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md index 4ddf18a..d59f6ba 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -16,7 +16,7 @@ A Next.js 15 App Router app demonstrating `@contentful/experiences-react` render 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. **Environment tokens** for the paths you want to hit — different Contentful APIs use different tokens. +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. @@ -24,7 +24,7 @@ The [`examples/scripts/bootstrap-example.ts`](../scripts/bootstrap-example.ts) s ```sh # From the repo root: -npm install --ignore-scripts +npm install npm run build # build the SDK packages cd examples/scripts @@ -44,7 +44,9 @@ Visit `http://localhost:3000/landing`. This route is the minimal three-line inte ### 2b. Run the advanced route (`/advanced/landing?preview=true`) -The advanced route can also 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`, which rejects CDA tokens. +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`: @@ -267,6 +269,3 @@ 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/components/HeroPlain.tsx b/examples/nextjs/components/HeroPlain.tsx index cfd7d23..143ab9a 100644 --- a/examples/nextjs/components/HeroPlain.tsx +++ b/examples/nextjs/components/HeroPlain.tsx @@ -6,7 +6,7 @@ import { toCss, useDesignValues } from '@contentful/experiences-react'; export interface HeroPlainProps { title?: string; - body?: unknown; // RichText document; omitted in the DA return for now + body?: unknown; ctaLabel?: string; ctaUrl?: string; image?: string; diff --git a/examples/nextjs/components/Page.tsx b/examples/nextjs/components/Page.tsx index c1b2c1c..aa19947 100644 --- a/examples/nextjs/components/Page.tsx +++ b/examples/nextjs/components/Page.tsx @@ -5,13 +5,6 @@ export interface PageProps { children?: ReactNode; } -/** - * Page-level template: passthrough wrapper. Each top-level node handles its - * own containment — the hero goes edge-to-edge, the cards Section applies - * its own horizontal padding via design values. Keeping the template thin - * matches the Svelte example and lets full-width heroes render without a - * fight against a max-width parent. - */ export function Page({ children }: PageProps) { const wrapper: CSSProperties = { display: 'flex', diff --git a/examples/scripts/README.md b/examples/scripts/README.md index abc050e..6f9a583 100644 --- a/examples/scripts/README.md +++ b/examples/scripts/README.md @@ -1,6 +1,6 @@ # examples/scripts -One-time setup scripts for the customer-facing example apps in [`../nextjs`](../nextjs) and [`../sveltekit`](../sveltekit). Not published; not part of the SDK; not something the running example apps depend on. This directory only exists to make the examples runnable from a clean checkout. +One-time setup scripts for the customer-facing example apps in [`../nextjs`](../nextjs) and [`../sveltekit`](../sveltekit). ## bootstrap-example.ts @@ -48,6 +48,5 @@ The concrete data the script provisions lives in [`fixture/`](./fixture) — one ### Known limitations -- **RichText body is currently omitted from the DataAssembly return mapping.** The DA validator rejects our `body { document: json }` GraphQL alias with "Field path `_node.body` not found." Symbol fields (title, ctaLabel, ctaUrl, image.url) all work. Follow-up needed to figure out the right RichText mapping shape. - **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, so the script talks to it directly. +- **`/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/sveltekit/README.md b/examples/sveltekit/README.md index 9a26bc0..59cf985 100644 --- a/examples/sveltekit/README.md +++ b/examples/sveltekit/README.md @@ -19,7 +19,7 @@ The example is a real integration against Contentful, not a mock. You need a Con ```sh # From the repo root: -npm install --ignore-scripts +npm install npm run build # build the SDK packages cd examples/scripts diff --git a/examples/sveltekit/src/lib/components/HeroPlain.svelte b/examples/sveltekit/src/lib/components/HeroPlain.svelte index f4e7be6..56b4805 100644 --- a/examples/sveltekit/src/lib/components/HeroPlain.svelte +++ b/examples/sveltekit/src/lib/components/HeroPlain.svelte @@ -6,7 +6,7 @@