@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.
Files changed (87) hide show
  1. package/README.md +59 -0
  2. package/dist/formative/index.d.ts +16 -0
  3. package/dist/formative/index.js +5 -0
  4. package/dist/formative/mastery.d.ts +16 -0
  5. package/dist/formative/mastery.js +43 -0
  6. package/dist/formative/outcome.d.ts +19 -0
  7. package/dist/formative/outcome.js +90 -0
  8. package/dist/formative/policy.d.ts +37 -0
  9. package/dist/formative/policy.js +86 -0
  10. package/dist/formative/session.d.ts +19 -0
  11. package/dist/formative/session.js +113 -0
  12. package/dist/formative/state.d.ts +60 -0
  13. package/dist/formative/state.js +140 -0
  14. package/dist/formative/types.d.ts +164 -0
  15. package/dist/formative/types.js +13 -0
  16. package/dist/i18n/catalogs.d.ts +38 -0
  17. package/dist/i18n/catalogs.js +47 -0
  18. package/dist/i18n/index.d.ts +28 -4
  19. package/dist/i18n/index.js +34 -3
  20. package/dist/i18n/language-tags.d.ts +51 -0
  21. package/dist/i18n/language-tags.js +84 -0
  22. package/dist/i18n/messages/en-US.d.ts +601 -0
  23. package/dist/i18n/messages/en-US.js +617 -0
  24. package/dist/i18n/messages/nl-NL.d.ts +516 -0
  25. package/dist/i18n/messages/nl-NL.js +516 -0
  26. package/dist/i18n/provider.d.ts +168 -0
  27. package/dist/i18n/provider.js +472 -0
  28. package/dist/i18n/types.d.ts +177 -28
  29. package/dist/i18n/types.js +6 -2
  30. package/dist/index.d.ts +3 -1
  31. package/dist/index.js +2 -0
  32. package/dist/pie/custom-element-define.d.ts +24 -0
  33. package/dist/pie/custom-element-define.js +36 -6
  34. package/dist/pie/index.d.ts +1 -1
  35. package/dist/pie/index.js +1 -1
  36. package/dist/pie/versioned-tag.js +3 -2
  37. package/dist/security/sanitize-forbidden-lists.d.ts +8 -0
  38. package/dist/security/sanitize-forbidden-lists.js +41 -0
  39. package/dist/security/sanitize-item-markup.js +3 -36
  40. package/dist/security/sanitize-svg-icon.js +3 -25
  41. package/dist/security/wrap-overwide-images.d.ts +5 -10
  42. package/dist/security/wrap-overwide-images.js +13 -62
  43. package/dist/security/wrap-overwide-tables.d.ts +5 -10
  44. package/dist/security/wrap-overwide-tables.js +13 -62
  45. package/dist/security/wrap-overwide.d.ts +51 -0
  46. package/dist/security/wrap-overwide.js +91 -0
  47. package/dist/server/npm-auth-env.d.ts +13 -0
  48. package/dist/server/npm-auth-env.js +64 -0
  49. package/dist/timed-media/index.d.ts +17 -0
  50. package/dist/timed-media/index.js +16 -0
  51. package/dist/timed-media/media-element-source.d.ts +36 -0
  52. package/dist/timed-media/media-element-source.js +121 -0
  53. package/dist/timed-media/policy.d.ts +36 -0
  54. package/dist/timed-media/policy.js +297 -0
  55. package/dist/timed-media/session.d.ts +21 -0
  56. package/dist/timed-media/session.js +85 -0
  57. package/dist/timed-media/state.d.ts +66 -0
  58. package/dist/timed-media/state.js +357 -0
  59. package/dist/timed-media/types.d.ts +343 -0
  60. package/dist/timed-media/types.js +12 -0
  61. package/dist/tools/term-lookup.d.ts +189 -0
  62. package/dist/tools/term-lookup.js +284 -0
  63. package/dist/types/index.d.ts +105 -1
  64. package/dist/ui/attribute-coercion.d.ts +1 -0
  65. package/dist/ui/attribute-coercion.js +19 -0
  66. package/dist/ui/first-focusable.d.ts +22 -0
  67. package/dist/ui/first-focusable.js +47 -0
  68. package/dist/ui/focus-trap.js +7 -2
  69. package/dist/ui/pointer-drag.d.ts +30 -0
  70. package/dist/ui/pointer-drag.js +34 -0
  71. package/package.json +38 -6
  72. package/dist/i18n/loader.d.ts +0 -35
  73. package/dist/i18n/loader.js +0 -132
  74. package/dist/i18n/simple-i18n.d.ts +0 -68
  75. package/dist/i18n/simple-i18n.js +0 -198
  76. package/dist/i18n/translations/ar/common.json +0 -36
  77. package/dist/i18n/translations/ar/toolkit.json +0 -48
  78. package/dist/i18n/translations/ar/tools.json +0 -103
  79. package/dist/i18n/translations/en/common.json +0 -36
  80. package/dist/i18n/translations/en/toolkit.json +0 -48
  81. package/dist/i18n/translations/en/tools.json +0 -103
  82. package/dist/i18n/translations/es/common.json +0 -36
  83. package/dist/i18n/translations/es/toolkit.json +0 -48
  84. package/dist/i18n/translations/es/tools.json +0 -103
  85. package/dist/i18n/translations/zh/common.json +0 -36
  86. package/dist/i18n/translations/zh/toolkit.json +0 -48
  87. package/dist/i18n/translations/zh/tools.json +0 -103
