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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +16 -14
  2. package/dist/i18n/catalogs.d.ts +20 -13
  3. package/dist/i18n/catalogs.js +22 -23
  4. package/dist/i18n/language-tags.d.ts +23 -6
  5. package/dist/i18n/language-tags.js +74 -9
  6. package/dist/i18n/messages/en-US.d.ts +21 -4
  7. package/dist/i18n/messages/en-US.js +23 -4
  8. package/dist/i18n/types.d.ts +7 -2
  9. package/dist/index.d.ts +1 -1
  10. package/dist/item-session-control/index.d.ts +16 -0
  11. package/dist/item-session-control/index.js +5 -0
  12. package/dist/{formative → item-session-control}/mastery.d.ts +5 -5
  13. package/dist/{formative → item-session-control}/mastery.js +3 -3
  14. package/dist/{formative → item-session-control}/outcome.d.ts +3 -3
  15. package/dist/{formative → item-session-control}/outcome.js +2 -2
  16. package/dist/{formative → item-session-control}/policy.d.ts +11 -11
  17. package/dist/{formative → item-session-control}/policy.js +12 -12
  18. package/dist/item-session-control/session.d.ts +19 -0
  19. package/dist/{formative → item-session-control}/session.js +12 -12
  20. package/dist/{formative → item-session-control}/state.d.ts +26 -26
  21. package/dist/{formative → item-session-control}/state.js +13 -13
  22. package/dist/{formative → item-session-control}/types.d.ts +38 -40
  23. package/dist/{formative → item-session-control}/types.js +2 -2
  24. package/dist/security/private-hosts.d.ts +27 -0
  25. package/dist/security/private-hosts.js +85 -0
  26. package/dist/timed-media/index.d.ts +1 -1
  27. package/dist/timed-media/index.js +1 -1
  28. package/dist/timed-media/policy.d.ts +2 -2
  29. package/dist/timed-media/policy.js +2 -2
  30. package/dist/timed-media/session.d.ts +1 -1
  31. package/dist/timed-media/session.js +1 -1
  32. package/dist/timed-media/types.d.ts +11 -11
  33. package/dist/tools/term-lookup.d.ts +15 -0
  34. package/dist/tools/term-lookup.js +16 -0
  35. package/dist/types/index.d.ts +32 -18
  36. package/dist/types/interchange.d.ts +66 -4
  37. package/dist/ui/fontawesome.d.ts +51 -0
  38. package/dist/ui/fontawesome.js +122 -0
  39. package/package.json +15 -6
  40. package/dist/formative/index.d.ts +0 -16
  41. package/dist/formative/index.js +0 -5
  42. package/dist/formative/session.d.ts +0 -19
  43. package/dist/i18n/messages/nl-NL.d.ts +0 -639
  44. package/dist/i18n/messages/nl-NL.js +0 -639
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Shared runtime utilities for the `@pie-players/*` player suite: the element
4
4
  loader, PIE config and session helpers, markup security, i18n, and the
5
- formative, timed-media and media contracts. For framework code and host
5
+ item session control, timed-media and media contracts. For framework code and host
6
6
  integrations that need the helpers the player packages use.
7
7
 
8
8
  ## Public Exports
@@ -20,9 +20,10 @@ The subpaths below are the package's `exports`; nothing else is importable.
20
20
  | `@pie-players/pie-players-shared` | The root: the loader, PIE, security and object helpers, loader config, instrumentation providers, the item-player strategy helpers, the default bundle host URL (`BUILDER_BUNDLE_URL`), a subset of the shared types, and the UI helpers (attribute coercion, content styles, pointer gestures, overlay placement, focus trap, safe storage, scoped CSS) |
21
21
  | `/loader-config` | `LoaderConfig` and its defaults: instrumentation (`trackPageActions`, `instrumentationProvider`), resource retry, and IIFE bundle retry |
22
22
  | `/security` | The item-markup sanitizer (`sanitizeItemMarkup`, `createDefaultItemMarkupSanitizer`, `buildAuthoringAllowList`), external-stylesheet URL validation, the SVG icon and `style` attribute sanitizers, and the wrappers that put overwide images and tables in a scrollable region |
23
+ | `/security/private-hosts` | `isPrivateHostname` and `isCloudMetadataHostname`, the host classification for a URL a server fetches on someone else's behalf. Node-safe: it loads none of the markup sanitizers |
23
24
  | `/object` | `mergeObjectsIgnoringNullUndefined`, `cloneDeep`, `isPlainRecord` |
