@stealthscale/core-locale 0.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/README.md +49 -0
- package/dist/index.d.mts +145 -0
- package/dist/index.mjs +245 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @stealthscale/core-locale
|
|
2
|
+
|
|
3
|
+
A **library**: the browser, the server and the message compiler all install it.
|
|
4
|
+
|
|
5
|
+
Which locale to answer in. BCP-47 tags read with the engine's own `Intl`, and ECMA-402's
|
|
6
|
+
lookup matcher against what a catalogue actually ships.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { chain, negotiate, preferences } from '@stealthscale/core-locale'
|
|
10
|
+
|
|
11
|
+
const wanted = preferences(request.headers.get('accept-language') ?? undefined)
|
|
12
|
+
const answer = negotiate(
|
|
13
|
+
wanted.map((p) => p.tag),
|
|
14
|
+
['en', 'nl', 'zh-Hans'],
|
|
15
|
+
'en',
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
chain('zh-Hant-TW') // ['zh-Hant-TW', 'zh-Hant', 'zh'] — the order a catalogue resolves in
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`negotiate` truncates each requested tag until it names something available, so `en-GB`
|
|
22
|
+
reaches a catalogue that ships `en`, and `nl-BE` reaches one that ships `nl`.
|
|
23
|
+
|
|
24
|
+
Where truncation finds nothing it widens both sides to their likely script and region and
|
|
25
|
+
matches over every step of the widened chain, so the two meet at whatever depth they share:
|
|
26
|
+
`zh` reaches `zh-Hans`, `zh-HK` reaches `zh-Hant` even though the two widen to different
|
|
27
|
+
regions, and `en-GB` reaches `en-US`. It gives back the available tag **as the catalogue wrote
|
|
28
|
+
it**, because that string is a file name.
|
|
29
|
+
|
|
30
|
+
`preferences` drops what the client cannot use: the wildcard, anything that is not a tag, and
|
|
31
|
+
a tag at `q=0`, which RFC 9110 defines as "not acceptable" rather than as a weak preference.
|
|
32
|
+
|
|
33
|
+
`directionOf` reads which way a tag's text runs from its script, so `ar` and `ur` are `rtl`,
|
|
34
|
+
`ar-Latn` is `ltr`, and a string that is not a tag is `ltr` as the ordinary case.
|
|
35
|
+
|
|
36
|
+
## No locale library
|
|
37
|
+
|
|
38
|
+
`@formatjs/intl-localematcher` is the usual answer and it is 58 KB, almost all of it the
|
|
39
|
+
likely-subtags data. The engine already has that data: `Intl.Locale.prototype.maximize()`
|
|
40
|
+
turns `zh` into `zh-Hans-CN`, `Intl.getCanonicalLocales` fixes subtag case, and `Intl.Locale`
|
|
41
|
+
takes a tag apart. So this package is the lookup algorithm over what `Intl` already knows, and
|
|
42
|
+
carries one table of its own: the right-to-left scripts, because the engines agree on a
|
|
43
|
+
language's likely script and disagree on what `getTextInfo` says about it.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
bun add @stealthscale/core-locale
|
|
49
|
+
```
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
//#region src/tags.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Reads a BCP-47 language tag with the engine's own ECMA-402 implementation:
|
|
4
|
+
* canonicalising it, taking it apart, walking it from most specific to least, and reading
|
|
5
|
+
* the direction its text runs in. `Intl` carries the locale data, including the likely
|
|
6
|
+
* subtags a `zh` to `zh-Hans-CN` widening needs; the one table here is the right-to-left
|
|
7
|
+
* scripts, for the reason `directionOf` gives.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Names a canonical BCP-47 language tag: `nl`, `en-GB`, `zh-Hant-TW`.
|
|
11
|
+
*
|
|
12
|
+
* It is a string rather than a class so a tag can be a catalogue key, a URL segment and a
|
|
13
|
+
* `lang` attribute without unwrapping.
|
|
14
|
+
*/
|
|
15
|
+
type Tag = string;
|
|
16
|
+
/**
|
|
17
|
+
* Describes the parts of a tag a caller reaches for.
|
|
18
|
+
*/
|
|
19
|
+
interface Parts {
|
|
20
|
+
/**
|
|
21
|
+
* Names the primary language: `nl`, `en`, `zh`.
|
|
22
|
+
*/
|
|
23
|
+
language: string;
|
|
24
|
+
/**
|
|
25
|
+
* Names the region, where the tag names one: `GB`, `TW`.
|
|
26
|
+
*/
|
|
27
|
+
region: string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Names the script, where the tag names one: `Hant`.
|
|
30
|
+
*/
|
|
31
|
+
script: string | undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Canonicalises a tag, correcting the case of each subtag as BCP-47 writes it.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} tag - The tag as it arrived, from a header, a URL or a manifest.
|
|
37
|
+
* @returns {Tag | undefined} The canonical tag, or `undefined` when it is not a tag at all.
|
|
38
|
+
*/
|
|
39
|
+
declare function canonical(tag: string): Tag | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Takes a tag apart into the language, script and region.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} tag - The tag to read.
|
|
44
|
+
* @returns {Parts | undefined} The parts, or `undefined` when the tag is not a tag at all.
|
|
45
|
+
*/
|
|
46
|
+
declare function parts(tag: string): Parts | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Widens a tag to the script and region the engine considers likely, so two tags written at
|
|
49
|
+
* different depths can be compared: `zh` becomes `zh-Hans-CN`.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} tag - The tag to widen.
|
|
52
|
+
* @returns {Tag | undefined} The widened tag, or `undefined` when the tag is not a tag at all.
|
|
53
|
+
*/
|
|
54
|
+
declare function widened(tag: string): Tag | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Walks a tag from most specific to least, which is the order a catalogue chain resolves in
|
|
57
|
+
* and the order ECMA-402's lookup matcher truncates in.
|
|
58
|
+
*
|
|
59
|
+
* A single-character subtag starts an extension (`-u-`, `-x-`), and everything from there on
|
|
60
|
+
* is dropped rather than walked, as the lookup algorithm requires.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} tag - The tag to walk.
|
|
63
|
+
* @returns {Tag[]} The tag and each shorter form, most specific first, empty when the tag is
|
|
64
|
+
* not a tag at all. `zh-Hant-TW` gives `zh-Hant-TW`, `zh-Hant`, `zh`.
|
|
65
|
+
*/
|
|
66
|
+
declare function chain(tag: string): Tag[];
|
|
67
|
+
/**
|
|
68
|
+
* Names the direction a script's text runs in.
|
|
69
|
+
*/
|
|
70
|
+
type Direction = 'ltr' | 'rtl';
|
|
71
|
+
/**
|
|
72
|
+
* Reads the direction a tag's text runs in, from the script the tag names or the one the
|
|
73
|
+
* engine considers likely for its language.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} tag - The tag to read.
|
|
76
|
+
* @returns {Direction} `rtl` for `ar`, `he`, `fa`, `ur` and `dv`. `ltr` for every other
|
|
77
|
+
* script, for `ar-Latn`, and for a string that is not a tag at all.
|
|
78
|
+
*/
|
|
79
|
+
declare function directionOf(tag: string): Direction;
|
|
80
|
+
/**
|
|
81
|
+
* Widens a tag and walks the result, which is the depth a lookup compares two tags at.
|
|
82
|
+
*
|
|
83
|
+
* A tag and an offer that widen to different regions still share their script, so both sides
|
|
84
|
+
* of a lookup walk this rather than compare their widened tags whole.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} tag - The tag to widen and walk.
|
|
87
|
+
* @returns {Tag[]} The widened tag and each shorter form, most specific first, empty when the
|
|
88
|
+
* tag is not a tag at all. `zh-HK` gives `zh-Hant-HK`, `zh-Hant`, `zh`.
|
|
89
|
+
*/
|
|
90
|
+
declare function widenedChain(tag: string): Tag[];
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/negotiate.d.ts
|
|
93
|
+
/**
|
|
94
|
+
* Describes one entry of an `Accept-Language` header: a tag and how much the client wants it.
|
|
95
|
+
*/
|
|
96
|
+
interface Preference {
|
|
97
|
+
/**
|
|
98
|
+
* Carries how much the client wants this tag, above 0 and up to 1. A header that names no
|
|
99
|
+
* quality means 1, and one that names 0 refuses the tag, which `preferences` drops.
|
|
100
|
+
*/
|
|
101
|
+
quality: number;
|
|
102
|
+
/**
|
|
103
|
+
* Names the canonical tag the client asked for.
|
|
104
|
+
*/
|
|
105
|
+
tag: Tag;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Reads an `Accept-Language` header into the tags a client asked for, most wanted first.
|
|
109
|
+
*
|
|
110
|
+
* A tag the header writes but no canonical form exists for is dropped rather than carried as
|
|
111
|
+
* a string nothing can match. The wildcard `*` is dropped for the same reason: it names no
|
|
112
|
+
* locale, and a caller that runs out of preferences uses its own default.
|
|
113
|
+
*
|
|
114
|
+
* A tag at `q=0` is dropped as well. RFC 9110 gives that quality the meaning "not
|
|
115
|
+
* acceptable", so the client named the tag to refuse it, and a refusal carried on as a
|
|
116
|
+
* preference is a locale the client asked not to be answered in.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} [header] - The header as it arrived, such as `en-GB,en;q=0.9,nl;q=0.8`.
|
|
119
|
+
* Left out, or empty, means the client asked for nothing.
|
|
120
|
+
* @returns {Preference[]} The tags the client will accept, highest quality first, and in the
|
|
121
|
+
* header's own order where two share a quality. Empty when the header names nothing
|
|
122
|
+
* usable.
|
|
123
|
+
*/
|
|
124
|
+
declare function preferences(header?: string): Preference[];
|
|
125
|
+
/**
|
|
126
|
+
* Picks the first available locale a request would accept.
|
|
127
|
+
*
|
|
128
|
+
* Each requested tag is truncated in turn, `en-GB` and then `en`, and the first that names an
|
|
129
|
+
* available locale wins, which is ECMA-402's lookup matcher. `nl-BE` reaches a catalogue that
|
|
130
|
+
* ships `nl` in this pass.
|
|
131
|
+
*
|
|
132
|
+
* Where truncation finds nothing, both sides are widened to their likely script and region
|
|
133
|
+
* and matched over every step of the widened chain, so they meet at whatever depth they
|
|
134
|
+
* share: `zh` reaches `zh-Hans`, `zh-HK` reaches `zh-Hant`, and `en-GB` reaches `en-US`.
|
|
135
|
+
*
|
|
136
|
+
* @param {readonly string[]} requested - The tags asked for, most wanted first, as
|
|
137
|
+
* `preferences` orders them.
|
|
138
|
+
* @param {readonly string[]} available - The tags a catalogue ships.
|
|
139
|
+
* @param {string} fallback - The tag to answer in where nothing matches. It is returned as
|
|
140
|
+
* given, so a caller that ships it gets back something it can serve.
|
|
141
|
+
* @returns {Tag} The available tag to answer in, or the fallback.
|
|
142
|
+
*/
|
|
143
|
+
declare function negotiate(requested: readonly string[], available: readonly string[], fallback: string): Tag;
|
|
144
|
+
//#endregion
|
|
145
|
+
export { type Direction, type Parts, type Preference, type Tag, canonical, chain, directionOf, negotiate, parts, preferences, widened, widenedChain };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
//#region src/tags.ts
|
|
2
|
+
/**
|
|
3
|
+
* Canonicalises a tag, correcting the case of each subtag as BCP-47 writes it.
|
|
4
|
+
*
|
|
5
|
+
* @param {string} tag - The tag as it arrived, from a header, a URL or a manifest.
|
|
6
|
+
* @returns {Tag | undefined} The canonical tag, or `undefined` when it is not a tag at all.
|
|
7
|
+
*/
|
|
8
|
+
function canonical(tag) {
|
|
9
|
+
try {
|
|
10
|
+
return Intl.getCanonicalLocales(tag)[0];
|
|
11
|
+
} catch {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Takes a tag apart into the language, script and region.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} tag - The tag to read.
|
|
19
|
+
* @returns {Parts | undefined} The parts, or `undefined` when the tag is not a tag at all.
|
|
20
|
+
*/
|
|
21
|
+
function parts(tag) {
|
|
22
|
+
try {
|
|
23
|
+
const locale = new Intl.Locale(tag);
|
|
24
|
+
return {
|
|
25
|
+
language: locale.language,
|
|
26
|
+
region: locale.region,
|
|
27
|
+
script: locale.script
|
|
28
|
+
};
|
|
29
|
+
} catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Widens a tag to the script and region the engine considers likely, so two tags written at
|
|
35
|
+
* different depths can be compared: `zh` becomes `zh-Hans-CN`.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} tag - The tag to widen.
|
|
38
|
+
* @returns {Tag | undefined} The widened tag, or `undefined` when the tag is not a tag at all.
|
|
39
|
+
*/
|
|
40
|
+
function widened(tag) {
|
|
41
|
+
try {
|
|
42
|
+
return new Intl.Locale(tag).maximize().toString();
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Walks a tag from most specific to least, which is the order a catalogue chain resolves in
|
|
49
|
+
* and the order ECMA-402's lookup matcher truncates in.
|
|
50
|
+
*
|
|
51
|
+
* A single-character subtag starts an extension (`-u-`, `-x-`), and everything from there on
|
|
52
|
+
* is dropped rather than walked, as the lookup algorithm requires.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} tag - The tag to walk.
|
|
55
|
+
* @returns {Tag[]} The tag and each shorter form, most specific first, empty when the tag is
|
|
56
|
+
* not a tag at all. `zh-Hant-TW` gives `zh-Hant-TW`, `zh-Hant`, `zh`.
|
|
57
|
+
*/
|
|
58
|
+
function chain(tag) {
|
|
59
|
+
const start = canonical(tag);
|
|
60
|
+
if (start === void 0) return [];
|
|
61
|
+
const subtags = start.split("-");
|
|
62
|
+
const extension = subtags.findIndex((subtag, index) => index > 0 && subtag.length === 1);
|
|
63
|
+
const named = extension === -1 ? subtags : subtags.slice(0, extension);
|
|
64
|
+
const walked = [];
|
|
65
|
+
for (let depth = named.length; depth > 0; depth -= 1) walked.push(named.slice(0, depth).join("-"));
|
|
66
|
+
return walked;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Lists the scripts written right to left that a living language widens to, as CLDR's
|
|
70
|
+
* script metadata marks them. The engines answer `getTextInfo` differently, Bun's ICU calling
|
|
71
|
+
* Thaana and Hanifi Rohingya left to right, while every engine agrees on the likely script,
|
|
72
|
+
* so the script decides.
|
|
73
|
+
*/
|
|
74
|
+
const RIGHT_TO_LEFT = /* @__PURE__ */ new Set([
|
|
75
|
+
"Adlm",
|
|
76
|
+
"Arab",
|
|
77
|
+
"Aran",
|
|
78
|
+
"Hebr",
|
|
79
|
+
"Mand",
|
|
80
|
+
"Nkoo",
|
|
81
|
+
"Rohg",
|
|
82
|
+
"Samr",
|
|
83
|
+
"Syrc",
|
|
84
|
+
"Thaa",
|
|
85
|
+
"Yezi"
|
|
86
|
+
]);
|
|
87
|
+
/**
|
|
88
|
+
* Reads the direction a tag's text runs in, from the script the tag names or the one the
|
|
89
|
+
* engine considers likely for its language.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} tag - The tag to read.
|
|
92
|
+
* @returns {Direction} `rtl` for `ar`, `he`, `fa`, `ur` and `dv`. `ltr` for every other
|
|
93
|
+
* script, for `ar-Latn`, and for a string that is not a tag at all.
|
|
94
|
+
*/
|
|
95
|
+
function directionOf(tag) {
|
|
96
|
+
try {
|
|
97
|
+
const script = new Intl.Locale(tag).maximize().script;
|
|
98
|
+
return script !== void 0 && RIGHT_TO_LEFT.has(script) ? "rtl" : "ltr";
|
|
99
|
+
} catch {
|
|
100
|
+
return "ltr";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Widens a tag and walks the result, which is the depth a lookup compares two tags at.
|
|
105
|
+
*
|
|
106
|
+
* A tag and an offer that widen to different regions still share their script, so both sides
|
|
107
|
+
* of a lookup walk this rather than compare their widened tags whole.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} tag - The tag to widen and walk.
|
|
110
|
+
* @returns {Tag[]} The widened tag and each shorter form, most specific first, empty when the
|
|
111
|
+
* tag is not a tag at all. `zh-HK` gives `zh-Hant-HK`, `zh-Hant`, `zh`.
|
|
112
|
+
*/
|
|
113
|
+
function widenedChain(tag) {
|
|
114
|
+
const wide = widened(tag);
|
|
115
|
+
return wide === void 0 ? [] : chain(wide);
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/negotiate.ts
|
|
119
|
+
/**
|
|
120
|
+
* @fileoverview Picks the locale to answer in: reads what a request asked for, in the order
|
|
121
|
+
* it asked, and matches it against what a catalogue actually ships. The matcher is ECMA-402's
|
|
122
|
+
* lookup algorithm: it truncates the requested tag until it names something available.
|
|
123
|
+
*/
|
|
124
|
+
/**
|
|
125
|
+
* Reads how much a client wants a tag from the `q` parameter beside it.
|
|
126
|
+
*
|
|
127
|
+
* A parameter that names no number leaves the tag fully wanted. Only a `q` the client
|
|
128
|
+
* actually wrote lowers a tag, so `en;` and `en;q=` mean the same as `en`.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} [parameter] - The text after the tag's semicolon, such as `q=0.8`.
|
|
131
|
+
* @returns {number} The quality between 0 and 1, and 1 where the parameter names none.
|
|
132
|
+
*/
|
|
133
|
+
function qualityOf(parameter) {
|
|
134
|
+
const named = parameter?.trim().replace(/^q=/u, "").trim() ?? "";
|
|
135
|
+
if (named === "") return 1;
|
|
136
|
+
const value = Number(named);
|
|
137
|
+
return Number.isFinite(value) && value >= 0 && value <= 1 ? value : 1;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Reads an `Accept-Language` header into the tags a client asked for, most wanted first.
|
|
141
|
+
*
|
|
142
|
+
* A tag the header writes but no canonical form exists for is dropped rather than carried as
|
|
143
|
+
* a string nothing can match. The wildcard `*` is dropped for the same reason: it names no
|
|
144
|
+
* locale, and a caller that runs out of preferences uses its own default.
|
|
145
|
+
*
|
|
146
|
+
* A tag at `q=0` is dropped as well. RFC 9110 gives that quality the meaning "not
|
|
147
|
+
* acceptable", so the client named the tag to refuse it, and a refusal carried on as a
|
|
148
|
+
* preference is a locale the client asked not to be answered in.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} [header] - The header as it arrived, such as `en-GB,en;q=0.9,nl;q=0.8`.
|
|
151
|
+
* Left out, or empty, means the client asked for nothing.
|
|
152
|
+
* @returns {Preference[]} The tags the client will accept, highest quality first, and in the
|
|
153
|
+
* header's own order where two share a quality. Empty when the header names nothing
|
|
154
|
+
* usable.
|
|
155
|
+
*/
|
|
156
|
+
function preferences(header) {
|
|
157
|
+
if (header === void 0 || header.trim() === "") return [];
|
|
158
|
+
return header.split(",").map((entry) => {
|
|
159
|
+
const semicolon = entry.indexOf(";");
|
|
160
|
+
const named = semicolon === -1 ? entry : entry.slice(0, semicolon);
|
|
161
|
+
const parameter = semicolon === -1 ? void 0 : entry.slice(semicolon + 1);
|
|
162
|
+
const canonicalised = canonical(named.trim());
|
|
163
|
+
return canonicalised === void 0 ? void 0 : {
|
|
164
|
+
quality: qualityOf(parameter),
|
|
165
|
+
tag: canonicalised
|
|
166
|
+
};
|
|
167
|
+
}).filter((preference) => preference !== void 0 && preference.quality > 0).toSorted((left, right) => right.quality - left.quality);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Keys what a catalogue ships by its canonical tag, keeping the spelling the catalogue used.
|
|
171
|
+
*
|
|
172
|
+
* The spelling matters because that string is a file name. Where two entries canonicalise the
|
|
173
|
+
* same, the first wins, so a catalogue's own order decides.
|
|
174
|
+
*
|
|
175
|
+
* @param {readonly string[]} available - The tags a catalogue ships.
|
|
176
|
+
* @returns {Map<Tag, Tag>} The canonical tag mapped to the tag as it was written, skipping
|
|
177
|
+
* anything that is not a tag.
|
|
178
|
+
*/
|
|
179
|
+
function offers(available) {
|
|
180
|
+
const offered = /* @__PURE__ */ new Map();
|
|
181
|
+
for (const tag of available) {
|
|
182
|
+
const canonicalised = canonical(tag);
|
|
183
|
+
if (canonicalised !== void 0 && !offered.has(canonicalised)) offered.set(canonicalised, tag);
|
|
184
|
+
}
|
|
185
|
+
return offered;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Keys the same offers by every step of their widened chain, so the two sides meet at
|
|
189
|
+
* whatever depth they share.
|
|
190
|
+
*
|
|
191
|
+
* An offer keyed by its full widened tag alone is reached only by a request that widens to
|
|
192
|
+
* the same region. `zh-Hant` widens to `zh-Hant-TW`, and a request for `zh-HK` widens to
|
|
193
|
+
* `zh-Hant-HK`, so the two never meet at their full depth; keying `zh-Hant` and `zh` as well
|
|
194
|
+
* lets them meet at the script. Where two offers claim a step, the first wins, so a
|
|
195
|
+
* catalogue's own order decides.
|
|
196
|
+
*
|
|
197
|
+
* @param {Offers} offered - The offers, as `offers` keyed them.
|
|
198
|
+
* @returns {Map<Tag, Tag>} Every step of every offer's widened chain, mapped to the tag as
|
|
199
|
+
* the catalogue wrote it.
|
|
200
|
+
*/
|
|
201
|
+
function widenedOffers(offered) {
|
|
202
|
+
const wide = /* @__PURE__ */ new Map();
|
|
203
|
+
for (const [canonicalised, written] of offered) for (const step of widenedChain(canonicalised)) if (!wide.has(step)) wide.set(step, written);
|
|
204
|
+
return wide;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Runs ECMA-402's lookup over the requested tags: the first whose chain names an offer wins.
|
|
208
|
+
*
|
|
209
|
+
* @param {readonly string[]} requested - The tags asked for, most wanted first.
|
|
210
|
+
* @param {Offers} offered - The offers to match against.
|
|
211
|
+
* @param {boolean} widen - Widens each requested tag before walking it. The second pass
|
|
212
|
+
* sets it when truncation alone found nothing.
|
|
213
|
+
* @returns {Tag | undefined} The offer as the catalogue wrote it, or `undefined` when none
|
|
214
|
+
* of the requested tags names one.
|
|
215
|
+
*/
|
|
216
|
+
function lookup(requested, offered, widen) {
|
|
217
|
+
for (const tag of requested) for (const step of widen ? widenedChain(tag) : chain(tag)) {
|
|
218
|
+
const match = offered.get(step);
|
|
219
|
+
if (match !== void 0) return match;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Picks the first available locale a request would accept.
|
|
224
|
+
*
|
|
225
|
+
* Each requested tag is truncated in turn, `en-GB` and then `en`, and the first that names an
|
|
226
|
+
* available locale wins, which is ECMA-402's lookup matcher. `nl-BE` reaches a catalogue that
|
|
227
|
+
* ships `nl` in this pass.
|
|
228
|
+
*
|
|
229
|
+
* Where truncation finds nothing, both sides are widened to their likely script and region
|
|
230
|
+
* and matched over every step of the widened chain, so they meet at whatever depth they
|
|
231
|
+
* share: `zh` reaches `zh-Hans`, `zh-HK` reaches `zh-Hant`, and `en-GB` reaches `en-US`.
|
|
232
|
+
*
|
|
233
|
+
* @param {readonly string[]} requested - The tags asked for, most wanted first, as
|
|
234
|
+
* `preferences` orders them.
|
|
235
|
+
* @param {readonly string[]} available - The tags a catalogue ships.
|
|
236
|
+
* @param {string} fallback - The tag to answer in where nothing matches. It is returned as
|
|
237
|
+
* given, so a caller that ships it gets back something it can serve.
|
|
238
|
+
* @returns {Tag} The available tag to answer in, or the fallback.
|
|
239
|
+
*/
|
|
240
|
+
function negotiate(requested, available, fallback) {
|
|
241
|
+
const offered = offers(available);
|
|
242
|
+
return lookup(requested, offered, false) ?? lookup(requested, widenedOffers(offered), true) ?? fallback;
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
export { canonical, chain, directionOf, negotiate, parts, preferences, widened, widenedChain };
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stealthscale/core-locale",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Which locale to answer in: BCP-47 tags read with the engine's own Intl, and ECMA-402 lookup against what a catalogue ships.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/stealth-scale/tooling.git",
|
|
9
|
+
"directory": "core/locale"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"imports": {
|
|
17
|
+
"#*": "./src/*"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"tooling-source": "./src/index.ts",
|
|
22
|
+
"default": "./dist/index.mjs"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./dist/index.mjs",
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "vp pack src/index.ts"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@stealthscale/tool-config": "^0.1.0"
|
|
38
|
+
}
|
|
39
|
+
}
|