@openmrs/esm-utils 9.0.3-pre.4715 → 9.0.3-pre.4728

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.
@@ -1,3 +1,3 @@
1
- [0] Successfully compiled: 13 files with swc (205.79ms)
1
+ [0] Successfully compiled: 14 files with swc (131.99ms)
2
2
  [0] swc --strip-leading-paths src -d dist exited with code 0
3
3
  [1] tsc --project tsconfig.build.json exited with code 0
@@ -3,7 +3,7 @@
3
3
  * @returns string
4
4
  */ export function getLocale() {
5
5
  let language = window.i18next.language;
6
- language = language.replace('_', '-'); // just in case
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
@@ -2,6 +2,7 @@ export * from './age-helpers';
2
2
  export * from './dates';
3
3
  export * from './get-locale';
4
4
  export * from './is-online';
5
+ export * from './match-locale';
5
6
  export * from './patient-helpers';
6
7
  export * from './shallowEqual';
7
8
  export * from './storage';
@@ -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.4715",
3
+ "version": "9.0.3-pre.4728",
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.4715",
66
+ "@openmrs/esm-globals": "9.0.3-pre.4728",
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.replace('_', '-'); // just in case
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
@@ -3,6 +3,7 @@ export * from './age-helpers';
3
3
  export * from './dates';
4
4
  export * from './get-locale';
5
5
  export * from './is-online';
6
+ export * from './match-locale';
6
7
  export * from './patient-helpers';
7
8
  export * from './shallowEqual';
8
9
  export * from './storage';
@@ -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
+ }