/** * @file Developer-site implementation of docUtils `LocaleDeps` for the unified * hybrid search UI (P4.1). Supplies per-device storage of the user's chosen * search language (consent-gated behind OneTrust functional-cookie consent) and * a browser-language -> supported-locale resolver. * * dx has no equivalent of arch/localePreference or arch/browserLocale and no * shared consent util, so this mirrors the consent read used elsewhere in the * org (the OptanonConsent cookie's `groups` field, functional category C0003). * OneTrust's standard functional group id is C0003; "C0003:1" means granted, * "C0003:0" means denied. We never persist a preference without an explicit * grant. * * Exposes `searchLocalePrefs`, an object implementing the docUtils LocaleDeps * shape ({ getStored, setStored, resolveBrowser }), plus a `buildSearchLocalePrefs` * factory for tests / dependency injection. */ import type { LocaleDeps } from "docUtils/searchLocale"; export const STORAGE_KEY = "dx-search-language"; export const FUNCTIONAL_GROUP_ID = "C0003"; export const DEFAULT_LOCALE = "en-us"; /** * Region/script-aware overrides for cases a plain language-only match can't * resolve. Keys are normalized lowercase BCP-47 tags (or language-only codes); * values are site locale ids. Order of checks: exact id -> these rules -> * language-only first match -> default. * * Mirrors the product decisions baked into arch/browserLocale: * - bare "es" -> es-mx (es-mx is the generic Spanish) * - any pt-* -> pt-br (only Brazilian Portuguese is published) * - no/nb/nn -> nb-no (only Norwegian id) * - Chinese is matched on script/region, never collapsed to language-only. */ const TAG_OVERRIDES: Record = { // Chinese — Traditional "zh-tw": "zh-tw", "zh-hant": "zh-tw", "zh-hk": "zh-tw", "zh-mo": "zh-tw", // Chinese — Simplified (and bare "zh") zh: "zh-cn", "zh-cn": "zh-cn", "zh-hans": "zh-cn", "zh-sg": "zh-cn", // Spanish es: "es-mx", "es-es": "es-es", // Portuguese (only pt-br exists) pt: "pt-br", "pt-pt": "pt-br", // Norwegian (only nb-no exists) no: "nb-no", nb: "nb-no", nn: "nb-no" }; /** * Read the OptanonConsent cookie and return whether the functional group is * granted. Defensive: if OneTrust hasn't loaded or the cookie is absent * (tests, pre-consent, non-prod), returns false so we never persist without an * explicit grant. */ export function hasFunctionalConsent(): boolean { try { const cookie = document.cookie .split("; ") .find((row) => row.startsWith("OptanonConsent=")); if (!cookie) { return false; } // The cookie value is URL-encoded querystring-style, e.g. // "...&groups=C0001:1,C0003:1,C0002:0&...". const value = decodeURIComponent(cookie.split("=").slice(1).join("=")); const groups = new URLSearchParams(value).get("groups"); if (!groups) { return false; } return groups .split(",") .some((entry) => entry.trim() === `${FUNCTIONAL_GROUP_ID}:1`); } catch (e) { return false; } } /** * Map a single language-only code (e.g. "ja", "de") to its site locale id by * finding the first supported id whose language subtag matches. */ function languageOnlyMatch( language: string, supportedIds: string[] ): string | undefined { return supportedIds.find((id) => id.split("-")[0] === language); } /** * Get the stored language preference, or null if none / unavailable. Reading an * already-stored preference is low-risk, so it is NOT consent-gated. */ export function getStoredLanguage(): string | null { try { return window.localStorage.getItem(STORAGE_KEY); } catch (e) { return null; } } /** * Persist the chosen language, but only if the user has granted functional * consent. Returns true if the value was written, false if it was skipped (no * consent) or failed. When skipped, the choice still applies for the current * page/session via in-memory state and the ?lang= URL param. */ export function setStoredLanguage(language: string): boolean { if (!language || !hasFunctionalConsent()) { return false; } try { window.localStorage.setItem(STORAGE_KEY, language); return true; } catch (e) { return false; } } /** * Resolve the best site locale id for the given browser languages. The first * browser language that maps to a supported id wins; earlier entries take * precedence over later ones (matching navigator.languages ordering). * Returns "en-us" if nothing matches. */ export function resolveBrowserLocale( navigatorLanguages: readonly string[] | undefined, supportedIds: string[] ): string { if (!navigatorLanguages || navigatorLanguages.length === 0) { return DEFAULT_LOCALE; } const supported = new Set(supportedIds); for (const raw of navigatorLanguages) { if (!raw) { continue; } const tag = raw.toLowerCase(); // 1. Exact id match (e.g. "ja-jp" -> ja-jp). if (supported.has(tag)) { return tag; } // 2. Region/script override (e.g. "zh-tw" -> zh-tw, "es" -> es-mx). const overridden = TAG_OVERRIDES[tag]; if (overridden && supported.has(overridden)) { return overridden; } // 3. Language-only fall-through (e.g. "de-ch" -> de -> de-de). const language = tag.split("-")[0]; const overriddenLang = TAG_OVERRIDES[language]; if (overriddenLang && supported.has(overriddenLang)) { return overriddenLang; } const matched = languageOnlyMatch(language, supportedIds); if (matched) { return matched; } } return DEFAULT_LOCALE; } /** * Build a docUtils LocaleDeps bundle for the developer site. Exposed as a * factory so tests / callers can inject overrides; the default-exported * `searchLocalePrefs` is the production singleton. */ export function buildSearchLocalePrefs( overrides: Partial = {} ): LocaleDeps { return { getStored: overrides.getStored ?? getStoredLanguage, setStored: overrides.setStored ?? setStoredLanguage, resolveBrowser: overrides.resolveBrowser ?? resolveBrowserLocale }; } export const searchLocalePrefs: LocaleDeps = buildSearchLocalePrefs();