@pie-players/pie-language-tags 0.0.0-stage → 1.0.0-beta.9

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PIE Framework contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,38 @@
1
- # Temporary Holding Version
1
+ # @pie-players/pie-language-tags
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ BCP 47 language tag lookup and matching for the two PIE i18n engines: pie-players'
4
+ `@pie-players/pie-players-shared/i18n`, which re-exports it as
5
+ `@pie-players/pie-players-shared/i18n/language-tags`, and pie-elements-ng's
6
+ `@pie-element/shared-i18n`. The package carries no locale data, touches no DOM and
7
+ has no dependencies, so it imports in a browser, in Node.js and in a server bundle
8
+ alike.
9
+
10
+ ```ts
11
+ import {
12
+ canonicalLanguageTag,
13
+ findBestLanguageMatch,
14
+ findClosestLanguageMatch,
15
+ languageTagLookupSequence,
16
+ negotiateContentLanguage,
17
+ normalizeLanguageTag,
18
+ toLanguageTag,
19
+ } from "@pie-players/pie-language-tags";
20
+
21
+ languageTagLookupSequence("zh-Hans-CN"); // ["zh-hans-cn", "zh-hans", "zh"]
22
+ findBestLanguageMatch("es_MX", ["en-US", "es"]); // "es"
23
+ toLanguageTag("es_ES"); // "es-ES"
24
+ ```
25
+
26
+ Lookup follows RFC 4647: a tag widens by dropping its last subtag, and a script
27
+ subtag is never crossed, so `zh-TW` reaches `zh-Hant` but not a bare `zh`. Widening
28
+ to a sibling (`es-MX` to `es-ES`, through `findClosestLanguageMatch`) reaches only a
29
+ sibling with the same variant subtags. POSIX forms (`es_ES`, `es_ES.UTF-8`) read as
30
+ their BCP 47 tag.
31
+
32
+ ## Conformance spec
33
+
34
+ `@pie-players/pie-language-tags/i18n-conformance-fixtures.json` is the behaviour
35
+ the two engines share: lookup sequence, catalog matching, direction, plural
36
+ routing, interpolation and the fallback chain. Each repository runs it against its
37
+ own engine, so a case one engine passes and the other fails is drift, and the fix
38
+ goes to the engine.
@@ -0,0 +1,270 @@
1
+ {
2
+ "$comment": "Cross-repository conformance spec for the two PIE i18n engines: pie-players' players-shared/src/i18n and pie-elements-ng's @pie-element/shared-i18n, which share this package's language-tag code and keep their own providers. Both repositories read this one file from @pie-players/pie-language-tags and run it against their own engine. A case that only one engine passes is drift, and the fix goes to the engine, not the fixture. Locale tags are full where CLDR defaults differ by region: bare `ar` formats Latin digits and `ar-SA` Arabic-Indic, which is CLDR, not an engine.",
3
+ "lookupSequence": [
4
+ { "tag": "nl-BE", "expected": ["nl-be", "nl"] },
5
+ { "tag": "pt-BR", "expected": ["pt-br", "pt"] },
6
+ { "tag": "zh-Hans-CN", "expected": ["zh-hans-cn", "zh-hans", "zh"] },
7
+ { "tag": "sr-Latn-RS", "expected": ["sr-latn-rs", "sr-latn", "sr"] },
8
+ { "tag": "es", "expected": ["es"] },
9
+ { "tag": "und", "expected": ["und"] }
10
+ ],
11
+ "catalogMatch": [
12
+ {
13
+ "locale": "zh-TW",
14
+ "available": ["en-US", "zh", "zh-Hant"],
15
+ "expected": "zh-Hant"
16
+ },
17
+ { "locale": "zh-TW", "available": ["en-US", "zh"], "expected": "en-US" },
18
+ { "locale": "zh-HK", "available": ["en-US", "zh"], "expected": "en-US" },
19
+ {
20
+ "locale": "zh-HK",
21
+ "available": ["en-US", "zh", "zh-TW"],
22
+ "expected": "zh-TW"
23
+ },
24
+ { "locale": "zh-SG", "available": ["en-US", "zh"], "expected": "zh" },
25
+ { "locale": "zh-CN", "available": ["en-US", "zh-TW"], "expected": "en-US" },
26
+ { "locale": "zh", "available": ["en-US", "zh-TW"], "expected": "en-US" },
27
+ {
28
+ "locale": "sr-ME",
29
+ "available": ["en-US", "sr", "sr-Latn"],
30
+ "expected": "sr-Latn"
31
+ },
32
+ { "locale": "sr-ME", "available": ["en-US", "sr"], "expected": "en-US" },
33
+ { "locale": "sr-Cyrl-RS", "available": ["en-US", "sr"], "expected": "sr" },
34
+ { "locale": "sr-Latn", "available": ["en-US", "sr"], "expected": "en-US" },
35
+ {
36
+ "locale": "sr-Latn",
37
+ "available": ["en-US", "sr-ME"],
38
+ "expected": "sr-ME"
39
+ },
40
+ { "locale": "es-MX", "available": ["en-US", "es"], "expected": "es" },
41
+ { "locale": "es_MX", "available": ["en-US", "es-ES"], "expected": "es-ES" },
42
+ { "locale": "pt-BR", "available": ["en-US", "pt-PT"], "expected": "pt-PT" },
43
+ {
44
+ "locale": "de-CH-1996",
45
+ "available": ["en-US", "de-DE"],
46
+ "expected": "en-US"
47
+ },
48
+ {
49
+ "locale": "ca-ES-valencia",
50
+ "available": ["en-US", "ca-AD", "ca-AD-valencia"],
51
+ "expected": "ca-AD-valencia"
52
+ },
53
+ {
54
+ "locale": "en-AU",
55
+ "available": ["en-GB-x-gulf", "en-US"],
56
+ "expected": "en-US"
57
+ }
58
+ ],
59
+ "direction": [
60
+ { "tag": "ar-SA", "expected": "rtl" },
61
+ { "tag": "he", "expected": "rtl" },
62
+ { "tag": "fa", "expected": "rtl" },
63
+ { "tag": "ur", "expected": "rtl" },
64
+ { "tag": "en-US", "expected": "ltr" },
65
+ { "tag": "nl-NL", "expected": "ltr" },
66
+ { "tag": "zh-Hans-CN", "expected": "ltr" }
67
+ ],
68
+ "plural": {
69
+ "catalogs": {
70
+ "en-US": { "items": { "one": "{count} o", "other": "{count} x" } },
71
+ "ar-SA": {
72
+ "items": {
73
+ "zero": "{count} z",
74
+ "one": "{count} o",
75
+ "two": "{count} t",
76
+ "few": "{count} f",
77
+ "many": "{count} m",
78
+ "other": "{count} x"
79
+ }
80
+ },
81
+ "pl-PL": {
82
+ "items": {
83
+ "one": "{count} o",
84
+ "few": "{count} f",
85
+ "many": "{count} m",
86
+ "other": "{count} x"
87
+ }
88
+ }
89
+ },
90
+ "cases": [
91
+ { "locale": "en-US", "count": 1, "expected": "1 o" },
92
+ { "locale": "en-US", "count": 0, "expected": "0 x" },
93
+ { "locale": "en-US", "count": 1234.5, "expected": "1,234.5 x" },
94
+ { "locale": "ar-SA", "count": 0, "expected": "٠ z" },
95
+ { "locale": "ar-SA", "count": 1, "expected": "١ o" },
96
+ { "locale": "ar-SA", "count": 2, "expected": "٢ t" },
97
+ { "locale": "ar-SA", "count": 5, "expected": "٥ f" },
98
+ { "locale": "ar-SA", "count": 11, "expected": "١١ m" },
99
+ { "locale": "ar-SA", "count": 100, "expected": "١٠٠ x" },
100
+ { "locale": "ar-SA", "count": 103, "expected": "١٠٣ f" },
101
+ { "locale": "pl-PL", "count": 1, "expected": "1 o" },
102
+ { "locale": "pl-PL", "count": 2, "expected": "2 f" },
103
+ { "locale": "pl-PL", "count": 5, "expected": "5 m" },
104
+ { "locale": "pl-PL", "count": 22, "expected": "22 f" },
105
+ { "locale": "pl-PL", "count": 25, "expected": "25 m" },
106
+ { "locale": "pl-PL", "count": 1.5, "expected": "1,5 x" }
107
+ ]
108
+ },
109
+ "interpolation": [
110
+ {
111
+ "locale": "en-US",
112
+ "template": "Hello {name}",
113
+ "values": { "name": "Ana" },
114
+ "expected": "Hello Ana"
115
+ },
116
+ {
117
+ "locale": "en-US",
118
+ "template": "{name} and {name}",
119
+ "values": { "name": "A" },
120
+ "expected": "A and A"
121
+ },
122
+ {
123
+ "locale": "en-US",
124
+ "template": "Keep {missing} literal",
125
+ "values": {},
126
+ "expected": "Keep {missing} literal"
127
+ },
128
+ {
129
+ "locale": "de-DE",
130
+ "template": "{count} Punkte",
131
+ "values": { "count": 1234.5 },
132
+ "expected": "1.234,5 Punkte"
133
+ },
134
+ {
135
+ "locale": "ar-SA",
136
+ "template": "{count}",
137
+ "values": { "count": 5 },
138
+ "expected": "٥"
139
+ }
140
+ ],
141
+ "fallbackChain": {
142
+ "locale": "es-ES",
143
+ "source": { "a": "A", "b": "B" },
144
+ "shipped": { "a": "Á" },
145
+ "hostMessages": { "a": "Ä" },
146
+ "expected": { "a": "Ä", "b": "B", "nope.key": "nope.key" }
147
+ },
148
+ "scenarios": [
149
+ {
150
+ "name": "a host plural override of one category leaves the others to the catalog",
151
+ "locale": "en-US",
152
+ "source": {
153
+ "max": { "one": "{count} answer", "other": "{count} answers" }
154
+ },
155
+ "shipped": {},
156
+ "hostMessages": { "en-US": { "max": { "one": "Just {count} answer" } } },
157
+ "expected": {
158
+ "locale": "en-US",
159
+ "direction": "ltr",
160
+ "t": { "max": "{count} answers" },
161
+ "plural": [
162
+ { "key": "max", "count": 1, "expected": "Just 1 answer" },
163
+ { "key": "max", "count": 2, "expected": "2 answers" }
164
+ ]
165
+ }
166
+ },
167
+ {
168
+ "name": "a host plural override in a shipped locale falls through to that locale's catalog per category",
169
+ "locale": "es",
170
+ "source": {
171
+ "max": { "one": "{count} answer", "other": "{count} answers" }
172
+ },
173
+ "shipped": {
174
+ "es": {
175
+ "max": { "one": "{count} respuesta", "other": "{count} respuestas" }
176
+ }
177
+ },
178
+ "hostMessages": { "es": { "max": { "one": "Solo {count} respuesta" } } },
179
+ "expected": {
180
+ "locale": "es",
181
+ "direction": "ltr",
182
+ "t": { "max": "{count} respuestas" },
183
+ "plural": [
184
+ { "key": "max", "count": 1, "expected": "Solo 1 respuesta" },
185
+ { "key": "max", "count": 2, "expected": "2 respuestas" }
186
+ ]
187
+ }
188
+ },
189
+ {
190
+ "name": "a locale's own other form wins over the fallback's category",
191
+ "locale": "es",
192
+ "source": {
193
+ "max": { "one": "{count} answer", "other": "{count} answers" }
194
+ },
195
+ "shipped": { "es": { "max": { "other": "{count} respuestas" } } },
196
+ "hostMessages": {},
197
+ "expected": {
198
+ "locale": "es",
199
+ "direction": "ltr",
200
+ "t": { "max": "{count} respuestas" },
201
+ "plural": [{ "key": "max", "count": 1, "expected": "1 respuestas" }]
202
+ }
203
+ },
204
+ {
205
+ "name": "a regional host override layers over the shipped parent catalog",
206
+ "locale": "es-MX",
207
+ "source": { "a": "A", "b": "B", "c": "C" },
208
+ "shipped": { "es": { "a": "Á", "b": "Bé" } },
209
+ "hostMessages": { "es-MX": { "a": "Ä" } },
210
+ "expected": {
211
+ "locale": "es",
212
+ "direction": "ltr",
213
+ "t": { "a": "Ä", "b": "Bé", "c": "C" },
214
+ "plural": []
215
+ }
216
+ },
217
+ {
218
+ "name": "a parent host override reaches a regional request",
219
+ "locale": "es-MX",
220
+ "source": { "a": "A", "b": "B" },
221
+ "shipped": { "es": { "a": "Á", "b": "Bé" } },
222
+ "hostMessages": { "es": { "a": "Ä" } },
223
+ "expected": {
224
+ "locale": "es",
225
+ "direction": "ltr",
226
+ "t": { "a": "Ä", "b": "Bé" },
227
+ "plural": []
228
+ }
229
+ },
230
+ {
231
+ "name": "host messages match the request whatever their tag's casing",
232
+ "locale": "es",
233
+ "source": { "a": "A", "b": "B" },
234
+ "shipped": { "es": { "a": "Á", "b": "Bé" } },
235
+ "hostMessages": { "ES": { "a": "Ä" } },
236
+ "expected": {
237
+ "locale": "es",
238
+ "direction": "ltr",
239
+ "t": { "a": "Ä", "b": "Bé" },
240
+ "plural": []
241
+ }
242
+ },
243
+ {
244
+ "name": "a locale nothing serves reports the fallback it is served in",
245
+ "locale": "he",
246
+ "source": { "a": "A" },
247
+ "shipped": { "es": { "a": "Á" } },
248
+ "hostMessages": {},
249
+ "expected": {
250
+ "locale": "en-US",
251
+ "direction": "ltr",
252
+ "t": { "a": "A" },
253
+ "plural": []
254
+ }
255
+ },
256
+ {
257
+ "name": "a locale served by host messages alone reports the host-message locale",
258
+ "locale": "he-IL",
259
+ "source": { "a": "A", "b": "B" },
260
+ "shipped": {},
261
+ "hostMessages": { "he": { "a": "א" } },
262
+ "expected": {
263
+ "locale": "he",
264
+ "direction": "rtl",
265
+ "t": { "a": "א", "b": "B" },
266
+ "plural": []
267
+ }
268
+ }
269
+ ]
270
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * BCP-47 language tag comparison, the one implementation both PIE i18n engines
3
+ * run: players-shared re-exports it as
4
+ * `@pie-players/pie-players-shared/i18n/language-tags`, and pie-elements-ng's
5
+ * `@pie-element/shared-i18n` imports it.
6
+ *
7
+ * Deliberately carries no locale data and no DOM access: this module is the one
8
+ * piece of language machinery that everything else needs, including the
9
+ * accessibility catalog resolver and TTS voice selection, neither of which wants
10
+ * a message catalog.
11
+ *
12
+ * Content producers do not agree on tag syntax. The Learnosity transform emits
13
+ * POSIX `es_ES`, QTI catalog cards carry BCP-47 `xml:lang`, and pie-elements-ng
14
+ * carried a hand-written POSIX-to-BCP-47 mapping table of its own. Comparing
15
+ * with `===`, as the catalog resolver did, means an `es_ES` card matches no
16
+ * request for `es-ES` and surfaces only through a no-language-constraint
17
+ * fallback — resolution by accident.
18
+ *
19
+ * @module @pie-players/pie-language-tags
20
+ */
21
+ /**
22
+ * Canonicalize a language tag for comparison.
23
+ *
24
+ * Converts POSIX underscores to hyphens, strips a POSIX charset or modifier
25
+ * suffix (`es_ES.UTF-8`, `es_ES@euro`), and lowercases. Lowercasing rather than
26
+ * applying BCP-47 display casing is intentional — this output is a comparison
27
+ * key, not something to render. `toLanguageTag` is the form to render.
28
+ *
29
+ * Returns an empty string for nullish or blank input, which callers read as
30
+ * "this card declares no language".
31
+ */
32
+ export declare function normalizeLanguageTag(tag: string | null | undefined): string;
33
+ /**
34
+ * A tag in BCP-47 syntax with its authored casing kept: POSIX underscores become
35
+ * hyphens, so `es_ES` renders as `es-ES`. The form for a `lang` attribute or a
36
+ * platform locale API.
37
+ *
38
+ * Returns an empty string for nullish or blank input.
39
+ */
40
+ export declare function toLanguageTag(tag: string | null | undefined): string;
41
+ /**
42
+ * Whether two language tags denote the same locale, ignoring separator style and
43
+ * case. Two tags that both declare no language are not a match.
44
+ */
45
+ export declare function languageTagsEqual(a: string | null | undefined, b: string | null | undefined): boolean;
46
+ /**
47
+ * The RFC 4647 lookup sequence for a tag, most specific first.
48
+ *
49
+ * `es-MX` yields `["es-mx", "es"]`, so a request for Mexican Spanish reaches a
50
+ * card tagged plain `es` without also reaching one tagged `es-ES`. Truncation
51
+ * never stops on a singleton subtag (`x`, `u`), per RFC 4647 §3.4, and never
52
+ * produces a bare region or script.
53
+ */
54
+ export declare function languageTagLookupSequence(tag: string | null | undefined): string[];
55
+ /**
56
+ * The script a tag is written in, lowercased: explicit (`sr-Latn`), or the
57
+ * CLDR likely script (`zh-TW` maximizes to Hant, `sr` to Cyrl). `undefined`
58
+ * when the tag has no script data or the runtime has no `Intl.Locale`.
59
+ */
60
+ export declare function languageTagScript(tag: string | null | undefined): string | undefined;
61
+ /**
62
+ * Whether a catalog reached by widening serves a request in the same script.
63
+ * A tag without script data passes, so a runtime without `Intl.Locale`
64
+ * resolves as plain RFC 4647 lookup.
65
+ */
66
+ export declare function languageTagScriptsMatch(requested: string | null | undefined, candidate: string | null | undefined): boolean;
67
+ /**
68
+ * The script-aware lookup sequence: the RFC 4647 sequence with the language
69
+ * and likely script inserted before the bare language, so `zh-TW` yields
70
+ * `["zh-tw", "zh-hant", "zh"]`. A bare-language step serves the request only
71
+ * in the same script, which `findBestLanguageMatch` checks.
72
+ */
73
+ export declare function languageTagScriptLookupSequence(tag: string | null | undefined): string[];
74
+ /**
75
+ * Best available tag for a request, or `undefined` when nothing matches.
76
+ * Ranking is by the request's specificity rather than the candidates' order,
77
+ * so `es-MX` prefers an `es-MX` candidate over an `es` one however they were
78
+ * authored, and `zh-TW` reaches `zh-Hant`. A catalog shorter than the request
79
+ * serves it only in the same script: a bare `zh` catalog is Simplified, so
80
+ * `zh-TW` passes it by.
81
+ */
82
+ export declare function findBestLanguageMatch(requested: string | null | undefined, available: readonly (string | null | undefined)[]): string | undefined;
83
+ /**
84
+ * A tag without its variant, extension and private-use subtags, casing kept:
85
+ * `en-GB-x-gulf` yields `en-GB`. The form for a speech engine, whose voices
86
+ * are keyed by language and region.
87
+ */
88
+ export declare function languageTagWithoutVariants(tag: string | null | undefined): string;
89
+ /**
90
+ * `findBestLanguageMatch`, then primary-subtag widening, so `nl-BE` and a bare
91
+ * `nl` reach a regional `nl-NL` and `es-MX` reaches `es-ES`. Widening stays
92
+ * within the request's script and keeps its variant subtags: `zh` never
93
+ * reaches `zh-TW`, `sr-Latn` may reach `sr-ME`, `en-GB` never reaches
94
+ * `en-GB-x-gulf`, and `en-US-x-gulf` may reach `en-GB-x-gulf`. Among several
95
+ * siblings the first in `available` wins. The rule that picks an interface
96
+ * catalog and a content variant alike.
97
+ */
98
+ export declare function findClosestLanguageMatch(requested: string | null | undefined, available: readonly (string | null | undefined)[]): string | undefined;
99
+ /** The content language a backend serves, and whether it is a substitute. */
100
+ export interface ContentLanguageMatch {
101
+ /** The `available` entry served, as authored; `null` for content that declares no language. */
102
+ language: string | null;
103
+ /**
104
+ * `"fallback"` when the served content stands in for the request;
105
+ * `"variety"` when it is another variety of the requested language.
106
+ */
107
+ substitution?: "fallback" | "variety";
108
+ }
109
+ /**
110
+ * The language to serve content in: the closest `available` match for the
111
+ * first `requested` tag that has one, under `findClosestLanguageMatch`, or with
112
+ * `matching: "exact"` only an equal tag, so `es-MX` then passes an `es`
113
+ * variant by. With no match and `fallback` other than `"none"`, the fallback
114
+ * chain serves the closest match for `defaultLanguage` (`en-US` reaches `en`),
115
+ * then content that declares no language (a `null` entry), then `source`;
116
+ * `matching` does not narrow it. `undefined` means nothing can be served. No
117
+ * requested languages run the chain without a substitution. A closest match
118
+ * in another variety, `es-MX` served `es-ES`, records `"variety"`; a host
119
+ * that must not serve one sets `matching: "exact"` and lists the varieties it
120
+ * accepts. Candidates rank
121
+ * `source` first, then by tag, so the order of `available` never decides
122
+ * between two siblings.
123
+ */
124
+ export declare function negotiateContentLanguage(requested: readonly string[], available: readonly (string | null)[], options?: {
125
+ defaultLanguage?: string;
126
+ fallback?: "default" | "none";
127
+ matching?: "closest" | "exact";
128
+ /** The family source's `available` entry. */
129
+ source?: string | null;
130
+ }): ContentLanguageMatch | undefined;
131
+ /**
132
+ * The canonical form of a usable language tag, or `undefined` for anything a
133
+ * language in use would not be: `es_MX` normalizes to `es-MX`, `NL-nl` to
134
+ * `nl-NL`, and `Spanish` or an empty string are not tags. One rule for every
135
+ * `lang` this repository writes — content and chrome alike — and the same rule
136
+ * the PIE elements apply to `model.language`, so every stamped attribute on a
137
+ * page carries the same shape.
138
+ */
139
+ export declare function canonicalLanguageTag(language: string | null | undefined): string | undefined;
package/dist/index.js ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * BCP-47 language tag comparison, the one implementation both PIE i18n engines
3
+ * run: players-shared re-exports it as
4
+ * `@pie-players/pie-players-shared/i18n/language-tags`, and pie-elements-ng's
5
+ * `@pie-element/shared-i18n` imports it.
6
+ *
7
+ * Deliberately carries no locale data and no DOM access: this module is the one
8
+ * piece of language machinery that everything else needs, including the
9
+ * accessibility catalog resolver and TTS voice selection, neither of which wants
10
+ * a message catalog.
11
+ *
12
+ * Content producers do not agree on tag syntax. The Learnosity transform emits
13
+ * POSIX `es_ES`, QTI catalog cards carry BCP-47 `xml:lang`, and pie-elements-ng
14
+ * carried a hand-written POSIX-to-BCP-47 mapping table of its own. Comparing
15
+ * with `===`, as the catalog resolver did, means an `es_ES` card matches no
16
+ * request for `es-ES` and surfaces only through a no-language-constraint
17
+ * fallback — resolution by accident.
18
+ *
19
+ * @module @pie-players/pie-language-tags
20
+ */
21
+ /**
22
+ * Canonicalize a language tag for comparison.
23
+ *
24
+ * Converts POSIX underscores to hyphens, strips a POSIX charset or modifier
25
+ * suffix (`es_ES.UTF-8`, `es_ES@euro`), and lowercases. Lowercasing rather than
26
+ * applying BCP-47 display casing is intentional — this output is a comparison
27
+ * key, not something to render. `toLanguageTag` is the form to render.
28
+ *
29
+ * Returns an empty string for nullish or blank input, which callers read as
30
+ * "this card declares no language".
31
+ */
32
+ export function normalizeLanguageTag(tag) {
33
+ if (!tag)
34
+ return "";
35
+ const separatorsUnified = tag.trim().replace(/_/g, "-");
36
+ const withoutPosixSuffix = separatorsUnified.split(/[.@]/)[0];
37
+ return withoutPosixSuffix.toLowerCase();
38
+ }
39
+ /**
40
+ * A tag in BCP-47 syntax with its authored casing kept: POSIX underscores become
41
+ * hyphens, so `es_ES` renders as `es-ES`. The form for a `lang` attribute or a
42
+ * platform locale API.
43
+ *
44
+ * Returns an empty string for nullish or blank input.
45
+ */
46
+ export function toLanguageTag(tag) {
47
+ return tag ? tag.trim().replace(/_/g, "-") : "";
48
+ }
49
+ /**
50
+ * Whether two language tags denote the same locale, ignoring separator style and
51
+ * case. Two tags that both declare no language are not a match.
52
+ */
53
+ export function languageTagsEqual(a, b) {
54
+ const left = normalizeLanguageTag(a);
55
+ if (!left)
56
+ return false;
57
+ return left === normalizeLanguageTag(b);
58
+ }
59
+ /**
60
+ * The RFC 4647 lookup sequence for a tag, most specific first.
61
+ *
62
+ * `es-MX` yields `["es-mx", "es"]`, so a request for Mexican Spanish reaches a
63
+ * card tagged plain `es` without also reaching one tagged `es-ES`. Truncation
64
+ * never stops on a singleton subtag (`x`, `u`), per RFC 4647 §3.4, and never
65
+ * produces a bare region or script.
66
+ */
67
+ export function languageTagLookupSequence(tag) {
68
+ const normalized = normalizeLanguageTag(tag);
69
+ if (!normalized)
70
+ return [];
71
+ const subtags = normalized.split("-");
72
+ const sequence = [];
73
+ for (let length = subtags.length; length > 0; length--) {
74
+ const candidate = subtags.slice(0, length);
75
+ // A trailing singleton introduces an extension with nothing left in it.
76
+ if (candidate.length > 1 && candidate[candidate.length - 1].length === 1) {
77
+ continue;
78
+ }
79
+ sequence.push(candidate.join("-"));
80
+ }
81
+ return sequence;
82
+ }
83
+ /** `maximize()` runs on every resolution, for each candidate catalog. */
84
+ const scripts = new Map();
85
+ /**
86
+ * The script a tag is written in, lowercased: explicit (`sr-Latn`), or the
87
+ * CLDR likely script (`zh-TW` maximizes to Hant, `sr` to Cyrl). `undefined`
88
+ * when the tag has no script data or the runtime has no `Intl.Locale`.
89
+ */
90
+ export function languageTagScript(tag) {
91
+ const normalized = normalizeLanguageTag(tag);
92
+ if (!normalized ||
93
+ typeof Intl === "undefined" ||
94
+ typeof Intl.Locale !== "function") {
95
+ return undefined;
96
+ }
97
+ if (scripts.has(normalized))
98
+ return scripts.get(normalized);
99
+ let script;
100
+ try {
101
+ script = new Intl.Locale(normalized).maximize().script?.toLowerCase();
102
+ }
103
+ catch {
104
+ // Not a tag `Intl.Locale` parses: no script data.
105
+ }
106
+ scripts.set(normalized, script);
107
+ return script;
108
+ }
109
+ /**
110
+ * Whether a catalog reached by widening serves a request in the same script.
111
+ * A tag without script data passes, so a runtime without `Intl.Locale`
112
+ * resolves as plain RFC 4647 lookup.
113
+ */
114
+ export function languageTagScriptsMatch(requested, candidate) {
115
+ const requestedScript = languageTagScript(requested);
116
+ const candidateScript = languageTagScript(candidate);
117
+ return (!requestedScript || !candidateScript || requestedScript === candidateScript);
118
+ }
119
+ /**
120
+ * The script-aware lookup sequence: the RFC 4647 sequence with the language
121
+ * and likely script inserted before the bare language, so `zh-TW` yields
122
+ * `["zh-tw", "zh-hant", "zh"]`. A bare-language step serves the request only
123
+ * in the same script, which `findBestLanguageMatch` checks.
124
+ */
125
+ export function languageTagScriptLookupSequence(tag) {
126
+ const sequence = languageTagLookupSequence(tag);
127
+ const script = languageTagScript(tag);
128
+ if (!script || sequence.length < 2)
129
+ return sequence;
130
+ const language = sequence[sequence.length - 1];
131
+ const withScript = `${language}-${script}`;
132
+ if (sequence.includes(withScript))
133
+ return sequence;
134
+ return [...sequence.slice(0, -1), withScript, language];
135
+ }
136
+ /**
137
+ * Best available tag for a request, or `undefined` when nothing matches.
138
+ * Ranking is by the request's specificity rather than the candidates' order,
139
+ * so `es-MX` prefers an `es-MX` candidate over an `es` one however they were
140
+ * authored, and `zh-TW` reaches `zh-Hant`. A catalog shorter than the request
141
+ * serves it only in the same script: a bare `zh` catalog is Simplified, so
142
+ * `zh-TW` passes it by.
143
+ */
144
+ export function findBestLanguageMatch(requested, available) {
145
+ const exact = normalizeLanguageTag(requested);
146
+ for (const step of languageTagScriptLookupSequence(requested)) {
147
+ const hit = available.find((candidate) => normalizeLanguageTag(candidate) === step &&
148
+ (step === exact || languageTagScriptsMatch(requested, candidate)));
149
+ if (hit != null)
150
+ return hit;
151
+ }
152
+ return undefined;
153
+ }
154
+ /**
155
+ * A tag's subtags split at the end of its language, script and region:
156
+ * `en-GB-x-gulf` yields `[["en", "GB"], ["x", "gulf"]]`. The second part holds
157
+ * variants, extensions and private use, as authored.
158
+ */
159
+ function splitVariantSubtags(tag) {
160
+ const subtags = toLanguageTag(tag).split(/[.@]/)[0].split("-");
161
+ let length = 1;
162
+ if (/^[a-z]{4}$/i.test(subtags[length] ?? ""))
163
+ length++;
164
+ if (/^([a-z]{2}|\d{3})$/i.test(subtags[length] ?? ""))
165
+ length++;
166
+ return [subtags.slice(0, length), subtags.slice(length)];
167
+ }
168
+ /** The variant, extension and private-use subtags as a comparison key. */
169
+ function variantSubtagsKey(tag) {
170
+ return splitVariantSubtags(tag)[1].join("-").toLowerCase();
171
+ }
172
+ /**
173
+ * Whether `served` is another variety of `requested`: it names a different
174
+ * region, or different variant or private-use subtags. `es-MX` served `es-ES`
175
+ * or `en-GB-x-gulf` served `en-GB` is; `es-MX` served `es`, or `zh-TW` served
176
+ * `zh-Hant`, is not.
177
+ */
178
+ function isOtherLanguageVariety(requested, served) {
179
+ const region = (tag) => {
180
+ const [base] = splitVariantSubtags(tag);
181
+ const last = base.length > 1 ? base[base.length - 1] : "";
182
+ return /^([a-z]{2}|\d{3})$/i.test(last) ? last.toLowerCase() : undefined;
183
+ };
184
+ const requestedRegion = region(requested);
185
+ const servedRegion = region(served);
186
+ return ((requestedRegion !== undefined &&
187
+ servedRegion !== undefined &&
188
+ requestedRegion !== servedRegion) ||
189
+ variantSubtagsKey(requested) !== variantSubtagsKey(served));
190
+ }
191
+ /**
192
+ * A tag without its variant, extension and private-use subtags, casing kept:
193
+ * `en-GB-x-gulf` yields `en-GB`. The form for a speech engine, whose voices
194
+ * are keyed by language and region.
195
+ */
196
+ export function languageTagWithoutVariants(tag) {
197
+ return splitVariantSubtags(tag)[0].join("-");
198
+ }
199
+ /**
200
+ * `findBestLanguageMatch`, then primary-subtag widening, so `nl-BE` and a bare
201
+ * `nl` reach a regional `nl-NL` and `es-MX` reaches `es-ES`. Widening stays
202
+ * within the request's script and keeps its variant subtags: `zh` never
203
+ * reaches `zh-TW`, `sr-Latn` may reach `sr-ME`, `en-GB` never reaches
204
+ * `en-GB-x-gulf`, and `en-US-x-gulf` may reach `en-GB-x-gulf`. Among several
205
+ * siblings the first in `available` wins. The rule that picks an interface
206
+ * catalog and a content variant alike.
207
+ */
208
+ export function findClosestLanguageMatch(requested, available) {
209
+ const best = findBestLanguageMatch(requested, available);
210
+ if (best !== undefined)
211
+ return best;
212
+ const language = normalizeLanguageTag(requested).split("-")[0];
213
+ if (!language)
214
+ return undefined;
215
+ const variants = variantSubtagsKey(requested);
216
+ const sibling = available.find((candidate) => normalizeLanguageTag(candidate).split("-")[0] === language &&
217
+ variantSubtagsKey(candidate) === variants &&
218
+ languageTagScriptsMatch(requested, candidate));
219
+ return sibling ?? undefined;
220
+ }
221
+ /**
222
+ * The language to serve content in: the closest `available` match for the
223
+ * first `requested` tag that has one, under `findClosestLanguageMatch`, or with
224
+ * `matching: "exact"` only an equal tag, so `es-MX` then passes an `es`
225
+ * variant by. With no match and `fallback` other than `"none"`, the fallback
226
+ * chain serves the closest match for `defaultLanguage` (`en-US` reaches `en`),
227
+ * then content that declares no language (a `null` entry), then `source`;
228
+ * `matching` does not narrow it. `undefined` means nothing can be served. No
229
+ * requested languages run the chain without a substitution. A closest match
230
+ * in another variety, `es-MX` served `es-ES`, records `"variety"`; a host
231
+ * that must not serve one sets `matching: "exact"` and lists the varieties it
232
+ * accepts. Candidates rank
233
+ * `source` first, then by tag, so the order of `available` never decides
234
+ * between two siblings.
235
+ */
236
+ export function negotiateContentLanguage(requested, available, options = {}) {
237
+ const { defaultLanguage, fallback = "default", matching = "closest", source, } = options;
238
+ const candidates = [...available].sort((a, b) => {
239
+ if ((a === source) !== (b === source))
240
+ return a === source ? -1 : 1;
241
+ const left = normalizeLanguageTag(a);
242
+ const right = normalizeLanguageTag(b);
243
+ return left < right ? -1 : left > right ? 1 : 0;
244
+ });
245
+ for (const tag of requested) {
246
+ const language = matching === "exact"
247
+ ? candidates.find((candidate) => languageTagsEqual(candidate, tag))
248
+ : findClosestLanguageMatch(tag, candidates);
249
+ if (language != null) {
250
+ return isOtherLanguageVariety(tag, language)
251
+ ? { language, substitution: "variety" }
252
+ : { language };
253
+ }
254
+ }
255
+ if (requested.length > 0 && fallback === "none")
256
+ return undefined;
257
+ let language = findClosestLanguageMatch(defaultLanguage, candidates);
258
+ if (language === undefined && available.includes(null))
259
+ language = null;
260
+ if (language === undefined &&
261
+ source !== undefined &&
262
+ available.includes(source)) {
263
+ language = source;
264
+ }
265
+ if (language === undefined)
266
+ return undefined;
267
+ return requested.length > 0
268
+ ? { language, substitution: "fallback" }
269
+ : { language };
270
+ }
271
+ /**
272
+ * The canonical form of a usable language tag, or `undefined` for anything a
273
+ * language in use would not be: `es_MX` normalizes to `es-MX`, `NL-nl` to
274
+ * `nl-NL`, and `Spanish` or an empty string are not tags. One rule for every
275
+ * `lang` this repository writes — content and chrome alike — and the same rule
276
+ * the PIE elements apply to `model.language`, so every stamped attribute on a
277
+ * page carries the same shape.
278
+ */
279
+ export function canonicalLanguageTag(language) {
280
+ const tag = toLanguageTag(language).split(/[.@]/)[0];
281
+ // `Intl` accepts any 5-8 letter primary subtag, `Spanish` included; a
282
+ // language in use has an ISO 639 code of two or three letters.
283
+ if (!/^[a-z]{2,3}(-|$)/i.test(tag))
284
+ return undefined;
285
+ try {
286
+ return Intl.getCanonicalLocales(tag)[0];
287
+ }
288
+ catch {
289
+ return undefined;
290
+ }
291
+ }
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
- "name": "@pie-players/pie-language-tags",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@pie-players/pie-language-tags",
3
+ "version": "1.0.0-beta.9",
4
+ "type": "module",
5
+ "description": "BCP 47 language tag lookup and matching shared by the PIE i18n engines, with the conformance spec both engines run",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/pie-framework/pie-players.git",
9
+ "directory": "packages/pie-language-tags"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "license": "MIT",
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./i18n-conformance-fixtures.json": "./dist/i18n-conformance-fixtures.json",
24
+ "./package.json": "./package.json"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "build": "rm -rf dist tsconfig.tsbuildinfo && tsc -p tsconfig.json && cp i18n-conformance-fixtures.json dist/",
32
+ "dev": "tsc -p tsconfig.json --watch",
33
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json",
34
+ "lint": "biome lint .",
35
+ "test": "bun test"
36
+ },
37
+ "devDependencies": {
38
+ "@biomejs/biome": "^2.6.4",
39
+ "typescript": "^5.9.3"
40
+ },
41
+ "homepage": "https://github.com/pie-framework/pie-players/tree/master/packages/pie-language-tags#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/pie-framework/pie-players/issues"
44
+ },
45
+ "engines": {
46
+ "node": ">=20.0.0"
47
+ },
48
+ "sideEffects": false
49
+ }