@oxyhq/core 9.2.4 → 10.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/i18n/index.js +22 -2
- package/dist/cjs/i18n/locales/en-US.json +4 -1
- package/dist/cjs/i18n/locales/es-ES.json +4 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +4 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +4 -1
- package/dist/cjs/index.js +16 -5
- package/dist/cjs/mixins/OxyServices.assets.js +10 -6
- package/dist/cjs/mixins/OxyServices.language.js +11 -10
- package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
- package/dist/cjs/mixins/OxyServices.user.js +40 -0
- package/dist/cjs/server/safeFetch.js +3 -3
- package/dist/cjs/session/SessionClient.js +1 -1
- package/dist/cjs/shared/utils/colorUtils.js +9 -9
- package/dist/cjs/utils/languageUtils.js +195 -158
- package/dist/cjs/utils/platform.js +9 -2
- package/dist/cjs/utils/textNormalization.js +168 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/i18n/index.js +22 -2
- package/dist/esm/i18n/locales/en-US.json +4 -1
- package/dist/esm/i18n/locales/es-ES.json +4 -1
- package/dist/esm/i18n/locales/locales/en-US.json +4 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +4 -1
- package/dist/esm/index.js +5 -1
- package/dist/esm/mixins/OxyServices.assets.js +10 -6
- package/dist/esm/mixins/OxyServices.language.js +12 -11
- package/dist/esm/mixins/OxyServices.privacy.js +6 -0
- package/dist/esm/mixins/OxyServices.user.js +40 -0
- package/dist/esm/server/safeFetch.js +3 -3
- package/dist/esm/session/SessionClient.js +1 -1
- package/dist/esm/shared/utils/colorUtils.js +9 -9
- package/dist/esm/utils/languageUtils.js +189 -156
- package/dist/esm/utils/platform.js +9 -2
- package/dist/esm/utils/textNormalization.js +164 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +4 -3
- package/dist/types/mixins/OxyServices.language.d.ts +4 -4
- package/dist/types/mixins/OxyServices.user.d.ts +30 -0
- package/dist/types/models/interfaces.d.ts +14 -0
- package/dist/types/session/accountDialogController.d.ts +1 -1
- package/dist/types/utils/languageUtils.d.ts +86 -24
- package/dist/types/utils/textNormalization.d.ts +100 -0
- package/package.json +3 -3
- package/src/HttpService.ts +2 -2
- package/src/crypto/keyManager.ts +3 -3
- package/src/crypto/recoveryPhrase.ts +1 -1
- package/src/i18n/index.ts +21 -2
- package/src/i18n/locales/en-US.json +4 -1
- package/src/i18n/locales/es-ES.json +4 -1
- package/src/index.ts +16 -2
- package/src/mixins/OxyServices.assets.ts +15 -11
- package/src/mixins/OxyServices.language.ts +31 -17
- package/src/mixins/OxyServices.privacy.ts +6 -0
- package/src/mixins/OxyServices.security.ts +1 -1
- package/src/mixins/OxyServices.user.ts +57 -0
- package/src/models/interfaces.ts +14 -0
- package/src/server/safeFetch.ts +3 -3
- package/src/session/SessionClient.ts +1 -1
- package/src/session/accountDialogController.ts +1 -1
- package/src/shared/utils/colorUtils.ts +11 -11
- package/src/shared/utils/errorUtils.ts +1 -1
- package/src/utils/__tests__/languageUtils.test.ts +219 -0
- package/src/utils/__tests__/textNormalization.test.ts +196 -0
- package/src/utils/avatarUtils.ts +1 -1
- package/src/utils/languageUtils.ts +223 -162
- package/src/utils/platform.ts +21 -3
- package/src/utils/requestUtils.ts +3 -3
- package/src/utils/sessionUtils.ts +2 -2
- package/src/utils/textNormalization.ts +173 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { normalizeInlineText, normalizeMultilineText } from '../textNormalization';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whitespace characters are built from their code points rather than pasted as
|
|
5
|
+
* literals: an NBSP or a U+2028 in the source would be invisible to a reviewer
|
|
6
|
+
* and trivially "fixed" by an editor, silently gutting the test it anchors.
|
|
7
|
+
*/
|
|
8
|
+
const NBSP = String.fromCharCode(0x00a0); // no-break space
|
|
9
|
+
const EN_QUAD = String.fromCharCode(0x2000); // U+2000 space block
|
|
10
|
+
const IDEOGRAPHIC_SPACE = String.fromCharCode(0x3000); // CJK full-width space
|
|
11
|
+
const NARROW_NBSP = String.fromCharCode(0x202f);
|
|
12
|
+
const ZWNBSP = String.fromCharCode(0xfeff); // BOM when leading
|
|
13
|
+
const LINE_SEPARATOR = String.fromCharCode(0x2028);
|
|
14
|
+
const PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);
|
|
15
|
+
|
|
16
|
+
/** "é" as a base letter plus a combining acute accent (NFD form). */
|
|
17
|
+
const DECOMPOSED_E_ACUTE = `e${String.fromCharCode(0x0301)}`;
|
|
18
|
+
/** The precomposed "é" (NFC form) the two above must normalize into. */
|
|
19
|
+
const COMPOSED_E_ACUTE = String.fromCharCode(0x00e9);
|
|
20
|
+
|
|
21
|
+
describe('normalizeInlineText', () => {
|
|
22
|
+
it('flattens the indented multi-line <title> that caused the original bug', () => {
|
|
23
|
+
expect(normalizeInlineText('\n Mi título\n ')).toBe('Mi título');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('collapses every whitespace form to a single space', () => {
|
|
27
|
+
expect(normalizeInlineText('a\tb\nc\r\nd e')).toBe('a b c d e');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('collapses Unicode spaces (NBSP, U+2000 block, ideographic, narrow NBSP)', () => {
|
|
31
|
+
expect(
|
|
32
|
+
normalizeInlineText(`a${NBSP}b${EN_QUAD}c${IDEOGRAPHIC_SPACE}d${NARROW_NBSP}e`)
|
|
33
|
+
).toBe('a b c d e');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('collapses the Unicode line and paragraph separators', () => {
|
|
37
|
+
expect(normalizeInlineText(`a${LINE_SEPARATOR}b${PARAGRAPH_SEPARATOR}c`)).toBe('a b c');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('strips a leading BOM / zero-width no-break space', () => {
|
|
41
|
+
expect(normalizeInlineText(`${ZWNBSP}Título`)).toBe('Título');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('handles CRLF line endings', () => {
|
|
45
|
+
expect(normalizeInlineText('News\r\n\r\nToday')).toBe('News Today');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns an empty string for empty and whitespace-only input', () => {
|
|
49
|
+
expect(normalizeInlineText('')).toBe('');
|
|
50
|
+
expect(normalizeInlineText(' ')).toBe('');
|
|
51
|
+
expect(normalizeInlineText(`\n\t ${NBSP}\r\n`)).toBe('');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('NFC-normalizes so decomposed accents are stored composed', () => {
|
|
55
|
+
const normalized = normalizeInlineText(`Ren${DECOMPOSED_E_ACUTE}e`);
|
|
56
|
+
expect(normalized).toBe(`Ren${COMPOSED_E_ACUTE}e`);
|
|
57
|
+
expect(normalized).toHaveLength(5);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('leaves already-clean text untouched', () => {
|
|
61
|
+
expect(normalizeInlineText('Hacker News')).toBe('Hacker News');
|
|
62
|
+
expect(normalizeInlineText('Título en español — con guion')).toBe(
|
|
63
|
+
'Título en español — con guion'
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does not truncate long values (length caps are a product rule)', () => {
|
|
68
|
+
const long = 'a'.repeat(500);
|
|
69
|
+
expect(normalizeInlineText(long)).toHaveLength(500);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('does not strip markup or punctuation (that is the sanitizer\'s job)', () => {
|
|
73
|
+
expect(normalizeInlineText(' <b>Bold</b> & "quoted" ')).toBe('<b>Bold</b> & "quoted"');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('is idempotent', () => {
|
|
77
|
+
const inputs = [
|
|
78
|
+
'\n Mi título\n ',
|
|
79
|
+
`a${NBSP}${NBSP}b`,
|
|
80
|
+
'Hacker News',
|
|
81
|
+
'',
|
|
82
|
+
' ',
|
|
83
|
+
`Ren${DECOMPOSED_E_ACUTE}e`,
|
|
84
|
+
];
|
|
85
|
+
for (const input of inputs) {
|
|
86
|
+
const once = normalizeInlineText(input);
|
|
87
|
+
expect(normalizeInlineText(once)).toBe(once);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('normalizeMultilineText', () => {
|
|
93
|
+
it('preserves the author\'s paragraphs', () => {
|
|
94
|
+
const body = 'First paragraph.\n\nSecond paragraph.\nSame paragraph, next line.';
|
|
95
|
+
expect(normalizeMultilineText(body)).toBe(body);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('collapses a blank line that contains spaces — the ordering bug', () => {
|
|
99
|
+
// The "blank" lines hold spaces, so they break the run of \n characters: a
|
|
100
|
+
// bare /\n{3,}/ collapse never sees them. Trailing horizontal whitespace
|
|
101
|
+
// must be stripped FIRST.
|
|
102
|
+
expect(normalizeMultilineText('a\n \n \nb')).toBe('a\n\nb');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('collapses a blank line that contains tabs and NBSP', () => {
|
|
106
|
+
expect(normalizeMultilineText(`a\n\t\n${NBSP}${NBSP}\nb`)).toBe('a\n\nb');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('strips trailing horizontal whitespace from every line', () => {
|
|
110
|
+
expect(normalizeMultilineText('one \ntwo\t\nthree ')).toBe('one\ntwo\nthree');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('collapses three or more newlines to a single blank line', () => {
|
|
114
|
+
expect(normalizeMultilineText('a\n\n\n\n\nb')).toBe('a\n\nb');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('keeps exactly one blank line as-is', () => {
|
|
118
|
+
expect(normalizeMultilineText('a\n\nb')).toBe('a\n\nb');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('collapses runs of horizontal whitespace to one space, keeping newlines', () => {
|
|
122
|
+
expect(normalizeMultilineText('hello world\nsecond\t\tline')).toBe(
|
|
123
|
+
'hello world\nsecond line'
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('preserves a single-space indent (it is the author\'s, not markup noise)', () => {
|
|
128
|
+
expect(normalizeMultilineText('a\n b')).toBe('a\n b');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('normalizes CRLF and lone CR to \\n', () => {
|
|
132
|
+
expect(normalizeMultilineText('a\r\nb\rc')).toBe('a\nb\nc');
|
|
133
|
+
expect(normalizeMultilineText('a\r\n\r\n\r\n\r\nb')).toBe('a\n\nb');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('normalizes the Unicode line and paragraph separators to \\n', () => {
|
|
137
|
+
expect(normalizeMultilineText(`a${LINE_SEPARATOR}b${PARAGRAPH_SEPARATOR}c`)).toBe('a\nb\nc');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('collapses Unicode spaces without touching line structure', () => {
|
|
141
|
+
expect(normalizeMultilineText(`a${NBSP}${NBSP}b\nc${IDEOGRAPHIC_SPACE}d`)).toBe('a b\nc d');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('trims both ends, including leading and trailing blank lines', () => {
|
|
145
|
+
expect(normalizeMultilineText('\n\n Hello\n\nWorld \n\n ')).toBe('Hello\n\nWorld');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('returns an empty string for empty and whitespace-only input', () => {
|
|
149
|
+
expect(normalizeMultilineText('')).toBe('');
|
|
150
|
+
expect(normalizeMultilineText(' ')).toBe('');
|
|
151
|
+
expect(normalizeMultilineText(`\n\n \t${NBSP}\r\n \n`)).toBe('');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('NFC-normalizes so decomposed accents are stored composed', () => {
|
|
155
|
+
expect(normalizeMultilineText(`Caf${DECOMPOSED_E_ACUTE}\n\nabierto`)).toBe(
|
|
156
|
+
`Caf${COMPOSED_E_ACUTE}\n\nabierto`
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('leaves an already-clean body untouched', () => {
|
|
161
|
+
const body = 'Line one\nLine two\n\nNew paragraph.';
|
|
162
|
+
expect(normalizeMultilineText(body)).toBe(body);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('cleans a realistic federated post body without losing its paragraphs', () => {
|
|
166
|
+
const federated = ' Hola a todos.\r\n \r\n\r\nEsto es una prueba.\t\r\nFin. ';
|
|
167
|
+
expect(normalizeMultilineText(federated)).toBe(
|
|
168
|
+
'Hola a todos.\n\nEsto es una prueba.\nFin.'
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('is idempotent', () => {
|
|
173
|
+
const inputs = [
|
|
174
|
+
'a\n \n \nb',
|
|
175
|
+
'a\r\n\r\n\r\nb',
|
|
176
|
+
'First paragraph.\n\nSecond paragraph.',
|
|
177
|
+
`a${NBSP}b`,
|
|
178
|
+
'a\n b',
|
|
179
|
+
'',
|
|
180
|
+
' ',
|
|
181
|
+
`Caf${DECOMPOSED_E_ACUTE}`,
|
|
182
|
+
];
|
|
183
|
+
for (const input of inputs) {
|
|
184
|
+
const once = normalizeMultilineText(input);
|
|
185
|
+
expect(normalizeMultilineText(once)).toBe(once);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('choosing between the two helpers', () => {
|
|
191
|
+
it('inline flattens what multiline preserves', () => {
|
|
192
|
+
const body = 'Title\n\nSubtitle';
|
|
193
|
+
expect(normalizeInlineText(body)).toBe('Title Subtitle');
|
|
194
|
+
expect(normalizeMultilineText(body)).toBe('Title\n\nSubtitle');
|
|
195
|
+
});
|
|
196
|
+
});
|
package/src/utils/avatarUtils.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface AssetVisibilityService {
|
|
|
17
17
|
export async function updateAvatarVisibility(
|
|
18
18
|
fileId: string | undefined,
|
|
19
19
|
oxyServices: AssetVisibilityService,
|
|
20
|
-
contextName
|
|
20
|
+
contextName = 'AvatarUtils'
|
|
21
21
|
): Promise<void> {
|
|
22
22
|
if (!fileId || fileId.startsWith('temp-')) {
|
|
23
23
|
return;
|
|
@@ -1,195 +1,256 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Locale utilities for OxyServices.
|
|
3
|
+
*
|
|
4
|
+
* The Oxy platform models account languages as full BCP-47 locales
|
|
5
|
+
* (`language-REGION`, e.g. `es-ES`, `es-MX`, `pt-BR`). Region is significant:
|
|
6
|
+
* "Spanish (Spain)" is a different locale than "Spanish (Mexico)". A user's
|
|
7
|
+
* account locales live on `User.languages` as an ordered list with the PRIMARY
|
|
8
|
+
* (UI) locale first — there is no singular `language` field.
|
|
9
|
+
*
|
|
10
|
+
* This module is the single source of truth for the supported-locale catalog
|
|
11
|
+
* and every locale operation the SDK exposes: parsing base subtags, canonical
|
|
12
|
+
* normalization, validation, metadata lookup, and resolving a user's locales.
|
|
13
|
+
* It is pure and side-effect free.
|
|
4
14
|
*/
|
|
5
15
|
|
|
6
|
-
|
|
7
|
-
|
|
16
|
+
import type { User } from '../models/interfaces';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A supported BCP-47 locale in the Oxy catalog.
|
|
20
|
+
*/
|
|
21
|
+
export interface SupportedLanguage {
|
|
22
|
+
/** Canonical BCP-47 locale tag, `language-REGION` (e.g. `'es-ES'`). */
|
|
23
|
+
code: string;
|
|
24
|
+
/** ISO 639-1 base language subtag, lowercased (e.g. `'es'`). */
|
|
25
|
+
language: string;
|
|
26
|
+
/** ISO 3166-1 alpha-2 region subtag, uppercased (e.g. `'ES'`). */
|
|
27
|
+
region: string;
|
|
28
|
+
/** English display name, `'Language (Region)'` (e.g. `'Spanish (Spain)'`). */
|
|
8
29
|
name: string;
|
|
30
|
+
/** Endonym — the name as written in the locale itself (e.g. `'Español (España)'`). */
|
|
9
31
|
nativeName: string;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
color: string;
|
|
32
|
+
/** `true` when the locale is written right-to-left. Omitted for LTR locales. */
|
|
33
|
+
rtl?: boolean;
|
|
13
34
|
}
|
|
14
35
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
{
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
},
|
|
73
|
-
{
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
color: '#30B0C7',
|
|
104
|
-
},
|
|
36
|
+
/**
|
|
37
|
+
* The supported-locale catalog. Ordered by prominence for display; the order
|
|
38
|
+
* is not otherwise significant. Every entry is a full `language-REGION` tag,
|
|
39
|
+
* with the base subtag lowercased and the region uppercased.
|
|
40
|
+
*/
|
|
41
|
+
export const SUPPORTED_LANGUAGES: readonly SupportedLanguage[] = [
|
|
42
|
+
// English
|
|
43
|
+
{ code: 'en-US', language: 'en', region: 'US', name: 'English (United States)', nativeName: 'English (United States)' },
|
|
44
|
+
{ code: 'en-GB', language: 'en', region: 'GB', name: 'English (United Kingdom)', nativeName: 'English (United Kingdom)' },
|
|
45
|
+
{ code: 'en-AU', language: 'en', region: 'AU', name: 'English (Australia)', nativeName: 'English (Australia)' },
|
|
46
|
+
{ code: 'en-CA', language: 'en', region: 'CA', name: 'English (Canada)', nativeName: 'English (Canada)' },
|
|
47
|
+
{ code: 'en-IN', language: 'en', region: 'IN', name: 'English (India)', nativeName: 'English (India)' },
|
|
48
|
+
|
|
49
|
+
// Spanish
|
|
50
|
+
{ code: 'es-ES', language: 'es', region: 'ES', name: 'Spanish (Spain)', nativeName: 'Español (España)' },
|
|
51
|
+
{ code: 'es-MX', language: 'es', region: 'MX', name: 'Spanish (Mexico)', nativeName: 'Español (México)' },
|
|
52
|
+
{ code: 'es-US', language: 'es', region: 'US', name: 'Spanish (United States)', nativeName: 'Español (Estados Unidos)' },
|
|
53
|
+
{ code: 'es-AR', language: 'es', region: 'AR', name: 'Spanish (Argentina)', nativeName: 'Español (Argentina)' },
|
|
54
|
+
{ code: 'es-CO', language: 'es', region: 'CO', name: 'Spanish (Colombia)', nativeName: 'Español (Colombia)' },
|
|
55
|
+
|
|
56
|
+
// Portuguese
|
|
57
|
+
{ code: 'pt-BR', language: 'pt', region: 'BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
|
|
58
|
+
{ code: 'pt-PT', language: 'pt', region: 'PT', name: 'Portuguese (Portugal)', nativeName: 'Português (Portugal)' },
|
|
59
|
+
|
|
60
|
+
// French
|
|
61
|
+
{ code: 'fr-FR', language: 'fr', region: 'FR', name: 'French (France)', nativeName: 'Français (France)' },
|
|
62
|
+
{ code: 'fr-CA', language: 'fr', region: 'CA', name: 'French (Canada)', nativeName: 'Français (Canada)' },
|
|
63
|
+
|
|
64
|
+
// German
|
|
65
|
+
{ code: 'de-DE', language: 'de', region: 'DE', name: 'German (Germany)', nativeName: 'Deutsch (Deutschland)' },
|
|
66
|
+
{ code: 'de-AT', language: 'de', region: 'AT', name: 'German (Austria)', nativeName: 'Deutsch (Österreich)' },
|
|
67
|
+
{ code: 'de-CH', language: 'de', region: 'CH', name: 'German (Switzerland)', nativeName: 'Deutsch (Schweiz)' },
|
|
68
|
+
|
|
69
|
+
// Italian
|
|
70
|
+
{ code: 'it-IT', language: 'it', region: 'IT', name: 'Italian (Italy)', nativeName: 'Italiano (Italia)' },
|
|
71
|
+
|
|
72
|
+
// Dutch
|
|
73
|
+
{ code: 'nl-NL', language: 'nl', region: 'NL', name: 'Dutch (Netherlands)', nativeName: 'Nederlands (Nederland)' },
|
|
74
|
+
{ code: 'nl-BE', language: 'nl', region: 'BE', name: 'Dutch (Belgium)', nativeName: 'Nederlands (België)' },
|
|
75
|
+
|
|
76
|
+
// Nordic
|
|
77
|
+
{ code: 'sv-SE', language: 'sv', region: 'SE', name: 'Swedish (Sweden)', nativeName: 'Svenska (Sverige)' },
|
|
78
|
+
{ code: 'nb-NO', language: 'nb', region: 'NO', name: 'Norwegian Bokmål (Norway)', nativeName: 'Norsk bokmål (Norge)' },
|
|
79
|
+
{ code: 'da-DK', language: 'da', region: 'DK', name: 'Danish (Denmark)', nativeName: 'Dansk (Danmark)' },
|
|
80
|
+
{ code: 'fi-FI', language: 'fi', region: 'FI', name: 'Finnish (Finland)', nativeName: 'Suomi (Suomi)' },
|
|
81
|
+
|
|
82
|
+
// Central & Eastern Europe
|
|
83
|
+
{ code: 'pl-PL', language: 'pl', region: 'PL', name: 'Polish (Poland)', nativeName: 'Polski (Polska)' },
|
|
84
|
+
{ code: 'cs-CZ', language: 'cs', region: 'CZ', name: 'Czech (Czechia)', nativeName: 'Čeština (Česko)' },
|
|
85
|
+
{ code: 'sk-SK', language: 'sk', region: 'SK', name: 'Slovak (Slovakia)', nativeName: 'Slovenčina (Slovensko)' },
|
|
86
|
+
{ code: 'hu-HU', language: 'hu', region: 'HU', name: 'Hungarian (Hungary)', nativeName: 'Magyar (Magyarország)' },
|
|
87
|
+
{ code: 'ro-RO', language: 'ro', region: 'RO', name: 'Romanian (Romania)', nativeName: 'Română (România)' },
|
|
88
|
+
{ code: 'bg-BG', language: 'bg', region: 'BG', name: 'Bulgarian (Bulgaria)', nativeName: 'Български (България)' },
|
|
89
|
+
{ code: 'hr-HR', language: 'hr', region: 'HR', name: 'Croatian (Croatia)', nativeName: 'Hrvatski (Hrvatska)' },
|
|
90
|
+
{ code: 'sr-RS', language: 'sr', region: 'RS', name: 'Serbian (Serbia)', nativeName: 'Српски (Србија)' },
|
|
91
|
+
{ code: 'sl-SI', language: 'sl', region: 'SI', name: 'Slovenian (Slovenia)', nativeName: 'Slovenščina (Slovenija)' },
|
|
92
|
+
{ code: 'lt-LT', language: 'lt', region: 'LT', name: 'Lithuanian (Lithuania)', nativeName: 'Lietuvių (Lietuva)' },
|
|
93
|
+
{ code: 'lv-LV', language: 'lv', region: 'LV', name: 'Latvian (Latvia)', nativeName: 'Latviešu (Latvija)' },
|
|
94
|
+
{ code: 'et-EE', language: 'et', region: 'EE', name: 'Estonian (Estonia)', nativeName: 'Eesti (Eesti)' },
|
|
95
|
+
{ code: 'uk-UA', language: 'uk', region: 'UA', name: 'Ukrainian (Ukraine)', nativeName: 'Українська (Україна)' },
|
|
96
|
+
{ code: 'ru-RU', language: 'ru', region: 'RU', name: 'Russian (Russia)', nativeName: 'Русский (Россия)' },
|
|
97
|
+
{ code: 'el-GR', language: 'el', region: 'GR', name: 'Greek (Greece)', nativeName: 'Ελληνικά (Ελλάδα)' },
|
|
98
|
+
|
|
99
|
+
// Turkish
|
|
100
|
+
{ code: 'tr-TR', language: 'tr', region: 'TR', name: 'Turkish (Türkiye)', nativeName: 'Türkçe (Türkiye)' },
|
|
101
|
+
|
|
102
|
+
// Catalan
|
|
103
|
+
{ code: 'ca-ES', language: 'ca', region: 'ES', name: 'Catalan (Spain)', nativeName: 'Català (Espanya)' },
|
|
104
|
+
|
|
105
|
+
// East, South & Southeast Asia
|
|
106
|
+
{ code: 'ja-JP', language: 'ja', region: 'JP', name: 'Japanese (Japan)', nativeName: '日本語 (日本)' },
|
|
107
|
+
{ code: 'ko-KR', language: 'ko', region: 'KR', name: 'Korean (South Korea)', nativeName: '한국어 (대한민국)' },
|
|
108
|
+
{ code: 'zh-CN', language: 'zh', region: 'CN', name: 'Chinese (Simplified, China)', nativeName: '中文 (中国)' },
|
|
109
|
+
{ code: 'zh-TW', language: 'zh', region: 'TW', name: 'Chinese (Traditional, Taiwan)', nativeName: '中文 (台灣)' },
|
|
110
|
+
{ code: 'zh-HK', language: 'zh', region: 'HK', name: 'Chinese (Traditional, Hong Kong)', nativeName: '中文 (香港)' },
|
|
111
|
+
{ code: 'hi-IN', language: 'hi', region: 'IN', name: 'Hindi (India)', nativeName: 'हिन्दी (भारत)' },
|
|
112
|
+
{ code: 'bn-BD', language: 'bn', region: 'BD', name: 'Bengali (Bangladesh)', nativeName: 'বাংলা (বাংলাদেশ)' },
|
|
113
|
+
{ code: 'id-ID', language: 'id', region: 'ID', name: 'Indonesian (Indonesia)', nativeName: 'Bahasa Indonesia (Indonesia)' },
|
|
114
|
+
{ code: 'ms-MY', language: 'ms', region: 'MY', name: 'Malay (Malaysia)', nativeName: 'Bahasa Melayu (Malaysia)' },
|
|
115
|
+
{ code: 'th-TH', language: 'th', region: 'TH', name: 'Thai (Thailand)', nativeName: 'ไทย (ประเทศไทย)' },
|
|
116
|
+
{ code: 'vi-VN', language: 'vi', region: 'VN', name: 'Vietnamese (Vietnam)', nativeName: 'Tiếng Việt (Việt Nam)' },
|
|
117
|
+
|
|
118
|
+
// Right-to-left
|
|
119
|
+
{ code: 'ar-SA', language: 'ar', region: 'SA', name: 'Arabic (Saudi Arabia)', nativeName: 'العربية (السعودية)', rtl: true },
|
|
120
|
+
{ code: 'ar-EG', language: 'ar', region: 'EG', name: 'Arabic (Egypt)', nativeName: 'العربية (مصر)', rtl: true },
|
|
121
|
+
{ code: 'he-IL', language: 'he', region: 'IL', name: 'Hebrew (Israel)', nativeName: 'עברית (ישראל)', rtl: true },
|
|
122
|
+
{ code: 'fa-IR', language: 'fa', region: 'IR', name: 'Persian (Iran)', nativeName: 'فارسی (ایران)', rtl: true },
|
|
123
|
+
{ code: 'ur-PK', language: 'ur', region: 'PK', name: 'Urdu (Pakistan)', nativeName: 'اردو (پاکستان)', rtl: true },
|
|
105
124
|
];
|
|
106
125
|
|
|
107
|
-
|
|
126
|
+
/** Canonical fallback locale used when a display value cannot be resolved. */
|
|
127
|
+
export const FALLBACK_LOCALE = 'en-US';
|
|
128
|
+
|
|
129
|
+
/** O(1) lookup of a catalog entry by its canonical code. */
|
|
130
|
+
const LOCALE_BY_CODE: ReadonlyMap<string, SupportedLanguage> = new Map(
|
|
131
|
+
SUPPORTED_LANGUAGES.map((entry) => [entry.code, entry]),
|
|
132
|
+
);
|
|
108
133
|
|
|
109
134
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
135
|
+
* Extract the base language subtag from a locale, lowercased.
|
|
136
|
+
*
|
|
137
|
+
* Tolerant of bare base subtags and of extra subtags (script/variant):
|
|
138
|
+
* `'es-ES'` → `'es'`, `'es'` → `'es'`, `'zh-Hant-TW'` → `'zh'`. Returns an
|
|
139
|
+
* empty string for empty input.
|
|
113
140
|
*/
|
|
114
|
-
export function
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// Direct match
|
|
118
|
-
const exactMatch = SUPPORTED_LANGUAGES.find(lang => lang.id === languageCode);
|
|
119
|
-
if (exactMatch) return exactMatch;
|
|
120
|
-
|
|
121
|
-
// Try to match base language code (e.g., 'en' matches 'en-US')
|
|
122
|
-
const baseCode = languageCode.split('-')[0];
|
|
123
|
-
const baseMatch = SUPPORTED_LANGUAGES.find(lang => lang.id.startsWith(baseCode + '-'));
|
|
124
|
-
if (baseMatch) return baseMatch;
|
|
125
|
-
|
|
126
|
-
return null;
|
|
141
|
+
export function getBaseLanguage(locale: string): string {
|
|
142
|
+
return locale.trim().toLowerCase().split('-')[0] ?? '';
|
|
127
143
|
}
|
|
128
144
|
|
|
129
145
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
146
|
+
* Normalize a locale string to its canonical `language-REGION` form when it is
|
|
147
|
+
* a supported locale, otherwise `undefined`.
|
|
148
|
+
*
|
|
149
|
+
* Canonicalization lowercases the base subtag and uppercases the region
|
|
150
|
+
* subtag (`'es-es'` → `'es-ES'`, `'EN-us'` → `'en-US'`) and validates the
|
|
151
|
+
* result against {@link SUPPORTED_LANGUAGES}. A bare base subtag (`'es'`) has
|
|
152
|
+
* no region and is therefore not a locale — it returns `undefined`.
|
|
133
153
|
*/
|
|
134
|
-
export function
|
|
135
|
-
const
|
|
136
|
-
|
|
154
|
+
export function normalizeLocale(input: string): string | undefined {
|
|
155
|
+
const trimmed = input.trim();
|
|
156
|
+
if (!trimmed) return undefined;
|
|
157
|
+
|
|
158
|
+
const parts = trimmed.split('-');
|
|
159
|
+
if (parts.length < 2) return undefined;
|
|
160
|
+
|
|
161
|
+
const base = parts[0]?.toLowerCase();
|
|
162
|
+
const region = parts[parts.length - 1]?.toUpperCase();
|
|
163
|
+
if (!base || !region) return undefined;
|
|
164
|
+
|
|
165
|
+
const canonical = `${base}-${region}`;
|
|
166
|
+
return LOCALE_BY_CODE.has(canonical) ? canonical : undefined;
|
|
137
167
|
}
|
|
138
168
|
|
|
139
169
|
/**
|
|
140
|
-
*
|
|
141
|
-
* @param languageCode - BCP-47 language code (e.g., 'en-US', 'es-ES')
|
|
142
|
-
* @returns Native language name (e.g., 'Español') or the code if not found
|
|
170
|
+
* Whether `input` resolves to a supported BCP-47 locale.
|
|
143
171
|
*/
|
|
144
|
-
export function
|
|
145
|
-
|
|
146
|
-
return metadata?.nativeName || languageCode || FALLBACK_LANGUAGE;
|
|
172
|
+
export function isSupportedLocale(input: string): boolean {
|
|
173
|
+
return normalizeLocale(input) !== undefined;
|
|
147
174
|
}
|
|
148
175
|
|
|
149
176
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* @
|
|
177
|
+
* Resolve catalog metadata for a locale.
|
|
178
|
+
*
|
|
179
|
+
* @param code - A locale tag (any case; e.g. `'es-ES'`, `'es-es'`).
|
|
180
|
+
* @returns The {@link SupportedLanguage} entry, or `null` when the tag is empty
|
|
181
|
+
* or not a supported locale.
|
|
153
182
|
*/
|
|
154
|
-
export function
|
|
155
|
-
if (!
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
en: 'en-US',
|
|
160
|
-
es: 'es-ES',
|
|
161
|
-
ca: 'ca-ES',
|
|
162
|
-
fr: 'fr-FR',
|
|
163
|
-
de: 'de-DE',
|
|
164
|
-
it: 'it-IT',
|
|
165
|
-
pt: 'pt-PT',
|
|
166
|
-
ja: 'ja-JP',
|
|
167
|
-
ko: 'ko-KR',
|
|
168
|
-
zh: 'zh-CN',
|
|
169
|
-
ar: 'ar-SA',
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
return map[lang] || lang;
|
|
183
|
+
export function getLanguageMetadata(code: string | null | undefined): SupportedLanguage | null {
|
|
184
|
+
if (!code) return null;
|
|
185
|
+
const canonical = normalizeLocale(code);
|
|
186
|
+
if (!canonical) return null;
|
|
187
|
+
return LOCALE_BY_CODE.get(canonical) ?? null;
|
|
173
188
|
}
|
|
174
189
|
|
|
175
190
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
*
|
|
185
|
-
*
|
|
191
|
+
* English display name for a locale (e.g. `'Spanish (Spain)'`).
|
|
192
|
+
* Falls back to the input tag, then {@link FALLBACK_LOCALE}.
|
|
193
|
+
*/
|
|
194
|
+
export function getLanguageName(code: string | null | undefined): string {
|
|
195
|
+
return getLanguageMetadata(code)?.name || code || FALLBACK_LOCALE;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Native display name (endonym) for a locale (e.g. `'Español (España)'`).
|
|
200
|
+
* Falls back to the input tag, then {@link FALLBACK_LOCALE}.
|
|
201
|
+
*/
|
|
202
|
+
export function getNativeLanguageName(code: string | null | undefined): string {
|
|
203
|
+
return getLanguageMetadata(code)?.nativeName || code || FALLBACK_LOCALE;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Base language subtags whose scripts are written right-to-left. Used to drive
|
|
208
|
+
* `I18nManager.forceRTL(...)` on React Native and `<html dir="rtl">` on web.
|
|
209
|
+
* Includes Arabic (`ar`), Hebrew (`he` / legacy `iw`), Persian (`fa`), and
|
|
210
|
+
* Urdu (`ur`).
|
|
186
211
|
*/
|
|
187
|
-
const RTL_LANGUAGE_BASES = new Set(['ar', 'he', 'iw', 'fa', 'ur']);
|
|
212
|
+
const RTL_LANGUAGE_BASES: ReadonlySet<string> = new Set(['ar', 'he', 'iw', 'fa', 'ur']);
|
|
188
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Whether a locale (or bare base subtag) is written right-to-left. Unknown
|
|
216
|
+
* tags are treated as left-to-right.
|
|
217
|
+
*/
|
|
189
218
|
export function isRTLLocale(locale?: string | null): boolean {
|
|
190
219
|
if (!locale) return false;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
220
|
+
return RTL_LANGUAGE_BASES.has(getBaseLanguage(locale));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Resolve the ordered list of account locales for a user, primary first.
|
|
225
|
+
*
|
|
226
|
+
* Reads `user.languages` (the only source — there is no singular `language`
|
|
227
|
+
* field), then normalizes each entry to its canonical `language-REGION` form,
|
|
228
|
+
* drops non-string and unsupported entries, and de-duplicates while preserving
|
|
229
|
+
* first-seen order. Pure and side-effect free — never throws on bad input.
|
|
230
|
+
*/
|
|
231
|
+
export function getUserLanguages(user: Pick<User, 'languages'> | null | undefined): string[] {
|
|
232
|
+
const source = user?.languages;
|
|
233
|
+
if (!Array.isArray(source)) return [];
|
|
234
|
+
|
|
235
|
+
const result: string[] = [];
|
|
236
|
+
const seen = new Set<string>();
|
|
237
|
+
|
|
238
|
+
for (const entry of source) {
|
|
239
|
+
if (typeof entry !== 'string') continue;
|
|
240
|
+
const canonical = normalizeLocale(entry);
|
|
241
|
+
if (!canonical || seen.has(canonical)) continue;
|
|
242
|
+
seen.add(canonical);
|
|
243
|
+
result.push(canonical);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return result;
|
|
194
247
|
}
|
|
195
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Resolve the single PRIMARY locale for a user (the first entry from
|
|
251
|
+
* {@link getUserLanguages}), or `undefined` when the user has no supported
|
|
252
|
+
* locale.
|
|
253
|
+
*/
|
|
254
|
+
export function getPrimaryLanguage(user: Pick<User, 'languages'> | null | undefined): string | undefined {
|
|
255
|
+
return getUserLanguages(user)[0];
|
|
256
|
+
}
|