From f919c288b2a4c19f81bd16644fdba2baffb91757 Mon Sep 17 00:00:00 2001 From: rasm-its Date: Mon, 24 Aug 2026 19:06:06 +0200 Subject: [PATCH] i18n: rename locale cookie to NEXT_LOCALE (Next.js convention) --- dist/modules/i18n/negotiateLocale.d.ts | 9 +++++- dist/modules/i18n/negotiateLocale.js | 8 ++++- dist/modules/i18n/negotiateLocale.js.map | 2 +- docs/README.md | 1 + docs/consent.md | 39 ++++++++++++++++++++++-- docs/i18n.md | 34 ++++++++++++++++++++- src/modules/i18n/negotiateLocale.ts | 9 +++++- 7 files changed, 95 insertions(+), 7 deletions(-) diff --git a/dist/modules/i18n/negotiateLocale.d.ts b/dist/modules/i18n/negotiateLocale.d.ts index 9527eb3..f4d7ce8 100644 --- a/dist/modules/i18n/negotiateLocale.d.ts +++ b/dist/modules/i18n/negotiateLocale.d.ts @@ -1,6 +1,13 @@ import type { I18nConfig } from './types.js'; /** Cookie name the template uses to persist a visitor's locale choice. */ -export declare const LOCALE_COOKIE_NAME = "ipal-locale"; +/** + * Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention + * Next.js and its i18n ecosystem expect — so the cookie is interoperable with + * other libraries that read the active locale (instead of a plugin-specific + * name). Written only under functional consent; cleared when that consent is + * withdrawn (see consent cookieMap). + */ +export declare const LOCALE_COOKIE_NAME = "NEXT_LOCALE"; type NegotiateLocaleArgs = { /** Raw Accept-Language header value */ acceptLanguage?: null | string; diff --git a/dist/modules/i18n/negotiateLocale.js b/dist/modules/i18n/negotiateLocale.js index 7996d0e..2c5025d 100644 --- a/dist/modules/i18n/negotiateLocale.js +++ b/dist/modules/i18n/negotiateLocale.js @@ -1,5 +1,11 @@ import { getLocaleCodes, isValidLocale } from './helpers.js'; -/** Cookie name the template uses to persist a visitor's locale choice. */ export const LOCALE_COOKIE_NAME = 'ipal-locale'; +/** Cookie name the template uses to persist a visitor's locale choice. */ /** + * Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention + * Next.js and its i18n ecosystem expect — so the cookie is interoperable with + * other libraries that read the active locale (instead of a plugin-specific + * name). Written only under functional consent; cleared when that consent is + * withdrawn (see consent cookieMap). + */ export const LOCALE_COOKIE_NAME = 'NEXT_LOCALE'; /** * Resolves which locale to serve, in priority order: * 1. Cookie (explicit prior choice) diff --git a/dist/modules/i18n/negotiateLocale.js.map b/dist/modules/i18n/negotiateLocale.js.map index a5dd6ac..8a60092 100644 --- a/dist/modules/i18n/negotiateLocale.js.map +++ b/dist/modules/i18n/negotiateLocale.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/i18n/negotiateLocale.ts"],"sourcesContent":["import type { I18nConfig } from './types.js'\n\nimport { getLocaleCodes, isValidLocale } from './helpers.js'\n\n/** Cookie name the template uses to persist a visitor's locale choice. */\nexport const LOCALE_COOKIE_NAME = 'ipal-locale'\n\ntype NegotiateLocaleArgs = {\n /** Raw Accept-Language header value */\n acceptLanguage?: null | string\n config: I18nConfig\n /** Value of the locale cookie, if present (from LOCALE_COOKIE_NAME) */\n cookieLocale?: null | string\n}\n\n/**\n * Resolves which locale to serve, in priority order:\n * 1. Cookie (explicit prior choice)\n * 2. Accept-Language header (best match against configured locales)\n * 3. Configured default locale\n *\n * Pure function — the template feeds it request data and acts on the result\n * (redirect, cookie set). No Next.js or request objects here.\n */\nexport function negotiateLocale({\n acceptLanguage,\n config,\n cookieLocale,\n}: NegotiateLocaleArgs): string {\n // 1. Explicit prior choice wins\n if (cookieLocale && isValidLocale(cookieLocale, config)) {\n return cookieLocale\n }\n\n // 2. Best match from Accept-Language\n const fromHeader = matchAcceptLanguage(acceptLanguage, config)\n if (fromHeader) {\n return fromHeader\n }\n\n // 3. Fall back to configured default\n return config.defaultLocale\n}\n\n/**\n * Parses an Accept-Language header and returns the best-matching configured\n * locale, or undefined if none match.\n *\n * Matches on the primary subtag (e.g. \"en-US\" matches configured \"en\"),\n * respecting the header's quality-value ordering.\n */\nexport function matchAcceptLanguage(\n acceptLanguage: null | string | undefined,\n config: I18nConfig,\n): string | undefined {\n if (!acceptLanguage) {return undefined}\n\n const available = getLocaleCodes(config)\n const ranked = parseAcceptLanguage(acceptLanguage)\n\n for (const tag of ranked) {\n // Exact match (e.g. \"pt-BR\" === \"pt-BR\")\n const exact = available.find((code) => code.toLowerCase() === tag)\n if (exact) {return exact}\n\n // Primary-subtag match (e.g. \"en-us\" → \"en\")\n const primary = tag.split('-')[0]\n const partial = available.find((code) => code.toLowerCase().split('-')[0] === primary)\n if (partial) {return partial}\n }\n\n return undefined\n}\n\n/**\n * Parses an Accept-Language header into locale tags ordered by descending\n * quality value. Tags are lowercased for comparison.\n *\n * \"en-US,en;q=0.9,pl;q=0.8\" → [\"en-us\", \"en\", \"pl\"]\n */\nfunction parseAcceptLanguage(header: string): string[] {\n return header\n .split(',')\n .map((part) => {\n const [tag, ...params] = part.trim().split(';')\n const qParam = params.find((p) => p.trim().startsWith('q='))\n const quality = qParam ? parseFloat(qParam.split('=')[1]) : 1\n return { quality: Number.isNaN(quality) ? 0 : quality, tag: tag.trim().toLowerCase() }\n })\n .filter((entry) => entry.tag && entry.tag !== '*')\n .sort((a, b) => b.quality - a.quality)\n .map((entry) => entry.tag)\n}\n"],"names":["getLocaleCodes","isValidLocale","LOCALE_COOKIE_NAME","negotiateLocale","acceptLanguage","config","cookieLocale","fromHeader","matchAcceptLanguage","defaultLocale","undefined","available","ranked","parseAcceptLanguage","tag","exact","find","code","toLowerCase","primary","split","partial","header","map","part","params","trim","qParam","p","startsWith","quality","parseFloat","Number","isNaN","filter","entry","sort","a","b"],"mappings":"AAEA,SAASA,cAAc,EAAEC,aAAa,QAAQ,eAAc;AAE5D,wEAAwE,GACxE,OAAO,MAAMC,qBAAqB,cAAa;AAU/C;;;;;;;;CAQC,GACD,OAAO,SAASC,gBAAgB,EAC9BC,cAAc,EACdC,MAAM,EACNC,YAAY,EACQ;IACpB,gCAAgC;IAChC,IAAIA,gBAAgBL,cAAcK,cAAcD,SAAS;QACvD,OAAOC;IACT;IAEA,qCAAqC;IACrC,MAAMC,aAAaC,oBAAoBJ,gBAAgBC;IACvD,IAAIE,YAAY;QACd,OAAOA;IACT;IAEA,qCAAqC;IACrC,OAAOF,OAAOI,aAAa;AAC7B;AAEA;;;;;;CAMC,GACD,OAAO,SAASD,oBACdJ,cAAyC,EACzCC,MAAkB;IAElB,IAAI,CAACD,gBAAgB;QAAC,OAAOM;IAAS;IAEtC,MAAMC,YAAYX,eAAeK;IACjC,MAAMO,SAASC,oBAAoBT;IAEnC,KAAK,MAAMU,OAAOF,OAAQ;QACxB,yCAAyC;QACzC,MAAMG,QAAQJ,UAAUK,IAAI,CAAC,CAACC,OAASA,KAAKC,WAAW,OAAOJ;QAC9D,IAAIC,OAAO;YAAC,OAAOA;QAAK;QAExB,6CAA6C;QAC7C,MAAMI,UAAUL,IAAIM,KAAK,CAAC,IAAI,CAAC,EAAE;QACjC,MAAMC,UAAUV,UAAUK,IAAI,CAAC,CAACC,OAASA,KAAKC,WAAW,GAAGE,KAAK,CAAC,IAAI,CAAC,EAAE,KAAKD;QAC9E,IAAIE,SAAS;YAAC,OAAOA;QAAO;IAC9B;IAEA,OAAOX;AACT;AAEA;;;;;CAKC,GACD,SAASG,oBAAoBS,MAAc;IACzC,OAAOA,OACJF,KAAK,CAAC,KACNG,GAAG,CAAC,CAACC;QACJ,MAAM,CAACV,KAAK,GAAGW,OAAO,GAAGD,KAAKE,IAAI,GAAGN,KAAK,CAAC;QAC3C,MAAMO,SAASF,OAAOT,IAAI,CAAC,CAACY,IAAMA,EAAEF,IAAI,GAAGG,UAAU,CAAC;QACtD,MAAMC,UAAUH,SAASI,WAAWJ,OAAOP,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI;QAC5D,OAAO;YAAEU,SAASE,OAAOC,KAAK,CAACH,WAAW,IAAIA;YAAShB,KAAKA,IAAIY,IAAI,GAAGR,WAAW;QAAG;IACvF,GACCgB,MAAM,CAAC,CAACC,QAAUA,MAAMrB,GAAG,IAAIqB,MAAMrB,GAAG,KAAK,KAC7CsB,IAAI,CAAC,CAACC,GAAGC,IAAMA,EAAER,OAAO,GAAGO,EAAEP,OAAO,EACpCP,GAAG,CAAC,CAACY,QAAUA,MAAMrB,GAAG;AAC7B"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/i18n/negotiateLocale.ts"],"sourcesContent":["import type { I18nConfig } from './types.js'\n\nimport { getLocaleCodes, isValidLocale } from './helpers.js'\n\n/** Cookie name the template uses to persist a visitor's locale choice. */\n/**\n * Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention\n * Next.js and its i18n ecosystem expect — so the cookie is interoperable with\n * other libraries that read the active locale (instead of a plugin-specific\n * name). Written only under functional consent; cleared when that consent is\n * withdrawn (see consent cookieMap).\n */\nexport const LOCALE_COOKIE_NAME = 'NEXT_LOCALE'\n\ntype NegotiateLocaleArgs = {\n /** Raw Accept-Language header value */\n acceptLanguage?: null | string\n config: I18nConfig\n /** Value of the locale cookie, if present (from LOCALE_COOKIE_NAME) */\n cookieLocale?: null | string\n}\n\n/**\n * Resolves which locale to serve, in priority order:\n * 1. Cookie (explicit prior choice)\n * 2. Accept-Language header (best match against configured locales)\n * 3. Configured default locale\n *\n * Pure function — the template feeds it request data and acts on the result\n * (redirect, cookie set). No Next.js or request objects here.\n */\nexport function negotiateLocale({\n acceptLanguage,\n config,\n cookieLocale,\n}: NegotiateLocaleArgs): string {\n // 1. Explicit prior choice wins\n if (cookieLocale && isValidLocale(cookieLocale, config)) {\n return cookieLocale\n }\n\n // 2. Best match from Accept-Language\n const fromHeader = matchAcceptLanguage(acceptLanguage, config)\n if (fromHeader) {\n return fromHeader\n }\n\n // 3. Fall back to configured default\n return config.defaultLocale\n}\n\n/**\n * Parses an Accept-Language header and returns the best-matching configured\n * locale, or undefined if none match.\n *\n * Matches on the primary subtag (e.g. \"en-US\" matches configured \"en\"),\n * respecting the header's quality-value ordering.\n */\nexport function matchAcceptLanguage(\n acceptLanguage: null | string | undefined,\n config: I18nConfig,\n): string | undefined {\n if (!acceptLanguage) {return undefined}\n\n const available = getLocaleCodes(config)\n const ranked = parseAcceptLanguage(acceptLanguage)\n\n for (const tag of ranked) {\n // Exact match (e.g. \"pt-BR\" === \"pt-BR\")\n const exact = available.find((code) => code.toLowerCase() === tag)\n if (exact) {return exact}\n\n // Primary-subtag match (e.g. \"en-us\" → \"en\")\n const primary = tag.split('-')[0]\n const partial = available.find((code) => code.toLowerCase().split('-')[0] === primary)\n if (partial) {return partial}\n }\n\n return undefined\n}\n\n/**\n * Parses an Accept-Language header into locale tags ordered by descending\n * quality value. Tags are lowercased for comparison.\n *\n * \"en-US,en;q=0.9,pl;q=0.8\" → [\"en-us\", \"en\", \"pl\"]\n */\nfunction parseAcceptLanguage(header: string): string[] {\n return header\n .split(',')\n .map((part) => {\n const [tag, ...params] = part.trim().split(';')\n const qParam = params.find((p) => p.trim().startsWith('q='))\n const quality = qParam ? parseFloat(qParam.split('=')[1]) : 1\n return { quality: Number.isNaN(quality) ? 0 : quality, tag: tag.trim().toLowerCase() }\n })\n .filter((entry) => entry.tag && entry.tag !== '*')\n .sort((a, b) => b.quality - a.quality)\n .map((entry) => entry.tag)\n}\n"],"names":["getLocaleCodes","isValidLocale","LOCALE_COOKIE_NAME","negotiateLocale","acceptLanguage","config","cookieLocale","fromHeader","matchAcceptLanguage","defaultLocale","undefined","available","ranked","parseAcceptLanguage","tag","exact","find","code","toLowerCase","primary","split","partial","header","map","part","params","trim","qParam","p","startsWith","quality","parseFloat","Number","isNaN","filter","entry","sort","a","b"],"mappings":"AAEA,SAASA,cAAc,EAAEC,aAAa,QAAQ,eAAc;AAE5D,wEAAwE,GACxE;;;;;;CAMC,GACD,OAAO,MAAMC,qBAAqB,cAAa;AAU/C;;;;;;;;CAQC,GACD,OAAO,SAASC,gBAAgB,EAC9BC,cAAc,EACdC,MAAM,EACNC,YAAY,EACQ;IACpB,gCAAgC;IAChC,IAAIA,gBAAgBL,cAAcK,cAAcD,SAAS;QACvD,OAAOC;IACT;IAEA,qCAAqC;IACrC,MAAMC,aAAaC,oBAAoBJ,gBAAgBC;IACvD,IAAIE,YAAY;QACd,OAAOA;IACT;IAEA,qCAAqC;IACrC,OAAOF,OAAOI,aAAa;AAC7B;AAEA;;;;;;CAMC,GACD,OAAO,SAASD,oBACdJ,cAAyC,EACzCC,MAAkB;IAElB,IAAI,CAACD,gBAAgB;QAAC,OAAOM;IAAS;IAEtC,MAAMC,YAAYX,eAAeK;IACjC,MAAMO,SAASC,oBAAoBT;IAEnC,KAAK,MAAMU,OAAOF,OAAQ;QACxB,yCAAyC;QACzC,MAAMG,QAAQJ,UAAUK,IAAI,CAAC,CAACC,OAASA,KAAKC,WAAW,OAAOJ;QAC9D,IAAIC,OAAO;YAAC,OAAOA;QAAK;QAExB,6CAA6C;QAC7C,MAAMI,UAAUL,IAAIM,KAAK,CAAC,IAAI,CAAC,EAAE;QACjC,MAAMC,UAAUV,UAAUK,IAAI,CAAC,CAACC,OAASA,KAAKC,WAAW,GAAGE,KAAK,CAAC,IAAI,CAAC,EAAE,KAAKD;QAC9E,IAAIE,SAAS;YAAC,OAAOA;QAAO;IAC9B;IAEA,OAAOX;AACT;AAEA;;;;;CAKC,GACD,SAASG,oBAAoBS,MAAc;IACzC,OAAOA,OACJF,KAAK,CAAC,KACNG,GAAG,CAAC,CAACC;QACJ,MAAM,CAACV,KAAK,GAAGW,OAAO,GAAGD,KAAKE,IAAI,GAAGN,KAAK,CAAC;QAC3C,MAAMO,SAASF,OAAOT,IAAI,CAAC,CAACY,IAAMA,EAAEF,IAAI,GAAGG,UAAU,CAAC;QACtD,MAAMC,UAAUH,SAASI,WAAWJ,OAAOP,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI;QAC5D,OAAO;YAAEU,SAASE,OAAOC,KAAK,CAACH,WAAW,IAAIA;YAAShB,KAAKA,IAAIY,IAAI,GAAGR,WAAW;QAAG;IACvF,GACCgB,MAAM,CAAC,CAACC,QAAUA,MAAMrB,GAAG,IAAIqB,MAAMrB,GAAG,KAAK,KAC7CsB,IAAI,CAAC,CAACC,GAAGC,IAAMA,EAAER,OAAO,GAAGO,EAAEP,OAAO,EACpCP,GAAG,CAAC,CAACY,QAAUA,MAAMrB,GAAG;AAC7B"} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 58cf2f8..bf81ff0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -108,6 +108,7 @@ export default buildConfig({ | access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) | | payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) | | seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.md) | +| architektura-tresci | **Jak budować, żeby klient wszystko edytował** (filozofia CMS) | [architektura-tresci.md](./architektura-tresci.md) | | blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) | | consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) | | turnstile | Cloudflare Turnstile (widget + verify) | [turnstile.md](./turnstile.md) | diff --git a/docs/consent.md b/docs/consent.md index ac6b8dc..15c1326 100644 --- a/docs/consent.md +++ b/docs/consent.md @@ -109,7 +109,7 @@ Dostępne tokeny (każdy ma odpowiednik `-dark` używany pod `dark:`): | `--ipal-hover` | `#f5f5f5` | hover przycisków drugorzędnych | | `--ipal-radius` | `0.375rem` | zaokrąglenie przycisków | -Wymaga `@source` skanującego pakiet (patrz frontend-setup.md) — inaczej Tailwind +Wymaga `@source` skanującego pakiet (patrz getting-started.md) — inaczej Tailwind nie wygeneruje tych klas. ### Gdy tokeny nie wystarczą @@ -131,4 +131,39 @@ Sloty: `root`, `primaryButton`, `secondaryButton`. Podany className zastępuje domyślny (nie dokleja się). Elementy mają też `data-ipal="banner"` i `data-ipal="cookie-button"` — stabilne -uchwyty do CSS albo testów e2e. \ No newline at end of file +uchwyty do CSS albo testów e2e. + +## Locale jako cookie functional (wbudowane) + +Plugin sam zarządza jedną cookie functional: **`NEXT_LOCALE`** (wybór języka). +Nie musisz nic konfigurować — działa out of the box: + +- **Zapis za zgodą.** Middleware zapisuje `NEXT_LOCALE` tylko, gdy użytkownik + zaakceptował kategorię **functional**. Bez zgody język działa (negocjacja per + żądanie), ale nie jest utrwalany w cookie. +- **Sprzątanie po cofnięciu.** Gdy użytkownik cofnie zgodę na functional, hook + consent usuwa `NEXT_LOCALE` automatycznie. Odpowiada za to `DEFAULT_COOKIE_MAP`: + +```ts +const DEFAULT_COOKIE_MAP = { + functional: [LOCALE_COOKIE_NAME], // 'NEXT_LOCALE' — plugin zna własną cookie +} +``` + +### Twoje własne cookie functional/analytics + +Jeśli ustawiasz własne cookie podlegające zgodzie, rozszerz mapę — hook wtedy +sprzątnie też Twoje po cofnięciu zgody: + +```ts +useConsent({ + functional: ['NEXT_LOCALE', 'moje-ustawienie'], + analytics: ['_ga', '_gid'], +}) +``` + +Przekazana mapa zastępuje domyślną — pamiętaj dołączyć `NEXT_LOCALE`, jeśli +chcesz zachować sprzątanie locale (albo zaimportuj `LOCALE_COOKIE_NAME` i dodaj). + +> Mechanizm zgody dla locale jest opisany też od strony i18n: +> [i18n.md](./i18n.md#cookie-locale-a-zgoda-rodo). \ No newline at end of file diff --git a/docs/i18n.md b/docs/i18n.md index 2794aef..f77201c 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -107,4 +107,36 @@ Zachowanie: locale z: cookie → Accept-Language → default - wybrany locale zapisany w cookie (`LOCALE_COOKIE_NAME`) -`DEFAULT_MIDDLEWARE_MATCHER` wyklucza `api`, `admin`, `_next`, pliki statyczne. \ No newline at end of file +`DEFAULT_MIDDLEWARE_MATCHER` wyklucza `api`, `admin`, `_next`, pliki statyczne. + +## Cookie locale a zgoda (RODO) + +Wybór języka zapisywany jest w cookie **`NEXT_LOCALE`** (konwencja Next.js — +kompatybilna z innymi bibliotekami i18n, które czytają aktywny locale). Ale +zapis podlega zgodzie: to cookie kategorii **functional**, więc: + +- **Zapis TYLKO za zgodą.** Middleware zapisuje `NEXT_LOCALE` jedynie, gdy + użytkownik zgodził się na kategorię functional (`mayPersistLocale` sprawdza + zgodę). Bez zgody język działa per żądanie (negocjacja z Accept-Language), + ale nie jest utrwalany. +- **Sprzątanie po cofnięciu zgody.** Gdy użytkownik cofnie zgodę na functional, + cookie `NEXT_LOCALE` jest usuwane automatycznie (consent zna tę cookie przez + `DEFAULT_COOKIE_MAP` — patrz [consent.md](./consent.md)). + +Nazwa cookie to jedna stała `LOCALE_COOKIE_NAME` (`modules/i18n/negotiateLocale`), +propagująca do middleware i sprzątania consent. Można nadpisać w +`createLocaleMiddleware({ cookieName })`, ale domyślnie `NEXT_LOCALE` jest +zalecane (interop). + +### Kolejność negocjacji locale + +1. Cookie `NEXT_LOCALE` (jeśli jest — czyli był wybór za zgodą) +2. Nagłówek `Accept-Language` (preferencje przeglądarki) +3. `defaultLocale` z konfiguracji + +Wejście na `/` → negocjacja → redirect na `/pl` (albo wynik negocjacji). +Zmiana języka (URL `/en` różny od cookie) → zapis nowego wyboru (za zgodą). + +> **Migracja ze starej nazwy:** wcześniej cookie nazywało się `ipal-locale`. +> Po zmianie na `NEXT_LOCALE` użytkownicy ze starą cookie przejdą raz ponowną +> negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty. \ No newline at end of file diff --git a/src/modules/i18n/negotiateLocale.ts b/src/modules/i18n/negotiateLocale.ts index 3a88cb2..2f87e83 100644 --- a/src/modules/i18n/negotiateLocale.ts +++ b/src/modules/i18n/negotiateLocale.ts @@ -3,7 +3,14 @@ import type { I18nConfig } from './types.js' import { getLocaleCodes, isValidLocale } from './helpers.js' /** Cookie name the template uses to persist a visitor's locale choice. */ -export const LOCALE_COOKIE_NAME = 'ipal-locale' +/** + * Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention + * Next.js and its i18n ecosystem expect — so the cookie is interoperable with + * other libraries that read the active locale (instead of a plugin-specific + * name). Written only under functional consent; cleared when that consent is + * withdrawn (see consent cookieMap). + */ +export const LOCALE_COOKIE_NAME = 'NEXT_LOCALE' type NegotiateLocaleArgs = { /** Raw Accept-Language header value */