diff --git a/.nxignore b/.nxignore index 660936a..261d783 100644 --- a/.nxignore +++ b/.nxignore @@ -1,5 +1,7 @@ -# Examples are local testbeds for the SDK packages, not part of the -# Nx project graph. They stay linked via npm workspaces (so they consume -# the local SDK packages by symlink) but Nx commands like `nx run-many`, -# `nx graph`, `nx affected`, and `nx release` ignore them entirely. +# Examples and test-apps are local testbeds for the SDK packages, not part +# of the Nx project graph. They stay linked via npm workspaces (so they +# consume the local SDK packages by symlink) but Nx commands like +# `nx run-many`, `nx graph`, `nx affected`, and `nx release` ignore them +# entirely. examples +test-apps diff --git a/AGENTS.md b/AGENTS.md index b1e0ca9..4c5f762 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,11 +35,16 @@ experiences/ │ ├── client/ # @contentful/experiences-client (internal) │ ├── adapter-react/ # @contentful/experiences-react (customer-facing) │ └── adapter-svelte/ # @contentful/experiences-svelte (customer-facing) -└── examples/ - ├── nextjs/ # Next.js 15 example app - └── sveltekit/ # SvelteKit 2 example app (1:1 parity with nextjs) +├── examples/ # Customer-facing example apps +│ ├── nextjs/ # Next.js 15 example (external developers run this) +│ └── sveltekit/ # SvelteKit 2 example (1:1 parity with nextjs) +└── test-apps/ # Internal testing + ├── nextjs/ # Next.js scratchpad + └── sveltekit/ # SvelteKit scratchpad ``` +**`examples/` vs `test-apps/`.** `examples/` is the stable, external-facing surface — every commit to it should keep the customer-facing "clone + bootstrap + run" flow working. `test-apps/` is where you experiment: try new component patterns, break things, prototype features. Don't reach into `examples/` when you just need a place to poke — copy your changes into `test-apps/` first, iterate there, then port back deliberately. + ### Package roles | Folder | npm name | Audience | @@ -357,11 +362,19 @@ npx nx run-many -t build --skip-nx-cache ### Run the example app ```sh -cd examples/nextjs -cp .env.example .env.local # fill in SPACE_ID + CDA_TOKEN -npm run dev # http://localhost:3000/ +# 1. Seed the demo Experience into your target space (one-time). +cd examples/scripts +cp .env.example .env # fill in SPACE_ID, ENVIRONMENT_ID, CMA_TOKEN +npm run bootstrap # prints experienceId (default: `landing`) + +# 2. Run the app. +cd ../nextjs +cp .env.example .env.local # fill in SPACE_ID, ENVIRONMENT_ID, CDA_TOKEN +npm run dev # http://localhost:3000/landing ``` +The bootstrap script (`examples/scripts/bootstrap-example.ts`) provisions everything the demo Experience references — ContentType, entries, assets, design tokens, ComponentTypes, Template, DataAssemblies, Experience — via the experiences management API (currently `contentful-management@12.6.0-dev.4`). Idempotent per resource; safe to re-run against a half-seeded env. See `examples/scripts/README.md` for details. + ### Add a new framework adapter `packages/adapter-svelte` is the canonical example of "framework that isn't React" — copy from there for non-React frameworks (different build tool, peer dep, etc.); copy from `packages/adapter-react` for "framework like React" (JSX-ish + tsup). diff --git a/README.md b/README.md index a9f9f47..314577f 100644 --- a/README.md +++ b/README.md @@ -335,16 +335,26 @@ Runnable apps for both frameworks live in [`examples/`](./examples). They regist | [`examples/nextjs`](./examples/nextjs) | Next.js 15 (App Router) | Simple + advanced routes (preview, UA→viewport, async `resolveData`), design tokens, styling hooks | | [`examples/sveltekit`](./examples/sveltekit) | SvelteKit 2 + Svelte 5 | 1:1 parity with the Next.js app; hydration-safe viewport seeding via `+page.server.ts` | +Both examples render the same demo Experience. To run them you first seed that Experience into your Contentful space with the one-time bootstrap script — the script uses the experiences management API to provision the ContentType, entries, assets, design tokens, ComponentTypes, template, DataAssemblies, and the Experience itself. + ```sh npm install --ignore-scripts -npm run build # build the SDK packages +npm run build # build the SDK packages + +# 1. Seed the demo Experience into your Contentful space (one-time). +cd examples/scripts +cp .env.example .env # fill in SPACE_ID, ENVIRONMENT_ID, CMA_TOKEN +npm run bootstrap # prints the experienceId at the end (default: `landing`) -cd examples/nextjs # or examples/sveltekit -cp .env.example .env.local # sveltekit uses .env; fill in SPACE_ID + CDA_TOKEN +# 2. Run one of the example apps against the seeded space. +cd ../nextjs # or ../sveltekit +cp .env.example .env.local # sveltekit uses .env; fill in SPACE_ID, ENVIRONMENT_ID, CDA_TOKEN npm run dev ``` -Then visit `/`. See each example's README for its file map and route-by-route walkthrough. +Then visit `/landing` (or whichever experienceId the bootstrap printed). See each example's README for its file map and route-by-route walkthrough. + +**Tokens.** `CMA_TOKEN` is a Personal Access Token that only the bootstrap script sees. `CDA_TOKEN` is a Content Delivery API token — this is what the running app uses at runtime. `CPA_TOKEN` is a Content Preview API token, only needed if you want to exercise `?preview=true` routes; see each example's README for details. --- diff --git a/examples/nextjs/.env.example b/examples/nextjs/.env.example index 2bddab2..5c18231 100644 --- a/examples/nextjs/.env.example +++ b/examples/nextjs/.env.example @@ -1,5 +1,16 @@ # Copy this file to .env.local and fill in real values from a Contentful space. +# +# Bootstrap the demo Experience into your space first with: +# cd examples/scripts && npm run bootstrap +# The script prints the experienceId to hit at the end (default: `landing`). SPACE_ID= ENVIRONMENT_ID=master + +# Content Delivery API token — used by every route by default. CDA_TOKEN= + +# Content Preview API token — required only for the /advanced route with +# ?preview=true (that path hits preview.xdn.contentful.com, which rejects +# CDA tokens). Leave blank if you don't need preview mode. +CPA_TOKEN= diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md index 949ecea..45225f2 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -13,26 +13,67 @@ A Next.js 15 App Router app demonstrating `@contentful/experiences-react` render ## Run it +The example is a real integration against Contentful, not a mock. You need: + +1. **A Contentful space** with the demo content model + Experience seeded into it (a one-time step below), and +2. **Tokens** for the paths you want to hit — different Contentful APIs use different tokens. + +The [`examples/scripts/bootstrap-example.ts`](../scripts/bootstrap-example.ts) script does the seeding via the management API. See [`examples/scripts/README.md`](../scripts/README.md) for what it provisions. + +### 1. Seed the demo Experience (one-time) + ```sh # From the repo root: -npm install --ignore-scripts -npm run build # builds the SDK packages +npm install +npm run build # build the SDK packages + +cd examples/scripts +cp .env.example .env # fill in SPACE_ID, ENVIRONMENT_ID, CMA_TOKEN +npm run bootstrap # prints the experienceId at the end (default: `landing`) +``` -cd examples/nextjs -cp .env.example .env.local # fill in SPACE_ID + CDA_TOKEN +### 2a. Run the basic route (`/landing`) + +```sh +cd ../nextjs +cp .env.example .env.local # fill in SPACE_ID, ENVIRONMENT_ID, CDA_TOKEN npm run dev ``` -Then visit `http://localhost:3000/`. The slug becomes the Experience ID passed to `client.view.getExperience`. +Visit `http://localhost:3000/landing`. This route is the minimal three-line integration described below — `fetchExperience` → ``. It reads from the Content Delivery API using `CDA_TOKEN`. + +### 2b. Run the advanced route (`/advanced/landing?preview=true`) + +The advanced route can be reached at `http://localhost:3000/advanced/landing` (no query params) using the same `CDA_TOKEN` — you'll get the enrichment + viewport-seeding demo. + +To exercise **preview mode** as well (the "Advanced demo (preview)" button on the index page), you also need a **Content Preview API token** — preview requests hit `preview.xdn.contentful.com`. + +Add it to `.env.local`: + +``` +CPA_TOKEN=... # Content Preview API token, from Settings → API keys in your space +``` + +Then visit `http://localhost:3000/advanced/landing?preview=true&locale=en-US`. + +### Tokens summary + +| Token | API | Used by | Required? | +| ----------- | ------------------ | ------------------------------------------------ | -------------------------------------- | +| `CMA_TOKEN` | Content Management | The bootstrap script (one-time seed) | Yes, to run bootstrap | +| `CDA_TOKEN` | Content Delivery | The example app for `/landing` and `/advanced/*` | Yes, to run the app | +| `CPA_TOKEN` | Content Preview | The example app when `?preview=true` | Only for preview mode on `/advanced/*` | ## Two routes, same data The example ships two side-by-side routes so you can see what each SDK option gives you. They render the same Experience id; only the SDK setup changes. -| Route | Page | Config | Demonstrates | -| ------------------ | ---------------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `/[slug]` | [`app/[slug]/page.tsx`](./app/[slug]/page.tsx) | `experience-config.tsx` | The minimum: `fetchExperience` into `` with `NotFoundError` routed to Next's `notFound()`. | -| `/advanced/[slug]` | [`app/advanced/[slug]/page.tsx`](./app/advanced/[slug]/page.tsx) | `experience-config-advanced.tsx` | Preview mode via `?preview=true`, User-Agent to `initialViewportId`, async `resolveData` with external fetch. | +| Route | Try it locally | Config | Demonstrates | +| ------------------ | ------------------------------------------------------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `/[slug]` | `http://localhost:3000/landing` | `experience-config.tsx` | The minimum: `fetchExperience` into `` with `NotFoundError` routed to Next's `notFound()`. | +| `/advanced/[slug]` | `http://localhost:3000/advanced/landing?preview=true&locale=en-US` | `experience-config-advanced.tsx` | Preview mode via `?preview=true` (needs `CPA_TOKEN`), User-Agent to `initialViewportId`, async `resolveData`. | + +Source: [`app/[slug]/page.tsx`](./app/[slug]/page.tsx), [`app/advanced/[slug]/page.tsx`](./app/advanced/[slug]/page.tsx). The minimal `[slug]/page.tsx`: @@ -227,7 +268,3 @@ export const experienceConfig: Config = { components, templates }; If the payload references a template id that isn't registered, the renderer warns once and renders the nodes unwrapped, the same graceful-degradation behavior as missing components. - -## Where the live preview / editor support fits - -Live preview (postMessage from the Contentful editor iframe) lands in a separate increment with a client-component wrapper that uses `ClientExperienceRenderer` and a `useMessagingClient`-style hook. SSR and interactive editor mode are mutually exclusive: the editor mode requires `'use client'` so the message listener can attach. diff --git a/examples/nextjs/app/advanced/[slug]/page.tsx b/examples/nextjs/app/advanced/[slug]/page.tsx index 617ff67..1763dd5 100644 --- a/examples/nextjs/app/advanced/[slug]/page.tsx +++ b/examples/nextjs/app/advanced/[slug]/page.tsx @@ -50,7 +50,12 @@ export default async function AdvancedExperiencePage({ params, searchParams }: P locale, }, { - accessToken: process.env.CDA_TOKEN!, + // Preview mode reads from the CPA endpoint, which needs a Content + // Preview token — the CDA token is rejected by that host. If preview + // mode is on but CPA_TOKEN is unset, we still call preview but the + // request will 401; document this in .env.example. + accessToken: + previewMode && process.env.CPA_TOKEN ? process.env.CPA_TOKEN : process.env.CDA_TOKEN!, host: previewMode ? 'https://preview.xdn.contentful.com' : 'https://xdn.contentful.com', }, { diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx index a019c68..ded731c 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -69,16 +69,17 @@ export default function HomePage() {

- + Simple demo - + Advanced demo (preview)

