feat(desktop): add i18n infrastructure with react-intl (#8105)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user