@openmrs/esm-utils 9.0.3-pre.4715 → 9.0.3-pre.4735
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/.turbo/turbo-build.log +1 -1
- package/dist/get-locale.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/match-locale.d.ts +34 -0
- package/dist/match-locale.d.ts.map +1 -0
- package/dist/match-locale.js +104 -0
- package/package.json +2 -2
- package/src/get-locale.ts +1 -1
- package/src/index.ts +1 -0
- package/src/match-locale.test.ts +180 -0
- package/src/match-locale.ts +118 -0
package/.turbo/turbo-build.log
CHANGED
package/dist/get-locale.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @returns string
|
|
4
4
|
*/ export function getLocale() {
|
|
5
5
|
let language = window.i18next.language;
|
|
6
|
-
language = language.
|
|
6
|
+
language = language.replaceAll('_', '-'); // just in case
|
|
7
7
|
// Hack for `ht` until all browsers update their unicode support with ht to fr mapping.
|
|
8
8
|
// See https://unicode-org.atlassian.net/browse/CLDR-14956
|
|
9
9
|
if (language === 'ht') {
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,eAAe,CAAC;AAC9B,cAAc,SAAS,CAAC;AACxB,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC;AAC1B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,eAAe,CAAC;AAC9B,cAAc,SAAS,CAAC;AACxB,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC;AAC1B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export * from "./age-helpers.js";
|
|
|
3
3
|
export * from "./dates/index.js";
|
|
4
4
|
export * from "./get-locale.js";
|
|
5
5
|
export * from "./is-online.js";
|
|
6
|
+
export * from "./match-locale.js";
|
|
6
7
|
export * from "./patient-helpers.js";
|
|
7
8
|
export * from "./shallowEqual.js";
|
|
8
9
|
export * from "./storage.js";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a requested locale against a list of available locales using the
|
|
3
|
+
* BCP 47 lookup algorithm (RFC 4647, §3.4).
|
|
4
|
+
*
|
|
5
|
+
* Tags are compared in canonical form via {@link Intl.Locale}, so casing and
|
|
6
|
+
* underscore-vs-hyphen differences in the inputs do not affect matching. The
|
|
7
|
+
* value returned is taken verbatim from `available`, preserving the caller's
|
|
8
|
+
* original casing.
|
|
9
|
+
*
|
|
10
|
+
* In addition to the truncation steps prescribed by RFC 4647, this function
|
|
11
|
+
* also performs prefix expansion at each level: when the requested tag is
|
|
12
|
+
* truncated to a more general range (e.g. `en` after stripping `en-CA`), any
|
|
13
|
+
* available locale whose canonical form begins with that range plus a hyphen
|
|
14
|
+
* (e.g. `en-US`, `en-GB`) is treated as a match. This is a relaxation of the
|
|
15
|
+
* strict lookup algorithm but typically reflects user intent — a user who
|
|
16
|
+
* asked for `en-CA` will usually accept `en-US` over a non-English fallback.
|
|
17
|
+
*
|
|
18
|
+
* Tags that fail to parse are skipped with a console warning; they never match
|
|
19
|
+
* and never throw.
|
|
20
|
+
*
|
|
21
|
+
* @param requested - The locale the caller would like to use, or `null`/`undefined`
|
|
22
|
+
* if no preference is known.
|
|
23
|
+
* @param available - The list of locales the application supports.
|
|
24
|
+
* @param fallback - The value to return when no match is found. Defaults to
|
|
25
|
+
* `undefined`.
|
|
26
|
+
* @returns The matched locale from `available`, or `fallback` if nothing matches.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* matchLocale('en-CA', ['en-US', 'en-GB', 'fr-FR'], 'en-US'); // => 'en-US'
|
|
30
|
+
* matchLocale('fr-BE', ['en-US', 'fr-FR', 'de-DE'], 'en-US'); // => 'fr-FR'
|
|
31
|
+
* matchLocale('zh-Hant-TW', ['zh-Hant', 'zh-Hans', 'en'], 'en'); // => 'zh-Hant'
|
|
32
|
+
*/
|
|
33
|
+
export declare function matchLocale(requested: string | null | undefined, available: ReadonlyArray<string>, fallback?: string): string | undefined;
|
|
34
|
+
//# sourceMappingURL=match-locale.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"match-locale.d.ts","sourceRoot":"","sources":["../src/match-locale.ts"],"names":[],"mappings":"AAoBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,WAAW,CACzB,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACpC,SAAS,EAAE,aAAa,CAAC,MAAM,CAAC,EAChC,QAAQ,CAAC,EAAE,MAAM,GAChB,MAAM,GAAG,SAAS,CA6DpB"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a canonical BCP 47 representation of `tag`, or `undefined` if it is not a
|
|
3
|
+
* structurally valid tag. Underscores are accepted as a separator and normalized to
|
|
4
|
+
* hyphens before canonicalization to accommodate locale strings that originate from
|
|
5
|
+
* Java-style identifiers (e.g. `en_GB`).
|
|
6
|
+
*/ function canonicalize(tag) {
|
|
7
|
+
if (typeof tag !== 'string' || tag.trim().length === 0) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const normalized = tag.replaceAll('_', '-');
|
|
11
|
+
try {
|
|
12
|
+
return new Intl.Locale(normalized).toString();
|
|
13
|
+
} catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolves a requested locale against a list of available locales using the
|
|
19
|
+
* BCP 47 lookup algorithm (RFC 4647, §3.4).
|
|
20
|
+
*
|
|
21
|
+
* Tags are compared in canonical form via {@link Intl.Locale}, so casing and
|
|
22
|
+
* underscore-vs-hyphen differences in the inputs do not affect matching. The
|
|
23
|
+
* value returned is taken verbatim from `available`, preserving the caller's
|
|
24
|
+
* original casing.
|
|
25
|
+
*
|
|
26
|
+
* In addition to the truncation steps prescribed by RFC 4647, this function
|
|
27
|
+
* also performs prefix expansion at each level: when the requested tag is
|
|
28
|
+
* truncated to a more general range (e.g. `en` after stripping `en-CA`), any
|
|
29
|
+
* available locale whose canonical form begins with that range plus a hyphen
|
|
30
|
+
* (e.g. `en-US`, `en-GB`) is treated as a match. This is a relaxation of the
|
|
31
|
+
* strict lookup algorithm but typically reflects user intent — a user who
|
|
32
|
+
* asked for `en-CA` will usually accept `en-US` over a non-English fallback.
|
|
33
|
+
*
|
|
34
|
+
* Tags that fail to parse are skipped with a console warning; they never match
|
|
35
|
+
* and never throw.
|
|
36
|
+
*
|
|
37
|
+
* @param requested - The locale the caller would like to use, or `null`/`undefined`
|
|
38
|
+
* if no preference is known.
|
|
39
|
+
* @param available - The list of locales the application supports.
|
|
40
|
+
* @param fallback - The value to return when no match is found. Defaults to
|
|
41
|
+
* `undefined`.
|
|
42
|
+
* @returns The matched locale from `available`, or `fallback` if nothing matches.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* matchLocale('en-CA', ['en-US', 'en-GB', 'fr-FR'], 'en-US'); // => 'en-US'
|
|
46
|
+
* matchLocale('fr-BE', ['en-US', 'fr-FR', 'de-DE'], 'en-US'); // => 'fr-FR'
|
|
47
|
+
* matchLocale('zh-Hant-TW', ['zh-Hant', 'zh-Hans', 'en'], 'en'); // => 'zh-Hant'
|
|
48
|
+
*/ export function matchLocale(requested, available, fallback) {
|
|
49
|
+
if (requested == null) {
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
const requestedCanonical = canonicalize(requested);
|
|
53
|
+
if (!requestedCanonical) {
|
|
54
|
+
console.warn(`matchLocale: invalid requested locale tag: ${JSON.stringify(requested)}`);
|
|
55
|
+
return fallback;
|
|
56
|
+
}
|
|
57
|
+
const pool = [];
|
|
58
|
+
for (const entry of available){
|
|
59
|
+
const canonical = canonicalize(entry);
|
|
60
|
+
if (!canonical) {
|
|
61
|
+
console.warn(`matchLocale: skipping invalid available locale tag: ${JSON.stringify(entry)}`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
pool.push({
|
|
65
|
+
canonical,
|
|
66
|
+
original: entry
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
let candidate = requestedCanonical;
|
|
70
|
+
while(candidate){
|
|
71
|
+
const exact = pool.find((p)=>p.canonical === candidate);
|
|
72
|
+
if (exact) {
|
|
73
|
+
return exact.original;
|
|
74
|
+
}
|
|
75
|
+
const prefix = candidate + '-';
|
|
76
|
+
const prefixed = pool.find((p)=>p.canonical.startsWith(prefix));
|
|
77
|
+
if (prefixed) {
|
|
78
|
+
return prefixed.original;
|
|
79
|
+
}
|
|
80
|
+
// Once the truncation chain has exhausted every `ht` option, retry the
|
|
81
|
+
// lookup with `fr-HT`. This mirrors the `ht` → `fr-HT` workaround in
|
|
82
|
+
// `get-locale.ts`: Haitian Creole content is typically registered under
|
|
83
|
+
// `fr-HT` because of incomplete browser Intl support for `ht`, so a caller
|
|
84
|
+
// asking for `ht` needs to find it there.
|
|
85
|
+
if (candidate === 'ht') {
|
|
86
|
+
candidate = 'fr-HT';
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// RFC 4647 §3.4 step 4: strip the trailing subtag.
|
|
90
|
+
const lastDash = candidate.lastIndexOf('-');
|
|
91
|
+
if (lastDash === -1) {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
candidate = candidate.slice(0, lastDash);
|
|
95
|
+
// RFC 4647 §3.4 step 5: a trailing single-letter subtag is an extension
|
|
96
|
+
// singleton (e.g. `-x-` for private use, `-u-` for Unicode extensions) and
|
|
97
|
+
// is not meaningful on its own, so strip it together with its separator.
|
|
98
|
+
const tailDash = candidate.lastIndexOf('-');
|
|
99
|
+
if (tailDash !== -1 && candidate.length - tailDash === 2) {
|
|
100
|
+
candidate = candidate.slice(0, tailDash);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return fallback;
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openmrs/esm-utils",
|
|
3
|
-
"version": "9.0.3-pre.
|
|
3
|
+
"version": "9.0.3-pre.4735",
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
5
|
"description": "Helper utilities for OpenMRS",
|
|
6
6
|
"type": "module",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"rxjs": "6.x"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@openmrs/esm-globals": "9.0.3-pre.
|
|
66
|
+
"@openmrs/esm-globals": "9.0.3-pre.4735",
|
|
67
67
|
"@swc/cli": "0.8.1",
|
|
68
68
|
"@swc/core": "1.15.21",
|
|
69
69
|
"@types/lodash-es": "^4.17.12",
|
package/src/get-locale.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export function getLocale() {
|
|
6
6
|
let language = window.i18next.language;
|
|
7
|
-
language = language.
|
|
7
|
+
language = language.replaceAll('_', '-'); // just in case
|
|
8
8
|
// Hack for `ht` until all browsers update their unicode support with ht to fr mapping.
|
|
9
9
|
// See https://unicode-org.atlassian.net/browse/CLDR-14956
|
|
10
10
|
if (language === 'ht') {
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { matchLocale } from './match-locale';
|
|
3
|
+
|
|
4
|
+
describe('matchLocale', () => {
|
|
5
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
warnSpy.mockRestore();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('examples from the spec', () => {
|
|
16
|
+
it('falls back to en-US when en-CA is requested but only other English variants exist', () => {
|
|
17
|
+
expect(matchLocale('en-CA', ['en-US', 'en-GB', 'fr-FR'], 'en-US')).toBe('en-US');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('matches the language family when the region differs', () => {
|
|
21
|
+
expect(matchLocale('fr-BE', ['en-US', 'fr-FR', 'de-DE'], 'en-US')).toBe('fr-FR');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('truncates a script subtag to find a script-only match', () => {
|
|
25
|
+
expect(matchLocale('zh-Hant-TW', ['zh-Hant', 'zh-Hans', 'en'], 'en')).toBe('zh-Hant');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('exact matching', () => {
|
|
30
|
+
it('returns the exact match when one is present', () => {
|
|
31
|
+
expect(matchLocale('en-US', ['fr-FR', 'en-US', 'de-DE'], 'fr-FR')).toBe('en-US');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns the original casing from `available`, not the canonical form', () => {
|
|
35
|
+
expect(matchLocale('en-us', ['en-US'])).toBe('en-US');
|
|
36
|
+
expect(matchLocale('EN-US', ['en-us'])).toBe('en-us');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('matches the first occurrence when an available locale appears multiple times', () => {
|
|
40
|
+
expect(matchLocale('en', ['en', 'en-US', 'en'])).toBe('en');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('canonicalization', () => {
|
|
45
|
+
it('treats underscore-separated tags as equivalent to hyphen-separated tags', () => {
|
|
46
|
+
expect(matchLocale('en_GB', ['en-GB'])).toBe('en-GB');
|
|
47
|
+
expect(matchLocale('en-GB', ['en_GB'])).toBe('en_GB');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('is case-insensitive across language, script, and region subtags', () => {
|
|
51
|
+
expect(matchLocale('ZH-hant-tw', ['zh-Hant'])).toBe('zh-Hant');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('canonicalizes script casing for matching (Latn vs latn)', () => {
|
|
55
|
+
expect(matchLocale('sr-latn-rs', ['sr-Latn'])).toBe('sr-Latn');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('truncation', () => {
|
|
60
|
+
it('strips a region subtag to match a language-only available locale', () => {
|
|
61
|
+
expect(matchLocale('en-CA', ['en'])).toBe('en');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('strips a region from a script+region tag, leaving the script', () => {
|
|
65
|
+
expect(matchLocale('zh-Hant-TW', ['zh-Hant'])).toBe('zh-Hant');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('strips a single-letter extension singleton along with its trailing subtag', () => {
|
|
69
|
+
// `en-US-x-private` → `en-US-x` → strip singleton → `en-US` → `en`.
|
|
70
|
+
expect(matchLocale('en-US-x-private', ['en'])).toBe('en');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('prefix expansion', () => {
|
|
75
|
+
it('expands a language-only request to a regional variant', () => {
|
|
76
|
+
expect(matchLocale('en', ['en-US', 'en-GB'])).toBe('en-US');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('falls through truncation steps before expanding', () => {
|
|
80
|
+
// `fr-BE` → no exact, no `fr-BE-…` prefix → truncate to `fr` →
|
|
81
|
+
// no exact `fr` → expand to `fr-FR`.
|
|
82
|
+
expect(matchLocale('fr-BE', ['fr-FR'])).toBe('fr-FR');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does not match across language families', () => {
|
|
86
|
+
expect(matchLocale('fr-BE', ['en-US', 'de-DE'], 'en-US')).toBe('en-US');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('fallback handling', () => {
|
|
91
|
+
it('returns the fallback when no match is found', () => {
|
|
92
|
+
expect(matchLocale('ja', ['en-US', 'fr-FR'], 'en-US')).toBe('en-US');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns undefined when no fallback is provided and no match is found', () => {
|
|
96
|
+
expect(matchLocale('ja', ['en-US', 'fr-FR'], undefined)).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns the fallback when `available` is empty', () => {
|
|
100
|
+
expect(matchLocale('en-US', [], 'fallback-tag')).toBe('fallback-tag');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns the fallback when `requested` is null', () => {
|
|
104
|
+
expect(matchLocale(null, ['en-US'], 'en-US')).toBe('en-US');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns the fallback when `requested` is undefined', () => {
|
|
108
|
+
expect(matchLocale(undefined, ['en-US'], 'en-US')).toBe('en-US');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('ht → fr-HT fallback', () => {
|
|
113
|
+
it('falls back to fr-HT when `ht` is requested and no Haitian Creole locale is available', () => {
|
|
114
|
+
expect(matchLocale('ht', ['fr-HT', 'en-US'])).toBe('fr-HT');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('falls back from `ht-HT` to `fr-HT`', () => {
|
|
118
|
+
expect(matchLocale('ht-HT', ['fr-HT', 'en-US'])).toBe('fr-HT');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('falls back from `ht-Latn-HT` to `fr-HT`', () => {
|
|
122
|
+
expect(matchLocale('ht-Latn-HT', ['fr-HT', 'en-US'])).toBe('fr-HT');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('continues truncating to plain `fr` when `fr-HT` is not available', () => {
|
|
126
|
+
expect(matchLocale('ht', ['fr', 'en-US'])).toBe('fr');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('expands the post-remap `fr-HT` candidate to any available `fr-*` variant', () => {
|
|
130
|
+
// Truncate `ht` → remap to `fr-HT` → no exact `fr-HT` and no `fr-HT-…` →
|
|
131
|
+
// truncate to `fr` → no exact `fr`, but prefix `fr-` matches `fr-FR`.
|
|
132
|
+
expect(matchLocale('ht', ['fr-FR'])).toBe('fr-FR');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('prefers an available `ht` locale over the French fallback', () => {
|
|
136
|
+
expect(matchLocale('ht', ['ht', 'fr-HT'])).toBe('ht');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('prefers an available `ht-HT` locale over the French fallback', () => {
|
|
140
|
+
expect(matchLocale('ht-HT', ['ht-HT', 'fr-HT'])).toBe('ht-HT');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('uses the `ht` prefix expansion before falling back to French', () => {
|
|
144
|
+
// Requested `ht-Latn-HT` truncates to `ht`; prefix expansion finds `ht-HT`
|
|
145
|
+
// before the loop ever sees the `ht` → `fr-HT` remap.
|
|
146
|
+
expect(matchLocale('ht-Latn-HT', ['ht-HT', 'fr-HT'])).toBe('ht-HT');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns the fallback when neither Haitian Creole nor French is available', () => {
|
|
150
|
+
expect(matchLocale('ht-HT', ['en-US', 'de-DE'], 'en-US')).toBe('en-US');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('invalid input', () => {
|
|
155
|
+
it('returns the fallback and warns when `requested` is not a valid tag', () => {
|
|
156
|
+
expect(matchLocale('not a locale', ['en-US'], 'en-US')).toBe('en-US');
|
|
157
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid requested locale tag'));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns the fallback and warns when `requested` is an empty string', () => {
|
|
161
|
+
expect(matchLocale('', ['en-US'], 'en-US')).toBe('en-US');
|
|
162
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('skips invalid entries in `available` and warns about each', () => {
|
|
166
|
+
expect(matchLocale('en-US', ['not-a-locale!', 'en-US'])).toBe('en-US');
|
|
167
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid available locale tag'));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('still returns the fallback when every available entry is invalid', () => {
|
|
171
|
+
expect(matchLocale('en-US', ['!!!', '@@@'], 'fallback')).toBe('fallback');
|
|
172
|
+
expect(warnSpy).toHaveBeenCalledTimes(2);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('does not warn when all inputs are valid', () => {
|
|
176
|
+
matchLocale('en-US', ['en-US']);
|
|
177
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a canonical BCP 47 representation of `tag`, or `undefined` if it is not a
|
|
3
|
+
* structurally valid tag. Underscores are accepted as a separator and normalized to
|
|
4
|
+
* hyphens before canonicalization to accommodate locale strings that originate from
|
|
5
|
+
* Java-style identifiers (e.g. `en_GB`).
|
|
6
|
+
*/
|
|
7
|
+
function canonicalize(tag: string): string | undefined {
|
|
8
|
+
if (typeof tag !== 'string' || tag.trim().length === 0) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const normalized = tag.replaceAll('_', '-');
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
return new Intl.Locale(normalized).toString();
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolves a requested locale against a list of available locales using the
|
|
23
|
+
* BCP 47 lookup algorithm (RFC 4647, §3.4).
|
|
24
|
+
*
|
|
25
|
+
* Tags are compared in canonical form via {@link Intl.Locale}, so casing and
|
|
26
|
+
* underscore-vs-hyphen differences in the inputs do not affect matching. The
|
|
27
|
+
* value returned is taken verbatim from `available`, preserving the caller's
|
|
28
|
+
* original casing.
|
|
29
|
+
*
|
|
30
|
+
* In addition to the truncation steps prescribed by RFC 4647, this function
|
|
31
|
+
* also performs prefix expansion at each level: when the requested tag is
|
|
32
|
+
* truncated to a more general range (e.g. `en` after stripping `en-CA`), any
|
|
33
|
+
* available locale whose canonical form begins with that range plus a hyphen
|
|
34
|
+
* (e.g. `en-US`, `en-GB`) is treated as a match. This is a relaxation of the
|
|
35
|
+
* strict lookup algorithm but typically reflects user intent — a user who
|
|
36
|
+
* asked for `en-CA` will usually accept `en-US` over a non-English fallback.
|
|
37
|
+
*
|
|
38
|
+
* Tags that fail to parse are skipped with a console warning; they never match
|
|
39
|
+
* and never throw.
|
|
40
|
+
*
|
|
41
|
+
* @param requested - The locale the caller would like to use, or `null`/`undefined`
|
|
42
|
+
* if no preference is known.
|
|
43
|
+
* @param available - The list of locales the application supports.
|
|
44
|
+
* @param fallback - The value to return when no match is found. Defaults to
|
|
45
|
+
* `undefined`.
|
|
46
|
+
* @returns The matched locale from `available`, or `fallback` if nothing matches.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* matchLocale('en-CA', ['en-US', 'en-GB', 'fr-FR'], 'en-US'); // => 'en-US'
|
|
50
|
+
* matchLocale('fr-BE', ['en-US', 'fr-FR', 'de-DE'], 'en-US'); // => 'fr-FR'
|
|
51
|
+
* matchLocale('zh-Hant-TW', ['zh-Hant', 'zh-Hans', 'en'], 'en'); // => 'zh-Hant'
|
|
52
|
+
*/
|
|
53
|
+
export function matchLocale(
|
|
54
|
+
requested: string | null | undefined,
|
|
55
|
+
available: ReadonlyArray<string>,
|
|
56
|
+
fallback?: string,
|
|
57
|
+
): string | undefined {
|
|
58
|
+
if (requested == null) {
|
|
59
|
+
return fallback;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const requestedCanonical = canonicalize(requested);
|
|
63
|
+
if (!requestedCanonical) {
|
|
64
|
+
console.warn(`matchLocale: invalid requested locale tag: ${JSON.stringify(requested)}`);
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const pool: Array<{ canonical: string; original: string }> = [];
|
|
69
|
+
for (const entry of available) {
|
|
70
|
+
const canonical = canonicalize(entry);
|
|
71
|
+
if (!canonical) {
|
|
72
|
+
console.warn(`matchLocale: skipping invalid available locale tag: ${JSON.stringify(entry)}`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
pool.push({ canonical, original: entry });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let candidate = requestedCanonical;
|
|
79
|
+
while (candidate) {
|
|
80
|
+
const exact = pool.find((p) => p.canonical === candidate);
|
|
81
|
+
if (exact) {
|
|
82
|
+
return exact.original;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const prefix = candidate + '-';
|
|
86
|
+
const prefixed = pool.find((p) => p.canonical.startsWith(prefix));
|
|
87
|
+
if (prefixed) {
|
|
88
|
+
return prefixed.original;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Once the truncation chain has exhausted every `ht` option, retry the
|
|
92
|
+
// lookup with `fr-HT`. This mirrors the `ht` → `fr-HT` workaround in
|
|
93
|
+
// `get-locale.ts`: Haitian Creole content is typically registered under
|
|
94
|
+
// `fr-HT` because of incomplete browser Intl support for `ht`, so a caller
|
|
95
|
+
// asking for `ht` needs to find it there.
|
|
96
|
+
if (candidate === 'ht') {
|
|
97
|
+
candidate = 'fr-HT';
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// RFC 4647 §3.4 step 4: strip the trailing subtag.
|
|
102
|
+
const lastDash = candidate.lastIndexOf('-');
|
|
103
|
+
if (lastDash === -1) {
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
candidate = candidate.slice(0, lastDash);
|
|
107
|
+
|
|
108
|
+
// RFC 4647 §3.4 step 5: a trailing single-letter subtag is an extension
|
|
109
|
+
// singleton (e.g. `-x-` for private use, `-u-` for Unicode extensions) and
|
|
110
|
+
// is not meaningful on its own, so strip it together with its separator.
|
|
111
|
+
const tailDash = candidate.lastIndexOf('-');
|
|
112
|
+
if (tailDash !== -1 && candidate.length - tailDash === 2) {
|
|
113
|
+
candidate = candidate.slice(0, tailDash);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return fallback;
|
|
118
|
+
}
|