i18n
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Failing after 10s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped

This commit is contained in:
2026-08-08 15:29:40 -07:00
parent 22c282d8fa
commit 0a7b5fa00f
178 changed files with 13099 additions and 1285 deletions
+23
View File
@@ -0,0 +1,23 @@
import type { I18n, MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { ErrorType } from "@/lib/tanstack-query/types";
// Mirrors the fixed userMessage strings mapKnownError produces
// (src/lib/tanstack-query/utils/to-server-result.ts) so they can be localized.
export const errorMessages: Partial<Record<ErrorType, MessageDescriptor>> = {
[ErrorType.VALIDATION]: msg`Invalid request`,
[ErrorType.NOT_FOUND]: msg`The requested resource was not found`,
[ErrorType.UNAUTHORIZED]: msg`You are not authorized to perform this action`,
[ErrorType.FORBIDDEN]: msg`Access denied`,
[ErrorType.NETWORK]: msg`Network error. Please check your connection and try again.`,
[ErrorType.POCKETBASE]: msg`Something went wrong. Please try again.`,
[ErrorType.UNKNOWN]: msg`An unexpected error occurred. Please try again.`,
};
export function localizeServerError(
i18n: I18n,
error: { code: ErrorType; userMessage: string }
): string {
const descriptor = errorMessages[error.code];
return descriptor ? i18n._(descriptor) : error.userMessage;
}
+73
View File
@@ -0,0 +1,73 @@
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;
}
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]!;
const { messages } = await loader();
messageCache[locale] = messages;
return messages;
}
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;
}
+26
View File
@@ -0,0 +1,26 @@
import { msg } from "@lingui/core/macro";
import { createI18n, type AppLocale } from "@/lib/i18n";
// og:locale wants a territory-qualified tag (en_US), not our bare AppLocale code.
const OG_LOCALE_BY_LOCALE: Record<AppLocale, string> = {
en: "en_US",
es: "es_MX",
de: "de_DE",
ja: "ja_JP",
};
export interface RootMeta {
description: string;
ogImageAlt: string;
ogLocale: string;
}
/** Localized strings for the root route's <head> meta tags. */
export function getRootMeta(locale: AppLocale): RootMeta {
const i18n = createI18n(locale);
return {
description: i18n._(msg`FLXN — beer pong tournaments, brackets, and stats.`),
ogImageAlt: i18n._(msg`FLXN logo`),
ogLocale: OG_LOCALE_BY_LOCALE[locale] ?? OG_LOCALE_BY_LOCALE.en,
};
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { I18nProvider } from "@lingui/react";
import { playerQueries } from "@/features/players/queries";
import {
createI18n,
ensureMessages,
hasCachedMessages,
resolveLocale,
} from "@/lib/i18n";
/**
* Must mount ABOVE AuthProvider: the localized server-query hooks (useMe
* included) call useLingui, so this provider cannot depend on auth context.
* It reads the same auth query via raw useQuery instead — passively (useMe
* is the active fetcher; SSR data is ensured in __root beforeLoad).
*/
export const LinguiProvider = ({ children }: { children: React.ReactNode }) => {
const { queryKey, queryFn } = playerQueries.auth();
const { data } = useQuery({
queryKey,
queryFn: async () => {
const result = await queryFn();
if (!result.success) throw new Error(result.error.userMessage);
return result.data;
},
staleTime: 30 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
const locale = resolveLocale(data?.metadata?.locale);
const [catalogVersion, setCatalogVersion] = useState(0);
// Fresh instance per locale (and per SSR request tree). __root beforeLoad
// preloads the catalog on authed routes; on public routes (login) it may
// not be cached yet — createI18n falls back to en until the effect below
// loads it and bumps catalogVersion.
const i18n = useMemo(
() => createI18n(locale),
// eslint-disable-next-line react-hooks/exhaustive-deps
[locale, catalogVersion]
);
useEffect(() => {
if (hasCachedMessages(locale)) return;
let cancelled = false;
ensureMessages(locale).then(() => {
if (!cancelled) setCatalogVersion((v) => v + 1);
});
return () => {
cancelled = true;
};
}, [locale]);
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
};
+12
View File
@@ -0,0 +1,12 @@
import type { I18n } from "@lingui/core";
import { createI18n, resolveLocale } from "@/lib/i18n";
/**
* Server-side i18n instance for the requester's locale, for use with
* msg`` descriptors + i18n._() when localizing API route error strings.
* Pass the SuperTokens user metadata (or undefined/null for anonymous
* requests) — falls back to DEFAULT_LOCALE when no locale is set.
*/
export function localizedFor(metadata?: { locale?: string } | null): I18n {
return createI18n(resolveLocale(metadata?.locale));
}