@pie-players/pie-players-shared 1.0.0-beta.5 → 1.0.0-beta.7

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.
@@ -12,8 +12,32 @@
12
12
  * The player reflects the resolved language onto its content subtree's root as
13
13
  * `lang` and `dir`, so a screen reader takes the content's voice rules and RTL
14
14
  * content lays out right to left, independent of the chrome's locale.
15
+ *
16
+ * Content that declares nothing falls back to a default the container names:
17
+ * a section's `runtime.contentLanguage`, or {@link UNDECLARED_CONTENT_LANGUAGE}
18
+ * once the host sets an interface locale, which stamps `lang=""` and
19
+ * `dir="auto"` so the content takes neither the interface's language nor its
20
+ * direction. With neither, undeclared content inherits its context, which keeps
21
+ * hosts that set no interface locale rendering as they always have.
15
22
  */
16
23
  import type { TextDirection } from "./types.js";
24
+ /**
25
+ * The content-language default for content that declares no language under a
26
+ * host-set interface locale: `lang=""` names the language unknown and
27
+ * `dir="auto"` takes direction from the content's first strong character.
28
+ */
29
+ export declare const UNDECLARED_CONTENT_LANGUAGE = "auto";
30
+ /**
31
+ * The default a player stamps on content whose `env` declares no language: the
32
+ * host's content language, else {@link UNDECLARED_CONTENT_LANGUAGE} when the
33
+ * host set an interface locale, else `undefined`, which leaves undeclared
34
+ * content inheriting its context. Inputs are read defensively, since both
35
+ * arrive from attributes.
36
+ */
37
+ export declare function defaultContentLanguage(options: {
38
+ contentLanguage?: unknown;
39
+ interfaceLocale?: unknown;
40
+ }): string | undefined;
17
41
  /** `lang` and `dir` for declared content, or `null` when nothing is declared. */
