Files
ipal-kit/src/modules/i18n/negotiateLocale.ts
T

94 lines
2.9 KiB
TypeScript

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'
type NegotiateLocaleArgs = {
/** Raw Accept-Language header value */
acceptLanguage?: null | string
config: I18nConfig
/** Value of the locale cookie, if present (from LOCALE_COOKIE_NAME) */
cookieLocale?: null | string
}
/**
* Resolves which locale to serve, in priority order:
* 1. Cookie (explicit prior choice)
* 2. Accept-Language header (best match against configured locales)
* 3. Configured default locale
*
* Pure function — the template feeds it request data and acts on the result
* (redirect, cookie set). No Next.js or request objects here.
*/
export function negotiateLocale({
acceptLanguage,
config,
cookieLocale,
}: NegotiateLocaleArgs): string {
// 1. Explicit prior choice wins
if (cookieLocale && isValidLocale(cookieLocale, config)) {
return cookieLocale
}
// 2. Best match from Accept-Language
const fromHeader = matchAcceptLanguage(acceptLanguage, config)
if (fromHeader) {
return fromHeader
}
// 3. Fall back to configured default
return config.defaultLocale
}
/**
* Parses an Accept-Language header and returns the best-matching configured
* locale, or undefined if none match.
*
* Matches on the primary subtag (e.g. "en-US" matches configured "en"),
* respecting the header's quality-value ordering.
*/
export function matchAcceptLanguage(
acceptLanguage: null | string | undefined,
config: I18nConfig,
): string | undefined {
if (!acceptLanguage) {return undefined}
const available = getLocaleCodes(config)
const ranked = parseAcceptLanguage(acceptLanguage)
for (const tag of ranked) {
// Exact match (e.g. "pt-BR" === "pt-BR")
const exact = available.find((code) => code.toLowerCase() === tag)
if (exact) {return exact}
// Primary-subtag match (e.g. "en-us" → "en")
const primary = tag.split('-')[0]
const partial = available.find((code) => code.toLowerCase().split('-')[0] === primary)
if (partial) {return partial}
}
return undefined
}
/**
* Parses an Accept-Language header into locale tags ordered by descending
* quality value. Tags are lowercased for comparison.
*
* "en-US,en;q=0.9,pl;q=0.8" → ["en-us", "en", "pl"]
*/
function parseAcceptLanguage(header: string): string[] {
return header
.split(',')
.map((part) => {
const [tag, ...params] = part.trim().split(';')
const qParam = params.find((p) => p.trim().startsWith('q='))
const quality = qParam ? parseFloat(qParam.split('=')[1]) : 1
return { quality: Number.isNaN(quality) ? 0 : quality, tag: tag.trim().toLowerCase() }
})
.filter((entry) => entry.tag && entry.tag !== '*')
.sort((a, b) => b.quality - a.quality)
.map((entry) => entry.tag)
}