diff --git a/packages/app/supported-languages/package.json b/packages/app/supported-languages/package.json index 6d2be3feb..b5504ee60 100644 --- a/packages/app/supported-languages/package.json +++ b/packages/app/supported-languages/package.json @@ -23,8 +23,6 @@ }, "scripts": { "build": "rimraf dist && tsc --project tsconfig.build.json", - "dev": "tsc --watch", - "clean": "rimraf dist .eslintcache", "lint": "biome check . && tsc", "lint:fix": "biome check --write", "test": "vitest run", @@ -36,9 +34,7 @@ "devDependencies": { "@biomejs/biome": "^2.3.7", "@lokalise/biome-config": "^3.1.0", - "@lokalise/node-core": "^14.0.0", "@lokalise/tsconfig": "^3.1.0", - "@rollup/plugin-typescript": "^12.1.0", "@vitest/coverage-v8": "^4.0.15", "rimraf": "^6.0.1", "typescript": "5.9.3", diff --git a/packages/app/supported-languages/src/constants/index.spec.ts b/packages/app/supported-languages/src/constants/index.spec.ts new file mode 100644 index 000000000..3bd0df988 --- /dev/null +++ b/packages/app/supported-languages/src/constants/index.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + getAllLanguages, + getAllRegions, + getAllScripts, + getLokaliseSupportedLanguagesAndLocales, + getStandardLocales, +} from './index.ts' + +describe('getAllLanguages', () => { + it('returns a non-empty array', () => { + expect(getAllLanguages().length).toBeGreaterThan(0) + }) +}) + +describe('getAllRegions', () => { + it('returns a non-empty array', () => { + expect(getAllRegions().length).toBeGreaterThan(0) + }) +}) + +describe('getAllScripts', () => { + it('returns a non-empty array', () => { + expect(getAllScripts().length).toBeGreaterThan(0) + }) +}) + +describe('getStandardLocales', () => { + it('returns a non-empty array', () => { + expect(getStandardLocales().length).toBeGreaterThan(0) + }) +}) + +describe('getLokaliseSupportedLanguagesAndLocales', () => { + it('returns a non-empty array', () => { + expect(getLokaliseSupportedLanguagesAndLocales().length).toBeGreaterThan(0) + }) +}) diff --git a/packages/app/supported-languages/src/constants/index.ts b/packages/app/supported-languages/src/constants/index.ts new file mode 100644 index 000000000..f38875629 --- /dev/null +++ b/packages/app/supported-languages/src/constants/index.ts @@ -0,0 +1,12 @@ +import { languages } from './languages.ts' +import { lokaliseSupportedLanguagesAndLocales } from './lokalise-languages.ts' +import { regions } from './regions.ts' +import { scripts } from './scripts.ts' +import { standardLocales } from './standard-locales.ts' + +export const getAllLanguages = () => Array.from(languages) +export const getAllRegions = () => Array.from(regions) +export const getAllScripts = () => Array.from(scripts) +export const getStandardLocales = () => Array.from(standardLocales) +export const getLokaliseSupportedLanguagesAndLocales = () => + Array.from(lokaliseSupportedLanguagesAndLocales) diff --git a/packages/app/supported-languages/src/languages.ts b/packages/app/supported-languages/src/constants/languages.ts similarity index 98% rename from packages/app/supported-languages/src/languages.ts rename to packages/app/supported-languages/src/constants/languages.ts index d30896d23..435f11d78 100644 --- a/packages/app/supported-languages/src/languages.ts +++ b/packages/app/supported-languages/src/constants/languages.ts @@ -12,7 +12,7 @@ * * @link https://cldr.unicode.org/index */ -export const languages = [ +export const languages = new Set([ 'aa', // Afar 'ab', // Abkhazian 'ace', // Achinese @@ -649,8 +649,4 @@ export const languages = [ 'zun', // Zuni 'zxx', // No linguistic content 'zza', // Zaza -] - -export const languagesSet: ReadonlySet = new Set(languages) - -export type Language = (typeof languages)[number] +]) diff --git a/packages/app/supported-languages/src/constants/lokalise-languages.spec.ts b/packages/app/supported-languages/src/constants/lokalise-languages.spec.ts new file mode 100644 index 000000000..a4c1dd020 --- /dev/null +++ b/packages/app/supported-languages/src/constants/lokalise-languages.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { languages } from './languages.ts' +import { lokaliseSupportedLanguagesAndLocales } from './lokalise-languages.ts' +import { standardLocales } from './standard-locales.ts' + +describe('lokaliseSupportedLanguagesAndLocales', () => { + it('is a subset of all languages and standard locales', () => { + for (const entry of lokaliseSupportedLanguagesAndLocales) { + const isLanguage = languages.has(entry) + const isLocale = standardLocales.has(entry) + expect(isLanguage || isLocale).toBe(true) + } + }) +}) diff --git a/packages/app/supported-languages/src/lokaliseLanguages.ts b/packages/app/supported-languages/src/constants/lokalise-languages.ts similarity index 98% rename from packages/app/supported-languages/src/lokaliseLanguages.ts rename to packages/app/supported-languages/src/constants/lokalise-languages.ts index c6db0056b..00cf93866 100644 --- a/packages/app/supported-languages/src/lokaliseLanguages.ts +++ b/packages/app/supported-languages/src/constants/lokalise-languages.ts @@ -1,5 +1,5 @@ // Subset of CLDR43 languages and standard locales that are supported by Lokalise -export const lokaliseSupportedLanguagesAndLocales = [ +export const lokaliseSupportedLanguagesAndLocales = new Set([ // Subset of CLDR43 languages that are supported by Lokalise 'ru', 'en', @@ -382,4 +382,4 @@ export const lokaliseSupportedLanguagesAndLocales = [ 'fil-PH', 'bs-Latn-BA', 'ca-ES', -] +]) diff --git a/packages/app/supported-languages/src/regions.ts b/packages/app/supported-languages/src/constants/regions.ts similarity index 98% rename from packages/app/supported-languages/src/regions.ts rename to packages/app/supported-languages/src/constants/regions.ts index a26587f73..f8fde29c7 100644 --- a/packages/app/supported-languages/src/regions.ts +++ b/packages/app/supported-languages/src/constants/regions.ts @@ -8,7 +8,7 @@ * * @link https://cldr.unicode.org/index */ -export const regions = [ +export const regions = new Set([ '001', // World '002', // Africa '003', // North America @@ -304,8 +304,4 @@ export const regions = [ 'ZM', // Zambia 'ZW', // Zimbabwe 'ZZ', // Unknown Region -] - -export const regionsSet: ReadonlySet = new Set(regions) - -export type Region = (typeof regions)[number] +]) diff --git a/packages/app/supported-languages/src/constants/rtl-languages.spec.ts b/packages/app/supported-languages/src/constants/rtl-languages.spec.ts new file mode 100644 index 000000000..7c19f910b --- /dev/null +++ b/packages/app/supported-languages/src/constants/rtl-languages.spec.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { languages } from './languages.ts' +import { rtlLanguages } from './rtl-languages.ts' + +describe('rtlLanguages', () => { + it('is a subset of all languages', () => { + for (const entry of rtlLanguages) { + expect(languages.has(entry)).toBe(true) + } + }) +}) diff --git a/packages/app/supported-languages/src/constants/rtl-languages.ts b/packages/app/supported-languages/src/constants/rtl-languages.ts new file mode 100644 index 000000000..671412b56 --- /dev/null +++ b/packages/app/supported-languages/src/constants/rtl-languages.ts @@ -0,0 +1,26 @@ +/** + * Set of language subtags that use right-to-left script by default. + * Used to determine text direction without relying on Intl.Locale.getTextInfo(), + * which is not supported in Firefox. + * + * @link https://www.w3.org/International/questions/qa-scripts + */ +export const rtlLanguages = new Set([ + 'ar', // Arabic + 'arc', // Aramaic + 'ckb', // Central Kurdish (Sorani) + 'dv', // Dhivehi + 'fa', // Persian (Farsi) + 'ha', // Hausa (Ajami) + 'he', // Hebrew + 'khw', // Khowar + 'ks', // Kashmiri + 'ku', // Kurdish (Arabic script) + 'nqo', // N'Ko + 'ps', // Pashto + 'sd', // Sindhi + 'syr', // Syriac + 'ug', // Uyghur + 'ur', // Urdu + 'yi', // Yiddish +]) diff --git a/packages/app/supported-languages/src/scripts.ts b/packages/app/supported-languages/src/constants/scripts.ts similarity index 97% rename from packages/app/supported-languages/src/scripts.ts rename to packages/app/supported-languages/src/constants/scripts.ts index c29d4c253..c45f67123 100644 --- a/packages/app/supported-languages/src/scripts.ts +++ b/packages/app/supported-languages/src/constants/scripts.ts @@ -8,7 +8,7 @@ * * @link https://cldr.unicode.org/index */ -export const scripts = [ +export const scripts = new Set([ 'Adlm', // Adlam 'Afak', // Afaka 'Aghb', // Caucasian Albanian @@ -211,8 +211,4 @@ export const scripts = [ 'Zxxx', // Unwritten 'Zyyy', // Common 'Zzzz', // Unknown Script -] - -export const scriptsSet: ReadonlySet = new Set(scripts) - -export type Script = (typeof scripts)[number] +]) diff --git a/packages/app/supported-languages/src/standard-locales.test.ts b/packages/app/supported-languages/src/constants/standard-locales.spec.ts similarity index 100% rename from packages/app/supported-languages/src/standard-locales.test.ts rename to packages/app/supported-languages/src/constants/standard-locales.spec.ts diff --git a/packages/app/supported-languages/src/standard-locales.ts b/packages/app/supported-languages/src/constants/standard-locales.ts similarity index 97% rename from packages/app/supported-languages/src/standard-locales.ts rename to packages/app/supported-languages/src/constants/standard-locales.ts index 2b3686e0c..7dfec811b 100644 --- a/packages/app/supported-languages/src/standard-locales.ts +++ b/packages/app/supported-languages/src/constants/standard-locales.ts @@ -1,5 +1,5 @@ // Locales supported as default -export const standardLocales = [ +export const standardLocales = new Set([ 'af-ZA', // Afrikaans - South Africa 'am-ET', // Amharic - Ethiopia 'ar-AE', // Arabic - U.A.E. @@ -219,8 +219,4 @@ export const standardLocales = [ 'zh-SG', // Chinese - Singapore 'zh-TW', // Chinese - Taiwan 'zu-ZA', // Zulu -] as const - -export type StandardLocale = (typeof standardLocales)[number] - -export const standardLocalesSet: ReadonlySet = new Set(standardLocales) +]) diff --git a/packages/app/supported-languages/src/display-names.spec.ts b/packages/app/supported-languages/src/display-names.spec.ts new file mode 100644 index 000000000..517a58e7c --- /dev/null +++ b/packages/app/supported-languages/src/display-names.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { getLanguageNameInEnglish, getLocalisedLanguageName } from './display-names.ts' + +describe('getLanguageNameInEnglish', () => { + it('returns name for a language code', () => { + expect(getLanguageNameInEnglish('es')).toBe('Spanish') + expect(getLanguageNameInEnglish('en')).toBe('English') + expect(getLanguageNameInEnglish('fr')).toBe('French') + }) + + it('returns name for a language-region locale', () => { + expect(getLanguageNameInEnglish('en-US')).toBe('English (United States)') + expect(getLanguageNameInEnglish('pt-BR')).toBe('Portuguese (Brazil)') + }) + + it('returns name for a language-script-region locale', () => { + expect(getLanguageNameInEnglish('bs-Latn-BA')).toBe('Bosnian (Latin, Bosnia & Herzegovina)') + expect(getLanguageNameInEnglish('zh-Hans-CN')).toBe('Chinese (Simplified, China)') + }) + + it('returns null for an empty string', () => { + expect(getLanguageNameInEnglish('')).toBeNull() + }) + + it('returns null for an unsupported tag', () => { + expect(getLanguageNameInEnglish('3141516')).toBeNull() + }) +}) + +describe('getLocalisedLanguageName', () => { + it('returns name in the destination language', () => { + expect(getLocalisedLanguageName('es', 'fr')).toBe('espagnol') + expect(getLocalisedLanguageName('en', 'de')).toBe('Englisch') + expect(getLocalisedLanguageName('fr', 'es')).toBe('francés') + }) + + it('returns name for a language-region locale', () => { + expect(getLocalisedLanguageName('en-US', 'fr')).toBe('anglais (États-Unis)') + expect(getLocalisedLanguageName('pt-BR', 'es')).toBe('portugués (Brasil)') + }) + + it('returns name for a language-script-region locale', () => { + expect(getLocalisedLanguageName('bs-Latn-BA', 'fr')).toBe( + 'bosniaque (latin, Bosnie-Herzégovine)', + ) + }) + + it('respects the languageDisplay option', () => { + expect(getLocalisedLanguageName('en-US', 'fr', { languageDisplay: 'dialect' })).toBe( + 'anglais américain', + ) + }) + + it('returns null for an invalid source tag', () => { + expect(getLocalisedLanguageName('', 'en')).toBeNull() + expect(getLocalisedLanguageName('3141516', 'en')).toBeNull() + }) + + it('returns null for an invalid destination tag', () => { + expect(getLocalisedLanguageName('fr', 'wow')).toBeNull() + expect(getLocalisedLanguageName('fr', '')).toBeNull() + }) +}) diff --git a/packages/app/supported-languages/src/display-names.ts b/packages/app/supported-languages/src/display-names.ts new file mode 100644 index 000000000..773175ba8 --- /dev/null +++ b/packages/app/supported-languages/src/display-names.ts @@ -0,0 +1,35 @@ +import type { Locale } from './locale.ts' +import { isSupportedLocale } from './locale.ts' + +export const getLocalisedLanguageName = ( + tag: Locale, + destinationTag: Locale, + options?: Omit, 'type'>, +): string | null => { + if (!isSupportedLocale(tag) || !isSupportedLocale(destinationTag)) { + return null + } + + const displayNames = new Intl.DisplayNames([destinationTag], { + type: 'language', + languageDisplay: 'standard', + ...options, + }) + + try { + const displayName = displayNames.of(tag) + + /* v8 ignore start */ + if (!displayName || displayName === 'root') return null + /* v8 ignore stop */ + + return displayName + } catch { + /* v8 ignore start */ + return null + /* v8 ignore stop */ + } +} + +export const getLanguageNameInEnglish = (tag: Locale): string | null => + getLocalisedLanguageName(tag, 'en') diff --git a/packages/app/supported-languages/src/either.ts b/packages/app/supported-languages/src/either.ts index a98951700..e5c374ec1 100644 --- a/packages/app/supported-languages/src/either.ts +++ b/packages/app/supported-languages/src/either.ts @@ -16,11 +16,3 @@ type Right = { * @see {@link https://antman-does-software.com/stop-catching-errors-in-typescript-use-the-either-type-to-make-your-code-predictable Further reading on motivation for Either type} */ export type Either = NonNullable | Right> - -/*** - * Variation of Either, which may or may not have Error set, but always has Result - */ -export type DefiniteEither = { - error?: T - result: U -} diff --git a/packages/app/supported-languages/src/index.test.ts b/packages/app/supported-languages/src/index.test.ts deleted file mode 100644 index 3c8129393..000000000 --- a/packages/app/supported-languages/src/index.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - getCommonLanguagesForRegion, - getCommonRegionsForLanguage, - getLanguageNameInEnglish, - getLocalisedLanguageName, - getStandardLocales, - isStandardLocale, - isSupportedLocale, - normalizeLocale, - parseLocale, - stringifyLocale, -} from './index.ts' - -describe('supported-languages package', () => { - describe('getStandardLocales', () => { - it('returns a list of languages', () => { - const languages = getStandardLocales() - expect(languages).toHaveLength(219) - - expect(languages).toContain('da-DK') - expect(languages).toContain('en-US') - expect(languages).toContain('bs-Latn-BA') - }) - }) - - describe('isStandardLocale', () => { - it('returns true for matching locales', () => { - expect(isStandardLocale('en-US')).toBe(true) - expect(isStandardLocale('fr-CA')).toBe(true) - expect(isStandardLocale('bs-Latn-BA')).toBe(true) - }) - - it('returns false for non-matching locales', () => { - expect(isStandardLocale('en')).toBe(false) - expect(isStandardLocale('en-DA')).toBe(false) - expect(isStandardLocale('foo')).toBe(false) - }) - }) - - describe('isSupportedLocale', () => { - it('returns true for valid values', () => { - expect(isSupportedLocale('en')).toBe(true) - expect(isSupportedLocale('en-US')).toBe(true) - expect(isSupportedLocale('en-Latn-US')).toBe(true) - expect(isSupportedLocale('sr-Cyrl-CS')).toBe(true) - - expect(isSupportedLocale('en-us')).toBe(true) - expect(isSupportedLocale('my')).toBe(true) // Burmese - - expect(isSupportedLocale('en-RU')).toBe(true) // Valid Language and Region - expect(isSupportedLocale('en-001')).toBe(true) // 001 is a valid Region - - expect(isSupportedLocale('en-1234')).toBe(true) - expect(isSupportedLocale('en-9999')).toBe(true) - expect(isSupportedLocale('en-00001')).toBe(true) - - expect(isSupportedLocale('zh-CN-script')).toBe(true) - expect(isSupportedLocale('in-FRENCH')).toBe(true) // 'in' is identified as 'id' which is Indonesian - expect(isSupportedLocale('in-FILES')).toBe(true) - expect(isSupportedLocale('in-FILES-lalaland')).toBe(true) - }) - - it('returns false for invalid values', () => { - expect(isSupportedLocale('abc')).toBe(false) // invalid language - expect(isSupportedLocale('abc-US')).toBe(false) // invalid language - expect(isSupportedLocale('en-AB')).toBe(false) // invalid region - expect(isSupportedLocale('en-Abcd-US')).toBe(false) // invalid script - }) - }) - - describe('getCommonRegionsForLanguage', () => { - it('returns list of regions for known languages', () => { - expect(getCommonRegionsForLanguage('zh')).toMatchObject(['CN', 'HK', 'MO', 'SG', 'TW']) - }) - - it('returns empty list for languages not in standard locales', () => { - expect(getCommonRegionsForLanguage('ace')).toMatchObject([]) - }) - - it('returns empty list for unknown languages', () => { - expect(getCommonRegionsForLanguage('abc')).toMatchObject([]) - }) - }) - - describe('getCommonLanguagesForRegion', () => { - it('returns list of languages for known regions', () => { - expect(getCommonLanguagesForRegion('CA')).toMatchObject(['en', 'fr', 'iu']) - }) - - it('returns empty list for regions not in standard locales', () => { - expect(getCommonLanguagesForRegion('AC')).toMatchObject([]) - }) - - it('returns empty list for unknown regions', () => { - expect(getCommonLanguagesForRegion('AB')).toMatchObject([]) - }) - }) - - describe('parseLocale', () => { - describe('with valid locale tags', () => { - it('parses language only', () => { - const { language, script, region } = parseLocale('en').result! - expect(language).toBe('en') - expect(script).toBeUndefined() - expect(region).toBeUndefined() - }) - - it('parses language and script', () => { - const { language, script, region } = parseLocale('en-Latn').result! - expect(language).toBe('en') - expect(script).toBe('Latn') - expect(region).toBeUndefined() - }) - - it('parses language and region', () => { - const { language, script, region } = parseLocale('en-US').result! - expect(language).toBe('en') - expect(script).toBeUndefined() - expect(region).toBe('US') - }) - - it('parses language, script and region', () => { - const { language, script, region } = parseLocale('en-Latn-US').result! - expect(language).toBe('en') - expect(script).toBe('Latn') - expect(region).toBe('US') - }) - - it('ignores additional subtags', () => { - const { language, script, region } = parseLocale('en-US-u-ca-gregory').result! - expect(language).toBe('en') - expect(script).toBeUndefined() - expect(region).toBe('US') - }) - }) - - it('throws on unsupported value', () => { - expect(parseLocale('abc-AB').error).toBe('Locale tag abc-AB is not supported') - }) - }) - - describe('stringifyLocale', () => { - it('stringifies language only', () => { - expect(stringifyLocale({ language: 'en' })).toBe('en') - }) - - it('stringifies language and script', () => { - expect(stringifyLocale({ language: 'en', script: 'Latn' })).toBe('en-Latn') - }) - - it('stringifies language and region', () => { - expect(stringifyLocale({ language: 'en', region: 'US' })).toBe('en-US') - }) - - it('stringifies language, script and region', () => { - expect(stringifyLocale({ language: 'en', script: 'Latn', region: 'US' })).toBe('en-Latn-US') - }) - }) - - describe('normalizeLocale', () => { - it('normalizeLocale language', () => { - expect(normalizeLocale('en')).toBe('en') - expect(normalizeLocale('hello')).toBe('hello') - }) - - it('normalizeLocale language and script', () => { - expect(normalizeLocale('en-Latn')).toBe('en-Latn') - }) - - it('normalizeLocale language and region', () => { - expect(normalizeLocale('en-US')).toBe('en-US') - expect(normalizeLocale('en-RU')).toBe('en-RU') - - expect(normalizeLocale('en-001')).toBe('en-001') // 001 is a valid Region - - expect(normalizeLocale('en-1234')).toBe('en') - expect(normalizeLocale('en-9999')).toBe('en') - expect(normalizeLocale('en-00001')).toBe('en') - expect(normalizeLocale('en-hello')).toBe('en') - - expect(normalizeLocale('in-FRENCH')).toBe('id') // 'in' is identified as 'id' which is Indonesian - expect(normalizeLocale('in-FILES')).toBe('id') - }) - - it('normalizeLocale language, script and region', () => { - expect(normalizeLocale('en-Latn-US')).toBe('en-Latn-US') - expect(normalizeLocale('zh-CN-script')).toBe('zh-CN') // script is ignored by Intl.Locale - expect(normalizeLocale('sr-Cyrl-CS')).toBe('sr-Cyrl-RS') // CS is converted to RS by Intl.Locale - expect(normalizeLocale('sr-Cyrl-RS')).toBe('sr-Cyrl-RS') - expect(normalizeLocale('in-FILES-lalaland')).toBe('id') // FILES and lalaland are ignored - }) - - it('normalizeLocale language is und', () => { - expect(normalizeLocale('und')).toBeNull() - }) - }) - - describe('getLanguageNameInEnglish', () => { - it('get language name in English', () => { - expect(getLanguageNameInEnglish('es')).toBe('Spanish') - expect(getLanguageNameInEnglish('en')).toBe('English') - expect(getLanguageNameInEnglish('en-US')).toBe('English (United States)') - expect(getLanguageNameInEnglish('bs-Latn-BA')).toBe('Bosnian (Latin, Bosnia & Herzegovina)') - }) - - it('wrong tag', () => { - expect(getLanguageNameInEnglish('')).toBeNull() - expect(getLanguageNameInEnglish('3141516')).toBeNull() - }) - }) - - describe('getLocalisedLanguageName', () => { - it('get language name in French', () => { - expect(getLocalisedLanguageName('es', 'fr')).toBe('espagnol') - expect(getLocalisedLanguageName('en-US', 'fr')).toBe('anglais (États-Unis)') - expect(getLocalisedLanguageName('en-US', 'fr', { languageDisplay: 'dialect' })).toBe( - 'anglais américain', - ) - expect(getLocalisedLanguageName('bs-Latn-BA', 'fr')).toBe( - 'bosniaque (latin, Bosnie-Herzégovine)', - ) - }) - - it('wrong tag', () => { - expect(getLocalisedLanguageName('', 'en')).toBeNull() - expect(getLocalisedLanguageName('3141516', 'en')).toBeNull() - }) - - it('wrong destination tag', () => { - expect(getLocalisedLanguageName('fr', 'wow')).toBeNull() - expect(getLocalisedLanguageName('fr', 'wow')).toBeNull() - }) - }) -}) diff --git a/packages/app/supported-languages/src/index.ts b/packages/app/supported-languages/src/index.ts index 0040cb400..464a77749 100644 --- a/packages/app/supported-languages/src/index.ts +++ b/packages/app/supported-languages/src/index.ts @@ -1,201 +1,4 @@ -import type { Either } from '@lokalise/node-core' -import type { Language } from './languages.ts' -import { languages, languagesSet } from './languages.ts' -import { lokaliseSupportedLanguagesAndLocales } from './lokaliseLanguages.ts' -import type { Region } from './regions.ts' -import { regions, regionsSet } from './regions.ts' -import type { Script } from './scripts.ts' -import { scripts, scriptsSet } from './scripts.ts' -import type { StandardLocale } from './standard-locales.ts' -import { standardLocales, standardLocalesSet } from './standard-locales.ts' - -/** - * String representation of a locale. - */ -// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -export type LocaleString = Language | `${Language}-${Region}` | `${Language}-${Script}-${Region}` - -/** - * Object representation of a locale. - */ -export type LocaleObject = { - language: string - script?: string - region?: string -} - -/** - * Get list of all our standard locale codes. - */ -export const getStandardLocales = () => { - return standardLocales -} - -/** - * Get list of all available languages we support. - */ -export const getAllLanguages = () => { - return languages -} - -/** - * Get list of all available scripts we support. - */ -export const getAllScripts = () => { - return scripts -} - -/** - * Get list of all available regions we support. - */ -export const getAllRegions = () => { - return regions -} - -export const getLokaliseSupportedLanguagesAndLocales = () => { - return lokaliseSupportedLanguagesAndLocales -} - -/** - * Determine if `tag` is part of our standard locales. - */ -export const isStandardLocale = (tag: string): tag is StandardLocale => { - return standardLocalesSet.has(tag as StandardLocale) -} - -/** - * Parse locale string into object. - * - * @throws {RangeError} If locale is structurally invalid or values are not in our supported values - */ -export const parseLocale = (tag: LocaleString): Either => { - if (!isSupportedLocale(tag)) { - return { error: `Locale tag ${tag} is not supported` } - } - - const { language, script, region } = new Intl.Locale(tag) - - return { result: { language, script, region } } -} - -/** - * Turn LocaleObject into LocaleString. - */ -export const stringifyLocale = (obj: LocaleObject): LocaleString => { - return [obj.language, obj.script, obj.region].filter(Boolean).join('-') -} - -/** - * Verify that `tag` is a valid locale code and all parts of it is in our lists of supported values. - */ -export const isSupportedLocale = (tag: string) => { - try { - const { language, script, region } = new Intl.Locale(tag) - - if (region && !regionsSet.has(region)) { - return false - } - - if (script && !scriptsSet.has(script)) { - return false - } - - return languagesSet.has(language) - } catch (_) { - return false - } -} - -/** - * Get common regions for a language, based on our standard locales. - */ -export const getCommonRegionsForLanguage = (() => { - const cache: Record> = {} - - // Create a mapping of languages to common regions up front - for (const locale of standardLocales) { - const { language, region } = new Intl.Locale(locale) - - if (!region) { - continue - } - - const current = cache[language] ?? [] - current.push(region) - cache[language] = current - } - - return (language: string): Array => { - return cache[language] ?? [] - } -})() - -/** - * Get common languages for a region, based on our standard locales. - */ -export const getCommonLanguagesForRegion = (() => { - const cache: Record> = {} - - // Create a mapping of languages to common regions up front - for (const locale of standardLocales) { - const { language, region } = new Intl.Locale(locale) - - if (!region) { - continue - } - - const current = cache[region] ?? [] - current.push(language) - cache[region] = current - } - - return (region: string): Array => { - return cache[region] ?? [] - } -})() - -export const normalizeLocale = (tag: string) => { - /** - * "und" is used in some systems to mean an "Unknown Language". - * Throughout our system however, we prefer to use "null" to mean unknown language.* - * */ - if (tag === 'und') return null - - try { - return stringifyLocale(new Intl.Locale(tag)) - } catch (_) { - return null - } -} - -export const getLanguageNameInEnglish = (tag: LocaleString): string | null => { - return getLocalisedLanguageName(tag, 'en') -} - -export const getLocalisedLanguageName = ( - tag: LocaleString, - destinationTag: LocaleString, - options?: Omit, 'type'>, -): string | null => { - if (!isSupportedLocale(tag) || !isSupportedLocale(destinationTag)) { - return null - } - - const displayNames = new Intl.DisplayNames([destinationTag], { - type: 'language', - languageDisplay: 'standard', - ...options, - }) - - try { - const displayName = displayNames.of(tag) - - if (displayName === 'root') { - return null - } - - return displayName ?? null - } catch (_) { - return null - } -} +export * from './constants/index.ts' +export * from './display-names.ts' +export * from './locale.ts' +export * from './locale-lookup.ts' diff --git a/packages/app/supported-languages/src/locale-lookup.spec.ts b/packages/app/supported-languages/src/locale-lookup.spec.ts new file mode 100644 index 000000000..bf28b153a --- /dev/null +++ b/packages/app/supported-languages/src/locale-lookup.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { getCommonLanguagesForRegion, getCommonRegionsForLanguage } from './locale-lookup.ts' + +describe('getCommonRegionsForLanguage', () => { + it('returns regions for a language present in standard locales', () => { + expect(getCommonRegionsForLanguage('zh')).toMatchObject(['CN', 'HK', 'MO', 'SG', 'TW']) + expect(getCommonRegionsForLanguage('en')).toContain('US') + expect(getCommonRegionsForLanguage('en')).toContain('GB') + }) + + it('returns empty array for a language not present in standard locales', () => { + expect(getCommonRegionsForLanguage('ace')).toEqual([]) + }) + + it('returns empty array for an unknown language', () => { + expect(getCommonRegionsForLanguage('abc')).toEqual([]) + }) +}) + +describe('getCommonLanguagesForRegion', () => { + it('returns languages for a region present in standard locales', () => { + expect(getCommonLanguagesForRegion('CA')).toMatchObject(['en', 'fr', 'iu']) + expect(getCommonLanguagesForRegion('CH')).toContain('de') + expect(getCommonLanguagesForRegion('CH')).toContain('fr') + }) + + it('returns empty array for a region not present in standard locales', () => { + expect(getCommonLanguagesForRegion('AC')).toEqual([]) + }) + + it('returns empty array for an unknown region', () => { + expect(getCommonLanguagesForRegion('AB')).toEqual([]) + }) +}) diff --git a/packages/app/supported-languages/src/locale-lookup.ts b/packages/app/supported-languages/src/locale-lookup.ts new file mode 100644 index 000000000..8ac9c7c03 --- /dev/null +++ b/packages/app/supported-languages/src/locale-lookup.ts @@ -0,0 +1,49 @@ +import { standardLocales } from './constants/standard-locales.ts' + +/** + * Get common regions for a language, based on our standard locales. + */ +export const getCommonRegionsForLanguage = (() => { + const cache: Record> = {} + + // Create a mapping of languages to common regions up front + for (const locale of standardLocales) { + const { language, region } = new Intl.Locale(locale) + + if (!region) { + continue + } + + const current = cache[language] ?? [] + current.push(region) + cache[language] = current + } + + return (language: string): Array => { + return cache[language] ?? [] + } +})() + +/** + * Get common languages for a region, based on our standard locales. + */ +export const getCommonLanguagesForRegion = (() => { + const cache: Record> = {} + + // Create a mapping of languages to common regions up front + for (const locale of standardLocales) { + const { language, region } = new Intl.Locale(locale) + + if (!region) { + continue + } + + const current = cache[region] ?? [] + current.push(language) + cache[region] = current + } + + return (region: string): Array => { + return cache[region] ?? [] + } +})() diff --git a/packages/app/supported-languages/src/locale.spec.ts b/packages/app/supported-languages/src/locale.spec.ts new file mode 100644 index 000000000..7d81fdb54 --- /dev/null +++ b/packages/app/supported-languages/src/locale.spec.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest' +import { languages } from './constants/languages.ts' +import { rtlLanguages } from './constants/rtl-languages.ts' +import { + getLocaleDirection, + isStandardLocale, + isSupportedLocale, + normalizeLocale, + parseLocale, + stringifyLocale, +} from './locale.ts' + +describe('isSupportedLocale', () => { + it('returns true for valid language codes', () => { + expect(isSupportedLocale('en')).toBe(true) + expect(isSupportedLocale('my')).toBe(true) // Burmese + }) + + it('returns true for valid language-region locales', () => { + expect(isSupportedLocale('en-US')).toBe(true) + expect(isSupportedLocale('en-RU')).toBe(true) + expect(isSupportedLocale('en-001')).toBe(true) // 001 is a valid region + }) + + it('returns true for valid language-script-region locales', () => { + expect(isSupportedLocale('en-Latn-US')).toBe(true) + expect(isSupportedLocale('sr-Cyrl-CS')).toBe(true) + }) + + it('is case-insensitive', () => { + expect(isSupportedLocale('en-us')).toBe(true) + }) + + it('returns false for an unknown language', () => { + expect(isSupportedLocale('abc')).toBe(false) + expect(isSupportedLocale('abc-US')).toBe(false) + }) + + it('returns false for an unknown region', () => { + expect(isSupportedLocale('en-AB')).toBe(false) + }) + + it('returns false for an unknown script', () => { + expect(isSupportedLocale('en-Abcd-US')).toBe(false) + }) +}) + +describe('isStandardLocale', () => { + it('returns true for standard locales', () => { + expect(isStandardLocale('en-US')).toBe(true) + expect(isStandardLocale('fr-CA')).toBe(true) + expect(isStandardLocale('bs-Latn-BA')).toBe(true) + }) + + it('returns false for language-only codes', () => { + expect(isStandardLocale('en')).toBe(false) + }) + + it('returns false for non-standard locale combinations', () => { + expect(isStandardLocale('en-DA')).toBe(false) + expect(isStandardLocale('foo')).toBe(false) + }) +}) + +describe('stringifyLocale', () => { + it('stringifies language only', () => { + expect(stringifyLocale({ language: 'en' })).toBe('en') + }) + + it('stringifies language and script', () => { + expect(stringifyLocale({ language: 'en', script: 'Latn' })).toBe('en-Latn') + }) + + it('stringifies language and region', () => { + expect(stringifyLocale({ language: 'en', region: 'US' })).toBe('en-US') + }) + + it('stringifies language, script and region', () => { + expect(stringifyLocale({ language: 'en', script: 'Latn', region: 'US' })).toBe('en-Latn-US') + }) +}) + +describe('parseLocale', () => { + it('parses language only', () => { + const { language, script, region } = parseLocale('en').result! + expect(language).toBe('en') + expect(script).toBeUndefined() + expect(region).toBeUndefined() + }) + + it('parses language and script', () => { + const { language, script, region } = parseLocale('en-Latn').result! + expect(language).toBe('en') + expect(script).toBe('Latn') + expect(region).toBeUndefined() + }) + + it('parses language and region', () => { + const { language, script, region } = parseLocale('en-US').result! + expect(language).toBe('en') + expect(script).toBeUndefined() + expect(region).toBe('US') + }) + + it('parses language, script and region', () => { + const { language, script, region } = parseLocale('en-Latn-US').result! + expect(language).toBe('en') + expect(script).toBe('Latn') + expect(region).toBe('US') + }) + + it('ignores additional subtags', () => { + const { language, script, region } = parseLocale('en-US-u-ca-gregory').result! + expect(language).toBe('en') + expect(script).toBeUndefined() + expect(region).toBe('US') + }) + + it('returns error for an unsupported locale', () => { + expect(parseLocale('abc-AB').error).toBe('Locale tag abc-AB is not supported') + }) +}) + +describe('getLocaleDirection', () => { + it.each([ + ...Array.from(rtlLanguages).map((lang) => [lang, 'rtl'] as [string, 'rtl']), + ...Array.from(languages) + .filter((lang) => !rtlLanguages.has(lang)) + .map((lang) => [lang, 'ltr'] as [string, 'ltr']), + ['ar-SA', 'rtl'], + ['en-US', 'ltr'], + ['not-a-valid-!!!-locale', null], + ['abc', null], + ])('getLocaleDirection(%s) → %s', (tag, expected) => { + expect(getLocaleDirection(tag)).toBe(expected) + }) +}) + +describe('normalizeLocale', () => { + it('returns null for "und"', () => { + expect(normalizeLocale('und')).toBeNull() + }) + + it('returns null for invalid locale', () => { + expect(normalizeLocale('not a locale')).toBeNull() + }) + + it('normalizes language only', () => { + expect(normalizeLocale('en')).toBe('en') + }) + + it('normalizes language and script', () => { + expect(normalizeLocale('en-Latn')).toBe('en-Latn') + }) + + it('normalizes language and region', () => { + expect(normalizeLocale('en-US')).toBe('en-US') + expect(normalizeLocale('en-001')).toBe('en-001') + }) + + it('normalizes language, script and region', () => { + expect(normalizeLocale('en-Latn-US')).toBe('en-Latn-US') + expect(normalizeLocale('sr-Cyrl-CS')).toBe('sr-Cyrl-RS') // CS → RS via Intl.Locale + }) + + it('strips invalid subtags', () => { + expect(normalizeLocale('en-1234')).toBe('en') + expect(normalizeLocale('in-FRENCH')).toBe('id') // 'in' → 'id' (Indonesian) + expect(normalizeLocale('zh-CN-script')).toBe('zh-CN') + }) + + it('returns the tag as-is for structurally valid but unknown values', () => { + expect(normalizeLocale('hello')).toBe('hello') + }) +}) diff --git a/packages/app/supported-languages/src/locale.ts b/packages/app/supported-languages/src/locale.ts new file mode 100644 index 000000000..e1ae0ff2b --- /dev/null +++ b/packages/app/supported-languages/src/locale.ts @@ -0,0 +1,97 @@ +import { languages } from './constants/languages.ts' +import { regions } from './constants/regions.ts' +import { rtlLanguages } from './constants/rtl-languages.ts' +import { scripts } from './constants/scripts.ts' +import { standardLocales } from './constants/standard-locales.ts' +import type { Either } from './either.ts' + +/** + * String representation of a locale. + */ +export type Locale = string | `${string}-${string}` | `${string}-${string}-${string}` +/** + * @deprecated Use Locale instead + */ +export type LocaleString = Locale + +/** + * Object representation of a locale. + */ +export type LocaleObject = { + language: string + script?: string + region?: string +} + +export type LanguageDirection = 'ltr' | 'rtl' + +/** + * Verify that `tag` is a valid locale code and all parts of it is in our lists of supported values. + */ +export const isSupportedLocale = (tag: Locale) => { + try { + const { language, script, region } = new Intl.Locale(tag) + + if (region && !regions.has(region)) { + return false + } + + if (script && !scripts.has(script)) { + return false + } + + return languages.has(language) + } catch (_) { + return false + } +} + +/** + * Determine if `tag` is part of our standard locales. + */ +export const isStandardLocale = (tag: Locale): boolean => standardLocales.has(tag) + +/** + * Turn LocaleObject into LocaleString. + */ +export const stringifyLocale = (obj: LocaleObject): Locale => + [obj.language, obj.script, obj.region].filter(Boolean).join('-') + +/** + * Parse locale string into object. + * + * @throws {RangeError} If locale is structurally invalid or values are not in our supported values + */ +export const parseLocale = (tag: Locale): Either => { + if (!isSupportedLocale(tag)) { + return { error: `Locale tag ${tag} is not supported` } + } + + const { language, script, region } = new Intl.Locale(tag) + + return { result: { language, script, region } } +} + +export const normalizeLocale = (tag: Locale) => { + /** + * "und" is used in some systems to mean an "Unknown Language". + * Throughout our system however, we prefer to use "null" to mean unknown language.* + * */ + if (tag === 'und') return null + + try { + return stringifyLocale(new Intl.Locale(tag)) + } catch { + return null + } +} + +export const getLocaleDirection = (tag: Locale): LanguageDirection | null => { + const parsedLocale = parseLocale(tag) + + return parsedLocale.result + ? rtlLanguages.has(parsedLocale.result?.language) + ? 'rtl' + : 'ltr' + : null +} diff --git a/packages/app/supported-languages/vitest.config.ts b/packages/app/supported-languages/vitest.config.ts index 1e482c119..596a828ba 100644 --- a/packages/app/supported-languages/vitest.config.ts +++ b/packages/app/supported-languages/vitest.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ coverage: { provider: 'v8', include: ['src/**/*.ts'], - exclude: ['src/index.ts'], + exclude: ['src/**/index.ts'], thresholds: { lines: 100, functions: 100,