94 lines
2.9 KiB
TypeScript
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)
|
|
}
|