Refactor shared language lists to avoid duplication (#776)

This commit is contained in:
Leendert de Borst
2026-06-28 21:52:20 +02:00
parent f2159e1263
commit da940fbf2d
25 changed files with 1821 additions and 484 deletions

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { MIN_WORD_COUNT, MAX_WORD_COUNT, DEFAULT_WORD_COUNT, DEFAULT_LANGUAGE_CODE, getLanguageInfo } from '@/utils/dist/core/models/defaults';
import { MIN_WORD_COUNT, MAX_WORD_COUNT, DEFAULT_WORD_COUNT, getLanguageInfo, resolveDefaultLanguage } from '@/utils/dist/core/models/defaults';
import type {
PasswordSettings,
PasswordGeneratorType,
@@ -103,7 +103,9 @@ const PasswordConfigForm: React.FC<IPasswordConfigFormProps> = ({
const { t } = useTranslation();
const isDiceware = (settings.Type ?? 'basic') === 'diceware';
const currentLanguage = settings.Language ?? DEFAULT_LANGUAGE_CODE;
const currentLanguage = (settings.Language && settings.Language.length > 0)
? settings.Language
: resolveDefaultLanguage(navigator.language, dicewareLanguages);
/**
* Human label for a Diceware option value (used in button tooltips).

View File

@@ -10,6 +10,7 @@
*/
import { browser } from 'wxt/browser';
import { resolveDefaultLanguage } from '@/utils/dist/core/models/defaults';
import type { Item, PasswordSettings } from '@/utils/dist/core/models/vault';
import { FieldKey } from '@/utils/dist/core/models/vault';
import initWasm, * as core from '@/utils/dist/core/rust/aliasvault_core.js';
@@ -70,10 +71,24 @@ export async function extractRootDomain(domain: string): Promise<string> {
*/
export async function generatePassword(settings: PasswordSettings, seed?: string): Promise<string> {
await initRustCore();
const payload = seed ? { ...settings, Seed: seed } : settings;
const effective = await applyEffectiveDicewareLanguage(settings);
const payload = seed ? { ...effective, Seed: seed } : effective;
return core.generatePassword(JSON.stringify(payload));
}
/**
* Resolve the effective Diceware passphrase language when none is explicitly chosen.
*
* The passphrase language is left empty by default ("auto").
*/
async function applyEffectiveDicewareLanguage(settings: PasswordSettings): Promise<PasswordSettings> {
if (settings.Type !== 'diceware' || (settings.Language && settings.Language.trim().length > 0)) {
return settings;
}
const codes = await getDicewareLanguages();
return { ...settings, Language: resolveDefaultLanguage(navigator.language, codes) };
}
/**
* Get the list of bundled Diceware wordlist language ISO codes (first is the default, 'en').
* The set is owned by the Rust core; unknown codes fall back to English during generation.

View File

@@ -1,4 +1,5 @@
import { DEFAULT_PASSWORD_LENGTH, DEFAULT_WORD_COUNT, DEFAULT_LANGUAGE_CODE } from '@/utils/dist/core/models/defaults';
import { getAvailableLanguages } from '@/utils/dist/core/identity-generator';
import { DEFAULT_PASSWORD_LENGTH, DEFAULT_WORD_COUNT, DEFAULT_LANGUAGE_CODE, matchAvailableLanguage } from '@/utils/dist/core/models/defaults';
import type { EncryptionKey, PasswordSettings, TotpCode, Attachment } from '@/utils/dist/core/models/vault';
import { BaseRepository } from '../BaseRepository';
@@ -69,7 +70,8 @@ export class SettingsRepository extends BaseRepository {
UseNonAmbiguousChars: false,
Type: 'basic',
WordCount: DEFAULT_WORD_COUNT,
Language: DEFAULT_LANGUAGE_CODE,
// Empty = "auto": the passphrase language is resolved from the app language during runtime.
Language: '',
Capitalization: 'Lowercase',
Separator: 'Dash',
Salt: 'None'
@@ -151,7 +153,9 @@ export class SettingsRepository extends BaseRepository {
}
/**
* Get the effective identity language, falling back to browser language.
* Get the effective identity language. Uses the explicit override when set, otherwise matches the
* browser language to one of the identity generator's available languages via the shared
* region-variant alternative-code table (e.g. "de-CH" -> "de"), falling back to English.
* @returns The effective language code
*/
public getEffectiveIdentityLanguage(): string {
@@ -159,8 +163,7 @@ export class SettingsRepository extends BaseRepository {
if (storedLanguage) {
return storedLanguage;
}
// Fall back to browser language (first two characters)
return navigator.language.substring(0, 2);
return matchAvailableLanguage(navigator.language, getAvailableLanguages()) ?? DEFAULT_LANGUAGE_CODE;
}
/**

View File

@@ -399,20 +399,6 @@ declare function convertAgeRangeToBirthdateOptions(ageRange: string): IBirthdate
* @returns Array of ISO language codes (e.g. ["da", "de", "en", ...]).
*/
declare function getAvailableLanguages(): string[];
/**
* Maps a UI language code to an identity generator language code.
* If no explicit match is found, returns null to indicate no preference.
*
* @param uiLanguageCode - The UI language code (e.g., "en", "en-US", "nl-NL", "de-DE", "fr")
* @returns The matching identity generator language code or null if no match
*
* @example
* mapUiLanguageToIdentityLanguage("en-US") // returns "en"
* mapUiLanguageToIdentityLanguage("nl") // returns "nl"
* mapUiLanguageToIdentityLanguage("de-CH") // returns "de"
* mapUiLanguageToIdentityLanguage("ja") // returns null (no Japanese identity generator)
*/
declare function mapUiLanguageToIdentityLanguage(uiLanguageCode: string | null | undefined): string | null;
/**
* Creates a new identity generator based on the language.
@@ -429,4 +415,4 @@ declare const CreateIdentityGenerator: (language: string) => IdentityGenerator;
*/
declare const CreateUsernameEmailGenerator: () => UsernameEmailGenerator;
export { CreateIdentityGenerator, CreateUsernameEmailGenerator, Gender, type IAgeRangeOption, type IBirthdateOptions, type IDecadeFirstnames, type IIdentityGenerator, type Identity, IdentityGenerator, IdentityGeneratorDa, IdentityGeneratorDe, IdentityGeneratorEn, IdentityGeneratorEs, IdentityGeneratorFa, IdentityGeneratorFr, IdentityGeneratorIt, IdentityGeneratorNl, IdentityGeneratorRo, IdentityGeneratorSv, IdentityGeneratorUr, IdentityHelperUtils, UsernameEmailGenerator, convertAgeRangeToBirthdateOptions, getAvailableAgeRanges, getAvailableLanguages, mapUiLanguageToIdentityLanguage };
export { CreateIdentityGenerator, CreateUsernameEmailGenerator, Gender, type IAgeRangeOption, type IBirthdateOptions, type IDecadeFirstnames, type IIdentityGenerator, type Identity, IdentityGenerator, IdentityGeneratorDa, IdentityGeneratorDe, IdentityGeneratorEn, IdentityGeneratorEs, IdentityGeneratorFa, IdentityGeneratorFr, IdentityGeneratorIt, IdentityGeneratorNl, IdentityGeneratorRo, IdentityGeneratorSv, IdentityGeneratorUr, IdentityHelperUtils, UsernameEmailGenerator, convertAgeRangeToBirthdateOptions, getAvailableAgeRanges, getAvailableLanguages };

View File

@@ -42,8 +42,7 @@ __export(index_exports, {
UsernameEmailGenerator: () => UsernameEmailGenerator,
convertAgeRangeToBirthdateOptions: () => convertAgeRangeToBirthdateOptions,
getAvailableAgeRanges: () => getAvailableAgeRanges,
getAvailableLanguages: () => getAvailableLanguages,
mapUiLanguageToIdentityLanguage: () => mapUiLanguageToIdentityLanguage
getAvailableLanguages: () => getAvailableLanguages
});
module.exports = __toCommonJS(index_exports);
@@ -11763,43 +11762,21 @@ function convertAgeRangeToBirthdateOptions(ageRange) {
}
// src/utils/LanguageProvider.ts
var LANGUAGE_DEFS = [
{ value: "da", alternativeCodes: ["da-DK"] },
{ value: "de", alternativeCodes: ["de-DE", "de-AT", "de-CH", "de-LU", "de-LI"] },
{ value: "en", alternativeCodes: ["en-US", "en-GB", "en-CA", "en-AU", "en-NZ", "en-IE", "en-ZA", "en-SG", "en-IN"] },
{ value: "es", alternativeCodes: ["es-ES", "es-MX", "es-AR", "es-CO", "es-CL", "es-PE", "es-VE", "es-EC", "es-GT", "es-CU", "es-BO", "es-DO", "es-HN", "es-PY", "es-SV", "es-NI", "es-CR", "es-PA", "es-UY", "es-PR"] },
{ value: "fr", alternativeCodes: ["fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr-LU", "fr-MC"] },
{ value: "it", alternativeCodes: ["it-IT", "it-CH", "it-SM", "it-VA"] },
{ value: "nl", alternativeCodes: ["nl-NL", "nl-BE"] },
{ value: "ro", alternativeCodes: ["ro-RO", "ro-MD"] },
{ value: "sv", alternativeCodes: ["sv-SE", "sv-FI"] },
{ value: "ur", alternativeCodes: ["ur-PK", "ur-IN"] },
{ value: "fa", alternativeCodes: ["fa-IR", "fa-AF"] }
var SUPPORTED_LANGUAGE_CODES = [
"da",
"de",
"en",
"es",
"fr",
"it",
"nl",
"ro",
"sv",
"ur",
"fa"
];
function getAvailableLanguages() {
return LANGUAGE_DEFS.map((lang) => lang.value);
}
function mapUiLanguageToIdentityLanguage(uiLanguageCode) {
if (!uiLanguageCode) {
return null;
}
const normalizedCode = uiLanguageCode.toLowerCase();
const exactMatch = LANGUAGE_DEFS.find((lang) => lang.value.toLowerCase() === normalizedCode);
if (exactMatch) {
return exactMatch.value;
}
const alternativeMatch = LANGUAGE_DEFS.find(
(lang) => lang.alternativeCodes?.some((code) => code.toLowerCase() === normalizedCode)
);
if (alternativeMatch) {
return alternativeMatch.value;
}
const baseCode = normalizedCode.split("-")[0];
const baseMatch = LANGUAGE_DEFS.find((lang) => lang.value.toLowerCase() === baseCode);
if (baseMatch) {
return baseMatch.value;
}
return null;
return [...SUPPORTED_LANGUAGE_CODES];
}
// src/factories/IdentityGeneratorFactory.ts
@@ -11858,6 +11835,5 @@ var CreateUsernameEmailGenerator = () => {
UsernameEmailGenerator,
convertAgeRangeToBirthdateOptions,
getAvailableAgeRanges,
getAvailableLanguages,
mapUiLanguageToIdentityLanguage
getAvailableLanguages
});

View File

@@ -11718,43 +11718,21 @@ function convertAgeRangeToBirthdateOptions(ageRange) {
}
// src/utils/LanguageProvider.ts
var LANGUAGE_DEFS = [
{ value: "da", alternativeCodes: ["da-DK"] },
{ value: "de", alternativeCodes: ["de-DE", "de-AT", "de-CH", "de-LU", "de-LI"] },
{ value: "en", alternativeCodes: ["en-US", "en-GB", "en-CA", "en-AU", "en-NZ", "en-IE", "en-ZA", "en-SG", "en-IN"] },
{ value: "es", alternativeCodes: ["es-ES", "es-MX", "es-AR", "es-CO", "es-CL", "es-PE", "es-VE", "es-EC", "es-GT", "es-CU", "es-BO", "es-DO", "es-HN", "es-PY", "es-SV", "es-NI", "es-CR", "es-PA", "es-UY", "es-PR"] },
{ value: "fr", alternativeCodes: ["fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr-LU", "fr-MC"] },
{ value: "it", alternativeCodes: ["it-IT", "it-CH", "it-SM", "it-VA"] },
{ value: "nl", alternativeCodes: ["nl-NL", "nl-BE"] },
{ value: "ro", alternativeCodes: ["ro-RO", "ro-MD"] },
{ value: "sv", alternativeCodes: ["sv-SE", "sv-FI"] },
{ value: "ur", alternativeCodes: ["ur-PK", "ur-IN"] },
{ value: "fa", alternativeCodes: ["fa-IR", "fa-AF"] }
var SUPPORTED_LANGUAGE_CODES = [
"da",
"de",
"en",
"es",
"fr",
"it",
"nl",
"ro",
"sv",
"ur",
"fa"
];
function getAvailableLanguages() {
return LANGUAGE_DEFS.map((lang) => lang.value);
}
function mapUiLanguageToIdentityLanguage(uiLanguageCode) {
if (!uiLanguageCode) {
return null;
}
const normalizedCode = uiLanguageCode.toLowerCase();
const exactMatch = LANGUAGE_DEFS.find((lang) => lang.value.toLowerCase() === normalizedCode);
if (exactMatch) {
return exactMatch.value;
}
const alternativeMatch = LANGUAGE_DEFS.find(
(lang) => lang.alternativeCodes?.some((code) => code.toLowerCase() === normalizedCode)
);
if (alternativeMatch) {
return alternativeMatch.value;
}
const baseCode = normalizedCode.split("-")[0];
const baseMatch = LANGUAGE_DEFS.find((lang) => lang.value.toLowerCase() === baseCode);
if (baseMatch) {
return baseMatch.value;
}
return null;
return [...SUPPORTED_LANGUAGE_CODES];
}
// src/factories/IdentityGeneratorFactory.ts
@@ -11812,6 +11790,5 @@ export {
UsernameEmailGenerator,
convertAgeRangeToBirthdateOptions,
getAvailableAgeRanges,
getAvailableLanguages,
mapUiLanguageToIdentityLanguage
getAvailableLanguages
};

View File

@@ -22,8 +22,8 @@ declare const MIN_WORD_COUNT = 3;
declare const MAX_WORD_COUNT = 10;
/**
* Generic, cross-platform language reference: maps a two-letter ISO 639-1 code to a flag and a
* native display label.
* Generic, cross-platform language reference: maps a two-letter ISO 639-1 code to a flag, a native
* display label, and the alternative locale codes (BCP-47 region variants) that map onto it
*/
/**
* Display metadata for a single language.
@@ -35,12 +35,14 @@ interface ILanguageInfo {
flag: string;
/** Native display label. */
label: string;
/** Alternative locale codes (BCP-47 language-region tags) that map onto this language. */
alternativeCodes?: string[];
}
/** Default ISO language code used as the universal fallback. */
declare const DEFAULT_LANGUAGE_CODE = "en";
/**
* Known languages keyed by ISO 639-1 code, with a flag and native label.
* Covers the AliasVault app UI languages so this list can be reused beyond a single feature.
* Known languages keyed by ISO 639-1 code, with a flag, native label, and the region-variant locale
* codes that map onto each.
*/
declare const LANGUAGES: ILanguageInfo[];
/**
@@ -56,14 +58,32 @@ declare function normalizeLanguageCode(code: string | null | undefined): string;
* @returns The flag + label info for the code.
*/
declare function getLanguageInfo(code: string): ILanguageInfo;
/**
* Match an app/UI/browser locale to one of a feature's available ISO codes, using the region-variant
* alternative codes from {@link LANGUAGES} (e.g. 'en-GB' -> 'en', 'de-CH' -> 'de'). Matching order:
* exact match against an available code, then the alternative-code table, then the base language code
* (the part before the '-'). Returns null when nothing matches, so callers can decide their own
* fallback (e.g. "no preference" vs. a concrete default).
*
* @param appLanguage The app/UI/browser language tag (e.g. 'en', 'en-US', 'nl-BE').
* @param availableCodes The codes the feature actually supports.
* @returns The matching available code (in its original casing) or null if none matched.
*
* @example
* matchAvailableLanguage('en-US', ['en', 'nl']) // 'en'
* matchAvailableLanguage('de-CH', ['de', 'en']) // 'de'
* matchAvailableLanguage('ja', ['en', 'nl']) // null
*/
declare function matchAvailableLanguage(appLanguage: string | null | undefined, availableCodes: string[]): string | null;
/**
* Resolve a default language code for an app/UI language, restricted to a set of available codes
* (e.g. the Diceware wordlist languages returned by the Rust core). Returns the app language when it
* is available, otherwise the first available code, otherwise English.
* @param appLanguage The app/UI language tag.
* (e.g. the Diceware wordlist languages returned by the Rust core, or the identity generator's
* supported languages). Uses {@link matchAvailableLanguage} (region-variant aware), then falls back
* to the first available code, otherwise English.
* @param appLanguage The app/UI/browser language tag.
* @param availableCodes The codes the feature actually supports.
* @returns The resolved ISO code.
*/
declare function resolveDefaultLanguage(appLanguage: string | null | undefined, availableCodes: string[]): string;
export { DEFAULT_LANGUAGE_CODE, DEFAULT_PASSWORD_LENGTH, DEFAULT_WORD_COUNT, type ILanguageInfo, LANGUAGES, MAX_PASSWORD_LENGTH, MAX_WORD_COUNT, MIN_PASSWORD_LENGTH, MIN_WORD_COUNT, getLanguageInfo, normalizeLanguageCode, resolveDefaultLanguage };
export { DEFAULT_LANGUAGE_CODE, DEFAULT_PASSWORD_LENGTH, DEFAULT_WORD_COUNT, type ILanguageInfo, LANGUAGES, MAX_PASSWORD_LENGTH, MAX_WORD_COUNT, MIN_PASSWORD_LENGTH, MIN_WORD_COUNT, getLanguageInfo, matchAvailableLanguage, normalizeLanguageCode, resolveDefaultLanguage };

View File

@@ -13,24 +13,24 @@ var MAX_WORD_COUNT = 10;
// src/defaults/Languages.ts
var DEFAULT_LANGUAGE_CODE = "en";
var LANGUAGES = [
{ code: "en", flag: "\u{1F1FA}\u{1F1F8}", label: "English" },
{ code: "nl", flag: "\u{1F1F3}\u{1F1F1}", label: "Nederlands" },
{ code: "de", flag: "\u{1F1E9}\u{1F1EA}", label: "Deutsch" },
{ code: "fr", flag: "\u{1F1EB}\u{1F1F7}", label: "Fran\xE7ais" },
{ code: "es", flag: "\u{1F1EA}\u{1F1F8}", label: "Espa\xF1ol" },
{ code: "it", flag: "\u{1F1EE}\u{1F1F9}", label: "Italiano" },
{ code: "da", flag: "\u{1F1E9}\u{1F1F0}", label: "Dansk" },
{ code: "fi", flag: "\u{1F1EB}\u{1F1EE}", label: "Suomi" },
{ code: "he", flag: "\u{1F1EE}\u{1F1F1}", label: "\u05E2\u05D1\u05E8\u05D9\u05EA" },
{ code: "pl", flag: "\u{1F1F5}\u{1F1F1}", label: "Polski" },
{ code: "pt", flag: "\u{1F1E7}\u{1F1F7}", label: "Portugu\xEAs Brasileiro" },
{ code: "ro", flag: "\u{1F1F7}\u{1F1F4}", label: "Rom\xE2n\u0103" },
{ code: "ru", flag: "\u{1F1F7}\u{1F1FA}", label: "\u0420\u0443\u0441\u0441\u043A\u0438\u0439" },
{ code: "sv", flag: "\u{1F1F8}\u{1F1EA}", label: "Svenska" },
{ code: "uk", flag: "\u{1F1FA}\u{1F1E6}", label: "\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430" },
{ code: "zh", flag: "\u{1F1E8}\u{1F1F3}", label: "\u7B80\u4F53\u4E2D\u6587" },
{ code: "ur", flag: "\u{1F1F5}\u{1F1F0}", label: "\u0627\u0631\u062F\u0648" },
{ code: "fa", flag: "\u{1F1EE}\u{1F1F7}", label: "\u0641\u0627\u0631\u0633\u06CC" }
{ code: "en", flag: "\u{1F1FA}\u{1F1F8}", label: "English", alternativeCodes: ["en-US", "en-GB", "en-CA", "en-AU", "en-NZ", "en-IE", "en-ZA", "en-SG", "en-IN"] },
{ code: "nl", flag: "\u{1F1F3}\u{1F1F1}", label: "Nederlands", alternativeCodes: ["nl-NL", "nl-BE"] },
{ code: "de", flag: "\u{1F1E9}\u{1F1EA}", label: "Deutsch", alternativeCodes: ["de-DE", "de-AT", "de-CH", "de-LU", "de-LI"] },
{ code: "fr", flag: "\u{1F1EB}\u{1F1F7}", label: "Fran\xE7ais", alternativeCodes: ["fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr-LU", "fr-MC"] },
{ code: "es", flag: "\u{1F1EA}\u{1F1F8}", label: "Espa\xF1ol", alternativeCodes: ["es-ES", "es-MX", "es-AR", "es-CO", "es-CL", "es-PE", "es-VE", "es-EC", "es-GT", "es-CU", "es-BO", "es-DO", "es-HN", "es-PY", "es-SV", "es-NI", "es-CR", "es-PA", "es-UY", "es-PR"] },
{ code: "it", flag: "\u{1F1EE}\u{1F1F9}", label: "Italiano", alternativeCodes: ["it-IT", "it-CH", "it-SM", "it-VA"] },
{ code: "da", flag: "\u{1F1E9}\u{1F1F0}", label: "Dansk", alternativeCodes: ["da-DK"] },
{ code: "fi", flag: "\u{1F1EB}\u{1F1EE}", label: "Suomi", alternativeCodes: ["fi-FI"] },
{ code: "he", flag: "\u{1F1EE}\u{1F1F1}", label: "\u05E2\u05D1\u05E8\u05D9\u05EA", alternativeCodes: ["he-IL"] },
{ code: "pl", flag: "\u{1F1F5}\u{1F1F1}", label: "Polski", alternativeCodes: ["pl-PL"] },
{ code: "pt", flag: "\u{1F1E7}\u{1F1F7}", label: "Portugu\xEAs Brasileiro", alternativeCodes: ["pt-BR", "pt-PT"] },
{ code: "ro", flag: "\u{1F1F7}\u{1F1F4}", label: "Rom\xE2n\u0103", alternativeCodes: ["ro-RO", "ro-MD"] },
{ code: "ru", flag: "\u{1F1F7}\u{1F1FA}", label: "\u0420\u0443\u0441\u0441\u043A\u0438\u0439", alternativeCodes: ["ru-RU", "ru-BY", "ru-KZ", "ru-UA"] },
{ code: "sv", flag: "\u{1F1F8}\u{1F1EA}", label: "Svenska", alternativeCodes: ["sv-SE", "sv-FI"] },
{ code: "uk", flag: "\u{1F1FA}\u{1F1E6}", label: "\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430", alternativeCodes: ["uk-UA"] },
{ code: "zh", flag: "\u{1F1E8}\u{1F1F3}", label: "\u7B80\u4F53\u4E2D\u6587", alternativeCodes: ["zh-CN", "zh-SG", "zh-Hans", "zh-TW", "zh-HK", "zh-MO", "zh-Hant"] },
{ code: "ur", flag: "\u{1F1F5}\u{1F1F0}", label: "\u0627\u0631\u062F\u0648", alternativeCodes: ["ur-PK", "ur-IN"] },
{ code: "fa", flag: "\u{1F1EE}\u{1F1F7}", label: "\u0641\u0627\u0631\u0633\u06CC", alternativeCodes: ["fa-IR", "fa-AF"] }
];
function normalizeLanguageCode(code) {
return (code ?? "").slice(0, 2).toLowerCase();
@@ -40,12 +40,31 @@ function getLanguageInfo(code) {
const match = LANGUAGES.find((l) => l.code === iso);
return match ?? { code, flag: "\u{1F310}", label: code };
}
function resolveDefaultLanguage(appLanguage, availableCodes) {
const iso = normalizeLanguageCode(appLanguage);
if (availableCodes.some((c) => c.toLowerCase() === iso)) {
return iso;
function matchAvailableLanguage(appLanguage, availableCodes) {
if (!appLanguage) {
return null;
}
return availableCodes[0] ?? DEFAULT_LANGUAGE_CODE;
const lower = appLanguage.toLowerCase();
const exact = availableCodes.find((c) => c.toLowerCase() === lower);
if (exact) {
return exact;
}
const altEntry = LANGUAGES.find((l) => l.alternativeCodes?.some((ac) => ac.toLowerCase() === lower));
if (altEntry) {
const altMatch = availableCodes.find((c) => c.toLowerCase() === altEntry.code.toLowerCase());
if (altMatch) {
return altMatch;
}
}
const base = normalizeLanguageCode(appLanguage);
const baseMatch = availableCodes.find((c) => c.toLowerCase() === base);
if (baseMatch) {
return baseMatch;
}
return null;
}
function resolveDefaultLanguage(appLanguage, availableCodes) {
return matchAvailableLanguage(appLanguage, availableCodes) ?? availableCodes[0] ?? DEFAULT_LANGUAGE_CODE;
}
export { DEFAULT_LANGUAGE_CODE, DEFAULT_PASSWORD_LENGTH, DEFAULT_WORD_COUNT, LANGUAGES, MAX_PASSWORD_LENGTH, MAX_WORD_COUNT, MIN_PASSWORD_LENGTH, MIN_WORD_COUNT, getLanguageInfo, normalizeLanguageCode, resolveDefaultLanguage };
export { DEFAULT_LANGUAGE_CODE, DEFAULT_PASSWORD_LENGTH, DEFAULT_WORD_COUNT, LANGUAGES, MAX_PASSWORD_LENGTH, MAX_WORD_COUNT, MIN_PASSWORD_LENGTH, MIN_WORD_COUNT, getLanguageInfo, matchAvailableLanguage, normalizeLanguageCode, resolveDefaultLanguage };

View File

@@ -339,12 +339,14 @@ class SqliteClient implements IDatabaseClient {
return explicitLanguage;
}
// Otherwise, try to match UI language to an identity generator language
const { mapUiLanguageToIdentityLanguage } = await import('@/utils/dist/core/identity-generator');
// Otherwise, match the UI language to one of the identity generator's available languages using
// the shared region-variant alternative-code table (e.g. "de-CH" -> "de").
const { getAvailableLanguages } = await import('@/utils/dist/core/identity-generator');
const { matchAvailableLanguage } = await import('@/utils/dist/core/models/defaults');
const { default: i18n } = await import('@/i18n');
const uiLanguage = i18n.language;
const mappedLanguage = mapUiLanguageToIdentityLanguage(uiLanguage);
const mappedLanguage = matchAvailableLanguage(uiLanguage, getAvailableLanguages());
// Return the mapped language, or fall back to "en" if no match found
return mappedLanguage ?? 'en';

View File

@@ -97,7 +97,7 @@
<div>
<label for="diceware-language" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">@Localizer["LanguageLabel"]</label>
<select id="diceware-language" class="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white focus:ring-primary-500 focus:border-primary-500"
@bind="_workingSettings.Language" @bind:after="OnSettingChanged">
@bind="SelectedLanguage" @bind:after="OnSettingChanged">
@foreach (var language in _dicewareLanguages)
{
<option value="@language">@Languages.GetDisplayLabel(language)</option>
@@ -194,11 +194,27 @@
/// </summary>
private List<string> _dicewareLanguages = [Languages.DefaultLanguageCode];
/// <summary>
/// The passphrase language shown when none is explicitly chosen ("auto"): the most appropriate
/// available wordlist for the current app language.
/// </summary>
private string _effectiveLanguage = Languages.DefaultLanguageCode;
/// <summary>
/// Gets a value indicating whether the Diceware generator is currently selected.
/// </summary>
private bool IsDiceware => _workingSettings.Type == "diceware";
/// <summary>
/// Gets or sets the selected passphrase language for the dropdown. When the user has not made an
/// explicit choice the resolved "auto" language is shown.
/// </summary>
private string SelectedLanguage
{
get => string.IsNullOrEmpty(_workingSettings.Language) ? _effectiveLanguage : _workingSettings.Language;
set => _workingSettings.Language = value;
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
@@ -228,6 +244,10 @@
// Load the Diceware wordlist languages available from the Rust core for the language picker.
_dicewareLanguages = (await RustCoreService.GetDicewareLanguagesAsync()).ToList();
// Resolve the "auto" language shown when the user has not picked one explicitly. Uses the same
// app language + shared resolver as the Rust core, so the dropdown matches the generated preview.
_effectiveLanguage = Languages.ResolveDefaultLanguage(CultureInfo.CurrentCulture.Name, _dicewareLanguages);
await RefreshPreview(false);
}

View File

@@ -9,7 +9,7 @@ namespace AliasVault.Client.Main.Models;
/// <summary>
/// Generic language reference shared across all AliasVault clients: maps a two-letter ISO 639-1
/// code to a flag and native label.
/// code to a flag, native label, and the region-variant locale codes that map onto it.
/// </summary>
public static class Languages
{
@@ -19,28 +19,29 @@ public static class Languages
public const string DefaultLanguageCode = "en";
/// <summary>
/// Known languages keyed by ISO 639-1 code, with a flag and native label.
/// Known languages keyed by ISO 639-1 code, with a flag, native label, and the BCP-47
/// region-variant locale codes (e.g. "en-US", "en-GB") that resolve onto the code.
/// </summary>
private static readonly (string Code, string Flag, string Label)[] Meta =
private static readonly (string Code, string Flag, string Label, string[] AlternativeCodes)[] Meta =
[
("en", "🇺🇸", "English"),
("nl", "🇳🇱", "Nederlands"),
("de", "🇩🇪", "Deutsch"),
("fr", "🇫🇷", "Français"),
("es", "🇪🇸", "Español"),
("it", "🇮🇹", "Italiano"),
("da", "🇩🇰", "Dansk"),
("fi", "🇫🇮", "Suomi"),
("he", "🇮🇱", "עברית"),
("pl", "🇵🇱", "Polski"),
("pt", "🇧🇷", "Português Brasileiro"),
("ro", "🇷🇴", "Română"),
("ru", "🇷🇺", "Русский"),
("sv", "🇸🇪", "Svenska"),
("uk", "🇺🇦", "Українська"),
("zh", "🇨🇳", "简体中文"),
("ur", "🇵🇰", "اردو"),
("fa", "🇮🇷", "فارسی"),
("en", "🇺🇸", "English", ["en-US", "en-GB", "en-CA", "en-AU", "en-NZ", "en-IE", "en-ZA", "en-SG", "en-IN"]),
("nl", "🇳🇱", "Nederlands", ["nl-NL", "nl-BE"]),
("de", "🇩🇪", "Deutsch", ["de-DE", "de-AT", "de-CH", "de-LU", "de-LI"]),
("fr", "🇫🇷", "Français", ["fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr-LU", "fr-MC"]),
("es", "🇪🇸", "Español", ["es-ES", "es-MX", "es-AR", "es-CO", "es-CL", "es-PE", "es-VE", "es-EC", "es-GT", "es-CU", "es-BO", "es-DO", "es-HN", "es-PY", "es-SV", "es-NI", "es-CR", "es-PA", "es-UY", "es-PR"]),
("it", "🇮🇹", "Italiano", ["it-IT", "it-CH", "it-SM", "it-VA"]),
("da", "🇩🇰", "Dansk", ["da-DK"]),
("fi", "🇫🇮", "Suomi", ["fi-FI"]),
("he", "🇮🇱", "עברית", ["he-IL"]),
("pl", "🇵🇱", "Polski", ["pl-PL"]),
("pt", "🇧🇷", "Português Brasileiro", ["pt-BR", "pt-PT"]),
("ro", "🇷🇴", "Română", ["ro-RO", "ro-MD"]),
("ru", "🇷🇺", "Русский", ["ru-RU", "ru-BY", "ru-KZ", "ru-UA"]),
("sv", "🇸🇪", "Svenska", ["sv-SE", "sv-FI"]),
("uk", "🇺🇦", "Українська", ["uk-UA"]),
("zh", "🇨🇳", "简体中文", ["zh-CN", "zh-SG", "zh-Hans", "zh-TW", "zh-HK", "zh-MO", "zh-Hant"]),
("ur", "🇵🇰", "اردو", ["ur-PK", "ur-IN"]),
("fa", "🇮🇷", "فارسی", ["fa-IR", "fa-AF"]),
];
/// <summary>
@@ -63,7 +64,7 @@ public static class Languages
public static string GetDisplayLabel(string code)
{
var iso = Normalize(code);
foreach (var (metaCode, flag, label) in Meta)
foreach (var (metaCode, flag, label, _) in Meta)
{
if (metaCode == iso)
{
@@ -82,7 +83,7 @@ public static class Languages
public static string GetFlag(string code)
{
var iso = Normalize(code);
foreach (var (metaCode, flag, _) in Meta)
foreach (var (metaCode, flag, _, _) in Meta)
{
if (metaCode == iso)
{
@@ -102,7 +103,7 @@ public static class Languages
public static string GetLabel(string code)
{
var iso = Normalize(code);
foreach (var (metaCode, _, label) in Meta)
foreach (var (metaCode, _, label, _) in Meta)
{
if (metaCode == iso)
{
@@ -113,25 +114,83 @@ public static class Languages
return code;
}
/// <summary>
/// Match an app/UI/browser locale to one of a feature's available ISO codes, using the
/// region-variant alternative codes (e.g. "en-GB" -> "en", "de-CH" -> "de"). Matching order:
/// exact match against an available code, then the alternative-code table, then the base
/// language code. Returns null when nothing matches, so callers can decide their own fallback.
/// </summary>
/// <param name="appLanguage">The app/UI/browser language tag (e.g. "en", "en-US", "nl-BE").</param>
/// <param name="availableCodes">The codes the feature actually supports.</param>
/// <returns>The matching available code or null if none matched.</returns>
public static string? MatchAvailableLanguage(string? appLanguage, System.Collections.Generic.IReadOnlyList<string> availableCodes)
{
if (string.IsNullOrEmpty(appLanguage))
{
return null;
}
var lower = appLanguage.ToLowerInvariant();
// 1. Exact match against an available code.
foreach (var code in availableCodes)
{
if (string.Equals(code, lower, System.StringComparison.OrdinalIgnoreCase))
{
return code;
}
}
// 2. Alternative-code match: find the language whose region variants include this tag, then
// return its base code if the feature supports it.
foreach (var (metaCode, _, _, alternativeCodes) in Meta)
{
var isAlternative = false;
foreach (var alt in alternativeCodes)
{
if (string.Equals(alt, lower, System.StringComparison.OrdinalIgnoreCase))
{
isAlternative = true;
break;
}
}
if (isAlternative)
{
foreach (var code in availableCodes)
{
if (string.Equals(code, metaCode, System.StringComparison.OrdinalIgnoreCase))
{
return code;
}
}
}
}
// 3. Base language code match (e.g. an unlisted "en-ZZ" still resolves to "en").
var baseCode = Normalize(appLanguage);
foreach (var code in availableCodes)
{
if (string.Equals(code, baseCode, System.StringComparison.OrdinalIgnoreCase))
{
return code;
}
}
return null;
}
/// <summary>
/// Resolve a default language code for an app/UI language, restricted to a set of available
/// codes. Returns the app language when available, otherwise the first available code,
/// otherwise English.
/// codes. Uses <see cref="MatchAvailableLanguage"/> (region-variant aware), then falls back to
/// the first available code, otherwise English.
/// </summary>
/// <param name="appLanguage">The app/UI language tag.</param>
/// <param name="availableCodes">The codes the feature actually supports.</param>
/// <returns>The resolved ISO code.</returns>
public static string ResolveDefaultLanguage(string? appLanguage, System.Collections.Generic.IReadOnlyList<string> availableCodes)
{
var iso = Normalize(appLanguage);
foreach (var code in availableCodes)
{
if (string.Equals(code, iso, System.StringComparison.OrdinalIgnoreCase))
{
return iso;
}
}
return availableCodes.Count > 0 ? availableCodes[0] : DefaultLanguageCode;
return MatchAvailableLanguage(appLanguage, availableCodes)
?? (availableCodes.Count > 0 ? availableCodes[0] : DefaultLanguageCode);
}
}

View File

@@ -64,10 +64,11 @@ public class PasswordSettings
public int WordCount { get; set; } = PasswordGeneratorDefaults.DefaultWordCount;
/// <summary>
/// Gets or sets the Diceware wordlist language. Unknown languages fall back to English in the Rust core.
/// Gets or sets the Diceware wordlist language. Empty means "auto": the most appropriate available
/// wordlist is resolved from the app language at runtime.
/// </summary>
[JsonPropertyName("Language")]
public string Language { get; set; } = "en";
public string Language { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the Diceware capitalization ("None", "TitleCase", "Uppercase", "Lowercase" or "Random").

View File

@@ -503,8 +503,13 @@ public sealed class JsInteropService(IJSRuntime jsRuntime)
await InitializeAsync();
}
var result = await _identityGeneratorModule!.InvokeAsync<string?>("mapUiLanguageToIdentityLanguage", uiLanguageCode);
return result;
var codes = await _identityGeneratorModule!.InvokeAsync<List<string>>("getAvailableLanguages");
if (codes == null || codes.Count == 0)
{
return null;
}
return Languages.MatchAvailableLanguage(uiLanguageCode, codes);
}
catch (JSException ex)
{

View File

@@ -372,6 +372,17 @@ public class RustCoreService : IAsyncDisposable
node["Seed"] = seed;
}
// Resolve the effective passphrase language when none is explicitly chosen ("auto"). Pick the
// most appropriate available Diceware wordlist for the current app language using the shared
// region-variant table (e.g. "de-CH" -> "de"), falling back to English.
if (string.Equals(settings.Type, "diceware", StringComparison.OrdinalIgnoreCase)
&& string.IsNullOrWhiteSpace(settings.Language))
{
var codes = await GetDicewareLanguagesAsync();
var appLanguage = System.Globalization.CultureInfo.CurrentCulture.Name;
node["Language"] = Languages.ResolveDefaultLanguage(appLanguage, codes);
}
return await jsRuntime.InvokeAsync<string>("rustCoreGeneratePassword", node.ToJsonString());
}

View File

@@ -399,20 +399,6 @@ declare function convertAgeRangeToBirthdateOptions(ageRange: string): IBirthdate
* @returns Array of ISO language codes (e.g. ["da", "de", "en", ...]).
*/
declare function getAvailableLanguages(): string[];
/**
* Maps a UI language code to an identity generator language code.
* If no explicit match is found, returns null to indicate no preference.
*
* @param uiLanguageCode - The UI language code (e.g., "en", "en-US", "nl-NL", "de-DE", "fr")
* @returns The matching identity generator language code or null if no match
*
* @example
* mapUiLanguageToIdentityLanguage("en-US") // returns "en"
* mapUiLanguageToIdentityLanguage("nl") // returns "nl"
* mapUiLanguageToIdentityLanguage("de-CH") // returns "de"
* mapUiLanguageToIdentityLanguage("ja") // returns null (no Japanese identity generator)
*/
declare function mapUiLanguageToIdentityLanguage(uiLanguageCode: string | null | undefined): string | null;
/**
* Creates a new identity generator based on the language.
@@ -429,4 +415,4 @@ declare const CreateIdentityGenerator: (language: string) => IdentityGenerator;
*/
declare const CreateUsernameEmailGenerator: () => UsernameEmailGenerator;
export { CreateIdentityGenerator, CreateUsernameEmailGenerator, Gender, type IAgeRangeOption, type IBirthdateOptions, type IDecadeFirstnames, type IIdentityGenerator, type Identity, IdentityGenerator, IdentityGeneratorDa, IdentityGeneratorDe, IdentityGeneratorEn, IdentityGeneratorEs, IdentityGeneratorFa, IdentityGeneratorFr, IdentityGeneratorIt, IdentityGeneratorNl, IdentityGeneratorRo, IdentityGeneratorSv, IdentityGeneratorUr, IdentityHelperUtils, UsernameEmailGenerator, convertAgeRangeToBirthdateOptions, getAvailableAgeRanges, getAvailableLanguages, mapUiLanguageToIdentityLanguage };
export { CreateIdentityGenerator, CreateUsernameEmailGenerator, Gender, type IAgeRangeOption, type IBirthdateOptions, type IDecadeFirstnames, type IIdentityGenerator, type Identity, IdentityGenerator, IdentityGeneratorDa, IdentityGeneratorDe, IdentityGeneratorEn, IdentityGeneratorEs, IdentityGeneratorFa, IdentityGeneratorFr, IdentityGeneratorIt, IdentityGeneratorNl, IdentityGeneratorRo, IdentityGeneratorSv, IdentityGeneratorUr, IdentityHelperUtils, UsernameEmailGenerator, convertAgeRangeToBirthdateOptions, getAvailableAgeRanges, getAvailableLanguages };

View File

@@ -42,8 +42,7 @@ __export(index_exports, {
UsernameEmailGenerator: () => UsernameEmailGenerator,
convertAgeRangeToBirthdateOptions: () => convertAgeRangeToBirthdateOptions,
getAvailableAgeRanges: () => getAvailableAgeRanges,
getAvailableLanguages: () => getAvailableLanguages,
mapUiLanguageToIdentityLanguage: () => mapUiLanguageToIdentityLanguage
getAvailableLanguages: () => getAvailableLanguages
});
module.exports = __toCommonJS(index_exports);
@@ -11763,43 +11762,21 @@ function convertAgeRangeToBirthdateOptions(ageRange) {
}
// src/utils/LanguageProvider.ts
var LANGUAGE_DEFS = [
{ value: "da", alternativeCodes: ["da-DK"] },
{ value: "de", alternativeCodes: ["de-DE", "de-AT", "de-CH", "de-LU", "de-LI"] },
{ value: "en", alternativeCodes: ["en-US", "en-GB", "en-CA", "en-AU", "en-NZ", "en-IE", "en-ZA", "en-SG", "en-IN"] },
{ value: "es", alternativeCodes: ["es-ES", "es-MX", "es-AR", "es-CO", "es-CL", "es-PE", "es-VE", "es-EC", "es-GT", "es-CU", "es-BO", "es-DO", "es-HN", "es-PY", "es-SV", "es-NI", "es-CR", "es-PA", "es-UY", "es-PR"] },
{ value: "fr", alternativeCodes: ["fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr-LU", "fr-MC"] },
{ value: "it", alternativeCodes: ["it-IT", "it-CH", "it-SM", "it-VA"] },
{ value: "nl", alternativeCodes: ["nl-NL", "nl-BE"] },
{ value: "ro", alternativeCodes: ["ro-RO", "ro-MD"] },
{ value: "sv", alternativeCodes: ["sv-SE", "sv-FI"] },
{ value: "ur", alternativeCodes: ["ur-PK", "ur-IN"] },
{ value: "fa", alternativeCodes: ["fa-IR", "fa-AF"] }
var SUPPORTED_LANGUAGE_CODES = [
"da",
"de",
"en",
"es",
"fr",
"it",
"nl",
"ro",
"sv",
"ur",
"fa"
];
function getAvailableLanguages() {
return LANGUAGE_DEFS.map((lang) => lang.value);
}
function mapUiLanguageToIdentityLanguage(uiLanguageCode) {
if (!uiLanguageCode) {
return null;
}
const normalizedCode = uiLanguageCode.toLowerCase();
const exactMatch = LANGUAGE_DEFS.find((lang) => lang.value.toLowerCase() === normalizedCode);
if (exactMatch) {
return exactMatch.value;
}
const alternativeMatch = LANGUAGE_DEFS.find(
(lang) => lang.alternativeCodes?.some((code) => code.toLowerCase() === normalizedCode)
);
if (alternativeMatch) {
return alternativeMatch.value;
}
const baseCode = normalizedCode.split("-")[0];
const baseMatch = LANGUAGE_DEFS.find((lang) => lang.value.toLowerCase() === baseCode);
if (baseMatch) {
return baseMatch.value;
}
return null;
return [...SUPPORTED_LANGUAGE_CODES];
}
// src/factories/IdentityGeneratorFactory.ts
@@ -11858,6 +11835,5 @@ var CreateUsernameEmailGenerator = () => {
UsernameEmailGenerator,
convertAgeRangeToBirthdateOptions,
getAvailableAgeRanges,
getAvailableLanguages,
mapUiLanguageToIdentityLanguage
getAvailableLanguages
});

View File

@@ -11718,43 +11718,21 @@ function convertAgeRangeToBirthdateOptions(ageRange) {
}
// src/utils/LanguageProvider.ts
var LANGUAGE_DEFS = [
{ value: "da", alternativeCodes: ["da-DK"] },
{ value: "de", alternativeCodes: ["de-DE", "de-AT", "de-CH", "de-LU", "de-LI"] },
{ value: "en", alternativeCodes: ["en-US", "en-GB", "en-CA", "en-AU", "en-NZ", "en-IE", "en-ZA", "en-SG", "en-IN"] },
{ value: "es", alternativeCodes: ["es-ES", "es-MX", "es-AR", "es-CO", "es-CL", "es-PE", "es-VE", "es-EC", "es-GT", "es-CU", "es-BO", "es-DO", "es-HN", "es-PY", "es-SV", "es-NI", "es-CR", "es-PA", "es-UY", "es-PR"] },
{ value: "fr", alternativeCodes: ["fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr-LU", "fr-MC"] },
{ value: "it", alternativeCodes: ["it-IT", "it-CH", "it-SM", "it-VA"] },
{ value: "nl", alternativeCodes: ["nl-NL", "nl-BE"] },
{ value: "ro", alternativeCodes: ["ro-RO", "ro-MD"] },
{ value: "sv", alternativeCodes: ["sv-SE", "sv-FI"] },
{ value: "ur", alternativeCodes: ["ur-PK", "ur-IN"] },
{ value: "fa", alternativeCodes: ["fa-IR", "fa-AF"] }
var SUPPORTED_LANGUAGE_CODES = [
"da",
"de",
"en",
"es",
"fr",
"it",
"nl",
"ro",
"sv",
"ur",
"fa"
];
function getAvailableLanguages() {
return LANGUAGE_DEFS.map((lang) => lang.value);
}
function mapUiLanguageToIdentityLanguage(uiLanguageCode) {
if (!uiLanguageCode) {
return null;
}
const normalizedCode = uiLanguageCode.toLowerCase();
const exactMatch = LANGUAGE_DEFS.find((lang) => lang.value.toLowerCase() === normalizedCode);
if (exactMatch) {
return exactMatch.value;
}
const alternativeMatch = LANGUAGE_DEFS.find(
(lang) => lang.alternativeCodes?.some((code) => code.toLowerCase() === normalizedCode)
);
if (alternativeMatch) {
return alternativeMatch.value;
}
const baseCode = normalizedCode.split("-")[0];
const baseMatch = LANGUAGE_DEFS.find((lang) => lang.value.toLowerCase() === baseCode);
if (baseMatch) {
return baseMatch.value;
}
return null;
return [...SUPPORTED_LANGUAGE_CODES];
}
// src/factories/IdentityGeneratorFactory.ts
@@ -11812,6 +11790,5 @@ export {
UsernameEmailGenerator,
convertAgeRangeToBirthdateOptions,
getAvailableAgeRanges,
getAvailableLanguages,
mapUiLanguageToIdentityLanguage
getAvailableLanguages
};

View File

@@ -14,7 +14,7 @@ package_name="models"
package_path="."
echo "📦 Building $package_name..."
npm install && npm run lint && npm run build
npm install && npm run lint && npm run test && npm run build
echo ""
echo "🔄 Generating platform-specific models (C#, Swift, Kotlin)..."

View File

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,8 @@
"build": "tsup",
"clean": "rm -rf dist",
"lint": "eslint src",
"lint:fix": "eslint src --fix"
"lint:fix": "eslint src --fix",
"test": "vitest run"
},
"files": [
"dist"
@@ -19,6 +20,7 @@
"@typescript-eslint/parser": "^8.21.0",
"eslint": "^9.19.0",
"tsup": "^8.4.0",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"vitest": "^4.1.8"
}
}

View File

@@ -25,7 +25,10 @@ function csString(value) {
function generateCSharp(languages, defaultCode) {
const tuples = languages
.map((l) => ` ("${csString(l.code)}", "${l.flag}", "${csString(l.label)}"),`)
.map((l) => {
const alts = (l.alternativeCodes ?? []).map((c) => `"${csString(c)}"`).join(', ');
return ` ("${csString(l.code)}", "${l.flag}", "${csString(l.label)}", [${alts}]),`;
})
.join('\n');
return `// <auto-generated />
@@ -39,7 +42,7 @@ namespace AliasVault.Client.Main.Models;
/// <summary>
/// Generic language reference shared across all AliasVault clients: maps a two-letter ISO 639-1
/// code to a flag and native label.
/// code to a flag, native label, and the region-variant locale codes that map onto it.
/// </summary>
public static class Languages
{
@@ -49,9 +52,10 @@ public static class Languages
public const string DefaultLanguageCode = "${csString(defaultCode)}";
/// <summary>
/// Known languages keyed by ISO 639-1 code, with a flag and native label.
/// Known languages keyed by ISO 639-1 code, with a flag, native label, and the BCP-47
/// region-variant locale codes (e.g. "en-US", "en-GB") that resolve onto the code.
/// </summary>
private static readonly (string Code, string Flag, string Label)[] Meta =
private static readonly (string Code, string Flag, string Label, string[] AlternativeCodes)[] Meta =
[
${tuples}
];
@@ -76,7 +80,7 @@ ${tuples}
public static string GetDisplayLabel(string code)
{
var iso = Normalize(code);
foreach (var (metaCode, flag, label) in Meta)
foreach (var (metaCode, flag, label, _) in Meta)
{
if (metaCode == iso)
{
@@ -95,7 +99,7 @@ ${tuples}
public static string GetFlag(string code)
{
var iso = Normalize(code);
foreach (var (metaCode, flag, _) in Meta)
foreach (var (metaCode, flag, _, _) in Meta)
{
if (metaCode == iso)
{
@@ -115,7 +119,7 @@ ${tuples}
public static string GetLabel(string code)
{
var iso = Normalize(code);
foreach (var (metaCode, _, label) in Meta)
foreach (var (metaCode, _, label, _) in Meta)
{
if (metaCode == iso)
{
@@ -126,26 +130,84 @@ ${tuples}
return code;
}
/// <summary>
/// Match an app/UI/browser locale to one of a feature's available ISO codes, using the
/// region-variant alternative codes (e.g. "en-GB" -> "en", "de-CH" -> "de"). Matching order:
/// exact match against an available code, then the alternative-code table, then the base
/// language code. Returns null when nothing matches, so callers can decide their own fallback.
/// </summary>
/// <param name="appLanguage">The app/UI/browser language tag (e.g. "en", "en-US", "nl-BE").</param>
/// <param name="availableCodes">The codes the feature actually supports.</param>
/// <returns>The matching available code or null if none matched.</returns>
public static string? MatchAvailableLanguage(string? appLanguage, System.Collections.Generic.IReadOnlyList<string> availableCodes)
{
if (string.IsNullOrEmpty(appLanguage))
{
return null;
}
var lower = appLanguage.ToLowerInvariant();
// 1. Exact match against an available code.
foreach (var code in availableCodes)
{
if (string.Equals(code, lower, System.StringComparison.OrdinalIgnoreCase))
{
return code;
}
}
// 2. Alternative-code match: find the language whose region variants include this tag, then
// return its base code if the feature supports it.
foreach (var (metaCode, _, _, alternativeCodes) in Meta)
{
var isAlternative = false;
foreach (var alt in alternativeCodes)
{
if (string.Equals(alt, lower, System.StringComparison.OrdinalIgnoreCase))
{
isAlternative = true;
break;
}
}
if (isAlternative)
{
foreach (var code in availableCodes)
{
if (string.Equals(code, metaCode, System.StringComparison.OrdinalIgnoreCase))
{
return code;
}
}
}
}
// 3. Base language code match (e.g. an unlisted "en-ZZ" still resolves to "en").
var baseCode = Normalize(appLanguage);
foreach (var code in availableCodes)
{
if (string.Equals(code, baseCode, System.StringComparison.OrdinalIgnoreCase))
{
return code;
}
}
return null;
}
/// <summary>
/// Resolve a default language code for an app/UI language, restricted to a set of available
/// codes. Returns the app language when available, otherwise the first available code,
/// otherwise English.
/// codes. Uses <see cref="MatchAvailableLanguage"/> (region-variant aware), then falls back to
/// the first available code, otherwise English.
/// </summary>
/// <param name="appLanguage">The app/UI language tag.</param>
/// <param name="availableCodes">The codes the feature actually supports.</param>
/// <returns>The resolved ISO code.</returns>
public static string ResolveDefaultLanguage(string? appLanguage, System.Collections.Generic.IReadOnlyList<string> availableCodes)
{
var iso = Normalize(appLanguage);
foreach (var code in availableCodes)
{
if (string.Equals(code, iso, System.StringComparison.OrdinalIgnoreCase))
{
return iso;
}
}
return availableCodes.Count > 0 ? availableCodes[0] : DefaultLanguageCode;
return MatchAvailableLanguage(appLanguage, availableCodes)
?? (availableCodes.Count > 0 ? availableCodes[0] : DefaultLanguageCode);
}
}
`;

View File

@@ -1,6 +1,6 @@
/**
* Generic, cross-platform language reference: maps a two-letter ISO 639-1 code to a flag and a
* native display label.
* Generic, cross-platform language reference: maps a two-letter ISO 639-1 code to a flag, a native
* display label, and the alternative locale codes (BCP-47 region variants) that map onto it
*/
/**
@@ -13,34 +13,36 @@ export interface ILanguageInfo {
flag: string;
/** Native display label. */
label: string;
/** Alternative locale codes (BCP-47 language-region tags) that map onto this language. */
alternativeCodes?: string[];
}
/** Default ISO language code used as the universal fallback. */
export const DEFAULT_LANGUAGE_CODE = 'en';
/**
* Known languages keyed by ISO 639-1 code, with a flag and native label.
* Covers the AliasVault app UI languages so this list can be reused beyond a single feature.
* Known languages keyed by ISO 639-1 code, with a flag, native label, and the region-variant locale
* codes that map onto each.
*/
export const LANGUAGES: ILanguageInfo[] = [
{ code: 'en', flag: '🇺🇸', label: 'English' },
{ code: 'nl', flag: '🇳🇱', label: 'Nederlands' },
{ code: 'de', flag: '🇩🇪', label: 'Deutsch' },
{ code: 'fr', flag: '🇫🇷', label: 'Français' },
{ code: 'es', flag: '🇪🇸', label: 'Español' },
{ code: 'it', flag: '🇮🇹', label: 'Italiano' },
{ code: 'da', flag: '🇩🇰', label: 'Dansk' },
{ code: 'fi', flag: '🇫🇮', label: 'Suomi' },
{ code: 'he', flag: '🇮🇱', label: 'עברית' },
{ code: 'pl', flag: '🇵🇱', label: 'Polski' },
{ code: 'pt', flag: '🇧🇷', label: 'Português Brasileiro' },
{ code: 'ro', flag: '🇷🇴', label: 'Română' },
{ code: 'ru', flag: '🇷🇺', label: 'Русский' },
{ code: 'sv', flag: '🇸🇪', label: 'Svenska' },
{ code: 'uk', flag: '🇺🇦', label: 'Українська' },
{ code: 'zh', flag: '🇨🇳', label: '简体中文' },
{ code: 'ur', flag: '🇵🇰', label: 'اردو' },
{ code: 'fa', flag: '🇮🇷', label: 'فارسی' },
{ code: 'en', flag: '🇺🇸', label: 'English', alternativeCodes: ['en-US', 'en-GB', 'en-CA', 'en-AU', 'en-NZ', 'en-IE', 'en-ZA', 'en-SG', 'en-IN'] },
{ code: 'nl', flag: '🇳🇱', label: 'Nederlands', alternativeCodes: ['nl-NL', 'nl-BE'] },
{ code: 'de', flag: '🇩🇪', label: 'Deutsch', alternativeCodes: ['de-DE', 'de-AT', 'de-CH', 'de-LU', 'de-LI'] },
{ code: 'fr', flag: '🇫🇷', label: 'Français', alternativeCodes: ['fr-FR', 'fr-CA', 'fr-BE', 'fr-CH', 'fr-LU', 'fr-MC'] },
{ code: 'es', flag: '🇪🇸', label: 'Español', alternativeCodes: ['es-ES', 'es-MX', 'es-AR', 'es-CO', 'es-CL', 'es-PE', 'es-VE', 'es-EC', 'es-GT', 'es-CU', 'es-BO', 'es-DO', 'es-HN', 'es-PY', 'es-SV', 'es-NI', 'es-CR', 'es-PA', 'es-UY', 'es-PR'] },
{ code: 'it', flag: '🇮🇹', label: 'Italiano', alternativeCodes: ['it-IT', 'it-CH', 'it-SM', 'it-VA'] },
{ code: 'da', flag: '🇩🇰', label: 'Dansk', alternativeCodes: ['da-DK'] },
{ code: 'fi', flag: '🇫🇮', label: 'Suomi', alternativeCodes: ['fi-FI'] },
{ code: 'he', flag: '🇮🇱', label: 'עברית', alternativeCodes: ['he-IL'] },
{ code: 'pl', flag: '🇵🇱', label: 'Polski', alternativeCodes: ['pl-PL'] },
{ code: 'pt', flag: '🇧🇷', label: 'Português Brasileiro', alternativeCodes: ['pt-BR', 'pt-PT'] },
{ code: 'ro', flag: '🇷🇴', label: 'Română', alternativeCodes: ['ro-RO', 'ro-MD'] },
{ code: 'ru', flag: '🇷🇺', label: 'Русский', alternativeCodes: ['ru-RU', 'ru-BY', 'ru-KZ', 'ru-UA'] },
{ code: 'sv', flag: '🇸🇪', label: 'Svenska', alternativeCodes: ['sv-SE', 'sv-FI'] },
{ code: 'uk', flag: '🇺🇦', label: 'Українська', alternativeCodes: ['uk-UA'] },
{ code: 'zh', flag: '🇨🇳', label: '简体中文', alternativeCodes: ['zh-CN', 'zh-SG', 'zh-Hans', 'zh-TW', 'zh-HK', 'zh-MO', 'zh-Hant'] },
{ code: 'ur', flag: '🇵🇰', label: 'اردو', alternativeCodes: ['ur-PK', 'ur-IN'] },
{ code: 'fa', flag: '🇮🇷', label: 'فارسی', alternativeCodes: ['fa-IR', 'fa-AF'] },
];
/**
@@ -64,18 +66,69 @@ export function getLanguageInfo(code: string): ILanguageInfo {
return match ?? { code, flag: '🌐', label: code };
}
/**
* Match an app/UI/browser locale to one of a feature's available ISO codes, using the region-variant
* alternative codes from {@link LANGUAGES} (e.g. 'en-GB' -> 'en', 'de-CH' -> 'de'). Matching order:
* exact match against an available code, then the alternative-code table, then the base language code
* (the part before the '-'). Returns null when nothing matches, so callers can decide their own
* fallback (e.g. "no preference" vs. a concrete default).
*
* @param appLanguage The app/UI/browser language tag (e.g. 'en', 'en-US', 'nl-BE').
* @param availableCodes The codes the feature actually supports.
* @returns The matching available code (in its original casing) or null if none matched.
*
* @example
* matchAvailableLanguage('en-US', ['en', 'nl']) // 'en'
* matchAvailableLanguage('de-CH', ['de', 'en']) // 'de'
* matchAvailableLanguage('ja', ['en', 'nl']) // null
*/
export function matchAvailableLanguage(
appLanguage: string | null | undefined,
availableCodes: string[]
): string | null {
if (!appLanguage) {
return null;
}
const lower = appLanguage.toLowerCase();
// 1. Exact match against an available code (e.g. 'nl' or even a full tag the feature lists).
const exact = availableCodes.find((c) => c.toLowerCase() === lower);
if (exact) {
return exact;
}
/*
* 2. Alternative-code match: find the language whose region variants include this tag, then return
* its base code if the feature supports it (e.g. 'en-GB' -> 'en').
*/
const altEntry = LANGUAGES.find((l) => l.alternativeCodes?.some((ac) => ac.toLowerCase() === lower));
if (altEntry) {
const altMatch = availableCodes.find((c) => c.toLowerCase() === altEntry.code.toLowerCase());
if (altMatch) {
return altMatch;
}
}
// 3. Base language code match (e.g. an unlisted 'en-ZZ' still resolves to 'en').
const base = normalizeLanguageCode(appLanguage);
const baseMatch = availableCodes.find((c) => c.toLowerCase() === base);
if (baseMatch) {
return baseMatch;
}
return null;
}
/**
* Resolve a default language code for an app/UI language, restricted to a set of available codes
* (e.g. the Diceware wordlist languages returned by the Rust core). Returns the app language when it
* is available, otherwise the first available code, otherwise English.
* @param appLanguage The app/UI language tag.
* (e.g. the Diceware wordlist languages returned by the Rust core, or the identity generator's
* supported languages). Uses {@link matchAvailableLanguage} (region-variant aware), then falls back
* to the first available code, otherwise English.
* @param appLanguage The app/UI/browser language tag.
* @param availableCodes The codes the feature actually supports.
* @returns The resolved ISO code.
*/
export function resolveDefaultLanguage(appLanguage: string | null | undefined, availableCodes: string[]): string {
const iso = normalizeLanguageCode(appLanguage);
if (availableCodes.some((c) => c.toLowerCase() === iso)) {
return iso;
}
return availableCodes[0] ?? DEFAULT_LANGUAGE_CODE;
return matchAvailableLanguage(appLanguage, availableCodes) ?? availableCodes[0] ?? DEFAULT_LANGUAGE_CODE;
}

View File

@@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest';
import {
DEFAULT_LANGUAGE_CODE,
LANGUAGES,
getLanguageInfo,
matchAvailableLanguage,
normalizeLanguageCode,
resolveDefaultLanguage,
} from '../Languages';
/** The identity generator's supported subset, used to exercise restricted matching. */
const IDENTITY_CODES = ['da', 'de', 'en', 'es', 'fr', 'it', 'nl', 'ro', 'sv', 'ur', 'fa'];
/** The Diceware passphrase wordlist subset (owned by the Rust core). */
const DICEWARE_CODES = ['en', 'nl', 'de', 'fr', 'es', 'it'];
describe('Languages', () => {
describe('normalizeLanguageCode', () => {
it('takes the first two characters, lowercased', () => {
expect(normalizeLanguageCode('nl-NL')).toBe('nl');
expect(normalizeLanguageCode('EN')).toBe('en');
expect(normalizeLanguageCode('de-CH')).toBe('de');
});
it('handles null/undefined/empty', () => {
expect(normalizeLanguageCode(null)).toBe('');
expect(normalizeLanguageCode(undefined)).toBe('');
expect(normalizeLanguageCode('')).toBe('');
});
});
describe('getLanguageInfo', () => {
it('returns the metadata for a known code', () => {
const nl = getLanguageInfo('nl');
expect(nl.code).toBe('nl');
expect(nl.label).toBe('Nederlands');
expect(nl.alternativeCodes).toContain('nl-BE');
});
it('falls back to a globe + raw code for unknown languages', () => {
const unknown = getLanguageInfo('xx');
expect(unknown.flag).toBe('🌐');
expect(unknown.label).toBe('xx');
});
});
describe('alternativeCodes data', () => {
it('defines unique, region-tagged alternatives for every language', () => {
const seen = new Set<string>();
for (const lang of LANGUAGES) {
for (const alt of lang.alternativeCodes ?? []) {
expect(alt.toLowerCase()).not.toBe(lang.code.toLowerCase());
expect(seen.has(alt.toLowerCase())).toBe(false);
seen.add(alt.toLowerCase());
}
}
});
});
describe('matchAvailableLanguage', () => {
it('matches an exact available code', () => {
expect(matchAvailableLanguage('nl', IDENTITY_CODES)).toBe('nl');
});
it('maps a region variant via alternativeCodes', () => {
expect(matchAvailableLanguage('nl-BE', IDENTITY_CODES)).toBe('nl');
expect(matchAvailableLanguage('de-CH', IDENTITY_CODES)).toBe('de');
expect(matchAvailableLanguage('en-US', DICEWARE_CODES)).toBe('en');
});
it('is case-insensitive', () => {
expect(matchAvailableLanguage('NL-BE', IDENTITY_CODES)).toBe('nl');
expect(matchAvailableLanguage('En-Us', IDENTITY_CODES)).toBe('en');
});
it('falls back to the base language for unlisted region variants', () => {
expect(matchAvailableLanguage('en-ZZ', IDENTITY_CODES)).toBe('en');
expect(matchAvailableLanguage('nl-ZZ', IDENTITY_CODES)).toBe('nl');
});
it('returns null when the language is not available', () => {
// Japanese has no identity generator.
expect(matchAvailableLanguage('ja', IDENTITY_CODES)).toBeNull();
// Polish is a known UI language but not a Diceware wordlist.
expect(matchAvailableLanguage('pl-PL', DICEWARE_CODES)).toBeNull();
});
it('returns null for empty/invalid input', () => {
expect(matchAvailableLanguage(null, IDENTITY_CODES)).toBeNull();
expect(matchAvailableLanguage(undefined, IDENTITY_CODES)).toBeNull();
expect(matchAvailableLanguage('', IDENTITY_CODES)).toBeNull();
expect(matchAvailableLanguage('invalid', IDENTITY_CODES)).toBeNull();
expect(matchAvailableLanguage('123', IDENTITY_CODES)).toBeNull();
});
});
describe('resolveDefaultLanguage', () => {
it('returns the matched code when available', () => {
expect(resolveDefaultLanguage('de-AT', DICEWARE_CODES)).toBe('de');
expect(resolveDefaultLanguage('nl', DICEWARE_CODES)).toBe('nl');
});
it('falls back to the first available code when nothing matches', () => {
expect(resolveDefaultLanguage('ja', DICEWARE_CODES)).toBe('en');
expect(resolveDefaultLanguage('pl-PL', DICEWARE_CODES)).toBe('en');
});
it('falls back to English when there are no available codes', () => {
expect(resolveDefaultLanguage('nl', [])).toBe(DEFAULT_LANGUAGE_CODE);
});
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { getAvailableLanguages, mapUiLanguageToIdentityLanguage } from '../utils/LanguageProvider';
import { getAvailableLanguages } from '../utils/LanguageProvider';
describe('LanguageProvider', () => {
describe('getAvailableLanguages', () => {
@@ -8,82 +8,9 @@ describe('LanguageProvider', () => {
expect(languages).toBeDefined();
expect(languages.length).toBeGreaterThan(0);
// Returns ISO codes only; display metadata (flag/label) is resolved by clients from core/models.
expect(languages.every(code => typeof code === 'string')).toBe(true);
expect(languages).toContain('en');
expect(languages).toContain('nl');
});
});
describe('mapUiLanguageToIdentityLanguage', () => {
describe('Dutch mappings', () => {
it('should map "nl" to "nl"', () => {
expect(mapUiLanguageToIdentityLanguage('nl')).toBe('nl');
});
it('should map "nl-NL" to "nl"', () => {
expect(mapUiLanguageToIdentityLanguage('nl-NL')).toBe('nl');
});
it('should map "nl-BE" to "nl" (Belgian Dutch)', () => {
expect(mapUiLanguageToIdentityLanguage('nl-BE')).toBe('nl');
});
it('should be case-insensitive for "NL"', () => {
expect(mapUiLanguageToIdentityLanguage('NL')).toBe('nl');
});
it('should be case-insensitive for "NL-NL"', () => {
expect(mapUiLanguageToIdentityLanguage('NL-NL')).toBe('nl');
});
it('should be case-insensitive for "NL-BE"', () => {
expect(mapUiLanguageToIdentityLanguage('NL-BE')).toBe('nl');
});
});
describe('Edge cases', () => {
it('should return null for null input', () => {
expect(mapUiLanguageToIdentityLanguage(null)).toBe(null);
});
it('should return null for undefined input', () => {
expect(mapUiLanguageToIdentityLanguage(undefined)).toBe(null);
});
it('should return null for empty string', () => {
expect(mapUiLanguageToIdentityLanguage('')).toBe(null);
});
it('should return null for whitespace', () => {
expect(mapUiLanguageToIdentityLanguage(' ')).toBe(null);
});
it('should return null for invalid format', () => {
expect(mapUiLanguageToIdentityLanguage('invalid')).toBe(null);
});
it('should return null for numbers', () => {
expect(mapUiLanguageToIdentityLanguage('123')).toBe(null);
});
});
describe('Mixed case and locale variants', () => {
it('should handle mixed case "En-Us"', () => {
expect(mapUiLanguageToIdentityLanguage('En-Us')).toBe('en');
});
it('should handle mixed case "nL-Nl"', () => {
expect(mapUiLanguageToIdentityLanguage('nL-Nl')).toBe('nl');
});
it('should extract base language from unknown locale "en-ZZ"', () => {
expect(mapUiLanguageToIdentityLanguage('en-ZZ')).toBe('en');
});
it('should extract base language from unknown locale "nl-ZZ"', () => {
expect(mapUiLanguageToIdentityLanguage('nl-ZZ')).toBe('nl');
});
});
});
});

View File

@@ -1,39 +1,18 @@
/**
* Internal definition of an identity-generator language: its ISO code plus the alternative locale
* codes that map onto it. Display metadata (flag, native label) is intentionally NOT stored here —
* clients look that up from the shared `core/models` language reference so it lives in one place.
* The set of languages the identity generator can produce identities for.
*/
interface ILanguageDef {
/**
* The language code (e.g., "en", "nl", "de").
*/
value: string;
/**
* Alternative language codes that map to this identity generator language.
* Used for matching UI locale codes to identity generator languages.
* For example, "en-US", "en-GB", "en-CA" all map to "en".
*/
alternativeCodes?: string[];
}
/**
* The languages supported by the identity generator. Only ISO codes and their alternative locale
* codes are defined here; flag emojis and native labels come from the shared `core/models` language
* reference (`getLanguageInfo`).
*/
const LANGUAGE_DEFS: ILanguageDef[] = [
{ value: 'da', alternativeCodes: ['da-DK'] },
{ value: 'de', alternativeCodes: ['de-DE', 'de-AT', 'de-CH', 'de-LU', 'de-LI'] },
{ value: 'en', alternativeCodes: ['en-US', 'en-GB', 'en-CA', 'en-AU', 'en-NZ', 'en-IE', 'en-ZA', 'en-SG', 'en-IN'] },
{ value: 'es', alternativeCodes: ['es-ES', 'es-MX', 'es-AR', 'es-CO', 'es-CL', 'es-PE', 'es-VE', 'es-EC', 'es-GT', 'es-CU', 'es-BO', 'es-DO', 'es-HN', 'es-PY', 'es-SV', 'es-NI', 'es-CR', 'es-PA', 'es-UY', 'es-PR'] },
{ value: 'fr', alternativeCodes: ['fr-FR', 'fr-CA', 'fr-BE', 'fr-CH', 'fr-LU', 'fr-MC'] },
{ value: 'it', alternativeCodes: ['it-IT', 'it-CH', 'it-SM', 'it-VA'] },
{ value: 'nl', alternativeCodes: ['nl-NL', 'nl-BE'] },
{ value: 'ro', alternativeCodes: ['ro-RO', 'ro-MD'] },
{ value: 'sv', alternativeCodes: ['sv-SE', 'sv-FI'] },
{ value: 'ur', alternativeCodes: ['ur-PK', 'ur-IN'] },
{ value: 'fa', alternativeCodes: ['fa-IR', 'fa-AF'] },
const SUPPORTED_LANGUAGE_CODES: string[] = [
'da',
'de',
'en',
'es',
'fr',
'it',
'nl',
'ro',
'sv',
'ur',
'fa',
];
/**
@@ -41,50 +20,5 @@ const LANGUAGE_DEFS: ILanguageDef[] = [
* @returns Array of ISO language codes (e.g. ["da", "de", "en", ...]).
*/
export function getAvailableLanguages(): string[] {
return LANGUAGE_DEFS.map(lang => lang.value);
}
/**
* Maps a UI language code to an identity generator language code.
* If no explicit match is found, returns null to indicate no preference.
*
* @param uiLanguageCode - The UI language code (e.g., "en", "en-US", "nl-NL", "de-DE", "fr")
* @returns The matching identity generator language code or null if no match
*
* @example
* mapUiLanguageToIdentityLanguage("en-US") // returns "en"
* mapUiLanguageToIdentityLanguage("nl") // returns "nl"
* mapUiLanguageToIdentityLanguage("de-CH") // returns "de"
* mapUiLanguageToIdentityLanguage("ja") // returns null (no Japanese identity generator)
*/
export function mapUiLanguageToIdentityLanguage(uiLanguageCode: string | null | undefined): string | null {
if (!uiLanguageCode) {
return null;
}
const normalizedCode = uiLanguageCode.toLowerCase();
// First, try exact match with the primary value
const exactMatch = LANGUAGE_DEFS.find(lang => lang.value.toLowerCase() === normalizedCode);
if (exactMatch) {
return exactMatch.value;
}
// Then, try matching with alternative codes
const alternativeMatch = LANGUAGE_DEFS.find(lang =>
lang.alternativeCodes?.some(code => code.toLowerCase() === normalizedCode)
);
if (alternativeMatch) {
return alternativeMatch.value;
}
// Finally, try matching the base language code (e.g., "en" from "en-US")
const baseCode = normalizedCode.split('-')[0];
const baseMatch = LANGUAGE_DEFS.find(lang => lang.value.toLowerCase() === baseCode);
if (baseMatch) {
return baseMatch.value;
}
// No match found
return null;
return [...SUPPORTED_LANGUAGE_CODES];
}