feat(desktop): add i18n infrastructure with react-intl (#8105)

This commit is contained in:
jh-block
2026-03-27 19:39:50 +01:00
committed by GitHub
parent d596113e72
commit a6765fbcd9
172 changed files with 13793 additions and 2054 deletions
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { getLocale } from './index';
// Helper to mock window.appConfig for tests
function mockAppConfig(values: Record<string, unknown>) {
(window as unknown as Record<string, unknown>).appConfig = {
get: (key: string) => values[key],
getAll: () => values,
};
}
describe('getLocale', () => {
afterEach(() => {
// Clean up appConfig mock
if (typeof window !== 'undefined') {
delete (window as unknown as Record<string, unknown>).appConfig;
}
vi.restoreAllMocks();
});
it('returns "en" as the default fallback', () => {
// navigator.language returns something unsupported
vi.stubGlobal('navigator', { language: 'xx-XX' });
expect(getLocale()).toEqual({ locale: 'en', messageLocale: 'en' });
});
it('preserves regional tag for formatting when base language is supported', () => {
vi.stubGlobal('navigator', { language: 'en-US' });
expect(getLocale()).toEqual({ locale: 'en-US', messageLocale: 'en' });
});
it('returns exact match when navigator.language matches a supported locale', () => {
vi.stubGlobal('navigator', { language: 'en' });
expect(getLocale()).toEqual({ locale: 'en', messageLocale: 'en' });
});
it('respects GOOSE_LOCALE over navigator.language', () => {
mockAppConfig({ GOOSE_LOCALE: 'en' });
vi.stubGlobal('navigator', { language: 'xx-XX' });
expect(getLocale()).toEqual({ locale: 'en', messageLocale: 'en' });
});
it('preserves regional tag from GOOSE_LOCALE', () => {
mockAppConfig({ GOOSE_LOCALE: 'en-GB' });
vi.stubGlobal('navigator', { language: 'xx-XX' });
expect(getLocale()).toEqual({ locale: 'en-GB', messageLocale: 'en' });
});
it('falls back to base language tag for message catalog', () => {
// "en-GB" should use "en" catalog but keep "en-GB" for formatting
vi.stubGlobal('navigator', { language: 'en-GB' });
expect(getLocale()).toEqual({ locale: 'en-GB', messageLocale: 'en' });
});
it('falls back to base language when locale tag is invalid BCP 47', () => {
// "en-" is not a valid BCP 47 tag and would cause RangeError in Intl APIs
mockAppConfig({ GOOSE_LOCALE: 'en-' });
vi.stubGlobal('navigator', { language: 'xx-XX' });
expect(getLocale()).toEqual({ locale: 'en', messageLocale: 'en' });
});
});
describe('loadMessages', () => {
it('returns empty object for English locale', async () => {
const { loadMessages } = await import('./index');
const messages = await loadMessages('en');
expect(messages).toEqual({});
});
it('returns empty object for unsupported locale (with warning)', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { loadMessages } = await import('./index');
const messages = await loadMessages('xx');
expect(messages).toEqual({});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('No message catalog found')
);
warnSpy.mockRestore();
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* Locale detection and message loading for the i18n system.
*
* Locale resolution order:
* 1. GOOSE_LOCALE config value (set via environment variable, passed through appConfig)
* 2. navigator.language (browser/OS locale)
* 3. "en" (fallback)
*/
// Re-export react-intl utilities that components use directly
export { defineMessages, useIntl } from 'react-intl';
/** The set of locales that have translation catalogs. */
const SUPPORTED_LOCALES = new Set(['en']);
/**
* Detect the user's preferred locale.
*
* Returns two values:
* - `locale`: the full BCP 47 tag (e.g. "en-GB") for formatting (dates, numbers).
* - `messageLocale`: the base language that has a translation catalog (e.g. "en").
*/
export function getLocale(): { locale: string; messageLocale: string } {
const explicit =
typeof window !== 'undefined' && window.appConfig
? window.appConfig.get('GOOSE_LOCALE')
: undefined;
const candidates: string[] = [];
if (typeof explicit === 'string' && explicit) {
candidates.push(explicit);
}
if (typeof navigator !== 'undefined' && navigator.language) {
candidates.push(navigator.language);
}
for (const tag of candidates) {
// Exact match first
if (SUPPORTED_LOCALES.has(tag)) return { locale: tag, messageLocale: tag };
// Try base language (e.g. "pt-BR" → "pt") for the catalog, but keep the
// full regional tag for formatting so date/number output respects the region.
const base = tag.split('-')[0];
if (SUPPORTED_LOCALES.has(base)) {
// Validate the full tag is a well-formed BCP 47 locale before using it
// for formatting. Invalid tags (e.g. "en-") would cause RangeError in
// Intl APIs, so fall back to the base language in that case.
let locale = base;
try {
[locale] = Intl.getCanonicalLocales(tag);
} catch {
// tag is not valid BCP 47 — use the base language instead
}
return { locale, messageLocale: base };
}
}
return { locale: 'en', messageLocale: 'en' };
}
/** Resolved locales — computed once at module load. */
const resolvedLocale = getLocale();
/** Full BCP 47 tag for date/number formatting (e.g. "en-GB"). */
export const currentLocale = resolvedLocale.locale;
/** Base language for loading message catalogs (e.g. "en"). */
export const currentMessageLocale = resolvedLocale.messageLocale;
/**
* Load compiled messages for a given locale.
* Returns an empty object for English (react-intl uses defaultMessage as fallback).
*/
export async function loadMessages(
locale: string
): Promise<Record<string, string>> {
if (locale === 'en') {
// English strings live in source code as defaultMessage — no catalog needed.
return {};
}
try {
// Dynamic import so compiled translation bundles are code-split.
const mod = await import(`./compiled/${locale}.json`);
return mod.default ?? mod;
} catch {
console.warn(`[i18n] No message catalog found for locale "${locale}", falling back to English.`);
return {};
}
}
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import { IntlProvider } from 'react-intl';
/**
* Wraps a component tree with IntlProvider for tests.
* Uses English locale with no messages (defaultMessage values are used).
*/
export function IntlTestWrapper({ children }: { children: React.ReactNode }) {
return (
<IntlProvider locale="en" defaultLocale="en" messages={{}}>
{children}
</IntlProvider>
);
}