initial commit
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
/* ============================================================================
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user