@@ -0,0 +1,140 @@
1
+ export const FORMATIVE_ITEM_STATE_VERSION = 1;
2
+ export function createFormativeItemState(itemIdentifier) {
3
+ return {
4
+ version: FORMATIVE_ITEM_STATE_VERSION,
5
+ itemIdentifier,
6
+ tryCount: 0,
7
+ revealed: false,
8
+ };
9
+ }
10
+ function triesRemaining(policy, tryCount) {
11
+ if (policy.maxTries === "unlimited")
12
+ return "unlimited";
13
+ return Math.max(0, policy.maxTries - tryCount);
14
+ }
15
+ function hasTriesLeft(remaining) {
16
+ return remaining === "unlimited" || remaining > 0;
17
+ }
18
+ function shouldReveal(policy, tryCount) {
19
+ if (policy.feedback === "none")
20
+ return false;
21
+ if (policy.revealOn === "on-try")
22
+ return true;
23
+ // Defensive: `resolveFormativePolicy` already coerces `"on-final-try"` to
24
+ // `"on-try"` under unlimited Tries, so this branch only guards a
25
+ // hand-constructed policy.
26
+ if (policy.maxTries === "unlimited")
27
+ return true;
28
+ return tryCount >= policy.maxTries;
29
+ }
30
+ /**
31
+ * Record one Try.
32
+ *
33
+ * Idempotent against a double submit: a Try is only counted when the current
34
+ * state can be checked, so a second click landing before the projection
35
+ * round-trips is dropped rather than spending a Try.
36
+ */
37
+ export function recordFormativeTry(args) {
38
+ const current = args.state ?? createFormativeItemState(args.itemIdentifier);
39
+ if (!args.policy.enabled)
40
+ return current;
41
+ const remaining = triesRemaining(args.policy, current.tryCount);
42
+ if (current.revealed || !hasTriesLeft(remaining))
43
+ return current;
44
+ const tryCount = current.tryCount + 1;
45
+ return {
46
+ ...current,
47
+ version: FORMATIVE_ITEM_STATE_VERSION,
48
+ itemIdentifier: args.itemIdentifier,
49
+ tryCount,
50
+ revealed: shouldReveal(args.policy, tryCount),
51
+ lastOutcome: args.outcome,
52
+ // First correct Try is recorded once. A later incorrect Try changes
53
+ // `lastOutcome` but not the fact that the learner got there, which is what
54
+ // mastery counts.
55
+ firstCorrectTry: current.firstCorrectTry ??
56
+ (args.outcome.correctness === "correct" ? tryCount : undefined),
57
+ };
58
+ }
59
+ /**
60
+ * Dismiss a reveal and reopen the item for editing. Withdrawing `revealed` is
61
+ * what withdraws the env override, so the item returns to the section's own
62
+ * mode.
63
+ *
64
+ * A learner action, so it respects the Try budget: an item with none left keeps
65
+ * its feedback on screen. `hideFormativeItem` is the host-authority version.
66
+ */
67
+ export function retryFormativeItem(args) {
68
+ const current = args.state;
69
+ if (!current || !args.policy.enabled)
70
+ return current;
71
+ if (!current.revealed)
72
+ return current;
73
+ if (!hasTriesLeft(triesRemaining(args.policy, current.tryCount))) {
74
+ return current;
75
+ }
76
+ return { ...current, revealed: false, revealOverride: undefined };
77
+ }
78
+ /**
79
+ * Reveal on host authority — a teacher-driven "show the answer".
80
+ *
81
+ * Spends no Try and ignores the Try budget and `revealOn`, because none of those
82
+ * bound a decision the host has already taken. `feedback` is required rather
83
+ * than defaulted from the policy: a forced reveal under `feedback: "none"` would
84
+ * project nothing, so the caller states what to show.
85
+ *
86
+ * Works on an item with no Try yet. The element renders evaluate mode over
87
+ * whatever response is there, including none, which is what "show the answer"
88
+ * means before the learner has answered.
89
+ */
90
+ export function revealFormativeItem(args) {
91
+ const current = args.state ?? createFormativeItemState(args.itemIdentifier);
92
+ if (!args.policy.enabled)
93
+ return current;
94
+ if (current.revealed && current.revealOverride === args.feedback) {
95
+ return current;
96
+ }
97
+ return { ...current, revealed: true, revealOverride: args.feedback };
98
+ }
99
+ /**
100
+ * Withdraw a reveal on host authority. Unlike a learner retry this ignores the
101
+ * Try budget: a host that revealed an item with no Tries left must be able to
102
+ * put it back.
103
+ */
104
+ export function hideFormativeItem(args) {
105
+ const current = args.state;
106
+ if (!current || !args.policy.enabled)
107
+ return current;
108
+ if (!current.revealed && !current.revealOverride)
109
+ return current;
110
+ return { ...current, revealed: false, revealOverride: undefined };
111
+ }
112
+ function envOverrideFor(policy, revealed, revealOverride) {
113
+ const feedback = revealOverride ?? policy.feedback;
114
+ if (!revealed || feedback === "none")
115
+ return undefined;
116
+ return {
117
+ mode: "evaluate",
118
+ role: feedback === "solution" ? "instructor" : "student",
119
+ };
120
+ }
121
+ /** Derive everything a rendering component needs from policy plus state. */
122
+ export function resolveFormativeItemView(args) {
123
+ const { policy } = args;
124
+ const tryCount = args.state?.tryCount ?? 0;
125
+ const revealed = args.state?.revealed === true;
126
+ const remaining = triesRemaining(policy, tryCount);
127
+ const available = policy.enabled && hasTriesLeft(remaining);
128
+ return {
129
+ enabled: policy.enabled,
130
+ tryCount,
131
+ triesRemaining: remaining,
132
+ canCheck: available && !revealed,
133
+ canRetry: available && revealed,
134
+ revealed,
135
+ envOverride: policy.enabled
136
+ ? envOverrideFor(policy, revealed, args.state?.revealOverride)
137
+ : undefined,
138
+ lastOutcome: args.state?.lastOutcome,
139
+ };
140
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Formative delivery vocabulary.
3
+ *
4
+ * A **Try** is one submitted-for-checking pass over a single item. It is not
5
+ * `TestAttemptSession` (the assessment administration) and not
6
+ * `TestAttemptItemSession.attemptCount` (a count of distinct PIE session ids for
7
+ * an item). Both of those already own the word "attempt" in this codebase, at
8
+ * two different scopes, which is why this contract does not use it.
9
+ *
10
+ * See `docs/prds/formative-delivery-contract.md` for the ratified contract and
11
+ * its QTI 3 mapping.
12
+ */
13
+ /** How many Tries a learner gets on one item. */
14
+ export type FormativeTryLimit = number | "unlimited";
15
+ /**
16
+ * What the learner sees after a Try.
17
+ *
18
+ * PIE does not render feedback — it selects the render mode the element already
19
+ * implements. `"correctness"` projects `role: "student"`, `"solution"` projects
20
+ * `role: "instructor"`, which is the element convention for additionally
21
+ * revealing the authored correct response.
22
+ */
23
+ export type FormativeFeedbackReveal = "none" | "correctness" | "solution";
24
+ /** When a reveal happens: after every Try, or only once Tries are spent. */
25
+ export type FormativeRevealTiming = "on-try" | "on-final-try";
26
+ /**
27
+ * Authored policy. Lives on `AssessmentSection.formative` as the section
28
+ * default and on `AssessmentItemRef.formative` as a per-item override.
29
+ */
30
+ export interface FormativeDeliveryPolicy {
31
+ /**
32
+ * Absent or `false` leaves delivery exactly as it is without this contract:
33
+ * no control, no projection, no state.
34
+ */
35
+ enabled?: boolean;
36
+ /** Default 1. */
37
+ maxTries?: FormativeTryLimit;
38
+ /** Default `"correctness"`. */
39
+ feedback?: FormativeFeedbackReveal;
40
+ /** Default `"on-try"`. */
41
+ revealOn?: FormativeRevealTiming;
42
+ }
43
+ /** Same shape as the section policy; every field overrides independently. */
44
+ export type FormativeItemPolicy = FormativeDeliveryPolicy;
45
+ /** The policy after built-in defaults, section, and item ref are merged. */
46
+ export interface ResolvedFormativePolicy {
47
+ enabled: boolean;
48
+ maxTries: FormativeTryLimit;
49
+ feedback: FormativeFeedbackReveal;
50
+ revealOn: FormativeRevealTiming;
51
+ }
52
+ /**
53
+ * `"unknown"` is the honest state for an item no loaded controller can score —
54
+ * one holding a rubric element, or one whose element bundle exposes no
55
+ * `outcome`. It is never collapsed into `"incorrect"`.
56
+ */
57
+ export type FormativeCorrectness = "correct" | "partial" | "incorrect" | "unknown";
58
+ /**
59
+ * The subset of an element outcome that aggregation reads. `OutcomeResponse` is
60
+ * assignable to this; the structural type keeps this module free of a
61
+ * dependency on the entity types, which import policy types from here.
62
+ */
63
+ export interface FormativeScoredOutcome {
64
+ score?: number;
65
+ max?: number;
66
+ [key: string]: unknown;
67
+ }
68
+ /** The recorded result of one Try. */
69
+ export interface FormativeTryOutcome {
70
+ correctness: FormativeCorrectness;
71
+ points?: number;
72
+ max?: number;
73
+ scoredElementCount: number;
74
+ totalElementCount: number;
75
+ /**
76
+ * The per-element outcomes the aggregate was derived from, for a host that
77
+ * renders its own feedback instead of the element's evaluate-mode rendering.
78
+ *
79
+ * Element-shaped and unvalidated beyond "an object" — each entry carries
80
+ * whatever that element's controller returned, keyed to a model by its own
81
+ * `id`. Empty slots (`provideScore()` leaves one per model with no element or
82
+ * controller) are dropped; `totalElementCount` still counts them.
83
+ *
84
+ * A trade: this persists inside the session slice, so a snapshot grows by
85
+ * whatever the elements put in their outcomes — some include a scoring trace.
86
+ */
87
+ elementOutcomes?: FormativeScoredOutcome[];
88
+ }
89
+ /** Per-item delivery state. Persisted; see `FormativeSectionSlice`. */
90
+ export interface FormativeItemState {
91
+ version: 1;
92
+ itemIdentifier: string;
93
+ tryCount: number;
94
+ /**
95
+ * Whether feedback is on screen right now. Live delivery state rather than a
96
+ * fact about the response: it distinguishes "checked, feedback shown,
97
+ * editing locked" from "editable again after a retry", and it persists so a
98
+ * reload restores the screen the learner left.
99
+ */
100
+ revealed: boolean;
101
+ lastOutcome?: FormativeTryOutcome;
102
+ /** 1-based Try on which the item first scored full credit. Never overwritten. */
103
+ firstCorrectTry?: number;
104
+ /**
105
+ * Reveal level in force for this item, when a host forced the reveal rather
106
+ * than the learner earning it. Overrides the policy's `feedback` for the env
107
+ * projection only — the policy still decides Tries.
108
+ *
109
+ * Set by `revealFormativeItem`, cleared by `hideFormativeItem` and by a
110
+ * learner retry, so a teacher-driven "show the answer" does not silently
111
+ * upgrade every later reveal on that item.
112
+ */
113
+ revealOverride?: FormativeFeedbackReveal;
114
+ }
115
+ /** The persisted slice, carried on `SectionControllerSessionState.formative`. */
116
+ export interface FormativeSectionSlice {
117
+ version: 1;
118
+ items: Record<string, FormativeItemState>;
119
+ }
120
+ /** Projected over the section env for one revealed item. */
121
+ export interface FormativeEnvOverride {
122
+ mode: "evaluate";
123
+ role: "student" | "instructor";
124
+ }
125
+ /**
126
+ * What a rendering component needs, derived once so no component reimplements
127
+ * the predicate. Carries no copy — wording belongs to the rendering package.
128
+ */
129
+ export interface FormativeItemView {
130
+ enabled: boolean;
131
+ tryCount: number;
132
+ triesRemaining: number | "unlimited";
133
+ canCheck: boolean;
134
+ canRetry: boolean;
135
+ revealed: boolean;
136
+ envOverride?: FormativeEnvOverride;
137
+ lastOutcome?: FormativeTryOutcome;
138
+ }
139
+ /** Section rollup over Try outcomes. */
140
+ export interface FormativeMasteryRollup {
141
+ version: 1;
142
+ totalItems: number;
143
+ /**
144
+ * Items not known to be unscorable — total minus those whose last outcome
145
+ * was `"unknown"`. An untried item counts here: nothing yet says it cannot
146
+ * be scored.
147
+ */
148
+ scorableItems: number;
149
+ masteredItems: number;
150
+ triedItems: number;
151
+ /** Mean `firstCorrectTry` across mastered items. */
152
+ averageTriesToMastery?: number;
153
+ complete: boolean;
154
+ }
155
+ /** The composition-model projection layouts read. */
156
+ export interface FormativeSectionProjection {
157
+ version: 1;
158
+ enabled: boolean;
159
+ policies: Record<string, ResolvedFormativePolicy>;
160
+ states: Record<string, FormativeItemState>;
161
+ mastery: FormativeMasteryRollup;
162
+ }
163
+ /** The two learner actions a rendering component can request. */
164
+ export type FormativeAction = "check" | "retry";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Formative delivery vocabulary.
3
+ *
4
+ * A **Try** is one submitted-for-checking pass over a single item. It is not
5
+ * `TestAttemptSession` (the assessment administration) and not
6
+ * `TestAttemptItemSession.attemptCount` (a count of distinct PIE session ids for
7
+ * an item). Both of those already own the word "attempt" in this codebase, at
8
+ * two different scopes, which is why this contract does not use it.
9
+ *
10
+ * See `docs/prds/formative-delivery-contract.md` for the ratified contract and
11
+ * its QTI 3 mapping.
12
+ */
13
+ export {};
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Lazy catalog loaders for every locale this repository ships.
3
+ *
4
+ * Imported by players, never by tools. A tool resolves its provider from the
5
+ * toolkit runtime context and needs only the interface plus the English
6
+ * fallback; reaching this module from a tool entry would emit a locale chunk
7
+ * into a bundle that never calls `setLocale`, because every tool
8
+ * `vite.config.ts` sets `external: []`.
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.
17
+ *
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.
20
+ *
21
+ * @module @pie-players/pie-players-shared/i18n/catalogs
22
+ */
23
+ import type { BundledLocaleCode, MessageCatalog } from "./types.js";
24
+ /**
25
+ * Every locale tag this repository ships a catalog for, English included.
26
+ *
27
+ * 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`.
29
+ */
30
+ export declare const BUNDLED_LOCALES: readonly BundledLocaleCode[];
31
+ /**
32
+ * Load one shipped catalog.
33
+ *
34
+ * Throws for a locale with no shipped catalog; the provider treats that as
35
+ * "resolve through the fallback chain" rather than an error, so a host locale we
36
+ * do not ship still renders.
37
+ */
38
+ export declare function loadBundledCatalog(locale: string): Promise<MessageCatalog>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Lazy catalog loaders for every locale this repository ships.
3
+ *
4
+ * Imported by players, never by tools. A tool resolves its provider from the
5
+ * toolkit runtime context and needs only the interface plus the English
6
+ * fallback; reaching this module from a tool entry would emit a locale chunk
7
+ * into a bundle that never calls `setLocale`, because every tool
8
+ * `vite.config.ts` sets `external: []`.
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.
17
+ *
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.
20
+ *
21
+ * @module @pie-players/pie-players-shared/i18n/catalogs
22
+ */
23
+ const CATALOG_LOADERS = {
24
+ "nl-NL": () => import("./messages/nl-NL.js"),
25
+ };
26
+ /**
27
+ * Every locale tag this repository ships a catalog for, English included.
28
+ *
29
+ * The provider resolves a request against this list through RFC 4647 lookup and
30
+ * primary-subtag widening, so `nl`, `nl_NL` and `nl-BE` all reach `nl-NL`.
31
+ */
32
+ export const BUNDLED_LOCALES = ["en-US", "nl-NL"];
33
+ /**
34
+ * Load one shipped catalog.
35
+ *
36
+ * Throws for a locale with no shipped catalog; the provider treats that as
37
+ * "resolve through the fallback chain" rather than an error, so a host locale we
38
+ * do not ship still renders.
39
+ */
40
+ export async function loadBundledCatalog(locale) {
41
+ const loader = CATALOG_LOADERS[locale];
42
+ if (!loader) {
43
+ throw new Error(`No bundled i18n catalog for locale: ${locale}`);
44
+ }
45
+ const module = await loader();
46
+ return module.default;
47
+ }
@@ -1,10 +1,34 @@
1
1
  /**
2
2
  * I18n Module
3
3
  *
4
- * Internationalization system for PIE Players.
4
+ * Chrome internationalization for PIE Players: the strings our own packages
5
+ * render. Content language and in-item alternates are separate concerns on
6
+ * separate channels — see `docs/architecture/internationalization.md`.
7
+ *
8
+ * Two entry points, and which one you import decides what ships:
9
+ *
10
+ * - **Players** import this module. `createPieI18n()` wires the provider to the
11
+ * lazy catalog loaders, so every shipped locale is reachable and each lands in
12
+ * its own chunk.
13
+ * - **Tools** import `./types.js` as `import type` (fully erased) plus
14
+ * `getDefaultI18n` from `./provider.js` for the no-publisher default. That
15
+ * path never reaches `./catalogs.js`, so no locale chunk is emitted into a
16
+ * tool bundle — which matters because every tool `vite.config.ts` sets
17
+ * `external: []`.
5
18
  *
6
19
  * @module @pie-players/pie-players-shared/i18n
7
20
  */
8
- export { BUNDLED_TRANSLATIONS, getAvailableLocales, isRTLLocale, loadTranslations, } from "./loader.js";
9
- export { SimpleI18n } from "./simple-i18n.js";
10
- export type { I18nConfig, I18nServiceApi, PluralTranslation, TranslationBundle, } from "./types.js";
21
+ import type { I18nConfig, I18nServiceApi } from "./types.js";
22
+ /**
23
+ * A provider serving every locale this repository ships.
24
+ *
25
+ * Defaults to `en-US` with no locale detection: under fixed lockstep patch-only
26
+ * versioning a rendered-string change reaches a host's live delivery on their
27
+ * next install with no build signal on their side, so a player must render
28
+ * exactly what it rendered before until a host supplies a locale.
29
+ */
30
+ export declare function createPieI18n(config?: I18nConfig): I18nServiceApi;
31
+ export { BUNDLED_LOCALES, loadBundledCatalog } from "./catalogs.js";
32
+ export { findBestLanguageMatch, languageTagLookupSequence, languageTagsEqual, normalizeLanguageTag, } from "./language-tags.js";
33
+ export { DEFAULT_LOCALE, detectBrowserLocale, dynamicMessageKey, getDefaultI18n, localeDirection, resolveInterfaceI18n, SimpleI18n, } from "./provider.js";
34
+ export type { BundledLocaleCode, DynamicMessageKey, I18nConfig, I18nProvider, I18nServiceApi, InterpolationValues, LocaleCode, MessageCatalog, MessageKey, MessageKeyInput, MessageNode, PluralOptions, PluralTranslation, TextDirection, TranslationBundle, } from "./types.js";
@@ -1,9 +1,40 @@
1
1
  /**
2
2
  * I18n Module
3
3
  *
4
- * Internationalization system for PIE Players.
4
+ * Chrome internationalization for PIE Players: the strings our own packages
5
+ * render. Content language and in-item alternates are separate concerns on
6
+ * separate channels — see `docs/architecture/internationalization.md`.
7
+ *
8
+ * Two entry points, and which one you import decides what ships:
9
+ *
10
+ * - **Players** import this module. `createPieI18n()` wires the provider to the
11
+ * lazy catalog loaders, so every shipped locale is reachable and each lands in
12
+ * its own chunk.
13
+ * - **Tools** import `./types.js` as `import type` (fully erased) plus
14
+ * `getDefaultI18n` from `./provider.js` for the no-publisher default. That
15
+ * path never reaches `./catalogs.js`, so no locale chunk is emitted into a
16
+ * tool bundle — which matters because every tool `vite.config.ts` sets
17
+ * `external: []`.
5
18
  *
6
19
  * @module @pie-players/pie-players-shared/i18n
7
20
  */
8
- export { BUNDLED_TRANSLATIONS, getAvailableLocales, isRTLLocale, loadTranslations, } from "./loader.js";
9
- export { SimpleI18n } from "./simple-i18n.js";
21
+ import { BUNDLED_LOCALES, loadBundledCatalog } from "./catalogs.js";
22
+ import { SimpleI18n } from "./provider.js";
23
+ /**
24
+ * A provider serving every locale this repository ships.
25
+ *
26
+ * Defaults to `en-US` with no locale detection: under fixed lockstep patch-only
27
+ * versioning a rendered-string change reaches a host's live delivery on their
28
+ * next install with no build signal on their side, so a player must render
29
+ * exactly what it rendered before until a host supplies a locale.
30
+ */
31
+ export function createPieI18n(config = {}) {
32
+ return new SimpleI18n({
33
+ loadCatalog: loadBundledCatalog,
34
+ availableLocales: BUNDLED_LOCALES,
35
+ ...config,
36
+ });
37
+ }
38
+ export { BUNDLED_LOCALES, loadBundledCatalog } from "./catalogs.js";
39
+ export { findBestLanguageMatch, languageTagLookupSequence, languageTagsEqual, normalizeLanguageTag, } from "./language-tags.js";
40
+ export { DEFAULT_LOCALE, detectBrowserLocale, dynamicMessageKey, getDefaultI18n, localeDirection, resolveInterfaceI18n, SimpleI18n, } from "./provider.js";
@@ -0,0 +1,51 @@
1
+ /**
2
+ * BCP-47 language tag comparison.
3
+ *
4
+ * Deliberately carries no locale data and no DOM access: this module is the one
5
+ * piece of language machinery that everything else needs, including the
6
+ * accessibility catalog resolver and TTS voice selection, neither of which wants
7
+ * a message catalog. Importing `../i18n/index.js` would pull the eagerly-bundled
8
+ * English translations, so this stays a separate entry point.
9
+ *
10
+ * Content producers do not agree on tag syntax. The Learnosity transform emits
11
+ * POSIX `es_ES`, QTI catalog cards carry BCP-47 `xml:lang`, and pie-elements-ng
12
+ * carries a hand-written POSIX-to-BCP-47 mapping table of its own. Comparing
13
+ * with `===`, as the catalog resolver did, means an `es_ES` card matches no
14
+ * request for `es-ES` and surfaces only through a no-language-constraint
15
+ * fallback — resolution by accident.
16
+ *
17
+ * @module @pie-players/pie-players-shared/i18n/language-tags
18
+ */
19
+ /**
20
+ * Canonicalize a language tag for comparison.
21
+ *
22
+ * Converts POSIX underscores to hyphens, strips a POSIX charset or modifier
23
+ * suffix (`es_ES.UTF-8`, `es_ES@euro`), and lowercases. Lowercasing rather than
24
+ * applying BCP-47 display casing is intentional — this output is a comparison
25
+ * key, not something to render.
26
+ *
27
+ * Returns an empty string for nullish or blank input, which callers read as
28
+ * "this card declares no language".
29
+ */
30
+ export declare function normalizeLanguageTag(tag: string | null | undefined): string;
31
+ /**
32
+ * Whether two language tags denote the same locale, ignoring separator style and
33
+ * case. Two tags that both declare no language are not a match.
34
+ */
35
+ export declare function languageTagsEqual(a: string | null | undefined, b: string | null | undefined): boolean;
36
+ /**
37
+ * The RFC 4647 lookup sequence for a tag, most specific first.
38
+ *
39
+ * `es-MX` yields `["es-mx", "es"]`, so a request for Mexican Spanish reaches a
40
+ * card tagged plain `es` without also reaching one tagged `es-ES`. Truncation
41
+ * never stops on a singleton subtag (`x`, `u`), per RFC 4647 §3.4, and never
42
+ * produces a bare region or script.
43
+ */
44
+ export declare function languageTagLookupSequence(tag: string | null | undefined): string[];
45
+ /**
46
+ * Best available tag for a request, by RFC 4647 lookup, or `undefined` when
47
+ * nothing matches. Ranking is by the request's specificity rather than the
48
+ * candidates' order, so `es-MX` prefers an `es-MX` candidate over an `es` one
49
+ * however they were authored.
50
+ */
51
+ export declare function findBestLanguageMatch(requested: string | null | undefined, available: readonly (string | null | undefined)[]): string | undefined;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * BCP-47 language tag comparison.
3
+ *
4
+ * Deliberately carries no locale data and no DOM access: this module is the one
5
+ * piece of language machinery that everything else needs, including the
6
+ * accessibility catalog resolver and TTS voice selection, neither of which wants
7
+ * a message catalog. Importing `../i18n/index.js` would pull the eagerly-bundled
8
+ * English translations, so this stays a separate entry point.
9
+ *
10
+ * Content producers do not agree on tag syntax. The Learnosity transform emits
11
+ * POSIX `es_ES`, QTI catalog cards carry BCP-47 `xml:lang`, and pie-elements-ng
12
+ * carries a hand-written POSIX-to-BCP-47 mapping table of its own. Comparing
13
+ * with `===`, as the catalog resolver did, means an `es_ES` card matches no
14
+ * request for `es-ES` and surfaces only through a no-language-constraint
15
+ * fallback — resolution by accident.
16
+ *
17
+ * @module @pie-players/pie-players-shared/i18n/language-tags
18
+ */
19
+ /**
20
+ * Canonicalize a language tag for comparison.
21
+ *
22
+ * Converts POSIX underscores to hyphens, strips a POSIX charset or modifier
23
+ * suffix (`es_ES.UTF-8`, `es_ES@euro`), and lowercases. Lowercasing rather than
24
+ * applying BCP-47 display casing is intentional — this output is a comparison
25
+ * key, not something to render.
26
+ *
27
+ * Returns an empty string for nullish or blank input, which callers read as
28
+ * "this card declares no language".
29
+ */
30
+ export function normalizeLanguageTag(tag) {
31
+ if (!tag)
32
+ return "";
33
+ const separatorsUnified = tag.trim().replace(/_/g, "-");
34
+ const withoutPosixSuffix = separatorsUnified.split(/[.@]/)[0];
35
+ return withoutPosixSuffix.toLowerCase();
36
+ }
37
+ /**
38
+ * Whether two language tags denote the same locale, ignoring separator style and
39
+ * case. Two tags that both declare no language are not a match.
40
+ */
41
+ export function languageTagsEqual(a, b) {
42
+ const left = normalizeLanguageTag(a);
43
+ if (!left)
44
+ return false;
45
+ return left === normalizeLanguageTag(b);
46
+ }
47
+ /**
48
+ * The RFC 4647 lookup sequence for a tag, most specific first.
49
+ *
50
+ * `es-MX` yields `["es-mx", "es"]`, so a request for Mexican Spanish reaches a
51
+ * card tagged plain `es` without also reaching one tagged `es-ES`. Truncation
52
+ * never stops on a singleton subtag (`x`, `u`), per RFC 4647 §3.4, and never
53
+ * produces a bare region or script.
54
+ */
55
+ export function languageTagLookupSequence(tag) {
56
+ const normalized = normalizeLanguageTag(tag);
57
+ if (!normalized)
58
+ return [];
59
+ const subtags = normalized.split("-");
60
+ const sequence = [];
61
+ for (let length = subtags.length; length > 0; length--) {
62
+ const candidate = subtags.slice(0, length);
63
+ // A trailing singleton introduces an extension with nothing left in it.
64
+ if (candidate.length > 1 && candidate[candidate.length - 1].length === 1) {
65
+ continue;
66
+ }
67
+ sequence.push(candidate.join("-"));
68
+ }
69
+ return sequence;
70
+ }
71
+ /**
72
+ * Best available tag for a request, by RFC 4647 lookup, or `undefined` when
73
+ * nothing matches. Ranking is by the request's specificity rather than the
74
+ * candidates' order, so `es-MX` prefers an `es-MX` candidate over an `es` one
75
+ * however they were authored.
76
+ */
77
+ export function findBestLanguageMatch(requested, available) {
78
+ for (const step of languageTagLookupSequence(requested)) {
79
+ const hit = available.find((candidate) => normalizeLanguageTag(candidate) === step);
80
+ if (hit != null)
81
+ return hit;
82
+ }
83
+ return undefined;
84
+ }