refactor progress
This commit is contained in:
23
packages/locale/package.json
Normal file
23
packages/locale/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@fluxer/locale",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
63
packages/locale/src/LocaleService.tsx
Normal file
63
packages/locale/src/LocaleService.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {LocaleCode} from '@fluxer/constants/src/Locales';
|
||||
import {
|
||||
getLocaleByCode,
|
||||
getLocaleDisplayName,
|
||||
getLocaleFlagCode,
|
||||
getLocaleLanguageCode,
|
||||
} from '@fluxer/locale/src/catalog/LocaleCatalog';
|
||||
import {resolveLocaleFromAcceptLanguageHeader} from '@fluxer/locale/src/resolution/AcceptLanguageNegotiation';
|
||||
|
||||
export interface LocaleMetadata {
|
||||
code: LocaleCode;
|
||||
languageCode: string;
|
||||
name: string;
|
||||
flagCode: string;
|
||||
}
|
||||
|
||||
export function getLocaleMetadata(locale: LocaleCode): LocaleMetadata {
|
||||
return {
|
||||
code: locale,
|
||||
languageCode: getLocaleLanguageCode(locale),
|
||||
name: getLocaleDisplayName(locale),
|
||||
flagCode: getLocaleFlagCode(locale),
|
||||
};
|
||||
}
|
||||
|
||||
export function getLocaleName(locale: LocaleCode): string {
|
||||
return getLocaleDisplayName(locale);
|
||||
}
|
||||
|
||||
export function getFlagCode(locale: LocaleCode): string {
|
||||
return getLocaleFlagCode(locale);
|
||||
}
|
||||
|
||||
export function getLocaleFromCode(code: string): LocaleCode | null {
|
||||
return getLocaleByCode(code);
|
||||
}
|
||||
|
||||
export function getLocaleCode(locale: LocaleCode): string {
|
||||
return getLocaleLanguageCode(locale);
|
||||
}
|
||||
|
||||
export function parseAcceptLanguage(acceptLanguageHeader: string | null | undefined): LocaleCode {
|
||||
return resolveLocaleFromAcceptLanguageHeader(acceptLanguageHeader);
|
||||
}
|
||||
263
packages/locale/src/catalog/LocaleCatalog.tsx
Normal file
263
packages/locale/src/catalog/LocaleCatalog.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AllLocales, type LocaleCode, Locales} from '@fluxer/constants/src/Locales';
|
||||
|
||||
interface LocaleMetadata {
|
||||
displayName: string;
|
||||
flagCode: string;
|
||||
aliases?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export const DEFAULT_LOCALE: LocaleCode = Locales.EN_US;
|
||||
export const SUPPORTED_LOCALES: ReadonlyArray<LocaleCode> = AllLocales;
|
||||
|
||||
const SUPPORTED_LOCALE_SET: ReadonlySet<LocaleCode> = new Set<LocaleCode>(SUPPORTED_LOCALES);
|
||||
|
||||
const LANGUAGE_FALLBACK_BY_LANGUAGE_CODE: Record<string, LocaleCode> = {
|
||||
en: Locales.EN_US,
|
||||
es: Locales.ES_ES,
|
||||
pt: Locales.PT_BR,
|
||||
zh: Locales.ZH_CN,
|
||||
sv: Locales.SV_SE,
|
||||
};
|
||||
|
||||
const LOCALE_METADATA_BY_CODE: Record<LocaleCode, LocaleMetadata> = {
|
||||
[Locales.AR]: {
|
||||
displayName: 'العربية',
|
||||
flagCode: '1f1f8-1f1e6',
|
||||
},
|
||||
[Locales.BG]: {
|
||||
displayName: 'Български',
|
||||
flagCode: '1f1e7-1f1ec',
|
||||
},
|
||||
[Locales.CS]: {
|
||||
displayName: 'Čeština',
|
||||
flagCode: '1f1e8-1f1ff',
|
||||
},
|
||||
[Locales.DA]: {
|
||||
displayName: 'Dansk',
|
||||
flagCode: '1f1e9-1f1f0',
|
||||
},
|
||||
[Locales.DE]: {
|
||||
displayName: 'Deutsch',
|
||||
flagCode: '1f1e9-1f1ea',
|
||||
},
|
||||
[Locales.EL]: {
|
||||
displayName: 'Ελληνικά',
|
||||
flagCode: '1f1ec-1f1f7',
|
||||
},
|
||||
[Locales.EN_GB]: {
|
||||
displayName: 'English',
|
||||
flagCode: '1f1ec-1f1e7',
|
||||
},
|
||||
[Locales.EN_US]: {
|
||||
displayName: 'English (US)',
|
||||
flagCode: '1f1fa-1f1f8',
|
||||
aliases: ['en'],
|
||||
},
|
||||
[Locales.ES_ES]: {
|
||||
displayName: 'Español (España)',
|
||||
flagCode: '1f1ea-1f1f8',
|
||||
},
|
||||
[Locales.ES_419]: {
|
||||
displayName: 'Español (Latinoamérica)',
|
||||
flagCode: '1f30e',
|
||||
},
|
||||
[Locales.FI]: {
|
||||
displayName: 'Suomi',
|
||||
flagCode: '1f1eb-1f1ee',
|
||||
},
|
||||
[Locales.FR]: {
|
||||
displayName: 'Français',
|
||||
flagCode: '1f1eb-1f1f7',
|
||||
},
|
||||
[Locales.HE]: {
|
||||
displayName: 'עברית',
|
||||
flagCode: '1f1ee-1f1f1',
|
||||
},
|
||||
[Locales.HI]: {
|
||||
displayName: 'हिन्दी',
|
||||
flagCode: '1f1ee-1f1f3',
|
||||
},
|
||||
[Locales.HR]: {
|
||||
displayName: 'Hrvatski',
|
||||
flagCode: '1f1ed-1f1f7',
|
||||
},
|
||||
[Locales.HU]: {
|
||||
displayName: 'Magyar',
|
||||
flagCode: '1f1ed-1f1fa',
|
||||
},
|
||||
[Locales.ID]: {
|
||||
displayName: 'Bahasa Indonesia',
|
||||
flagCode: '1f1ee-1f1e9',
|
||||
},
|
||||
[Locales.IT]: {
|
||||
displayName: 'Italiano',
|
||||
flagCode: '1f1ee-1f1f9',
|
||||
},
|
||||
[Locales.JA]: {
|
||||
displayName: '日本語',
|
||||
flagCode: '1f1ef-1f1f5',
|
||||
},
|
||||
[Locales.KO]: {
|
||||
displayName: '한국어',
|
||||
flagCode: '1f1f0-1f1f7',
|
||||
},
|
||||
[Locales.LT]: {
|
||||
displayName: 'Lietuvių',
|
||||
flagCode: '1f1f1-1f1f9',
|
||||
},
|
||||
[Locales.NL]: {
|
||||
displayName: 'Nederlands',
|
||||
flagCode: '1f1f3-1f1f1',
|
||||
},
|
||||
[Locales.NO]: {
|
||||
displayName: 'Norsk',
|
||||
flagCode: '1f1f3-1f1f4',
|
||||
},
|
||||
[Locales.PL]: {
|
||||
displayName: 'Polski',
|
||||
flagCode: '1f1f5-1f1f1',
|
||||
},
|
||||
[Locales.PT_BR]: {
|
||||
displayName: 'Português (Brasil)',
|
||||
flagCode: '1f1e7-1f1f7',
|
||||
},
|
||||
[Locales.RO]: {
|
||||
displayName: 'Română',
|
||||
flagCode: '1f1f7-1f1f4',
|
||||
},
|
||||
[Locales.RU]: {
|
||||
displayName: 'Русский',
|
||||
flagCode: '1f1f7-1f1fa',
|
||||
},
|
||||
[Locales.SV_SE]: {
|
||||
displayName: 'Svenska',
|
||||
flagCode: '1f1f8-1f1ea',
|
||||
aliases: ['sv'],
|
||||
},
|
||||
[Locales.TH]: {
|
||||
displayName: 'ไทย',
|
||||
flagCode: '1f1f9-1f1ed',
|
||||
},
|
||||
[Locales.TR]: {
|
||||
displayName: 'Türkçe',
|
||||
flagCode: '1f1f9-1f1f7',
|
||||
},
|
||||
[Locales.UK]: {
|
||||
displayName: 'Українська',
|
||||
flagCode: '1f1fa-1f1e6',
|
||||
},
|
||||
[Locales.VI]: {
|
||||
displayName: 'Tiếng Việt',
|
||||
flagCode: '1f1fb-1f1f3',
|
||||
},
|
||||
[Locales.ZH_CN]: {
|
||||
displayName: '简体中文',
|
||||
flagCode: '1f1e8-1f1f3',
|
||||
},
|
||||
[Locales.ZH_TW]: {
|
||||
displayName: '繁體中文',
|
||||
flagCode: '1f1f9-1f1fc',
|
||||
},
|
||||
};
|
||||
|
||||
const NORMALIZED_LOCALE_TO_CODE = createNormalizedLocaleLookup();
|
||||
|
||||
export function normalizeLocaleCode(code: string): string {
|
||||
return code.trim().replace(/_/g, '-').toLowerCase();
|
||||
}
|
||||
|
||||
export function isSupportedLocale(locale: LocaleCode): boolean {
|
||||
return SUPPORTED_LOCALE_SET.has(locale);
|
||||
}
|
||||
|
||||
export function getLocaleDisplayName(locale: LocaleCode): string {
|
||||
return LOCALE_METADATA_BY_CODE[locale].displayName;
|
||||
}
|
||||
|
||||
export function getLocaleFlagCode(locale: LocaleCode): string {
|
||||
return LOCALE_METADATA_BY_CODE[locale].flagCode;
|
||||
}
|
||||
|
||||
export function getLocaleByCode(code: string): LocaleCode | null {
|
||||
const normalizedCode = normalizeLocaleCode(code);
|
||||
if (!normalizedCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const locale = NORMALIZED_LOCALE_TO_CODE.get(normalizedCode);
|
||||
return locale ?? null;
|
||||
}
|
||||
|
||||
export function getLocaleLanguageCode(locale: LocaleCode): string {
|
||||
return locale.split('-')[0];
|
||||
}
|
||||
|
||||
export function getPreferredLocaleForLanguageCode(languageCode: string): LocaleCode | null {
|
||||
const normalizedLanguageCode = normalizeLocaleCode(languageCode);
|
||||
if (!normalizedLanguageCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preferredLocale = LANGUAGE_FALLBACK_BY_LANGUAGE_CODE[normalizedLanguageCode];
|
||||
if (!preferredLocale) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isSupportedLocale(preferredLocale)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preferredLocale;
|
||||
}
|
||||
|
||||
export function findLocaleByLanguagePrefix(languageCode: string): LocaleCode | null {
|
||||
const normalizedLanguageCode = normalizeLocaleCode(languageCode);
|
||||
if (!normalizedLanguageCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const languagePrefix = `${normalizedLanguageCode}-`;
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
if (locale.toLowerCase().startsWith(languagePrefix)) {
|
||||
return locale;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createNormalizedLocaleLookup(): ReadonlyMap<string, LocaleCode> {
|
||||
const lookup = new Map<string, LocaleCode>();
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
lookup.set(normalizeLocaleCode(locale), locale);
|
||||
}
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const aliases = LOCALE_METADATA_BY_CODE[locale].aliases ?? [];
|
||||
for (const alias of aliases) {
|
||||
lookup.set(normalizeLocaleCode(alias), locale);
|
||||
}
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
120
packages/locale/src/resolution/AcceptLanguageNegotiation.tsx
Normal file
120
packages/locale/src/resolution/AcceptLanguageNegotiation.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {LocaleCode} from '@fluxer/constants/src/Locales';
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
findLocaleByLanguagePrefix,
|
||||
getLocaleByCode,
|
||||
getPreferredLocaleForLanguageCode,
|
||||
normalizeLocaleCode,
|
||||
} from '@fluxer/locale/src/catalog/LocaleCatalog';
|
||||
|
||||
interface AcceptLanguagePreference {
|
||||
locale: string;
|
||||
quality: number;
|
||||
}
|
||||
|
||||
export function resolveLocaleFromAcceptLanguageHeader(acceptLanguageHeader: string | null | undefined): LocaleCode {
|
||||
if (!acceptLanguageHeader) {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
try {
|
||||
const preferences = parseAcceptLanguagePreferences(acceptLanguageHeader);
|
||||
const exactLocaleMatch = findExactLocaleMatch(preferences);
|
||||
if (exactLocaleMatch) {
|
||||
return exactLocaleMatch;
|
||||
}
|
||||
|
||||
const languageFallbackMatch = findLanguageFallbackMatch(preferences);
|
||||
if (languageFallbackMatch) {
|
||||
return languageFallbackMatch;
|
||||
}
|
||||
|
||||
return DEFAULT_LOCALE;
|
||||
} catch {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
}
|
||||
|
||||
function parseAcceptLanguagePreferences(acceptLanguageHeader: string): Array<AcceptLanguagePreference> {
|
||||
return acceptLanguageHeader
|
||||
.split(',')
|
||||
.map((languageToken) => parseLanguagePreference(languageToken))
|
||||
.sort((a, b) => b.quality - a.quality);
|
||||
}
|
||||
|
||||
function parseLanguagePreference(languageToken: string): AcceptLanguagePreference {
|
||||
const parts = languageToken.trim().split(';');
|
||||
const locale = parts[0].trim();
|
||||
const quality = parseQualityValue(parts[1]);
|
||||
return {
|
||||
locale,
|
||||
quality,
|
||||
};
|
||||
}
|
||||
|
||||
function parseQualityValue(rawQuality: string | undefined): number {
|
||||
const qualityMatch = rawQuality?.match(/q=([\d.]+)/);
|
||||
if (!qualityMatch) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
const parsedQuality = Number.parseFloat(qualityMatch[1]);
|
||||
if (!Number.isFinite(parsedQuality)) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
return parsedQuality;
|
||||
}
|
||||
|
||||
function findExactLocaleMatch(preferences: ReadonlyArray<AcceptLanguagePreference>): LocaleCode | null {
|
||||
for (const {locale} of preferences) {
|
||||
const exactMatch = getLocaleByCode(locale);
|
||||
if (exactMatch) {
|
||||
return exactMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLanguageFallbackMatch(preferences: ReadonlyArray<AcceptLanguagePreference>): LocaleCode | null {
|
||||
for (const {locale} of preferences) {
|
||||
const languageCode = getLanguageCode(locale);
|
||||
const preferredFallbackLocale = getPreferredLocaleForLanguageCode(languageCode);
|
||||
if (preferredFallbackLocale) {
|
||||
return preferredFallbackLocale;
|
||||
}
|
||||
|
||||
const prefixMatchLocale = findLocaleByLanguagePrefix(languageCode);
|
||||
if (prefixMatchLocale) {
|
||||
return prefixMatchLocale;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLanguageCode(locale: string): string {
|
||||
const normalizedLocale = normalizeLocaleCode(locale);
|
||||
const [languageCode] = normalizedLocale.split('-');
|
||||
return languageCode ?? '';
|
||||
}
|
||||
193
packages/locale/src/tests/LocaleService.test.tsx
Normal file
193
packages/locale/src/tests/LocaleService.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AllLocales, type LocaleCode, Locales} from '@fluxer/constants/src/Locales';
|
||||
import {
|
||||
getFlagCode,
|
||||
getLocaleCode,
|
||||
getLocaleFromCode,
|
||||
getLocaleMetadata,
|
||||
getLocaleName,
|
||||
parseAcceptLanguage,
|
||||
} from '@fluxer/locale/src/LocaleService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface LocaleLookupCase {
|
||||
input: string;
|
||||
expected: LocaleCode | null;
|
||||
}
|
||||
|
||||
interface AcceptLanguageCase {
|
||||
header: string | null | undefined;
|
||||
expected: LocaleCode;
|
||||
}
|
||||
|
||||
const localeNameCases: Array<{locale: LocaleCode; expected: string}> = [
|
||||
{locale: Locales.EN_US, expected: 'English (US)'},
|
||||
{locale: Locales.EN_GB, expected: 'English'},
|
||||
{locale: Locales.DE, expected: 'Deutsch'},
|
||||
{locale: Locales.JA, expected: '日本語'},
|
||||
{locale: Locales.ZH_CN, expected: '简体中文'},
|
||||
{locale: Locales.ZH_TW, expected: '繁體中文'},
|
||||
{locale: Locales.ES_419, expected: 'Español (Latinoamérica)'},
|
||||
{locale: Locales.PT_BR, expected: 'Português (Brasil)'},
|
||||
];
|
||||
|
||||
const flagCodeCases: Array<{locale: LocaleCode; expected: string}> = [
|
||||
{locale: Locales.EN_US, expected: '1f1fa-1f1f8'},
|
||||
{locale: Locales.EN_GB, expected: '1f1ec-1f1e7'},
|
||||
{locale: Locales.DE, expected: '1f1e9-1f1ea'},
|
||||
{locale: Locales.JA, expected: '1f1ef-1f1f5'},
|
||||
{locale: Locales.ZH_CN, expected: '1f1e8-1f1f3'},
|
||||
{locale: Locales.ES_419, expected: '1f30e'},
|
||||
{locale: Locales.PT_BR, expected: '1f1e7-1f1f7'},
|
||||
];
|
||||
|
||||
const localeLookupCases: Array<LocaleLookupCase> = [
|
||||
{input: 'en', expected: Locales.EN_US},
|
||||
{input: 'en-US', expected: Locales.EN_US},
|
||||
{input: 'en-us', expected: Locales.EN_US},
|
||||
{input: 'en_GB', expected: Locales.EN_GB},
|
||||
{input: 'en-gb', expected: Locales.EN_GB},
|
||||
{input: 'de', expected: Locales.DE},
|
||||
{input: 'ja', expected: Locales.JA},
|
||||
{input: 'zh-CN', expected: Locales.ZH_CN},
|
||||
{input: 'zh-cn', expected: Locales.ZH_CN},
|
||||
{input: 'zh-TW', expected: Locales.ZH_TW},
|
||||
{input: 'zh-tw', expected: Locales.ZH_TW},
|
||||
{input: 'es-419', expected: Locales.ES_419},
|
||||
{input: 'pt-br', expected: Locales.PT_BR},
|
||||
{input: 'sv', expected: Locales.SV_SE},
|
||||
{input: 'sv-se', expected: Locales.SV_SE},
|
||||
{input: 'xx', expected: null},
|
||||
{input: '', expected: null},
|
||||
{input: 'en-AU', expected: null},
|
||||
];
|
||||
|
||||
const localeCodeCases: Array<{locale: LocaleCode; expected: string}> = [
|
||||
{locale: Locales.EN_US, expected: 'en'},
|
||||
{locale: Locales.EN_GB, expected: 'en'},
|
||||
{locale: Locales.DE, expected: 'de'},
|
||||
{locale: Locales.ZH_CN, expected: 'zh'},
|
||||
{locale: Locales.ZH_TW, expected: 'zh'},
|
||||
{locale: Locales.ES_ES, expected: 'es'},
|
||||
{locale: Locales.ES_419, expected: 'es'},
|
||||
{locale: Locales.PT_BR, expected: 'pt'},
|
||||
{locale: Locales.SV_SE, expected: 'sv'},
|
||||
{locale: Locales.JA, expected: 'ja'},
|
||||
];
|
||||
|
||||
const acceptLanguageCases: Array<AcceptLanguageCase> = [
|
||||
{header: null, expected: Locales.EN_US},
|
||||
{header: undefined, expected: Locales.EN_US},
|
||||
{header: '', expected: Locales.EN_US},
|
||||
{header: 'de', expected: Locales.DE},
|
||||
{header: 'en-GB', expected: Locales.EN_GB},
|
||||
{header: 'en-gb', expected: Locales.EN_GB},
|
||||
{header: 'de;q=0.9, fr;q=1.0', expected: Locales.FR},
|
||||
{header: 'en-US,en;q=0.9,de;q=0.8', expected: Locales.EN_US},
|
||||
{header: 'en-AU', expected: Locales.EN_US},
|
||||
{header: 'es', expected: Locales.ES_ES},
|
||||
{header: 'pt', expected: Locales.PT_BR},
|
||||
{header: 'zh', expected: Locales.ZH_CN},
|
||||
{header: 'sv', expected: Locales.SV_SE},
|
||||
{header: 'de;q=0.5, ja;q=0.9, fr;q=0.7', expected: Locales.JA},
|
||||
{header: 'de, fr;q=0.5', expected: Locales.DE},
|
||||
{header: 'xx-YY', expected: Locales.EN_US},
|
||||
{header: 'fr-CA', expected: Locales.EN_US},
|
||||
{header: ' de , fr ', expected: Locales.DE},
|
||||
{header: 'zh-TW', expected: Locales.ZH_TW},
|
||||
{header: 'zh-CN', expected: Locales.ZH_CN},
|
||||
{header: 'es-419', expected: Locales.ES_419},
|
||||
{header: 'pt-BR', expected: Locales.PT_BR},
|
||||
{header: 'sv-SE', expected: Locales.SV_SE},
|
||||
{header: 'xx-YY, zz-AA, qq-BB', expected: Locales.EN_US},
|
||||
{header: 'zh-TW, zh;q=0.9', expected: Locales.ZH_TW},
|
||||
{header: 'en-US,en;q=0.9,ja;q=0.8,de;q=0.7,fr;q=0.6', expected: Locales.EN_US},
|
||||
{header: 'de;q=0.999, fr;q=0.998', expected: Locales.DE},
|
||||
{header: 'de;q=0, fr;q=1', expected: Locales.FR},
|
||||
];
|
||||
|
||||
describe('LocaleService', () => {
|
||||
describe('getLocaleMetadata', () => {
|
||||
it('returns consolidated metadata for a locale', () => {
|
||||
expect(getLocaleMetadata(Locales.EN_US)).toEqual({
|
||||
code: Locales.EN_US,
|
||||
languageCode: 'en',
|
||||
name: 'English (US)',
|
||||
flagCode: '1f1fa-1f1f8',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns metadata for every supported locale', () => {
|
||||
for (const locale of AllLocales) {
|
||||
const metadata = getLocaleMetadata(locale);
|
||||
expect(metadata.code).toBe(locale);
|
||||
expect(metadata.name.length).toBeGreaterThan(0);
|
||||
expect(metadata.flagCode.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLocaleName', () => {
|
||||
for (const {locale, expected} of localeNameCases) {
|
||||
it(`returns ${expected} for ${locale}`, () => {
|
||||
expect(getLocaleName(locale)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('getFlagCode', () => {
|
||||
for (const {locale, expected} of flagCodeCases) {
|
||||
it(`returns ${expected} for ${locale}`, () => {
|
||||
expect(getFlagCode(locale)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('getLocaleFromCode', () => {
|
||||
for (const {input, expected} of localeLookupCases) {
|
||||
it(`resolves ${input || 'empty'} to ${expected ?? 'null'}`, () => {
|
||||
expect(getLocaleFromCode(input)).toBe(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it('resolves canonical locale codes for all supported locales', () => {
|
||||
for (const locale of AllLocales) {
|
||||
expect(getLocaleFromCode(locale)).toBe(locale);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLocaleCode', () => {
|
||||
for (const {locale, expected} of localeCodeCases) {
|
||||
it(`returns ${expected} for ${locale}`, () => {
|
||||
expect(getLocaleCode(locale)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('parseAcceptLanguage', () => {
|
||||
for (const {header, expected} of acceptLanguageCases) {
|
||||
it(`selects ${expected} for ${header ?? 'nullish header'}`, () => {
|
||||
expect(parseAcceptLanguage(header)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
5
packages/locale/tsconfig.json
Normal file
5
packages/locale/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfigs/package.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
44
packages/locale/vitest.config.ts
Normal file
44
packages/locale/vitest.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user