24
25
  | `/types` | The shared types: item, passage, section and session entities, PIE models and controllers, accessibility catalogs, and the [media vocabulary](#media-validation) |
25
- | `/formative` | [Formative delivery](#formative-delivery) |
26
+ | `/item-session-control` | [Item session control](#item-session-control) |
26
27
  | `/timed-media` | [Timed media](#timed-media) |
27
28
  | `/media` | [Media validation](#media-validation) |
28
29
  | `/pie` | PIE runtime utilities: config helpers, bundle initialization, `ItemController` and the item session contract, session commit and snapshots, authoring helpers, math renderer setup, instrumentation event maps ([PIE utilities](src/pie/README.md)) |
@@ -33,14 +34,15 @@ The subpaths below are the package's `exports`; nothing else is importable.
33
34
  | `/i18n/provider` | `SimpleI18n`, `getDefaultI18n` and `resolveInterfaceI18n`, with the English catalog only |
34
35
  | `/i18n/catalogs` | `BUNDLED_LOCALES` and lazy loaders for the bundled locales other than English |
35
36
  | `/i18n/language-tags` | BCP 47 language tag normalization and matching |
36
- | `/nds-icon-button` | `<nds-icon-button>`, an icon button vendored as a prebuilt bundle with Lit inlined; importing it registers the tag. The assessment toolkit and `@pie-players/pie-tool-tts-inline` render it |
37
+ | `/nds-icon-button` | `<nds-icon-button>`, an icon button vendored as a prebuilt bundle with Lit inlined; importing it registers the tag. The assessment toolkit, the section player and `@pie-players/pie-tool-tts-inline` render it |
37
38
  | `/ui/attribute-coercion` | Boolean attribute coercion for custom elements |
38
39
  | `/ui/content-styles` | `installContentStyles`, `auditContentStyles` and the opt-out checks behind the item player's content styles |
40
+ | `/ui/fontawesome` | The FontAwesome `<nds-icon-button>` glyphs need: the one FA Free pin, Pro detection, the `<head>` and shadow-root installs, and the glyph weight. The assessment toolkit, the section player and `@pie-players/pie-tool-tts-inline` use it |
39
41
  | `/tools/term-lookup` | Term normalization and the lookup session the dictionary tools share; the host supplies the endpoint or a resolver |
40
42
 
41
- ## Formative Delivery
43
+ ## Item Session Control
42
44
 
43
- `@pie-players/pie-players-shared/formative` holds the policy resolution, outcome
45
+ `@pie-players/pie-players-shared/item-session-control` holds the policy resolution, outcome
44
46
  aggregation, Try-state reducer, item view, and mastery rollup that
45
47
  `pie-section-player` drives. It is pure (no DOM, no timers, no element
46
48
  registry), so it is testable without a browser and importable by an adapter that
@@ -48,18 +50,18 @@ has no player.
48
50
 
49
51
  ```ts
50
52
  import {
51
- aggregateFormativeOutcome,
52
- resolveFormativePolicy,
53
- resolveFormativeItemView,
54
- rollupFormativeMastery,
55
- } from "@pie-players/pie-players-shared/formative";
53
+ aggregateTryOutcome,
54
+ resolveItemSessionControl,
55
+ resolveItemTryView,
56
+ rollupMastery,
57
+ } from "@pie-players/pie-players-shared/item-session-control";
56
58
  ```
57
59
 
58
- The authored half, `FormativeDeliveryPolicy` on `AssessmentSection` and
59
- `FormativeItemPolicy` on `AssessmentItemRef`, is re-exported from the package
60
+ The authored half, `ItemSessionControlPolicy` on `AssessmentSection` and
61
+ `AssessmentItemRef`, is re-exported from the package
60
62
  root and from `/types`, beside the section types it annotates.
61
63
 
62
- The contract is [`docs/prds/formative-delivery-contract.md`](../../docs/prds/formative-delivery-contract.md),
64
+ The contract is [`docs/prds/item-session-control-contract.md`](../../docs/prds/item-session-control-contract.md),
63
65
  including the QTI 3 mapping and why PIE says **Try** rather than "attempt".
64
66
 
65
67
  ## Timed Media
@@ -92,7 +94,7 @@ re-exported from the package root and from `/types`, beside the section types it
92
94
  annotates.
93
95
 
94
96
  The contract is [`docs/prds/timed-media-section-contract.md`](../../docs/prds/timed-media-section-contract.md),
95
- including why cue gate conditions name the formative vocabulary and why an
97
+ including why cue gate conditions name the Try vocabulary and why an
96
98
  unenforceable policy degrades to advisory rather than failing closed.
97
99
 
98
100
  ## Media Validation
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Lazy catalog loaders for every locale this repository ships.
2
+ * The bundled catalog loader: `en-US`, the one statically shipped catalog.
3
3
  *
4
4
  * Imported by players, never by tools. A tool resolves its provider from the
5
5
  * toolkit runtime context and needs only the interface plus the English
@@ -7,25 +7,32 @@
7
7
  * into a bundle that never calls `setLocale`, because every tool
8
8
  * `vite.config.ts` sets `external: []`.
9
9
  *
10
- * An explicit static map rather than a bundler macro: `tsc` emits these
11
- * `import()` calls verbatim, so `players-shared/dist` evaluates anywhere ESM
12
- * does — webpack, esbuild, Rollup, Node, or a browser loading the files
13
- * directly. `pie-qti` used `import.meta.glob` and shipped a `dist` that throws
14
- * under every one of those. Each bundler still sees the specifiers statically,
15
- * so each locale stays its own chunk. Adding a locale means adding one line here
16
- * alongside the file.
10
+ * `en-US` is the source of truth — the fallback for every missing key in every
11
+ * other locale — so the provider imports it statically and it is always
12
+ * resident. It is the only catalog this package ships: every other locale this
13
+ * repository ships lives in the optional generated tier
14
+ * (`@pie-players/pie-i18n-generated`), whose loaders a host composes after
15
+ * this one. The composed shape is that package's README's wiring example:
17
16
  *
18
- * `en-US` is absent: it is the fallback for every missing key in every other
19
- * locale, so the provider imports it statically and it is always resident.
17
+ * ```ts
18
+ * loadCatalog: (locale) =>
19
+ * loadBundledCatalog(locale).catch(() => loadGeneratedCatalog(locale));
20
+ * ```
21
+ *
22
+ * A request this function cannot serve rejects, and the provider treats that
23
+ * as "resolve through the fallback chain" rather than an error, so a host
24
+ * locale nothing ships still renders English.
20
25
  *
21
26
  * @module @pie-players/pie-players-shared/i18n/catalogs
22
27
  */
23
28
  import type { BundledLocaleCode, MessageCatalog } from "./types.js";
24
29
  /**
25
- * Every locale tag this repository ships a catalog for, English included.
30
+ * Every locale tag this repository bundles a catalog for. `en-US` alone: the
31
+ * statically resident source of truth.
26
32
  *
27
33
  * The provider resolves a request against this list through RFC 4647 lookup and
28
- * primary-subtag widening, so `nl`, `nl_NL` and `nl-BE` all reach `nl-NL`.
34
+ * primary-subtag widening, so `en`, `en_US` and `en-GB`-without-a-catalog all
35
+ * land on `en-US` unless a composed loader serves the region.
29
36
  */
30
37
  export declare const BUNDLED_LOCALES: readonly BundledLocaleCode[];
31
38
  /**
@@ -36,7 +43,7 @@ export declare const BUNDLED_LOCALES: readonly BundledLocaleCode[];
36
43
  * or a pseudo-localization of the English catalog — asks for it by name like
37
44
  * any other locale.
38
45
  *
39
- * Throws for a locale with no shipped catalog; the provider treats that as
46
+ * Rejects for a locale with no bundled catalog; the provider treats that as
40
47
  * "resolve through the fallback chain" rather than an error, so a host locale we
41
48
  * do not ship still renders.
42
49
  */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Lazy catalog loaders for every locale this repository ships.
2
+ * The bundled catalog loader: `en-US`, the one statically shipped catalog.
3
3
  *
4
4
  * Imported by players, never by tools. A tool resolves its provider from the
5
5
  * toolkit runtime context and needs only the interface plus the English
@@ -7,30 +7,34 @@
7
7
  * into a bundle that never calls `setLocale`, because every tool
8
8
  * `vite.config.ts` sets `external: []`.
9
9
  *
10
- * An explicit static map rather than a bundler macro: `tsc` emits these
11
- * `import()` calls verbatim, so `players-shared/dist` evaluates anywhere ESM
12
- * does — webpack, esbuild, Rollup, Node, or a browser loading the files
13
- * directly. `pie-qti` used `import.meta.glob` and shipped a `dist` that throws
14
- * under every one of those. Each bundler still sees the specifiers statically,
15
- * so each locale stays its own chunk. Adding a locale means adding one line here
16
- * alongside the file.
10
+ * `en-US` is the source of truth — the fallback for every missing key in every
11
+ * other locale — so the provider imports it statically and it is always
12
+ * resident. It is the only catalog this package ships: every other locale this
13
+ * repository ships lives in the optional generated tier
14
+ * (`@pie-players/pie-i18n-generated`), whose loaders a host composes after
15
+ * this one. The composed shape is that package's README's wiring example:
17
16
  *
18
- * `en-US` is absent: it is the fallback for every missing key in every other
19
- * locale, so the provider imports it statically and it is always resident.
17
+ * ```ts
18
+ * loadCatalog: (locale) =>
19
+ * loadBundledCatalog(locale).catch(() => loadGeneratedCatalog(locale));
20
+ * ```
21
+ *
22
+ * A request this function cannot serve rejects, and the provider treats that
23
+ * as "resolve through the fallback chain" rather than an error, so a host
24
+ * locale nothing ships still renders English.
20
25
  *
21
26
  * @module @pie-players/pie-players-shared/i18n/catalogs
22
27
  */
23
28
  import enUS from "./messages/en-US.js";
24
- const CATALOG_LOADERS = {
25
- "nl-NL": () => import("./messages/nl-NL.js"),
26
- };
27
29
  /**
28
- * Every locale tag this repository ships a catalog for, English included.
30
+ * Every locale tag this repository bundles a catalog for. `en-US` alone: the
31
+ * statically resident source of truth.
29
32
  *
30
33
  * The provider resolves a request against this list through RFC 4647 lookup and
31
- * primary-subtag widening, so `nl`, `nl_NL` and `nl-BE` all reach `nl-NL`.
34
+ * primary-subtag widening, so `en`, `en_US` and `en-GB`-without-a-catalog all
35
+ * land on `en-US` unless a composed loader serves the region.
32
36
  */
33
- export const BUNDLED_LOCALES = ["en-US", "nl-NL"];
37
+ export const BUNDLED_LOCALES = ["en-US"];
34
38
  /**
35
39
  * Load one shipped catalog.
36
40
  *
@@ -39,7 +43,7 @@ export const BUNDLED_LOCALES = ["en-US", "nl-NL"];
39
43
  * or a pseudo-localization of the English catalog — asks for it by name like
40
44
  * any other locale.
41
45
  *
42
- * Throws for a locale with no shipped catalog; the provider treats that as
46
+ * Rejects for a locale with no bundled catalog; the provider treats that as
43
47
  * "resolve through the fallback chain" rather than an error, so a host locale we
44
48
  * do not ship still renders.
45
49
  */
@@ -47,10 +51,5 @@ export async function loadBundledCatalog(locale) {
47
51
  if (locale === "en-US") {
48
52
  return enUS;
49
53
  }
50
- const loader = CATALOG_LOADERS[locale];
51
- if (!loader) {
52
- throw new Error(`No bundled i18n catalog for locale: ${locale}`);
53
- }
54
- const module = await loader();
55
- return module.default;
54
+ throw new Error(`No bundled i18n catalog for locale: ${locale}`);
56
55
  }
@@ -78,19 +78,31 @@ export declare function languageTagScriptLookupSequence(tag: string | null | und
78
78
  * `zh-TW` passes it by.
79
79
  */
80
80
  export declare function findBestLanguageMatch(requested: string | null | undefined, available: readonly (string | null | undefined)[]): string | undefined;
81
+ /**
82
+ * A tag without its variant, extension and private-use subtags, casing kept:
83
+ * `en-GB-x-gulf` yields `en-GB`. The form for a speech engine, whose voices
84
+ * are keyed by language and region.
85
+ */
86
+ export declare function languageTagWithoutVariants(tag: string | null | undefined): string;
81
87
  /**
82
88
  * `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.
89
+ * `nl` reach a regional `nl-NL` and `es-MX` reaches `es-ES`. Widening stays
90
+ * within the request's script and keeps its variant subtags: `zh` never
91
+ * reaches `zh-TW`, `sr-Latn` may reach `sr-ME`, `en-GB` never reaches
92
+ * `en-GB-x-gulf`, and `en-US-x-gulf` may reach `en-GB-x-gulf`. Among several
93
+ * siblings the first in `available` wins. The rule that picks an interface
94
+ * catalog and a content variant alike.
86
95
  */
87
96
  export declare function findClosestLanguageMatch(requested: string | null | undefined, available: readonly (string | null | undefined)[]): string | undefined;
88
97
  /** The content language a backend serves, and whether it is a substitute. */
89
98
  export interface ContentLanguageMatch {
90
99
  /** The `available` entry served, as authored; `null` for content that declares no language. */
91
100
  language: string | null;
92
- /** `"fallback"` when the served content stands in for the request. */
93
- substitution?: "fallback";
101
+ /**
102
+ * `"fallback"` when the served content stands in for the request;
103
+ * `"variety"` when it is another variety of the requested language.
104
+ */
105
+ substitution?: "fallback" | "variety";
94
106
  }
95
107
  /**
96
108
  * The language to serve content in: the closest `available` match for the
@@ -100,7 +112,12 @@ export interface ContentLanguageMatch {
100
112
  * chain serves the closest match for `defaultLanguage` (`en-US` reaches `en`),
101
113
  * then content that declares no language (a `null` entry), then `source`;
102
114
  * `matching` does not narrow it. `undefined` means nothing can be served. No
103
- * requested languages run the chain without a substitution.
115
+ * requested languages run the chain without a substitution. A closest match
116
+ * in another variety, `es-MX` served `es-ES`, records `"variety"`; a host
117
+ * that must not serve one sets `matching: "exact"` and lists the varieties it
118
+ * accepts. Candidates rank
119
+ * `source` first, then by tag, so the order of `available` never decides
120
+ * between two siblings.
104
121
  */
105
122
  export declare function negotiateContentLanguage(requested: readonly string[], available: readonly (string | null)[], options?: {
106
123
  defaultLanguage?: string;
@@ -149,11 +149,59 @@ export function findBestLanguageMatch(requested, available) {
149
149
  }
150
150
  return undefined;
151
151
  }
152
+ /**
153
+ * A tag's subtags split at the end of its language, script and region:
154
+ * `en-GB-x-gulf` yields `[["en", "GB"], ["x", "gulf"]]`. The second part holds
155
+ * variants, extensions and private use, as authored.
156
+ */
157
+ function splitVariantSubtags(tag) {
158
+ const subtags = toLanguageTag(tag).split(/[.@]/)[0].split("-");
159
+ let length = 1;
160
+ if (/^[a-z]{4}$/i.test(subtags[length] ?? ""))
161
+ length++;
162
+ if (/^([a-z]{2}|\d{3})$/i.test(subtags[length] ?? ""))
163
+ length++;
164
+ return [subtags.slice(0, length), subtags.slice(length)];
165
+ }
166
+ /** The variant, extension and private-use subtags as a comparison key. */
167
+ function variantSubtagsKey(tag) {
168
+ return splitVariantSubtags(tag)[1].join("-").toLowerCase();
169
+ }
170
+ /**
171
+ * Whether `served` is another variety of `requested`: it names a different
172
+ * region, or different variant or private-use subtags. `es-MX` served `es-ES`
173
+ * or `en-GB-x-gulf` served `en-GB` is; `es-MX` served `es`, or `zh-TW` served
174
+ * `zh-Hant`, is not.
175
+ */
176
+ function isOtherLanguageVariety(requested, served) {
177
+ const region = (tag) => {
178
+ const [base] = splitVariantSubtags(tag);
179
+ const last = base.length > 1 ? base[base.length - 1] : "";
180
+ return /^([a-z]{2}|\d{3})$/i.test(last) ? last.toLowerCase() : undefined;
181
+ };
182
+ const requestedRegion = region(requested);
183
+ const servedRegion = region(served);
184
+ return ((requestedRegion !== undefined &&
185
+ servedRegion !== undefined &&
186
+ requestedRegion !== servedRegion) ||
187
+ variantSubtagsKey(requested) !== variantSubtagsKey(served));
188
+ }
189
+ /**
190
+ * A tag without its variant, extension and private-use subtags, casing kept:
191
+ * `en-GB-x-gulf` yields `en-GB`. The form for a speech engine, whose voices
192
+ * are keyed by language and region.
193
+ */
194
+ export function languageTagWithoutVariants(tag) {
195
+ return splitVariantSubtags(tag)[0].join("-");
196
+ }
152
197
  /**
153
198
  * `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.
199
+ * `nl` reach a regional `nl-NL` and `es-MX` reaches `es-ES`. Widening stays
200
+ * within the request's script and keeps its variant subtags: `zh` never
201
+ * reaches `zh-TW`, `sr-Latn` may reach `sr-ME`, `en-GB` never reaches
202
+ * `en-GB-x-gulf`, and `en-US-x-gulf` may reach `en-GB-x-gulf`. Among several
203
+ * siblings the first in `available` wins. The rule that picks an interface
204
+ * catalog and a content variant alike.
157
205
  */
158
206
  export function findClosestLanguageMatch(requested, available) {
159
207
  const best = findBestLanguageMatch(requested, available);
@@ -162,7 +210,9 @@ export function findClosestLanguageMatch(requested, available) {
162
210
  const language = normalizeLanguageTag(requested).split("-")[0];
163
211
  if (!language)
164
212
  return undefined;
213
+ const variants = variantSubtagsKey(requested);
165
214
  const sibling = available.find((candidate) => normalizeLanguageTag(candidate).split("-")[0] === language &&
215
+ variantSubtagsKey(candidate) === variants &&
166
216
  languageTagScriptsMatch(requested, candidate));
167
217
  return sibling ?? undefined;
168
218
  }
@@ -174,20 +224,35 @@ export function findClosestLanguageMatch(requested, available) {
174
224
  * chain serves the closest match for `defaultLanguage` (`en-US` reaches `en`),
175
225
  * then content that declares no language (a `null` entry), then `source`;
176
226
  * `matching` does not narrow it. `undefined` means nothing can be served. No
177
- * requested languages run the chain without a substitution.
227
+ * requested languages run the chain without a substitution. A closest match
228
+ * in another variety, `es-MX` served `es-ES`, records `"variety"`; a host
229
+ * that must not serve one sets `matching: "exact"` and lists the varieties it
230
+ * accepts. Candidates rank
231
+ * `source` first, then by tag, so the order of `available` never decides
232
+ * between two siblings.
178
233
  */
179
234
  export function negotiateContentLanguage(requested, available, options = {}) {
180
235
  const { defaultLanguage, fallback = "default", matching = "closest", source } = options;
236
+ const candidates = [...available].sort((a, b) => {
237
+ if ((a === source) !== (b === source))
238
+ return a === source ? -1 : 1;
239
+ const left = normalizeLanguageTag(a);
240
+ const right = normalizeLanguageTag(b);
241
+ return left < right ? -1 : left > right ? 1 : 0;
242
+ });
181
243
  for (const tag of requested) {
182
244
  const language = matching === "exact"
183
- ? available.find((candidate) => languageTagsEqual(candidate, tag))
184
- : findClosestLanguageMatch(tag, available);
185
- if (language != null)
186
- return { language };
245
+ ? candidates.find((candidate) => languageTagsEqual(candidate, tag))
246
+ : findClosestLanguageMatch(tag, candidates);
247
+ if (language != null) {
248
+ return isOtherLanguageVariety(tag, language)
249
+ ? { language, substitution: "variety" }
250
+ : { language };
251
+ }
187
252
  }
188
253
  if (requested.length > 0 && fallback === "none")
189
254
  return undefined;
190
- let language = findClosestLanguageMatch(defaultLanguage, available);
255
+ let language = findClosestLanguageMatch(defaultLanguage, candidates);
191
256
  if (language === undefined && available.includes(null))
192
257
  language = null;
193
258
  if (language === undefined && source !== undefined && available.includes(source)) {
@@ -109,16 +109,16 @@ declare const enUS: {
109
109
  loadFailed: string;
110
110
  restoreFailed: string;
111
111
  };
112
- /** Formative delivery: check-answer control and its outcome announcements. */
113
- formative: {
114
- checkAnswer: string;
112
+ /** Item session control: check-answer control and its outcome announcements. */
113
+ check: {
114
+ answer: string;
115
115
  tryAgain: string;
116
116
  answerRecorded: string;
117
117
  correct: string;
118
118
  partlyCorrect: string;
119
119
  notCorrect: string;
120
120
  notAutoScored: string;
121
- checkFailed: string;
121
+ failed: string;
122
122
  /**
123
123
  * Appended after the outcome, so the announcement reads
124
124
  * "Not correct. 2 tries left." Kept a separate plural group rather than
@@ -301,6 +301,12 @@ declare const enUS: {
301
301
  lanthanide: string;
302
302
  actinide: string;
303
303
  };
304
+ /** Standard-state phase, keyed by the data file's lowercased `phase` value. */
305
+ phaseValue: {
306
+ solid: string;
307
+ liquid: string;
308
+ gas: string;
309
+ };
304
310
  };
305
311
  ruler: {
306
312
  name: string;
@@ -462,6 +468,17 @@ declare const enUS: {
462
468
  highlightPinkA11y: string;
463
469
  highlightBlueA11y: string;
464
470
  highlightGreenA11y: string;
471
+ selectionAvailableA11y: string;
472
+ highlightedYellowA11y: string;
473
+ highlightedPinkA11y: string;
474
+ highlightedBlueA11y: string;
475
+ highlightedGreenA11y: string;
476
+ underlinedA11y: string;
477
+ removedA11y: string;
478
+ clearedA11y: {
479
+ one: string;
480
+ other: string;
481
+ };
465
482
  };
466
483
  theme: {
467
484
  name: string;
@@ -109,16 +109,16 @@ const enUS = {
109
109
  loadFailed: "The assessment could not be loaded. Try again.",
110
110
  restoreFailed: "Your saved answers for this section could not be restored. Try again.",
111
111
  },
112
- /** Formative delivery: check-answer control and its outcome announcements. */
113
- formative: {
114
- checkAnswer: "Check answer",
112
+ /** Item session control: check-answer control and its outcome announcements. */
113
+ check: {
114
+ answer: "Check answer",
115
115
  tryAgain: "Try again",
116
116
  answerRecorded: "Answer recorded.",
117
117
  correct: "Correct.",
118
118
  partlyCorrect: "Partly correct.",
119
119
  notCorrect: "Not correct.",
120
120
  notAutoScored: "Answer recorded. This question is not scored automatically.",
121
- checkFailed: "This question could not be checked. Try again.",
121
+ failed: "This question could not be checked. Try again.",
122
122
  /**
123
123
  * Appended after the outcome, so the announcement reads
124
124
  * "Not correct. 2 tries left." Kept a separate plural group rather than
@@ -306,6 +306,12 @@ const enUS = {
306
306
  lanthanide: "Lanthanide",
307
307
  actinide: "Actinide",
308
308
  },
309
+ /** Standard-state phase, keyed by the data file's lowercased `phase` value. */
310
+ phaseValue: {
311
+ solid: "Solid",
312
+ liquid: "Liquid",
313
+ gas: "Gas",
314
+ },
309
315
  },
310
316
  ruler: {
311
317
  name: "Ruler",
@@ -482,6 +488,19 @@ const enUS = {
482
488
  highlightPinkA11y: "Pink highlight",
483
489
  highlightBlueA11y: "Blue highlight",
484
490
  highlightGreenA11y: "Green highlight",
491
+ // Announced in the strip's live region. A colour is authored per phrase:
492
+ // a colour adjective agrees with its noun in some languages.
493
+ selectionAvailableA11y: "Annotation toolbar available for “{text}”. Press Shift+F10 for annotation tools.",
494
+ highlightedYellowA11y: "“{text}” highlighted in yellow",
495
+ highlightedPinkA11y: "“{text}” highlighted in pink",
496
+ highlightedBlueA11y: "“{text}” highlighted in blue",
497
+ highlightedGreenA11y: "“{text}” highlighted in green",
498
+ underlinedA11y: "“{text}” underlined",
499
+ removedA11y: "Removed annotation from “{text}”",
500
+ clearedA11y: {
501
+ one: "{count} annotation cleared",
502
+ other: "{count} annotations cleared",
503
+ },
485
504
  },
486
505
  theme: {
487
506
  name: "Theme",
@@ -16,8 +16,13 @@ import type enUS from "./messages/en-US.js";
16
16
  * syntax — the Learnosity transform emits POSIX `nl_NL`, QTI catalog cards carry
17
17
  * `xml:lang` — and the resolver normalizes both onto these, so this list is the
18
18
  * set of catalogs, not the set of accepted inputs.
19
+ *
20
+ * `en-US` is the only bundled catalog: the statically resident source of truth
21
+ * every other locale falls back through. Every other locale this repository
22
+ * ships lives in the optional generated tier
23
+ * (`@pie-players/pie-i18n-generated`), which a host composes into its loader.
19
24
  */
20
- export type BundledLocaleCode = "en-US" | "nl-NL";
25
+ export type BundledLocaleCode = "en-US";
21
26
  /**
22
27
  * Any locale tag. A host may supply a locale we ship no catalog for and provide
23
28
  * its own messages through {@link I18nConfig.customMessages}, so this is
@@ -52,7 +57,7 @@ type PluralCategoryKey = keyof PluralTranslation;
52
57
  * Dot-notation keys of a catalog shape.
53
58
  *
54
59
  * A plural group terminates the path at the group itself: `plural()` is called
55
- * with `player.formative.triesLeft`, never with `…triesLeft.other`. A group is
60
+ * with `player.check.triesLeft`, never with `…triesLeft.other`. A group is
56
61
  * recognized by every one of its keys being a CLDR plural category — the
57
62
  * containment has to run this way round, because a group carrying only
58
63
  * `one`/`other` does not contain the whole category union.
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export * from "./security/index.js";
6
6
  export * from "./loaders/index.js";
7
7
  export * from "./object/index.js";
8
8
  export * from "./pie/index.js";
9
- export type { AssessmentEntity, AssessmentItemRef, AssessmentSection, AdvancedItemConfig, ConfigEntity, ConfigResource, Env, FormativeDeliveryPolicy, FormativeItemPolicy, TimedMediaSectionData, ItemConfig, ItemEntity, ItemSession, OutcomeResponse, PassageEntity, PieController, PieContent, PieDefaultModel, PieItemElement, PieModel, PieUpdateSession, ReferencedItemEntity, ReferencedPassageEntity, RubricBlock, TestPart, } from "./types/index.js";
9
+ export type { AssessmentEntity, AssessmentItemRef, AssessmentSection, AdvancedItemConfig, ConfigEntity, ConfigResource, Env, ItemSessionControlPolicy, TimedMediaSectionData, ItemConfig, ItemEntity, ItemSession, OutcomeResponse, PassageEntity, PieController, PieContent, PieDefaultModel, PieItemElement, PieModel, PieUpdateSession, ReferencedItemEntity, ReferencedPassageEntity, RubricBlock, TestPart, } from "./types/index.js";
10
10
  export { editorPostFix } from "./types/index.js";
11
11
  export * from "./ui/attribute-coercion.js";
12
12
  export * from "./ui/content-styles.js";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Item session control: Try state, feedback reveal, and section mastery.
3
+ *
4
+ * Pure and Node-safe by design — no DOM, no timers, no element registry — so
5
+ * the contract is testable without a browser and an adapter can import it
6
+ * without pulling in a player. Everything that touches the DOM stays in
7
+ * `@pie-players/pie-section-player`.
8
+ *
9
+ * Contract: `docs/prds/item-session-control-contract.md`.
10
+ */
11
+ export type { TryAction, TryCorrectness, ItemSessionControlPolicy, FeedbackEnvOverride, FeedbackReveal, ItemTryState, ItemTryView, MasteryRollup, FeedbackRevealTiming, ScoredElementOutcome, TrySectionProjection, TrySectionSlice, TryLimit, TryOutcome, ResolvedItemSessionControlPolicy, } from "./types.js";
12
+ export { ITEM_SESSION_CONTROL_DEFAULTS, isItemSessionControlEnabledInSection, resolveItemSessionControls, resolveItemSessionControl, } from "./policy.js";
13
+ export { aggregateTryOutcome } from "./outcome.js";
14
+ export { ITEM_TRY_STATE_VERSION, createItemTryState, hideItemFeedback, recordTry, resolveItemTryView, retryItem, revealItemFeedback, } from "./state.js";
15
+ export { MASTERY_ROLLUP_VERSION, rollupMastery } from "./mastery.js";
16
+ export { TRY_SLICE_VERSION, normalizeTrySectionSlice, toTrySectionSlice, } from "./session.js";
@@ -0,0 +1,5 @@
1
+ export { ITEM_SESSION_CONTROL_DEFAULTS, isItemSessionControlEnabledInSection, resolveItemSessionControls, resolveItemSessionControl, } from "./policy.js";
2
+ export { aggregateTryOutcome } from "./outcome.js";
3
+ export { ITEM_TRY_STATE_VERSION, createItemTryState, hideItemFeedback, recordTry, resolveItemTryView, retryItem, revealItemFeedback, } from "./state.js";
4
+ export { MASTERY_ROLLUP_VERSION, rollupMastery } from "./mastery.js";
5
+ export { TRY_SLICE_VERSION, normalizeTrySectionSlice, toTrySectionSlice, } from "./session.js";
@@ -1,5 +1,5 @@
1
- import type { FormativeItemState, FormativeMasteryRollup } from "./types.js";
2
- export declare const FORMATIVE_MASTERY_VERSION: 1;
1
+ import type { ItemTryState, MasteryRollup } from "./types.js";
2
+ export declare const MASTERY_ROLLUP_VERSION: 1;
3
3
  /**
4
4
  * Roll Try outcomes up to the section.
5
5
  *
@@ -10,7 +10,7 @@ export declare const FORMATIVE_MASTERY_VERSION: 1;
10
10
  * excluded: nothing yet says it cannot be scored, so it keeps the section from
11
11
  * reading as complete after one correct answer.
12
12
  */
13
- export declare function rollupFormativeMastery(args: {
13
+ export declare function rollupMastery(args: {
14
14
  itemIdentifiers: readonly string[];
15
- states: Record<string, FormativeItemState>;
16
- }): FormativeMasteryRollup;
15
+ states: Record<string, ItemTryState>;
16
+ }): MasteryRollup;
@@ -1,4 +1,4 @@
1
- export const FORMATIVE_MASTERY_VERSION = 1;
1
+ export const MASTERY_ROLLUP_VERSION = 1;
2
2
  /**
3
3
  * Roll Try outcomes up to the section.
4
4
  *
@@ -9,7 +9,7 @@ export const FORMATIVE_MASTERY_VERSION = 1;
9
9
  * excluded: nothing yet says it cannot be scored, so it keeps the section from
10
10
  * reading as complete after one correct answer.
11
11
  */
12
- export function rollupFormativeMastery(args) {
12
+ export function rollupMastery(args) {
13
13
  const identifiers = args.itemIdentifiers ?? [];
14
14
  const totalItems = identifiers.length;
15
15
  let notScorableItems = 0;
@@ -31,7 +31,7 @@ export function rollupFormativeMastery(args) {
31
31
  }
32
32
  const scorableItems = Math.max(0, totalItems - notScorableItems);
33
33
  return {
34
- version: FORMATIVE_MASTERY_VERSION,
34
+ version: MASTERY_ROLLUP_VERSION,
35
35
  totalItems,
36
36
  scorableItems,
37
37
  masteredItems,
@@ -1,10 +1,10 @@
1
- import type { FormativeScoredOutcome, FormativeTryOutcome } from "./types.js";
1
+ import type { ScoredElementOutcome, TryOutcome } from "./types.js";
2
2
  /**
3
3
  * Reduce the array `pie-item-player.provideScore()` returns to one Try outcome.
4
4
  *
5
5
  * The aggregation mirrors the policy the persisted API scoring path already
6
6
  * documents (`docs/item-player/scoring-and-rubrics.md`), so a browser-derived
7
- * formative result and a server-derived score do not disagree about what a
7
+ * Try result and a server-derived score do not disagree about what a
8
8
  * multi-element item is worth:
9
9
  *
10
10
  * - one scored outcome: `points = score`, `max = max ?? 1`;
@@ -16,4 +16,4 @@ import type { FormativeScoredOutcome, FormativeTryOutcome } from "./types.js";
16
16
  * those slots count toward `totalElementCount` and not toward
17
17
  * `scoredElementCount`.
18
18
  */
19
- export declare function aggregateFormativeOutcome(outcomes: ReadonlyArray<FormativeScoredOutcome | null | undefined> | null): FormativeTryOutcome;
19
+ export declare function aggregateTryOutcome(outcomes: ReadonlyArray<ScoredElementOutcome | null | undefined> | null): TryOutcome;
@@ -39,7 +39,7 @@ function classify(points, max) {
39
39
  *
40
40
  * The aggregation mirrors the policy the persisted API scoring path already
41
41
  * documents (`docs/item-player/scoring-and-rubrics.md`), so a browser-derived
42
- * formative result and a server-derived score do not disagree about what a
42
+ * Try result and a server-derived score do not disagree about what a
43
43
  * multi-element item is worth:
44
44
  *
45
45
  * - one scored outcome: `points = score`, `max = max ?? 1`;
@@ -51,7 +51,7 @@ function classify(points, max) {
51
51
  * those slots count toward `totalElementCount` and not toward
52
52
  * `scoredElementCount`.
53
53
  */
54
- export function aggregateFormativeOutcome(outcomes) {
54
+ export function aggregateTryOutcome(outcomes) {
55
55
  const all = Array.isArray(outcomes) ? outcomes : [];
56
56
  const scored = all.filter(isScored);
57
57
  const totalElementCount = all.length;