- Replace demo in either URL with a real Experience id from your space. + landing is the id the bootstrap script (examples/scripts) seeds by + default. Replace it in the URL with any other Experience id from your space.

); diff --git a/examples/nextjs/components/Card.tsx b/examples/nextjs/components/Card.tsx new file mode 100644 index 0000000..baa1115 --- /dev/null +++ b/examples/nextjs/components/Card.tsx @@ -0,0 +1,70 @@ +'use client'; + +import type { CSSProperties } from 'react'; + +import { toCss, useDesignValues } from '@contentful/experiences-react'; + +export interface CardProps { + title?: string; + teaser?: string; + ctaLabel?: string; + ctaUrl?: string; + image?: string; +} + +/** + * Compact card: image + title + teaser + CTA. Content properties come from + * a `Card from Promotion` DataAssembly binding. + */ +export function Card({ title, teaser, ctaLabel, ctaUrl, image }: CardProps) { + const design = useDesignValues<{ + backgroundColor?: string; + color?: string; + }>(); + + const style: CSSProperties = { + display: 'flex', + flexDirection: 'column', + borderRadius: '0.5rem', + overflow: 'hidden', + boxShadow: '0 1px 3px rgba(0,0,0,0.08)', + ...toCss(design), + }; + + return ( +
+ {image && ( + + )} +
+ {title &&

{title}

} + {teaser &&

{teaser}

} + {ctaLabel && ctaUrl && ( + + {ctaLabel} + + )} +
+
+ ); +} diff --git a/examples/nextjs/components/HeroPlain.tsx b/examples/nextjs/components/HeroPlain.tsx new file mode 100644 index 0000000..143ab9a --- /dev/null +++ b/examples/nextjs/components/HeroPlain.tsx @@ -0,0 +1,65 @@ +'use client'; + +import type { CSSProperties } from 'react'; + +import { toCss, useDesignValues } from '@contentful/experiences-react'; + +export interface HeroPlainProps { + title?: string; + body?: unknown; + ctaLabel?: string; + ctaUrl?: string; + image?: string; +} + +/** + * Composed hero: title + optional body + CTA + hero image. All content + * properties come from a `Hero from Promotion` DataAssembly binding — this + * component just lays them out. + */ +export function HeroPlain({ title, ctaLabel, ctaUrl, image }: HeroPlainProps) { + const design = useDesignValues<{ + backgroundColor?: string; + color?: string; + }>(); + + const style: CSSProperties = { + display: 'grid', + gridTemplateColumns: image ? '1fr 1fr' : '1fr', + alignItems: 'center', + gap: '2rem', + padding: '4rem 2rem', + ...toCss(design), + }; + + return ( +
+
+ {title &&

{title}

} + {ctaLabel && ctaUrl && ( + + {ctaLabel} + + )} +
+ {image && ( + + )} +
+ ); +} diff --git a/examples/nextjs/components/Page.tsx b/examples/nextjs/components/Page.tsx index 4ca31fc..aa19947 100644 --- a/examples/nextjs/components/Page.tsx +++ b/examples/nextjs/components/Page.tsx @@ -5,15 +5,11 @@ export interface PageProps { children?: ReactNode; } -/** Page-level template: wraps all top-level nodes in the outer page chrome. */ export function Page({ children }: PageProps) { const wrapper: CSSProperties = { - maxWidth: 1024, - margin: '0 auto', - padding: '48px 24px', display: 'flex', flexDirection: 'column', - gap: 48, + gap: 16, }; return
{children}
; } diff --git a/examples/nextjs/lib/design-tokens.ts b/examples/nextjs/lib/design-tokens.ts index e07647e..82c005a 100644 --- a/examples/nextjs/lib/design-tokens.ts +++ b/examples/nextjs/lib/design-tokens.ts @@ -10,6 +10,8 @@ export const designTokens: Record = { 'color.none': 'transparent', // resolves "no background" to a real value 'color.white': '#ffffff', 'color.text': '#1f2937', + 'color.primary': '#0f172a', // hero background + 'color.primaryText': '#f8fafc', // hero foreground 'fontSize.sm': '14px', 'fontSize.md': '16px', diff --git a/examples/nextjs/lib/experience-config-advanced.tsx b/examples/nextjs/lib/experience-config-advanced.tsx index 0a67cb1..00ab091 100644 --- a/examples/nextjs/lib/experience-config-advanced.tsx +++ b/examples/nextjs/lib/experience-config-advanced.tsx @@ -1,49 +1,71 @@ /** - * Advanced config: async `resolveData` on `Button` (fake fetch + a localized - * URL from `experience.metadata`), on top of the simple `./experience-config`. + * Advanced config: async `resolveData` on `card` (fake enrichment fetch + + * a metadata-aware CTA rewrite), plus preview-mode + design-token wiring + * — layered on top of the simple `./experience-config`. + * + * Compared to `./experience-config`, the differences are: + * 1. `card` is registered with `defineComponent({ resolveData, component })` + * so we can demonstrate async enrichment (a stand-in for "fetch a badge + * from a catalog service") and use per-page metadata (locale, slug) to + * rewrite the CTA URL. + * 2. Everything else — primitives + hero-plain — is identical to the + * simple config. */ import { defineComponent, type Components, type Config, + type ResolveToken, type Templates, } from '@contentful/experiences-react'; -import { Button, type ButtonProps } from '@/components/Button'; +import { Button } from '@/components/Button'; +import { Card, type CardProps } from '@/components/Card'; import { Heading } from '@/components/Heading'; +import { HeroPlain } from '@/components/HeroPlain'; import { Image } from '@/components/Image'; import { Page } from '@/components/Page'; import { RichText } from '@/components/RichText'; import { Section } from '@/components/Section'; import { Text } from '@/components/Text'; +import { designTokens } from '@/lib/design-tokens'; -// Stand-in for an async enrichment fetch; resolvers run in parallel per node. -async function fetchButtonEnrichment(label: string): Promise<{ formattedLabel: string }> { +// Stand-in for an async enrichment fetch — a catalog lookup, a personalization +// service call, or anything else you'd want to do off the critical render path. +// Resolvers run in parallel across nodes, so slow ones don't block others. +async function fetchCardEnrichment(title: string): Promise<{ badge: string }> { await new Promise((resolve) => setTimeout(resolve, 50)); - return { formattedLabel: label.toUpperCase() }; + return { badge: `Featured: ${title}` }; } const components: Components = { Section, Heading, RichText, - Image, Text, + Button, + Image, + 'hero-plain': HeroPlain, - // defineComponent narrows the resolveData ctx + return type to ButtonProps. - Button: defineComponent({ + // defineComponent narrows the resolveData ctx + return type to CardProps. + card: defineComponent({ resolveData: async ({ content, experience }) => { - const rawLabel = (content.label as string) ?? 'Button'; - const { formattedLabel } = await fetchButtonEnrichment(rawLabel); + const rawTitle = (content.title as string) ?? 'Untitled'; + const { badge } = await fetchCardEnrichment(rawTitle); const locale = (experience.metadata.locale as string) ?? 'en-US'; const slug = (experience.metadata.slug as string) ?? ''; + // Rewrite the CTA to a locale-aware localized route (fake — for demo). + const originalUrl = (content.ctaUrl as string) ?? ''; + const ctaUrl = originalUrl.startsWith('http') + ? originalUrl + : `/${locale}/${slug}${originalUrl}`; return { - label: formattedLabel, - url: `/${locale}/${slug}`, + title: `${badge}`, + ctaUrl, }; }, - component: Button, + component: Card, }), }; @@ -51,4 +73,6 @@ const templates: Templates = { page: { component: Page, defaults: { title: 'Featured (advanced)' } }, }; -export const advancedExperienceConfig: Config = { components, templates }; +const resolveToken: ResolveToken = (token) => designTokens[token.value]; + +export const advancedExperienceConfig: Config = { components, templates, resolveToken }; diff --git a/examples/nextjs/lib/experience-config.tsx b/examples/nextjs/lib/experience-config.tsx index dcbf8e4..68f193d 100644 --- a/examples/nextjs/lib/experience-config.tsx +++ b/examples/nextjs/lib/experience-config.tsx @@ -12,7 +12,9 @@ import { } from '@contentful/experiences-react'; import { Button } from '@/components/Button'; +import { Card } from '@/components/Card'; import { Heading } from '@/components/Heading'; +import { HeroPlain } from '@/components/HeroPlain'; import { Image } from '@/components/Image'; import { Page } from '@/components/Page'; import { RichText } from '@/components/RichText'; @@ -27,6 +29,8 @@ const components: Components = { Text, Button, Image, + 'hero-plain': HeroPlain, + card: Card, }; const templates: Templates = { diff --git a/examples/scripts/.env.example b/examples/scripts/.env.example new file mode 100644 index 0000000..06d0c8b --- /dev/null +++ b/examples/scripts/.env.example @@ -0,0 +1,9 @@ +# Copy this file to .env (in this directory) and fill in real values. +# +# CMA_TOKEN needs write access to the space + environment above. Create a +# Personal Access Token (CFPAT) at https://app.contentful.com/account/profile/cma_tokens +# — make sure you're in the org that owns the target space. + +SPACE_ID= +ENVIRONMENT_ID=master +CMA_TOKEN= diff --git a/examples/scripts/README.md b/examples/scripts/README.md new file mode 100644 index 0000000..e382e46 --- /dev/null +++ b/examples/scripts/README.md @@ -0,0 +1,52 @@ +# examples/scripts + +One-time setup scripts for the customer-facing example apps in [`../nextjs`](../nextjs) and [`../sveltekit`](../sveltekit). + +## bootstrap-example.ts + +Seeds the demo Experience into a Contentful space + environment via the experiences management API. After it succeeds, the example apps can fetch and render the seeded Experience by id. + +### Run it + +```sh +cp .env.example .env +# Fill in: +# SPACE_ID — id of the space you're seeding into +# ENVIRONMENT_ID — id of the environment (usually `master`) +# CMA_TOKEN — Personal Access Token (CFPAT-...) with write access +# to that space. Create one at +# https://app.contentful.com/account/profile/cma_tokens +# from within the org that owns the space. + +npm install # from the repo root, if you haven't already +npm run bootstrap +``` + +The script prints the resulting experienceId at the end — paste it into the example app's `.env.local` (or hit `/landing` directly if you left the fixture unchanged). + +### What it seeds + +The demo is a minimal `landing` Experience — one hero + two cards — that exercises the ExO composition pattern end-to-end. It provisions: + +| Step | Resource type | Count | Notes | +| ---- | ------------- | ----- | --------------------------------------------------------------------------------------- | +| 1 | ContentType | 1 | `promotion` (title, teaser, body, ctaLabel, ctaUrl, image) | +| 2 | Asset | 3 | hero background + 2 card images, read from `fixture/assets/` and uploaded to your space | +| 3 | Entry | 3 | 3 `promotion` entries (hero + 2 cards) | +| 4 | DesignToken | 15 | color/size/fontSize/fontWeight tokens referenced by ComponentTypes | +| 5 | ComponentType | 8 | Section, Heading, RichText, Text, Button, Image (primitives) + hero-plain + card | +| 6 | Template | 1 | `page` (passthrough) | +| 7 | DataAssembly | 2 | `Hero from Promotion` + `Card from Promotion` (map entry fields to CT props) | +| 8 | (linkage) | 2 | Append DA links to hero-plain and card ComponentTypes, republish | +| 9 | Experience | 1 | `landing` — hero + Section(card, card) | + +Each step is idempotent: if a resource with the fixture's id already exists, that step is skipped. Re-running against a half-seeded env picks up where a previous run left off. + +### Fixture + +The concrete data the script provisions lives in [`fixture/`](./fixture) — one TypeScript module per resource kind. If you want a different demo, edit those files rather than the bootstrap. `fixture/types.ts` documents each shape; every fixture module exports typed data the bootstrap imports directly. + +### Known limitations + +- **CMA is pinned to `12.6.0-dev.4`** — this is a dev build that exposes the ExO plain client (component types, templates, data assemblies, experiences). Newer stable versions of `contentful-management` don't ship these APIs yet. +- **`/design_tokens` is called via raw `fetch()`** — the CMA dev build's plain client doesn't cover that endpoint yet. Customers setting up Experience Orchestration are encouraged to use the [Design System Import CLI tool](https://github.com/contentful/experience-design-system-sdk-public) diff --git a/examples/scripts/bootstrap-example.ts b/examples/scripts/bootstrap-example.ts new file mode 100644 index 0000000..19a388b --- /dev/null +++ b/examples/scripts/bootstrap-example.ts @@ -0,0 +1,680 @@ +/** + * Bootstrap the ExO example into a caller-specified Contentful space/environment. + * + * Reads SPACE_ID / ENVIRONMENT_ID / CMA_TOKEN from env (dotenv-friendly — expects + * an .env in the CWD). Provisions the fixture in order: + * + * 1. ContentTypes (create + publish) + * 2. Assets (upload + processForAllLocales + publish) + * 3. Entries (create + publish, with tempId asset refs resolved) + * 4. Design tokens (PUT via raw HTTP — plain client doesn't cover this yet) + * 5. ComponentTypes (create + publish, referencing tokens by id) + * 6. Template (create + publish) + * 7. DataAssemblies (create + publish, with cross-fixture ids resolved) + * 8. Link DAs to CTs (append DA links to composed CTs, republish CTs) + * 9. Experience (create + publish, with dataAssembly + entry refs resolved) + * + * Idempotent per resource: if a resource with the fixture's id already exists, + * skip it. Re-running against a half-seeded env picks up where a previous run + * left off. + * + * Prints the resulting experienceId at the end. + * + * Run with: + * npm run bootstrap + */ +/* eslint-disable no-console */ +import { createClient, type PlainClientAPI } from 'contentful-management'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +import { + contentTypes, + assets, + entries, + designTokens, + componentTypes, + templates, + dataAssemblies, + dataAssemblyComponentTypeLinks, + experience, + type AssetFixture, + type EntryFixture, + type ContentTypeFixture, + type ComponentTypeFixture, + type TemplateFixture, + type DataAssemblyFixture, + type DesignTokenFixture, + type ExperienceFixture, + type ExperienceNode, + type TempId, +} from './fixture/index.js'; + +// --- Env --------------------------------------------------------------------- + +// Load a local .env if present, without adding dotenv as a hard dep. +try { + const envPath = process.env.DOTENV_PATH || '.env'; + const contents = readFileSync(envPath, 'utf8'); + for (const line of contents.split('\n')) { + const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/); + if (m && !process.env[m[1]!]) { + process.env[m[1]!] = m[2]!.replace(/^["']|["']$/g, ''); + } + } +} catch { + // no .env — assume env is set elsewhere +} + +const { SPACE_ID, ENVIRONMENT_ID, CMA_TOKEN } = process.env; +if (!SPACE_ID || !ENVIRONMENT_ID || !CMA_TOKEN) { + console.error( + 'Missing env — set SPACE_ID, ENVIRONMENT_ID, and CMA_TOKEN (either in the shell or in a local .env).' + ); + process.exit(1); +} + +const cma: PlainClientAPI = createClient( + { accessToken: CMA_TOKEN }, + { type: 'plain', defaults: { spaceId: SPACE_ID, environmentId: ENVIRONMENT_ID } } +); + +// --- Small helpers ----------------------------------------------------------- + +const log = (msg: string) => console.log(msg); +const step = (label: string) => log(`\n▸ ${label}`); + +// contentful-sdk-core wraps API errors as new Error() with .name = data.sys.id +// (e.g. 'NotFound') and JSON-stringifies the payload into .message. Neither +// .status nor .response is available. Check name; fall back to parsing the +// JSON message for a status field so we don't miss anything. +const isNotFound = (err: unknown): boolean => { + const e = err as { name?: string; message?: string }; + if (e?.name === 'NotFound') return true; + if (typeof e?.message === 'string') { + try { + const parsed = JSON.parse(e.message) as { status?: number }; + if (parsed?.status === 404) return true; + } catch { + /* not JSON — ignore */ + } + } + return false; +}; + +// Registry of tempId → real Contentful sys.id, built as resources are created. +const idMap = new Map(); +const remember = (tempId: TempId, realId: string) => idMap.set(tempId, realId); +const resolveId = (tempId: TempId): string => { + const id = idMap.get(tempId); + if (!id) { + throw new Error(`Unresolved tempId: ${tempId} (no resource with that tempId was created)`); + } + return id; +}; + +// A stable Contentful sys.id derived from a fixture tempId — keeps the seed +// idempotent since we can .get() by known id before .create()ing. +const stableId = (tempId: TempId) => `demo-${tempId.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}`; + +// --- URN builders ------------------------------------------------------------ + +const componentTypeUrn = (id: string) => + `crn:contentful:::experience:spaces/$self/environments/$self/componentTypes/${id}`; +const templateUrn = (id: string) => + `crn:contentful:::experience:spaces/$self/environments/$self/templates/${id}`; +const dataAssemblyUrn = (id: string) => + `crn:contentful:::experience:spaces/$self/environments/$self/dataAssemblies/${id}`; +const entryUrn = (id: string) => + `crn:contentful:::content:spaces/$self/environments/$self/entries/${id}`; + +const SAME_SPACE_CONTENT_SOURCE = 'crn:contentful:::content:spaces/$self/environments/$self'; + +// --- Raw CMA fetch (for endpoints the plain client doesn't expose) ---------- + +const CMA_BASE = `https://api.contentful.com/spaces/${SPACE_ID}/environments/${ENVIRONMENT_ID}`; + +async function cmaFetch(path: string, init: RequestInit = {}): Promise { + return fetch(`${CMA_BASE}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${CMA_TOKEN}`, + 'Content-Type': 'application/vnd.contentful.management.v1+json', + ...(init.headers ?? {}), + }, + }); +} + +// --- Resource seeders -------------------------------------------------------- + +async function seedContentType(fixture: ContentTypeFixture) { + try { + await cma.contentType.get({ contentTypeId: fixture.id }); + log(` ✓ ContentType "${fixture.id}" already exists — skipping`); + return; + } catch (err) { + if (!isNotFound(err)) throw err; + } + + const created = await cma.contentType.createWithId( + { contentTypeId: fixture.id }, + { + name: fixture.name, + description: fixture.description ?? '', + displayField: fixture.displayField, + fields: fixture.fields.map((f) => ({ + ...f, + required: 'required' in f && f.required ? true : false, + localized: 'localized' in f && f.localized ? true : false, + })) as never, + } + ); + await cma.contentType.publish({ contentTypeId: fixture.id }, created); + log(` ✓ ContentType "${fixture.id}" created + published`); +} + +async function seedAsset(fixture: AssetFixture) { + const assetId = stableId(fixture.tempId); + try { + const existing = await cma.asset.get({ assetId }); + remember(fixture.tempId, existing.sys.id); + log(` ✓ Asset "${fixture.tempId}" already exists — skipping`); + return; + } catch (err) { + if (!isNotFound(err)) throw err; + } + + // Bytes live on disk under fixture/assets/; resolved relative to this file + // so the script works from any CWD. + const absPath = resolvePath(__dirname, 'fixture', fixture.sourcePath); + log(` … reading ${fixture.sourcePath}`); + const bytes = readFileSync(absPath); + // Node's Buffer is a Uint8Array; the upload API accepts ArrayBuffer/Stream. + const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + + const upload = await cma.upload.create({}, { file: arrayBuffer }); + + const asset = await cma.asset.createWithId( + { assetId }, + { + fields: { + title: { 'en-US': fixture.title }, + file: { + 'en-US': { + contentType: fixture.contentType, + fileName: fixture.fileName, + uploadFrom: { sys: { type: 'Link', linkType: 'Upload', id: upload.sys.id } }, + }, + }, + } as never, + } + ); + const processed = await cma.asset.processForAllLocales({}, asset); + // processForAllLocales returns the asset once processing is initiated; we + // need to wait for the file URL to appear before publishing. + const ready = await waitForAssetProcessed(processed.sys.id); + await cma.asset.publish({ assetId: ready.sys.id }, ready); + + remember(fixture.tempId, ready.sys.id); + log(` ✓ Asset "${fixture.tempId}" → ${ready.sys.id}`); +} + +async function waitForAssetProcessed(assetId: string, maxAttempts = 30) { + for (let i = 0; i < maxAttempts; i++) { + const a = await cma.asset.get({ assetId }); + const file = (a.fields.file as Record | undefined)?.['en-US']; + if (file?.url) return a; + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error(`Asset ${assetId} did not finish processing within ${maxAttempts}s`); +} + +// Recursively walk an entry's field values and swap { $assetTempId } placeholders +// for real Contentful Link->Asset payloads. +function resolveAssetRefs(value: unknown): unknown { + if (Array.isArray(value)) return value.map(resolveAssetRefs); + if (value && typeof value === 'object') { + const obj = value as Record; + if (typeof obj.$assetTempId === 'string') { + return { sys: { type: 'Link', linkType: 'Asset', id: resolveId(obj.$assetTempId) } }; + } + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, resolveAssetRefs(v)])); + } + return value; +} + +async function seedEntry(fixture: EntryFixture) { + const entryId = stableId(fixture.tempId); + try { + const existing = await cma.entry.get({ entryId }); + remember(fixture.tempId, existing.sys.id); + log(` ✓ Entry "${fixture.tempId}" already exists — skipping`); + return; + } catch (err) { + if (!isNotFound(err)) throw err; + } + + const resolvedFields = resolveAssetRefs(fixture.fields) as Record; + const created = await cma.entry.createWithId( + { entryId, contentTypeId: fixture.contentTypeId }, + { fields: resolvedFields as never } + ); + await cma.entry.publish({ entryId: created.sys.id }, created); + + remember(fixture.tempId, created.sys.id); + log(` ✓ Entry "${fixture.tempId}" → ${created.sys.id}`); +} + +async function seedDesignToken(fixture: DesignTokenFixture) { + // Check existence first — an update needs the current version header, a + // create doesn't. Design tokens are content-addressable by name, so if it + // exists we can skip. + const existing = await cmaFetch(`/design_tokens/${fixture.id}`, { method: 'GET' }); + if (existing.ok) { + log(` ✓ DesignToken "${fixture.id}" already exists — skipping`); + return; + } + if (existing.status !== 404) { + const body = await existing.text(); + throw new Error(`Unexpected status ${existing.status} checking design_token: ${body}`); + } + + const res = await cmaFetch(`/design_tokens/${fixture.id}`, { + method: 'PUT', + body: JSON.stringify({ name: fixture.id, type: fixture.type, metadata: {} }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Failed to PUT design_token "${fixture.id}": ${res.status} ${body}`); + } + log(` ✓ DesignToken "${fixture.id}" (${fixture.type})`); +} + +async function seedComponentType(fixture: ComponentTypeFixture) { + const componentTypeId = fixture.id; + try { + await cma.componentType.get({ componentTypeId }); + log(` ✓ ComponentType "${fixture.id}" already exists — skipping`); + return; + } catch (err) { + if (!isNotFound(err)) throw err; + } + + const created = await cma.componentType.upsert({ componentTypeId }, { + sys: { id: componentTypeId, type: 'ComponentType' }, + name: fixture.name, + description: fixture.description ?? '', + viewports: [{ id: 'all-sizes', query: '*', displayName: 'All Sizes', previewSize: '100%' }], + contentProperties: (fixture.contentProperties ?? []).map((p) => ({ + ...p, + required: p.required ?? false, + })), + designProperties: fixture.designProperties ?? [], + slots: (fixture.slots ?? []).map((s) => ({ + ...s, + required: false, + validations: [], + })), + } as never); + await cma.componentType.publish({ + componentTypeId, + version: created.sys.version, + }); + log(` ✓ ComponentType "${fixture.id}" created + published`); +} + +async function seedTemplate(fixture: TemplateFixture) { + const templateId = fixture.id; + let existing: Awaited> | null = null; + try { + existing = await cma.template.get({ templateId }); + } catch (err) { + if (!isNotFound(err)) throw err; + } + + const desiredSlots = (fixture.slots ?? []).map((s) => ({ + ...s, + required: false, + validations: [], + })); + const desiredTree = fixture.componentTree ?? []; + + const templateBody = { + name: fixture.name, + description: fixture.description ?? '', + viewports: [{ id: 'all-sizes', query: '*', displayName: 'All Sizes', previewSize: '100%' }], + contentProperties: (fixture.contentProperties ?? []).map((p) => ({ + ...p, + required: p.required ?? false, + })), + designProperties: fixture.designProperties ?? [], + slots: desiredSlots, + componentTree: desiredTree, + // Composed (not Coded) — a Coded template requires an empty componentTree. + // The `page` template has a Slot node in its tree, so it must be composed. + metadata: { + tags: [], + annotations: { + Template: [ + { + sys: { + id: 'Contentful:ComposedImplementation', + type: 'Link', + linkType: 'Annotation', + }, + }, + ], + }, + }, + }; + + if (!existing) { + const created = await cma.template.upsert({ templateId }, { + sys: { id: templateId, type: 'Template' }, + ...templateBody, + } as never); + await cma.template.publish({ templateId, version: created.sys.version }); + log(` ✓ Template "${fixture.id}" created + published`); + return; + } + + // Always upsert on existing so any fixture edit (slots, tree, annotations, + // whatever) takes effect. If we're already published at the current version + // we can skip the network round-trip. + const isPublished = + !!existing.sys.publishedVersion && existing.sys.publishedVersion === existing.sys.version; + if (isPublished) { + // Compare desired vs. current to decide if we still need to write. + const currentTree = JSON.stringify(existing.componentTree ?? []); + const desiredTreeJson = JSON.stringify(desiredTree); + const currentSlotIds = (existing.slots ?? []).map((s) => s.id).sort(); + const desiredSlotIds = desiredSlots.map((s) => s.id).sort(); + const currentImpl = ( + ((existing.metadata as { annotations?: { Template?: Array<{ sys: { id: string } }> } }) + ?.annotations?.Template ?? []) as Array<{ sys: { id: string } }> + ) + .map((a) => a.sys.id) + .sort() + .join(','); + const desiredImpl = 'Contentful:ComposedImplementation'; + if ( + currentTree === desiredTreeJson && + JSON.stringify(currentSlotIds) === JSON.stringify(desiredSlotIds) && + currentImpl === desiredImpl + ) { + log(` ✓ Template "${fixture.id}" already exists — skipping`); + return; + } + } + + const updated = await cma.template.upsert({ templateId }, { + sys: { id: templateId, type: 'Template', version: existing.sys.version }, + ...templateBody, + } as never); + await cma.template.publish({ templateId, version: updated.sys.version }); + log(` ✓ Template "${fixture.id}" updated + published`); +} + +async function seedDataAssembly(fixture: DataAssemblyFixture) { + // DataAssemblies use server-assigned ids. To be idempotent we tag by name. + const existing = await cma.dataAssembly.getMany({ query: { limit: 100 } }); + const match = existing.items.find((da) => da.name === fixture.name); + if (match) { + remember(fixture.tempId, match.sys.id); + log(` ✓ DataAssembly "${fixture.name}" already exists — skipping`); + return; + } + + const parameters: Record = {}; + for (const [pid, pdef] of Object.entries(fixture.parameters)) { + parameters[pid] = { + name: pdef.name, + type: 'ResourceLink', + linkType: 'Contentful:Entry', + allowedResources: [ + { + type: 'Contentful:Entry', + source: SAME_SPACE_CONTENT_SOURCE, + allowedTypes: pdef.allowedContentTypes, + }, + ], + }; + } + + const created = await cma.dataAssembly.create({}, { + sys: { + type: 'DataAssembly', + dataType: fixture.dataType.map((p) => ({ + ...p, + required: p.required ?? false, + })), + }, + name: fixture.name, + description: fixture.description ?? '', + metadata: { tags: [] }, + parameters, + resolvers: fixture.resolvers, + return: fixture.return, + } as never); + await cma.dataAssembly.publish({ + dataAssemblyId: created.sys.id, + version: created.sys.version, + }); + + remember(fixture.tempId, created.sys.id); + log(` ✓ DataAssembly "${fixture.name}" → ${created.sys.id}`); +} + +// After DAs are created, ComponentTypes that host DA-bound nodes need those +// DAs listed on their `dataAssemblies` field, or Experience publish fails +// with `DataAssemblyMembershipViolation`. +async function linkDataAssembliesToComponentTypes() { + // Group DA links by target ComponentType so we do one update per CT. + const byComponentType = new Map(); + for (const link of dataAssemblyComponentTypeLinks) { + const daId = resolveId(link.dataAssemblyTempId); + const arr = byComponentType.get(link.componentTypeId) ?? []; + arr.push(daId); + byComponentType.set(link.componentTypeId, arr); + } + + for (const [componentTypeId, daIds] of byComponentType) { + const current = await cma.componentType.get({ componentTypeId }); + const existingLinks = new Set( + (current.dataAssemblies ?? []).map((l) => l.sys.urn.split('/').pop()!) + ); + const missing = daIds.filter((id) => !existingLinks.has(id)); + if (missing.length === 0) { + log(` ✓ ComponentType "${componentTypeId}" already links all DAs — skipping`); + continue; + } + + const newLinks = [ + ...(current.dataAssemblies ?? []), + ...missing.map((id) => ({ + sys: { + type: 'ResourceLink' as const, + linkType: 'Contentful:DataAssembly' as const, + urn: dataAssemblyUrn(id), + }, + })), + ]; + const updated = await cma.componentType.upsert({ componentTypeId }, { + sys: { id: componentTypeId, type: 'ComponentType', version: current.sys.version }, + name: current.name, + description: current.description, + viewports: current.viewports, + contentProperties: current.contentProperties, + designProperties: current.designProperties, + slots: current.slots, + dataAssemblies: newLinks, + } as never); + await cma.componentType.publish({ + componentTypeId, + version: updated.sys.version, + }); + log(` ✓ ComponentType "${componentTypeId}" linked to ${missing.length} DA(s)`); + } +} + +// Walk fixture nodes and swap tempIds for real ids in contentBindings. +function resolveNode(node: ExperienceNode): unknown { + const out: Record = { + id: node.id, + nodeType: node.nodeType, + componentType: { + sys: { + type: 'ResourceLink', + linkType: 'Contentful:ComponentType', + urn: componentTypeUrn(node.componentTypeId), + }, + }, + }; + if ('designProperties' in node && node.designProperties) { + out.designProperties = node.designProperties; + } + if ('contentProperties' in node && node.contentProperties) { + out.contentProperties = node.contentProperties; + } + if ('contentBindings' in node && node.contentBindings) { + const parameters: Record = {}; + for (const [pid, ref] of Object.entries(node.contentBindings.parameters)) { + parameters[pid] = { + sys: { + type: 'ResourceLink', + linkType: 'Contentful:Entry', + urn: entryUrn(resolveId(ref.$entryTempId)), + }, + }; + } + out.contentBindings = { + sys: { + type: 'ResourceLink', + linkType: 'Contentful:DataAssembly', + urn: dataAssemblyUrn(resolveId(node.contentBindings.dataAssemblyTempId)), + }, + parameters, + }; + } + if (node.slots) { + const slots: Record = {}; + for (const [slotName, children] of Object.entries(node.slots)) { + slots[slotName] = children.map(resolveNode); + } + out.slots = slots; + } else { + out.slots = {}; + } + return out; +} + +async function seedExperience(fixture: ExperienceFixture) { + const experienceId = fixture.id; + let existing: Awaited> | null = null; + try { + existing = await cma.experience.get({ experienceId }); + } catch (err) { + if (!isNotFound(err)) throw err; + } + + const slots: Record = {}; + for (const [slotName, children] of Object.entries(fixture.slots)) { + slots[slotName] = children.map(resolveNode); + } + + // Always upsert so we pick up template/DA changes from earlier steps in the + // run — the CMA rejects Experience publish with `DefiningEntityIsChanged` if + // any referenced entity has moved on since the Experience's last version. + // `template` is immutable after creation, so include it only on create. + const templateLink = { + sys: { + type: 'ResourceLink' as const, + linkType: 'Contentful:Template' as const, + urn: templateUrn(fixture.templateId), + }, + }; + const commonBody = { + name: fixture.name, + description: fixture.description ?? '', + viewports: fixture.viewports, + designProperties: {}, + metadata: { tags: [], concepts: [] }, + slots, + }; + const upserted = await cma.experience.upsert( + { experienceId }, + (existing + ? { + sys: { id: experienceId, type: 'Experience', version: existing.sys.version }, + ...commonBody, + } + : { + sys: { id: experienceId, type: 'Experience' }, + template: templateLink, + ...commonBody, + }) as never + ); + log( + existing ? ` ✓ Experience "${fixture.id}" updated` : ` ✓ Experience "${fixture.id}" created` + ); + + const isPublished = + !!upserted.sys.publishedVersion && upserted.sys.publishedVersion === upserted.sys.version; + if (isPublished) { + log(` ✓ Experience "${fixture.id}" already published`); + } else { + await cma.experience.publish( + { experienceId: upserted.sys.id, version: upserted.sys.version }, + { add: ['en-US'] } + ); + log(` ✓ Experience "${fixture.id}" published`); + } + return experienceId; +} + +// --- Orchestrator ------------------------------------------------------------ + +async function main() { + log(`Bootstrapping ExO demo into space ${SPACE_ID} / env ${ENVIRONMENT_ID}\n`); + + step('Step 1/9 — ContentTypes'); + for (const ct of contentTypes) await seedContentType(ct); + + step('Step 2/9 — Assets'); + for (const a of assets) await seedAsset(a); + + step('Step 3/9 — Entries'); + for (const e of entries) await seedEntry(e); + + step('Step 4/9 — Design tokens'); + for (const t of designTokens) await seedDesignToken(t); + + step('Step 5/9 — ComponentTypes'); + for (const ct of componentTypes) await seedComponentType(ct); + + step('Step 6/9 — Template'); + for (const t of templates) await seedTemplate(t); + + step('Step 7/9 — DataAssemblies'); + for (const da of dataAssemblies) await seedDataAssembly(da); + + step('Step 8/9 — Link DataAssemblies to composed ComponentTypes'); + await linkDataAssembliesToComponentTypes(); + + step('Step 9/9 — Experience'); + const experienceId = await seedExperience(experience); + + log(`\n✅ Done.\n`); + log(` Experience id: ${experienceId}`); + log( + ` Set NEXT_PUBLIC_EXPERIENCE_ID=${experienceId} (or paste it into your .env.local) and run \`npm run dev\`.` + ); +} + +main().catch((err) => { + console.error('\n✗ Bootstrap failed:', err); + process.exit(1); +}); diff --git a/examples/scripts/fixture/assets.ts b/examples/scripts/fixture/assets.ts new file mode 100644 index 0000000..5e61579 --- /dev/null +++ b/examples/scripts/fixture/assets.ts @@ -0,0 +1,28 @@ +import type { AssetFixture } from './types.js'; + +// Source files live next to this module under `assets/`. The bootstrap reads +// them from disk and uploads to the caller's space — the demo has no runtime +// dependency on any external CDN. +export const assets: AssetFixture[] = [ + { + tempId: 'asset:hero-bg', + title: 'Hero background — blue connections', + fileName: 'hero-bg.png', + contentType: 'image/png', + sourcePath: 'assets/hero-bg.png', + }, + { + tempId: 'asset:card-on', + title: 'On case study image', + fileName: 'card-on.png', + contentType: 'image/png', + sourcePath: 'assets/card-on.png', + }, + { + tempId: 'asset:card-guide', + title: 'Developer guide image', + fileName: 'card-guide.webp', + contentType: 'image/webp', + sourcePath: 'assets/card-guide.webp', + }, +]; diff --git a/examples/scripts/fixture/assets/card-guide.webp b/examples/scripts/fixture/assets/card-guide.webp new file mode 100644 index 0000000..00c54c9 Binary files /dev/null and b/examples/scripts/fixture/assets/card-guide.webp differ diff --git a/examples/scripts/fixture/assets/card-on.png b/examples/scripts/fixture/assets/card-on.png new file mode 100644 index 0000000..8c6f6dc Binary files /dev/null and b/examples/scripts/fixture/assets/card-on.png differ diff --git a/examples/scripts/fixture/assets/hero-bg.png b/examples/scripts/fixture/assets/hero-bg.png new file mode 100644 index 0000000..ef21924 Binary files /dev/null and b/examples/scripts/fixture/assets/hero-bg.png differ diff --git a/examples/scripts/fixture/component-types.ts b/examples/scripts/fixture/component-types.ts new file mode 100644 index 0000000..351db19 --- /dev/null +++ b/examples/scripts/fixture/component-types.ts @@ -0,0 +1,200 @@ +import type { ComponentTypeFixture } from './types.js'; + +// Shared allowed-resource lists. The token strings must match the design-token +// table in the example app (examples/nextjs/lib/design-tokens.ts). +const COLOR_TOKENS = [ + 'color.primary', + 'color.primaryText', + 'color.text', + 'color.white', + 'color.none', +]; +const SIZE_TOKENS = ['size.xl', 'size.md', 'size.sm', 'size.none']; +const FONT_SIZE_TOKENS = ['fontSize.3xl', 'fontSize.lg', 'fontSize.md', 'fontSize.sm']; +const FONT_WEIGHT_TOKENS = ['fontWeight.bold', 'fontWeight.normal']; + +const colorProp = (id: string, name: string, description = '') => ({ + id, + name, + description, + type: 'DTCG.Color' as const, + allowedResources: COLOR_TOKENS.map((value) => ({ type: 'DesignToken' as const, value })), +}); + +const sizeProp = (id: string, name: string, description = '') => ({ + id, + name, + description, + type: 'DTCG.Dimension' as const, + allowedResources: SIZE_TOKENS.map((value) => ({ type: 'DesignToken' as const, value })), +}); + +// --- Design-system primitives ------------------------------------------------ +// Each is a shallow shape — enough to render, not a full copy of the source +// space's schemas. The example app's design-system components read these design +// values via useDesignValues() and (mostly) render them through toCss(). + +const Section: ComponentTypeFixture = { + id: 'Section', + name: 'Section', + description: 'Layout primitive — flex/grid row or column of children', + contentProperties: [], + designProperties: [ + { + id: 'direction', + name: 'Direction', + type: 'String', + validations: [{ regexp: { pattern: '^(row|column)$' } }], + }, + { + id: 'columns', + name: 'Columns', + type: 'String', + validations: [{ regexp: { pattern: '^(auto|1|2|3|4)$' } }], + }, + { id: 'itemAlign', name: 'Item align', type: 'String' }, + colorProp('backgroundColor', 'Background color'), + sizeProp('gap', 'Gap'), + sizeProp('verticalSpacing', 'Vertical spacing'), + sizeProp('horizontalSpacing', 'Horizontal spacing'), + ], + slots: [{ id: 'children', name: 'Children' }], +}; + +const Heading: ComponentTypeFixture = { + id: 'Heading', + name: 'Heading', + description: 'Semantic HTML heading (h1–h6)', + contentProperties: [{ id: 'text', name: 'Text', type: 'String' }], + designProperties: [ + { + id: 'as', + name: 'Semantic tag', + type: 'String', + validations: [{ regexp: { pattern: '^h[1-6]$' } }], + }, + { id: 'align', name: 'Align', type: 'String' }, + colorProp('color', 'Color'), + { + id: 'fontSize', + name: 'Font size', + type: 'DTCG.Dimension', + allowedResources: FONT_SIZE_TOKENS.map((value) => ({ type: 'DesignToken', value })), + }, + { + id: 'fontWeight', + name: 'Font weight', + type: 'DTCG.FontWeight', + allowedResources: FONT_WEIGHT_TOKENS.map((value) => ({ type: 'DesignToken', value })), + }, + ], +}; + +const RichText: ComponentTypeFixture = { + id: 'RichText', + name: 'Rich text', + description: 'Minimal rich-text renderer', + contentProperties: [{ id: 'document', name: 'Document', type: 'RichText' }], + designProperties: [ + { id: 'align', name: 'Align', type: 'String' }, + colorProp('color', 'Color'), + { + id: 'fontSize', + name: 'Font size', + type: 'DTCG.Dimension', + allowedResources: FONT_SIZE_TOKENS.map((value) => ({ type: 'DesignToken', value })), + }, + ], +}; + +const Text: ComponentTypeFixture = { + id: 'Text', + name: 'Text', + description: 'Plain-text span', + contentProperties: [{ id: 'text', name: 'Text', type: 'String' }], + designProperties: [ + { id: 'align', name: 'Align', type: 'String' }, + colorProp('color', 'Color'), + { + id: 'fontSize', + name: 'Font size', + type: 'DTCG.Dimension', + allowedResources: FONT_SIZE_TOKENS.map((value) => ({ type: 'DesignToken', value })), + }, + ], +}; + +const Button: ComponentTypeFixture = { + id: 'Button', + name: 'Button', + description: 'Link styled as a button (label + url)', + contentProperties: [ + { id: 'label', name: 'Label', type: 'String' }, + { id: 'url', name: 'URL', type: 'String' }, + ], + designProperties: [ + { + id: 'target', + name: 'Target', + type: 'String', + validations: [{ regexp: { pattern: '^_(self|blank)$' } }], + }, + colorProp('backgroundColor', 'Background color'), + colorProp('color', 'Color'), + ], +}; + +const Image: ComponentTypeFixture = { + id: 'Image', + name: 'Image', + description: 'Image (src + alt)', + contentProperties: [ + { id: 'src', name: 'Source URL', type: 'String' }, + { id: 'alt', name: 'Alt text', type: 'String' }, + ], + designProperties: [], +}; + +// --- Composed ComponentTypes ------------------------------------------------- +// These aren't primitives — they're editor-authored vocabulary the customer +// maps entry fields ONTO via DataAssembly. Their contentProperties are what the +// hero-assembly / card-assembly declare in their `return` blocks. + +const heroPlain: ComponentTypeFixture = { + id: 'hero-plain', + name: 'Hero: plain', + description: 'Full-width hero — title + body + CTA + image, sourced from a promotion entry', + contentProperties: [ + { id: 'title', name: 'Title', type: 'String', required: true }, + { id: 'body', name: 'Body', type: 'RichText' }, + { id: 'ctaLabel', name: 'CTA label', type: 'String' }, + { id: 'ctaUrl', name: 'CTA URL', type: 'String' }, + { id: 'image', name: 'Image URL', type: 'String' }, + ], + designProperties: [colorProp('backgroundColor', 'Background color'), colorProp('color', 'Color')], +}; + +const card: ComponentTypeFixture = { + id: 'card', + name: 'Card', + description: 'Compact card — image + title + teaser + CTA, sourced from a promotion entry', + contentProperties: [ + { id: 'title', name: 'Title', type: 'String', required: true }, + { id: 'teaser', name: 'Teaser', type: 'String' }, + { id: 'ctaLabel', name: 'CTA label', type: 'String' }, + { id: 'ctaUrl', name: 'CTA URL', type: 'String' }, + { id: 'image', name: 'Image URL', type: 'String' }, + ], + designProperties: [colorProp('backgroundColor', 'Background color'), colorProp('color', 'Color')], +}; + +export const componentTypes: ComponentTypeFixture[] = [ + Section, + Heading, + RichText, + Text, + Button, + Image, + heroPlain, + card, +]; diff --git a/examples/scripts/fixture/content-types.ts b/examples/scripts/fixture/content-types.ts new file mode 100644 index 0000000..d7937a2 --- /dev/null +++ b/examples/scripts/fixture/content-types.ts @@ -0,0 +1,20 @@ +import type { ContentTypeFixture } from './types.js'; + +export const contentTypes: ContentTypeFixture[] = [ + { + id: 'promotion', + name: 'Promotion', + description: + 'A promotional card: title + short teaser + long-form body + CTA + hero image. One entry backs one node in the demo Experience.', + displayField: 'internalName', + fields: [ + { id: 'internalName', name: 'Internal name', type: 'Symbol', required: true }, + { id: 'title', name: 'Title', type: 'Symbol', required: true }, + { id: 'teaser', name: 'Teaser', type: 'Symbol' }, + { id: 'body', name: 'Body', type: 'RichText' }, + { id: 'ctaLabel', name: 'CTA label', type: 'Symbol' }, + { id: 'ctaUrl', name: 'CTA URL', type: 'Symbol' }, + { id: 'image', name: 'Image', type: 'Link', linkType: 'Asset' }, + ], + }, +]; diff --git a/examples/scripts/fixture/data-assemblies.ts b/examples/scripts/fixture/data-assemblies.ts new file mode 100644 index 0000000..d0eeb06 --- /dev/null +++ b/examples/scripts/fixture/data-assemblies.ts @@ -0,0 +1,110 @@ +import type { DataAssemblyFixture } from './types.js'; + +// Each DataAssembly is a small transform from an Entry to a ComponentType's +// contentProperties. GraphQL is the resolver source; the query field naming +// matches how Contentful's content graph auto-generates types (Promotion, +// promotion.name, etc.). + +const heroAssembly: DataAssemblyFixture = { + tempId: 'assembly:hero', + name: 'Hero from Promotion', + description: 'Maps a promotion entry into the hero-plain ComponentType', + // dataType MUST mirror hero-plain's contentProperties. + dataType: [ + { id: 'title', name: 'Title', type: 'String', required: true }, + { id: 'ctaLabel', name: 'CTA label', type: 'String' }, + { id: 'ctaUrl', name: 'CTA URL', type: 'String' }, + { id: 'image', name: 'Image URL', type: 'String' }, + ], + parameters: { + promo: { + name: 'Promotion entry', + linkType: 'Contentful:Entry', + allowedContentTypes: ['promotion'], + }, + }, + resolvers: { + promoNode: { + source: 'Contentful:GraphQL', + query: `query ($id: ID!) { + _node(id: $id) { + __typename + ... on Promotion { + title + ctaLabel + ctaUrl + image { url } + } + } +}`, + parameters: { id: '$parameters/promo' }, + }, + }, + return: { + title: { $from: '$resolvers/promoNode/_node/title' }, + ctaLabel: { $from: '$resolvers/promoNode/_node/ctaLabel' }, + ctaUrl: { $from: '$resolvers/promoNode/_node/ctaUrl' }, + image: { $from: '$resolvers/promoNode/_node/image/url' }, + }, +}; + +const cardAssembly: DataAssemblyFixture = { + tempId: 'assembly:card', + name: 'Card from Promotion', + description: 'Maps a promotion entry into the card ComponentType', + // dataType MUST mirror card's contentProperties. + dataType: [ + { id: 'title', name: 'Title', type: 'String', required: true }, + { id: 'teaser', name: 'Teaser', type: 'String' }, + { id: 'ctaLabel', name: 'CTA label', type: 'String' }, + { id: 'ctaUrl', name: 'CTA URL', type: 'String' }, + { id: 'image', name: 'Image URL', type: 'String' }, + ], + parameters: { + promo: { + name: 'Promotion entry', + linkType: 'Contentful:Entry', + allowedContentTypes: ['promotion'], + }, + }, + resolvers: { + promoNode: { + source: 'Contentful:GraphQL', + query: `query ($id: ID!) { + _node(id: $id) { + __typename + ... on Promotion { + title + teaser + ctaLabel + ctaUrl + image { url } + } + } +}`, + parameters: { id: '$parameters/promo' }, + }, + }, + return: { + title: { $from: '$resolvers/promoNode/_node/title' }, + teaser: { $from: '$resolvers/promoNode/_node/teaser' }, + ctaLabel: { $from: '$resolvers/promoNode/_node/ctaLabel' }, + ctaUrl: { $from: '$resolvers/promoNode/_node/ctaUrl' }, + image: { $from: '$resolvers/promoNode/_node/image/url' }, + }, +}; + +export const dataAssemblies: DataAssemblyFixture[] = [heroAssembly, cardAssembly]; + +// Which ComponentType each DataAssembly binds to. When publishing an Experience +// that uses a DA on a ComponentType node, that ComponentType MUST list the DA +// in its `dataAssemblies` array or publish fails with +// `DataAssemblyMembershipViolation`. This mapping lets the bootstrap update the +// ComponentType after the DA is created. +export const dataAssemblyComponentTypeLinks: Array<{ + dataAssemblyTempId: string; + componentTypeId: string; +}> = [ + { dataAssemblyTempId: 'assembly:hero', componentTypeId: 'hero-plain' }, + { dataAssemblyTempId: 'assembly:card', componentTypeId: 'card' }, +]; diff --git a/examples/scripts/fixture/design-tokens.ts b/examples/scripts/fixture/design-tokens.ts new file mode 100644 index 0000000..d1f3991 --- /dev/null +++ b/examples/scripts/fixture/design-tokens.ts @@ -0,0 +1,43 @@ +// Design tokens are the abstract names ComponentTypes reference in +// `allowedResources`. Their runtime CSS values are resolved by the example +// app's own `resolveToken` (see examples/nextjs/lib/design-tokens.ts) — the +// server just tracks that these ids exist and what DTCG type they belong to. + +export type DesignTokenType = + | 'DTCG.Color' + | 'DTCG.Dimension' + | 'DTCG.FontWeight' + | 'DTCG.FontFamily' + | 'DTCG.Duration' + | 'DTCG.CubicBezier' + | 'DTCG.Number'; + +export type DesignTokenFixture = { + id: string; + type: DesignTokenType; +}; + +// Every token referenced anywhere in the fixture (allowedResources + +// experience.designProperties). If you add a token to a ComponentType or +// Experience node, add it here too or the CMA will reject the ComponentType. +export const designTokens: DesignTokenFixture[] = [ + // Colors + { id: 'color.primary', type: 'DTCG.Color' }, + { id: 'color.primaryText', type: 'DTCG.Color' }, + { id: 'color.text', type: 'DTCG.Color' }, + { id: 'color.white', type: 'DTCG.Color' }, + { id: 'color.none', type: 'DTCG.Color' }, + // Sizes + { id: 'size.xl', type: 'DTCG.Dimension' }, + { id: 'size.md', type: 'DTCG.Dimension' }, + { id: 'size.sm', type: 'DTCG.Dimension' }, + { id: 'size.none', type: 'DTCG.Dimension' }, + // Font sizes + { id: 'fontSize.3xl', type: 'DTCG.Dimension' }, + { id: 'fontSize.lg', type: 'DTCG.Dimension' }, + { id: 'fontSize.md', type: 'DTCG.Dimension' }, + { id: 'fontSize.sm', type: 'DTCG.Dimension' }, + // Font weights + { id: 'fontWeight.bold', type: 'DTCG.FontWeight' }, + { id: 'fontWeight.normal', type: 'DTCG.FontWeight' }, +]; diff --git a/examples/scripts/fixture/entries.ts b/examples/scripts/fixture/entries.ts new file mode 100644 index 0000000..1fa945c --- /dev/null +++ b/examples/scripts/fixture/entries.ts @@ -0,0 +1,98 @@ +import { assetRef, type EntryFixture } from './types.js'; + +// Small helper for authoring rich text without importing @contentful/rich-text-types. +const rt = (paragraphs: Array>) => ({ + nodeType: 'document', + data: {}, + content: paragraphs.map((spans) => ({ + nodeType: 'paragraph', + data: {}, + content: spans.map(({ text, bold, italic }) => ({ + nodeType: 'text', + value: text, + data: {}, + marks: [ + ...(bold ? [{ type: 'bold' as const }] : []), + ...(italic ? [{ type: 'italic' as const }] : []), + ], + })), + })), +}); + +export const entries: EntryFixture[] = [ + { + tempId: 'entry:hero', + contentTypeId: 'promotion', + fields: { + internalName: { 'en-US': 'Hero — Modernize your content stack' }, + title: { 'en-US': 'Ready to modernize your content stack?' }, + teaser: { + 'en-US': 'Unify your content operations and deliver experiences that convert.', + }, + body: { + 'en-US': rt([ + [ + { text: 'The world’s leading brands trust Contentful to ' }, + { text: 'unify their content operations', bold: true }, + { text: ' and deliver experiences that convert.' }, + ], + [ + { text: 'Join thousands of teams who’ve left legacy CMS behind. ' }, + { text: 'Your content infrastructure starts here.', italic: true }, + ], + ]), + }, + ctaLabel: { 'en-US': 'Book a demo' }, + ctaUrl: { 'en-US': 'https://www.contentful.com/contact/sales/' }, + image: { 'en-US': assetRef('asset:hero-bg') }, + }, + }, + { + tempId: 'entry:card-on', + contentTypeId: 'promotion', + fields: { + internalName: { 'en-US': 'Card — On case study' }, + title: { 'en-US': 'See how On runs on Contentful' }, + teaser: { + 'en-US': + 'On, the Swiss performance brand, uses Contentful to power global digital experiences at speed.', + }, + body: { + 'en-US': rt([ + [ + { text: 'On uses Contentful to power ' }, + { text: 'global digital experiences at speed', bold: true }, + { text: ' — across DTC, wholesale, and brand channels simultaneously.' }, + ], + ]), + }, + ctaLabel: { 'en-US': 'Read the On case study' }, + ctaUrl: { 'en-US': 'https://www.contentful.com/case-studies/on/' }, + image: { 'en-US': assetRef('asset:card-on') }, + }, + }, + { + tempId: 'entry:card-guide', + contentTypeId: 'promotion', + fields: { + internalName: { 'en-US': 'Card — Developer guide' }, + title: { 'en-US': 'Content as Infrastructure' }, + teaser: { + 'en-US': + 'Contentful isn’t just a CMS — it’s a content infrastructure layer for developers.', + }, + body: { + 'en-US': rt([ + [ + { + text: 'Contentful isn’t just a CMS — it’s a content infrastructure layer for developers building modern digital experiences.', + }, + ], + ]), + }, + ctaLabel: { 'en-US': 'Read the developer guide' }, + ctaUrl: { 'en-US': 'https://www.contentful.com/developers/' }, + image: { 'en-US': assetRef('asset:card-guide') }, + }, + }, +]; diff --git a/examples/scripts/fixture/experience.ts b/examples/scripts/fixture/experience.ts new file mode 100644 index 0000000..dfed44b --- /dev/null +++ b/examples/scripts/fixture/experience.ts @@ -0,0 +1,87 @@ +import type { ExperienceFixture, ExperienceNode, DesignValue } from './types.js'; + +const tokenValue = (value: string): DesignValue => ({ type: 'DesignToken', value }); +const manualValue = (value: string | number | boolean): DesignValue => ({ + type: 'ManualDesignValue', + value, +}); + +// Wrap a design value for the default viewport ('_'). +const atDefault = (value: DesignValue) => ({ _: value }); + +const heroNode: ExperienceNode = { + id: 'node:hero', + nodeType: 'InlineFragment', + componentTypeId: 'hero-plain', + designProperties: { + backgroundColor: atDefault(tokenValue('color.primary')), + color: atDefault(tokenValue('color.primaryText')), + }, + contentBindings: { + dataAssemblyTempId: 'assembly:hero', + parameters: { + promo: { $entryTempId: 'entry:hero' }, + }, + }, +}; + +const cardOnNode: ExperienceNode = { + id: 'node:card-on', + nodeType: 'InlineFragment', + componentTypeId: 'card', + designProperties: { + backgroundColor: atDefault(tokenValue('color.white')), + color: atDefault(tokenValue('color.text')), + }, + contentBindings: { + dataAssemblyTempId: 'assembly:card', + parameters: { + promo: { $entryTempId: 'entry:card-on' }, + }, + }, +}; + +const cardGuideNode: ExperienceNode = { + id: 'node:card-guide', + nodeType: 'InlineFragment', + componentTypeId: 'card', + designProperties: { + backgroundColor: atDefault(tokenValue('color.white')), + color: atDefault(tokenValue('color.text')), + }, + contentBindings: { + dataAssemblyTempId: 'assembly:card', + parameters: { + promo: { $entryTempId: 'entry:card-guide' }, + }, + }, +}; + +const cardsContainerNode: ExperienceNode = { + id: 'node:cards', + nodeType: 'InlineFragment', + componentTypeId: 'Section', + designProperties: { + direction: atDefault(manualValue('row')), + columns: atDefault(manualValue('2')), + gap: atDefault(tokenValue('size.xl')), + verticalSpacing: atDefault(tokenValue('size.xl')), + horizontalSpacing: atDefault(tokenValue('size.sm')), + backgroundColor: atDefault(tokenValue('color.none')), + }, + slots: { + children: [cardOnNode, cardGuideNode], + }, +}; + +export const experience: ExperienceFixture = { + id: 'landing', + name: 'Landing (demo)', + description: + 'Minimal ExO demo — 1 hero + 2 cards, all bound via DataAssembly to promotion entries', + templateId: 'page', + viewports: [{ id: '_', query: '*', displayName: 'Default', previewSize: '1024px' }], + slots: { + content: [heroNode, cardsContainerNode], + }, +}; diff --git a/examples/scripts/fixture/index.ts b/examples/scripts/fixture/index.ts new file mode 100644 index 0000000..636223d --- /dev/null +++ b/examples/scripts/fixture/index.ts @@ -0,0 +1,9 @@ +export * from './types.js'; +export { contentTypes } from './content-types.js'; +export { assets } from './assets.js'; +export { entries } from './entries.js'; +export { designTokens, type DesignTokenFixture } from './design-tokens.js'; +export { componentTypes } from './component-types.js'; +export { templates } from './templates.js'; +export { dataAssemblies, dataAssemblyComponentTypeLinks } from './data-assemblies.js'; +export { experience } from './experience.js'; diff --git a/examples/scripts/fixture/templates.ts b/examples/scripts/fixture/templates.ts new file mode 100644 index 0000000..6a1834f --- /dev/null +++ b/examples/scripts/fixture/templates.ts @@ -0,0 +1,21 @@ +import type { TemplateFixture } from './types.js'; + +// The `page` template declares a single "content" slot: an Experience using +// this template puts its top-level nodes into `slots.content`, and the +// template's componentTree tells the renderer where to drop them. No +// template-level content/design props for the minimal demo. +export const templates: TemplateFixture[] = [ + { + id: 'page', + name: 'Page', + description: 'Passthrough page wrapper — renders the Experience content slot', + slots: [{ id: 'content', name: 'Content' }], + componentTree: [ + { + id: 'page-content-slot', + nodeType: 'Slot', + slotId: 'content', + }, + ], + }, +]; diff --git a/examples/scripts/fixture/types.ts b/examples/scripts/fixture/types.ts new file mode 100644 index 0000000..e30f93b --- /dev/null +++ b/examples/scripts/fixture/types.ts @@ -0,0 +1,203 @@ +// Fixture types — shared between the fixture modules and the bootstrap script. +// +// Everything here is expressed in terms of tempIds (opaque local strings the +// bootstrap resolves to real Contentful sys.ids after each resource is created). +// This keeps the fixture self-referential without baking any target-space ids in. + +export type TempId = string; + +// --- Content model ------------------------------------------------------------ + +export type ContentTypeFixture = { + id: string; + name: string; + description?: string; + displayField?: string; + fields: ContentTypeField[]; +}; + +export type ContentTypeField = + | { + id: string; + name: string; + type: 'Symbol' | 'Text' | 'Date' | 'RichText' | 'Object' | 'Boolean' | 'Integer' | 'Number'; + required?: boolean; + localized?: boolean; + } + | { + id: string; + name: string; + type: 'Link'; + linkType: 'Asset' | 'Entry'; + required?: boolean; + localized?: boolean; + validations?: unknown[]; + } + | { + id: string; + name: string; + type: 'Array'; + items: + | { type: 'Link'; linkType: 'Asset' | 'Entry'; validations?: unknown[] } + | { type: 'Symbol'; validations?: unknown[] }; + required?: boolean; + localized?: boolean; + }; + +// --- Assets ------------------------------------------------------------------- + +export type AssetFixture = { + tempId: TempId; + title: string; + fileName: string; + contentType: string; + // Path to the source file on disk, relative to the fixture/ directory. + // The bootstrap reads the bytes locally and uploads them to the caller's + // space — nothing gets fetched over HTTP, so the demo doesn't depend on + // any external CDN staying up. + sourcePath: string; +}; + +// --- Entries ------------------------------------------------------------------ + +export type EntryFixture = { + tempId: TempId; + contentTypeId: string; + fields: Record; +}; + +// Placeholder value the bootstrap swaps for a real Link->Asset payload. +export type AssetRef = { $assetTempId: TempId }; +export const assetRef = (tempId: TempId): AssetRef => ({ $assetTempId: tempId }); + +// --- ComponentTypes ----------------------------------------------------------- + +export type ComponentTypeFixture = { + id: string; + name: string; + description?: string; + contentProperties?: ContentPropertyDef[]; + designProperties?: DesignPropertyDef[]; + slots?: SlotDef[]; +}; + +export type ContentPropertyDef = { + id: string; + name: string; + type: 'String' | 'RichText' | 'Boolean' | 'Number'; + required?: boolean; +}; + +export type DesignPropertyDef = { + id: string; + name: string; + description?: string; + type: 'String' | 'DTCG.Color' | 'DTCG.Dimension' | 'DTCG.FontWeight' | 'Boolean' | 'Number'; + validations?: unknown[]; + allowedResources?: Array<{ type: 'DesignToken'; value: string }>; +}; + +export type SlotDef = { id: string; name: string; description?: string }; + +// --- Templates ---------------------------------------------------------------- + +export type TemplateFixture = { + id: string; + name: string; + description?: string; + contentProperties?: ContentPropertyDef[]; + designProperties?: DesignPropertyDef[]; + // Slot declarations — Experiences using this template put their top-level + // nodes into a slot with a matching id. `slots: [{ id: 'content' }]` on the + // template + a `componentTree` node with `{ nodeType: 'Slot', slotId: 'content' }` + // is the mechanism that says "render the Experience's `content` slot here." + slots?: SlotDef[]; + componentTree?: TemplateTreeNode[]; +}; + +export type TemplateTreeNode = { + id: string; + nodeType: 'Slot'; + slotId: string; +}; + +// --- DataAssemblies ----------------------------------------------------------- +// +// A DataAssembly declares: +// 1. parameters — inputs (entries or values) the assembly is invoked with. +// 2. resolvers — how to fetch data from those parameters (GraphQL against +// the space's content graph, or nested DataAssembly). +// 3. return — how to map resolver output onto the target ComponentType's +// contentProperties by id. +// +// For the minimal demo we only use the GraphQL resolver flavor and one +// parameter per assembly. + +export type DataAssemblyFixture = { + tempId: TempId; + name: string; + description?: string; + // dataType MUST mirror the ComponentType's contentProperties this assembly targets. + dataType: ContentPropertyDef[]; + parameters: Record< + string, + { + name: string; + linkType: 'Contentful:Entry'; + allowedContentTypes: string[]; + } + >; + resolvers: Record< + string, + { + source: 'Contentful:GraphQL'; + query: string; + parameters: Record; // e.g. { id: '$parameters/promo' } + } + >; + // Map from ComponentType contentProperty id -> resolver output path. + return: Record; +}; + +// --- Experience --------------------------------------------------------------- + +export type ExperienceFixture = { + id: string; + name: string; + description?: string; + templateId: string; + viewports: Array<{ id: string; query: string; displayName: string; previewSize: string }>; + slots: { content: ExperienceNode[] }; +}; + +export type ExperienceNode = InlineFragmentNode | ContainerNode; + +// An InlineFragment node has its content sourced from a DataAssembly binding. +export type InlineFragmentNode = { + id: string; + nodeType: 'InlineFragment'; + componentTypeId: string; + designProperties?: Record; + contentBindings: { + dataAssemblyTempId: TempId; + parameters: Record; + }; + slots?: Record; +}; + +// A Container node has inline contentProperties + designProperties and children in slots. +export type ContainerNode = { + id: string; + nodeType: 'InlineFragment'; + componentTypeId: string; + contentProperties?: Record; + designProperties?: Record; + slots?: Record; +}; + +// A design/content property value is keyed by viewport id ('_' = default). +export type ViewportValue = Record; + +export type DesignValue = + | { type: 'ManualDesignValue'; value: string | number | boolean | null } + | { type: 'DesignToken'; value: string }; diff --git a/examples/scripts/package.json b/examples/scripts/package.json new file mode 100644 index 0000000..a196e1f --- /dev/null +++ b/examples/scripts/package.json @@ -0,0 +1,19 @@ +{ + "name": "@contentful/experiences-example-scripts", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Bootstrap scripts for the customer-facing example apps", + "scripts": { + "bootstrap": "tsx bootstrap-example.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "contentful-management": "12.6.0-dev.4" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.19.0", + "typescript": "^5.4.0" + } +} diff --git a/examples/scripts/tsconfig.json b/examples/scripts/tsconfig.json new file mode 100644 index 0000000..799abbe --- /dev/null +++ b/examples/scripts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "esModuleInterop": true, + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"] +} diff --git a/examples/sveltekit/.env.example b/examples/sveltekit/.env.example index 40490a6..4bbd046 100644 --- a/examples/sveltekit/.env.example +++ b/examples/sveltekit/.env.example @@ -1,3 +1,16 @@ +# Copy this file to .env and fill in real values from a Contentful space. +# +# Bootstrap the demo Experience into your space first with: +# cd examples/scripts && npm run bootstrap +# The script prints the experienceId to hit at the end (default: `landing`). + SPACE_ID= ENVIRONMENT_ID=master + +# Content Delivery API token — used by every route by default. CDA_TOKEN= + +# Content Preview API token — required only when hitting a route with +# ?preview=true (that path uses preview.xdn.contentful.com, which rejects +# CDA tokens). Leave blank if you don't need preview mode. +CPA_TOKEN= diff --git a/examples/sveltekit/README.md b/examples/sveltekit/README.md index 7a9a5f2..59cf985 100644 --- a/examples/sveltekit/README.md +++ b/examples/sveltekit/README.md @@ -13,17 +13,41 @@ A SvelteKit 2 + Svelte 5 app demonstrating `@contentful/experiences-svelte` rend ## Run it +The example is a real integration against Contentful, not a mock. You need a Contentful space with the demo content model + Experience seeded, plus a Content Delivery API token. The [`examples/scripts/bootstrap-example.ts`](../scripts/bootstrap-example.ts) script does the seeding via the management API — see [`examples/scripts/README.md`](../scripts/README.md) for what it provisions. + +### 1. Seed the demo Experience (one-time) + ```sh # From the repo root: -npm install --ignore-scripts -npm run build # builds the SDK packages +npm install +npm run build # build the SDK packages + +cd examples/scripts +cp .env.example .env # fill in SPACE_ID, ENVIRONMENT_ID, CMA_TOKEN +npm run bootstrap # prints the experienceId at the end (default: `landing`) +``` + +### 2. Run the app -cd examples/sveltekit -cp .env.example .env # fill in SPACE_ID + CDA_TOKEN +```sh +cd ../sveltekit +cp .env.example .env # fill in SPACE_ID, ENVIRONMENT_ID, CDA_TOKEN npm run dev ``` -Then visit `http://localhost:5173/`. The slug becomes the Experience ID passed to `client.view.getExperience`. +Visit `http://localhost:5173/landing`. `landing` is the Experience id the bootstrap printed; any other Experience id in your space works too. + +### Optional: preview mode + +Add `CPA_TOKEN=...` (Content Preview API token from **Settings → API keys** in your space) to `.env`, then visit `http://localhost:5173/landing?preview=true`. The route reads from `preview.xdn.contentful.com`, which needs a preview token — a CDA token gets rejected there. + +### Tokens summary + +| Token | API | Used by | Required? | +| ----------- | ------------------ | ------------------------------------ | --------------------- | +| `CMA_TOKEN` | Content Management | The bootstrap script (one-time seed) | Yes, to run bootstrap | +| `CDA_TOKEN` | Content Delivery | The example app | Yes, to run the app | +| `CPA_TOKEN` | Content Preview | The example app when `?preview=true` | Only for preview mode | ## File map diff --git a/examples/sveltekit/src/lib/components/Button.svelte b/examples/sveltekit/src/lib/components/Button.svelte index 2240221..e22564f 100644 --- a/examples/sveltekit/src/lib/components/Button.svelte +++ b/examples/sveltekit/src/lib/components/Button.svelte @@ -1,53 +1,38 @@ - {#if url} - - {text} - {#if children}{@render children()}{/if} - + {label} {:else} - + {/if} diff --git a/examples/sveltekit/src/lib/components/Card.svelte b/examples/sveltekit/src/lib/components/Card.svelte new file mode 100644 index 0000000..cbee722 --- /dev/null +++ b/examples/sveltekit/src/lib/components/Card.svelte @@ -0,0 +1,55 @@ + + + + + +
+ {#if image} + + {/if} +
+ {#if title} +

{title}

+ {/if} + {#if teaser} +

{teaser}

+ {/if} + {#if ctaLabel && ctaUrl} + + {ctaLabel} + + {/if} +
+
diff --git a/examples/sveltekit/src/lib/components/Heading.svelte b/examples/sveltekit/src/lib/components/Heading.svelte new file mode 100644 index 0000000..0f0fb4a --- /dev/null +++ b/examples/sveltekit/src/lib/components/Heading.svelte @@ -0,0 +1,27 @@ + + + + +{text ?? ''} diff --git a/examples/sveltekit/src/lib/components/HeroPlain.svelte b/examples/sveltekit/src/lib/components/HeroPlain.svelte new file mode 100644 index 0000000..56b4805 --- /dev/null +++ b/examples/sveltekit/src/lib/components/HeroPlain.svelte @@ -0,0 +1,53 @@ + + + + + +
+
+ {#if title} +

{title}

+ {/if} + {#if ctaLabel && ctaUrl} + + {ctaLabel} + + {/if} +
+ {#if image} + + {/if} +
diff --git a/examples/sveltekit/src/lib/components/Image.svelte b/examples/sveltekit/src/lib/components/Image.svelte new file mode 100644 index 0000000..4e77d2d --- /dev/null +++ b/examples/sveltekit/src/lib/components/Image.svelte @@ -0,0 +1,14 @@ + + + + +{#if src} + +{/if} diff --git a/examples/sveltekit/src/lib/components/Page.svelte b/examples/sveltekit/src/lib/components/Page.svelte index fe3d4c4..3378367 100644 --- a/examples/sveltekit/src/lib/components/Page.svelte +++ b/examples/sveltekit/src/lib/components/Page.svelte @@ -15,12 +15,10 @@ let { title, children }: PageProps = $props(); -
+
{#if title}

{title}

diff --git a/examples/sveltekit/src/lib/components/RichText.svelte b/examples/sveltekit/src/lib/components/RichText.svelte new file mode 100644 index 0000000..f0e1277 --- /dev/null +++ b/examples/sveltekit/src/lib/components/RichText.svelte @@ -0,0 +1,55 @@ + + + + +{#if doc} +
+ {#each doc.content as p} +

+ {#each p.content as span} + {#if span.marks?.some((m) => m.type === 'bold')}{span.value} + {:else if span.marks?.some((m) => m.type === 'italic')}{span.value} + {:else}{span.value}{/if} + {/each} +

+ {/each} +
+{/if} diff --git a/examples/sveltekit/src/lib/components/Section.svelte b/examples/sveltekit/src/lib/components/Section.svelte new file mode 100644 index 0000000..c6c2459 --- /dev/null +++ b/examples/sveltekit/src/lib/components/Section.svelte @@ -0,0 +1,38 @@ + + + + +
+ {#if children}{@render children()}{/if} +
diff --git a/examples/sveltekit/src/lib/components/Text.svelte b/examples/sveltekit/src/lib/components/Text.svelte index de56880..afdc95f 100644 --- a/examples/sveltekit/src/lib/components/Text.svelte +++ b/examples/sveltekit/src/lib/components/Text.svelte @@ -1,17 +1,24 @@ -

- {value} - {#if children}{@render children()}{/if} -

+

{text ?? ''}

diff --git a/examples/sveltekit/src/lib/design-tokens.ts b/examples/sveltekit/src/lib/design-tokens.ts new file mode 100644 index 0000000..22d7d00 --- /dev/null +++ b/examples/sveltekit/src/lib/design-tokens.ts @@ -0,0 +1,27 @@ +// In-code token table for the example — `resolveToken` looks ids up here. +// Mirrors examples/nextjs/lib/design-tokens.ts. A real integration would point +// at CSS vars, a Tailwind theme, or a DTCG package. +export const designTokens: Record = { + 'size.none': '0', + 'size.sm': '12px', + 'size.md': '24px', + 'size.lg': '40px', + 'size.xl': '64px', + + 'color.none': 'transparent', + 'color.white': '#ffffff', + 'color.text': '#1f2937', + 'color.primary': '#0f172a', + 'color.primaryText': '#f8fafc', + + 'fontSize.sm': '14px', + 'fontSize.md': '16px', + 'fontSize.lg': '20px', + 'fontSize.xl': '28px', + 'fontSize.2xl': '36px', + 'fontSize.3xl': '48px', + + 'fontWeight.regular': '400', + 'fontWeight.medium': '500', + 'fontWeight.bold': '700', +}; diff --git a/examples/sveltekit/src/lib/experience-config.ts b/examples/sveltekit/src/lib/experience-config.ts index df7bd7e..d23c2b3 100644 --- a/examples/sveltekit/src/lib/experience-config.ts +++ b/examples/sveltekit/src/lib/experience-config.ts @@ -1,44 +1,41 @@ /** - * Maps Contentful component/template ids to the app's components, and wires - * `resolveToken`. Components read design via `getDesignValues()`. + * Maps Contentful component/template ids to the app's Svelte components, + * and wires `resolveToken`. Registry keys match the last URN segment of + * each node's `componentType` / `template`. Components read design via + * `getDesignValues()`. */ -import { - defineComponent, - type Components, - type Config, - type ResolveToken, - type Templates, -} from '@contentful/experiences-svelte'; +import type { Components, Config, ResolveToken, Templates } from '@contentful/experiences-svelte'; import Button from './components/Button.svelte'; -import Header, { type HeaderProps } from './components/Header.svelte'; +import Card from './components/Card.svelte'; +import Heading from './components/Heading.svelte'; +import HeroPlain from './components/HeroPlain.svelte'; +import Image from './components/Image.svelte'; import Page from './components/Page.svelte'; +import RichText from './components/RichText.svelte'; +import Section from './components/Section.svelte'; import Text from './components/Text.svelte'; +import { designTokens } from './design-tokens.js'; const components: Components = { - button: Button, - text: Text, - header: defineComponent({ - component: Header, - defaults: { text: 'Hello World' }, - }), + Section, + Heading, + RichText, + Text, + Button, + Image, + 'hero-plain': HeroPlain, + card: Card, }; const templates: Templates = { - hi: { component: Page, defaults: { title: 'Welcome' } }, - hero: { component: Page, defaults: { title: 'Featured' } }, + page: Page, }; -// Resolves opaque token ids to their underlying values — only you know what a -// token id means. Returning undefined drops the key. A real app might use CSS -// vars, a Tailwind theme, or a tokens package. -const brandTokens: Record = { - 'color.surface.hero': '#4f39f6', - 'color.surface.subtle': '#f4f4f5', - 'color.text.onPrimary': '#ffffff', -}; - -const resolveToken: ResolveToken = (token) => brandTokens[token.value]; +// Resolves opaque token ids (`size.xl`, `color.text`) to their underlying +// values — the SDK doesn't know what a token id means, only you do. Returning +// undefined drops the key. +const resolveToken: ResolveToken = (token) => designTokens[token.value]; export const experienceConfig: Config = { components, templates, resolveToken }; diff --git a/examples/sveltekit/src/routes/+page.svelte b/examples/sveltekit/src/routes/+page.svelte index e93afbf..bf2dcbd 100644 --- a/examples/sveltekit/src/routes/+page.svelte +++ b/examples/sveltekit/src/routes/+page.svelte @@ -12,7 +12,7 @@

View the demo experience diff --git a/examples/sveltekit/src/routes/[slug]/+page.server.ts b/examples/sveltekit/src/routes/[slug]/+page.server.ts index f5412f6..baddb2c 100644 --- a/examples/sveltekit/src/routes/[slug]/+page.server.ts +++ b/examples/sveltekit/src/routes/[slug]/+page.server.ts @@ -1,7 +1,7 @@ import { error } from '@sveltejs/kit'; import { NotFoundError, fetchExperience } from '@contentful/experiences-svelte'; -import { CDA_TOKEN, ENVIRONMENT_ID, SPACE_ID } from '$env/static/private'; +import { env } from '$env/dynamic/private'; import { detectViewportFromUserAgent } from '$lib/detect-viewport.js'; import { experienceConfig } from '$lib/experience-config.js'; @@ -15,12 +15,14 @@ export const load: PageServerLoad = async ({ params, url, request }) => { try { const experience = await fetchExperience( { - spaceId: SPACE_ID, - environmentId: ENVIRONMENT_ID || 'master', + spaceId: env.SPACE_ID, + environmentId: env.ENVIRONMENT_ID || 'master', experienceId: params.slug, }, { - accessToken: CDA_TOKEN, + // Preview mode reads from the CPA endpoint, which needs a Content + // Preview token — the CDA token is rejected by that host. + accessToken: previewMode && env.CPA_TOKEN ? env.CPA_TOKEN : env.CDA_TOKEN, host: previewMode ? 'https://preview.xdn.contentful.com' : 'https://xdn.contentful.com', }, { diff --git a/examples/sveltekit/vite.config.ts b/examples/sveltekit/vite.config.ts index 19f5bb3..24b925e 100644 --- a/examples/sveltekit/vite.config.ts +++ b/examples/sveltekit/vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ '@contentful/experiences-svelte', '@contentful/experiences-sdk-core', '@contentful/experiences-design', + '@contentful/experiences-client', ], }, server: { diff --git a/package-lock.json b/package-lock.json index 37174a8..0969ad0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "workspaces": [ "packages/*", - "examples/*" + "examples/*", + "test-apps/*" ], "devDependencies": { "@commitlint/cli": "^18.6.1", @@ -59,6 +60,18 @@ "typescript": "^5.4.0" } }, + "examples/scripts": { + "name": "@contentful/experiences-example-scripts", + "version": "0.0.0", + "dependencies": { + "contentful-management": "12.6.0-dev.4" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.19.0", + "typescript": "^5.4.0" + } + }, "examples/sveltekit": { "name": "@contentful/experiences-example-sveltekit", "version": "0.0.0", @@ -161,6 +174,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2173,6 +2187,10 @@ "resolved": "examples/nextjs", "link": true }, + "node_modules/@contentful/experiences-example-scripts": { + "resolved": "examples/scripts", + "link": true + }, "node_modules/@contentful/experiences-example-sveltekit": { "resolved": "examples/sveltekit", "link": true @@ -2189,6 +2207,23 @@ "resolved": "packages/adapter-svelte", "link": true }, + "node_modules/@contentful/experiences-testapp-nextjs": { + "resolved": "test-apps/nextjs", + "link": true + }, + "node_modules/@contentful/experiences-testapp-sveltekit": { + "resolved": "test-apps/sveltekit", + "link": true + }, + "node_modules/@contentful/rich-text-types": { + "version": "16.8.5", + "resolved": "https://registry.npmjs.org/@contentful/rich-text-types/-/rich-text-types-16.8.5.tgz", + "integrity": "sha512-q18RJuJCOuYveGiCIjE5xLCQc5lZ3L2Qgxrlg/H2YEobDFqdtmklazRi1XwEWaK3tMg6yVXBzKKkQfLB4qW14A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@csstools/css-syntax-patches-for-csstree": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", @@ -4373,7 +4408,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4521,6 +4555,7 @@ "integrity": "sha512-JXHbsDwRes1Wgyof3q5ApzzpbCWvinKXMQCiV67TFO6xlZPYLoK0fq3xQMqSicDMgCtFGqLkrQXkseOcASdZ8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", @@ -4626,6 +4661,7 @@ "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", "debug": "^4.3.7", @@ -4862,6 +4898,7 @@ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4886,6 +4923,7 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4942,6 +4980,7 @@ "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", @@ -5049,6 +5088,7 @@ "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -5311,6 +5351,7 @@ "integrity": "sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -5324,6 +5365,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5488,14 +5530,12 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/axios": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", - "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -5745,6 +5785,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -5821,7 +5862,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5831,6 +5871,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -6119,7 +6175,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -6173,6 +6228,53 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/contentful-management": { + "version": "12.6.0-dev.4", + "resolved": "https://registry.npmjs.org/contentful-management/-/contentful-management-12.6.0-dev.4.tgz", + "integrity": "sha512-1ajUmSSR6WfcvOO9bJ7RrkQY1MZwORMU5TLBid51j+KRyRv2mVmvU4TugSaWZE0poddvDta92rUKK7nOMcGpFw==", + "license": "MIT", + "dependencies": { + "@contentful/rich-text-types": "^16.6.1", + "axios": "^1.15.0", + "contentful-sdk-core": "^9.4.4", + "fast-copy": "^3.0.0", + "globals": "^15.15.0", + "process": "^0.11.10" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/contentful-management/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/contentful-sdk-core": { + "version": "9.4.5", + "resolved": "https://registry.npmjs.org/contentful-sdk-core/-/contentful-sdk-core-9.4.5.tgz", + "integrity": "sha512-8eGTMO11LXFAiosaiV38bjvW6cjoQFtNE38pNtVilZTXpmGFinClljihH0eriv11Ispd4q82TqtsXMJPYD/C+A==", + "license": "MIT", + "dependencies": { + "fast-copy": "^3.0.2", + "lodash": "^4.17.23", + "process": "^0.11.10", + "qs": "^6.15.0" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.18.0" + } + }, "node_modules/conventional-changelog-angular": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", @@ -6255,6 +6357,7 @@ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -6487,7 +6590,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -6600,7 +6702,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -6701,7 +6802,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6711,7 +6811,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6721,7 +6820,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -6734,7 +6832,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6753,6 +6850,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6817,6 +6915,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7135,6 +7234,12 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7307,7 +7412,6 @@ "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, "funding": [ { "type": "individual", @@ -7328,7 +7432,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -7374,7 +7477,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7427,7 +7529,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7452,7 +7553,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -7603,7 +7703,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7636,7 +7735,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7649,7 +7747,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -7665,7 +7762,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -8306,6 +8402,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -8329,6 +8426,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -8755,7 +8853,6 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash.camelcase": { @@ -9032,7 +9129,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9096,7 +9192,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9106,7 +9201,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -9402,6 +9496,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@emnapi/core": "1.4.5", "@emnapi/runtime": "1.4.5", @@ -9665,6 +9760,18 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -10050,6 +10157,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -10156,11 +10264,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -10198,6 +10314,22 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-lru": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", @@ -10213,6 +10345,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10222,6 +10355,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10854,6 +10988,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -11277,6 +11483,7 @@ "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -11748,91 +11955,595 @@ "node": ">= 12" } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1" + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" }, "engines": { - "node": ">= 0.8.0" + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" } }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/type-fest": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", - "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "(MIT OR CC0-1.0)", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=14.17" + "node": ">=18" } }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=20.18.1" + "node": ">=18" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/unicode-match-property-ecmascript": { + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", @@ -11931,6 +12642,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -12471,6 +13183,7 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -13027,6 +13740,38 @@ "dependencies": { "@contentful/experiences-sdk-core": "0.5.2" } + }, + "test-apps/nextjs": { + "name": "@contentful/experiences-testapp-nextjs", + "version": "0.0.0", + "dependencies": { + "@contentful/experiences-react": "*", + "next": "^15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.4.0" + } + }, + "test-apps/sveltekit": { + "name": "@contentful/experiences-testapp-sveltekit", + "version": "0.0.0", + "dependencies": { + "@contentful/experiences-svelte": "*" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^3.3.0", + "@sveltejs/kit": "^2.8.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.4.0", + "vite": "^5.4.0" + } } } } diff --git a/package.json b/package.json index 2a99004..933dead 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ }, "workspaces": [ "packages/*", - "examples/*" + "examples/*", + "test-apps/*" ], "scripts": { "build": "nx run-many -t build", diff --git a/test-apps/nextjs/.env.example b/test-apps/nextjs/.env.example new file mode 100644 index 0000000..2bddab2 --- /dev/null +++ b/test-apps/nextjs/.env.example @@ -0,0 +1,5 @@ +# Copy this file to .env.local and fill in real values from a Contentful space. + +SPACE_ID= +ENVIRONMENT_ID=master +CDA_TOKEN= diff --git a/test-apps/nextjs/README.md b/test-apps/nextjs/README.md new file mode 100644 index 0000000..949ecea --- /dev/null +++ b/test-apps/nextjs/README.md @@ -0,0 +1,233 @@ +# Next.js example: Contentful Experiences + +A Next.js 15 App Router app demonstrating `@contentful/experiences-react` rendering an Experience payload fetched from XDA. + +## What it shows + +- **Server-side fetch and resolve** via `fetchExperience` (re-exported from `@contentful/experiences-react`). One async call fetches the payload from the Experience Delivery API, walks the tree, classifies props, and runs any component-declared `resolveData` hooks in parallel. +- **SSR rendering** with `ServerExperienceRenderer` from `@contentful/experiences-react`. +- **Minimal page**: `fetchExperience` feeding ``, wrapped in a try/catch that routes `NotFoundError` to Next's `notFound()`. Preview mode, viewport seeding, and metadata are all optional advanced features; the minimal app needs none of them. +- **Styling via `useDesignValues()` and `toCss()`**: components read their own design (spacing, color, typography, layout) from the hook; design is never injected as props. `Section`, `Heading`, `Text`, `Button`, `Image`, and `RichText` all follow this pattern. +- **Design tokens**: `lib/experience-config.tsx` wires a `resolveToken` that maps token ids (`size.xl`, `color.text`, and so on) to CSS values from `lib/design-tokens.ts`. +- **Component registration**: bare components for the common case, `defineComponent({...})` when a component needs `defaults` or `resolveData`. + +## Run it + +```sh +# From the repo root: +npm install --ignore-scripts +npm run build # builds the SDK packages + +cd examples/nextjs +cp .env.example .env.local # fill in SPACE_ID + CDA_TOKEN +npm run dev +``` + +Then visit `http://localhost:3000/`. The slug becomes the Experience ID passed to `client.view.getExperience`. + +## Two routes, same data + +The example ships two side-by-side routes so you can see what each SDK option gives you. They render the same Experience id; only the SDK setup changes. + +| Route | Page | Config | Demonstrates | +| ------------------ | ---------------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `/[slug]` | [`app/[slug]/page.tsx`](./app/[slug]/page.tsx) | `experience-config.tsx` | The minimum: `fetchExperience` into `` with `NotFoundError` routed to Next's `notFound()`. | +| `/advanced/[slug]` | [`app/advanced/[slug]/page.tsx`](./app/advanced/[slug]/page.tsx) | `experience-config-advanced.tsx` | Preview mode via `?preview=true`, User-Agent to `initialViewportId`, async `resolveData` with external fetch. | + +The minimal `[slug]/page.tsx`: + +```tsx +try { + const experience = await fetchExperience( + { spaceId, environmentId, experienceId }, + { accessToken }, + { config: experienceConfig } + ); + return ; +} catch (err) { + if (err instanceof NotFoundError) notFound(); + throw err; +} +``` + +`NotFoundError` is thrown by the delivery client on 404 (Experience ID doesn't exist). An empty-nodes payload (draft, unpublished, or empty locale) is **not** a 404; it resolves to a plan with `nodes: []` and renders as an empty page. + +Slug-to-ID mapping is up to you. See the SDK roadmap in [`AGENTS.md`](../../AGENTS.md) for the longer-term direction. + +## File map + +``` +examples/nextjs/ +├── app/ +│ ├── layout.tsx # root layout +│ ├── page.tsx # index; links to both demo routes +│ ├── [slug]/page.tsx # SIMPLE: fetch + render + 404 handling +│ └── advanced/[slug]/page.tsx # ADVANCED: preview, UA seeding, async resolveData +├── components/ # design-system components; read design via useDesignValues() +│ ├── Section.tsx # flex/grid layout primitive +│ ├── Heading.tsx +│ ├── Text.tsx +│ ├── RichText.tsx # minimal rich-text renderer +│ ├── Image.tsx +│ ├── Button.tsx +│ └── Page.tsx # used as the page-level template +└── lib/ + ├── design-tokens.ts # token id to CSS value table (used by resolveToken) + ├── detect-viewport.ts # User-Agent to viewport id (used by the advanced route) + ├── experience-config.tsx # the integration layer for /[slug] + └── experience-config-advanced.tsx # the integration layer for /advanced/[slug]; async fetch + metadata-aware +``` + +## Integration pattern + +The example separates **two layers**: + +1. **Design-system components** (`components/Section.tsx`, `components/Heading.tsx`, …) receive their **content** props (`text`, `label`, `src`) and read design themselves via `useDesignValues()`. They import nothing SDK-shaped beyond that hook, and it returns `{}` outside a renderer, so they degrade gracefully. +2. **The experience config** (`lib/experience-config.tsx`) is the integration layer: it maps each `componentTypeId` to a component (bare, or `defineComponent({...})` for `defaults` / `resolveData`), maps `templateId`s under `templates`, and wires `resolveToken`. It composes into the single `experienceConfig` object the renderer takes. + +Why split this way: SDK-shaped concerns (registration, defaults, async resolvers, token resolution) all live in one file you can scan to understand the whole integration surface. + +```tsx +// components/Heading.tsx: content prop + design read from the hook +'use client'; +import { toCss, useDesignValues } from '@contentful/experiences-react'; + +export function Heading({ text }: { text?: string }) { + const design = useDesignValues<{ as?: 'h1' | 'h2' | 'h3' }>(); + const Tag = design.as ?? 'h2'; // semantic key, read by name + return {text}; // toCss keeps CSS-shaped keys +} +``` + +```tsx +// lib/experience-config.tsx: adapter layer +import { defineComponent, type Config, type ResolveToken } from '@contentful/experiences-react'; +import { Heading } from '@/components/Heading'; +import { Page } from '@/components/Page'; +import { designTokens } from '@/lib/design-tokens'; + +const components = { + Heading: defineComponent<{ text?: string }>({ + defaults: { text: 'Untitled' }, + component: Heading, + }), + // ... other component types (bare or config) ... +}; + +const templates = { page: Page }; + +const resolveToken: ResolveToken = (token) => designTokens[token.value]; + +export const experienceConfig: Config = { components, templates, resolveToken }; +``` + +### Merge precedence + +The component receives a flat set of props composed of (last-wins): + +1. `defaults` (componentConfig.defaults, fallback values) +2. `contentProperties` (editorial values from the payload) +3. `resolveData()` (return value of componentConfig.resolveData, see below) +4. slot props (each named slot becomes a pre-rendered React subtree) + +Design values are **not** included here; they're read via `useDesignValues()`. So a payload like: + +```json +{ + "componentType": { "sys": { "urn": ".../componentTypes/Button" } }, + "contentProperties": { "label": "Click me", "url": "example.com/go" }, + "designProperties": { "target": { "type": "ManualDesignValue", "value": "_self" } } +} +``` + +reaches your `Button` as `{ label: 'Click me', url: 'https://example.com/go' }` (after its `resolveData` runs), while `useDesignValues()` returns `{ target: '_self' }`. + +### `resolveData`: sync or async transforms + +Each entry can declare a `resolveData` hook that derives final props from the +raw inputs. Useful for reshaping editorial fields, fetching enrichment, or +localizing URLs. The result is merged in **after** content but **before** +slots. + +```tsx +PriceTag: defineComponent({ + resolveData: async ({ content }) => ({ + formattedPrice: await formatPriceFromCatalog(content.sku), + }), + component: PriceTag, +}), +``` + +The route calls `fetchExperience` once, and it handles the API call and resolution in one step: + +```ts +const experience = await fetchExperience( + { spaceId: process.env.SPACE_ID!, environmentId: 'master', experienceId: slug }, + { accessToken: process.env.CDA_TOKEN! }, + { config: experienceConfig } +); +``` + +Resolvers run in parallel across nodes. Viewport resolution stays at render +time, so client-side viewport changes never re-trigger `resolveData`. + +#### Optional `context` + +The `context` option (on `resolveOptions`, the third arg) passes per-render context into every component's `resolveData` hook (and to components via `useExperience()`). +Default is `{ isPreview: false, metadata: {} }`, which is fine for production. Add +fields when: + +- **Preview mode**: `{ isPreview: true }`. `MissingComponent` renders a visible + red box, and your own components can branch on it too. Set `host` to the + preview endpoint on `clientOptions` to also hit the preview API. +- **Per-page metadata**: `{ metadata: { slug, locale } }`, available to every + `resolveData` for URL building, locale-aware lookups, and so on. + +```ts +const experience = await fetchExperience( + { spaceId: process.env.SPACE_ID!, environmentId: 'master', experienceId: slug, locale }, + { + accessToken: process.env.CDA_TOKEN!, + host: previewMode ? 'https://preview.xdn.contentful.com' : 'https://xdn.contentful.com', + }, + { + config: experienceConfig, + context: { isPreview: previewMode, metadata: { slug, locale } }, + } +); +``` + +Pair with `` (User-Agent +parsed on the server) when you want SSR output to match the device's expected +viewport. Otherwise the renderer defaults to `viewports[0]`. + +### `defineTemplate`: page-level wrappers + +When a payload carries `sys.template`, the SDK looks up a matching id under +`Config.templates` and wraps the rendered nodes with the template's component. +Templates use the same `defaults` / `resolveData` shape as components; the +only structural difference is that the component always receives a fixed +`children: ReactNode` (the rendered experience) alongside its declared props. + +```tsx +import { defineTemplate } from '@contentful/experiences-react'; +import { Page } from './Page'; + +const templates = { + // bare component, or defineTemplate({...}) for defaults / resolveData + page: defineTemplate({ + defaults: { title: 'Welcome' }, + component: Page, // Page receives { title, children } + }), +}; + +export const experienceConfig: Config = { components, templates }; +``` + +If the payload references a template id that isn't registered, the renderer +warns once and renders the nodes unwrapped, the same graceful-degradation +behavior as missing components. + +## Where the live preview / editor support fits + +Live preview (postMessage from the Contentful editor iframe) lands in a separate increment with a client-component wrapper that uses `ClientExperienceRenderer` and a `useMessagingClient`-style hook. SSR and interactive editor mode are mutually exclusive: the editor mode requires `'use client'` so the message listener can attach. diff --git a/test-apps/nextjs/app/[slug]/page.tsx b/test-apps/nextjs/app/[slug]/page.tsx new file mode 100644 index 0000000..1cfa4fd --- /dev/null +++ b/test-apps/nextjs/app/[slug]/page.tsx @@ -0,0 +1,33 @@ +import { notFound } from 'next/navigation'; +import { + NotFoundError, + ServerExperienceRenderer, + fetchExperience, +} from '@contentful/experiences-react'; + +import { experienceConfig } from '@/lib/experience-config'; + +interface PageProps { + params: Promise<{ slug: string }>; +} + +export default async function ExperiencePage({ params }: PageProps) { + const { slug: experienceId } = await params; + + try { + const experience = await fetchExperience( + { + spaceId: process.env.SPACE_ID ?? '', + environmentId: process.env.ENVIRONMENT_ID ?? 'master', + experienceId, + }, + { accessToken: process.env.CDA_TOKEN! }, + { config: experienceConfig } + ); + + return ; + } catch (err) { + if (err instanceof NotFoundError) notFound(); + throw err; + } +} diff --git a/test-apps/nextjs/app/advanced/[slug]/page.tsx b/test-apps/nextjs/app/advanced/[slug]/page.tsx new file mode 100644 index 0000000..617ff67 --- /dev/null +++ b/test-apps/nextjs/app/advanced/[slug]/page.tsx @@ -0,0 +1,77 @@ +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; +import { + NotFoundError, + ServerExperienceRenderer, + fetchExperience, +} from '@contentful/experiences-react'; + +import { detectViewportFromUserAgent } from '@/lib/detect-viewport'; +import { advancedExperienceConfig } from '@/lib/experience-config-advanced'; + +interface PageProps { + params: Promise<{ slug: string }>; + searchParams?: Promise>; +} + +/** + * Advanced version of the [slug] route. Demonstrates three SDK features the + * minimal three-line page in `app/[slug]/page.tsx` doesn't reach for: + * + * 1. **Preview mode + per-page metadata** via the `context` arg of + * `fetchExperience`. `?preview=true` flips `MissingComponent` from + * "silent null" to "visible red box"; metadata flows into every + * `resolveData` hook. + * 2. **User-Agent → viewport seeding** via `initialViewportId` so SSR + * renders at the device's expected viewport (avoids hydration drift on + * the client renderer's first paint). + * 3. **Async `resolveData` with external fetch** — the advanced config + * `lib/experience-config-advanced.tsx` does a fake catalog fetch on the + * `button` component and uppercases the editorial text. The SDK runs + * resolvers in parallel across nodes, so the slow resolver doesn't + * block the others. + */ +export default async function AdvancedExperiencePage({ params, searchParams }: PageProps) { + const { slug: experienceId } = await params; + const sp = (await searchParams) ?? {}; + + const previewMode = sp.preview === 'true' || sp.preview === '1'; + const locale = typeof sp.locale === 'string' ? sp.locale : 'en-US'; + + const userAgent = (await headers()).get('user-agent') ?? ''; + const initialViewportId = detectViewportFromUserAgent(userAgent); + + try { + const experience = await fetchExperience( + { + spaceId: process.env.SPACE_ID ?? '', + environmentId: process.env.ENVIRONMENT_ID ?? 'master', + experienceId, + locale, + }, + { + accessToken: process.env.CDA_TOKEN!, + host: previewMode ? 'https://preview.xdn.contentful.com' : 'https://xdn.contentful.com', + }, + { + config: advancedExperienceConfig, + context: { + isPreview: previewMode, + metadata: { slug: experienceId, locale }, + }, + } + ); + + return ( + + ); + } catch (err) { + if (err instanceof NotFoundError) notFound(); + throw err; + } +} diff --git a/test-apps/nextjs/app/layout.tsx b/test-apps/nextjs/app/layout.tsx new file mode 100644 index 0000000..a918505 --- /dev/null +++ b/test-apps/nextjs/app/layout.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; + +export const metadata = { + title: 'Contentful Experiences — Next.js example', + description: 'Demonstrates @contentful/experiences-react with the Next.js App Router.', +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/test-apps/nextjs/app/page.tsx b/test-apps/nextjs/app/page.tsx new file mode 100644 index 0000000..a019c68 --- /dev/null +++ b/test-apps/nextjs/app/page.tsx @@ -0,0 +1,85 @@ +import Link from 'next/link'; + +const linkStyle = { + display: 'inline-block', + padding: '10px 16px', + borderRadius: 8, + background: '#4f39f6', + color: '#ffffff', + textDecoration: 'none', + fontWeight: 500, +}; + +const secondaryLinkStyle = { + ...linkStyle, + background: '#ffffff', + color: '#1f2937', + border: '1px solid #d1d5db', +}; + +export default function HomePage() { + return ( +

+

Contentful Experiences — Next.js example

+

+ This app demonstrates rendering a Contentful Experience payload with{' '} + @contentful/experiences-react in a Next.js App Router server component. +

+ +

Two routes, same data

+

+ Both routes render the same Experience id. Compare to see what each SDK option buys you. +

+ +
    +
  • + + /[slug] + {' '} + — minimal three-line page. fetch →{' '} + resolveExperience(payload, config) →{' '} + <ServerExperienceRenderer>. No preview mode, no UA seeding, no + metadata. +
  • +
  • + + /advanced/[slug] + {' '} + — same render, opts dialed up: preview mode via ?preview=true, User-Agent →{' '} + initialViewportId for hydration-safe SSR, async resolveData with + external fetch + per-page metadata. +
  • +
+ +

+ + Simple demo + + + Advanced demo (preview) + +

+ +

+ Replace demo in either URL with a real Experience id from your space. +

+
+ ); +} diff --git a/test-apps/nextjs/components/Button.tsx b/test-apps/nextjs/components/Button.tsx new file mode 100644 index 0000000..a670660 --- /dev/null +++ b/test-apps/nextjs/components/Button.tsx @@ -0,0 +1,49 @@ +'use client'; + +import type { CSSProperties } from 'react'; + +import { useDesignValues } from '@contentful/experiences-react'; + +export interface ButtonProps { + label?: string; + url?: string | null; +} + +export function Button({ label, url }: ButtonProps) { + const design = useDesignValues(); + const target = (design.target as string | undefined) ?? '_self'; + + const style: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 6, + padding: '12px 18px', + borderRadius: 8, + background: (design.backgroundColor as string) ?? '#4f39f6', + color: (design.color as string) ?? '#ffffff', + fontWeight: 500, + border: 'none', + textDecoration: 'none', + cursor: 'pointer', + }; + + const content = <>{label ?? 'Button'}; + + if (url) { + return ( + + {content} + + ); + } + return ( + + ); +} 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 0000000..49b2422 Binary files /dev/null and b/test-apps/nextjs/public/favicon.ico differ diff --git a/test-apps/nextjs/tsconfig.json b/test-apps/nextjs/tsconfig.json new file mode 100644 index 0000000..0c66908 --- /dev/null +++ b/test-apps/nextjs/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/test-apps/sveltekit/.env.example b/test-apps/sveltekit/.env.example new file mode 100644 index 0000000..40490a6 --- /dev/null +++ b/test-apps/sveltekit/.env.example @@ -0,0 +1,3 @@ +SPACE_ID= +ENVIRONMENT_ID=master +CDA_TOKEN= diff --git a/test-apps/sveltekit/.gitignore b/test-apps/sveltekit/.gitignore new file mode 100644 index 0000000..538f076 --- /dev/null +++ b/test-apps/sveltekit/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.svelte-kit/ +build/ +.env +.env.local +.env.*.local +.DS_Store diff --git a/test-apps/sveltekit/README.md b/test-apps/sveltekit/README.md new file mode 100644 index 0000000..7a9a5f2 --- /dev/null +++ b/test-apps/sveltekit/README.md @@ -0,0 +1,60 @@ +# SvelteKit example: Contentful Experiences + +A SvelteKit 2 + Svelte 5 app demonstrating `@contentful/experiences-svelte` rendering an Experience payload fetched from XDA. Mirrors `examples/nextjs/` 1:1 in registered components, slugs, and visual output; the only thing that changes between the two apps is the framework-specific setup. + +## What it shows + +- **Server-side fetch and resolve** via `fetchExperience` re-exported from `@contentful/experiences-svelte`, which proves the fetch and resolver pipeline is genuinely framework-agnostic. +- **SSR rendering** with `ServerExperienceRenderer` from `@contentful/experiences-svelte`. +- **Hydration-safe viewport seeding**: User-Agent parsed on the server in `+page.server.ts`, passed as `initialViewportId`. +- **Styling via `getDesignValues()` and `toCss()`**: components read their own design inside a `$derived` (reactive across viewport changes); design is never injected as props. +- **Design tokens**: `experience-config.ts` wires a `resolveToken` mapping token ids to CSS values. +- **Component registration**: bare Svelte components for the common case, `defineComponent({ component, ... })` when a component needs `defaults` or `resolveData`. + +## Run it + +```sh +# From the repo root: +npm install --ignore-scripts +npm run build # builds the SDK packages + +cd examples/sveltekit +cp .env.example .env # fill in SPACE_ID + CDA_TOKEN +npm run dev +``` + +Then visit `http://localhost:5173/`. The slug becomes the Experience ID passed to `client.view.getExperience`. + +## File map + +``` +examples/sveltekit/ +├── src/ +│ ├── app.html # SvelteKit HTML shell +│ ├── routes/ +│ │ ├── +layout.svelte # root layout +│ │ ├── +page.svelte # index +│ │ ├── [slug]/+page.server.ts # dynamic Experience load (server) +│ │ └── [slug]/+page.svelte # dynamic Experience render +│ └── lib/ +│ ├── components/ # plain design-system components; no SDK imports +│ │ ├── Button.svelte +│ │ ├── Header.svelte +│ │ ├── Page.svelte # used as the page-level template +│ │ └── Text.svelte +│ ├── detect-viewport.ts +│ └── experience-config.ts # integration layer (maps components + templates into experienceConfig) +├── svelte.config.js +├── vite.config.ts +└── tsconfig.json +``` + +## Integration pattern + +Identical to the Next.js example: + +1. **Design-system components** stay portable, with no `@contentful/*` imports. +2. **`experience-config.ts`** is the wiring layer that maps Contentful component-type IDs to your design-system components. +3. **Routes** call `fetchExperience(experienceOptions, clientOptions, resolveOptions)` and pass the result to ``, wrapped in a try/catch that routes `NotFoundError` to SvelteKit's `error(404, ...)`. + +The only Svelte-specific difference is slots: the default `children` slot is passed as a `children` Snippet prop (render it with `{@render children()}`), and any additional named slots are reachable via `getContentfulComponent().slots` and rendered through the exported ``. Compare to the React adapter, where each slot becomes its own named React-node prop. See [`packages/adapter-svelte/README.md`](../../packages/adapter-svelte/README.md) for the full Svelte API surface. diff --git a/test-apps/sveltekit/package.json b/test-apps/sveltekit/package.json new file mode 100644 index 0000000..d48bb39 --- /dev/null +++ b/test-apps/sveltekit/package.json @@ -0,0 +1,25 @@ +{ + "name": "@contentful/experiences-testapp-sveltekit", + "version": "0.0.0", + "private": true, + "description": "Internal SvelteKit scratchpad for iterating on @contentful/experiences-svelte — not customer-facing. See examples/sveltekit for the stable external example.", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "dependencies": { + "@contentful/experiences-svelte": "*" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^3.3.0", + "@sveltejs/kit": "^2.8.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.4.0", + "vite": "^5.4.0" + } +} diff --git a/test-apps/sveltekit/src/app.d.ts b/test-apps/sveltekit/src/app.d.ts new file mode 100644 index 0000000..a6911e5 --- /dev/null +++ b/test-apps/sveltekit/src/app.d.ts @@ -0,0 +1,5 @@ +declare global { + namespace App {} +} + +export {}; diff --git a/test-apps/sveltekit/src/app.html b/test-apps/sveltekit/src/app.html new file mode 100644 index 0000000..973e1e4 --- /dev/null +++ b/test-apps/sveltekit/src/app.html @@ -0,0 +1,14 @@ + + + + + + Contentful Experiences — SvelteKit example + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/test-apps/sveltekit/src/lib/components/Button.svelte b/test-apps/sveltekit/src/lib/components/Button.svelte new file mode 100644 index 0000000..2240221 --- /dev/null +++ b/test-apps/sveltekit/src/lib/components/Button.svelte @@ -0,0 +1,53 @@ + + + + + +{#if url} + + {text} + {#if children}{@render children()}{/if} + +{:else} + +{/if} diff --git a/examples/sveltekit/src/lib/components/Header.svelte b/test-apps/sveltekit/src/lib/components/Header.svelte similarity index 100% rename from examples/sveltekit/src/lib/components/Header.svelte rename to test-apps/sveltekit/src/lib/components/Header.svelte diff --git a/test-apps/sveltekit/src/lib/components/Page.svelte b/test-apps/sveltekit/src/lib/components/Page.svelte new file mode 100644 index 0000000..fe3d4c4 --- /dev/null +++ b/test-apps/sveltekit/src/lib/components/Page.svelte @@ -0,0 +1,29 @@ + + + + + +
+ {#if title} +

+ {title} +

+ {/if} + {@render children()} +
diff --git a/test-apps/sveltekit/src/lib/components/Text.svelte b/test-apps/sveltekit/src/lib/components/Text.svelte new file mode 100644 index 0000000..de56880 --- /dev/null +++ b/test-apps/sveltekit/src/lib/components/Text.svelte @@ -0,0 +1,17 @@ + + + + +

+ {value} + {#if children}{@render children()}{/if} +

diff --git a/test-apps/sveltekit/src/lib/detect-viewport.ts b/test-apps/sveltekit/src/lib/detect-viewport.ts new file mode 100644 index 0000000..4377e50 --- /dev/null +++ b/test-apps/sveltekit/src/lib/detect-viewport.ts @@ -0,0 +1,19 @@ +/** + * Detect a viewport id from a User-Agent string for SSR seeding. + * + * The returned id seeds `ServerExperienceRenderer`'s `initialViewportId` so + * the server-rendered output matches the device's expected viewport. The + * client renderer uses the same id as its first paint, then transitions to + * live `matchMedia` matching as the window resizes — avoids hydration drift. + */ + +export function detectViewportFromUserAgent(userAgent: string): string { + const ua = userAgent.toLowerCase(); + if (/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/.test(ua)) { + return 'mobile'; + } + if (/ipad|tablet|kindle|playbook|silk/.test(ua)) { + return 'tablet'; + } + return 'desktop'; +} diff --git a/test-apps/sveltekit/src/lib/experience-config.ts b/test-apps/sveltekit/src/lib/experience-config.ts new file mode 100644 index 0000000..df7bd7e --- /dev/null +++ b/test-apps/sveltekit/src/lib/experience-config.ts @@ -0,0 +1,44 @@ +/** + * Maps Contentful component/template ids to the app's components, and wires + * `resolveToken`. Components read design via `getDesignValues()`. + */ + +import { + defineComponent, + type Components, + type Config, + type ResolveToken, + type Templates, +} from '@contentful/experiences-svelte'; + +import Button from './components/Button.svelte'; +import Header, { type HeaderProps } from './components/Header.svelte'; +import Page from './components/Page.svelte'; +import Text from './components/Text.svelte'; + +const components: Components = { + button: Button, + text: Text, + header: defineComponent({ + component: Header, + defaults: { text: 'Hello World' }, + }), +}; + +const templates: Templates = { + hi: { component: Page, defaults: { title: 'Welcome' } }, + hero: { component: Page, defaults: { title: 'Featured' } }, +}; + +// Resolves opaque token ids to their underlying values — only you know what a +// token id means. Returning undefined drops the key. A real app might use CSS +// vars, a Tailwind theme, or a tokens package. +const brandTokens: Record = { + 'color.surface.hero': '#4f39f6', + 'color.surface.subtle': '#f4f4f5', + 'color.text.onPrimary': '#ffffff', +}; + +const resolveToken: ResolveToken = (token) => brandTokens[token.value]; + +export const experienceConfig: Config = { components, templates, resolveToken }; diff --git a/test-apps/sveltekit/src/routes/+layout.svelte b/test-apps/sveltekit/src/routes/+layout.svelte new file mode 100644 index 0000000..2ccd9ab --- /dev/null +++ b/test-apps/sveltekit/src/routes/+layout.svelte @@ -0,0 +1,5 @@ + + +{@render children()} diff --git a/test-apps/sveltekit/src/routes/+page.svelte b/test-apps/sveltekit/src/routes/+page.svelte new file mode 100644 index 0000000..e93afbf --- /dev/null +++ b/test-apps/sveltekit/src/routes/+page.svelte @@ -0,0 +1,21 @@ +
+

Contentful Experiences — SvelteKit example

+

+ This app demonstrates rendering a Contentful Experience payload with + @contentful/experiences-svelte in a SvelteKit server load function. +

+

+ Without CDA_TOKEN set in .env, the app uses a built-in mock payload so it + works out of the box. +

+

+ + View the demo experience + +

+
diff --git a/test-apps/sveltekit/src/routes/[slug]/+page.server.ts b/test-apps/sveltekit/src/routes/[slug]/+page.server.ts new file mode 100644 index 0000000..f5412f6 --- /dev/null +++ b/test-apps/sveltekit/src/routes/[slug]/+page.server.ts @@ -0,0 +1,37 @@ +import { error } from '@sveltejs/kit'; + +import { NotFoundError, fetchExperience } from '@contentful/experiences-svelte'; +import { CDA_TOKEN, ENVIRONMENT_ID, SPACE_ID } from '$env/static/private'; +import { detectViewportFromUserAgent } from '$lib/detect-viewport.js'; +import { experienceConfig } from '$lib/experience-config.js'; + +import type { PageServerLoad } from './$types.js'; + +export const load: PageServerLoad = async ({ params, url, request }) => { + const previewMode = + url.searchParams.get('preview') === 'true' || url.searchParams.get('preview') === '1'; + const initialViewportId = detectViewportFromUserAgent(request.headers.get('user-agent') ?? ''); + + try { + const experience = await fetchExperience( + { + spaceId: SPACE_ID, + environmentId: ENVIRONMENT_ID || 'master', + experienceId: params.slug, + }, + { + accessToken: CDA_TOKEN, + host: previewMode ? 'https://preview.xdn.contentful.com' : 'https://xdn.contentful.com', + }, + { + config: experienceConfig, + context: { isPreview: previewMode, metadata: { slug: params.slug } }, + } + ); + + return { experience, previewMode, slug: params.slug, initialViewportId }; + } catch (err) { + if (err instanceof NotFoundError) error(404, 'Experience not found'); + throw err; + } +}; diff --git a/test-apps/sveltekit/src/routes/[slug]/+page.svelte b/test-apps/sveltekit/src/routes/[slug]/+page.svelte new file mode 100644 index 0000000..871acf2 --- /dev/null +++ b/test-apps/sveltekit/src/routes/[slug]/+page.svelte @@ -0,0 +1,14 @@ + + + diff --git a/test-apps/sveltekit/svelte.config.js b/test-apps/sveltekit/svelte.config.js new file mode 100644 index 0000000..5853b9e --- /dev/null +++ b/test-apps/sveltekit/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + }, +}; + +export default config; diff --git a/test-apps/sveltekit/tsconfig.json b/test-apps/sveltekit/tsconfig.json new file mode 100644 index 0000000..4344710 --- /dev/null +++ b/test-apps/sveltekit/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/test-apps/sveltekit/vite.config.ts b/test-apps/sveltekit/vite.config.ts new file mode 100644 index 0000000..19f5bb3 --- /dev/null +++ b/test-apps/sveltekit/vite.config.ts @@ -0,0 +1,23 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + // Force SvelteKit's SSR loader to bundle our workspace SDK packages instead + // of letting Node ESM resolve them directly. Their compiled dist/ files use + // extensionless relative imports (e.g. `./viewport`), which Vite happily + // resolves but Node's strict ESM loader rejects. This is the SvelteKit + // equivalent of Next.js's `transpilePackages` option. + ssr: { + noExternal: [ + '@contentful/experiences-svelte', + '@contentful/experiences-sdk-core', + '@contentful/experiences-design', + ], + }, + server: { + fs: { + allow: ['../../packages', '../..'], + }, + }, +});