@pie-players/pie-players-shared 1.0.0-beta.0 → 1.0.0-beta.10
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 +21 -0
- package/README.md +18 -17
- package/dist/THIRD-PARTY-LICENSES.txt +51 -0
- package/dist/components/vendor/nds/README.md +43 -5
- package/dist/components/vendor/nds/nds-icon-button.d.ts +2 -2
- package/dist/components/vendor/nds/nds-icon-button.js +2 -2
- package/dist/i18n/catalogs.d.ts +25 -13
- package/dist/i18n/catalogs.js +30 -22
- package/dist/i18n/content-language.d.ts +77 -0
- package/dist/i18n/content-language.js +129 -0
- package/dist/i18n/index.d.ts +7 -2
- package/dist/i18n/index.js +5 -2
- package/dist/i18n/language-tags.d.ts +3 -55
- package/dist/i18n/language-tags.js +3 -90
- package/dist/i18n/locale-load-error.d.ts +35 -0
- package/dist/i18n/locale-load-error.js +30 -0
- package/dist/i18n/messages/en-US.d.ts +151 -7
- package/dist/i18n/messages/en-US.js +168 -7
- package/dist/i18n/provider.d.ts +122 -18
- package/dist/i18n/provider.js +315 -133
- package/dist/i18n/pseudo.d.ts +34 -0
- package/dist/i18n/pseudo.js +106 -0
- package/dist/i18n/types.d.ts +19 -3
- package/dist/index.d.ts +16 -20
- package/dist/index.js +19 -19
- package/dist/instrumentation/providers/CompositeInstrumentationProvider.js +2 -1
- package/dist/item-session-control/index.d.ts +16 -0
- package/dist/item-session-control/index.js +5 -0
- package/dist/{formative → item-session-control}/mastery.d.ts +5 -5
- package/dist/{formative → item-session-control}/mastery.js +3 -3
- package/dist/{formative → item-session-control}/outcome.d.ts +3 -3
- package/dist/{formative → item-session-control}/outcome.js +2 -2
- package/dist/{formative → item-session-control}/policy.d.ts +11 -11
- package/dist/{formative → item-session-control}/policy.js +12 -12
- package/dist/item-session-control/session.d.ts +19 -0
- package/dist/{formative → item-session-control}/session.js +12 -12
- package/dist/{formative → item-session-control}/state.d.ts +26 -26
- package/dist/{formative → item-session-control}/state.js +13 -13
- package/dist/{formative → item-session-control}/types.d.ts +38 -40
- package/dist/{formative → item-session-control}/types.js +2 -2
- package/dist/loaders/ElementLoader.d.ts +1 -1
- package/dist/loaders/element-loader.d.ts +4 -1
- package/dist/loaders/element-loader.js +74 -21
- package/dist/loaders/element-package-spec.d.ts +16 -0
- package/dist/loaders/element-package-spec.js +30 -0
- package/dist/loaders/esm-adapter.d.ts +12 -1
- package/dist/loaders/esm-adapter.js +113 -19
- package/dist/loaders/iife-adapter.d.ts +7 -0
- package/dist/loaders/iife-adapter.js +1 -1
- package/dist/loaders/index.d.ts +2 -1
- package/dist/loaders/index.js +2 -1
- package/dist/loaders/preloaded-registration.d.ts +18 -6
- package/dist/loaders/preloaded-registration.js +44 -9
- package/dist/object/index.d.ts +4 -1
- package/dist/object/index.js +8 -1
- package/dist/pie/config.d.ts +8 -0
- package/dist/pie/config.js +32 -1
- package/dist/pie/index.d.ts +2 -0
- package/dist/pie/index.js +1 -0
- package/dist/pie/math-rendering.d.ts +13 -1
- package/dist/pie/math-rendering.js +54 -1
- package/dist/pie/updates.d.ts +4 -1
- package/dist/pie/updates.js +21 -5
- package/dist/pie/versioned-tag.d.ts +10 -0
- package/dist/pie/versioned-tag.js +27 -0
- package/dist/promise/index.d.ts +10 -0
- package/dist/promise/index.js +16 -0
- package/dist/security/overwide-regions.js +65 -0
- package/dist/security/private-hosts.d.ts +27 -0
- package/dist/security/private-hosts.js +85 -0
- package/dist/security/validate-style-url.js +2 -2
- package/dist/security/wrap-overwide-images.d.ts +6 -2
- package/dist/security/wrap-overwide-images.js +5 -2
- package/dist/security/wrap-overwide-tables.d.ts +6 -2
- package/dist/security/wrap-overwide-tables.js +5 -2
- package/dist/security/wrap-overwide.d.ts +13 -0
- package/dist/security/wrap-overwide.js +68 -3
- package/dist/timed-media/index.d.ts +1 -1
- package/dist/timed-media/index.js +1 -1
- package/dist/timed-media/policy.d.ts +2 -2
- package/dist/timed-media/policy.js +2 -2
- package/dist/timed-media/session.d.ts +1 -1
- package/dist/timed-media/session.js +1 -1
- package/dist/timed-media/types.d.ts +11 -11
- package/dist/tools/term-lookup.d.ts +15 -0
- package/dist/tools/term-lookup.js +16 -0
- package/dist/types/index.d.ts +127 -47
- package/dist/types/interchange.d.ts +116 -0
- package/dist/types/interchange.js +1 -0
- package/dist/ui/content-styles-entry.d.ts +1 -0
- package/dist/ui/content-styles-entry.js +2 -0
- package/dist/ui/content-styles.d.ts +24 -3
- package/dist/ui/content-styles.js +114 -3
- package/dist/ui/focus-trap.d.ts +12 -0
- package/dist/ui/focus-trap.js +41 -1
- package/dist/ui/fontawesome.d.ts +51 -0
- package/dist/ui/fontawesome.js +122 -0
- package/dist/ui/ime.d.ts +6 -0
- package/dist/ui/ime.js +10 -0
- package/dist/ui/overlay-containment.d.ts +13 -0
- package/dist/ui/overlay-containment.js +23 -0
- package/dist/ui/overlay-placement.d.ts +28 -0
- package/dist/ui/overlay-placement.js +17 -2
- package/package.json +72 -36
- package/dist/formative/index.d.ts +0 -16
- package/dist/formative/index.js +0 -5
- package/dist/formative/session.d.ts +0 -19
- package/dist/i18n/messages/nl-NL.d.ts +0 -529
- package/dist/i18n/messages/nl-NL.js +0 -529
- package/dist/instrumentation/new-relic-agent.d.ts +0 -20
- package/dist/loaders/module-shim.d.ts +0 -12
- package/dist/pie/authoring-tag.d.ts +0 -6
- package/dist/pie/configure-initialization.d.ts +0 -21
- package/dist/pie/correct-response-env.d.ts +0 -3
- package/dist/pie/element-announcements.d.ts +0 -39
- package/dist/pie/elements-rendered.d.ts +0 -23
- package/dist/pie/initialize-element.d.ts +0 -26
- package/dist/pie/markup-math.d.ts +0 -20
- package/dist/pie/resource-monitor.d.ts +0 -217
- package/dist/security/sanitize-forbidden-lists.d.ts +0 -8
- package/dist/ui/pointer-rotate.d.ts +0 -29
- package/dist/ui/safe-storage.d.ts +0 -2
package/dist/i18n/provider.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @module @pie-players/pie-players-shared/i18n/provider
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import { canonicalLanguageTag, findClosestLanguageMatch, languageTagLookupSequence, normalizeLanguageTag, toLanguageTag, } from "./language-tags.js";
|
|
13
13
|
import enUS from "./messages/en-US.js";
|
|
14
14
|
/** The locale every fallback chain terminates at. */
|
|
15
15
|
export const DEFAULT_LOCALE = "en-US";
|
|
@@ -57,6 +57,10 @@ const PLURAL_CATEGORIES = new Set([
|
|
|
57
57
|
"many",
|
|
58
58
|
"other",
|
|
59
59
|
]);
|
|
60
|
+
/** A request's tag in canonical form, or its hyphenated spelling where it is not a tag. */
|
|
61
|
+
function requestTag(locale) {
|
|
62
|
+
return canonicalLanguageTag(locale) ?? toLanguageTag(locale);
|
|
63
|
+
}
|
|
60
64
|
function isPluralGroup(node) {
|
|
61
65
|
if (typeof node !== "object" || node === null)
|
|
62
66
|
return false;
|
|
@@ -108,21 +112,35 @@ export function detectBrowserLocale() {
|
|
|
108
112
|
export class SimpleI18n {
|
|
109
113
|
/** Shared across instances and views: `Intl.PluralRules` is not cheap. */
|
|
110
114
|
static pluralRules = new Map();
|
|
111
|
-
|
|
115
|
+
/** The latest request, canonical; what `getRequestedLocale` names. */
|
|
116
|
+
requested;
|
|
117
|
+
/** The request whose strings are served; moves once its catalog is resident. */
|
|
118
|
+
active;
|
|
119
|
+
/** The catalog serving `active`, resolved against catalogs alone. */
|
|
120
|
+
activeCatalog;
|
|
112
121
|
fallbackLocale;
|
|
113
122
|
/** Shared by reference with every `withLocale` view. */
|
|
114
123
|
catalogs;
|
|
124
|
+
/** Keyed by normalized tag, so `ES`, `es_ES` and `es-es` reach one set. */
|
|
115
125
|
customMessages;
|
|
116
126
|
loading;
|
|
127
|
+
/** Counts `setLocale` calls, so a slow load cannot overwrite a later request. */
|
|
128
|
+
requests = 0;
|
|
117
129
|
listeners = new Set();
|
|
118
130
|
config;
|
|
119
131
|
constructor(config = {}) {
|
|
120
132
|
this.config = { ...config };
|
|
121
|
-
this.fallbackLocale =
|
|
133
|
+
this.fallbackLocale =
|
|
134
|
+
toLanguageTag(config.fallbackLocale) || DEFAULT_LOCALE;
|
|
122
135
|
this.catalogs = new Map([[DEFAULT_LOCALE, enUS]]);
|
|
123
|
-
this.customMessages = {
|
|
136
|
+
this.customMessages = {};
|
|
137
|
+
for (const [locale, messages] of Object.entries(config.customMessages ?? {})) {
|
|
138
|
+
this.mergeCustomMessages(locale, messages);
|
|
139
|
+
}
|
|
124
140
|
this.loading = new Map();
|
|
125
|
-
this.
|
|
141
|
+
this.requested = requestTag(config.locale) || DEFAULT_LOCALE;
|
|
142
|
+
this.active = this.requested;
|
|
143
|
+
this.activeCatalog = this.resolveCatalogLocale(this.active);
|
|
126
144
|
}
|
|
127
145
|
/**
|
|
128
146
|
* Set the locale, loading its catalog first.
|
|
@@ -138,10 +156,32 @@ export class SimpleI18n {
|
|
|
138
156
|
this.addCustomMessages(locale, messages);
|
|
139
157
|
}
|
|
140
158
|
}
|
|
141
|
-
await this.setLocale(config.locale ?? this.
|
|
159
|
+
await this.setLocale(config.locale ?? this.requested);
|
|
142
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* The locale the strings are served in: the resolved catalog locale once
|
|
163
|
+
* resident, else the most specific host-message tag on the request's
|
|
164
|
+
* truncation chain, canonical, else the fallback. A request nothing serves
|
|
165
|
+
* names the fallback, so a chrome root stamped from it never names a
|
|
166
|
+
* language its text is not in.
|
|
167
|
+
*/
|
|
143
168
|
getLocale() {
|
|
144
|
-
|
|
169
|
+
if (this.activeCatalog && this.catalogs.has(this.activeCatalog)) {
|
|
170
|
+
return this.activeCatalog;
|
|
171
|
+
}
|
|
172
|
+
const host = this.hostMessageLocale(this.active);
|
|
173
|
+
return host === undefined
|
|
174
|
+
? this.fallbackLocale
|
|
175
|
+
: (canonicalLanguageTag(host) ?? host);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The latest `setLocale` request (or the configured `locale`), canonical, as
|
|
179
|
+
* soon as it is made and whatever its load does. `getLocale` names what is
|
|
180
|
+
* served instead, so the two differ while a catalog loads, after a load
|
|
181
|
+
* fails, and for a tag nothing ships.
|
|
182
|
+
*/
|
|
183
|
+
getRequestedLocale() {
|
|
184
|
+
return this.requested;
|
|
145
185
|
}
|
|
146
186
|
/**
|
|
147
187
|
* Switch locale.
|
|
@@ -149,68 +189,77 @@ export class SimpleI18n {
|
|
|
149
189
|
* Resolves the request against the catalogs this provider can serve, loads
|
|
150
190
|
* the winner if it is not resident, then notifies subscribers — in that
|
|
151
191
|
* order, so a component's reactive read re-runs against a catalog that is
|
|
152
|
-
* already in place rather than against the previous locale.
|
|
192
|
+
* already in place rather than against the previous locale. The last call
|
|
193
|
+
* wins: a call whose load finishes after a later call's leaves the locale
|
|
194
|
+
* to that later call.
|
|
153
195
|
*/
|
|
154
196
|
async setLocale(locale) {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
else {
|
|
174
|
-
const load = this.loadCatalog(resolved);
|
|
175
|
-
this.loading.set(resolved, load);
|
|
176
|
-
try {
|
|
177
|
-
await load;
|
|
178
|
-
}
|
|
179
|
-
finally {
|
|
180
|
-
this.loading.delete(resolved);
|
|
197
|
+
const request = ++this.requests;
|
|
198
|
+
const tag = requestTag(locale) || DEFAULT_LOCALE;
|
|
199
|
+
this.requested = tag;
|
|
200
|
+
// A tag no catalog serves is legitimate: the host may have supplied its own
|
|
201
|
+
// messages for it, and the fallback chain covers whatever those omit.
|
|
202
|
+
const catalog = this.resolveCatalogLocale(tag);
|
|
203
|
+
// The loader is asked only for tags it declares.
|
|
204
|
+
if (catalog &&
|
|
205
|
+
!this.catalogs.has(catalog) &&
|
|
206
|
+
this.config.availableLocales?.includes(catalog)) {
|
|
207
|
+
try {
|
|
208
|
+
let load = this.loading.get(catalog);
|
|
209
|
+
if (!load) {
|
|
210
|
+
load = this.loadCatalog(catalog).finally(() => {
|
|
211
|
+
this.loading.delete(catalog);
|
|
212
|
+
});
|
|
213
|
+
this.loading.set(catalog, load);
|
|
181
214
|
}
|
|
215
|
+
await load;
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
// A superseded request stays silent: the later one reports its own
|
|
219
|
+
// outcome. The current one notifies, since `getRequestedLocale` moved
|
|
220
|
+
// although the served strings did not, and rejects.
|
|
221
|
+
if (request !== this.requests)
|
|
222
|
+
return;
|
|
223
|
+
this.notify();
|
|
224
|
+
throw error;
|
|
182
225
|
}
|
|
183
226
|
}
|
|
184
|
-
this.
|
|
227
|
+
if (request !== this.requests)
|
|
228
|
+
return;
|
|
229
|
+
this.active = tag;
|
|
230
|
+
this.activeCatalog = catalog;
|
|
185
231
|
this.notify();
|
|
186
232
|
}
|
|
187
233
|
t(key, values) {
|
|
188
|
-
const message = this.
|
|
234
|
+
const message = this.resolveMessage(key);
|
|
189
235
|
if (message === undefined) {
|
|
190
|
-
this.config.onMissingKey?.(key, this.
|
|
236
|
+
this.config.onMissingKey?.(key, this.getLocale());
|
|
191
237
|
return key;
|
|
192
238
|
}
|
|
193
|
-
return interpolate(message, values);
|
|
239
|
+
return interpolate(message, values, this.getLocale());
|
|
194
240
|
}
|
|
195
241
|
/**
|
|
196
242
|
* Translate a plural group.
|
|
197
243
|
*
|
|
198
244
|
* The category comes from `Intl.PluralRules` for the active locale, so a
|
|
199
245
|
* locale with more than two forms resolves correctly: Arabic selects among
|
|
200
|
-
* zero/one/two/few/many/other, Polish among one/few/many/other.
|
|
201
|
-
*
|
|
246
|
+
* zero/one/two/few/many/other, Polish among one/few/many/other.
|
|
247
|
+
*
|
|
248
|
+
* Each form resolves on its own, tier by tier (`resolveMessage`): a host
|
|
249
|
+
* overriding one category leaves the others to the catalog, and a locale's
|
|
250
|
+
* own `other` wins over the fallback's `one`. A group no source completes is
|
|
251
|
+
* a miss and returns the key.
|
|
202
252
|
*/
|
|
203
253
|
plural(key, options) {
|
|
204
|
-
const
|
|
205
|
-
if (
|
|
206
|
-
|
|
254
|
+
const message = this.resolveMessage(key, this.selectPluralCategory(options.count));
|
|
255
|
+
if (message === undefined) {
|
|
256
|
+
this.config.onMissingKey?.(key, this.getLocale());
|
|
257
|
+
return key;
|
|
207
258
|
}
|
|
208
|
-
|
|
209
|
-
const form = group[category] ?? group.other;
|
|
210
|
-
return interpolate(form, options);
|
|
259
|
+
return interpolate(message, options, this.getLocale());
|
|
211
260
|
}
|
|
212
261
|
getDirection() {
|
|
213
|
-
return localeDirection(this.
|
|
262
|
+
return localeDirection(this.getLocale());
|
|
214
263
|
}
|
|
215
264
|
/**
|
|
216
265
|
* Locales this provider can serve: resident catalogs, whatever the loader
|
|
@@ -220,12 +269,12 @@ export class SimpleI18n {
|
|
|
220
269
|
const tags = new Set([
|
|
221
270
|
...this.catalogs.keys(),
|
|
222
271
|
...(this.config.availableLocales ?? []),
|
|
223
|
-
...Object.keys(this.customMessages),
|
|
272
|
+
...Object.keys(this.customMessages).map((tag) => canonicalLanguageTag(tag) ?? tag),
|
|
224
273
|
]);
|
|
225
274
|
return [...tags];
|
|
226
275
|
}
|
|
227
276
|
isLocaleLoaded(locale) {
|
|
228
|
-
const resolved = this.
|
|
277
|
+
const resolved = this.resolveCatalogLocale(requestTag(locale));
|
|
229
278
|
return resolved !== undefined && this.catalogs.has(resolved);
|
|
230
279
|
}
|
|
231
280
|
subscribe(listener) {
|
|
@@ -235,19 +284,25 @@ export class SimpleI18n {
|
|
|
235
284
|
};
|
|
236
285
|
}
|
|
237
286
|
hasKey(key) {
|
|
238
|
-
return this.
|
|
287
|
+
return this.resolveMessage(key) !== undefined;
|
|
239
288
|
}
|
|
240
289
|
/**
|
|
241
290
|
* Add or override messages for one locale.
|
|
242
291
|
*
|
|
243
292
|
* Deep-merged, so a deployment can override a single label without restating
|
|
244
|
-
* a catalog
|
|
293
|
+
* a catalog, and keyed by normalized tag, so `es-mx` and `es-MX` are one
|
|
294
|
+
* locale's messages.
|
|
245
295
|
*/
|
|
246
296
|
addCustomMessages(locale, messages) {
|
|
247
|
-
|
|
248
|
-
this.customMessages[tag] = deepMerge(this.customMessages[tag] ?? {}, messages);
|
|
297
|
+
this.mergeCustomMessages(locale, messages);
|
|
249
298
|
this.notify();
|
|
250
299
|
}
|
|
300
|
+
mergeCustomMessages(locale, messages) {
|
|
301
|
+
const tag = normalizeLanguageTag(locale);
|
|
302
|
+
if (!tag)
|
|
303
|
+
return;
|
|
304
|
+
this.customMessages[tag] = deepMerge(this.customMessages[tag] ?? {}, messages);
|
|
305
|
+
}
|
|
251
306
|
/**
|
|
252
307
|
* A view of this provider fixed to another locale.
|
|
253
308
|
*
|
|
@@ -257,19 +312,21 @@ export class SimpleI18n {
|
|
|
257
312
|
* locales: the locale is per-view, the catalogs are per-provider.
|
|
258
313
|
*/
|
|
259
314
|
withLocale(locale) {
|
|
260
|
-
const
|
|
261
|
-
if (
|
|
315
|
+
const tag = requestTag(locale) || DEFAULT_LOCALE;
|
|
316
|
+
if (tag === this.active && tag === this.requested)
|
|
262
317
|
return this;
|
|
263
|
-
const view = new SimpleI18n({ ...this.config,
|
|
264
|
-
view.locale = resolved || DEFAULT_LOCALE;
|
|
318
|
+
const view = new SimpleI18n({ ...this.config, customMessages: {} });
|
|
265
319
|
view.catalogs = this.catalogs;
|
|
266
320
|
view.customMessages = this.customMessages;
|
|
267
321
|
view.loading = this.loading;
|
|
322
|
+
view.requested = tag;
|
|
323
|
+
view.active = tag;
|
|
324
|
+
view.activeCatalog = view.resolveCatalogLocale(tag);
|
|
268
325
|
return view;
|
|
269
326
|
}
|
|
270
327
|
formatNumber(value, options) {
|
|
271
328
|
try {
|
|
272
|
-
return new Intl.NumberFormat(this.
|
|
329
|
+
return new Intl.NumberFormat(this.getLocale(), options).format(value);
|
|
273
330
|
}
|
|
274
331
|
catch {
|
|
275
332
|
return new Intl.NumberFormat(DEFAULT_LOCALE, options).format(value);
|
|
@@ -277,15 +334,15 @@ export class SimpleI18n {
|
|
|
277
334
|
}
|
|
278
335
|
formatDate(date, options) {
|
|
279
336
|
try {
|
|
280
|
-
return new Intl.DateTimeFormat(this.
|
|
337
|
+
return new Intl.DateTimeFormat(this.getLocale(), options).format(date);
|
|
281
338
|
}
|
|
282
339
|
catch {
|
|
283
340
|
return new Intl.DateTimeFormat(DEFAULT_LOCALE, options).format(date);
|
|
284
341
|
}
|
|
285
342
|
}
|
|
286
343
|
/** The resolved catalog for a locale, for a host that wants to inspect it. */
|
|
287
|
-
getBundle(locale = this.
|
|
288
|
-
const tag = this.
|
|
344
|
+
getBundle(locale = this.getLocale()) {
|
|
345
|
+
const tag = this.resolveCatalogLocale(requestTag(locale));
|
|
289
346
|
if (!tag)
|
|
290
347
|
return undefined;
|
|
291
348
|
const translations = this.catalogs.get(tag);
|
|
@@ -294,24 +351,22 @@ export class SimpleI18n {
|
|
|
294
351
|
return { locale: tag, translations, direction: localeDirection(tag) };
|
|
295
352
|
}
|
|
296
353
|
/**
|
|
297
|
-
* Which
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
* catalog we actually ship. POSIX `nl_NL` normalizes on the way in, which is
|
|
302
|
-
* the form the Learnosity transform emits.
|
|
354
|
+
* Which catalog serves a requested tag, under `findClosestLanguageMatch`
|
|
355
|
+
* over the resident catalogs and the loader's declared tags. Host messages
|
|
356
|
+
* take no part: they resolve separately (`hostMessageLocale`), so a host
|
|
357
|
+
* override for `es-MX` cannot hide a shipped `es`.
|
|
303
358
|
*/
|
|
304
|
-
|
|
305
|
-
if (!
|
|
306
|
-
return undefined;
|
|
307
|
-
const available = this.getAvailableLocales();
|
|
308
|
-
const exactOrPrefix = findBestLanguageMatch(locale, available);
|
|
309
|
-
if (exactOrPrefix)
|
|
310
|
-
return exactOrPrefix;
|
|
311
|
-
const requestedLanguage = normalizeLanguageTag(locale).split("-")[0];
|
|
312
|
-
if (!requestedLanguage)
|
|
359
|
+
resolveCatalogLocale(tag) {
|
|
360
|
+
if (!tag)
|
|
313
361
|
return undefined;
|
|
314
|
-
return
|
|
362
|
+
return findClosestLanguageMatch(tag, [
|
|
363
|
+
...this.catalogs.keys(),
|
|
364
|
+
...(this.config.availableLocales ?? []),
|
|
365
|
+
]);
|
|
366
|
+
}
|
|
367
|
+
/** The most specific host-message tag on a request's truncation chain, normalized. */
|
|
368
|
+
hostMessageLocale(tag) {
|
|
369
|
+
return languageTagLookupSequence(tag).find((step) => this.customMessages[step] !== undefined);
|
|
315
370
|
}
|
|
316
371
|
async loadCatalog(locale) {
|
|
317
372
|
const load = this.config.loadCatalog;
|
|
@@ -322,52 +377,74 @@ export class SimpleI18n {
|
|
|
322
377
|
}
|
|
323
378
|
catch (error) {
|
|
324
379
|
// Rethrown with the locale named: `setLocale` rejects before switching
|
|
325
|
-
// (it
|
|
326
|
-
// stays active.
|
|
380
|
+
// (it moves the served state only after the load), so the previous
|
|
381
|
+
// locale stays active.
|
|
327
382
|
throw new Error(`Failed to load i18n catalog for locale: ${locale}`, {
|
|
328
383
|
cause: error,
|
|
329
384
|
});
|
|
330
385
|
}
|
|
331
386
|
}
|
|
332
387
|
/**
|
|
333
|
-
*
|
|
334
|
-
*
|
|
388
|
+
* Two tiers in lookup order: host messages along the active request's
|
|
389
|
+
* truncation chain (`es-MX`, then `es`) and the active catalog, then host
|
|
390
|
+
* messages and the catalog for the fallback locale. Host overrides win, so a
|
|
391
|
+
* deployment can rename one label without forking a catalog.
|
|
335
392
|
*/
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
393
|
+
sourceTiers() {
|
|
394
|
+
return [
|
|
395
|
+
[
|
|
396
|
+
...languageTagLookupSequence(this.active).map((step) => this.customMessages[step]),
|
|
397
|
+
this.activeCatalog ? this.catalogs.get(this.activeCatalog) : undefined,
|
|
398
|
+
],
|
|
399
|
+
[
|
|
400
|
+
this.customMessages[normalizeLanguageTag(this.fallbackLocale)],
|
|
401
|
+
this.catalogs.get(this.fallbackLocale),
|
|
402
|
+
],
|
|
342
403
|
];
|
|
343
|
-
for (const source of sources) {
|
|
344
|
-
const hit = readPath(source, key);
|
|
345
|
-
if (hit !== undefined)
|
|
346
|
-
return hit;
|
|
347
|
-
}
|
|
348
|
-
return undefined;
|
|
349
404
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
405
|
+
/**
|
|
406
|
+
* The text for `key`. The first source that has it decides its shape: a
|
|
407
|
+
* string is the text, and a plural group resolves `category`, else `other`,
|
|
408
|
+
* within the first tier that has a group, so a locale's own `other` wins
|
|
409
|
+
* over the fallback's `one`. A namespace (`t("common")`) is a miss:
|
|
410
|
+
* returning the object would put one where the caller expects a string.
|
|
411
|
+
*/
|
|
412
|
+
resolveMessage(key, category) {
|
|
413
|
+
const tiers = [];
|
|
414
|
+
for (const tier of this.sourceTiers()) {
|
|
415
|
+
const groups = [];
|
|
416
|
+
for (const source of tier) {
|
|
417
|
+
const node = readPath(source, key);
|
|
418
|
+
if (typeof node === "string") {
|
|
419
|
+
if (tiers.every((t) => t.length === 0) && groups.length === 0) {
|
|
420
|
+
return node;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
else if (node !== undefined && isPluralGroup(node)) {
|
|
424
|
+
groups.push(node);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
tiers.push(groups);
|
|
428
|
+
}
|
|
429
|
+
for (const groups of tiers) {
|
|
430
|
+
const form = (name) => groups.find((group) => typeof group[name] === "string")?.[name];
|
|
431
|
+
const text = (category === undefined ? undefined : form(category)) ?? form("other");
|
|
432
|
+
if (text !== undefined)
|
|
433
|
+
return text;
|
|
434
|
+
}
|
|
359
435
|
return undefined;
|
|
360
436
|
}
|
|
361
437
|
selectPluralCategory(count) {
|
|
362
|
-
|
|
438
|
+
const locale = this.getLocale();
|
|
439
|
+
let rules = SimpleI18n.pluralRules.get(locale);
|
|
363
440
|
if (!rules) {
|
|
364
441
|
try {
|
|
365
|
-
rules = new Intl.PluralRules(
|
|
442
|
+
rules = new Intl.PluralRules(locale);
|
|
366
443
|
}
|
|
367
444
|
catch {
|
|
368
445
|
return count === 1 ? "one" : "other";
|
|
369
446
|
}
|
|
370
|
-
SimpleI18n.pluralRules.set(
|
|
447
|
+
SimpleI18n.pluralRules.set(locale, rules);
|
|
371
448
|
}
|
|
372
449
|
return rules.select(count);
|
|
373
450
|
}
|
|
@@ -389,12 +466,41 @@ function readPath(source, key) {
|
|
|
389
466
|
}
|
|
390
467
|
return current;
|
|
391
468
|
}
|
|
392
|
-
|
|
469
|
+
/**
|
|
470
|
+
* Substitute `{name}` placeholders.
|
|
471
|
+
*
|
|
472
|
+
* A finite numeric value renders through `Intl.NumberFormat` for the active
|
|
473
|
+
* locale, so digits and grouping follow CLDR locale defaults: `ar` renders
|
|
474
|
+
* Arabic-Indic digits, `fr` groups with narrow no-break spaces, `en-US` with
|
|
475
|
+
* commas. Catalog text that spells digits out is untouched.
|
|
476
|
+
*/
|
|
477
|
+
const numberFormats = new Map();
|
|
478
|
+
function formatNumber(value, locale) {
|
|
479
|
+
let formatter = numberFormats.get(locale);
|
|
480
|
+
if (!formatter) {
|
|
481
|
+
try {
|
|
482
|
+
formatter = new Intl.NumberFormat(locale);
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
formatter = new Intl.NumberFormat(DEFAULT_LOCALE);
|
|
486
|
+
}
|
|
487
|
+
numberFormats.set(locale, formatter);
|
|
488
|
+
}
|
|
489
|
+
return formatter.format(value);
|
|
490
|
+
}
|
|
491
|
+
function interpolate(text, values, locale) {
|
|
393
492
|
if (!values)
|
|
394
493
|
return text;
|
|
395
494
|
return text.replace(/\{(\w+)\}/g, (match, name) => {
|
|
396
495
|
const value = values[name];
|
|
397
|
-
|
|
496
|
+
if (value === undefined || value === null)
|
|
497
|
+
return match;
|
|
498
|
+
if (locale !== undefined &&
|
|
499
|
+
typeof value === "number" &&
|
|
500
|
+
Number.isFinite(value)) {
|
|
501
|
+
return formatNumber(value, locale);
|
|
502
|
+
}
|
|
503
|
+
return String(value);
|
|
398
504
|
});
|
|
399
505
|
}
|
|
400
506
|
function deepMerge(target, source) {
|
|
@@ -421,48 +527,124 @@ let defaultProvider;
|
|
|
421
527
|
* present, and this is that state. A tool mounted bare — in `print-player` or
|
|
422
528
|
* an authoring harness — resolves this and renders English rather than
|
|
423
529
|
* leaking raw message keys onto the screen. It carries no locale loader, so it
|
|
424
|
-
* pulls no catalog beyond the
|
|
530
|
+
* pulls no catalog beyond the English one already in the bundle.
|
|
425
531
|
*/
|
|
426
532
|
export function getDefaultI18n() {
|
|
427
533
|
if (!defaultProvider)
|
|
428
534
|
defaultProvider = new SimpleI18n();
|
|
429
535
|
return defaultProvider;
|
|
430
536
|
}
|
|
431
|
-
/**
|
|
432
|
-
* Resolve a component's interface-locale provider from a published context.
|
|
433
|
-
*
|
|
434
|
-
* Returns a **fresh** object on every call. A
|
|
435
|
-
* provider is mutable: `setLocale` swaps its catalog in place, so its identity is
|
|
436
|
-
* unchanged before and after a locale load. A reactive `$derived` reading the
|
|
437
|
-
* provider directly therefore never invalidates, and a label rendered while the
|
|
438
|
-
* catalog was still loading stays English forever — the exact silent failure
|
|
439
|
-
* `composition-context.md` records for a context published without a change
|
|
440
|
-
* signal, and it is silent here too because English is a plausible answer.
|
|
441
|
-
*
|
|
442
|
-
* Deriving this instead gives the component a value whose identity tracks the
|
|
443
|
-
* context republish, so the strings built from it re-render. Call it inside
|
|
444
|
-
* `$derived`, never once at setup:
|
|
445
|
-
*
|
|
446
|
-
* ```ts
|
|
447
|
-
* const interfaceI18n = $derived(resolveInterfaceI18n(runtimeContext));
|
|
448
|
-
* ```
|
|
449
|
-
*
|
|
450
|
-
* A context with no provider — no publisher at all — resolves to the English-only
|
|
451
|
-
* default rather than nothing, which is the graceful default the pull pattern
|
|
452
|
-
* requires.
|
|
453
|
-
*/
|
|
454
537
|
export function resolveInterfaceI18n(source) {
|
|
455
538
|
const provider = source?.i18n ?? getDefaultI18n();
|
|
456
539
|
// A thin facade rather than the provider itself: cheap to allocate (a handful
|
|
457
540
|
// of times per session, once per republish) and it carries no state of its own,
|
|
458
|
-
// so it cannot drift from the provider it delegates to.
|
|
541
|
+
// so it cannot drift from the provider it delegates to. The locale formatters
|
|
542
|
+
// are part of the promised surface, so a provider that does not implement
|
|
543
|
+
// them falls through to `Intl` at the locale the provider resolves — the same
|
|
544
|
+
// thing the bundled provider does, and the same graceful default every other
|
|
545
|
+
// optional member here applies.
|
|
459
546
|
return {
|
|
460
|
-
|
|
547
|
+
// Canonical, because components read this for `lang` attributes; the
|
|
548
|
+
// provider's own `getLocale()` keeps the tag as resolved for lookups.
|
|
549
|
+
getLocale: () => {
|
|
550
|
+
const locale = provider.getLocale();
|
|
551
|
+
return canonicalLanguageTag(locale) ?? locale;
|
|
552
|
+
},
|
|
553
|
+
getRequestedLocale: () => requestedLocaleOf(provider),
|
|
461
554
|
setLocale: (locale) => provider.setLocale(locale),
|
|
462
555
|
t: (key, values) => provider.t(key, values),
|
|
463
556
|
plural: (key, options) => provider.plural?.(key, options) ?? provider.t(key, options),
|
|
464
557
|
getDirection: () => provider.getDirection?.() ?? "ltr",
|
|
465
558
|
hasKey: (key) => provider.hasKey?.(key) ?? false,
|
|
466
559
|
subscribe: (listener) => provider.subscribe?.(listener) ?? (() => { }),
|
|
560
|
+
formatNumber: (value, options) => provider.formatNumber?.(value, options) ??
|
|
561
|
+
new Intl.NumberFormat(provider.getLocale(), options).format(value),
|
|
562
|
+
formatDate: (value, options) => provider.formatDate?.(value, options) ??
|
|
563
|
+
new Intl.DateTimeFormat(provider.getLocale(), options).format(value),
|
|
467
564
|
};
|
|
468
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* Stamp the interface locale's `lang` and `dir` on a chrome element.
|
|
568
|
+
*
|
|
569
|
+
* The imperative form of the `lang={…} dir={…}` pair a template puts on its
|
|
570
|
+
* root, for the elements a template cannot reach: a shadow host whose chrome
|
|
571
|
+
* is slotted light DOM, a shell built with `document.createElement`, a vanilla
|
|
572
|
+
* element stamping itself. Call it from whatever already re-runs on a locale
|
|
573
|
+
* change and on a catalog load: `getLocale()` moves only once the requested
|
|
574
|
+
* catalog is resident, so a stamp taken at setup alone names the locale that
|
|
575
|
+
* rendered before the load.
|
|
576
|
+
*/
|
|
577
|
+
export function stampInterfaceLocale(target, provider) {
|
|
578
|
+
// `lang` takes the canonical form: a host's own provider may report a tag
|
|
579
|
+
// as the host spelled it, and `NL-nl` is a valid request but not a valid
|
|
580
|
+
// attribute value. Every `lang` this repository writes goes through the
|
|
581
|
+
// same rule, so chrome and content stamps match in shape.
|
|
582
|
+
const locale = provider.getLocale();
|
|
583
|
+
target.setAttribute("lang", canonicalLanguageTag(locale) ?? locale);
|
|
584
|
+
target.setAttribute("dir", provider.getDirection?.() ?? "ltr");
|
|
585
|
+
}
|
|
586
|
+
/** A provider's requested tag, canonical; `getLocale` for one that does not track requests. */
|
|
587
|
+
function requestedLocaleOf(provider) {
|
|
588
|
+
const locale = provider.getRequestedLocale?.() ?? provider.getLocale();
|
|
589
|
+
return canonicalLanguageTag(locale) ?? locale;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Whether `provider` was already asked for `locale`. An element handed its
|
|
593
|
+
* owner's provider together with the owner's `locale` skips asking again, so a
|
|
594
|
+
* catalog that fails to load is fetched and reported once, by the owner, and
|
|
595
|
+
* not again by every row and player under it.
|
|
596
|
+
*/
|
|
597
|
+
export function isLocaleRequested(provider, locale) {
|
|
598
|
+
const requested = provider.getRequestedLocale?.();
|
|
599
|
+
if (!requested)
|
|
600
|
+
return false;
|
|
601
|
+
return ((canonicalLanguageTag(requested) ?? requested) ===
|
|
602
|
+
(canonicalLanguageTag(locale) ?? locale));
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Whether a host chose a player's interface locale: it set the player's
|
|
606
|
+
* `locale`, or supplied a provider that was asked for a locale other than
|
|
607
|
+
* `en-US`.
|
|
608
|
+
*
|
|
609
|
+
* A player answers `interfaceLocaleContext` from the first time this holds,
|
|
610
|
+
* and never before it. An element left unanswered labels its chrome in the
|
|
611
|
+
* language its item declares, which is how an item declaring Spanish keeps
|
|
612
|
+
* Spanish labels under a host that sets no locale; a default `en-US` answer
|
|
613
|
+
* would override that declaration. A supplied provider asked for `en-US` is
|
|
614
|
+
* not a choice, because hosts supply one to add catalogs or messages; a host
|
|
615
|
+
* that wants `en-US` chrome over the item's language sets `locale="en-US"`.
|
|
616
|
+
* Once answering, a player keeps answering, so clearing `locale` republishes
|
|
617
|
+
* `en-US` to the elements that subscribed.
|
|
618
|
+
*
|
|
619
|
+
* The toolkit and the item player both decide through this rule. An item
|
|
620
|
+
* player a section mounts receives the toolkit's provider, so it answers only
|
|
621
|
+
* when the toolkit above it would. Callers re-evaluate it on the provider's
|
|
622
|
+
* change signal, since a supplied provider's locale moves without a property
|
|
623
|
+
* change.
|
|
624
|
+
*/
|
|
625
|
+
export function hostChoseInterfaceLocale(player) {
|
|
626
|
+
if (typeof player.locale === "string" && player.locale.trim() !== "") {
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
// The request, not the served locale: a provider asked for a tag nothing
|
|
630
|
+
// ships serves `en-US`, and the host still chose that tag.
|
|
631
|
+
if (!player.i18n)
|
|
632
|
+
return false;
|
|
633
|
+
return requestedLocaleOf(player.i18n) !== DEFAULT_LOCALE;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* The tag a player answers `interfaceLocaleContext` with: its `locale`,
|
|
637
|
+
* canonical, from the moment it is set, else its provider's requested tag.
|
|
638
|
+
*
|
|
639
|
+
* The request rather than the players' own catalog match, because elements
|
|
640
|
+
* resolve the tag against their own catalogs: a host asking for `en-GB` or
|
|
641
|
+
* `fr-CA` reaches the elements' catalogs and host messages for that tag
|
|
642
|
+
* whatever the players ship, and neither a players' catalog still loading nor
|
|
643
|
+
* one that failed holds the elements on the previous locale.
|
|
644
|
+
*/
|
|
645
|
+
export function requestedInterfaceLocale(player) {
|
|
646
|
+
const locale = typeof player.locale === "string" ? player.locale.trim() : "";
|
|
647
|
+
if (locale)
|
|
648
|
+
return requestTag(locale);
|
|
649
|
+
return requestedLocaleOf(player.i18n);
|
|
650
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pseudo-localization: a transform over the English catalog that keeps every key,
|
|
3
|
+
* plural category and `{placeholder}` intact while making each string accented,
|
|
4
|
+
* bracketed and padded roughly 40% longer.
|
|
5
|
+
*
|
|
6
|
+
* What it is for: the gates check parity, not rendered geometry. A pseudo-locale
|
|
7
|
+
* makes truncation, clipped labels, strings glued together in code and text that
|
|
8
|
+
* was never catalog text visible in any demo or e2e run, before a translator or a
|
|
9
|
+
* reviewer sees a real one. It is the industry's `qps-ploc` (Windows) and
|
|
10
|
+
* `en-XA`/`ar-XB` (Android) practice; PIE uses the Android tags, registered
|
|
11
|
+
* through `customMessages`, never shipped as catalogs.
|
|
12
|
+
*/
|
|
13
|
+
import type { MessageCatalog } from "./types.js";
|
|
14
|
+
/** Expanded Latin pseudo-locale: text grows ~40% and carries accents and brackets. */
|
|
15
|
+
export declare const PSEUDO_LOCALE_EXPANDED = "en-XA";
|
|
16
|
+
/**
|
|
17
|
+
* Right-to-left pseudo-locale: the same transformed English under an `ar` primary
|
|
18
|
+
* subtag, so `localeDirection` resolves RTL and chrome lays out mirrored without a
|
|
19
|
+
* word of Arabic on screen.
|
|
20
|
+
*/
|
|
21
|
+
export declare const PSEUDO_LOCALE_RTL = "ar-XB";
|
|
22
|
+
export interface PseudoLocalizeOptions {
|
|
23
|
+
/** Extra length as a fraction of the original, on top of what accents add. Default 0.4. */
|
|
24
|
+
expansion?: number;
|
|
25
|
+
/** Wrap every string in `[` and `]` so concatenated fragments stand out. Default true. */
|
|
26
|
+
brackets?: boolean;
|
|
27
|
+
/** Map Latin letters to accented look-alikes. Default true. */
|
|
28
|
+
accents?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Produce the pseudo-localized twin of a catalog: same keys, same plural
|
|
32
|
+
* categories, same placeholder sets, longer accented bracketed strings.
|
|
33
|
+
*/
|
|
34
|
+
export declare function pseudoLocalize(catalog: MessageCatalog, options?: PseudoLocalizeOptions): MessageCatalog;
|