Files
sharper.ly/apps/web/app/i18n/hooks.ts
T

112 lines
3.5 KiB
TypeScript
Raw Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
I18N HOOKS
----------------------------------------------------------------------------
The locale is a property of the URL, never of a cookie or of client state,
so every hook here is derived from the router - which means SSR and the
first client render agree by construction and hydration never mismatches.
`useParams().lang` is the fast path inside a page route. The root layout and
the root ErrorBoundary sit ABOVE the ":lang" segment, where that param does
not exist, so the pathname is the fallback and both are correct.
========================================================================= */
import { useMemo } from "react";
import { useLocation, useMatches, useParams } from "react-router";
import {
DEFAULT_LOCALE,
dirFor,
getLocaleFromPathname,
isLocale,
type Direction,
type Locale,
} from "./config";
import { alternatePath, localizedPath, sectionPath } from "./paths";
import type { RouteSlugKey } from "./paths";
import {
createTranslator,
getMessages,
type Messages,
type Translator,
} from "./t";
/** The locale of the page being rendered. */
export function useLocale(): Locale {
const params = useParams();
const { pathname } = useLocation();
const lang = params.lang;
return useMemo(() => {
if (isLocale(lang)) return lang;
return getLocaleFromPathname(pathname) ?? DEFAULT_LOCALE;
}, [lang, pathname]);
}
/** "ltr" for /en/*, "rtl" for /ar/*. Mirrors `<html dir>`. */
export function useDirection(): Direction {
return dirFor(useLocale());
}
export interface Translation {
readonly locale: Locale;
readonly dir: Direction;
/** `t("nav.sections.about")`, `t("pagination.page", { page, pageCount })`. */
readonly t: Translator;
/** The raw catalogue, for the rare case a whole namespace is needed. */
readonly messages: Messages;
/** `/{locale}` prefixed URL for an unprefixed path. */
readonly localizedPath: (path: string) => string;
/** The locale-prefixed URL of one of the eleven sections. */
2026-09-11 02:46:56 +02:00
readonly sectionPath: (key: RouteSlugKey) => string;
}
/** Everything a component needs to render in the current language. */
export function useTranslation(): Translation {
const locale = useLocale();
return useMemo(
() => ({
locale,
dir: dirFor(locale),
t: createTranslator(locale),
messages: getMessages(locale),
localizedPath: (path: string) => localizedPath(path, locale),
sectionPath: (key: RouteSlugKey) => sectionPath(key, locale),
}),
[locale],
);
}
/**
* The counterpart-language URL for the current location, query string and
* fragment preserved. This is what the header's language switch points at.
*/
export function useAlternatePath(targetLocale: Locale): string {
const { pathname, search, hash } = useLocation();
return useMemo(
() => alternatePath(pathname, targetLocale, { search, hash }),
[pathname, search, hash, targetLocale],
);
}
/**
* Read a value off the deepest route `handle` that declares it. Used by the
* shell to let a page opt into a light navigation or footer surface.
*/
export function useRouteHandleValue<T>(
read: (handle: Record<string, unknown>) => T | undefined,
): T | undefined {
const matches = useMatches();
for (let index = matches.length - 1; index >= 0; index -= 1) {
const handle = matches[index]?.handle;
if (handle && typeof handle === "object") {
const value = read(handle as Record<string, unknown>);
if (value !== undefined) return value;
}
}
return undefined;
}