141 lines
4.7 KiB
TypeScript
141 lines
4.7 KiB
TypeScript
/* ============================================================================
|
|
LOCALE + PATH HELPERS
|
|
----------------------------------------------------------------------------
|
|
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.
|
|
|
|
These are pure functions on purpose - they run identically during SSR and in
|
|
the browser, and they are the single source of truth for "what is the other
|
|
language's URL for the page I am on".
|
|
========================================================================= */
|
|
|
|
export const LOCALES = ["en", "ar"] as const;
|
|
|
|
export type Locale = (typeof LOCALES)[number];
|
|
|
|
export type Direction = "ltr" | "rtl";
|
|
|
|
export const DEFAULT_LOCALE: Locale = "en";
|
|
|
|
/** Locale -> the value that belongs in <html dir>. */
|
|
const DIRECTION_BY_LOCALE: Record<Locale, Direction> = {
|
|
en: "ltr",
|
|
ar: "rtl",
|
|
};
|
|
|
|
/** Locale -> its endonym, used as the accessible name of the switcher link. */
|
|
export const LOCALE_ENDONYM: Record<Locale, string> = {
|
|
en: "English",
|
|
ar: "العربية",
|
|
};
|
|
|
|
/** Locale -> the two-letter badge the switcher shows. */
|
|
export const LOCALE_SHORT_LABEL: Record<Locale, string> = {
|
|
en: "EN",
|
|
ar: "AR",
|
|
};
|
|
|
|
export function isLocale(value: unknown): value is Locale {
|
|
return (
|
|
typeof value === "string" && (LOCALES as readonly string[]).includes(value)
|
|
);
|
|
}
|
|
|
|
export function getDirection(locale: Locale): Direction {
|
|
return DIRECTION_BY_LOCALE[locale];
|
|
}
|
|
|
|
/** The language a reader on `locale` can switch to. Two locales, so it flips. */
|
|
export function getCounterpartLocale(locale: Locale): Locale {
|
|
return locale === "en" ? "ar" : "en";
|
|
}
|
|
|
|
/**
|
|
* The locale prefix of a pathname, or null when there is none.
|
|
* "/ar/contact" -> "ar" "/about" -> null "/" -> null
|
|
*/
|
|
export function getLocaleFromPathname(pathname: string): Locale | null {
|
|
const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase();
|
|
return isLocale(first) ? first : null;
|
|
}
|
|
|
|
/**
|
|
* The pathname with its locale prefix removed, always leading-slashed.
|
|
* "/ar/news/2026" -> "/news/2026" "/en" -> "/" "/news" -> "/news"
|
|
*/
|
|
export function stripLocale(pathname: string): string {
|
|
const locale = getLocaleFromPathname(pathname);
|
|
const withoutLeadingSlashes = pathname.replace(/^\/+/, "");
|
|
const rest = locale
|
|
? withoutLeadingSlashes.slice(locale.length)
|
|
: withoutLeadingSlashes;
|
|
|
|
if (rest === "" || rest === "/") return "/";
|
|
return rest.startsWith("/") ? rest : `/${rest}`;
|
|
}
|
|
|
|
/**
|
|
* The same page under `locale`. Safe to call with an already-prefixed path, an
|
|
* unprefixed path, or a bare "/" - the locale prefix is normalised either way.
|
|
* ("/en/contact", "ar") -> "/ar/contact"
|
|
* ("/contact", "ar") -> "/ar/contact"
|
|
* ("/", "en") -> "/en"
|
|
*/
|
|
export function localizePath(pathname: string, locale: Locale): string {
|
|
const rest = stripLocale(pathname);
|
|
return rest === "/" ? `/${locale}` : `/${locale}${rest}`;
|
|
}
|
|
|
|
export interface CounterpartPathOptions {
|
|
/** Query string to carry over, with or without the leading "?". */
|
|
search?: string;
|
|
/** Fragment to carry over, with or without the leading "#". */
|
|
hash?: string;
|
|
}
|
|
|
|
/**
|
|
* The counterpart-language URL for the current location: same page, other
|
|
* locale, query string and fragment preserved.
|
|
*/
|
|
export function getCounterpartPath(
|
|
pathname: string,
|
|
locale: Locale,
|
|
options: CounterpartPathOptions = {},
|
|
): string {
|
|
return buildLocalizedHref(
|
|
pathname,
|
|
getCounterpartLocale(locale),
|
|
options,
|
|
);
|
|
}
|
|
|
|
/** localizePath() plus the preserved search/hash. */
|
|
export function buildLocalizedHref(
|
|
pathname: string,
|
|
locale: Locale,
|
|
{ search = "", hash = "" }: CounterpartPathOptions = {},
|
|
): string {
|
|
const normalisedSearch =
|
|
search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : "";
|
|
const normalisedHash =
|
|
hash && hash !== "#" ? (hash.startsWith("#") ? hash : `#${hash}`) : "";
|
|
|
|
return `${localizePath(pathname, locale)}${normalisedSearch}${normalisedHash}`;
|
|
}
|
|
|
|
/**
|
|
* True when `href` addresses the page currently at `pathname`. Trailing
|
|
* slashes and an absent locale prefix on either side are tolerated so a link
|
|
* built as "/en/about" still matches a location of "/en/about/".
|
|
*/
|
|
export function isCurrentPath(href: string, pathname: string): boolean {
|
|
return normalizeForCompare(href) === normalizeForCompare(pathname);
|
|
}
|
|
|
|
function normalizeForCompare(value: string): string {
|
|
const pathOnly = value.split(/[?#]/)[0] ?? value;
|
|
const withoutTrailingSlash = pathOnly.replace(/\/+$/, "");
|
|
return withoutTrailingSlash === "" ? "/" : withoutTrailingSlash.toLowerCase();
|
|
}
|