18
42
  export interface ContentLanguageAttributes {
19
43
  lang: string;
@@ -28,8 +52,26 @@ export interface ContentLanguageAttributes {
28
52
  */
29
53
  export declare function contentLanguageAttributes(env: unknown): ContentLanguageAttributes | null;
30
54
  /**
31
- * Stamps `contentLanguageAttributes(env)` onto `target`, removing both
32
- * attributes when the env declares no language — a model set can drop the
33
- * language a previous one declared. No-op without a target.
55
+ * Stamps the content language onto `target`: `contentLanguageAttributes(env)`,
56
+ * else `fallback` — a tag, or {@link UNDECLARED_CONTENT_LANGUAGE} for
57
+ * `lang=""` and `dir="auto"` — else neither attribute, so a model set can drop
58
+ * the language a previous one declared. A fallback that is not a usable tag
59
+ * counts as absent. No-op without a target.
60
+ */
61
+ export declare function stampContentLanguage(target: Element | null | undefined, env: unknown, fallback?: string | null): void;
62
+ /**
63
+ * The direction of the first letter in `root`'s text, skipping `script`,
64
+ * `style` and `template`, or `null` when it has none. Letters only, which is
65
+ * where the bidi algorithm's strong characters overwhelmingly sit.
66
+ */
67
+ export declare function firstStrongDirection(root: Node): TextDirection | null;
68
+ /**
69
+ * The mismatch between undeclared content's script and the direction a
70
+ * fallback stamped on `root`, or `null` when there is none to report: the env
71
+ * declares a language, `root` carries no stamped direction or `dir="auto"`, or
72
+ * the content has no letters.
34
73
  */
35
- export declare function stampContentLanguage(target: Element | null | undefined, env: unknown): void;
74
+ export declare function undeclaredDirectionMismatch(root: Element, env: unknown): {
75
+ stamped: TextDirection;
76
+ script: TextDirection;
77
+ } | null;
@@ -12,9 +12,40 @@
12
12
  * The player reflects the resolved language onto its content subtree's root as
13
13
  * `lang` and `dir`, so a screen reader takes the content's voice rules and RTL
14
14
  * content lays out right to left, independent of the chrome's locale.
15
+ *
16
+ * Content that declares nothing falls back to a default the container names:
17
+ * a section's `runtime.contentLanguage`, or {@link UNDECLARED_CONTENT_LANGUAGE}
18
+ * once the host sets an interface locale, which stamps `lang=""` and
19
+ * `dir="auto"` so the content takes neither the interface's language nor its
20
+ * direction. With neither, undeclared content inherits its context, which keeps
21
+ * hosts that set no interface locale rendering as they always have.
15
22
  */
16
23
  import { canonicalLanguageTag } from "./language-tags.js";
17
24
  import { localeDirection } from "./provider.js";
25
+ /**
26
+ * The content-language default for content that declares no language under a
27
+ * host-set interface locale: `lang=""` names the language unknown and
28
+ * `dir="auto"` takes direction from the content's first strong character.
29
+ */
30
+ export const UNDECLARED_CONTENT_LANGUAGE = "auto";
31
+ /**
32
+ * The default a player stamps on content whose `env` declares no language: the
33
+ * host's content language, else {@link UNDECLARED_CONTENT_LANGUAGE} when the
34
+ * host set an interface locale, else `undefined`, which leaves undeclared
35
+ * content inheriting its context. Inputs are read defensively, since both
36
+ * arrive from attributes.
37
+ */
38
+ export function defaultContentLanguage(options) {
39
+ const named = typeof options.contentLanguage === "string"
40
+ ? options.contentLanguage.trim()
41
+ : "";
42
+ if (named)
43
+ return named;
44
+ const requested = typeof options.interfaceLocale === "string"
45
+ ? options.interfaceLocale.trim()
46
+ : "";
47
+ return requested ? UNDECLARED_CONTENT_LANGUAGE : undefined;
48
+ }
18
49
  /**
19
50
  * `lang` and `dir` for the content an `env` describes, or `null` when it
20
51
  * declares no usable language. Reads `env.locale` defensively — `env` is
@@ -32,14 +63,19 @@ export function contentLanguageAttributes(env) {
32
63
  return { lang, dir: localeDirection(lang) };
33
64
  }
34
65
  /**
35
- * Stamps `contentLanguageAttributes(env)` onto `target`, removing both
36
- * attributes when the env declares no language — a model set can drop the
37
- * language a previous one declared. No-op without a target.
66
+ * Stamps the content language onto `target`: `contentLanguageAttributes(env)`,
67
+ * else `fallback` — a tag, or {@link UNDECLARED_CONTENT_LANGUAGE} for
68
+ * `lang=""` and `dir="auto"` — else neither attribute, so a model set can drop
69
+ * the language a previous one declared. A fallback that is not a usable tag
70
+ * counts as absent. No-op without a target.
38
71
  */
39
- export function stampContentLanguage(target, env) {
72
+ export function stampContentLanguage(target, env, fallback) {
40
73
  if (!target)
41
74
  return;
42
- const attributes = contentLanguageAttributes(env);
75
+ const attributes = contentLanguageAttributes(env) ??
76
+ (fallback === UNDECLARED_CONTENT_LANGUAGE
77
+ ? { lang: "", dir: "auto" }
78
+ : contentLanguageAttributes({ locale: fallback }));
43
79
  if (attributes) {
44
80
  target.setAttribute("lang", attributes.lang);
45
81
  target.setAttribute("dir", attributes.dir);
@@ -49,3 +85,45 @@ export function stampContentLanguage(target, env) {
49
85
  target.removeAttribute("dir");
50
86
  }
51
87
  }
88
+ /** Scripts written right to left, for {@link firstStrongDirection}. */
89
+ const RTL_LETTER = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Adlam}\p{Script=Hanifi_Rohingya}\p{Script=Mandaic}\p{Script=Samaritan}]/u;
90
+ const LETTER = /\p{L}/u;
91
+ /**
92
+ * The direction of the first letter in `root`'s text, skipping `script`,
93
+ * `style` and `template`, or `null` when it has none. Letters only, which is
94
+ * where the bidi algorithm's strong characters overwhelmingly sit.
95
+ */
96
+ export function firstStrongDirection(root) {
97
+ const doc = root.ownerDocument ?? root;
98
+ const walker = doc.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */, {
99
+ acceptNode: (node) => node.parentElement?.closest("script, style, template")
100
+ ? 2 /* NodeFilter.FILTER_REJECT */
101
+ : 1 /* NodeFilter.FILTER_ACCEPT */,
102
+ });
103
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
104
+ for (const char of node.nodeValue ?? "") {
105
+ if (RTL_LETTER.test(char))
106
+ return "rtl";
107
+ if (LETTER.test(char))
108
+ return "ltr";
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+ /**
114
+ * The mismatch between undeclared content's script and the direction a
115
+ * fallback stamped on `root`, or `null` when there is none to report: the env
116
+ * declares a language, `root` carries no stamped direction or `dir="auto"`, or
117
+ * the content has no letters.
118
+ */
119
+ export function undeclaredDirectionMismatch(root, env) {
120
+ if (contentLanguageAttributes(env))
121
+ return null;
122
+ const stamped = root.getAttribute("dir");
123
+ if (stamped !== "ltr" && stamped !== "rtl")
124
+ return null;
125
+ const script = firstStrongDirection(root);
126
+ if (!script)
127
+ return null;
128
+ return script === stamped ? null : { stamped, script };
129
+ }
@@ -28,9 +28,10 @@ import type { I18nConfig, I18nServiceApi } from "./types.js";
28
28
  */
29
29
  export declare function createPieI18n(config?: I18nConfig): I18nServiceApi;
30
30
  export { BUNDLED_LOCALES, loadBundledCatalog } from "./catalogs.js";
31
- export { canonicalLanguageTag, findBestLanguageMatch, languageTagLookupSequence, languageTagsEqual, normalizeLanguageTag, toLanguageTag, } from "./language-tags.js";
31
+ export { canonicalLanguageTag, findBestLanguageMatch, findClosestLanguageMatch, languageTagLookupSequence, languageTagScriptLookupSequence, languageTagsEqual, negotiateContentLanguage, normalizeLanguageTag, toLanguageTag, } from "./language-tags.js";
32
+ export type { ContentLanguageMatch } from "./language-tags.js";
32
33
  export { DEFAULT_LOCALE, detectBrowserLocale, dynamicMessageKey, getDefaultI18n, localeDirection, resolveInterfaceI18n, SimpleI18n, stampInterfaceLocale, } from "./provider.js";
33
- export { type ContentLanguageAttributes, contentLanguageAttributes, stampContentLanguage, } from "./content-language.js";
34
+ export { type ContentLanguageAttributes, contentLanguageAttributes, defaultContentLanguage, firstStrongDirection, stampContentLanguage, UNDECLARED_CONTENT_LANGUAGE, undeclaredDirectionMismatch, } from "./content-language.js";
34
35
  export { type InterfaceLocaleLoadErrorModel, reportInterfaceLocaleLoadError, } from "./locale-load-error.js";
35
36
  export { PSEUDO_LOCALE_EXPANDED, PSEUDO_LOCALE_RTL, pseudoLocalize, } from "./pseudo.js";
36
37
  export type { PseudoLocalizeOptions } from "./pseudo.js";
@@ -35,8 +35,8 @@ export function createPieI18n(config = {}) {
35
35
  });
36
36
  }
37
37
  export { BUNDLED_LOCALES, loadBundledCatalog } from "./catalogs.js";
38
- export { canonicalLanguageTag, findBestLanguageMatch, languageTagLookupSequence, languageTagsEqual, normalizeLanguageTag, toLanguageTag, } from "./language-tags.js";
38
+ export { canonicalLanguageTag, findBestLanguageMatch, findClosestLanguageMatch, languageTagLookupSequence, languageTagScriptLookupSequence, languageTagsEqual, negotiateContentLanguage, normalizeLanguageTag, toLanguageTag, } from "./language-tags.js";
39
39
  export { DEFAULT_LOCALE, detectBrowserLocale, dynamicMessageKey, getDefaultI18n, localeDirection, resolveInterfaceI18n, SimpleI18n, stampInterfaceLocale, } from "./provider.js";
40
- export { contentLanguageAttributes, stampContentLanguage, } from "./content-language.js";
40
+ export { contentLanguageAttributes, defaultContentLanguage, firstStrongDirection, stampContentLanguage, UNDECLARED_CONTENT_LANGUAGE, undeclaredDirectionMismatch, } from "./content-language.js";
41
41
  export { reportInterfaceLocaleLoadError, } from "./locale-load-error.js";
42
42
  export { PSEUDO_LOCALE_EXPANDED, PSEUDO_LOCALE_RTL, pseudoLocalize, } from "./pseudo.js";
@@ -51,12 +51,64 @@ export declare function languageTagsEqual(a: string | null | undefined, b: strin
51
51
  */
52
52
  export declare function languageTagLookupSequence(tag: string | null | undefined): string[];
53
53
  /**
54
- * Best available tag for a request, by RFC 4647 lookup, or `undefined` when
55
- * nothing matches. Ranking is by the request's specificity rather than the
56
- * candidates' order, so `es-MX` prefers an `es-MX` candidate over an `es` one
57
- * however they were authored.
54
+ * The script a tag is written in, lowercased: explicit (`sr-Latn`), or the
55
+ * CLDR likely script (`zh-TW` maximizes to Hant, `sr` to Cyrl). `undefined`
56
+ * when the tag has no script data or the runtime has no `Intl.Locale`.
57
+ */
58
+ export declare function languageTagScript(tag: string | null | undefined): string | undefined;
59
+ /**
60
+ * Whether a catalog reached by widening serves a request in the same script.
61
+ * A tag without script data passes, so a runtime without `Intl.Locale`
62
+ * resolves as plain RFC 4647 lookup.
63
+ */
64
+ export declare function languageTagScriptsMatch(requested: string | null | undefined, candidate: string | null | undefined): boolean;
65
+ /**
66
+ * The script-aware lookup sequence: the RFC 4647 sequence with the language
67
+ * and likely script inserted before the bare language, so `zh-TW` yields
68
+ * `["zh-tw", "zh-hant", "zh"]`. A bare-language step serves the request only
69
+ * in the same script, which `findBestLanguageMatch` checks.
70
+ */
71
+ export declare function languageTagScriptLookupSequence(tag: string | null | undefined): string[];
72
+ /**
73
+ * Best available tag for a request, or `undefined` when nothing matches.
74
+ * Ranking is by the request's specificity rather than the candidates' order,
75
+ * so `es-MX` prefers an `es-MX` candidate over an `es` one however they were
76
+ * authored, and `zh-TW` reaches `zh-Hant`. A catalog shorter than the request
77
+ * serves it only in the same script: a bare `zh` catalog is Simplified, so
78
+ * `zh-TW` passes it by.
58
79
  */
59
80
  export declare function findBestLanguageMatch(requested: string | null | undefined, available: readonly (string | null | undefined)[]): string | undefined;
81
+ /**
82
+ * `findBestLanguageMatch`, then primary-subtag widening, so `nl-BE` and a bare
83
+ * `nl` reach a regional `nl-NL` and `es-MX` reaches `es-ES`. Both stay within
84
+ * the request's script: `zh` never reaches `zh-TW`, and `sr-Latn` may reach
85
+ * `sr-ME`. The rule that picks an interface catalog and a content variant alike.
86
+ */
87
+ export declare function findClosestLanguageMatch(requested: string | null | undefined, available: readonly (string | null | undefined)[]): string | undefined;
88
+ /** The content language a backend serves, and whether it is a substitute. */
89
+ export interface ContentLanguageMatch {
90
+ /** The `available` entry served, as authored; `null` for content that declares no language. */
91
+ language: string | null;
92
+ /** `"fallback"` when the served content stands in for the request. */
93
+ substitution?: "fallback";
94
+ }
95
+ /**
96
+ * The language to serve content in: the closest `available` match for the
97
+ * first `requested` tag that has one, under `findClosestLanguageMatch`, or with
98
+ * `matching: "exact"` only an equal tag, so `es-MX` then passes an `es`
99
+ * variant by. With no match and `fallback` other than `"none"`, the fallback
100
+ * chain serves the closest match for `defaultLanguage` (`en-US` reaches `en`),
101
+ * then content that declares no language (a `null` entry), then `source`;
102
+ * `matching` does not narrow it. `undefined` means nothing can be served. No
103
+ * requested languages run the chain without a substitution.
104
+ */
105
+ export declare function negotiateContentLanguage(requested: readonly string[], available: readonly (string | null)[], options?: {
106
+ defaultLanguage?: string;
107
+ fallback?: "default" | "none";
108
+ matching?: "closest" | "exact";
109
+ /** The family source's `available` entry. */
110
+ source?: string | null;
111
+ }): ContentLanguageMatch | undefined;
60
112
  /**
61
113
  * The canonical form of a usable language tag, or `undefined` for anything a
62
114
  * language in use would not be: `es_MX` normalizes to `es-MX`, `NL-nl` to
@@ -78,20 +78,125 @@ export function languageTagLookupSequence(tag) {
78
78
  }
79
79
  return sequence;
80
80
  }
81
+ /** `maximize()` runs on every resolution, for each candidate catalog. */
82
+ const scripts = new Map();
81
83
  /**
82
- * Best available tag for a request, by RFC 4647 lookup, or `undefined` when
83
- * nothing matches. Ranking is by the request's specificity rather than the
84
- * candidates' order, so `es-MX` prefers an `es-MX` candidate over an `es` one
85
- * however they were authored.
84
+ * The script a tag is written in, lowercased: explicit (`sr-Latn`), or the
85
+ * CLDR likely script (`zh-TW` maximizes to Hant, `sr` to Cyrl). `undefined`
86
+ * when the tag has no script data or the runtime has no `Intl.Locale`.
87
+ */
88
+ export function languageTagScript(tag) {
89
+ const normalized = normalizeLanguageTag(tag);
90
+ if (!normalized ||
91
+ typeof Intl === "undefined" ||
92
+ typeof Intl.Locale !== "function") {
93
+ return undefined;
94
+ }
95
+ if (scripts.has(normalized))
96
+ return scripts.get(normalized);
97
+ let script;
98
+ try {
99
+ script = new Intl.Locale(normalized).maximize().script?.toLowerCase();
100
+ }
101
+ catch {
102
+ // Not a tag `Intl.Locale` parses: no script data.
103
+ }
104
+ scripts.set(normalized, script);
105
+ return script;
106
+ }
107
+ /**
108
+ * Whether a catalog reached by widening serves a request in the same script.
109
+ * A tag without script data passes, so a runtime without `Intl.Locale`
110
+ * resolves as plain RFC 4647 lookup.
111
+ */
112
+ export function languageTagScriptsMatch(requested, candidate) {
113
+ const requestedScript = languageTagScript(requested);
114
+ const candidateScript = languageTagScript(candidate);
115
+ return (!requestedScript || !candidateScript || requestedScript === candidateScript);
116
+ }
117
+ /**
118
+ * The script-aware lookup sequence: the RFC 4647 sequence with the language
119
+ * and likely script inserted before the bare language, so `zh-TW` yields
120
+ * `["zh-tw", "zh-hant", "zh"]`. A bare-language step serves the request only
121
+ * in the same script, which `findBestLanguageMatch` checks.
122
+ */
123
+ export function languageTagScriptLookupSequence(tag) {
124
+ const sequence = languageTagLookupSequence(tag);
125
+ const script = languageTagScript(tag);
126
+ if (!script || sequence.length < 2)
127
+ return sequence;
128
+ const language = sequence[sequence.length - 1];
129
+ const withScript = `${language}-${script}`;
130
+ if (sequence.includes(withScript))
131
+ return sequence;
132
+ return [...sequence.slice(0, -1), withScript, language];
133
+ }
134
+ /**
135
+ * Best available tag for a request, or `undefined` when nothing matches.
136
+ * Ranking is by the request's specificity rather than the candidates' order,
137
+ * so `es-MX` prefers an `es-MX` candidate over an `es` one however they were
138
+ * authored, and `zh-TW` reaches `zh-Hant`. A catalog shorter than the request
139
+ * serves it only in the same script: a bare `zh` catalog is Simplified, so
140
+ * `zh-TW` passes it by.
86
141
  */
87
142
  export function findBestLanguageMatch(requested, available) {
88
- for (const step of languageTagLookupSequence(requested)) {
89
- const hit = available.find((candidate) => normalizeLanguageTag(candidate) === step);
143
+ const exact = normalizeLanguageTag(requested);
144
+ for (const step of languageTagScriptLookupSequence(requested)) {
145
+ const hit = available.find((candidate) => normalizeLanguageTag(candidate) === step &&
146
+ (step === exact || languageTagScriptsMatch(requested, candidate)));
90
147
  if (hit != null)
91
148
  return hit;
92
149
  }
93
150
  return undefined;
94
151
  }
152
+ /**
153
+ * `findBestLanguageMatch`, then primary-subtag widening, so `nl-BE` and a bare
154
+ * `nl` reach a regional `nl-NL` and `es-MX` reaches `es-ES`. Both stay within
155
+ * the request's script: `zh` never reaches `zh-TW`, and `sr-Latn` may reach
156
+ * `sr-ME`. The rule that picks an interface catalog and a content variant alike.
157
+ */
158
+ export function findClosestLanguageMatch(requested, available) {
159
+ const best = findBestLanguageMatch(requested, available);
160
+ if (best !== undefined)
161
+ return best;
162
+ const language = normalizeLanguageTag(requested).split("-")[0];
163
+ if (!language)
164
+ return undefined;
165
+ const sibling = available.find((candidate) => normalizeLanguageTag(candidate).split("-")[0] === language &&
166
+ languageTagScriptsMatch(requested, candidate));
167
+ return sibling ?? undefined;
168
+ }
169
+ /**
170
+ * The language to serve content in: the closest `available` match for the
171
+ * first `requested` tag that has one, under `findClosestLanguageMatch`, or with
172
+ * `matching: "exact"` only an equal tag, so `es-MX` then passes an `es`
173
+ * variant by. With no match and `fallback` other than `"none"`, the fallback
174
+ * chain serves the closest match for `defaultLanguage` (`en-US` reaches `en`),
175
+ * then content that declares no language (a `null` entry), then `source`;
176
+ * `matching` does not narrow it. `undefined` means nothing can be served. No
177
+ * requested languages run the chain without a substitution.
178
+ */
179
+ export function negotiateContentLanguage(requested, available, options = {}) {
180
+ const { defaultLanguage, fallback = "default", matching = "closest", source } = options;
181
+ for (const tag of requested) {
182
+ const language = matching === "exact"
183
+ ? available.find((candidate) => languageTagsEqual(candidate, tag))
184
+ : findClosestLanguageMatch(tag, available);
185
+ if (language != null)
186
+ return { language };
187
+ }
188
+ if (requested.length > 0 && fallback === "none")
189
+ return undefined;
190
+ let language = findClosestLanguageMatch(defaultLanguage, available);
191
+ if (language === undefined && available.includes(null))
192
+ language = null;
193
+ if (language === undefined && source !== undefined && available.includes(source)) {
194
+ language = source;
195
+ }
196
+ if (language === undefined)
197
+ return undefined;
198
+ return requested.length > 0 ? { language, substitution: "fallback" } : { language };
199
+ }
95
200
  /**
96
201
  * The canonical form of a usable language tag, or `undefined` for anything a
97
202
  * language in use would not be: `es_MX` normalizes to `es-MX`, `NL-nl` to
@@ -431,6 +431,12 @@ declare const enUS: {
431
431
  description: string;
432
432
  buttonA11y: string;
433
433
  tooltip: string;
434
+ /** A choice's toggle; `{choice}` is the choice's own accessible name. */
435
+ toggleA11y: string;
436
+ /** Appended to an eliminated choice's label for assistive technology. */
437
+ eliminatedA11y: string;
438
+ /** Stands in for `{choice}` when a choice has no text of its own. */
439
+ unlabeledChoice: string;
434
440
  };
435
441
  highlighter: {
436
442
  name: string;
@@ -448,6 +448,12 @@ const enUS = {
448
448
  description: "Strike through answer choices",
449
449
  buttonA11y: "Strike Through, eliminate answer choices",
450
450
  tooltip: "Strike Through",
451
+ /** A choice's toggle; `{choice}` is the choice's own accessible name. */
452
+ toggleA11y: "Toggle elimination for {choice}",
453
+ /** Appended to an eliminated choice's label for assistive technology. */
454
+ eliminatedA11y: "(eliminated)",
455
+ /** Stands in for `{choice}` when a choice has no text of its own. */
456
+ unlabeledChoice: "Unlabeled choice",
451
457
  },
452
458
  highlighter: {
453
459
  name: "Highlighter",
@@ -345,6 +345,9 @@ declare const nlNL: {
345
345
  description: string;
346
346
  buttonA11y: string;
347
347
  tooltip: string;
348
+ toggleA11y: string;
349
+ eliminatedA11y: string;
350
+ unlabeledChoice: string;
348
351
  };
349
352
  highlighter: {
350
353
  name: string;
@@ -345,6 +345,9 @@ const nlNL = {
345
345
  description: "Antwoordopties doorstrepen",
346
346
  buttonA11y: "Doorstrepen, antwoordopties wegstrepen",
347
347
  tooltip: "Doorstrepen",
348
+ toggleA11y: "Wegstrepen aan/uit voor {choice}",
349
+ eliminatedA11y: "(weggestreept)",
350
+ unlabeledChoice: "Keuze zonder label",
348
351
  },
349
352
  highlighter: {
350
353
  name: "Markeerstift",
@@ -122,12 +122,9 @@ export declare class SimpleI18n implements I18nServiceApi {
122
122
  /** The resolved catalog for a locale, for a host that wants to inspect it. */
123
123
  getBundle(locale?: LocaleCode): TranslationBundle | undefined;
124
124
  /**
125
- * Which shipped catalog serves a requested tag.
126
- *
127
- * RFC 4647 lookup first, so `nl-BE` reaches `nl-NL` through `nl`; then
128
- * primary-subtag widening, so a bare `nl` reaches the regional `nl-NL`
129
- * catalog we actually ship. POSIX `nl_NL` normalizes on the way in, which is
130
- * the form the Learnosity transform emits.
125
+ * Which shipped catalog serves a requested tag, under
126
+ * `findClosestLanguageMatch`. POSIX `nl_NL` normalizes on the way in, which
127
+ * is the form the Learnosity transform emits.
131
128
  */
132
129
  private resolveLocaleTag;
133
130
  private loadCatalog;
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * @module @pie-players/pie-players-shared/i18n/provider
11
11
  */
12
- import { canonicalLanguageTag, findBestLanguageMatch, normalizeLanguageTag, toLanguageTag, } from "./language-tags.js";
12
+ import { canonicalLanguageTag, findClosestLanguageMatch, 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";
@@ -302,24 +302,14 @@ export class SimpleI18n {
302
302
  return { locale: tag, translations, direction: localeDirection(tag) };
303
303
  }
304
304
  /**
305
- * Which shipped catalog serves a requested tag.
306
- *
307
- * RFC 4647 lookup first, so `nl-BE` reaches `nl-NL` through `nl`; then
308
- * primary-subtag widening, so a bare `nl` reaches the regional `nl-NL`
309
- * catalog we actually ship. POSIX `nl_NL` normalizes on the way in, which is
310
- * the form the Learnosity transform emits.
305
+ * Which shipped catalog serves a requested tag, under
306
+ * `findClosestLanguageMatch`. POSIX `nl_NL` normalizes on the way in, which
307
+ * is the form the Learnosity transform emits.
311
308
  */
312
309
  resolveLocaleTag(locale) {
313
310
  if (!locale)
314
311
  return undefined;
315
- const available = this.getAvailableLocales();
316
- const exactOrPrefix = findBestLanguageMatch(locale, available);
317
- if (exactOrPrefix)
318
- return exactOrPrefix;
319
- const requestedLanguage = normalizeLanguageTag(locale).split("-")[0];
320
- if (!requestedLanguage)
321
- return undefined;
322
- return available.find((candidate) => normalizeLanguageTag(candidate).split("-")[0] === requestedLanguage);
312
+ return findClosestLanguageMatch(locale, this.getAvailableLocales());
323
313
  }
324
314
  async loadCatalog(locale) {
325
315
  const load = this.config.loadCatalog;
package/dist/index.d.ts CHANGED
@@ -16,6 +16,7 @@ export * from "./ui/pointer-rotate.js";
16
16
  export * from "./ui/overlay-containment.js";
17
17
  export * from "./ui/overlay-placement.js";
18
18
  export * from "./ui/focus-trap.js";
19
+ export * from "./ui/ime.js";
19
20
  export * from "./ui/first-focusable.js";
20
21
  export * from "./ui/safe-storage.js";
21
22
  export * from "./ui/scope-css.js";
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export * from "./ui/pointer-rotate.js";
15
15
  export * from "./ui/overlay-containment.js";
16
16
  export * from "./ui/overlay-placement.js";
17
17
  export * from "./ui/focus-trap.js";
18
+ export * from "./ui/ime.js";
18
19
  export * from "./ui/first-focusable.js";
19
20
  export * from "./ui/safe-storage.js";
20
21
  export * from "./ui/scope-css.js";
@@ -41,4 +41,4 @@ export type ElementMap = {
41
41
  * // }
42
42
  * ```
43
43
  */
44
- export declare function aggregateElements(items: ItemEntity[]): ElementMap;
44
+ export declare function aggregateElements(items: ReadonlyArray<Pick<ItemEntity, "config">>): ElementMap;
@@ -88,7 +88,10 @@ export type EnsureRegisteredOptions = {
88
88
  *
89
89
  * The primitive is the sole authority on registration state:
90
90
  * - Empty element map resolves immediately (no backend call).
91
- * - Already-registered tags resolve immediately (no backend call).
91
+ * - Already-registered tags resolve immediately (no backend call). A request
92
+ * that loads other tags through an IIFE or ESM backend that loads controllers
93
+ * also loads its registered tags with no controller, so their controllers
94
+ * fill in.
92
95
  * - Concurrent identical requests share one backend call.
93
96
  * - After the adapter's `load` settles, a post-load verification pass
94
97
  * (bounded `customElements.whenDefined`) checks every tag. Any missing
@@ -26,7 +26,7 @@ import { parsePackageName } from "../pie/utils.js";
26
26
  import { parseVersionedTagName } from "../pie/versioned-tag.js";
27
27
  import { AdapterFailure, } from "./element-loader-types.js";
28
28
  import { clearPackageMetadataRequests, createEsmBackend, } from "./esm-adapter.js";
29
- import { createIifeBackend } from "./iife-adapter.js";
29
+ import { createIifeBackend, findIifeElementMapConflict, } from "./iife-adapter.js";
30
30
  import { assertElementPackagesAllowed, } from "./element-package-policy.js";
31
31
  export { AdapterFailure } from "./element-loader-types.js";
32
32
  /**
@@ -125,9 +125,10 @@ function registeredPackageName(tag) {
125
125
  }
126
126
  const DEFAULT_WHEN_DEFINED_TIMEOUT_MS = 5000;
127
127
  const DEFAULT_LOAD_TIMEOUT_MS = DEFAULT_IIFE_BUNDLE_RETRY_CONFIG.timeoutMs;
128
- // Module-scoped in-flight cache. Key: backend signature + sorted elements.
129
- // Keeps concurrent identical requests collapsed to one backend call.
128
+ // Requests share a backend call by both the original map and the unresolved
129
+ // subset, so incremental registration does not change an in-flight identity.
130
130
  const inFlightRequests = new Map();
131
+ const inFlightKeysByPromise = new Map();
131
132
  // An ESM backend keeps page state that later loads build on (the import maps it
132
133
  // injected, the shared dependency versions they fixed), so every load with the
133
134
  // same config reuses one backend. IIFE backends are created per call.
@@ -139,7 +140,10 @@ const resolvedBackendOverrides = new Map();
139
140
  *
140
141
  * The primitive is the sole authority on registration state:
141
142
  * - Empty element map resolves immediately (no backend call).
142
- * - Already-registered tags resolve immediately (no backend call).
143
+ * - Already-registered tags resolve immediately (no backend call). A request
144
+ * that loads other tags through an IIFE or ESM backend that loads controllers
145
+ * also loads its registered tags with no controller, so their controllers
146
+ * fill in.
143
147
  * - Concurrent identical requests share one backend call.
144
148
  * - After the adapter's `load` settles, a post-load verification pass
145
149
  * (bounded `customElements.whenDefined`) checks every tag. Any missing
@@ -153,30 +157,54 @@ export async function ensureRegistered(elements, options) {
153
157
  // Enforce policy before the already-registered fast path: a matching tag in
154
158
  // the registry must not turn a disallowed authored package into valid input.
155
159
  assertElementPackagesAllowed(elements, options.elementPackagePolicy);
156
- const tags = Object.keys(elements);
160
+ // Preserve the original request identity while tags register mid-flight.
161
+ const originalDedupKey = buildDedupKey(options.backend, elements);
162
+ const originalRequest = inFlightRequests.get(originalDedupKey);
163
+ if (originalRequest)
164
+ return originalRequest;
165
+ if (Object.keys(elements).every(isRegistered))
166
+ return;
167
+ const loadsControllers = backendLoadsControllers(options.backend);
168
+ const unresolvedElements = {};
169
+ for (const [tag, packageSpec] of Object.entries(elements)) {
170
+ if (!isRegistered(tag) || (loadsControllers && awaitsController(tag))) {
171
+ unresolvedElements[tag] = packageSpec;
172
+ }
173
+ }
174
+ const tags = Object.keys(unresolvedElements);
157
175
  const timeoutMs = options.whenDefinedTimeoutMs ?? DEFAULT_WHEN_DEFINED_TIMEOUT_MS;
158
176
  const loadTimeoutMs = options.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS;
159
- // Fast path: everything already registered. Skip backend and dedup
160
- // bookkeeping entirely.
161
- if (allAlreadyRegistered(tags))
162
- return;
163
- const dedupKey = buildDedupKey(options.backend, elements);
177
+ const dedupKey = buildDedupKey(options.backend, unresolvedElements);
164
178
  const existing = inFlightRequests.get(dedupKey);
165
- if (existing)
179
+ if (existing) {
180
+ aliasInFlightRequest(originalDedupKey, existing);
166
181
  return existing;
182
+ }
167
183
  const backend = resolveBackend(options.backend);
168
184
  const doc = resolveDoc(options.doc);
169
- const promise = runEnsureRegistered(elements, tags, backend, { doc, whenDefinedTimeoutMs: timeoutMs }, timeoutMs, loadTimeoutMs);
170
- inFlightRequests.set(dedupKey, promise);
185
+ const loadElements = isIifeConfig(options.backend)
186
+ ? iifeLoadElements(elements, unresolvedElements)
187
+ : unresolvedElements;
188
+ const promise = runEnsureRegistered(loadElements, tags, backend, { doc, whenDefinedTimeoutMs: timeoutMs }, timeoutMs, loadTimeoutMs);
189
+ aliasInFlightRequest(originalDedupKey, promise);
190
+ aliasInFlightRequest(dedupKey, promise);
171
191
  try {
172
192
  await promise;
173
193
  }
174
194
  finally {
175
- if (inFlightRequests.get(dedupKey) === promise) {
176
- inFlightRequests.delete(dedupKey);
195
+ for (const key of inFlightKeysByPromise.get(promise) ?? []) {
196
+ if (inFlightRequests.get(key) === promise)
197
+ inFlightRequests.delete(key);
177
198
  }
199
+ inFlightKeysByPromise.delete(promise);
178
200
  }
179
201
  }
202
+ function aliasInFlightRequest(key, promise) {
203
+ inFlightRequests.set(key, promise);
204
+ const keys = inFlightKeysByPromise.get(promise) ?? new Set();
205
+ keys.add(key);
206
+ inFlightKeysByPromise.set(promise, keys);
207
+ }
180
208
  async function runEnsureRegistered(elements, tags, backend, context, timeoutMs, loadTimeoutMs) {
181
209
  let adapterError;
182
210
  try {
@@ -273,13 +301,13 @@ function resolveBackend(backend) {
273
301
  if (isDirectBackend(backend))
274
302
  return backend;
275
303
  if ("kind" in backend) {
304
+ const backendKey = backendKeyOf(backend);
305
+ const override = resolvedBackendOverrides.get(backendKey);
306
+ if (override)
307
+ return override;
276
308
  if (backend.kind === "iife")
277
309
  return createIifeBackend(backend);
278
310
  if (backend.kind === "esm") {
279
- const backendKey = backendKeyOf(backend);
280
- const override = resolvedBackendOverrides.get(backendKey);
281
- if (override)
282
- return override;
283
311
  const existing = resolvedEsmBackends.get(backendKey);
284
312
  if (existing)
285
313
  return existing;
@@ -415,8 +443,32 @@ function getOpaqueObjectRefId(value) {
415
443
  }
416
444
  return id;
417
445
  }
418
- function allAlreadyRegistered(tags) {
419
- return tags.every((tag) => isRegistered(tag));
446
+ function isIifeConfig(backend) {
447
+ return !isDirectBackend(backend) && "kind" in backend && backend.kind === "iife";
448
+ }
449
+ /**
450
+ * An IIFE bundle URL names its packages, so the full map keeps it the URL a
451
+ * host already fetched. Two specs of one package cannot share a bundle, so a
452
+ * conflict narrows the map: first to the unresolved tags, then to the undefined.
453
+ */
454
+ function iifeLoadElements(elements, unresolvedElements) {
455
+ if (!findIifeElementMapConflict(elements))
456
+ return elements;
457
+ if (!findIifeElementMapConflict(unresolvedElements))
458
+ return unresolvedElements;
459
+ return Object.fromEntries(Object.entries(unresolvedElements).filter(([tag]) => !isRegistered(tag)));
460
+ }
461
+ function backendLoadsControllers(backend) {
462
+ if (isDirectBackend(backend) || !("kind" in backend))
463
+ return false;
464
+ if (backend.kind === "iife")
465
+ return backend.needsControllers ?? true;
466
+ if (backend.kind === "esm")
467
+ return backend.loadControllers ?? true;
468
+ return false;
469
+ }
470
+ function awaitsController(tag) {
471
+ return typeof window !== "undefined" && !pieRegistry()[tag]?.controller;
420
472
  }
421
473
  function isRegistered(tag) {
422
474
  if (typeof customElements === "undefined")
@@ -471,6 +523,7 @@ function extractAdapterReason(err, tag) {
471
523
  export const __testing = {
472
524
  resetDedupState() {
473
525
  inFlightRequests.clear();
526
+ inFlightKeysByPromise.clear();
474
527
  resolvedEsmBackends.clear();
475
528
  resolvedBackendOverrides.clear();
476
529
  clearPackageMetadataRequests();
@@ -21,6 +21,7 @@
21
21
  import type { InstrumentationProvider } from "../instrumentation/types.js";
22
22
  import { type IifeBundleRetryConfig } from "../loader-config.js";
23
23
  import { BundleType } from "../pie/types.js";
24
+ import type { ElementMap } from "./ElementLoader.js";
24
25
  import { type ElementLoaderBackend } from "./element-loader-types.js";
25
26
  /**
26
27
  * Bundle-build retry status emitted while a bundle is being built or after
@@ -77,4 +78,10 @@ export type IifeBackendConfig = {
77
78
  instrumentationProvider?: InstrumentationProvider;
78
79
  };
79
80
  export type IifeBackend = ElementLoaderBackend & {};
81
+ /**
82
+ * The legacy IIFE bundle global is keyed by unversioned package name. Two
83
+ * distinct specs for one package therefore cannot coexist in a single bundle:
84
+ * one constructor would silently be reused for both requested versioned tags.
85
+ */
86
+ export declare function findIifeElementMapConflict(elements: ElementMap): string | undefined;
80
87
  export declare function createIifeBackend(config: IifeBackendConfig): IifeBackend;
@@ -31,7 +31,7 @@ import { AdapterFailure, } from "./element-loader-types.js";
31
31
  * distinct specs for one package therefore cannot coexist in a single bundle:
32
32
  * one constructor would silently be reused for both requested versioned tags.
33
33
  */
34
- function findIifeElementMapConflict(elements) {
34
+ export function findIifeElementMapConflict(elements) {
35
35
  const specsByPackageName = new Map();
36
36
  for (const packageSpecValue of Object.values(elements)) {
37
37
  const packageSpec = String(packageSpecValue);
@@ -30,11 +30,11 @@ export function validateExternalStyleUrl(url, options = {}) {
30
30
  ? new URL(url, options.baseUrl)
31
31
  : new URL(url);
32
32
  }
33
- catch (err) {
33
+ catch {
34
34
  return {
35
35
  ok: false,
36
36
  reason: "invalid-url",
37
- message: `External stylesheet URL could not be parsed: ${String(err)}`,
37
+ message: "External stylesheet URL could not be parsed.",
38
38
  };
39
39
  }
40
40
  if (resolvedUrl.protocol !== "http:" && resolvedUrl.protocol !== "https:") {
@@ -10,8 +10,10 @@
10
10
  */
11
11
  import type { FormativeDeliveryPolicy, FormativeItemPolicy, FormativeSectionSlice } from "../formative/types.js";
12
12
  import type { TimedMediaSectionData, TimedMediaSectionSessionSlice } from "../timed-media/types.js";
13
+ import type { AdaptationLink, LanguagePolicy } from "./interchange.js";
13
14
  export type { FormativeDeliveryPolicy, FormativeItemPolicy };
14
15
  export type { TimedMediaSectionData };
16
+ export type { AdaptationLink, ContentSubstitution, LanguagePolicy, ServedContentIdentity, } from "./interchange.js";
15
17
  export interface BaseEntity {
16
18
  id?: string;
17
19
  createdAt?: Date;
@@ -32,12 +34,6 @@ export interface VersionEntity extends BaseEntity {
32
34
  signature?: string;
33
35
  version: SemVer;
34
36
  }
35
- export interface SearchMetaData {
36
- [key: string]: any;
37
- }
38
- export interface SearchMetaDataEntity extends BaseEntity {
39
- searchMetaData?: SearchMetaData;
40
- }
41
37
  export interface ConfigElements {
42
38
  [key: string]: string;
43
39
  }
@@ -69,16 +65,29 @@ export interface ConfigEntity {
69
65
  export interface ConfigContainerEntity extends BaseEntity {
70
66
  config: ConfigEntity;
71
67
  }
72
- export interface PassageEntity extends VersionEntity, ConfigContainerEntity, SearchMetaDataEntity {
68
+ export interface PassageEntity extends VersionEntity, ConfigContainerEntity {
73
69
  name: string;
70
+ /**
71
+ * BCP 47 tag of the content's language (QTI `xml:lang`). The players render
72
+ * and score it under this language; absent, the section's `env.locale` holds.
73
+ */
74
+ language?: string;
75
+ /** The source a translated version was adapted from. No player reads it. */
76
+ isAdaptationOf?: AdaptationLink;
74
77
  /** QTI/APIP-style accessibility catalogs owned by this passage content. */
75
78
  accessibilityCatalogs?: AccessibilityCatalog[];
76
79
  retired?: boolean;
77
80
  published?: boolean;
78
- highlights?: Record<string, string[]>;
79
81
  }
80
- export interface ItemEntity extends VersionEntity, ConfigContainerEntity, SearchMetaDataEntity {
82
+ export interface ItemEntity extends VersionEntity, ConfigContainerEntity {
81
83
  name?: string;
84
+ /**
85
+ * BCP 47 tag of the content's language (QTI `xml:lang`). The players render
86
+ * and score it under this language; absent, the section's `env.locale` holds.
87
+ */
88
+ language?: string;
89
+ /** The source a translated version was adapted from. No player reads it. */
90
+ isAdaptationOf?: AdaptationLink;
82
91
  /**
83
92
  * `null` as well as absent, because importers write it: JSON has no
84
93
  * `undefined`, so an item transformed from another format carries an
@@ -90,7 +99,6 @@ export interface ItemEntity extends VersionEntity, ConfigContainerEntity, Search
90
99
  retired?: boolean;
91
100
  published?: boolean;
92
101
  configuration?: any;
93
- highlights?: Record<string, string[]>;
94
102
  }
95
103
  /**
96
104
  * `baseId` and `version` are optional on an entity a section references. They
@@ -109,20 +117,6 @@ export type ReferencedPassageEntity = Omit<WithOptionalVersion<PassageEntity>, "
109
117
  export type ReferencedItemEntity = Omit<WithOptionalVersion<ItemEntity>, "passage"> & {
110
118
  passage?: string | ReferencedPassageEntity | null;
111
119
  };
112
- /**
113
- * Metadata specifically for interpretation by clients, typically containing
114
- * options that are relevant for the user interface. This is not indexed for search.
115
- */
116
- export interface SettingsMetaData {
117
- [key: string]: any;
118
- }
119
- /**
120
- * Objects that contain metadata in the settings property, which is NOT indexed for search
121
- * but typically only relevant for particular client cases.
122
- */
123
- export interface SettingsMetaDataEntity {
124
- settings?: SettingsMetaData;
125
- }
126
120
  export type RubricBlockView = "author" | "candidate" | "proctor" | "scorer" | "testConstructor" | "tutor";
127
121
  export interface RubricBlock {
128
122
  identifier?: string;
@@ -150,7 +144,7 @@ export interface RubricBlock {
150
144
  * References an item within a section. The item property contains the resolved
151
145
  * ItemEntity with its PIE config.
152
146
  */
153
- export interface AssessmentItemRef extends SearchMetaDataEntity {
147
+ export interface AssessmentItemRef extends BaseEntity {
154
148
  id?: string;
155
149
  identifier: string;
156
150
  title?: string;
@@ -175,7 +169,7 @@ export interface AssessmentItemRef extends SearchMetaDataEntity {
175
169
  */
176
170
  formative?: FormativeItemPolicy;
177
171
  }
178
- export interface AssessmentSection extends SearchMetaDataEntity, SettingsMetaDataEntity {
172
+ export interface AssessmentSection extends BaseEntity {
179
173
  id?: string;
180
174
  identifier: string;
181
175
  title?: string;
@@ -484,7 +478,7 @@ export interface PersonalNeedsProfile {
484
478
  */
485
479
  toolParameters?: ToolParameters;
486
480
  }
487
- export interface AssessmentEntity extends BaseEntity, SearchMetaDataEntity {
481
+ export interface AssessmentEntity extends BaseEntity {
488
482
  name?: string;
489
483
  title?: string;
490
484
  identifier?: string;
@@ -499,6 +493,11 @@ export interface AssessmentEntity extends BaseEntity, SearchMetaDataEntity {
499
493
  accessibilityCatalogs?: AccessibilityCatalog[];
500
494
  /** QTI 3.0: Personal Needs Profile (PNP 3.0) */
501
495
  personalNeedsProfile?: PersonalNeedsProfile;
496
+ /**
497
+ * The languages the assessment is offered in, read by a backend that
498
+ * resolves language variants. No player reads it.
499
+ */
500
+ languagePolicy?: LanguagePolicy;
502
501
  /**
503
502
  * QTI 3.0: testParts structure (authoritative for QTI format).
504
503
  */
@@ -548,6 +547,13 @@ export interface ToolParameterMap {
548
547
  signLanguage: {
549
548
  signLang?: string;
550
549
  };
550
+ /**
551
+ * `language` is the BCP 47 content language the learner reads items in. A
552
+ * host passes it to delivery as `requestedLanguages`.
553
+ */
554
+ itemTranslation: {
555
+ language?: string;
556
+ };
551
557
  /**
552
558
  * `rate` multiplies every playback speed, 1 being the provider's normal
553
559
  * speed; `pitch` runs 0–2 with 1 normal; `volume` runs 0–1.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Language-variant interchange types (PIE-1393). Backends and authoring tools
3
+ * exchange them; no player reads `AdaptationLink` or `LanguagePolicy`.
4
+ *
5
+ * A language variant is its own item or passage version that declares
6
+ * `language` and, when it is a translation, `isAdaptationOf`. Its family is
7
+ * `isAdaptationOf.baseId ?? baseId`.
8
+ */
9
+ import type { SemVer } from "./index.js";
10
+ /** The source a translated item or passage was adapted from. */
11
+ export interface AdaptationLink {
12
+ baseId: string;
13
+ /** The source version translated. Absent when provenance is unknown. */
14
+ version?: SemVer;
15
+ }
16
+ /**
17
+ * The languages an assessment offers and what a backend serves when a family
18
+ * has no variant in the requested language.
19
+ */
20
+ export interface LanguagePolicy {
21
+ /** BCP 47 tags the assessment may be served in. */
22
+ languages: string[];
23
+ /**
24
+ * `"default"` serves an item whose family lacks the requested language in
25
+ * the closest match for the platform default, `en-US` (so `en` or `en-GB`),
26
+ * then its content that declares no language, then its source. Fallback is
27
+ * per item; items sharing a passage fall back together. `"none"` fails the
28
+ * load.
29
+ */
30
+ fallback: "default" | "none";
31
+ /**
32
+ * `"closest"`, the default, serves the nearest variant in the requested
33
+ * language, so `es-MX` reaches `es` or `es-ES`; `"exact"` serves only an
34
+ * equal tag and leaves anything else to `fallback`.
35
+ */
36
+ matching?: "closest" | "exact";
37
+ /**
38
+ * `"serve"` serves a variant whose source has a newer release and records
39
+ * it as stale; `"require-current"` treats that variant as missing.
40
+ */
41
+ staleness: "serve" | "require-current";
42
+ }
43
+ /** Why the served content differs from what was requested. */
44
+ export type ContentSubstitution = "fallback" | "stale";
45
+ /** The content version a backend served, and the language it served it in. */
46
+ export interface ServedContentIdentity {
47
+ baseId: string;
48
+ version?: SemVer;
49
+ /** The family key, when the served version belongs to a variant family. */
50
+ family?: string;
51
+ /** BCP 47 tag of the served content. */
52
+ servedLanguage?: string;
53
+ substitution?: ContentSubstitution;
54
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,5 @@
1
1
  import { collectFocusable } from "./first-focusable.js";
2
+ import { isComposingKeyEvent } from "./ime.js";
2
3
  /**
3
4
  * Shadow-aware: a trapped panel almost always hosts a custom element rendering into
4
5
  * `shadow: "open"`, and a flat `querySelectorAll` would collect only the panel's own
@@ -44,7 +45,8 @@ export function createFocusTrap(container, options = {}) {
44
45
  const wrap = options.wrap ?? true;
45
46
  const onKeydown = (event) => {
46
47
  if (event.key === "Escape") {
47
- if (!options.onEscape)
48
+ // An Escape that cancels an IME composition belongs to the IME.
49
+ if (!options.onEscape || isComposingKeyEvent(event))
48
50
  return;
49
51
  // Claimed, so a page-wide Escape listener does not also act on it.
50
52
  event.preventDefault();
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Whether a keydown belongs to an IME composition, so it must not act as a
3
+ * shortcut. Safari reports `isComposing` false on the Enter that commits a
4
+ * composition and marks it only with `keyCode` 229.
5
+ */
6
+ export declare function isComposingKeyEvent(event: Pick<KeyboardEvent, "isComposing" | "keyCode">): boolean;
package/dist/ui/ime.js ADDED
@@ -0,0 +1,10 @@
1
+ /** The legacy `keyCode` a browser reports for a keydown the IME consumes. */
2
+ const IME_PROCESS_KEY_CODE = 229;
3
+ /**
4
+ * Whether a keydown belongs to an IME composition, so it must not act as a
5
+ * shortcut. Safari reports `isComposing` false on the Enter that commits a
6
+ * composition and marks it only with `keyCode` 229.
7
+ */
8
+ export function isComposingKeyEvent(event) {
9
+ return event.isComposing || event.keyCode === IME_PROCESS_KEY_CODE;
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pie-players/pie-players-shared",
3
- "version": "1.0.0-beta.5",
3
+ "version": "1.0.0-beta.7",
4
4
  "type": "module",
5
5
  "description": "Shared runtime + UI utilities for PIE players",
6
6
  "license": "MIT",
@@ -106,7 +106,7 @@
106
106
  "dist"
107
107
  ],
108
108
  "dependencies": {
109
- "@pie-element/shared-utils": "0.1.4",
109
+ "@pie-element/shared-utils": "0.1.6",
110
110
  "@pie-lib/math-rendering-module": "6.0.0",
111
111
  "dompurify": "^3.4.17",
112
112
  "semver": "^7.8.5"
@@ -122,9 +122,9 @@
122
122
  "scan-hardcoded": "bun run src/i18n/scripts/scan-hardcoded.ts"
123
123
  },
124
124
  "devDependencies": {
125
- "@biomejs/biome": "^2.5.15",
126
- "@happy-dom/global-registrator": "^20.14.5",
127
- "@pie-element/shared-math-rendering-mathjax": "0.1.7",
125
+ "@biomejs/biome": "^2.6.2",
126
+ "@happy-dom/global-registrator": "^21.0.4",
127
+ "@pie-element/shared-math-rendering-mathjax": "0.1.8",
128
128
  "@playwright/test": "^1.64.1",
129
129
  "@types/semver": "^7.8.0",
130
130
  "es-module-shims": "^2.8.4",