@pie-players/pie-players-shared 0.3.67 → 0.3.69
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 +59 -0
- package/dist/formative/index.d.ts +16 -0
- package/dist/formative/index.js +5 -0
- package/dist/formative/mastery.d.ts +16 -0
- package/dist/formative/mastery.js +43 -0
- package/dist/formative/outcome.d.ts +19 -0
- package/dist/formative/outcome.js +90 -0
- package/dist/formative/policy.d.ts +37 -0
- package/dist/formative/policy.js +86 -0
- package/dist/formative/session.d.ts +19 -0
- package/dist/formative/session.js +113 -0
- package/dist/formative/state.d.ts +60 -0
- package/dist/formative/state.js +140 -0
- package/dist/formative/types.d.ts +164 -0
- package/dist/formative/types.js +13 -0
- package/dist/i18n/catalogs.d.ts +38 -0
- package/dist/i18n/catalogs.js +47 -0
- package/dist/i18n/index.d.ts +28 -4
- package/dist/i18n/index.js +34 -3
- package/dist/i18n/language-tags.d.ts +51 -0
- package/dist/i18n/language-tags.js +84 -0
- package/dist/i18n/messages/en-US.d.ts +601 -0
- package/dist/i18n/messages/en-US.js +617 -0
- package/dist/i18n/messages/nl-NL.d.ts +516 -0
- package/dist/i18n/messages/nl-NL.js +516 -0
- package/dist/i18n/provider.d.ts +168 -0
- package/dist/i18n/provider.js +472 -0
- package/dist/i18n/types.d.ts +177 -28
- package/dist/i18n/types.js +6 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/pie/custom-element-define.d.ts +24 -0
- package/dist/pie/custom-element-define.js +36 -6
- package/dist/pie/index.d.ts +1 -1
- package/dist/pie/index.js +1 -1
- package/dist/pie/versioned-tag.js +3 -2
- package/dist/security/sanitize-forbidden-lists.d.ts +8 -0
- package/dist/security/sanitize-forbidden-lists.js +41 -0
- package/dist/security/sanitize-item-markup.js +3 -36
- package/dist/security/sanitize-svg-icon.js +3 -25
- package/dist/security/wrap-overwide-images.d.ts +5 -10
- package/dist/security/wrap-overwide-images.js +13 -62
- package/dist/security/wrap-overwide-tables.d.ts +5 -10
- package/dist/security/wrap-overwide-tables.js +13 -62
- package/dist/security/wrap-overwide.d.ts +51 -0
- package/dist/security/wrap-overwide.js +91 -0
- package/dist/server/npm-auth-env.d.ts +13 -0
- package/dist/server/npm-auth-env.js +64 -0
- package/dist/timed-media/index.d.ts +17 -0
- package/dist/timed-media/index.js +16 -0
- package/dist/timed-media/media-element-source.d.ts +36 -0
- package/dist/timed-media/media-element-source.js +121 -0
- package/dist/timed-media/policy.d.ts +36 -0
- package/dist/timed-media/policy.js +297 -0
- package/dist/timed-media/session.d.ts +21 -0
- package/dist/timed-media/session.js +85 -0
- package/dist/timed-media/state.d.ts +66 -0
- package/dist/timed-media/state.js +357 -0
- package/dist/timed-media/types.d.ts +343 -0
- package/dist/timed-media/types.js +12 -0
- package/dist/tools/term-lookup.d.ts +189 -0
- package/dist/tools/term-lookup.js +284 -0
- package/dist/types/index.d.ts +105 -1
- package/dist/ui/attribute-coercion.d.ts +1 -0
- package/dist/ui/attribute-coercion.js +19 -0
- package/dist/ui/first-focusable.d.ts +22 -0
- package/dist/ui/first-focusable.js +47 -0
- package/dist/ui/focus-trap.js +7 -2
- package/dist/ui/pointer-drag.d.ts +30 -0
- package/dist/ui/pointer-drag.js +34 -0
- package/package.json +38 -6
- package/dist/i18n/loader.d.ts +0 -35
- package/dist/i18n/loader.js +0 -132
- package/dist/i18n/simple-i18n.d.ts +0 -68
- package/dist/i18n/simple-i18n.js +0 -198
- package/dist/i18n/translations/ar/common.json +0 -36
- package/dist/i18n/translations/ar/toolkit.json +0 -48
- package/dist/i18n/translations/ar/tools.json +0 -103
- package/dist/i18n/translations/en/common.json +0 -36
- package/dist/i18n/translations/en/toolkit.json +0 -48
- package/dist/i18n/translations/en/tools.json +0 -103
- package/dist/i18n/translations/es/common.json +0 -36
- package/dist/i18n/translations/es/toolkit.json +0 -48
- package/dist/i18n/translations/es/tools.json +0 -103
- package/dist/i18n/translations/zh/common.json +0 -36
- package/dist/i18n/translations/zh/toolkit.json +0 -48
- package/dist/i18n/translations/zh/tools.json +0 -103
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bundled i18n provider.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately imports only the English catalog. The dynamic loader map for every
|
|
5
|
+
* other locale lives in `./catalogs.js`, which players import and tools do not:
|
|
6
|
+
* every player and tool `vite.config.ts` sets `external: []`, so anything
|
|
7
|
+
* reachable from a tool's entry inlines into that tool's bundle. Keeping the
|
|
8
|
+
* loader map out of this module is what stops a locale chunk from being emitted
|
|
9
|
+
* into eighteen tool bundles that will never call `setLocale`.
|
|
10
|
+
*
|
|
11
|
+
* @module @pie-players/pie-players-shared/i18n/provider
|
|
12
|
+
*/
|
|
13
|
+
import type { DynamicMessageKey, I18nConfig, I18nProvider, I18nServiceApi, InterpolationValues, LocaleCode, MessageCatalog, MessageKeyInput, PluralOptions, TextDirection, TranslationBundle } from "./types.js";
|
|
14
|
+
/** The locale every fallback chain terminates at. */
|
|
15
|
+
export declare const DEFAULT_LOCALE = "en-US";
|
|
16
|
+
/**
|
|
17
|
+
* Assert that a runtime-assembled string is a message key.
|
|
18
|
+
*
|
|
19
|
+
* Every other call site passes a literal checked against `MessageKey`, so a
|
|
20
|
+
* typo is a compile error. This is the one escape hatch, for a key built from an
|
|
21
|
+
* id the catalog cannot enumerate — a host-supplied element category, a
|
|
22
|
+
* colour-scheme id. Unchecked, like any assertion: pair it with `hasKey` where a
|
|
23
|
+
* miss must not reach the screen.
|
|
24
|
+
*/
|
|
25
|
+
export declare function dynamicMessageKey(key: string): DynamicMessageKey;
|
|
26
|
+
/**
|
|
27
|
+
* Writing direction of a locale tag.
|
|
28
|
+
*
|
|
29
|
+
* Exported because the catalog resolver and the TTS voice selector need it
|
|
30
|
+
* without constructing a provider.
|
|
31
|
+
*/
|
|
32
|
+
export declare function localeDirection(locale: string): TextDirection;
|
|
33
|
+
/**
|
|
34
|
+
* The browser's preferred locale, or `DEFAULT_LOCALE` where there is no
|
|
35
|
+
* `navigator` — so this is safe in Node, which `players-shared` must remain.
|
|
36
|
+
*
|
|
37
|
+
* Nothing calls this by default. Under fixed lockstep patch-only versioning a
|
|
38
|
+
* rendered-string change reaches a host's live delivery on their next install
|
|
39
|
+
* with no build signal on their side, so auto-detection would silently switch an
|
|
40
|
+
* English deployment's chrome on a Dutch-configured laptop. A host that wants
|
|
41
|
+
* detection opts into it.
|
|
42
|
+
*/
|
|
43
|
+
export declare function detectBrowserLocale(): string;
|
|
44
|
+
/**
|
|
45
|
+
* The bundled provider.
|
|
46
|
+
*
|
|
47
|
+
* Retains the `SimpleI18n` name and the `I18nServiceApi` surface it published
|
|
48
|
+
* before adoption. What changed is behaviour that had no caller: catalogs are
|
|
49
|
+
* BCP-47 keyed and resolved through RFC 4647 lookup rather than string equality,
|
|
50
|
+
* `dir`/`lang` are no longer written to `document.documentElement` (an embedded
|
|
51
|
+
* player has no business writing the host page's root), and plural categories
|
|
52
|
+
* come from `Intl.PluralRules` alone.
|
|
53
|
+
*/
|
|
54
|
+
export declare class SimpleI18n implements I18nServiceApi {
|
|
55
|
+
/** Shared across instances and views: `Intl.PluralRules` is not cheap. */
|
|
56
|
+
private static readonly pluralRules;
|
|
57
|
+
private locale;
|
|
58
|
+
private readonly fallbackLocale;
|
|
59
|
+
/** Shared by reference with every `withLocale` view. */
|
|
60
|
+
private catalogs;
|
|
61
|
+
private customMessages;
|
|
62
|
+
private loading;
|
|
63
|
+
private readonly listeners;
|
|
64
|
+
private config;
|
|
65
|
+
constructor(config?: I18nConfig);
|
|
66
|
+
/**
|
|
67
|
+
* Set the locale, loading its catalog first.
|
|
68
|
+
*
|
|
69
|
+
* Convenience over `setLocale` for a host that constructs the provider and
|
|
70
|
+
* configures it in two steps. Never falls back to the browser locale; see
|
|
71
|
+
* {@link detectBrowserLocale}.
|
|
72
|
+
*/
|
|
73
|
+
initialize(config: I18nConfig): Promise<void>;
|
|
74
|
+
getLocale(): string;
|
|
75
|
+
/**
|
|
76
|
+
* Switch locale.
|
|
77
|
+
*
|
|
78
|
+
* Resolves the request against the catalogs this provider can serve, loads
|
|
79
|
+
* the winner if it is not resident, then notifies subscribers — in that
|
|
80
|
+
* order, so a component's reactive read re-runs against a catalog that is
|
|
81
|
+
* already in place rather than against the previous locale.
|
|
82
|
+
*/
|
|
83
|
+
setLocale(locale: LocaleCode): Promise<void>;
|
|
84
|
+
t(key: MessageKeyInput, values?: InterpolationValues): string;
|
|
85
|
+
/**
|
|
86
|
+
* Translate a plural group.
|
|
87
|
+
*
|
|
88
|
+
* The category comes from `Intl.PluralRules` for the active locale, so a
|
|
89
|
+
* locale with more than two forms resolves correctly: Arabic selects among
|
|
90
|
+
* zero/one/two/few/many/other, Polish among one/few/many/other. A category the
|
|
91
|
+
* catalog does not carry resolves to `other`.
|
|
92
|
+
*/
|
|
93
|
+
plural(key: MessageKeyInput, options: PluralOptions): string;
|
|
94
|
+
getDirection(): TextDirection;
|
|
95
|
+
/**
|
|
96
|
+
* Locales this provider can serve: resident catalogs, whatever the loader
|
|
97
|
+
* declares, and any locale the host supplied messages for.
|
|
98
|
+
*/
|
|
99
|
+
getAvailableLocales(): string[];
|
|
100
|
+
isLocaleLoaded(locale: LocaleCode): boolean;
|
|
101
|
+
subscribe(listener: () => void): () => void;
|
|
102
|
+
hasKey(key: MessageKeyInput): boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Add or override messages for one locale.
|
|
105
|
+
*
|
|
106
|
+
* Deep-merged, so a deployment can override a single label without restating
|
|
107
|
+
* a catalog.
|
|
108
|
+
*/
|
|
109
|
+
addCustomMessages(locale: string, messages: MessageCatalog): void;
|
|
110
|
+
/**
|
|
111
|
+
* A view of this provider fixed to another locale.
|
|
112
|
+
*
|
|
113
|
+
* Catalogs, in-flight loads and custom messages are shared by reference, so
|
|
114
|
+
* `setLocale` through either side is visible to both and no catalog is parsed
|
|
115
|
+
* twice. This is what lets two players on one page render different chrome
|
|
116
|
+
* locales: the locale is per-view, the catalogs are per-provider.
|
|
117
|
+
*/
|
|
118
|
+
withLocale(locale: LocaleCode): I18nServiceApi;
|
|
119
|
+
formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
|
|
120
|
+
formatDate(date: Date, options?: Intl.DateTimeFormatOptions): string;
|
|
121
|
+
/** The resolved catalog for a locale, for a host that wants to inspect it. */
|
|
122
|
+
getBundle(locale?: LocaleCode): TranslationBundle | undefined;
|
|
123
|
+
/**
|
|
124
|
+
* Which shipped catalog serves a requested tag.
|
|
125
|
+
*
|
|
126
|
+
* RFC 4647 lookup first, so `nl-BE` reaches `nl-NL` through `nl`; then
|
|
127
|
+
* primary-subtag widening, so a bare `nl` reaches the regional `nl-NL`
|
|
128
|
+
* catalog we actually ship. POSIX `nl_NL` normalizes on the way in, which is
|
|
129
|
+
* the form the Learnosity transform emits.
|
|
130
|
+
*/
|
|
131
|
+
private resolveLocaleTag;
|
|
132
|
+
private loadCatalog;
|
|
133
|
+
/**
|
|
134
|
+
* Resolution order for one key. Host overrides for the active locale win, so
|
|
135
|
+
* a deployment can rename one label without forking a catalog.
|
|
136
|
+
*/
|
|
137
|
+
private lookupNode;
|
|
138
|
+
private lookup;
|
|
139
|
+
private selectPluralCategory;
|
|
140
|
+
private notify;
|
|
141
|
+
}
|
|
142
|
+
export declare function getDefaultI18n(): I18nServiceApi;
|
|
143
|
+
/**
|
|
144
|
+
* Resolve a component's interface-locale provider from a published context.
|
|
145
|
+
*
|
|
146
|
+
* Returns a **fresh** object on every call, and that is the whole point. A
|
|
147
|
+
* provider is mutable: `setLocale` swaps its catalog in place, so its identity is
|
|
148
|
+
* unchanged before and after a locale load. A reactive `$derived` reading the
|
|
149
|
+
* provider directly therefore never invalidates, and a label rendered while the
|
|
150
|
+
* catalog was still loading stays English forever — the exact silent failure
|
|
151
|
+
* `composition-context.md` records for a context published without a change
|
|
152
|
+
* signal, and it is silent here too because English is a plausible answer.
|
|
153
|
+
*
|
|
154
|
+
* Deriving this instead gives the component a value whose identity tracks the
|
|
155
|
+
* context republish, so the strings built from it re-render. Call it inside
|
|
156
|
+
* `$derived`, never once at setup:
|
|
157
|
+
*
|
|
158
|
+
* ```ts
|
|
159
|
+
* const interfaceI18n = $derived(resolveInterfaceI18n(runtimeContext));
|
|
160
|
+
* ```
|
|
161
|
+
*
|
|
162
|
+
* A context with no provider — no publisher at all — resolves to the English-only
|
|
163
|
+
* default rather than nothing, which is the graceful default the pull pattern
|
|
164
|
+
* requires.
|
|
165
|
+
*/
|
|
166
|
+
export declare function resolveInterfaceI18n(source: {
|
|
167
|
+
i18n?: I18nProvider;
|
|
168
|
+
} | null | undefined): I18nProvider;
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bundled i18n provider.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately imports only the English catalog. The dynamic loader map for every
|
|
5
|
+
* other locale lives in `./catalogs.js`, which players import and tools do not:
|
|
6
|
+
* every player and tool `vite.config.ts` sets `external: []`, so anything
|
|
7
|
+
* reachable from a tool's entry inlines into that tool's bundle. Keeping the
|
|
8
|
+
* loader map out of this module is what stops a locale chunk from being emitted
|
|
9
|
+
* into eighteen tool bundles that will never call `setLocale`.
|
|
10
|
+
*
|
|
11
|
+
* @module @pie-players/pie-players-shared/i18n/provider
|
|
12
|
+
*/
|
|
13
|
+
import { findBestLanguageMatch, normalizeLanguageTag, } from "./language-tags.js";
|
|
14
|
+
import enUS from "./messages/en-US.js";
|
|
15
|
+
/** The locale every fallback chain terminates at. */
|
|
16
|
+
export const DEFAULT_LOCALE = "en-US";
|
|
17
|
+
/**
|
|
18
|
+
* Assert that a runtime-assembled string is a message key.
|
|
19
|
+
*
|
|
20
|
+
* Every other call site passes a literal checked against `MessageKey`, so a
|
|
21
|
+
* typo is a compile error. This is the one escape hatch, for a key built from an
|
|
22
|
+
* id the catalog cannot enumerate — a host-supplied element category, a
|
|
23
|
+
* colour-scheme id. Unchecked, like any assertion: pair it with `hasKey` where a
|
|
24
|
+
* miss must not reach the screen.
|
|
25
|
+
*/
|
|
26
|
+
export function dynamicMessageKey(key) {
|
|
27
|
+
return key;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Right-to-left primary language subtags.
|
|
31
|
+
*
|
|
32
|
+
* Consulted only where `Intl.Locale.prototype.textInfo` is unavailable — it is
|
|
33
|
+
* the authoritative CLDR answer but shipped late in Safari, and an assessment
|
|
34
|
+
* laid out left-to-right for an Arabic reader is not a graceful degradation.
|
|
35
|
+
* Replaces a four-entry list that omitted every subtag below.
|
|
36
|
+
*/
|
|
37
|
+
const RTL_LANGUAGES = new Set([
|
|
38
|
+
"ar", // Arabic
|
|
39
|
+
"ckb", // Central Kurdish
|
|
40
|
+
"dv", // Dhivehi
|
|
41
|
+
"fa", // Persian
|
|
42
|
+
"he",
|
|
43
|
+
"iw", // Hebrew, current and legacy subtags
|
|
44
|
+
"ks", // Kashmiri
|
|
45
|
+
"ku", // Kurdish
|
|
46
|
+
"nqo", // N'Ko
|
|
47
|
+
"ps", // Pashto
|
|
48
|
+
"sd", // Sindhi
|
|
49
|
+
"syr", // Syriac
|
|
50
|
+
"ug", // Uyghur
|
|
51
|
+
"ur", // Urdu
|
|
52
|
+
"yi", // Yiddish
|
|
53
|
+
]);
|
|
54
|
+
const PLURAL_CATEGORIES = new Set([
|
|
55
|
+
"zero",
|
|
56
|
+
"one",
|
|
57
|
+
"two",
|
|
58
|
+
"few",
|
|
59
|
+
"many",
|
|
60
|
+
"other",
|
|
61
|
+
]);
|
|
62
|
+
function isPluralGroup(node) {
|
|
63
|
+
if (typeof node !== "object" || node === null)
|
|
64
|
+
return false;
|
|
65
|
+
const keys = Object.keys(node);
|
|
66
|
+
return keys.length > 0 && keys.every((key) => PLURAL_CATEGORIES.has(key));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Writing direction of a locale tag.
|
|
70
|
+
*
|
|
71
|
+
* Exported because the catalog resolver and the TTS voice selector need it
|
|
72
|
+
* without constructing a provider.
|
|
73
|
+
*/
|
|
74
|
+
export function localeDirection(locale) {
|
|
75
|
+
try {
|
|
76
|
+
const textInfo = new Intl.Locale(locale).textInfo;
|
|
77
|
+
if (textInfo?.direction === "rtl" || textInfo?.direction === "ltr") {
|
|
78
|
+
return textInfo.direction;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Not a tag `Intl.Locale` accepts; the subtag check below still answers.
|
|
83
|
+
}
|
|
84
|
+
const language = normalizeLanguageTag(locale).split("-")[0] ?? "";
|
|
85
|
+
return RTL_LANGUAGES.has(language) ? "rtl" : "ltr";
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The browser's preferred locale, or `DEFAULT_LOCALE` where there is no
|
|
89
|
+
* `navigator` — so this is safe in Node, which `players-shared` must remain.
|
|
90
|
+
*
|
|
91
|
+
* Nothing calls this by default. Under fixed lockstep patch-only versioning a
|
|
92
|
+
* rendered-string change reaches a host's live delivery on their next install
|
|
93
|
+
* with no build signal on their side, so auto-detection would silently switch an
|
|
94
|
+
* English deployment's chrome on a Dutch-configured laptop. A host that wants
|
|
95
|
+
* detection opts into it.
|
|
96
|
+
*/
|
|
97
|
+
export function detectBrowserLocale() {
|
|
98
|
+
if (typeof navigator === "undefined")
|
|
99
|
+
return DEFAULT_LOCALE;
|
|
100
|
+
return navigator.language || navigator.languages?.[0] || DEFAULT_LOCALE;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The bundled provider.
|
|
104
|
+
*
|
|
105
|
+
* Retains the `SimpleI18n` name and the `I18nServiceApi` surface it published
|
|
106
|
+
* before adoption. What changed is behaviour that had no caller: catalogs are
|
|
107
|
+
* BCP-47 keyed and resolved through RFC 4647 lookup rather than string equality,
|
|
108
|
+
* `dir`/`lang` are no longer written to `document.documentElement` (an embedded
|
|
109
|
+
* player has no business writing the host page's root), and plural categories
|
|
110
|
+
* come from `Intl.PluralRules` alone.
|
|
111
|
+
*/
|
|
112
|
+
export class SimpleI18n {
|
|
113
|
+
/** Shared across instances and views: `Intl.PluralRules` is not cheap. */
|
|
114
|
+
static pluralRules = new Map();
|
|
115
|
+
locale;
|
|
116
|
+
fallbackLocale;
|
|
117
|
+
/** Shared by reference with every `withLocale` view. */
|
|
118
|
+
catalogs;
|
|
119
|
+
customMessages;
|
|
120
|
+
loading;
|
|
121
|
+
listeners = new Set();
|
|
122
|
+
config;
|
|
123
|
+
constructor(config = {}) {
|
|
124
|
+
this.config = { ...config };
|
|
125
|
+
this.fallbackLocale = normalizeTag(config.fallbackLocale) || DEFAULT_LOCALE;
|
|
126
|
+
this.catalogs = new Map([[DEFAULT_LOCALE, enUS]]);
|
|
127
|
+
this.customMessages = { ...(config.customMessages ?? {}) };
|
|
128
|
+
this.loading = new Map();
|
|
129
|
+
this.locale = this.resolveLocaleTag(config.locale) ?? DEFAULT_LOCALE;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Set the locale, loading its catalog first.
|
|
133
|
+
*
|
|
134
|
+
* Convenience over `setLocale` for a host that constructs the provider and
|
|
135
|
+
* configures it in two steps. Never falls back to the browser locale; see
|
|
136
|
+
* {@link detectBrowserLocale}.
|
|
137
|
+
*/
|
|
138
|
+
async initialize(config) {
|
|
139
|
+
this.config = { ...this.config, ...config };
|
|
140
|
+
if (config.customMessages) {
|
|
141
|
+
for (const [locale, messages] of Object.entries(config.customMessages)) {
|
|
142
|
+
this.addCustomMessages(locale, messages);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
await this.setLocale(config.locale ?? this.locale);
|
|
146
|
+
}
|
|
147
|
+
getLocale() {
|
|
148
|
+
return this.locale;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Switch locale.
|
|
152
|
+
*
|
|
153
|
+
* Resolves the request against the catalogs this provider can serve, loads
|
|
154
|
+
* the winner if it is not resident, then notifies subscribers — in that
|
|
155
|
+
* order, so a component's reactive read re-runs against a catalog that is
|
|
156
|
+
* already in place rather than against the previous locale.
|
|
157
|
+
*/
|
|
158
|
+
async setLocale(locale) {
|
|
159
|
+
const resolved = this.resolveLocaleTag(locale);
|
|
160
|
+
if (!resolved) {
|
|
161
|
+
// A locale we ship no catalog for is legitimate: the host may have
|
|
162
|
+
// supplied its own messages for it. Honour the tag and let the
|
|
163
|
+
// fallback chain cover whatever those messages omit.
|
|
164
|
+
this.locale = normalizeTag(locale) || DEFAULT_LOCALE;
|
|
165
|
+
this.notify();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!this.catalogs.has(resolved)) {
|
|
169
|
+
const inFlight = this.loading.get(resolved);
|
|
170
|
+
if (inFlight) {
|
|
171
|
+
await inFlight;
|
|
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);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
this.locale = resolved;
|
|
185
|
+
this.notify();
|
|
186
|
+
}
|
|
187
|
+
t(key, values) {
|
|
188
|
+
const message = this.lookup(key);
|
|
189
|
+
if (message === undefined) {
|
|
190
|
+
this.config.onMissingKey?.(key, this.locale);
|
|
191
|
+
return key;
|
|
192
|
+
}
|
|
193
|
+
return interpolate(message, values);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Translate a plural group.
|
|
197
|
+
*
|
|
198
|
+
* The category comes from `Intl.PluralRules` for the active locale, so a
|
|
199
|
+
* locale with more than two forms resolves correctly: Arabic selects among
|
|
200
|
+
* zero/one/two/few/many/other, Polish among one/few/many/other. A category the
|
|
201
|
+
* catalog does not carry resolves to `other`.
|
|
202
|
+
*/
|
|
203
|
+
plural(key, options) {
|
|
204
|
+
const group = this.lookupNode(key);
|
|
205
|
+
if (group === undefined || !isPluralGroup(group)) {
|
|
206
|
+
return this.t(key, options);
|
|
207
|
+
}
|
|
208
|
+
const category = this.selectPluralCategory(options.count);
|
|
209
|
+
const form = group[category] ?? group.other;
|
|
210
|
+
return interpolate(form, options);
|
|
211
|
+
}
|
|
212
|
+
getDirection() {
|
|
213
|
+
return localeDirection(this.locale);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Locales this provider can serve: resident catalogs, whatever the loader
|
|
217
|
+
* declares, and any locale the host supplied messages for.
|
|
218
|
+
*/
|
|
219
|
+
getAvailableLocales() {
|
|
220
|
+
const tags = new Set([
|
|
221
|
+
...this.catalogs.keys(),
|
|
222
|
+
...(this.config.availableLocales ?? []),
|
|
223
|
+
...Object.keys(this.customMessages),
|
|
224
|
+
]);
|
|
225
|
+
return [...tags];
|
|
226
|
+
}
|
|
227
|
+
isLocaleLoaded(locale) {
|
|
228
|
+
const resolved = this.resolveLocaleTag(locale);
|
|
229
|
+
return resolved !== undefined && this.catalogs.has(resolved);
|
|
230
|
+
}
|
|
231
|
+
subscribe(listener) {
|
|
232
|
+
this.listeners.add(listener);
|
|
233
|
+
return () => {
|
|
234
|
+
this.listeners.delete(listener);
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
hasKey(key) {
|
|
238
|
+
return this.lookup(key) !== undefined;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Add or override messages for one locale.
|
|
242
|
+
*
|
|
243
|
+
* Deep-merged, so a deployment can override a single label without restating
|
|
244
|
+
* a catalog.
|
|
245
|
+
*/
|
|
246
|
+
addCustomMessages(locale, messages) {
|
|
247
|
+
const tag = normalizeTag(locale);
|
|
248
|
+
this.customMessages[tag] = deepMerge(this.customMessages[tag] ?? {}, messages);
|
|
249
|
+
this.notify();
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* A view of this provider fixed to another locale.
|
|
253
|
+
*
|
|
254
|
+
* Catalogs, in-flight loads and custom messages are shared by reference, so
|
|
255
|
+
* `setLocale` through either side is visible to both and no catalog is parsed
|
|
256
|
+
* twice. This is what lets two players on one page render different chrome
|
|
257
|
+
* locales: the locale is per-view, the catalogs are per-provider.
|
|
258
|
+
*/
|
|
259
|
+
withLocale(locale) {
|
|
260
|
+
const resolved = this.resolveLocaleTag(locale) ?? normalizeTag(locale);
|
|
261
|
+
if (resolved === this.locale)
|
|
262
|
+
return this;
|
|
263
|
+
const view = new SimpleI18n({ ...this.config, locale: resolved });
|
|
264
|
+
view.locale = resolved || DEFAULT_LOCALE;
|
|
265
|
+
view.catalogs = this.catalogs;
|
|
266
|
+
view.customMessages = this.customMessages;
|
|
267
|
+
view.loading = this.loading;
|
|
268
|
+
return view;
|
|
269
|
+
}
|
|
270
|
+
formatNumber(value, options) {
|
|
271
|
+
try {
|
|
272
|
+
return new Intl.NumberFormat(this.locale, options).format(value);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return new Intl.NumberFormat(DEFAULT_LOCALE, options).format(value);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
formatDate(date, options) {
|
|
279
|
+
try {
|
|
280
|
+
return new Intl.DateTimeFormat(this.locale, options).format(date);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return new Intl.DateTimeFormat(DEFAULT_LOCALE, options).format(date);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/** The resolved catalog for a locale, for a host that wants to inspect it. */
|
|
287
|
+
getBundle(locale = this.locale) {
|
|
288
|
+
const tag = this.resolveLocaleTag(locale);
|
|
289
|
+
if (!tag)
|
|
290
|
+
return undefined;
|
|
291
|
+
const translations = this.catalogs.get(tag);
|
|
292
|
+
if (!translations)
|
|
293
|
+
return undefined;
|
|
294
|
+
return { locale: tag, translations, direction: localeDirection(tag) };
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Which shipped catalog serves a requested tag.
|
|
298
|
+
*
|
|
299
|
+
* RFC 4647 lookup first, so `nl-BE` reaches `nl-NL` through `nl`; then
|
|
300
|
+
* primary-subtag widening, so a bare `nl` reaches the regional `nl-NL`
|
|
301
|
+
* catalog we actually ship. POSIX `nl_NL` normalizes on the way in, which is
|
|
302
|
+
* the form the Learnosity transform emits.
|
|
303
|
+
*/
|
|
304
|
+
resolveLocaleTag(locale) {
|
|
305
|
+
if (!locale)
|
|
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)
|
|
313
|
+
return undefined;
|
|
314
|
+
return available.find((candidate) => normalizeLanguageTag(candidate).split("-")[0] === requestedLanguage);
|
|
315
|
+
}
|
|
316
|
+
async loadCatalog(locale) {
|
|
317
|
+
const load = this.config.loadCatalog;
|
|
318
|
+
if (!load)
|
|
319
|
+
return;
|
|
320
|
+
try {
|
|
321
|
+
this.catalogs.set(locale, await load(locale));
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
// A failed load is not fatal: the fallback chain still resolves every
|
|
325
|
+
// key to English. Rethrowing here would take down a player over a
|
|
326
|
+
// missing chunk.
|
|
327
|
+
throw new Error(`Failed to load i18n catalog for locale: ${locale}`, {
|
|
328
|
+
cause: error,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Resolution order for one key. Host overrides for the active locale win, so
|
|
334
|
+
* a deployment can rename one label without forking a catalog.
|
|
335
|
+
*/
|
|
336
|
+
lookupNode(key) {
|
|
337
|
+
const sources = [
|
|
338
|
+
this.customMessages[this.locale],
|
|
339
|
+
this.catalogs.get(this.locale),
|
|
340
|
+
this.customMessages[this.fallbackLocale],
|
|
341
|
+
this.catalogs.get(this.fallbackLocale),
|
|
342
|
+
];
|
|
343
|
+
for (const source of sources) {
|
|
344
|
+
const hit = readPath(source, key);
|
|
345
|
+
if (hit !== undefined)
|
|
346
|
+
return hit;
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
lookup(key) {
|
|
351
|
+
const node = this.lookupNode(key);
|
|
352
|
+
if (typeof node === "string")
|
|
353
|
+
return node;
|
|
354
|
+
// A key landing on a namespace (`t("common")`) or on a plural group is a
|
|
355
|
+
// miss, not a hit: returning the object would put one where the caller
|
|
356
|
+
// expects a string, and interpolating it throws.
|
|
357
|
+
if (node !== undefined && isPluralGroup(node))
|
|
358
|
+
return node.other;
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
selectPluralCategory(count) {
|
|
362
|
+
let rules = SimpleI18n.pluralRules.get(this.locale);
|
|
363
|
+
if (!rules) {
|
|
364
|
+
try {
|
|
365
|
+
rules = new Intl.PluralRules(this.locale);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return count === 1 ? "one" : "other";
|
|
369
|
+
}
|
|
370
|
+
SimpleI18n.pluralRules.set(this.locale, rules);
|
|
371
|
+
}
|
|
372
|
+
return rules.select(count);
|
|
373
|
+
}
|
|
374
|
+
notify() {
|
|
375
|
+
for (const listener of this.listeners)
|
|
376
|
+
listener();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function normalizeTag(locale) {
|
|
380
|
+
return locale ? locale.trim().replace(/_/g, "-") : "";
|
|
381
|
+
}
|
|
382
|
+
function readPath(source, key) {
|
|
383
|
+
if (!source)
|
|
384
|
+
return undefined;
|
|
385
|
+
let current = source;
|
|
386
|
+
for (const part of key.split(".")) {
|
|
387
|
+
if (typeof current !== "object" || current === null)
|
|
388
|
+
return undefined;
|
|
389
|
+
current = current[part];
|
|
390
|
+
if (current === undefined)
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
return current;
|
|
394
|
+
}
|
|
395
|
+
function interpolate(text, values) {
|
|
396
|
+
if (!values)
|
|
397
|
+
return text;
|
|
398
|
+
return text.replace(/\{(\w+)\}/g, (match, name) => {
|
|
399
|
+
const value = values[name];
|
|
400
|
+
return value === undefined || value === null ? match : String(value);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
function deepMerge(target, source) {
|
|
404
|
+
const result = { ...target };
|
|
405
|
+
for (const [key, value] of Object.entries(source)) {
|
|
406
|
+
const existing = result[key];
|
|
407
|
+
if (value &&
|
|
408
|
+
typeof value === "object" &&
|
|
409
|
+
existing &&
|
|
410
|
+
typeof existing === "object") {
|
|
411
|
+
result[key] = deepMerge(existing, value);
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
result[key] = value;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return result;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The graceful default: an English-only provider, shared process-wide.
|
|
421
|
+
*
|
|
422
|
+
* `composition-context.md` requires a resolver to work with no publisher
|
|
423
|
+
* present, and this is that state. A tool mounted bare — in `print-player`, in
|
|
424
|
+
* Studio preview, in an authoring harness — resolves this and renders English
|
|
425
|
+
* rather than leaking raw message keys onto the screen. It carries no locale
|
|
426
|
+
* loader, so it pulls no catalog beyond the 5 KB English one already in the
|
|
427
|
+
* bundle.
|
|
428
|
+
*/
|
|
429
|
+
let defaultProvider;
|
|
430
|
+
export function getDefaultI18n() {
|
|
431
|
+
if (!defaultProvider)
|
|
432
|
+
defaultProvider = new SimpleI18n();
|
|
433
|
+
return defaultProvider;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Resolve a component's interface-locale provider from a published context.
|
|
437
|
+
*
|
|
438
|
+
* Returns a **fresh** object on every call, and that is the whole point. A
|
|
439
|
+
* provider is mutable: `setLocale` swaps its catalog in place, so its identity is
|
|
440
|
+
* unchanged before and after a locale load. A reactive `$derived` reading the
|
|
441
|
+
* provider directly therefore never invalidates, and a label rendered while the
|
|
442
|
+
* catalog was still loading stays English forever — the exact silent failure
|
|
443
|
+
* `composition-context.md` records for a context published without a change
|
|
444
|
+
* signal, and it is silent here too because English is a plausible answer.
|
|
445
|
+
*
|
|
446
|
+
* Deriving this instead gives the component a value whose identity tracks the
|
|
447
|
+
* context republish, so the strings built from it re-render. Call it inside
|
|
448
|
+
* `$derived`, never once at setup:
|
|
449
|
+
*
|
|
450
|
+
* ```ts
|
|
451
|
+
* const interfaceI18n = $derived(resolveInterfaceI18n(runtimeContext));
|
|
452
|
+
* ```
|
|
453
|
+
*
|
|
454
|
+
* A context with no provider — no publisher at all — resolves to the English-only
|
|
455
|
+
* default rather than nothing, which is the graceful default the pull pattern
|
|
456
|
+
* requires.
|
|
457
|
+
*/
|
|
458
|
+
export function resolveInterfaceI18n(source) {
|
|
459
|
+
const provider = source?.i18n ?? getDefaultI18n();
|
|
460
|
+
// A thin facade rather than the provider itself: cheap to allocate (a handful
|
|
461
|
+
// of times per session, once per republish) and it carries no state of its own,
|
|
462
|
+
// so it cannot drift from the provider it delegates to.
|
|
463
|
+
return {
|
|
464
|
+
getLocale: () => provider.getLocale(),
|
|
465
|
+
setLocale: (locale) => provider.setLocale(locale),
|
|
466
|
+
t: (key, values) => provider.t(key, values),
|
|
467
|
+
plural: (key, options) => provider.plural?.(key, options) ?? provider.t(key, options),
|
|
468
|
+
getDirection: () => provider.getDirection?.() ?? "ltr",
|
|
469
|
+
hasKey: (key) => provider.hasKey?.(key) ?? false,
|
|
470
|
+
subscribe: (listener) => provider.subscribe?.(listener) ?? (() => { }),
|
|
471
|
+
};
|
|
472
|
+
}
|