From 1b3e185555bf10473b2d72fbda5b30a5800d64cc Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Fri, 29 May 2026 13:10:55 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5=20feat(locales):=20Updating=20loca?= =?UTF-8?q?le=20support=20to=20be=20more=20consumer-friendly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [[NT-3327](https://contentful.atlassian.net/browse/NT-3327)] --- .github/workflows/main-pipeline.yaml | 1 + .prettierignore | 2 + AGENTS.md | 36 ++ documentation/AGENTS.md | 8 + documentation/concepts/README.md | 4 + ...-personalization-and-variant-resolution.md | 83 +++- ...king-in-node-and-stateless-environments.md | 20 +- ...-handling-in-the-optimization-sdk-suite.md | 434 ++++++++++++++++++ ...nchronization-between-client-and-server.md | 17 +- .../integrating-the-ios-sdk-fundamentals.md | 7 +- ...ntegrating-the-ios-sdk-in-a-swiftui-app.md | 4 + .../integrating-the-ios-sdk-in-a-uikit-app.md | 4 + .../integrating-the-node-sdk-in-a-node-app.md | 128 +++--- ...ptimization-sdk-in-a-nextjs-app-ssr-csr.md | 105 ++++- ...he-optimization-sdk-in-a-nextjs-app-ssr.md | 103 ++++- ...-react-native-sdk-in-a-react-native-app.md | 62 ++- ...rating-the-react-web-sdk-in-a-react-app.md | 74 ++- .../integrating-the-web-sdk-in-a-web-app.md | 39 +- eslint.config.ts | 3 + implementations/AGENTS.md | 11 + implementations/android-sdk/README.md | 17 + .../optimization/app/MainActivity.kt | 2 + .../app/screens/LiveUpdatesTestScreen.kt | 8 +- .../optimization/app/screens/MainScreen.kt | 5 +- .../optimization/shared/AppConfig.kt | 4 + .../optimization/shared/ContentfulFetcher.kt | 15 +- .../app/views/LiveUpdatesTestActivity.kt | 6 +- .../optimization/app/views/MainActivity.kt | 7 +- implementations/ios-sdk/README.md | 17 + implementations/ios-sdk/shared/Config.swift | 3 + .../ios-sdk/shared/ContentfulFetcher.swift | 10 +- implementations/ios-sdk/swiftui/App.swift | 2 + .../Screens/LiveUpdatesTestScreen.swift | 5 +- .../ios-sdk/swiftui/Screens/MainScreen.swift | 5 +- .../ios-sdk/uikit/SceneDelegate.swift | 2 + .../LiveUpdatesTestViewController.swift | 5 +- .../uikit/Screens/MainViewController.swift | 5 +- implementations/node-sdk+web-sdk/README.md | 14 + implementations/node-sdk+web-sdk/src/app.ts | 74 ++- .../node-sdk+web-sdk/src/index.ejs | 9 +- implementations/node-sdk/README.md | 13 + implementations/node-sdk/src/app.ts | 82 +++- implementations/react-native-sdk/App.tsx | 22 +- implementations/react-native-sdk/README.md | 13 + .../react-native-sdk/env.config.ts | 9 + .../screens/LiveUpdatesTestScreen.tsx | 3 +- .../react-native-sdk/utils/sdkHelpers.ts | 24 +- .../README.md | 14 + .../app/layout.tsx | 18 +- .../app/page.tsx | 12 +- .../components/ClientProviderWrapper.tsx | 9 +- .../lib/config.ts | 3 + .../lib/contentful-client.ts | 13 +- .../lib/optimization-server.ts | 24 +- .../middleware.ts | 29 +- .../README.md | 14 + .../app/layout.tsx | 19 +- .../app/page.tsx | 21 +- .../components/ClientProviderWrapper.tsx | 5 +- .../lib/config.ts | 3 + .../lib/contentful-client.ts | 13 +- .../lib/optimization-server.ts | 9 +- .../middleware.ts | 29 +- implementations/react-web-sdk/README.md | 16 +- implementations/react-web-sdk/src/App.tsx | 2 +- implementations/react-web-sdk/src/main.tsx | 13 +- .../src/services/contentfulClient.ts | 23 +- implementations/web-sdk/README.md | 13 + implementations/web-sdk/public/index.html | 9 +- implementations/web-sdk_react/README.md | 13 + .../src/optimization/createOptimization.ts | 4 + .../liveUpdates/LiveUpdatesContext.tsx | 7 +- .../src/services/contentfulClient.ts | 11 +- package.json | 6 +- packages/AGENTS.md | 4 +- .../optimization/core/OptimizationClient.kt | 29 ++ .../optimization/core/OptimizationConfig.kt | 94 ++++ .../optimization/core/OptimizationState.kt | 5 +- .../core/OptimizationConfigTest.kt | 85 ++++ packages/android/README.md | 11 + packages/ios/ContentfulOptimization/README.md | 41 ++ .../Core/OptimizationClient.swift | 27 +- .../Core/OptimizationConfig.swift | 137 +++++- .../Core/OptimizationState.swift | 5 +- .../OptimizationClientTests.swift | 132 +++++- packages/ios/README.md | 11 + packages/node/node-sdk/README.md | 97 +++- packages/node/node-sdk/package.json | 4 +- .../src/ContentfulOptimization.test.ts | 122 +++++ .../node-sdk/src/ContentfulOptimization.ts | 190 ++++++-- packages/react-native-sdk/README.md | 91 +++- .../src/ContentfulOptimization.test.ts | 143 ++++++ .../src/ContentfulOptimization.ts | 12 + .../OptimizationProvider.locale.test.tsx | 143 ++++++ .../components/OptimizationProvider.test.tsx | 26 +- .../src/components/OptimizationProvider.tsx | 19 +- .../src/hooks/useViewportTracking.ts | 2 +- packages/universal/api-client/README.md | 11 +- .../experience/ExperienceApiClient.test.ts | 27 ++ .../src/experience/ExperienceApiClient.ts | 16 +- .../src/fetch/createTimeoutFetchMethod.ts | 5 +- packages/universal/api-schemas/README.md | 9 + packages/universal/core-sdk/README.md | 25 +- packages/universal/core-sdk/package.json | 4 +- .../universal/core-sdk/src/CoreApiConfig.ts | 2 +- .../universal/core-sdk/src/CoreBase.test.ts | 101 +++- packages/universal/core-sdk/src/CoreBase.ts | 105 ++++- .../src/CoreStateful.detached-states.test.ts | 1 + .../core-sdk/src/CoreStateful.locale.test.ts | 151 ++++++ .../core-sdk/src/CoreStateful.test.ts | 2 + .../universal/core-sdk/src/CoreStateful.ts | 68 ++- .../core-sdk/src/CoreStateless.test.ts | 27 ++ .../universal/core-sdk/src/CoreStateless.ts | 23 +- packages/universal/core-sdk/src/index.ts | 1 + .../universal/core-sdk/src/locale.test.ts | 209 +++++++++ packages/universal/core-sdk/src/locale.ts | 196 ++++++++ .../universal/core-sdk/src/signals/signals.ts | 10 + .../optimization-js-bridge/src/index.ts | 18 + .../web/frameworks/react-web-sdk/README.md | 91 +++- .../src/context/OptimizationContext.tsx | 3 + .../react-web-sdk/src/index.test.tsx | 20 + ...ionProvider.trackEntryInteraction.test.tsx | 41 +- .../src/provider/OptimizationProvider.tsx | 13 + .../react-web-sdk/src/test/sdkTestUtils.tsx | 4 + packages/web/preview-panel/README.md | 4 +- .../src/attachOptimizationPreviewPanel.ts | 24 +- packages/web/web-sdk/README.md | 55 ++- .../src/ContentfulOptimization.test.ts | 91 ++++ .../web/web-sdk/src/ContentfulOptimization.ts | 14 +- scripts/run-implementation-script.ts | 9 +- 130 files changed, 4217 insertions(+), 471 deletions(-) create mode 100644 documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md create mode 100644 packages/android/ContentfulOptimization/src/test/kotlin/com/contentful/optimization/core/OptimizationConfigTest.kt create mode 100644 packages/react-native-sdk/src/ContentfulOptimization.test.ts create mode 100644 packages/react-native-sdk/src/components/OptimizationProvider.locale.test.tsx create mode 100644 packages/universal/core-sdk/src/CoreStateful.locale.test.ts create mode 100644 packages/universal/core-sdk/src/locale.test.ts create mode 100644 packages/universal/core-sdk/src/locale.ts diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index 4a10c81e..047d32d9 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -266,6 +266,7 @@ jobs: - run: pnpm install --prefer-offline --frozen-lockfile - run: pnpm build:ci + - run: pnpm size:check - name: Pack SDK tarballs for implementations run: | rm -rf pkgs diff --git a/.prettierignore b/.prettierignore index 6f6617c5..bdff6ed1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,7 @@ docs pnpm-lock.yaml +**/.next/** +**/next-env.d.ts **/ios/Pods/** **/ios/build/** **/android/build/** diff --git a/AGENTS.md b/AGENTS.md index a0ee91c8..5833975c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,13 +119,36 @@ High-signal repo-wide commands: - `pnpm test:unit` - `pnpm build` - `pnpm size:check` +- `pnpm size:report` - `pnpm build:pkgs` - `pnpm format:check` - `pnpm docs:generate` +## Bundle budget failures + +- When `pnpm size:check` or a package `size:check` fails, do not add package exports, new bundle + entries, new chunks, budget config changes, aliases, or separate build outputs to move bytes + outside the failing budget unless the user explicitly asks for that approach. +- `size:check` can stop at the first failing package or bundle. After a `size:check` failure, run + `pnpm size:report` or the relevant package `size:report` before drawing conclusions about the full + set of bundle-budget failures. +- For bundle-budget failures, the only source changes allowed without user direction are concise, + behavior-preserving reductions to code you added or changed for the task. +- Before attempting any bundle-budget fix, report: + - the exact command + - the failing package or bundle + - the budget, actual size, and delta + - the task-related files changed since the last known passing baseline + - the smallest behavior-preserving concision option, if one is clear + - the specific human direction needed if concision is not obvious + ## Failure handling and recovery - Do not rerun the same failing command unchanged more than once. +- Treat validation failures as diagnostic evidence before treating them as code defects. Do not edit + source, add compatibility shims, or change reference implementation code until you have classified + whether the failure is caused by source behavior, stale generated artifacts, install state, + package packaging, environment setup, or tooling. - Before retrying, classify the failure into one of these buckets: - command resolution or PATH - missing prerequisite or setup @@ -136,6 +159,9 @@ High-signal repo-wide commands: - unknown - Prefer a small probe before a full rerun. Check the nearest `AGENTS.md`, the target `package.json`, and any relevant `README.md` or `CONTRIBUTING.md` section before guessing. +- If the classified failure is not in source code, stop making source changes. Use the documented + package or implementation script for that failure class, or report the root cause and smallest + next action if cleanup, approval, or environment repair is needed. - If a lint or format command fails with findings that the tool can auto-fix, prefer a targeted fix-enabled rerun over repeated check-only runs, then revalidate once. - If the shell reports a command as missing: @@ -240,6 +266,16 @@ High-signal repo-wide commands: - Do not use broad cleanup commands such as `pm2 delete all` unless explicitly asked. - Do not assume full cross-platform E2E is required for every change. +## Context resume audit + +- After context compaction, interruption, or a long-running resume, audit before editing: reread the + latest user instruction, inspect `git status`, inspect relevant diffs, and restate the active + constraints in a short commentary update. +- If resumed context is missing, contradictory, or no longer explains why a change exists, stop and + ask or report the uncertainty instead of continuing from memory. +- Keep resumed work scoped to the newest user request. Do not continue older plans unless they are + still required by the latest instruction. + ## Preferred workflow 1. Read the root `AGENTS.md`. diff --git a/documentation/AGENTS.md b/documentation/AGENTS.md index d6c21d8d..c5236931 100644 --- a/documentation/AGENTS.md +++ b/documentation/AGENTS.md @@ -36,6 +36,8 @@ structure, cross-linking, and validation rules. ## Heading and writing style +- Write for human software engineers integrating the SDK into consumer applications. Authored docs + are not internal agent instructions or maintainer runbooks. - Use sentence case for headings. - Preserve official product, package, API, component, and hook casing. - Lead with what the reader is trying to implement. @@ -43,6 +45,12 @@ structure, cross-linking, and validation rules. - State what the SDK does not own when relevant, especially Contentful fetching, consent policy, identity policy, routing, and rendering. - Prefer concrete implementation guidance over marketing language. +- When documenting an integration constraint, tell the reader what breaks, what to do instead, and + how to choose a default or fallback for their own integration. +- Explain the consequence behind constraints. Prefer reader-facing phrasing such as "all-locale CDA + responses are incompatible with the resolver because..." over unexplained "do not" rules. +- Use direct imperatives only when they help an engineer avoid a concrete integration bug, security + issue, data leak, or broken runtime behavior. Pair them with the reason or the safer alternative. ## Cross-linking diff --git a/documentation/concepts/README.md b/documentation/concepts/README.md index 8e1aeba7..3a55ce65 100644 --- a/documentation/concepts/README.md +++ b/documentation/concepts/README.md @@ -3,6 +3,7 @@ title: Concepts children: - ./core-state-management.md - ./entry-personalization-and-variant-resolution.md + - ./locale-handling-in-the-optimization-sdk-suite.md - ./interaction-tracking-in-web-sdks.md - ./interaction-tracking-in-node-and-stateless-environments.md - ./profile-synchronization-between-client-and-server.md @@ -27,6 +28,9 @@ they are not the first stop for installation or setup commands. - [Entry personalization and variant resolution](./entry-personalization-and-variant-resolution.md) - explains how the SDK resolves a Contentful baseline entry to the selected entry variant, including data model expectations, fallback behavior, resolution paths, and preview overrides. +- [Locale handling in the Optimization SDK Suite](./locale-handling-in-the-optimization-sdk-suite.md) - + explains how Contentful locales, `contentful.js` locale response shapes, Experience API locale + options, SDK-assisted locale resolution, and runtime-specific locale sources work together. - [Interaction tracking in Web SDKs](./interaction-tracking-in-web-sdks.md) - explains how `@contentful/optimization-web` and `@contentful/optimization-react-web` detect browser entry views, clicks, hovers, Custom Flag views, page events, and custom events, including consent, diff --git a/documentation/concepts/entry-personalization-and-variant-resolution.md b/documentation/concepts/entry-personalization-and-variant-resolution.md index a912aa24..b08527cc 100644 --- a/documentation/concepts/entry-personalization-and-variant-resolution.md +++ b/documentation/concepts/entry-personalization-and-variant-resolution.md @@ -6,20 +6,26 @@ title: Entry personalization and variant resolution Use this document to understand how the Optimization SDK Suite resolves a Contentful baseline entry to the entry variant selected for a visitor. It explains the runtime contract shared by the Core, -Web, React Web, React Native, and Node SDKs. +Web, React Web, React Native, and Node SDKs. Because rendered entries can also contain MergeTag +entries, it also calls out where profile-dependent MergeTag value resolution differs from entry +replacement. For installation and package setup, use the relevant integration guide. For state propagation before -resolution, see [Core state management](./core-state-management.md). +resolution, see [Core state management](./core-state-management.md). For a broader explanation of +Contentful, Experience API, and runtime locale handling, see +[Locale handling in the Optimization SDK Suite](./locale-handling-in-the-optimization-sdk-suite.md).
Table of Contents - [Resolution boundary](#resolution-boundary) +- [Single-locale CDA entry contract](#single-locale-cda-entry-contract) - [Data model](#data-model) - [Baseline entry](#baseline-entry) - [Optimization entry](#optimization-entry) - [Selected optimization](#selected-optimization) + - [Merge tags and localized profile values](#merge-tags-and-localized-profile-values) - [Resolution flow](#resolution-flow) - [Worked example](#worked-example) - [Variant indexing](#variant-indexing) @@ -60,6 +66,57 @@ Application fetches Contentful baseline entry -> SDK returns baseline or linked variant entry for rendering ``` +## Single-locale CDA entry contract + +The resolver expects standard single-locale Contentful Delivery API entry payloads. In that shape, +localized fields are direct field values, so optimization references are available as +`entry.fields.nt_experiences` and variant entries are available as +`optimizationEntry.fields.nt_variants`. + +All-locale CDA payloads are incompatible with SDK entry resolution. `contentful.js` `withAllLocales` +and raw CDA `locale=*` return locale-keyed field maps instead of direct values, for example +`fields.nt_experiences['en-US']`. The SDK resolver intentionally handles one localized entry at a +time, so locale-keyed maps do not match the `OptimizedEntry` schema and resolution falls back to the +baseline entry. + +Fetch the entry with a single CDA locale and enough include depth for optimization links: + +```ts +const optimization = new ContentfulOptimization({ + clientId, + environment, + contentfulLocales: { + default: 'en-US', + supported: ['en-US', 'de-DE', 'fr-FR'], + }, +}) + +const contentful = optimization.withOptimizationLocale(contentfulClient) +const baselineEntry = await contentful.getEntry(entryId, { + include: 10, +}) +``` + +Use the SDK-resolved Contentful locale for CDA entry fetches that feed entry resolution. Browser, +React Web, and React Native apps can use `withOptimizationLocale(contentfulClient)` or pass +`optimization.locale` explicitly. Node and SSR apps can use the `contentfulLocale` returned by +`resolveRequestLocale(reqOrAcceptLanguage)`. Native iOS and Android apps can use `client.locale` in +their app-owned CDA request code. + +That Contentful locale is separate from event context locale and from an explicit Experience API +`api.locale` override. For the full locale model, including runtime candidates, fallback order, +Experience API localization, and environment-specific surfaces, see +[Locale handling in the Optimization SDK Suite](./locale-handling-in-the-optimization-sdk-suite.md). + +For entries that will be passed to the Optimization SDK resolver, use a concrete locale instead of +the CDA wildcard locale: + +```ts +// These patterns return all locales and produce locale-keyed fields. +await contentfulClient.withAllLocales.getEntry(entryId, { include: 10 }) +await fetch(`/spaces/${space}/environments/${environment}/entries/${entryId}?include=10&locale=*`) +``` + ## Data model ### Baseline entry @@ -103,6 +160,28 @@ resolved through the Custom Flag and `changes` flow, not through `resolveOptimiz The resolver uses `experienceId` and `variantIndex`. It returns the full `SelectedOptimization` as metadata only when it resolves to a non-baseline variant. +### Merge tags and localized profile values + +Entry rendering can also involve MergeTag entries embedded in Rich Text. MergeTag helpers such as +`getMergeTagValue()` resolve against the current profile data returned by the Experience API. This +is separate from entry replacement: `resolveOptimizedEntry()` chooses the entry variant, while +MergeTag helpers resolve profile-dependent values inside rendered fields. + +Stateful SDKs use the current resolved app/content locale as the default Experience API locale +unless an explicit `api.locale` override is provided. Apps can change that resolved locale with +`setLocale()` or, in React providers, by changing the provider `locale` prop. Locale changes update +SDK state only; fetch content or call `page()`, `screen()`, or `identify()` again when the rendered +data needs to refresh. Stateless server code should pass the `contentfulLocale` returned by +`resolveRequestLocale()` as the per-call `{ locale }` request option when that value is present. +That keeps localized profile values, such as `location.city` and `location.country`, in the same +language as the rendered Contentful content. When `contentfulLocale` is absent because no +`contentfulLocales` config is present, omit the request locale option intentionally. + +That request locale is not a Contentful CDA locale. The Experience API validates locale tag syntax +and uses its own default when the query parameter is omitted. If the locale tag is invalid, the API +request can fail validation before a profile response is returned. If the tag is valid but a +specific location translation is not available, localized location values may remain untranslated. + ## Resolution flow The shared Core resolver follows one path for every SDK package: diff --git a/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md b/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md index 20fc6e5e..b0029eb9 100644 --- a/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md +++ b/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md @@ -123,9 +123,18 @@ Server-side `page()` is the normal SSR entry point. It records the page request, `OptimizationData`, and gives the server `profile`, `selectedOptimizations`, and `changes` for the render. +Event `context.locale` is event data. It can be used by analytics and audience rules that inspect +event context, but it does not choose the CDA locale for Contentful entry fetches and it is separate +from the Experience API request `locale` query parameter. For the broader locale model, see +[Locale handling in the Optimization SDK Suite](./locale-handling-in-the-optimization-sdk-suite.md). + ```ts +const { contentfulLocale, eventLocale } = optimization.resolveRequestLocale(req) +const requestOptions = contentfulLocale ? { locale: contentfulLocale } : undefined + const pageResponse = await optimization.page( { + locale: eventLocale, profile: profileId ? { id: profileId } : undefined, page: { path: req.path, @@ -136,16 +145,20 @@ const pageResponse = await optimization.page( }, userAgent: req.get('user-agent') ?? 'node-server', }, - { locale: req.acceptsLanguages()[0] ?? 'en-US' }, + requestOptions, ) ``` Use server-side `track()` for server-known business events: ```ts +const { contentfulLocale, eventLocale } = optimization.resolveRequestLocale(req) +const requestOptions = contentfulLocale ? { locale: contentfulLocale } : undefined + await optimization.track( { profile: pageResponse.profile, + locale: eventLocale, event: 'quote_requested', properties: { plan: 'enterprise', @@ -163,11 +176,16 @@ visibility, use browser tracking. ```ts import { randomUUID } from 'node:crypto' +const { contentfulLocale, eventLocale } = optimization.resolveRequestLocale(req) +const requestOptions = contentfulLocale ? { locale: contentfulLocale } : undefined + await optimization.trackView( { profile: pageResponse.profile, + locale: eventLocale, componentId: resolvedEntry.sys.id, experienceId: selectedOptimization?.experienceId, + sticky: selectedOptimization?.sticky ?? false, variantIndex: selectedOptimization?.variantIndex, viewDurationMs: 0, viewId: randomUUID(), diff --git a/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md b/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md new file mode 100644 index 00000000..3d77a751 --- /dev/null +++ b/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md @@ -0,0 +1,434 @@ +--- +title: Locale handling in the Optimization SDK Suite +--- + +# Locale handling in the Optimization SDK Suite + +Use this document to understand how locales move through Contentful, `contentful.js`, the Experience +API, and the Optimization SDK Suite. It explains which layer owns each locale decision, why the SDKs +resolve locales before fetching Contentful entries, and where applications must keep content, event, +and profile localization aligned. + +For entry replacement mechanics, see +[Entry personalization and variant resolution](./entry-personalization-and-variant-resolution.md). +For package setup, use the relevant integration guide or package README. + +
+ Table of Contents + + +- [The locale channels](#the-locale-channels) +- [Contentful locale background](#contentful-locale-background) + - [Contentful Delivery API response shapes](#contentful-delivery-api-response-shapes) + - [`contentful.js` locale modifiers](#contentfuljs-locale-modifiers) +- [Why the SDKs resolve one Contentful locale](#why-the-sdks-resolve-one-contentful-locale) +- [SDK locale configuration](#sdk-locale-configuration) + - [Resolution modes](#resolution-modes) + - [Candidate matching](#candidate-matching) + - [Validation](#validation) +- [Runtime behavior](#runtime-behavior) + - [Browser and React Web](#browser-and-react-web) + - [Node and SSR](#node-and-ssr) + - [React Native](#react-native) + - [iOS and Android](#ios-and-android) +- [Experience API localization](#experience-api-localization) +- [Entry resolution and localized Contentful content](#entry-resolution-and-localized-contentful-content) +- [Application responsibilities](#application-responsibilities) +- [Caveats and gotchas](#caveats-and-gotchas) +- [Related documentation](#related-documentation) + + +
+ +## The locale channels + +Locale handling in an optimized application has multiple channels. Keeping those channels separate +prevents the most common integration bugs. + +| Channel | Owned by | Used for | +| ------------------------------------ | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Contentful locale | Contentful and the application Content Delivery API call | Selecting the localized entry, asset, and linked-entry field values returned by Contentful. | +| SDK-resolved Contentful locale | Optimization SDK locale helpers | Choosing a configured Contentful locale code before an app-owned CDA fetch. | +| Runtime or request locale candidates | Browser, device, route, server request, or application state | Inputs that the SDK can match against configured Contentful locale codes. | +| Experience API request locale | Experience API request option, stateful `api.locale`, or the SDK-resolved locale | Localizing Experience API response data such as profile location fields that merge tags can render. | +| Event context locale | Event payload context | Recording the visitor or request locale as analytics and audience-rule data. | +| Application UI or route locale | Application router, i18n framework, or native app state | Choosing URLs, UI strings, navigation, and refetch timing. | + +The SDKs can help resolve and expose a Contentful locale, but they do not own routing, translation +resources, Contentful fetching, or when the application refreshes data after a language change. + +## Contentful locale background + +Contentful locales are configured at the environment level. Each locale has a code, one default +locale exists for the environment, and non-default locales can have a fallback locale. Contentful +can also control whether a locale is included in Content Delivery API and Content Preview API +responses. + +Contentful supports several localization modeling approaches, including field-level localization, +entry-level localization, content type-level localization, and space-level localization. The +Optimization SDK Suite does not choose one of those approaches for you. The SDKs care about the +delivery payload passed to entry resolution: it must represent one localized view of the baseline +entry and its linked optimization entries. + +### Contentful Delivery API response shapes + +The Content Delivery API (CDA) can return localized entry fields in two important shapes: + +| CDA request | Field shape | `sys.locale` | SDK entry-resolution compatibility | +| --------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | -------------------------------------------------------------------------- | +| No `locale` query parameter | Direct field values from the space default locale | Present | Compatible when the app intentionally uses the default locale. | +| `locale=` | Direct field values for the requested locale, with Contentful locale fallback applied to missing fields | Present | Compatible. This is the recommended shape for localized optimized entries. | +| `locale=*` | Locale-keyed field maps such as `fields.title['en-US']` | Not set | Not compatible with SDK entry resolution. | + +For a configured locale request such as `locale=de-DE`, Contentful applies the locale fallback chain +defined in the space when a localized field value is missing. That fallback belongs to Contentful +and happens before the Optimization SDK sees the entry payload. + +For `locale=*`, Contentful returns every available locale under locale-code keys. That shape is +useful for some synchronization and authoring workflows, but it does not match the direct field +values the Optimization SDK resolver reads. + +### `contentful.js` locale modifiers + +`contentful.js` follows the same delivery shapes: + +- The default client returns entries and assets in one locale. +- `client.withAllLocales` returns entries and assets with all locales. +- The Sync API returns all localized content, so `withAllLocales` is accepted but not meaningful for + changing that Sync API behavior. + +The Optimization SDK helper `withOptimizationLocale(contentfulClient)` is designed for the +single-locale `getEntry()` and `getEntries()` path. It injects the live SDK locale when the caller +does not provide a `locale` query value. It does not convert an all-locale client or Sync API +payload back into the single-locale shape. + +## Why the SDKs resolve one Contentful locale + +Entry personalization joins two independent data sets: + +- Contentful returns the baseline entry, optimization entries, and variant entries. +- The Experience API returns selected optimization metadata for the profile or request. + +The local resolver then reads fields such as `fields.nt_experiences` and `fields.nt_variants` +directly from that Contentful payload. If those fields are locale-keyed maps, the resolver cannot +know which locale branch the application intends to render. Resolving one Contentful locale before +the CDA request keeps the resolver deterministic, typed, and independent of application routing. + +SDK-assisted locale resolution also avoids using raw browser, device, or request locales directly as +CDA query values. Runtime locales can differ from Contentful locale codes in casing, separators, +script subtags, market variants, or availability. A browser might report `de-AT` while the +Contentful space supports only `de-DE`. The SDK matches runtime input to the configured Contentful +locale list and returns the configured code that the CDA understands. + +The SDKs preserve configured Contentful locale codes in their outputs. Matching is case-insensitive, +but a configured value such as `en-US` remains `en-US` when exposed as `optimization.locale`, +`client.locale`, or `contentfulLocale`. + +## SDK locale configuration + +SDK locale configuration centers on `contentfulLocales` and an optional initial app/content +`locale`: + +```ts +const optimization = new ContentfulOptimization({ + clientId, + environment, + contentfulLocales: { + default: 'en-US', + supported: ['en-US', 'de-DE', 'fr-FR'], + }, + locale: 'de-AT', +}) +``` + +Native SDKs expose the same model with platform-specific syntax: + +```swift +let config = OptimizationConfig( + clientId: clientId, + contentfulLocales: ContentfulLocales( + default: "en-US", + supported: ["en-US", "de-DE", "fr-FR"] + ), + locale: "de-AT" +) +``` + +```kotlin +val config = OptimizationConfig( + clientId = clientId, + contentfulLocales = ContentfulLocales( + default = "en-US", + supported = listOf("en-US", "de-DE", "fr-FR"), + ), + locale = "de-AT", +) +``` + +Copy `default` and `supported` from Contentful locale settings or the locale list returned by the +Content Management API. The SDK does not discover the locale list automatically. + +### Resolution modes + +Locale resolution has these common configuration cases: + +| Configuration | Resolved Contentful locale | Result | +| --------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| No `contentfulLocales` and no explicit top-level `locale` | `undefined` | SDK-assisted CDA locale resolution is disabled. Wrapped CDA clients omit `locale` and Contentful uses the space default. | +| No `contentfulLocales` with explicit top-level `locale` | Normalized explicit locale | The SDK uses the explicit locale as-is after validation. The application is responsible for ensuring the space supports it. | +| `contentfulLocales.default` only | `contentfulLocales.default` | Single-locale apps get a concrete CDA locale and default Experience API locale. | +| `contentfulLocales.default` and `supported` | Matched configured locale, or default fallback | Localized apps can match browser, device, route, or request candidates to supported Contentful locale codes. | + +Use default-only configuration for an app that always renders one Contentful locale. Add `supported` +when the app needs to match runtime or request locales to multiple configured Contentful locales. + +### Candidate matching + +When `contentfulLocales` is configured, the SDK resolves candidates in this order: + +1. If an explicit top-level `locale` or runtime `setLocale()` value exists, use it as the only + candidate. +2. Otherwise, collect runtime, device, or request candidates for the environment. +3. Try exact configured locale matches across all candidates first. +4. Try progressively shorter language or script fallback matches by `supported` order. +5. Return `contentfulLocales.default` when no candidate matches. + +Exact matches across all candidates win before language-level fallback. For example, with +`supported: ['en-US', 'de-DE', 'fr-FR']` and request candidates `['fr-CA', 'de-DE']`, the SDK +returns `de-DE` because it is an exact configured match. It does not return `fr-FR` merely because +`fr-CA` appears first. + +The order of `supported` matters for broad fallback. If the runtime candidate is `de-AT` and the +space supports both `de-DE` and `de-CH`, the first matching `de-*` locale in `supported` wins. + +### Validation + +The SDK normalizes locale candidates by trimming whitespace and converting underscores to hyphens +before matching. Matching ignores case, but resolved values preserve configured Contentful locale +code casing. + +Ambient runtime candidates such as browser languages, device languages, and `Accept-Language` header +values are ignored when they are not valid locale strings. Explicit locale inputs are stricter: + +- `contentfulLocales.default` and `contentfulLocales.supported` must be valid locale strings. +- Top-level `locale` must be a valid locale string when present. +- `setLocale(locale)` must receive a valid locale string. +- Explicit CDA query `locale` values passed through `withOptimizationLocale()` must be valid locale + strings. +- `api.locale` must be a valid locale string when present. + +The wildcard value `*` is intentionally not a valid explicit SDK locale. Use a concrete locale for +entries that feed Optimization SDK entry resolution. + +## Runtime behavior + +Each SDK uses the same matching rules, but the candidate source differs by runtime. + +### Browser and React Web + +The Web SDK and React Web SDK are stateful browser runtimes. They collect locale candidates from +`navigator.languages` and `navigator.language` unless the application provides an explicit top-level +`locale`. + +The resolved locale is exposed through `optimization.locale` and `optimization.states.locale`. +`withOptimizationLocale(contentfulClient)` injects that locale into `getEntry()` and `getEntries()` +when the caller does not provide a locale. React Web exposes the same helper through +`useOptimization()`. + +Use `optimization.setLocale(nextLocale)` when application language state changes after +initialization. In React Web provider-owned instances, changing the provider `locale` prop calls +`setLocale(nextLocale)` for you. The method updates SDK locale state and the default Experience API +locale when `api.locale` is not configured. It does not refetch Contentful entries, rerun routing +loaders, or refresh profile data. + +For route-based localization, pass the route locale as the explicit `locale` candidate. Browser +language preferences are a fallback input, not a replacement for application routing policy. + +### Node and SSR + +The Node SDK is stateless. It does not store a current request locale between calls. Call +`resolveRequestLocale(reqOrAcceptLanguage)` for each request. + +That method accepts a raw `Accept-Language` header or a request-like object. It parses quality +weights, ignores invalid candidates, and returns: + +```ts +const { contentfulLocale, eventLocale } = optimization.resolveRequestLocale(req) +``` + +Use the returned values for different purposes: + +| Value | Use | +| ------------------ | ------------------------------------------------------------------------------------ | +| `contentfulLocale` | CDA entry fetches and the per-call Experience API `{ locale }` option, when present. | +| `eventLocale` | Event payload context, for example `page({ locale: eventLocale, ... })`. | + +When `contentfulLocales` is not configured, `contentfulLocale` is absent. Omit CDA and Experience +API locale options intentionally and let those APIs use their defaults. Do not substitute +`eventLocale` as a CDA locale unless your application has separately verified that it is a +configured Contentful locale. + +Server-rendered pages must keep the request-scoped locale with the request-scoped profile and +optimization data. Cache raw Contentful payloads by locale if needed; don't cache a profile-resolved +entry variant as a global response for another locale or visitor. + +### React Native + +The React Native SDK is stateful and uses `Intl.DateTimeFormat().resolvedOptions().locale` as its +runtime locale candidate unless the application provides an explicit `locale`. + +The resolved locale is exposed through `optimization.locale` and `optimization.states.locale`. +`withOptimizationLocale(contentfulClient)` works the same way as in the Web SDK for `contentful.js` +clients used by the React Native application layer. + +In provider-owned instances, changing the provider `locale` prop calls `setLocale(nextLocale)` after +initialization. Locale changes update SDK state, but the app must run its normal `screen()`, +`identify()`, and Contentful refetch flow when localized data needs to change. + +### iOS and Android + +The native SDKs use the shared JavaScript core through a native bridge. Swift and Kotlin expose the +same locale model as the JavaScript SDKs. + +| Runtime | Candidate source | Resolved locale surface | Contentful fetch responsibility | +| ------- | ---------------------------------------------------------------------- | ----------------------- | ---------------------------------------------- | +| iOS | `Locale.preferredLanguages`, unless `OptimizationConfig.locale` is set | `client.locale` | The app-owned CDA client uses `client.locale`. | +| Android | `LocaleList.getDefault()`, unless `OptimizationConfig.locale` is set | `client.locale` | The app-owned CDA client uses `client.locale`. | + +Native SDKs do not fetch Contentful entries for the app layer. Use `client.locale` in your CDA +request code before passing entries to `OptimizedEntry` or `personalizeEntry(...)`. + +Runtime language changes use `OptimizationClient.setLocale(...)`. Like the JavaScript SDKs, this +updates SDK locale state and the default Experience API locale when no explicit API override exists. +It does not fetch new Contentful entries for the application. + +## Experience API localization + +The Experience API has its own `locale` query parameter. In the Optimization SDK Suite, that locale +is not a CDA locale switch. It can localize Experience API response values, including profile +location fields used by merge tags such as `location.city` and `location.country`. + +Stateful SDKs choose the Experience API request locale this way: + +1. Use explicit `api.locale` when configured. +2. Otherwise, use the SDK-resolved Contentful locale when present. +3. Otherwise, omit the locale query parameter and let the Experience API use its default. + +Stateless Node code passes the Experience API locale per request: + +```ts +const { contentfulLocale, eventLocale } = optimization.resolveRequestLocale(req) +const requestOptions = contentfulLocale ? { locale: contentfulLocale } : undefined + +const data = await optimization.page( + { + locale: eventLocale, + profile: { id: profileId }, + properties: { path: req.path }, + }, + requestOptions, +) +``` + +Use the same resolved Contentful locale for CDA and Experience API requests when merge tags must +render profile values in the same language as the entry. Configure `api.locale` only when the +Experience API response language must intentionally differ from the Contentful content language. + +Event context locale is separate. It describes the event context for analytics and audience rules, +but it does not choose the Contentful entry locale and does not override the Experience API query +parameter. + +## Entry resolution and localized Contentful content + +The entry resolver expects one localized Contentful payload. Fetch the baseline entry with the +resolved Contentful locale and enough include depth for linked optimization and variant entries: + +```ts +const contentful = optimization.withOptimizationLocale(contentfulClient) + +const baselineEntry = await contentful.getEntry(entryId, { + include: 10, +}) +``` + +The resolver works with direct field values such as: + +```ts +entry.fields.nt_experiences +optimizationEntry.fields.nt_variants +``` + +It does not read locale-keyed values such as: + +```ts +entry.fields.nt_experiences['en-US'] +optimizationEntry.fields.nt_variants['en-US'] +``` + +Contentful locale fallback can affect the fields the resolver sees. If a localized `nt_experiences` +field is empty and falls back to another configured locale, the SDK resolves against that delivered +fallback value. If Contentful returns no usable optimization links for the requested locale, SDK +entry resolution returns the baseline entry. + +## Application responsibilities + +The SDKs deliberately leave several locale decisions to the application: + +- Choose the application route or UI locale. +- Decide when to use browser or device preferences and when to use an app-selected locale. +- Keep `contentfulLocales` aligned with the locales configured in the Contentful environment. +- Fetch Contentful entries and assets with the resolved Contentful locale. +- Refresh Contentful data and Experience API profile data after a runtime locale change. +- Decide cache keys for Contentful payloads, Experience API responses, and rendered pages. +- Decide whether `api.locale` must intentionally differ from the Contentful locale. + +This separation keeps SDK behavior predictable across web, server, React Native, iOS, and Android +runtimes without requiring the SDKs to own application routing or Contentful client setup. + +## Caveats and gotchas + +- **All-locale payloads don't resolve** - `contentful.js` `withAllLocales`, raw CDA `locale=*`, and + Sync API all-locale payloads return locale-keyed field maps. Use one concrete CDA locale for + entries passed to Optimization SDK entry resolution. +- **`contentfulLocales` is configuration, not discovery** - The SDK does not fetch the space locale + list. Update SDK configuration when Contentful locale settings change. +- **`supported` order affects broad fallback** - Exact configured matches win first, but language + fallback chooses the first matching configured locale by `supported` order. +- **Runtime locale is only a candidate** - Browser, device, and `Accept-Language` values can be + unsupported by Contentful. The SDK maps them to configured codes when `contentfulLocales` exists. +- **Explicit locale without `contentfulLocales` is trusted** - The SDK validates syntax but cannot + know whether the Contentful space supports that locale. +- **`setLocale()` does not fetch** - Locale changes update SDK state and default Experience API + locale. The app must refetch Contentful content and call Experience methods again when rendered + data needs to change. +- **`api.locale` is not the CDA locale** - It controls the Experience API request locale. If it + differs from the Contentful locale, merge tag profile values can render in a different language + than the entry content. +- **`eventLocale` is event data** - In Node and SSR, use `contentfulLocale` for CDA and Experience + API request options. Use `eventLocale` only in event payload context unless the application has + validated it separately. +- **Caller-supplied CDA locale wins** - `withOptimizationLocale()` does not overwrite a caller's + explicit `locale` query value. It validates that value and injects the SDK locale only when the + query omits `locale`. +- **Contentful fallback can hide missing localized optimization fields** - A localized entry can + still resolve if Contentful falls back `nt_experiences` or variant fields to another locale. + Verify fallback rules when personalization links differ by market. + +## Related documentation + +- [Entry personalization and variant resolution](./entry-personalization-and-variant-resolution.md) + explains how single-locale Contentful entries are resolved to selected variants. +- [Profile synchronization between client and server](./profile-synchronization-between-client-and-server.md) + explains how browser and server runtimes share profile and request context. +- [Interaction tracking in Node and stateless environments](./interaction-tracking-in-node-and-stateless-environments.md) + explains how event context locale works in server-side tracking. +- [Contentful localization strategies](https://www.contentful.com/help/localization/field-and-entry-localization/) + explains Contentful localization models and fallback behavior. +- [Manage locales in Contentful](https://www.contentful.com/help/localization/manage-locales/) + explains locale codes, fallback locales, and API response settings. +- [Content Delivery API localization](https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/localization) + explains the CDA `locale` query parameter and `locale=*` response shape. +- [`contentful.js` client chain modifiers](https://github.com/contentful/contentful.js#client-chain-modifiers) + explain single-locale and all-locale client behavior. +- [Experience API](https://www.contentful.com/developers/docs/personalization/experience-api/) + documents the Experience API profile endpoints and request `locale` option. diff --git a/documentation/concepts/profile-synchronization-between-client-and-server.md b/documentation/concepts/profile-synchronization-between-client-and-server.md index ca99b894..66d90915 100644 --- a/documentation/concepts/profile-synchronization-between-client-and-server.md +++ b/documentation/concepts/profile-synchronization-between-client-and-server.md @@ -140,15 +140,24 @@ pass request-scoped inputs into every SDK method call. In Node, Experience methods use `payload.profile?.id` as the profile selector: ```ts +const { contentfulLocale, eventLocale } = optimization.resolveRequestLocale(req) +const requestOptions = contentfulLocale ? { locale: contentfulLocale } : undefined +const profileId = req.cookies[ANONYMOUS_ID_COOKIE] + const optimizationData = await optimization.page( { - profile: { id: req.cookies[ANONYMOUS_ID_COOKIE] }, + locale: eventLocale, + profile: profileId ? { id: profileId } : undefined, properties: { path: req.path }, }, - { locale: req.acceptsLanguages()[0] ?? 'en-US' }, + requestOptions, ) ``` +For the difference between `contentfulLocale`, `eventLocale`, and the Experience API request +`locale`, see +[Locale handling in the Optimization SDK Suite](./locale-handling-in-the-optimization-sdk-suite.md). + The Node SDK passes that ID to the Experience API as `profileId`. If the ID is absent, the API can create a profile and return the new `profile.id`. @@ -288,8 +297,12 @@ the profile ID continues to select the Experience API profile being updated. On the server, pass the current anonymous profile ID when identifying a known user: ```ts +const { contentfulLocale, eventLocale } = optimization.resolveRequestLocale(req) +const requestOptions = contentfulLocale ? { locale: contentfulLocale } : undefined + const identifyResponse = await optimization.identify( { + locale: eventLocale, profile: { id: anonymousId }, userId, traits: { authenticated: true }, diff --git a/documentation/drafts/integrating-the-ios-sdk-fundamentals.md b/documentation/drafts/integrating-the-ios-sdk-fundamentals.md index 53647631..04852227 100644 --- a/documentation/drafts/integrating-the-ios-sdk-fundamentals.md +++ b/documentation/drafts/integrating-the-ios-sdk-fundamentals.md @@ -82,13 +82,18 @@ OptimizationConfig( environment: "master", experienceBaseUrl: nil, // optional override for the Experience API insightsBaseUrl: nil, // optional override for the Insights API + contentfulLocales: ContentfulLocales(default: "en-US", supported: ["en-US", "de-DE"]), + locale: "en-US", // app/content locale candidate used to resolve client.locale defaults: StorageDefaults(consent: true), debug: true // emits os.Logger output under com.contentful.optimization ) ``` Only `clientId` is required; `environment` defaults to `"master"`. Leave the base URLs as `nil` to -hit production endpoints. +hit production endpoints. When the same screen renders Contentful entries with MergeTags, configure +`contentfulLocales` from the Contentful space and set `locale` to the app/content locale candidate +that the SDK resolves to `client.locale`. Use `api.locale` only when an integration needs an +explicit Experience API locale override. `debug: true` enables structured logging to Xcode console and `Console.app` under the subsystem `com.contentful.optimization`. Leave it off in production. diff --git a/documentation/drafts/integrating-the-ios-sdk-in-a-swiftui-app.md b/documentation/drafts/integrating-the-ios-sdk-in-a-swiftui-app.md index eee33918..b4e2e595 100644 --- a/documentation/drafts/integrating-the-ios-sdk-in-a-swiftui-app.md +++ b/documentation/drafts/integrating-the-ios-sdk-in-a-swiftui-app.md @@ -89,6 +89,8 @@ struct MyApp: App { config: OptimizationConfig( clientId: "your-client-id", environment: "master", + contentfulLocales: ContentfulLocales(default: "en-US"), + locale: "en-US", defaults: StorageDefaults(consent: true), // demo: pre-grant debug: true ), @@ -411,6 +413,8 @@ struct SwiftUIDemoApp: App { config: OptimizationConfig( clientId: AppConfig.optimizationClientId, environment: AppConfig.optimizationEnvironment, + contentfulLocales: ContentfulLocales(default: "en-US"), + locale: "en-US", defaults: StorageDefaults(consent: true), debug: true ), diff --git a/documentation/drafts/integrating-the-ios-sdk-in-a-uikit-app.md b/documentation/drafts/integrating-the-ios-sdk-in-a-uikit-app.md index 33cee2e6..9404a6bc 100644 --- a/documentation/drafts/integrating-the-ios-sdk-in-a-uikit-app.md +++ b/documentation/drafts/integrating-the-ios-sdk-in-a-uikit-app.md @@ -109,6 +109,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { try? client.initialize(config: OptimizationConfig( clientId: AppConfig.optimizationClientId, environment: AppConfig.optimizationEnvironment, + contentfulLocales: ContentfulLocales(default: "en-US"), + locale: "en-US", defaults: StorageDefaults(consent: true), // demo pre-grant debug: true )) @@ -368,6 +370,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { try? client.initialize(config: OptimizationConfig( clientId: AppConfig.optimizationClientId, environment: AppConfig.optimizationEnvironment, + contentfulLocales: ContentfulLocales(default: "en-US"), + locale: "en-US", defaults: StorageDefaults(consent: true), debug: true )) diff --git a/documentation/guides/integrating-the-node-sdk-in-a-node-app.md b/documentation/guides/integrating-the-node-sdk-in-a-node-app.md index c33aa9f1..18fc0563 100644 --- a/documentation/guides/integrating-the-node-sdk-in-a-node-app.md +++ b/documentation/guides/integrating-the-node-sdk-in-a-node-app.md @@ -102,13 +102,26 @@ export const optimization = new ContentfulOptimization({ experienceBaseUrl: process.env.CONTENTFUL_EXPERIENCE_API_BASE_URL, insightsBaseUrl: process.env.CONTENTFUL_INSIGHTS_API_BASE_URL, }, + contentfulLocales: { + default: 'en-US', + supported: ['en-US', 'de-DE', 'fr-FR'], + }, logLevel: 'error', }) ``` Treat that SDK as a module-level singleton for the current Node process. Do not create a new -`ContentfulOptimization` instance per incoming request. Instead, compute request-scoped Experience -options per request and pass them as the final argument to stateless event methods. +`ContentfulOptimization` instance per incoming request. Pass request-scoped Experience API options +as the final argument to stateless event methods. + +Use `contentfulLocales.default` for single-locale apps, and add `contentfulLocales.supported` when +the app needs request locale matching across multiple Contentful locales. Copy those codes from +Contentful locale settings or the CMA locale list. The `contentfulLocale` returned by +`resolveRequestLocale(reqOrAcceptLanguage)`, when present, is the Contentful locale code to use for +CDA fetches and the Experience API request option. Use `eventLocale` separately in event context. + +For the full matching rules, configuration cases, and Experience API locale behavior, see +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md). Notes: @@ -127,10 +140,7 @@ The reference implementations do this by translating the Express request into ```ts import type { Request } from 'express' -import type { - CoreStatelessRequestOptions, - UniversalEventBuilderArgs, -} from '@contentful/optimization-node/core-sdk' +import type { UniversalEventBuilderArgs } from '@contentful/optimization-node/core-sdk' function toQueryValue(value: unknown): string | null { if (value === undefined || value === null) return null @@ -140,7 +150,7 @@ function toQueryValue(value: unknown): string | null { return JSON.stringify(value) } -function getRequestContext(req: Request): UniversalEventBuilderArgs { +function getRequestContext(req: Request, eventLocale: string): UniversalEventBuilderArgs { const url = new URL(`${req.protocol}://${req.get('host') ?? 'localhost'}${req.originalUrl}`) const query = Object.keys(req.query).reduce>((acc, key) => { @@ -154,7 +164,7 @@ function getRequestContext(req: Request): UniversalEventBuilderArgs { }, {}) return { - locale: req.acceptsLanguages()[0] ?? 'en-US', + locale: eventLocale, userAgent: req.get('user-agent') ?? 'node-server', page: { path: req.path, @@ -165,21 +175,15 @@ function getRequestContext(req: Request): UniversalEventBuilderArgs { }, } } - -function getExperienceRequestOptions(req: Request): CoreStatelessRequestOptions { - return { - locale: req.acceptsLanguages()[0] ?? 'en-US', - } -} ``` The exact page fields do not need to come from Express. The important part is that the app passes a stable, request-specific description of the current page or route. -`getRequestContext(req).locale` affects the event payload context. -`getExperienceRequestOptions(req).locale` affects the Experience API request itself. Those two -locale values are intentionally separate, even if your app derives them from the same request -header. +`getRequestContext(req, eventLocale).locale` affects the event payload context. The stateless +per-call `{ locale }` request option instead sets the Experience API `locale` query parameter. When +an SSR response renders Contentful entries that can contain MergeTags, use the resolved Contentful +locale for that request option so localized profile values match the entry language. ## 3. Handle consent in your application layer @@ -292,29 +296,23 @@ app.get('/', async (req, res) => { }) } - const requestContext = getRequestContext(req) - const requestOptions = getExperienceRequestOptions(req) + const { eventLocale } = optimization.resolveRequestLocale(req) + const requestContext = getRequestContext(req, eventLocale) const existingProfile = getProfileFromRequest(req) - const pageResponse: OptimizationData | undefined = await optimization.page( - { - ...requestContext, - profile: existingProfile, - }, - requestOptions, - ) + const pageResponse: OptimizationData | undefined = await optimization.page({ + ...requestContext, + profile: existingProfile, + }) const userId = getAuthenticatedUserId(req) const identifyResponse = userId - ? await optimization.identify( - { - ...requestContext, - profile: pageResponse?.profile ?? existingProfile, - userId, - traits: { authenticated: true }, - }, - requestOptions, - ) + ? await optimization.identify({ + ...requestContext, + profile: pageResponse?.profile ?? existingProfile, + userId, + traits: { authenticated: true }, + }) : undefined if (consented) { @@ -372,6 +370,7 @@ In the example below, replace `ArticleSkeleton` with the generated Contentful sk already uses. ```ts +import type { Request } from 'express' import type { Entry } from 'contentful' import * as contentful from 'contentful' @@ -383,26 +382,29 @@ const contentfulClient = contentful.createClient({ type ArticleEntry = Entry -async function getArticle(entryId: string): Promise { +async function getArticle(entryId: string, locale?: string): Promise { return await contentfulClient.getEntry(entryId, { include: 10, + ...(locale ? { locale } : {}), }) } app.get('/article/:entryId', async (req, res) => { const consented = hasOptimizationConsent(req) - const requestOptions = getExperienceRequestOptions(req) + const { contentfulLocale, eventLocale } = optimization.resolveRequestLocale(req) + const requestOptions = contentfulLocale ? { locale: contentfulLocale } : undefined const pageResponse = consented ? await optimization.page( { - ...getRequestContext(req), + ...getRequestContext(req, eventLocale), + locale: eventLocale, profile: getProfileFromRequest(req), }, requestOptions, ) : undefined - const article = await getArticle(req.params.entryId) + const article = await getArticle(req.params.entryId, contentfulLocale) const { entry: optimizedArticle, selectedOptimization } = optimization.resolveOptimizedEntry( article, @@ -424,11 +426,20 @@ app.get('/article/:entryId', async (req, res) => { This is the main server-side personalization loop: 1. Ask Optimization for the current profile's selected variants. -2. Fetch the baseline Contentful entry. +2. Fetch the baseline Contentful entry with `contentfulLocale` returned by `resolveRequestLocale()` + when configured, or intentionally omit the CDA locale option for no-config API default behavior. 3. Resolve the optimized entry variant before rendering. If your optimized entries contain linked entries or merge tags, fetch with an `include` depth that matches your content model. The SSR reference implementation uses `include: 10` for that reason. +All-locale CDA responses from `contentful.js` `withAllLocales` or raw CDA `locale=*` are not valid +input for `resolveOptimizedEntry()` because they contain locale-keyed fields. The resolver expects a +standard single-locale CDA entry shape where `fields.nt_experiences` and `fields.nt_variants` are +direct field values. See +[Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) +for the entry contract and +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md) +for the broader locale model. ## 7. Resolve merge tags and custom flags @@ -457,6 +468,11 @@ const html = documentToHtmlString(richTextField, { That is the pattern used in the SSR-only reference implementation. +If a merge tag references localized profile fields such as `location.city` or `location.country`, +its resolved value can change with the per-call `{ locale }` request option used to fetch that +profile. Use the same resolved Contentful locale for that option so `getMergeTagValue()` reads +localized profile values in the same language as the Contentful entry being rendered. + ### Custom flags Use `getFlag()` when the response includes Custom Flag changes: @@ -471,7 +487,7 @@ captured as an Insights event, call `trackFlagView()` explicitly: ```ts if (pageResponse?.profile) { await optimization.trackFlagView({ - ...getRequestContext(req), + ...getRequestContext(req, eventLocale), componentId: 'new-navigation', profile: pageResponse.profile, }) @@ -498,20 +514,15 @@ profile. Example custom event: ```ts -const requestOptions = getExperienceRequestOptions(req) - -await optimization.track( - { - ...getRequestContext(req), - profile: pageResponse?.profile, - event: 'quote_requested', - properties: { - plan: 'enterprise', - source: 'pricing-page', - }, +await optimization.track({ + ...getRequestContext(req, eventLocale), + profile: pageResponse?.profile, + event: 'quote_requested', + properties: { + plan: 'enterprise', + source: 'pricing-page', }, - requestOptions, -) +}) ``` Example rendered-entry view event: @@ -519,9 +530,8 @@ Example rendered-entry view event: ```ts import { randomUUID } from 'node:crypto' -const requestOptions = getExperienceRequestOptions(req) const viewPayload = { - ...getRequestContext(req), + ...getRequestContext(req, eventLocale), componentId: optimizedArticle.sys.id, experienceId: selectedOptimization?.experienceId, variantIndex: selectedOptimization?.variantIndex, @@ -530,9 +540,9 @@ const viewPayload = { } if (selectedOptimization?.sticky) { - await optimization.trackView({ ...viewPayload, sticky: true }, requestOptions) + await optimization.trackView({ ...viewPayload, sticky: true }) } else if (pageResponse?.profile) { - await optimization.trackView({ ...viewPayload, profile: pageResponse.profile }, requestOptions) + await optimization.trackView({ ...viewPayload, profile: pageResponse.profile }) } ``` diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md index 4cd427e9..d1f4d103 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md @@ -118,6 +118,10 @@ const sdk = new ContentfulOptimization({ experienceBaseUrl: process.env.CONTENTFUL_EXPERIENCE_API_BASE_URL, insightsBaseUrl: process.env.CONTENTFUL_INSIGHTS_API_BASE_URL, }, + contentfulLocales: { + default: 'en-US', + supported: ['en-US', 'de-DE', 'fr-FR'], + }, app: { name: 'my-next-app', version: '1.0.0', @@ -125,23 +129,33 @@ const sdk = new ContentfulOptimization({ logLevel: 'error', }) -const getOptimizationData = cache(async () => { +const getOptimizationData = cache(async (eventLocale: string, contentfulLocale?: string) => { const cookieStore = await cookies() const headerStore = await headers() const anonymousId = cookieStore.get(ANONYMOUS_ID_COOKIE)?.value const profile = anonymousId ? { id: anonymousId } : undefined + const requestOptions = contentfulLocale ? { locale: contentfulLocale } : undefined - return sdk.page({ - locale: headerStore.get('accept-language')?.split(',')[0] ?? 'en-US', - userAgent: headerStore.get('user-agent') ?? 'next-js-server', - profile, - }) + return sdk.page( + { + locale: eventLocale, + userAgent: headerStore.get('user-agent') ?? 'next-js-server', + profile, + }, + requestOptions, + ) }) export { sdk, getOptimizationData } ``` +The per-call `{ locale }` request option is sent as the Experience API `locale` query parameter. +Call `sdk.resolveRequestLocale(reqOrAcceptLanguage)` for each request, use `eventLocale` in event +context, and use `contentfulLocale` for the CDA fetch and the Experience API request option when it +is present. Merge tags that reference localized profile fields such as `location.city` and +`location.country` then resolve in a language consistent with the rendered content. + `cache()` is a React Server Component primitive that deduplicates calls within a single render pass. Both the layout and the page can call `getOptimizationData()` and only one HTTP request to the Experience API is made per server request. This is more important in the hybrid pattern than in the @@ -153,7 +167,6 @@ This step is identical to the SSR-primary pattern. Middleware runs before every the anonymous ID cookie is set before any Server Component reads it: ```ts -// middleware.ts import { sdk } from '@/lib/optimization-server' import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' import { type NextRequest, NextResponse } from 'next/server' @@ -161,20 +174,25 @@ import { type NextRequest, NextResponse } from 'next/server' export async function middleware(request: NextRequest): Promise { const anonymousId = request.cookies.get(ANONYMOUS_ID_COOKIE)?.value const profile = anonymousId ? { id: anonymousId } : undefined + const { contentfulLocale, eventLocale } = sdk.resolveRequestLocale(request) + const requestOptions = contentfulLocale ? { locale: contentfulLocale } : undefined const url = new URL(request.url) - const data = await sdk.page({ - locale: request.headers.get('accept-language')?.split(',')[0] ?? 'en-US', - userAgent: request.headers.get('user-agent') ?? 'next-js-server', - page: { - path: url.pathname, - query: Object.fromEntries(url.searchParams), - referrer: request.headers.get('referer') ?? '', - search: url.search, - url: request.url, + const data = await sdk.page( + { + locale: eventLocale, + userAgent: request.headers.get('user-agent') ?? 'next-js-server', + page: { + path: url.pathname, + query: Object.fromEntries(url.searchParams), + referrer: request.headers.get('referer') ?? '', + search: url.search, + url: request.url, + }, + profile, }, - profile, - }) + requestOptions, + ) const response = NextResponse.next() @@ -204,11 +222,17 @@ In Server Component pages, resolve entries the same way as the SSR-primary patte ```tsx // app/page.tsx import { sdk, getOptimizationData } from '@/lib/optimization-server' +import { headers } from 'next/headers' export default async function Home() { + const headerStore = await headers() + const { contentfulLocale, eventLocale } = sdk.resolveRequestLocale( + headerStore.get('accept-language'), + ) + const contentfulOptions = contentfulLocale ? { locale: contentfulLocale } : undefined const [baselineEntries, optimizationData] = await Promise.all([ - fetchEntriesFromContentful(), - getOptimizationData(), + fetchEntriesFromContentful(contentfulOptions), + getOptimizationData(eventLocale, contentfulLocale), ]) const resolvedEntries = baselineEntries.map((entry) => { @@ -231,6 +255,18 @@ export default async function Home() { entries and the server-resolved entries. It renders the server-resolved entries immediately, then switches to the client-resolved versions once the React Web SDK is ready. +Fetch server-rendered baseline entries with the `contentfulLocale` returned by +`resolveRequestLocale()` when configured. Configure `contentfulLocales.default` for single-locale +apps, and add `contentfulLocales.supported` for localized apps that need request locale matching. +Use the same resolved Contentful locale as the stateless per-call `{ locale }` request option when +it is present. `contentful.js` `withAllLocales` and raw CDA `locale=*` return locale-keyed maps, +while the resolver expects `fields.nt_experiences` and `fields.nt_variants` to be direct +single-locale field values. See +[Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) +for the entry contract and +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md) +for the broader locale model. + ### Add data attributes for client-side tracking Include `data-ctfl-entry-id` and `data-ctfl-baseline-id` on the wrapper element so the React Web SDK @@ -282,6 +318,7 @@ const NextAppAutoPageTracker = dynamic( interface ClientProviderWrapperProps { children: ReactNode + contentfulLocale?: string defaults?: { profile?: Profile selectedOptimizations?: SelectedOptimizationArray @@ -289,11 +326,20 @@ interface ClientProviderWrapperProps { } } -export function ClientProviderWrapper({ children, defaults }: ClientProviderWrapperProps) { +export function ClientProviderWrapper({ + children, + contentfulLocale, + defaults, +}: ClientProviderWrapperProps) { return ( + { const anonymousId = request.cookies.get(ANONYMOUS_ID_COOKIE)?.value const profile = anonymousId ? { id: anonymousId } : undefined + const { contentfulLocale, eventLocale } = sdk.resolveRequestLocale(request) + const requestOptions = contentfulLocale ? { locale: contentfulLocale } : undefined const url = new URL(request.url) - const data = await sdk.page({ - locale: request.headers.get('accept-language')?.split(',')[0] ?? 'en-US', - userAgent: request.headers.get('user-agent') ?? 'next-js-server', - page: { - path: url.pathname, - query: Object.fromEntries(url.searchParams), - referrer: request.headers.get('referer') ?? '', - search: url.search, - url: request.url, + const data = await sdk.page( + { + locale: eventLocale, + userAgent: request.headers.get('user-agent') ?? 'next-js-server', + page: { + path: url.pathname, + query: Object.fromEntries(url.searchParams), + referrer: request.headers.get('referer') ?? '', + search: url.search, + url: request.url, + }, + profile, }, - profile, - }) + requestOptions, + ) const response = NextResponse.next() @@ -190,7 +204,6 @@ Inside a Server Component, read the cookie, call `sdk.page()` in parallel with y fetch, then resolve each entry: ```tsx -// app/page.tsx import { sdk } from '@/lib/optimization-server' import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' import { cookies, headers } from 'next/headers' @@ -198,17 +211,25 @@ import { cookies, headers } from 'next/headers' export default async function Home() { const cookieStore = await cookies() const headerStore = await headers() + const { contentfulLocale, eventLocale } = sdk.resolveRequestLocale( + headerStore.get('accept-language'), + ) + const contentfulOptions = contentfulLocale ? { locale: contentfulLocale } : undefined + const requestOptions = contentfulLocale ? { locale: contentfulLocale } : undefined const anonymousId = cookieStore.get(ANONYMOUS_ID_COOKIE)?.value const profile = anonymousId ? { id: anonymousId } : undefined const [baselineEntries, optimizationData] = await Promise.all([ - fetchEntriesFromContentful(), - sdk.page({ - locale: headerStore.get('accept-language')?.split(',')[0] ?? 'en-US', - userAgent: headerStore.get('user-agent') ?? 'next-js-server', - profile, - }), + fetchEntriesFromContentful(contentfulOptions), + sdk.page( + { + locale: eventLocale, + userAgent: headerStore.get('user-agent') ?? 'next-js-server', + profile, + }, + requestOptions, + ), ]) const resolvedEntries = baselineEntries.map((entry) => { @@ -233,6 +254,17 @@ Fetch Contentful entries with `include: 10` so that linked optimization data (su `nt_experiences`) is included in the response. The Node SDK needs those nested fields to evaluate variants. +Also fetch entries with one CDA locale. Configure `contentfulLocales.default` for single-locale +apps, and add `contentfulLocales.supported` for localized apps that need request locale matching. +Use the `contentfulLocale` returned by `resolveRequestLocale()` for CDA requests when it is present. +All-locale responses from `contentful.js` `withAllLocales` or raw CDA `locale=*` contain +locale-keyed maps, while the resolver expects `fields.nt_experiences` and `fields.nt_variants` to be +direct single-locale field values. See +[Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) +for the entry contract and +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md) +for the broader locale model. + `resolveOptimizedEntry` is synchronous. It picks the correct variant from the resolved entry based on `selectedOptimizations` returned by `sdk.page()`. If no optimization applies, it returns the baseline entry unchanged. @@ -351,11 +383,22 @@ const NextAppAutoPageTracker = dynamic( { ssr: false }, ) -export function ClientProviderWrapper({ children }: { children: ReactNode }) { +export function ClientProviderWrapper({ + children, + contentfulLocale, +}: { + children: ReactNode + contentfulLocale?: string +}) { return ( @@ -371,12 +414,24 @@ export function ClientProviderWrapper({ children }: { children: ReactNode }) { ```tsx // app/layout.tsx import { ClientProviderWrapper } from '@/components/ClientProviderWrapper' +import { sdk } from '@/lib/optimization-server' +import { headers } from 'next/headers' + +function getHtmlLang(locale: string | undefined): string { + return locale?.split('-')[0] ?? 'en' +} + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const headerStore = await headers() + const { contentfulLocale } = sdk.resolveRequestLocale(headerStore.get('accept-language')) + const htmlLang = getHtmlLang(contentfulLocale) -export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + - {children} + + {children} + ) diff --git a/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md b/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md index 49606a0e..7db862c7 100644 --- a/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md +++ b/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md @@ -139,6 +139,11 @@ panel settings, and navigation integration: ` | -| `trackEntryInteraction` | `{ views?, taps? }` | No | `{ views: true, taps: false }` | Default interaction tracking for `` | -| `onStatesReady` | `(states) => cleanup` | No | `undefined` | Attach app-level state subscribers when SDK state is ready | +| Prop | Type | Required | Default | Description | +| ----------------------- | ---------------------------- | -------- | --------------------------------------- | --------------------------------------------------------------------- | +| `clientId` | `string` | Yes | N/A | Your Contentful Optimization client identifier | +| `environment` | `string` | No | `'main'` | Optimization environment to read from | +| `defaults` | `{ consent?: boolean, ... }` | No | `undefined` | Initial values applied at startup (e.g. `consent: true`) | +| `logLevel` | `LogLevels` | No | `'error'` | Minimum console log level | +| `previewPanel` | `PreviewPanelConfig` | No | `undefined` | Enables the in-app preview panel; see [Preview Panel](#preview-panel) | +| `liveUpdates` | `boolean` | No | `false` | Global live-updates default for `` | +| `locale` | `string` | No | `undefined` unless locale config is set | Initial app/content locale candidate | +| `trackEntryInteraction` | `{ views?, taps? }` | No | `{ views: true, taps: false }` | Default interaction tracking for `` | +| `onStatesReady` | `(states) => cleanup` | No | `undefined` | Attach app-level state subscribers when SDK state is ready | The full configuration reference (API endpoints, fetch retries, queue policy, event-builder overrides) is documented in the [React Native SDK README](../../packages/react-native-sdk/README.md#common-configuration). +Use `contentfulLocales` when the same app screen renders localized Contentful entries. Configure +`contentfulLocales.default` for single-locale apps, and add `contentfulLocales.supported` when the +app needs device locale matching across multiple Contentful locales. Copy those codes from +Contentful locale settings or the CMA locale list. The `locale` prop supplies the initial +app/content locale. The resolved `optimization.locale`, when present, is the Contentful locale code +used by `withOptimizationLocale()` and by default Experience API localization unless you provide an +explicit `api.locale` override. + +Changing the provider `locale` prop after initialization calls `optimization.setLocale(nextLocale)` +and updates `optimization.locale` plus `optimization.states.locale`. It does not fetch content or +refresh profile state; call `screen`, `identify`, or CDA methods again when your app needs localized +data refreshed. For the full matching rules, configuration cases, and Experience API locale +behavior, see +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md). + ### Access the SDK instance with hooks Inside the provider tree, use `useOptimization()` to interact with the SDK directly: @@ -339,15 +360,32 @@ interactions on Contentful entries. It: ### Fetch the entry with `include: 10` For variant data to resolve, the entry must be fetched with linked optimization references included. -Use `include: 10` on Contentful's Delivery API call: +Use `include: 10` and one CDA locale on Contentful's Delivery API call: ```tsx -const cta = await contentfulClient.getEntry(CTA_ENTRY_ID, { include: 10 }) +const optimization = useOptimization() +const contentful = optimization.withOptimizationLocale(contentfulClient) + +const cta = await contentful.getEntry(CTA_ENTRY_ID, { + include: 10, +}) ``` The [React Native reference implementation](../../implementations/react-native-sdk/README.md) centralizes this Contentful fetching pattern in its application helper layer. +Configure `contentfulLocales.default` for single-locale apps, and add `contentfulLocales.supported` +for localized apps that need device locale matching. The recommended `withOptimizationLocale()` +helper lets Contentful entry fetches use the live resolved locale by default; data layers that need +direct control can pass `optimization.locale` explicitly. Use `optimization.setLocale(nextLocale)` +when the app changes language after initialization. `contentful.js` `withAllLocales` and raw CDA +`locale=*` return locale-keyed fields; the SDK resolver expects a standard single-locale CDA entry +shape where `fields.nt_experiences` and `fields.nt_variants` are direct field values. See +[Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) +for the entry contract and +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md) +for the broader locale model. + ### Render the variant with a render prop Pass the baseline entry to `` and render with a render prop that receives the diff --git a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md index 33b96fb6..747d8c52 100644 --- a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md +++ b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md @@ -122,16 +122,18 @@ children can mount before the first visible paint. Available configuration props: -| Prop | Type | Required | Default | Description | -| ----------------------- | ------------------------------ | -------- | ----------------------------------------------- | ---------------------------------------------------------- | -| `clientId` | `string` | Yes | N/A | Your Contentful Optimization client identifier | -| `environment` | `string` | No | `'main'` | Contentful environment | -| `api` | `CoreApiConfig` | No | See below | Experience API and Insights API configuration | -| `app` | `App` | No | — | Application metadata attached to events | -| `trackEntryInteraction` | `TrackEntryInteractionOptions` | No | `{ views: true, clicks: false, hovers: false }` | Automatic entry interaction tracking options | -| `logLevel` | `LogLevels` | No | `'error'` | Minimum log level for console output | -| `liveUpdates` | `boolean` | No | `false` | Enable global live updates | -| `onStatesReady` | `(states) => cleanup` | No | — | Attach app-level state subscribers when SDK state is ready | +| Prop | Type | Required | Default | Description | +| ----------------------- | ------------------------------ | -------- | ----------------------------------------------- | ------------------------------------------------------------------- | +| `clientId` | `string` | Yes | N/A | Your Contentful Optimization client identifier | +| `environment` | `string` | No | `'main'` | Contentful environment | +| `api` | `CoreApiConfig` | No | See below | Experience API and Insights API configuration | +| `app` | `App` | No | — | Application metadata attached to events | +| `contentfulLocales` | `ContentfulLocales` | No | — | Contentful locale codes used for SDK-assisted CDA locale resolution | +| `locale` | `string` | No | `undefined` unless `contentfulLocales` is set | Initial app/content locale candidate | +| `trackEntryInteraction` | `TrackEntryInteractionOptions` | No | `{ views: true, clicks: false, hovers: false }` | Automatic entry interaction tracking options | +| `logLevel` | `LogLevels` | No | `'error'` | Minimum log level for console output | +| `liveUpdates` | `boolean` | No | `false` | Enable global live updates | +| `onStatesReady` | `(states) => cleanup` | No | — | Attach app-level state subscribers when SDK state is ready | A more complete initialization with explicit API endpoints and interaction tracking: @@ -143,6 +145,11 @@ A more complete initialization with explicit API endpoints and interaction track insightsBaseUrl: 'https://ingest.insights.ninetailed.co/', experienceBaseUrl: 'https://experience.ninetailed.co/', }} + contentfulLocales={{ + default: 'en-US', + supported: ['en-US', 'de-DE', 'fr-FR'], + }} + locale="en-US" trackEntryInteraction={{ views: true, clicks: true, hovers: true }} logLevel="warn" app={{ @@ -155,6 +162,20 @@ A more complete initialization with explicit API endpoints and interaction track ``` +Use `contentfulLocales.default` for single-locale apps, and add `contentfulLocales.supported` when +the app needs browser locale matching across multiple Contentful locales. Copy those codes from +Contentful locale settings or the CMA locale list. The `locale` prop supplies the initial +app/content locale. The resolved `optimization.locale`, when present, is the Contentful locale code +used by `withOptimizationLocale()` and by default Experience API localization unless you provide an +explicit `api.locale` override. + +Changing the provider `locale` prop after initialization calls `optimization.setLocale(nextLocale)` +and updates `optimization.locale` plus `optimization.states.locale`. It does not fetch content or +refresh profile state; call `page`, `identify`, or CDA methods again when your app needs localized +data refreshed. For the full matching rules, configuration cases, and Experience API locale +behavior, see +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md). + ### Access the SDK instance with hooks Inside the provider tree, use hooks to interact with the SDK: @@ -359,8 +380,17 @@ optimization state and renders the result. ### Basic usage -Pass a baseline entry fetched from Contentful (with `include: 10` to resolve linked optimization -data) and a render prop that receives the resolved entry: +Pass a baseline entry fetched from Contentful (with `include: 10` and a single CDA locale to resolve +linked optimization data) and a render prop that receives the resolved entry: + +```tsx +const optimization = useOptimization() +const contentful = optimization.withOptimizationLocale(contentfulClient) + +const baselineEntry = await contentful.getEntry(entryId, { + include: 10, +}) +``` ```tsx import { OptimizedEntry } from '@contentful/optimization-react-web' @@ -379,6 +409,18 @@ function HeroSection({ baselineEntry }) { } ``` +For localized apps, configure `contentfulLocales` on `OptimizationRoot` with the locale codes from +your Contentful space, then use the recommended `withOptimizationLocale()` helper or pass +`optimization.locale` explicitly when fetching entries. The wrapper injects `optimization.locale` +into `getEntry()` and `getEntries()` calls when the caller does not provide a locale and the SDK has +resolved one. `contentful.js` `withAllLocales` and raw CDA `locale=*` return locale-keyed fields; +the SDK resolver works with the standard single-locale CDA entry shape where `fields.nt_experiences` +and `fields.nt_variants` are direct field values. See +[Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) +for the entry contract and +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md) +for the broader locale model. + The component automatically determines readiness: - entries with optimization references (`nt_experiences`) render when `canOptimize` is `true` @@ -856,7 +898,8 @@ pnpm add @contentful/optimization-web-preview-panel ``` Import and attach it after the SDK is initialized. The preview panel requires both a Contentful -client (for fetching audience and optimization entries) and the SDK instance: +client (for fetching audience and optimization entries) and the SDK instance. Pass the unmodified +Contentful Delivery API client. ```tsx import { useOptimizationContext } from '@contentful/optimization-react-web' @@ -878,8 +921,7 @@ function PreviewPanelLoader() { void import('@contentful/optimization-web-preview-panel').then( ({ default: attachOptimizationPreviewPanel }) => { attachOptimizationPreviewPanel({ - contentful: - contentfulClient.withAllLocales.withoutLinkResolution.withoutUnresolvableLinks, + contentful: contentfulClient, optimization: sdk, nonce: undefined, }).catch((error) => { @@ -907,7 +949,7 @@ In environments with strict CSP policies, pass a nonce: ```tsx attachOptimizationPreviewPanel({ - contentful: contentfulClient.withAllLocales.withoutLinkResolution.withoutUnresolvableLinks, + contentful: contentfulClient, optimization: sdk, nonce: 'your-csp-nonce', }) diff --git a/documentation/guides/integrating-the-web-sdk-in-a-web-app.md b/documentation/guides/integrating-the-web-sdk-in-a-web-app.md index f35a80e2..8185a090 100644 --- a/documentation/guides/integrating-the-web-sdk-in-a-web-app.md +++ b/documentation/guides/integrating-the-web-sdk-in-a-web-app.md @@ -102,7 +102,7 @@ const APP_CONFIG = { insightsBaseUrl: 'https://ingest.insights.ninetailed.co/', } as const -export const contentfulClient = contentful.createClient({ +const rawContentfulClient = contentful.createClient({ accessToken: APP_CONFIG.contentfulAccessToken, environment: APP_CONFIG.contentfulEnvironment, space: APP_CONFIG.contentfulSpaceId, @@ -111,6 +111,7 @@ export const contentfulClient = contentful.createClient({ export const optimization = new ContentfulOptimization({ clientId: APP_CONFIG.optimizationClientId, environment: APP_CONFIG.optimizationEnvironment, + locale: 'en-US', app: { name: 'my-web-app', version: '1.0.0', @@ -119,11 +120,26 @@ export const optimization = new ContentfulOptimization({ experienceBaseUrl: APP_CONFIG.experienceBaseUrl, insightsBaseUrl: APP_CONFIG.insightsBaseUrl, }, + contentfulLocales: { + default: 'en-US', + supported: ['en-US', 'de-DE', 'fr-FR'], + }, autoTrackEntryInteraction: { views: true, clicks: true, hovers: true }, logLevel: 'warn', }) + +export const contentfulClient = optimization.withOptimizationLocale(rawContentfulClient) ``` +Use `contentfulLocales.default` for single-locale apps, and add `contentfulLocales.supported` when +the app needs browser locale matching across multiple Contentful locales. Copy those codes from +Contentful locale settings or the CMA locale list. The resolved `optimization.locale`, when present, +is the Contentful locale code used by `withOptimizationLocale()` and by default Experience API +localization unless you provide an explicit `api.locale` override. + +For the full matching rules, configuration cases, and Experience API locale behavior, see +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md). + Treat that SDK as a singleton. Do not create a new `ContentfulOptimization` instance per component, per route render, or per click handler. In browser runtimes, the constructor also attaches the instance to `window.contentfulOptimization` and throws if another instance is already active. @@ -277,9 +293,23 @@ names your application already uses. This is the main browser-side personalization loop: 1. Ask Optimization for the current profile's selected variants by calling `page()` or `identify()`. -2. Fetch the baseline Contentful entry. +2. Fetch the baseline Contentful entry with one CDA locale. 3. Resolve the optimized entry variant before rendering it into the DOM. +Configure `contentfulLocales.default` once for single-locale apps, and add +`contentfulLocales.supported` for localized apps that need browser locale matching. If the app +locale changes after initialization, call `optimization.setLocale(nextLocale)` and then run the +app's normal profile and content refresh flow. The recommended `withOptimizationLocale()` helper +lets Contentful entry fetches use that same resolved locale by default; data layers that need direct +control can pass `optimization.locale` explicitly. Fetching entries with `contentful.js` +`withAllLocales` or raw CDA `locale=*` returns locale-keyed fields, but the SDK resolver expects a +standard single-locale CDA entry where `fields.nt_experiences` and `fields.nt_variants` are direct +field values. See +[Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) +for the entry contract and +[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md) +for the broader locale model. + In a stateful browser integration, the usual rerender trigger is `states.selectedOptimizations`: ```ts @@ -340,6 +370,11 @@ const html = documentToHtmlString(article.fields.body, { That is the same basic pattern used in the reference implementations, even when the final Rich Text renderer differs. +If a merge tag references localized profile fields such as `location.city` or `location.country`, +its resolved value follows the localized profile values returned by the Experience API. In this +guide, `contentfulLocales` and the current SDK locale let the SDK keep the default Experience API +locale aligned with the CDA entry fetch locale. + ### Custom flags Use `getFlag()` when the current optimization response contains Custom Flag changes: diff --git a/eslint.config.ts b/eslint.config.ts index 50358827..91b5e3c1 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -96,6 +96,8 @@ export default defineConfig( '**/src/**/*.spec.tsx', '**/test/**/*.ts', '**/test/**/*.tsx', + '**/__tests__/**/*.ts', + '**/__tests__/**/*.tsx', '**/e2e/**/*.ts', '**/e2e/**/*.tsx', ], @@ -110,6 +112,7 @@ export default defineConfig( '@typescript-eslint/prefer-destructuring': 'off', '@typescript-eslint/unbound-method': 'off', complexity: 'off', + 'max-lines': 'off', 'max-nested-callbacks': 'off', 'promise/avoid-new': 'off', }, diff --git a/implementations/AGENTS.md b/implementations/AGENTS.md index 14f1b76b..12c7a5e8 100644 --- a/implementations/AGENTS.md +++ b/implementations/AGENTS.md @@ -14,6 +14,12 @@ owns implementation boundaries, implementation README structure, and local valid relevant package under `packages/`. - Keep implementation code minimal, example-oriented, and aligned with the public SDK surface it is demonstrating. +- When a reference implementation fetches CDA entries for SDK entry resolution, keep those fetches + single-locale. Do not use `withAllLocales` or `locale=*`; configure SDK `contentfulLocales` and + use the resolved SDK `locale`, `withOptimizationLocale()`, native `client.locale`, or Node + `resolveRequestLocale()` result. When the implementation calls the Experience API for content that + can render MergeTags, use that same resolved Contentful locale through SDK-assisted stateful + config or the stateless per-call `{ locale }` request option. - Prefer root wrapper commands such as `pnpm implementation:run -- ``` @@ -113,6 +119,8 @@ the Insights API for event ingestion. | `environment` | No | `'main'` | Contentful environment identifier | | `api` | No | See API options below | Experience API and Insights API endpoint and request options | | `app` | No | `undefined` | Application metadata attached to outgoing event context | +| `contentfulLocales` | No | `undefined` | Contentful locale codes used for SDK-assisted CDA locale resolution | +| `locale` | No | `undefined` unless `contentfulLocales` is set | Initial app/content locale candidate used to resolve the Contentful locale | | `defaults` | No | `undefined` | Initial state, commonly including consent or profile values | | `allowedEventTypes` | No | `['identify', 'page']` | Event types allowed before consent is explicitly set | | `autoTrackEntryInteraction` | No | `{ views: false, clicks: false, hovers: false }` | Opt-in automatic tracking for entry views, clicks, and hovers | @@ -128,7 +136,7 @@ Common `api` options: | ------------------- | --------- | ------------------------------------------ | ----------------------------------------------------------------- | | `experienceBaseUrl` | No | `'https://experience.ninetailed.co/'` | Base URL for the Experience API | | `insightsBaseUrl` | No | `'https://ingest.insights.ninetailed.co/'` | Base URL for the Insights API | -| `locale` | No | `'en-US'` (in API) | Locale used for Experience API location labels | +| `locale` | No | API default | Locale query parameter for localized Experience API responses | | `enabledFeatures` | No | `['ip-enrichment', 'location']` | Experience API features to apply to each request | | `preflight` | No | `false` | Aggregate a new profile state without storing it | | `beaconHandler` | No | Built-in beacon integration | Custom handler for enqueueing Insights API batches when needed | @@ -138,6 +146,34 @@ Common `fetchOptions` are `fetchMethod`, `requestTimeout`, `retries`, `intervalT `onFailedAttempt`, and `onRequestTimeout`. Default retries intentionally apply only to HTTP `503` responses. +Use `contentfulLocales.default` for single-locale apps. For apps that match browser locale input to +multiple Contentful locales, keep `default` as the fallback and list the supported Contentful locale +codes: + +```ts +contentfulLocales: { + default: 'en-US', + supported: ['en-US', 'de-DE', 'fr-FR'], +} +``` + +Copy `contentfulLocales.default` and optional `contentfulLocales.supported` from Contentful locale +settings or the CMA locale list. The resolved `optimization.locale`, when present, is the configured +Contentful locale code to use for CDA fetches and default Experience API localization. `api.locale` +remains an explicit Experience API override. The recommended helper, +`withOptimizationLocale(contentfulClient)`, reads the live SDK locale and injects it into +`getEntry()` and `getEntries()` calls when a caller does not provide one; data layers that need +direct control can pass `optimization.locale` explicitly instead. See +[Locale handling in the Optimization SDK Suite](https://contentful.github.io/optimization/documents/Documentation.Concepts.Locale_handling_in_the_Optimization_SDK_Suite.html) +for the full locale model. + +Use `optimization.setLocale(nextLocale)` when the application locale changes. The method validates +the explicit input, resolves it to a configured Contentful locale code when `contentfulLocales` is +present, updates `optimization.locale` and `optimization.states.locale`, and changes the default +Experience API locale unless `api.locale` is configured. It does not fetch content or refresh +profile state; call `page()`, `identify()`, or CDA methods again when the app needs localized data +for the new locale. + For every option, callback payload, and exported type, use the generated [Web SDK reference](https://contentful.github.io/optimization/modules/_contentful_optimization-web.html). @@ -172,8 +208,21 @@ const resolvedEntry = optimization.resolveOptimizedEntry( ) ``` -Use `getMergeTagValue()` for Contentful Rich Text merge tags and `getFlag()` for Custom Flags. The -Web SDK is stateful, so reading a flag also emits flag-view tracking. +Fetch entries with one CDA locale in the app layer. For localized apps, configure +`contentfulLocales`, then use `optimization.withOptimizationLocale(contentfulClient)` or pass +`optimization.locale` explicitly before calling `getEntry()` or `getEntries()`. Without +`contentfulLocales` or an explicit top-level `locale`, the wrapper omits the CDA locale and lets +Contentful use the space default. Do not pass all-locale CDA responses from `withAllLocales` or +`locale=*`; the resolver expects direct single-locale field values. See +[Entry personalization and variant resolution](https://contentful.github.io/optimization/documents/Documentation.Concepts.Entry_personalization_and_variant_resolution.html#single-locale-cda-entry-contract) +for the entry contract and +[Locale handling in the Optimization SDK Suite](https://contentful.github.io/optimization/documents/Documentation.Concepts.Locale_handling_in_the_Optimization_SDK_Suite.html) +for runtime locale behavior. + +Use `getMergeTagValue()` for Contentful Rich Text merge tags and `getFlag()` for Custom Flags. If a +merge tag references localized profile fields such as `location.city` or `location.country`, its +resolved value follows the localized profile data returned by the Experience API. The Web SDK is +stateful, so reading a flag also emits flag-view tracking. ### Entry interaction tracking diff --git a/packages/web/web-sdk/src/ContentfulOptimization.test.ts b/packages/web/web-sdk/src/ContentfulOptimization.test.ts index 9d1e8f44..b71ad3cb 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.test.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.test.ts @@ -99,6 +99,7 @@ describe('ContentfulOptimization', () => { afterEach(() => { window.contentfulOptimization?.destroy() delete window.contentfulOptimization + rs.restoreAllMocks() }) it('sets configured options', () => { @@ -108,6 +109,96 @@ describe('ContentfulOptimization', () => { expect(web.eventBuilder.library.name).toEqual(OPTIMIZATION_WEB_SDK_NAME) }) + it('resolves contentfulLocales from browser runtime candidates', () => { + rs.spyOn(navigator, 'languages', 'get').mockReturnValue(['DE_AT', 'es-ES']) + rs.spyOn(navigator, 'language', 'get').mockReturnValue('es-ES') + + const web = new ContentfulOptimization({ + ...config, + contentfulLocales: { + default: 'en-US', + supported: ['en-US', 'de-DE'], + }, + }) + + expect(web.locale).toBe('de-DE') + expect(Reflect.get(web.api.experience, 'locale')).toBe('de-DE') + }) + + it('falls back to default-only contentfulLocales from browser runtime candidates', () => { + rs.spyOn(navigator, 'languages', 'get').mockReturnValue(['DE_AT', 'es-ES']) + rs.spyOn(navigator, 'language', 'get').mockReturnValue('es-ES') + + const web = new ContentfulOptimization({ + ...config, + contentfulLocales: { + default: 'en-US', + }, + }) + + expect(web.locale).toBe('en-US') + expect(Reflect.get(web.api.experience, 'locale')).toBe('en-US') + }) + + it('keeps api.locale scoped to Experience API requests', () => { + rs.spyOn(navigator, 'languages', 'get').mockReturnValue(['de-AT', 'es-ES']) + rs.spyOn(navigator, 'language', 'get').mockReturnValue('es-ES') + + const web = new ContentfulOptimization({ + ...config, + api: { locale: 'fr-FR' }, + contentfulLocales: { + default: 'en-US', + supported: ['en-US', 'de-DE'], + }, + }) + + expect(web.locale).toBe('de-DE') + expect(Reflect.get(web.api.experience, 'locale')).toBe('fr-FR') + }) + + it('uses top-level locale as the app/content locale input', () => { + rs.spyOn(navigator, 'languages', 'get').mockReturnValue(['en-US']) + rs.spyOn(navigator, 'language', 'get').mockReturnValue('en-US') + + const web = new ContentfulOptimization({ + ...config, + locale: 'de-AT', + contentfulLocales: { + default: 'en-US', + supported: ['en-US', 'de-DE'], + }, + }) + + expect(web.locale).toBe('de-DE') + expect(Reflect.get(web.api.experience, 'locale')).toBe('de-DE') + }) + + it('updates the live locale without refreshing optimization data', () => { + const web = new ContentfulOptimization({ + ...config, + contentfulLocales: { + default: 'en-US', + supported: ['en-US', 'de-DE'], + }, + }) + const page = rs.spyOn(web, 'page') + const values: Array = [] + const subscription = web.states.locale.subscribe((locale) => { + values.push(locale) + }) + + const nextLocale = web.setLocale('de-AT') + + expect(nextLocale).toBe('de-DE') + expect(web.locale).toBe('de-DE') + expect(Reflect.get(web.api.experience, 'locale')).toBe('de-DE') + expect(page).not.toHaveBeenCalled() + expect(values).toEqual(['en-US', 'de-DE']) + + subscription.unsubscribe() + }) + it('defaults autoTrackEntryInteraction.views/clicks/hovers to false when omitted', () => { const web = new ContentfulOptimization(config) diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index 42cf2de9..21cebc58 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.ts @@ -14,11 +14,12 @@ import { CoreStateful, type CoreStatefulConfig, effect, + resolveContentfulLocale, signals, } from '@contentful/optimization-core' import type { App } from '@contentful/optimization-core/api-schemas' import { ANONYMOUS_ID_COOKIE_LEGACY } from '@contentful/optimization-core/constants' -import { getLocale, getPageProperties, getUserAgent } from './builders' +import { getPageProperties, getUserAgent } from './builders' import { ANONYMOUS_ID_COOKIE, OPTIMIZATION_WEB_SDK_NAME, @@ -72,6 +73,10 @@ export interface CookieAttributes { */ const EXPIRATION_DAYS_DEFAULT = 365 +function getRuntimeLocaleCandidates(): string[] { + return [...navigator.languages, navigator.language] +} + /** * Configuration options for the ContentfulOptimization Web SDK. * @@ -150,8 +155,14 @@ function mergeConfig({ }: OptimizationWebConfig): CoreStatefulConfig { const baseDefaults = resolveDefaultState(defaults) const { eventBuilder: configuredEventBuilder } = config + const locale = resolveContentfulLocale({ + candidates: getRuntimeLocaleCandidates(), + contentfulLocales: config.contentfulLocales, + locale: config.locale, + }) const mergedConfig: CoreStatefulConfig = { ...config, + locale, api: { beaconHandler, ...config.api, @@ -163,7 +174,6 @@ function mergeConfig({ eventBuilder: { app, channel: 'web', - getLocale, getPageProperties, getUserAgent, ...configuredEventBuilder, diff --git a/scripts/run-implementation-script.ts b/scripts/run-implementation-script.ts index bc40618f..4eeb9e64 100644 --- a/scripts/run-implementation-script.ts +++ b/scripts/run-implementation-script.ts @@ -169,7 +169,14 @@ function runAction( ): number { switch (requestedAction) { case 'implementation:install': { - const installArgs = ['install', '--force', ...actionArgs] + const installArgs = [ + 'install', + '--force', + '--no-lockfile', + '--no-optimistic-repeat-install', + '--update-checksums', + ...actionArgs, + ] if (!hasExplicitFrozenLockfileFlag(actionArgs)) { installArgs.push('--no-frozen-lockfile')