@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 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
+ ```
@@ -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
+ }