Skip to content

Commit

Permalink
Implement correct normalization of locales in i18n (opensearch-projec…
Browse files Browse the repository at this point in the history
…t#8535)

* Implement correct normalization of locales in i18n

Also:
* Stop examining the fragment identifier for possible locale overrides

Signed-off-by: Miki <miki@amazon.com>

* Update relative formats and plural rules for certain locales

Signed-off-by: Miki <miki@amazon.com>

---------

Signed-off-by: Miki <miki@amazon.com>
  • Loading branch information
AMoo-Miki authored Oct 11, 2024
1 parent fcc18fb commit 2b149d2
Show file tree
Hide file tree
Showing 14 changed files with 160 additions and 311 deletions.
22 changes: 16 additions & 6 deletions packages/osd-i18n/src/core/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ describe('I18n engine', () => {
'en-us'
);

expect(i18n.getLocale()).toBe('en-us');
expect(i18n.getLocale()).toBe('en-US');
expect(i18n.getTranslation()).toEqual({
messages: {
['a.b.c']: 'bar',
Expand Down Expand Up @@ -246,9 +246,19 @@ describe('I18n engine', () => {
expect(i18n.getLocale()).toBe('foo');
});

test('should normalize passed locale', () => {
i18n.setLocale('en-US');
expect(i18n.getLocale()).toBe('en-us');
test('should normalize basic locale', () => {
i18n.setLocale('It-iT');
expect(i18n.getLocale()).toBe('it-IT');
});

test('should normalize simple locale', () => {
i18n.setLocale('en-LATN-us_PRIVATE-variant');
expect(i18n.getLocale()).toBe('en-Latn-US');
});

test('should normalize complex locale', () => {
i18n.setLocale('FR-CA-X-FALLBACK-und-u@keyword=calendarKey');
expect(i18n.getLocale()).toBe('fr-CA');
});
});

Expand Down Expand Up @@ -280,8 +290,8 @@ describe('I18n engine', () => {
});

test('should normalize passed locale', () => {
i18n.setDefaultLocale('en-US');
expect(i18n.getDefaultLocale()).toBe('en-us');
i18n.setDefaultLocale('eN-uS');
expect(i18n.getDefaultLocale()).toBe('en-US');
});

test('should set "en" locale as default for IntlMessageFormat and IntlRelativeFormat', () => {
Expand Down
22 changes: 20 additions & 2 deletions packages/osd-i18n/src/core/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ const EN_LOCALE = 'en';
const translationsForLocale: Record<string, Translation> = {};
const getMessageFormat = memoizeIntlConstructor(IntlMessageFormat);

/* A locale code is made of several components:
* * lang: The two- and three-letter lower-case language code follows the ISO 639-1 and ISO 639-2/3 standards, respectively.
* * script: The optional four-letter title-case code follows the ISO 15924 standard for representing writing systems.
* * region: The two-letter upper-case region code follows the ISO 3166-1 alpha-2 standard.
*
* Ref: https://www.rfc-editor.org/rfc/rfc5646.txt
* Note: While case carries no distinction with locale codes, proper formatting is recommended.
*/
const localeParser = /^(?<lang>[a-z]{2,3})(?:-(?<script>[a-z]{4}))?(?:-(?<region>[a-z]{2}|[0-9]{3}))?(?:[_@\-].*)?$/i;

let defaultLocale = EN_LOCALE;
let currentLocale = EN_LOCALE;
let formats = EN_FORMATS;
Expand All @@ -64,8 +74,16 @@ function getMessageById(id: string): string | undefined {
* Normalizes locale to make it consistent with IntlMessageFormat locales
* @param locale
*/
function normalizeLocale(locale: string) {
return locale.toLowerCase();
export function normalizeLocale(locale: string) {
const { lang, script, region } = localeParser.exec(locale)?.groups || {};
// If parsing failed or the language code was not extracted, return the locale
if (!lang) return locale;

const parts = [lang.toLowerCase()];
if (script) parts.push(script[0].toUpperCase() + script.slice(1).toLowerCase());
if (region) parts.push(region.toUpperCase());

return parts.join('-');
}

/**
Expand Down
37 changes: 32 additions & 5 deletions packages/osd-i18n/src/core/locales.js

Large diffs are not rendered by default.

14 changes: 13 additions & 1 deletion packages/osd-i18n/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { promisify } from 'util';

import { unique } from './core/helper';
import { Translation } from './translation';
import { normalizeLocale } from './core';

const TRANSLATION_FILE_EXTENSION = '.json';

Expand Down Expand Up @@ -69,7 +70,9 @@ function getLocaleFromFileName(fullFileName: string) {
);
}

return path.basename(fullFileName, TRANSLATION_FILE_EXTENSION);
const basename = path.basename(fullFileName, TRANSLATION_FILE_EXTENSION);

return normalizeLocale(basename);
}

/**
Expand Down Expand Up @@ -131,6 +134,15 @@ export function getRegisteredLocales() {
return Object.keys(translationsRegistry);
}

/**
* Check if a locale is registered; returns undefined if i18n is uninitialized
*/
export function isRegisteredLocale(locale: string): boolean | undefined {
const normalizedLocale = normalizeLocale(locale);

return translationsRegistry?.hasOwnProperty(normalizedLocale);
}

/**
* Returns translation messages by specified locale
* @param locale
Expand Down
60 changes: 0 additions & 60 deletions src/core/public/application/scoped_history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,8 @@

import { ScopedHistory } from './scoped_history';
import { createMemoryHistory } from 'history';
import { getLocaleInUrl } from '../locale_helper';
import { i18n } from '@osd/i18n';

jest.mock('../locale_helper', () => ({
getLocaleInUrl: jest.fn(),
}));

jest.mock('@osd/i18n', () => ({
i18n: {
getLocale: jest.fn(),
},
}));

describe('ScopedHistory', () => {
beforeEach(() => {
(getLocaleInUrl as jest.Mock).mockReturnValue('en');
});
describe('construction', () => {
it('succeeds if current location matches basePath', () => {
const gh = createMemoryHistory();
Expand Down Expand Up @@ -373,49 +358,4 @@ describe('ScopedHistory', () => {
expect(gh.length).toBe(4);
});
});

describe('locale handling', () => {
let originalLocation: Location;

beforeEach(() => {
originalLocation = window.location;
delete (window as any).location;
window.location = { href: 'http://localhost/app/wow', reload: jest.fn() } as any;
(i18n.getLocale as jest.Mock).mockReturnValue('en');
});

afterEach(() => {
window.location = originalLocation;
jest.resetAllMocks();
});

it('reloads the page when locale changes', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');
const h = new ScopedHistory(gh, '/app/wow');
// Use the 'h' variable to trigger the listener
h.push('/new-page');

// Mock getLocaleInUrl to return a different locale
(getLocaleInUrl as jest.Mock).mockReturnValue('fr');

// Simulate navigation
gh.push('/app/wow/new-page');

expect(window.location.reload).toHaveBeenCalled();
});

it('does not reload the page when locale changes', () => {
const gh = createMemoryHistory();
gh.push('/app/wow');

// Mock getLocaleInUrl to return a different locale
(getLocaleInUrl as jest.Mock).mockReturnValue('en');

// Simulate navigation
gh.push('/app/wow/new-page');

expect(window.location.reload).not.toHaveBeenCalled();
});
});
});
11 changes: 0 additions & 11 deletions src/core/public/application/scoped_history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@ import {
Href,
Action,
} from 'history';
import { i18n } from '@osd/i18n';
import { getLocaleInUrl } from '../locale_helper';

/**
* A wrapper around a `History` instance that is scoped to a particular base path of the history stack. Behaves
Expand Down Expand Up @@ -309,7 +307,6 @@ export class ScopedHistory<HistoryLocationState = unknown>
* state. Also forwards events to child listeners with the base path stripped from the location.
*/
private setupHistoryListener() {
const currentLocale = i18n.getLocale() || 'en';
const unlisten = this.parentHistory.listen((location, action) => {
// If the user navigates outside the scope of this basePath, tear it down.
if (!location.pathname.startsWith(this.basePath)) {
Expand All @@ -318,14 +315,6 @@ export class ScopedHistory<HistoryLocationState = unknown>
return;
}

const localeValue = getLocaleInUrl(window.location.href);

if (localeValue !== currentLocale) {
// Force a full page reload
window.location.reload();
return;
}

/**
* Track location keys using the same algorithm the browser uses internally.
* - On PUSH, remove all items that came after the current location and append the new location.
Expand Down
50 changes: 0 additions & 50 deletions src/core/public/locale_helper.test.ts

This file was deleted.

68 changes: 0 additions & 68 deletions src/core/public/locale_helper.ts

This file was deleted.

Loading

0 comments on commit 2b149d2

Please sign in to comment.