@pie-players/pie-players-shared 0.3.67 → 0.3.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/dist/formative/index.d.ts +16 -0
- package/dist/formative/index.js +5 -0
- package/dist/formative/mastery.d.ts +16 -0
- package/dist/formative/mastery.js +43 -0
- package/dist/formative/outcome.d.ts +19 -0
- package/dist/formative/outcome.js +90 -0
- package/dist/formative/policy.d.ts +37 -0
- package/dist/formative/policy.js +86 -0
- package/dist/formative/session.d.ts +19 -0
- package/dist/formative/session.js +113 -0
- package/dist/formative/state.d.ts +60 -0
- package/dist/formative/state.js +140 -0
- package/dist/formative/types.d.ts +164 -0
- package/dist/formative/types.js +13 -0
- package/dist/i18n/catalogs.d.ts +38 -0
- package/dist/i18n/catalogs.js +47 -0
- package/dist/i18n/index.d.ts +28 -4
- package/dist/i18n/index.js +34 -3
- package/dist/i18n/language-tags.d.ts +51 -0
- package/dist/i18n/language-tags.js +84 -0
- package/dist/i18n/messages/en-US.d.ts +601 -0
- package/dist/i18n/messages/en-US.js +617 -0
- package/dist/i18n/messages/nl-NL.d.ts +516 -0
- package/dist/i18n/messages/nl-NL.js +516 -0
- package/dist/i18n/provider.d.ts +168 -0
- package/dist/i18n/provider.js +472 -0
- package/dist/i18n/types.d.ts +177 -28
- package/dist/i18n/types.js +6 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/pie/custom-element-define.d.ts +24 -0
- package/dist/pie/custom-element-define.js +36 -6
- package/dist/pie/index.d.ts +1 -1
- package/dist/pie/index.js +1 -1
- package/dist/pie/versioned-tag.js +3 -2
- package/dist/security/sanitize-forbidden-lists.d.ts +8 -0
- package/dist/security/sanitize-forbidden-lists.js +41 -0
- package/dist/security/sanitize-item-markup.js +3 -36
- package/dist/security/sanitize-svg-icon.js +3 -25
- package/dist/security/wrap-overwide-images.d.ts +5 -10
- package/dist/security/wrap-overwide-images.js +13 -62
- package/dist/security/wrap-overwide-tables.d.ts +5 -10
- package/dist/security/wrap-overwide-tables.js +13 -62
- package/dist/security/wrap-overwide.d.ts +51 -0
- package/dist/security/wrap-overwide.js +91 -0
- package/dist/server/npm-auth-env.d.ts +13 -0
- package/dist/server/npm-auth-env.js +64 -0
- package/dist/timed-media/index.d.ts +17 -0
- package/dist/timed-media/index.js +16 -0
- package/dist/timed-media/media-element-source.d.ts +36 -0
- package/dist/timed-media/media-element-source.js +121 -0
- package/dist/timed-media/policy.d.ts +36 -0
- package/dist/timed-media/policy.js +297 -0
- package/dist/timed-media/session.d.ts +21 -0
- package/dist/timed-media/session.js +85 -0
- package/dist/timed-media/state.d.ts +66 -0
- package/dist/timed-media/state.js +357 -0
- package/dist/timed-media/types.d.ts +343 -0
- package/dist/timed-media/types.js +12 -0
- package/dist/tools/term-lookup.d.ts +189 -0
- package/dist/tools/term-lookup.js +284 -0
- package/dist/types/index.d.ts +105 -1
- package/dist/ui/attribute-coercion.d.ts +1 -0
- package/dist/ui/attribute-coercion.js +19 -0
- package/dist/ui/first-focusable.d.ts +22 -0
- package/dist/ui/first-focusable.js +47 -0
- package/dist/ui/focus-trap.js +7 -2
- package/dist/ui/pointer-drag.d.ts +30 -0
- package/dist/ui/pointer-drag.js +34 -0
- package/package.json +38 -6
- package/dist/i18n/loader.d.ts +0 -35
- package/dist/i18n/loader.js +0 -132
- package/dist/i18n/simple-i18n.d.ts +0 -68
- package/dist/i18n/simple-i18n.js +0 -198
- package/dist/i18n/translations/ar/common.json +0 -36
- package/dist/i18n/translations/ar/toolkit.json +0 -48
- package/dist/i18n/translations/ar/tools.json +0 -103
- package/dist/i18n/translations/en/common.json +0 -36
- package/dist/i18n/translations/en/toolkit.json +0 -48
- package/dist/i18n/translations/en/tools.json +0 -103
- package/dist/i18n/translations/es/common.json +0 -36
- package/dist/i18n/translations/es/toolkit.json +0 -48
- package/dist/i18n/translations/es/tools.json +0 -103
- package/dist/i18n/translations/zh/common.json +0 -36
- package/dist/i18n/translations/zh/toolkit.json +0 -48
- package/dist/i18n/translations/zh/tools.json +0 -103
|
@@ -0,0 +1,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
|
+
}
|
package/dist/i18n/index.d.ts
CHANGED
|
@@ -1,10 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* I18n Module
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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";
|
package/dist/i18n/index.js
CHANGED
|
@@ -1,9 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* I18n Module
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
|
|
9
|
-
|
|
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
|
+
}
|