The client's message cache held only en at hydration, so a non-en user rendered English until the locale's catalog chunk loaded — and stayed English if that import failed or lost a race. __root beforeLoad now dehydrates the active locale's compiled catalog with the page and the provider primes its cache from that payload, so first paint is already in the user's locale with no dependency on a client-side chunk load. ensureMessages dedupes concurrent loads and drops rejected ones so a retry can succeed, and the provider's fallback loader retries instead of silently stranding the UI in en.
92 lines
3.0 KiB
TypeScript
92 lines
3.0 KiB
TypeScript
import { setupI18n, type I18n, type Messages } from "@lingui/core";
|
|
import { messages as enMessages } from "../../locales/en/messages.po";
|
|
|
|
export const SUPPORTED_LOCALES = ["en", "es", "de", "ja"] as const;
|
|
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
|
export const DEFAULT_LOCALE: AppLocale = "en";
|
|
|
|
// es is Mexican Spanish (translations + dayjs es-mx).
|
|
export const LOCALE_LABELS: Record<AppLocale, string> = {
|
|
en: "English",
|
|
es: "Español",
|
|
de: "Deutsch",
|
|
ja: "日本語",
|
|
};
|
|
|
|
// Adding a language: add it to SUPPORTED_LOCALES + LOCALE_LABELS, add the
|
|
// locale to lingui.config.ts, run `bunx lingui extract`, and translate the
|
|
// generated src/locales/<locale>/messages.po.
|
|
const catalogLoaders = import.meta.glob<{ messages: Messages }>(
|
|
"/src/locales/*/messages.po"
|
|
);
|
|
|
|
const messageCache: Partial<Record<AppLocale, Messages>> = {
|
|
[DEFAULT_LOCALE]: enMessages,
|
|
};
|
|
|
|
export function resolveLocale(candidate: string | null | undefined): AppLocale {
|
|
if (!candidate) return DEFAULT_LOCALE;
|
|
const base = candidate.toLowerCase().split("-")[0];
|
|
return (SUPPORTED_LOCALES as readonly string[]).includes(base)
|
|
? (base as AppLocale)
|
|
: DEFAULT_LOCALE;
|
|
}
|
|
|
|
const inflightLoads: Partial<Record<AppLocale, Promise<Messages>>> = {};
|
|
|
|
export async function ensureMessages(locale: AppLocale): Promise<Messages> {
|
|
const cached = messageCache[locale];
|
|
if (cached) return cached;
|
|
|
|
const loader = catalogLoaders[`/src/locales/${locale}/messages.po`];
|
|
if (!loader) return messageCache[DEFAULT_LOCALE]!;
|
|
|
|
// Dedupe concurrent loads; a rejected load is dropped so a retry can succeed.
|
|
const pending = (inflightLoads[locale] ??= loader()
|
|
.then(({ messages }) => {
|
|
messageCache[locale] = messages;
|
|
return messages;
|
|
})
|
|
.finally(() => {
|
|
delete inflightLoads[locale];
|
|
}));
|
|
return pending;
|
|
}
|
|
|
|
export function primeMessages(locale: AppLocale, messages: Messages) {
|
|
messageCache[locale] ??= messages;
|
|
}
|
|
|
|
// Seeded into the SSR-dehydrated query cache so hydration renders the active
|
|
// locale without depending on a client-side catalog chunk load.
|
|
export const i18nMessagesQueryKey = (locale: AppLocale) =>
|
|
["i18n-messages", locale] as const;
|
|
|
|
export function getCachedMessages(locale: AppLocale): Messages {
|
|
return messageCache[locale] ?? messageCache[DEFAULT_LOCALE]!;
|
|
}
|
|
|
|
export function hasCachedMessages(locale: AppLocale): boolean {
|
|
return messageCache[locale] !== undefined;
|
|
}
|
|
|
|
export function createI18n(locale: AppLocale): I18n {
|
|
const i18n = setupI18n();
|
|
i18n.load(locale, getCachedMessages(locale));
|
|
i18n.activate(locale);
|
|
return i18n;
|
|
}
|
|
|
|
export function parseAcceptLanguage(header: string | null): AppLocale {
|
|
if (!header) return DEFAULT_LOCALE;
|
|
for (const part of header.split(",")) {
|
|
const tag = part.split(";")[0]?.trim();
|
|
if (!tag) continue;
|
|
const base = tag.toLowerCase().split("-")[0];
|
|
if ((SUPPORTED_LOCALES as readonly string[]).includes(base)) {
|
|
return base as AppLocale;
|
|
}
|
|
}
|
|
return DEFAULT_LOCALE;
|
|
}
|