137 lines
4.5 KiB
TypeScript
137 lines
4.5 KiB
TypeScript
/* ============================================================================
|
|||
|
|
I18N CONFIG
|
||
|
|
----------------------------------------------------------------------------
|
||
|
|
SHARP-ER is served with an EXPLICIT locale prefix on BOTH languages:
|
||
|
|
/en/... and /ar/.... Bare "/" is redirected by the server on Accept-Language.
|
||
|
|
There is no ?lang= query parameter anywhere in the product.
|
||
|
|
|
||
|
|
The locale primitives themselves live in the LanguageSwitcher's pure
|
||
|
|
`locale-path` module - it is imported by <Navigation />, <LanguageSwitcher />
|
||
|
|
and <Footer />, so re-exporting from there (rather than declaring a second
|
||
|
|
`Locale` union) keeps one type across the whole app. A relative import is
|
||
|
|
used because this module is reachable from `app/routes.ts`, which the dev
|
||
|
|
plugin evaluates outside the app's normal alias resolution.
|
||
|
|
========================================================================= */
|
||
|
|
|
||
|
|
import {
|
||
|
|
DEFAULT_LOCALE,
|
||
|
|
getDirection,
|
||
|
|
getLocaleFromPathname,
|
||
|
|
isLocale,
|
||
|
|
LOCALES,
|
||
|
|
LOCALE_ENDONYM,
|
||
|
|
LOCALE_SHORT_LABEL,
|
||
|
|
type Direction,
|
||
|
|
type Locale,
|
||
|
|
} from "../components/ui/LanguageSwitcher/locale-path";
|
||
|
|
|
||
|
|
export type { Direction, Locale };
|
||
|
|
export {
|
||
|
|
DEFAULT_LOCALE,
|
||
|
|
getLocaleFromPathname,
|
||
|
|
isLocale,
|
||
|
|
LOCALE_ENDONYM,
|
||
|
|
LOCALE_SHORT_LABEL,
|
||
|
|
};
|
||
|
|
|
||
|
|
/** Every locale the site is published in, in preference order. */
|
||
|
|
export const SUPPORTED_LOCALES = LOCALES;
|
||
|
|
|
||
|
|
/** `<html dir>` for a locale. Alias of the component library's helper. */
|
||
|
|
export const dirFor = getDirection;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The BCP-47 tag handed to `Intl.*` and to `<html lang>`'s consumers.
|
||
|
|
* Libyan Arabic content is written in MSA, and the draft copy uses Western
|
||
|
|
* (latn) digits throughout, so number formatting stays latn - see ./format.ts.
|
||
|
|
*/
|
||
|
|
export const INTL_LOCALE: Readonly<Record<Locale, string>> = {
|
||
|
|
en: "en-GB",
|
||
|
|
ar: "ar-LY",
|
||
|
|
};
|
||
|
|
|
||
|
|
/** Open Graph `og:locale` values. */
|
||
|
|
export const OG_LOCALE: Readonly<Record<Locale, string>> = {
|
||
|
|
en: "en_GB",
|
||
|
|
ar: "ar_LY",
|
||
|
|
};
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
ACCEPT-LANGUAGE NEGOTIATION
|
||
|
|
Only ever used for bare "/" - once the reader is on a prefixed URL the URL
|
||
|
|
wins, so a shared /ar/... link never silently flips to English.
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
/** One parsed entry of an `Accept-Language` header. */
|
||
|
|
export interface LanguageRange {
|
||
|
|
readonly tag: string;
|
||
|
|
readonly quality: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Parse an `Accept-Language` header into ranges sorted by descending quality.
|
||
|
|
* Malformed entries are dropped rather than throwing - this runs on a header
|
||
|
|
* an arbitrary client controls.
|
||
|
|
*/
|
||
|
|
export function parseAcceptLanguage(
|
||
|
|
header: string | null | undefined,
|
||
|
|
): LanguageRange[] {
|
||
|
|
if (!header) return [];
|
||
|
|
|
||
|
|
return header
|
||
|
|
.split(",")
|
||
|
|
.map((part, index): LanguageRange | null => {
|
||
|
|
const [rawTag, ...parameters] = part.trim().split(";");
|
||
|
|
const tag = rawTag?.trim().toLowerCase();
|
||
|
|
if (!tag) return null;
|
||
|
|
|
||
|
|
const qParameter = parameters
|
||
|
|
.map((parameter) => parameter.trim())
|
||
|
|
.find((parameter) => parameter.startsWith("q="));
|
||
|
|
const parsed = qParameter ? Number.parseFloat(qParameter.slice(2)) : 1;
|
||
|
|
const quality = Number.isFinite(parsed) ? parsed : 0;
|
||
|
|
|
||
|
|
// Index keeps the header's own order stable inside one quality band.
|
||
|
|
return { tag, quality: quality - index * 1e-6 };
|
||
|
|
})
|
||
|
|
.filter((range): range is LanguageRange => range !== null && range.quality > 0)
|
||
|
|
.sort((a, b) => b.quality - a.quality);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The best supported locale for an `Accept-Language` header.
|
||
|
|
* "ar-LY,ar;q=0.9,en;q=0.8" -> "ar"; "fr-FR,fr;q=0.9" -> the default locale.
|
||
|
|
*/
|
||
|
|
export function negotiateLocale(
|
||
|
|
header: string | null | undefined,
|
||
|
|
fallback: Locale = DEFAULT_LOCALE,
|
||
|
|
): Locale {
|
||
|
|
for (const { tag } of parseAcceptLanguage(header)) {
|
||
|
|
if (tag === "*") return fallback;
|
||
|
|
|
||
|
|
const base = tag.split("-")[0];
|
||
|
|
if (isLocale(base)) return base;
|
||
|
|
}
|
||
|
|
|
||
|
|
return fallback;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** `negotiateLocale()` applied to a Fetch `Request`'s headers. */
|
||
|
|
export function negotiateLocaleFromRequest(
|
||
|
|
request: Request,
|
||
|
|
fallback: Locale = DEFAULT_LOCALE,
|
||
|
|
): Locale {
|
||
|
|
return negotiateLocale(request.headers.get("accept-language"), fallback);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The locale a URL addresses. Every page URL carries its prefix, so this is
|
||
|
|
* the authoritative resolver used by the root layout and by `useLocale()`.
|
||
|
|
*/
|
||
|
|
export function localeFromRequest(
|
||
|
|
request: Request,
|
||
|
|
fallback: Locale = DEFAULT_LOCALE,
|
||
|
|
): Locale {
|
||
|
|
return getLocaleFromPathname(new URL(request.url).pathname) ?? fallback;
|
||
|
|
}
|