From 5f00abe6517509065ac32caa999b023e1c7ba1df Mon Sep 17 00:00:00 2001 From: Sajid Mannikeri Date: Wed, 12 Aug 2026 18:05:01 +0530 Subject: [PATCH] Fix default language in i18n translation Signed-off-by: Sajid Mannikeri --- packages/javascript/src/index.ts | 2 + .../__tests__/normalizeLocaleTag.test.ts | 52 ++++++++++++++++ .../javascript/src/utils/getBaseLanguage.ts | 41 +++++++++++++ .../src/utils/normalizeLocaleTag.ts | 61 +++++++++++++++++++ .../LanguageSwitcher/LanguageSwitcher.tsx | 40 ++++++++++-- 5 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 packages/javascript/src/utils/__tests__/normalizeLocaleTag.test.ts create mode 100644 packages/javascript/src/utils/getBaseLanguage.ts create mode 100644 packages/javascript/src/utils/normalizeLocaleTag.ts diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 3c91d4d9..ee8198f2 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -182,6 +182,8 @@ export {default as resolveFlowTemplateLiterals} from './utils/resolveFlowTemplat export {default as countryCodeToFlagEmoji} from './utils/countryCodeToFlagEmoji'; export {default as resolveLocaleDisplayName} from './utils/resolveLocaleDisplayName'; export {default as resolveLocaleEmoji} from './utils/resolveLocaleEmoji'; +export {default as getBaseLanguage} from './utils/getBaseLanguage'; +export {default as normalizeLocaleTag} from './utils/normalizeLocaleTag'; export {default as buildValidatorFromRules} from './utils/buildValidatorFromRules'; export {default as evaluateValidationRule, DEFAULT_VALIDATION_MESSAGE_KEYS} from './utils/evaluateValidationRule'; export {default as processOpenIDScopes} from './utils/processOpenIDScopes'; diff --git a/packages/javascript/src/utils/__tests__/normalizeLocaleTag.test.ts b/packages/javascript/src/utils/__tests__/normalizeLocaleTag.test.ts new file mode 100644 index 00000000..12274854 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/normalizeLocaleTag.test.ts @@ -0,0 +1,52 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import normalizeLocaleTag from '../normalizeLocaleTag'; + +describe('normalizeLocaleTag', () => { + describe('with Intl.Locale', () => { + it('normalizes casing', () => { + expect(normalizeLocaleTag('en-us')).toBe('en-US'); + }); + + it('leaves an already-canonical tag unchanged', () => { + expect(normalizeLocaleTag('fr-CA')).toBe('fr-CA'); + }); + + it('normalizes a bare language subtag', () => { + expect(normalizeLocaleTag('EN')).toBe('en'); + }); + }); + + describe('without Intl.Locale', () => { + const originalLocale = Intl.Locale; + + beforeEach(() => { + // @ts-expect-error - simulating an environment without Intl.Locale + delete Intl.Locale; + }); + + afterEach(() => { + Intl.Locale = originalLocale; + }); + + it('still normalizes casing via the manual fallback', () => { + expect(normalizeLocaleTag('en-us')).toBe('en-US'); + }); + + it('matches the Intl.Locale result for the same input', () => { + expect(normalizeLocaleTag('fr-ca')).toBe(new originalLocale('fr-ca').toString()); + }); + }); + + it('produces the same result for equivalent tags with and without Intl.Locale', () => { + const withIntl: string = normalizeLocaleTag('en-us'); + const originalLocale = Intl.Locale; + // @ts-expect-error - simulating an environment without Intl.Locale + delete Intl.Locale; + const withoutIntl: string = normalizeLocaleTag('en-us'); + Intl.Locale = originalLocale; + + expect(withIntl).toBe(withoutIntl); + }); +}); diff --git a/packages/javascript/src/utils/getBaseLanguage.ts b/packages/javascript/src/utils/getBaseLanguage.ts new file mode 100644 index 00000000..71c285ed --- /dev/null +++ b/packages/javascript/src/utils/getBaseLanguage.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Resolves a BCP 47 locale tag to its base (primary) language subtag, so that + * region-qualified tags compare equal to their bare form (e.g. "en-US" and "en" + * both resolve to "en"). Uses `Intl.Locale` when available, falling back to a + * simple split on the first "-". + * + * @param tag - BCP 47 locale tag to resolve (e.g. "en-US", "hi-IN", "en") + * @returns The lowercased base language subtag (e.g. "en", "hi") + * + * @example + * ```typescript + * getBaseLanguage('en-US') // 'en' + * getBaseLanguage('hi-IN') // 'hi' + * getBaseLanguage('en') // 'en' + * ``` + */ +export default function getBaseLanguage(tag: string): string { + try { + return new Intl.Locale(tag).language.toLowerCase(); + } catch { + return tag.split('-')[0].toLowerCase(); + } +} diff --git a/packages/javascript/src/utils/normalizeLocaleTag.ts b/packages/javascript/src/utils/normalizeLocaleTag.ts new file mode 100644 index 00000000..11780c93 --- /dev/null +++ b/packages/javascript/src/utils/normalizeLocaleTag.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Lowercases the language subtag and uppercases a 2-letter region subtag (the common + * `language-REGION` shape), leaving everything else as-is. Not a full BCP 47 canonicalizer, + * but deterministic and dependency-free, so case-only differences still compare equal without + * `Intl.Locale`. + */ +function canonicalizeTagManually(tag: string): string { + return tag + .split('-') + .map((part: string, index: number): string => { + if (index === 0) { + return part.toLowerCase(); + } + return part.length === 2 ? part.toUpperCase() : part; + }) + .join('-'); +} + +/** + * Resolves a BCP 47 locale tag to its canonical form, so tags that differ only in casing or + * separator style compare equal (e.g. "en-us" and "en-US"). Uses `Intl.Locale` when available; + * falls back to {@link canonicalizeTagManually} when it isn't (or rejects the input), so exact + * dialect matching stays consistent either way. + * + * Unlike {@link getBaseLanguage}, this preserves the region/dialect — use it to test for an + * *exact* match (e.g. "en-IN" against "en-IN"), not a same-base-language match. + * + * @param tag - BCP 47 locale tag to resolve (e.g. "en-US", "fr-CA") + * @returns The canonical form of the tag + * + * @example + * ```typescript + * normalizeLocaleTag('en-us') // 'en-US' + * normalizeLocaleTag('fr-CA') // 'fr-CA' + * ``` + */ +export default function normalizeLocaleTag(tag: string): string { + try { + return new Intl.Locale(tag).toString(); + } catch { + return canonicalizeTagManually(tag); + } +} diff --git a/packages/react/src/components/presentation/LanguageSwitcher/LanguageSwitcher.tsx b/packages/react/src/components/presentation/LanguageSwitcher/LanguageSwitcher.tsx index 4baeb306..111a0378 100644 --- a/packages/react/src/components/presentation/LanguageSwitcher/LanguageSwitcher.tsx +++ b/packages/react/src/components/presentation/LanguageSwitcher/LanguageSwitcher.tsx @@ -1,7 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {resolveLocaleDisplayName, resolveLocaleEmoji} from '@thunderid/browser'; +import {getBaseLanguage, normalizeLocaleTag, resolveLocaleDisplayName, resolveLocaleEmoji} from '@thunderid/browser'; import {FC, ReactElement, ReactNode, useEffect, useMemo} from 'react'; import BaseLanguageSwitcher, {LanguageOption, LanguageSwitcherRenderProps} from './BaseLanguageSwitcher'; import useFlowMeta from '../../../contexts/FlowMeta/useFlowMeta'; @@ -89,13 +89,43 @@ const LanguageSwitcher: FC = ({children, className}: Lang [effectiveLanguageCodes], ); - // If the detected language isn't supported by the server, fall back to the first available language. + // If the detected language isn't supported by the server, fall back to English (matched by base + // language, e.g. browser "en-US" against server "en"), or the first available language if the + // server doesn't offer English either. useEffect(() => { - if (availableLanguageCodes.length > 0 && !availableLanguageCodes.includes(currentLanguage)) { - switchLanguage(availableLanguageCodes[0]); + if (availableLanguageCodes.length === 0) { + return; } + const currentBase: string = getBaseLanguage(currentLanguage); + const isSupported: boolean = availableLanguageCodes.some( + (code: string): boolean => getBaseLanguage(code) === currentBase, + ); + if (isSupported) { + return; + } + const englishCode: string | undefined = availableLanguageCodes.find( + (code: string): boolean => getBaseLanguage(code) === 'en', + ); + switchLanguage(englishCode ?? availableLanguageCodes[0]); }, [availableLanguageCodes, currentLanguage, switchLanguage]); + // Prefer an exact dialect match (e.g. "en-IN" against a supported "en-IN") over a base-language + // one, so a specific regional variant isn't silently collapsed to "en" when it's actually offered. + // Only fall back to a base-language match (e.g. "en-US" against a supported "en") when the exact + // dialect isn't available, and to the raw code if neither is. + const displayLanguage: string = useMemo(() => { + const exactMatch: LanguageOption | undefined = languages.find( + (option: LanguageOption): boolean => normalizeLocaleTag(option.code) === normalizeLocaleTag(currentLanguage), + ); + if (exactMatch) { + return exactMatch.code; + } + const baseMatch: LanguageOption | undefined = languages.find( + (option: LanguageOption): boolean => getBaseLanguage(option.code) === getBaseLanguage(currentLanguage), + ); + return baseMatch?.code ?? currentLanguage; + }, [languages, currentLanguage]); + const handleLanguageChange = (language: string): void => { if (language !== currentLanguage) { switchLanguage(language); @@ -104,7 +134,7 @@ const LanguageSwitcher: FC = ({children, className}: Lang return (