tessera-learn 0.0.1
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/AGENTS.md +1228 -0
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/plugin/index.d.ts +7 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +1239 -0
- package/dist/plugin/index.js.map +1 -0
- package/package.json +77 -0
- package/src/archiver.d.ts +27 -0
- package/src/components/Accordion.svelte +32 -0
- package/src/components/AccordionItem.svelte +144 -0
- package/src/components/Audio.svelte +38 -0
- package/src/components/Callout.svelte +81 -0
- package/src/components/Carousel.svelte +194 -0
- package/src/components/CarouselSlide.svelte +32 -0
- package/src/components/DefaultLayout.svelte +108 -0
- package/src/components/FillInTheBlank.svelte +345 -0
- package/src/components/Image.svelte +47 -0
- package/src/components/Matching.svelte +513 -0
- package/src/components/MultipleChoice.svelte +363 -0
- package/src/components/Quiz.svelte +569 -0
- package/src/components/RevealModal.svelte +228 -0
- package/src/components/Sorting.svelte +663 -0
- package/src/components/Video.svelte +118 -0
- package/src/components/index.ts +15 -0
- package/src/components/quiz-payload.ts +71 -0
- package/src/components/util.ts +24 -0
- package/src/index.ts +56 -0
- package/src/plugin/export.ts +264 -0
- package/src/plugin/index.ts +464 -0
- package/src/plugin/layout.ts +55 -0
- package/src/plugin/manifest.ts +330 -0
- package/src/plugin/quiz.ts +65 -0
- package/src/plugin/validation.ts +838 -0
- package/src/runtime/App.svelte +435 -0
- package/src/runtime/ErrorPage.svelte +14 -0
- package/src/runtime/LoadingSkeleton.svelte +26 -0
- package/src/runtime/Sidebar.svelte +76 -0
- package/src/runtime/access.ts +55 -0
- package/src/runtime/adapters/cmi5.ts +341 -0
- package/src/runtime/adapters/discovery.ts +38 -0
- package/src/runtime/adapters/index.ts +99 -0
- package/src/runtime/adapters/retry.ts +284 -0
- package/src/runtime/adapters/scorm12.ts +172 -0
- package/src/runtime/adapters/scorm2004.ts +162 -0
- package/src/runtime/adapters/web.ts +62 -0
- package/src/runtime/contexts.ts +76 -0
- package/src/runtime/duration.ts +29 -0
- package/src/runtime/hooks.svelte.ts +543 -0
- package/src/runtime/interaction-format.ts +132 -0
- package/src/runtime/interaction.ts +96 -0
- package/src/runtime/navigation.svelte.ts +117 -0
- package/src/runtime/persistence.ts +56 -0
- package/src/runtime/progress.svelte.ts +168 -0
- package/src/runtime/quiz-policy.ts +227 -0
- package/src/runtime/slugify.ts +17 -0
- package/src/runtime/types.ts +92 -0
- package/src/runtime/xapi/agent-rules.ts +93 -0
- package/src/runtime/xapi/client.ts +133 -0
- package/src/runtime/xapi/derive-actor.ts +90 -0
- package/src/runtime/xapi/publisher.ts +604 -0
- package/src/runtime/xapi/registry.ts +38 -0
- package/src/runtime/xapi/setup.ts +250 -0
- package/src/runtime/xapi/types.ts +106 -0
- package/src/runtime/xapi/uuid.ts +21 -0
- package/src/runtime/xapi/validation.ts +71 -0
- package/src/runtime/xapi/version.ts +23 -0
- package/src/virtual.d.ts +16 -0
- package/styles/base.css +194 -0
- package/styles/layout.css +408 -0
- package/styles/theme.css +36 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiz config desugaring. Authors drive feedback / retry / submit-gating /
|
|
3
|
+
* scoring with either string enums or predicate functions; this module
|
|
4
|
+
* normalizes both forms into predicates so `useQuiz` only ever interacts
|
|
5
|
+
* with the predicate API.
|
|
6
|
+
*/
|
|
7
|
+
import type { Interaction } from './interaction.js';
|
|
8
|
+
|
|
9
|
+
export interface QuizQuestionResult {
|
|
10
|
+
/** The original interaction reported for the question. */
|
|
11
|
+
interaction: Interaction;
|
|
12
|
+
/** Whether this question's response was correct. */
|
|
13
|
+
correct: boolean;
|
|
14
|
+
/** Per-question weight from `useQuestion({ weight })`. Defaults to 1. */
|
|
15
|
+
weight: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* State the feedback predicate is given so it can decide independently of
|
|
20
|
+
* the string-enum branches inside `useQuiz`. The predicate is the single
|
|
21
|
+
* source of truth — the enums (`'immediate'` / `'review'`) desugar into
|
|
22
|
+
* predicates over this same state.
|
|
23
|
+
*/
|
|
24
|
+
export interface FeedbackVisibilityState {
|
|
25
|
+
/** Index of the question being asked about. */
|
|
26
|
+
questionIndex: number;
|
|
27
|
+
/** Has `submit()` already fired for the current attempt? */
|
|
28
|
+
submitted: boolean;
|
|
29
|
+
/** Is the quiz currently in review mode? */
|
|
30
|
+
reviewing: boolean;
|
|
31
|
+
/** Has the question been answered (the shell called `setAnswer`)? */
|
|
32
|
+
hasAnswered: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Has the shell explicitly revealed feedback for this question via
|
|
35
|
+
* `revealFeedback(index)`? Lets `'immediate'` flows distinguish "answered
|
|
36
|
+
* but not yet revealed" from "Check Answer button pressed."
|
|
37
|
+
*/
|
|
38
|
+
revealed: boolean;
|
|
39
|
+
/** Number of times `submit()` has fired for this quiz instance. */
|
|
40
|
+
attemptCount: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type FeedbackModePredicate = (state: FeedbackVisibilityState) => boolean;
|
|
44
|
+
export type RetryStrategyPredicate = (results: QuizQuestionResult[]) => Set<number>;
|
|
45
|
+
export type CanSubmitPredicate = (answeredCount: number, totalCount: number) => boolean;
|
|
46
|
+
export type ScorePredicate = (results: QuizQuestionResult[]) => number;
|
|
47
|
+
|
|
48
|
+
export interface QuizPolicyConfig {
|
|
49
|
+
/**
|
|
50
|
+
* Show feedback after each answer (`'immediate'`), only on the review screen
|
|
51
|
+
* (`'review'`), or via a custom predicate `(state) => boolean` returning
|
|
52
|
+
* whether feedback should currently be visible. Predicates receive a full
|
|
53
|
+
* `FeedbackVisibilityState` so they can decide independently of the enum
|
|
54
|
+
* branches — the enums themselves desugar to predicates over the same state.
|
|
55
|
+
*/
|
|
56
|
+
feedbackMode?: 'immediate' | 'review' | FeedbackModePredicate;
|
|
57
|
+
/**
|
|
58
|
+
* On retry, clear every answer (`'full'`), preserve correct answers
|
|
59
|
+
* (`'incorrect-only'`), or pass a custom predicate that takes the previous
|
|
60
|
+
* attempt's results and returns the set of question indices to keep locked.
|
|
61
|
+
*/
|
|
62
|
+
retryMode?: 'full' | 'incorrect-only' | RetryStrategyPredicate;
|
|
63
|
+
/**
|
|
64
|
+
* Custom gate for the Submit button. Defaults to "every registered
|
|
65
|
+
* question has an answer". Predicates take (answered, total).
|
|
66
|
+
*/
|
|
67
|
+
canSubmit?: CanSubmitPredicate;
|
|
68
|
+
/**
|
|
69
|
+
* Custom score formula. Defaults to weighted-correct percentage —
|
|
70
|
+
* `Σ(weight × correct) / Σ(weight) × 100`. Authors must return a value in
|
|
71
|
+
* 0–100; values outside that range warn in dev mode.
|
|
72
|
+
*/
|
|
73
|
+
score?: ScorePredicate;
|
|
74
|
+
/**
|
|
75
|
+
* If false, feedback never renders even when `feedbackMode` says it should.
|
|
76
|
+
* Mirrors the historical `showFeedback` flag.
|
|
77
|
+
*/
|
|
78
|
+
showFeedback?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the configured feedback policy into a single predicate that owns
|
|
83
|
+
* the "should this question's feedback be visible right now?" decision.
|
|
84
|
+
*
|
|
85
|
+
* The shipping enums desugar to:
|
|
86
|
+
* - `'immediate'` — visible after the shell calls `revealFeedback` for the
|
|
87
|
+
* question, OR while the quiz is in review mode.
|
|
88
|
+
* - `'review'` (default) — visible only while the quiz is in review mode.
|
|
89
|
+
*
|
|
90
|
+
* Predicates receive the full visibility state so they can encode any policy
|
|
91
|
+
* — e.g. "only after first wrong attempt": `(s) => s.attemptCount > 0 && s.submitted`.
|
|
92
|
+
*
|
|
93
|
+
* The `showFeedback: false` global gate is applied separately by `useQuiz`
|
|
94
|
+
* before this predicate runs.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveFeedbackMode(cfg: QuizPolicyConfig | undefined | null): FeedbackModePredicate {
|
|
97
|
+
const mode = cfg?.feedbackMode;
|
|
98
|
+
if (typeof mode === 'function') return mode;
|
|
99
|
+
if (mode === 'immediate') {
|
|
100
|
+
return (s) => s.revealed || s.reviewing;
|
|
101
|
+
}
|
|
102
|
+
// Default + 'review'
|
|
103
|
+
return (s) => s.reviewing;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isDevMode(): boolean {
|
|
107
|
+
return import.meta.env?.DEV === true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the configured retry strategy into a predicate that returns the
|
|
112
|
+
* set of question indices to lock as "already correct" on the next attempt.
|
|
113
|
+
*
|
|
114
|
+
* - `'full'` (default) — reset everything.
|
|
115
|
+
* - `'incorrect-only'` — keep questions the learner got right.
|
|
116
|
+
* - function — author decides per result.
|
|
117
|
+
*
|
|
118
|
+
* Author predicates are wrapped: a non-Set return turns into "lock nothing"
|
|
119
|
+
* in production and throws in dev so the bug stays local. An author returning
|
|
120
|
+
* `[0, 1]` instead of `new Set([0, 1])` would otherwise silently no-op the
|
|
121
|
+
* lock and quietly break `'incorrect-only'`-style retries.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveRetryStrategy(cfg: QuizPolicyConfig | undefined | null): RetryStrategyPredicate {
|
|
124
|
+
const mode = cfg?.retryMode;
|
|
125
|
+
if (typeof mode === 'function') {
|
|
126
|
+
return (results) => {
|
|
127
|
+
const raw = mode(results);
|
|
128
|
+
if (!(raw instanceof Set)) {
|
|
129
|
+
if (isDevMode()) {
|
|
130
|
+
throw new TypeError(
|
|
131
|
+
`[tessera] quiz retryMode predicate returned ${Object.prototype.toString.call(raw)}; ` +
|
|
132
|
+
`expected a Set<number> of question indices to lock.`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return new Set<number>();
|
|
136
|
+
}
|
|
137
|
+
return raw;
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (mode === 'incorrect-only') {
|
|
141
|
+
return (results) => {
|
|
142
|
+
const locked = new Set<number>();
|
|
143
|
+
results.forEach((r, i) => {
|
|
144
|
+
if (r.correct) locked.add(i);
|
|
145
|
+
});
|
|
146
|
+
return locked;
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// Default 'full': clear every answer.
|
|
150
|
+
return () => new Set<number>();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve the Submit gate. Default — all answered.
|
|
155
|
+
*
|
|
156
|
+
* Author predicates are wrapped: a non-boolean return is coerced with `!!` in
|
|
157
|
+
* production and throws in dev. Authors returning `answered` (a number) would
|
|
158
|
+
* otherwise enable Submit on `0` answered ↔ disable on a count that happens
|
|
159
|
+
* to equal `NaN` — silently wrong gates either way.
|
|
160
|
+
*/
|
|
161
|
+
export function resolveCanSubmit(cfg: QuizPolicyConfig | undefined | null): CanSubmitPredicate {
|
|
162
|
+
if (typeof cfg?.canSubmit === 'function') {
|
|
163
|
+
const fn = cfg.canSubmit;
|
|
164
|
+
return (answered, total) => {
|
|
165
|
+
const raw = fn(answered, total);
|
|
166
|
+
if (typeof raw !== 'boolean') {
|
|
167
|
+
if (isDevMode()) {
|
|
168
|
+
throw new TypeError(
|
|
169
|
+
`[tessera] quiz canSubmit predicate returned ${typeof raw}; expected a boolean.`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return !!raw;
|
|
173
|
+
}
|
|
174
|
+
return raw;
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return (answered, total) => total > 0 && answered >= total;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve the score formula. Default — weighted-correct percentage. With all
|
|
182
|
+
* weights = 1 (the default for every existing course), the output equals the
|
|
183
|
+
* pre-Phase-5 unweighted formula.
|
|
184
|
+
*/
|
|
185
|
+
export function resolveScore(cfg: QuizPolicyConfig | undefined | null): ScorePredicate {
|
|
186
|
+
if (typeof cfg?.score === 'function') {
|
|
187
|
+
return (results) => {
|
|
188
|
+
const raw = cfg.score!(results);
|
|
189
|
+
const isDev = isDevMode();
|
|
190
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
|
191
|
+
// NaN/Infinity/non-number can't ride through to setScore(...) — the LMS
|
|
192
|
+
// either rejects the cmi write or rolls it up to nonsense. Throw in dev
|
|
193
|
+
// so the bug stays local; clamp to 0 in prod so a runaway predicate
|
|
194
|
+
// can't crash the learner's session.
|
|
195
|
+
if (isDev) {
|
|
196
|
+
throw new TypeError(
|
|
197
|
+
`[tessera] quiz score predicate returned ${String(raw)}; expected a finite number in 0–100.`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
if (raw < 0 || raw > 100) {
|
|
203
|
+
if (isDev) {
|
|
204
|
+
// eslint-disable-next-line no-console
|
|
205
|
+
console.warn(
|
|
206
|
+
`[tessera] quiz score predicate returned ${raw}; expected a finite number in 0–100. ` +
|
|
207
|
+
`Clamping to range — LMSes reject out-of-range cmi.score.raw values.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return Math.max(0, Math.min(100, raw));
|
|
211
|
+
}
|
|
212
|
+
return raw;
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return (results) => {
|
|
216
|
+
if (results.length === 0) return 0;
|
|
217
|
+
let weighted = 0;
|
|
218
|
+
let totalWeight = 0;
|
|
219
|
+
for (const r of results) {
|
|
220
|
+
const w = r.weight > 0 ? r.weight : 1;
|
|
221
|
+
totalWeight += w;
|
|
222
|
+
if (r.correct) weighted += w;
|
|
223
|
+
}
|
|
224
|
+
if (totalWeight === 0) return 0;
|
|
225
|
+
return Math.round((weighted / totalWeight) * 100);
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slugify a string for use as a URL-safe / filename-safe identifier.
|
|
3
|
+
* "My Course Title" → "my-course-title"
|
|
4
|
+
*
|
|
5
|
+
* Shared by the runtime (`WebAdapter` localStorage key) and the build-time
|
|
6
|
+
* exporter (`runExport` zip filename). Both want identical, deterministic
|
|
7
|
+
* output so a course's storage key matches its package name.
|
|
8
|
+
*/
|
|
9
|
+
export function slugify(text: string): string {
|
|
10
|
+
return text
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/[^\w\s-]/g, '')
|
|
14
|
+
.replace(/[\s_]+/g, '-')
|
|
15
|
+
.replace(/-+/g, '-')
|
|
16
|
+
.replace(/^-|-$/g, '');
|
|
17
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { AccessFn } from './access.js';
|
|
2
|
+
import type { XAPIAgent } from './xapi/types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-page quiz configuration. Single source of truth — the build plugin
|
|
6
|
+
* extracts this from `pageConfig.quiz` and embeds it in the manifest;
|
|
7
|
+
* the runtime reads it from there. Keep field shapes in sync.
|
|
8
|
+
*/
|
|
9
|
+
export interface QuizConfig {
|
|
10
|
+
graded?: boolean;
|
|
11
|
+
gatesProgress?: boolean;
|
|
12
|
+
maxAttempts?: number;
|
|
13
|
+
showFeedback?: boolean;
|
|
14
|
+
feedbackMode?: 'review' | 'immediate';
|
|
15
|
+
retryMode?: 'full' | 'incorrect-only';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CourseConfig {
|
|
19
|
+
title: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
author?: string;
|
|
22
|
+
version?: string;
|
|
23
|
+
branding?: {
|
|
24
|
+
logo?: string;
|
|
25
|
+
primaryColor?: string;
|
|
26
|
+
fontFamily?: string;
|
|
27
|
+
};
|
|
28
|
+
navigation: {
|
|
29
|
+
mode: 'free' | 'sequential';
|
|
30
|
+
canAccess?: AccessFn;
|
|
31
|
+
};
|
|
32
|
+
completion: {
|
|
33
|
+
mode: 'quiz' | 'percentage';
|
|
34
|
+
percentageThreshold?: number;
|
|
35
|
+
};
|
|
36
|
+
scoring: {
|
|
37
|
+
passingScore: number;
|
|
38
|
+
};
|
|
39
|
+
export: {
|
|
40
|
+
standard: 'web' | 'scorm12' | 'scorm2004' | 'cmi5';
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Optional xAPI destination(s) for custom statement publishing via
|
|
44
|
+
* `useXAPI()`. A single object or an array of destinations. Under cmi5
|
|
45
|
+
* export, the sentinel `endpoint: 'lms'` re-uses the LMS launch's
|
|
46
|
+
* credentials and shares the cmi5 adapter's queue.
|
|
47
|
+
*/
|
|
48
|
+
xapi?: XAPIConfig | XAPIConfig[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* cmi5 launch-inherited destination. Only valid under `export.standard:
|
|
53
|
+
* 'cmi5'`. Auth, actor, activityId, and registration are taken from the
|
|
54
|
+
* launch URL, so no other fields are accepted.
|
|
55
|
+
*/
|
|
56
|
+
export interface XAPILMSConfig {
|
|
57
|
+
endpoint: 'lms';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Explicit LRS destination. The author provides every field. `actor` is
|
|
62
|
+
* optional under SCORM (synthesized from `cmi.core.student_id` /
|
|
63
|
+
* `cmi.learner_id`) and required under web.
|
|
64
|
+
*/
|
|
65
|
+
export interface XAPIExplicitConfig {
|
|
66
|
+
/** Absolute http(s) URL of the LRS Statements endpoint base. */
|
|
67
|
+
endpoint: string;
|
|
68
|
+
/**
|
|
69
|
+
* Basic-auth credential value (the part after "Basic "), or a function
|
|
70
|
+
* that resolves one. Function form is re-invoked once on 401 to cover
|
|
71
|
+
* short-lived tokens.
|
|
72
|
+
*/
|
|
73
|
+
auth: string | (() => string | Promise<string>);
|
|
74
|
+
/**
|
|
75
|
+
* Identified Agent or a resolver function. Required for web export;
|
|
76
|
+
* optional under SCORM where it can be synthesized from the LMS data
|
|
77
|
+
* model. Optional under cmi5 where it can be inherited from the launch.
|
|
78
|
+
*/
|
|
79
|
+
actor?: XAPIAgent | (() => XAPIAgent | Promise<XAPIAgent>);
|
|
80
|
+
/** xAPI activity IRI scoped to this destination. */
|
|
81
|
+
activityId: string;
|
|
82
|
+
/** Optional UUID v4 — primarily a cmi5 launch concept. */
|
|
83
|
+
registration?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Override for the SCORM-derived actor's `account.homePage`. Defaults
|
|
86
|
+
* to the activityId origin when activityId is http(s); required when
|
|
87
|
+
* activityId uses a non-http(s) scheme.
|
|
88
|
+
*/
|
|
89
|
+
actorAccountHomePage?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type XAPIConfig = XAPILMSConfig | XAPIExplicitConfig;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xAPI Identified Agent and Basic-auth credential validation rules.
|
|
3
|
+
*
|
|
4
|
+
* Pure logic — no Svelte/runtime imports. Imported by both `publisher.ts`
|
|
5
|
+
* (runtime validation of resolved actor / auth) and `plugin/validation.ts`
|
|
6
|
+
* (build-time validation of static `course.config.js` actor / auth).
|
|
7
|
+
* Keeping the rules in one place prevents the two callsites from drifting.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validate that a candidate is an Identified Agent per xAPI 1.0.3.
|
|
12
|
+
* Returns null on success or a human-readable error suffix on failure.
|
|
13
|
+
*
|
|
14
|
+
* Suffixes are prefix-friendly: callers concatenate their own label
|
|
15
|
+
* (`xapi.actor`, `xapi[0].actor`, etc.) with a single space — no "actor"
|
|
16
|
+
* appears in the suffix to avoid doubling.
|
|
17
|
+
*/
|
|
18
|
+
export function validateAgent(actor: unknown): string | null {
|
|
19
|
+
if (!actor || typeof actor !== 'object') {
|
|
20
|
+
return 'must be an object';
|
|
21
|
+
}
|
|
22
|
+
const a = actor as Record<string, unknown>;
|
|
23
|
+
if (Array.isArray(a.member) && a.member.length > 0) {
|
|
24
|
+
return 'is a Group (has `member`); v1 supports Identified Agents only';
|
|
25
|
+
}
|
|
26
|
+
let count = 0;
|
|
27
|
+
if (a.mbox !== undefined) count++;
|
|
28
|
+
if (a.mbox_sha1sum !== undefined) count++;
|
|
29
|
+
if (a.openid !== undefined) count++;
|
|
30
|
+
if (a.account !== undefined) count++;
|
|
31
|
+
if (count === 0) {
|
|
32
|
+
return 'must have one of mbox, mbox_sha1sum, openid, or account (Identified Agent rule)';
|
|
33
|
+
}
|
|
34
|
+
if (count > 1) {
|
|
35
|
+
return 'must have exactly one IFI (mbox / mbox_sha1sum / openid / account), not multiple';
|
|
36
|
+
}
|
|
37
|
+
if (a.mbox !== undefined) {
|
|
38
|
+
if (typeof a.mbox !== 'string' || !a.mbox.startsWith('mailto:')) {
|
|
39
|
+
return '.mbox must be a string starting with "mailto:"';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (a.mbox_sha1sum !== undefined) {
|
|
43
|
+
if (typeof a.mbox_sha1sum !== 'string' || !/^[0-9a-f]{40}$/i.test(a.mbox_sha1sum)) {
|
|
44
|
+
return '.mbox_sha1sum must be a 40-character hex string';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (a.openid !== undefined) {
|
|
48
|
+
if (typeof a.openid !== 'string' || !a.openid) {
|
|
49
|
+
return '.openid must be a non-empty string';
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
new URL(a.openid);
|
|
53
|
+
} catch {
|
|
54
|
+
return '.openid must be an absolute URI';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (a.account !== undefined) {
|
|
58
|
+
const acc = a.account as Record<string, unknown>;
|
|
59
|
+
if (!acc || typeof acc !== 'object') {
|
|
60
|
+
return '.account must be an object with homePage and name';
|
|
61
|
+
}
|
|
62
|
+
if (typeof acc.homePage !== 'string' || !acc.homePage) {
|
|
63
|
+
return '.account.homePage must be a non-empty string';
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
new URL(acc.homePage);
|
|
67
|
+
} catch {
|
|
68
|
+
return '.account.homePage must be an absolute URL';
|
|
69
|
+
}
|
|
70
|
+
if (typeof acc.name !== 'string' || !acc.name) {
|
|
71
|
+
return '.account.name must be a non-empty string';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate a Basic-auth credential string (the value after "Basic ").
|
|
79
|
+
* v1 supports Basic only. Bearer is a hard error so OAuth users see the
|
|
80
|
+
* non-goal explicitly.
|
|
81
|
+
*/
|
|
82
|
+
export function validateAuthCredential(auth: string): string | null {
|
|
83
|
+
if (typeof auth !== 'string' || !auth) {
|
|
84
|
+
return 'auth must be a non-empty string';
|
|
85
|
+
}
|
|
86
|
+
if (/^basic\s/i.test(auth)) {
|
|
87
|
+
return "auth must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.";
|
|
88
|
+
}
|
|
89
|
+
if (/^bearer\s/i.test(auth)) {
|
|
90
|
+
return 'Bearer/OAuth credentials are not supported in v1. Use Basic auth, or wrap your token-exchange in an auth function that returns a Basic credential.';
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PartialStatement,
|
|
3
|
+
SendStatementOptions,
|
|
4
|
+
SendStatementResult,
|
|
5
|
+
XAPIAgent,
|
|
6
|
+
Statement,
|
|
7
|
+
DestinationOutcome,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
import { XAPIPublisher, XAPIConfigError } from './publisher.js';
|
|
10
|
+
import { validatePartialStatement } from './validation.js';
|
|
11
|
+
import { uuidv4 } from './uuid.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What `useXAPI()` returns. Wraps one or more `XAPIPublisher` destinations
|
|
15
|
+
* and presents a single `sendStatement` API to authors. The single-
|
|
16
|
+
* destination form (`xapi: { ... }`) and the multi-destination form
|
|
17
|
+
* (`xapi: [{...}, {...}]`) flow through the same machinery — single is
|
|
18
|
+
* just a one-element array.
|
|
19
|
+
*
|
|
20
|
+
* Each destination has its own queue, auth resolver, and retry loop;
|
|
21
|
+
* failures are isolated. One UUID is minted per `sendStatement` call and
|
|
22
|
+
* reused across destinations so analytics keyed on `statement.id` see
|
|
23
|
+
* identical statements regardless of which LRS they hit first.
|
|
24
|
+
*/
|
|
25
|
+
export class XAPIClient {
|
|
26
|
+
readonly #publishers: XAPIPublisher[];
|
|
27
|
+
|
|
28
|
+
constructor(publishers: XAPIPublisher[]) {
|
|
29
|
+
if (publishers.length === 0) {
|
|
30
|
+
throw new Error('XAPIClient: at least one publisher is required');
|
|
31
|
+
}
|
|
32
|
+
this.#publishers = publishers;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Send a statement to every configured destination. The returned
|
|
37
|
+
* promise resolves once all destinations have settled (success or
|
|
38
|
+
* final failure). Per-destination outcomes are exposed on
|
|
39
|
+
* `result.destinations` so authors can act on partial failures.
|
|
40
|
+
*
|
|
41
|
+
* Validation failures throw synchronously — the returned promise
|
|
42
|
+
* rejects before any HTTP traffic.
|
|
43
|
+
*/
|
|
44
|
+
sendStatement(
|
|
45
|
+
partial: PartialStatement,
|
|
46
|
+
options?: SendStatementOptions
|
|
47
|
+
): Promise<SendStatementResult> {
|
|
48
|
+
try {
|
|
49
|
+
validatePartialStatement(partial);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return Promise.reject(err);
|
|
52
|
+
}
|
|
53
|
+
// cmi5 §9.3.6 — Terminated must be the last statement of the session.
|
|
54
|
+
// The constraint is per-destination: only cmi5-mode publishers (the
|
|
55
|
+
// shared-queue cmi5 adapter case) need to block author sends during
|
|
56
|
+
// unload. Independent explicit-LRS destinations have no such ordering
|
|
57
|
+
// requirement and stay healthy until the browser tears them down.
|
|
58
|
+
const blocked = (p: XAPIPublisher) => p.isUnloading() && p.isCmi5Mode();
|
|
59
|
+
if (this.#publishers.every(blocked)) {
|
|
60
|
+
return Promise.reject(
|
|
61
|
+
new XAPIConfigError(
|
|
62
|
+
'XAPIClient.sendStatement: page is unloading; author statements queued during unload are dropped to keep Terminated last (cmi5 §9.3.6).'
|
|
63
|
+
)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const id = uuidv4();
|
|
67
|
+
// The first publisher's built statement is what we return as the
|
|
68
|
+
// canonical `statement` in the result. Other destinations may have
|
|
69
|
+
// a different actor/grouping but the verb/object/result/timestamp
|
|
70
|
+
// are author-supplied and identical.
|
|
71
|
+
let primary: Statement | null = null;
|
|
72
|
+
const destinationPromises: Promise<DestinationOutcome>[] = [];
|
|
73
|
+
for (let i = 0; i < this.#publishers.length; i++) {
|
|
74
|
+
const pub = this.#publishers[i];
|
|
75
|
+
const built = pub.buildStatement(partial, { id });
|
|
76
|
+
if (i === 0) primary = built;
|
|
77
|
+
if (blocked(pub)) {
|
|
78
|
+
destinationPromises.push(
|
|
79
|
+
Promise.resolve<DestinationOutcome>({
|
|
80
|
+
endpoint: pub.getEndpoint(),
|
|
81
|
+
ok: false,
|
|
82
|
+
error: new XAPIConfigError(
|
|
83
|
+
'destination skipped: cmi5 publisher is unloading; statement dropped to keep Terminated last (cmi5 §9.3.6).'
|
|
84
|
+
),
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
destinationPromises.push(pub.enqueueBuilt(built, options));
|
|
90
|
+
}
|
|
91
|
+
return Promise.all(destinationPromises).then((destinations) => ({
|
|
92
|
+
statementId: id,
|
|
93
|
+
statement: primary as Statement,
|
|
94
|
+
destinations,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Returns the actor of the first destination. For analytics object-id
|
|
100
|
+
* construction (`${xapi.getActivityId()}#widget-1`) this is what
|
|
101
|
+
* authors typically want.
|
|
102
|
+
*/
|
|
103
|
+
getActor(): XAPIAgent {
|
|
104
|
+
return this.#publishers[0].getActor();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Returns the activityId of the first destination. */
|
|
108
|
+
getActivityId(): string {
|
|
109
|
+
return this.#publishers[0].getActivityId();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Returns the sessionId of the first destination. */
|
|
113
|
+
getSessionId(): string {
|
|
114
|
+
return this.#publishers[0].getSessionId();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Returns the underlying publishers — mostly useful for tests. */
|
|
118
|
+
getPublishers(): readonly XAPIPublisher[] {
|
|
119
|
+
return this.#publishers;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Propagate "page is unloading" to every publisher. App.svelte's
|
|
124
|
+
* pagehide / beforeunload handler calls this before
|
|
125
|
+
* `adapter.terminate()` so independent (explicit-endpoint) publishers
|
|
126
|
+
* also stop accepting author sends during the close path. Idempotent;
|
|
127
|
+
* the cmi5 adapter calls `markUnloading()` on its own publisher
|
|
128
|
+
* separately and either order is fine.
|
|
129
|
+
*/
|
|
130
|
+
markUnloading(): void {
|
|
131
|
+
for (const p of this.#publishers) p.markUnloading();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { XAPIAgent } from './types.js';
|
|
2
|
+
import type { SCORM12API } from '../adapters/scorm12.js';
|
|
3
|
+
import type { SCORM2004API } from '../adapters/scorm2004.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Compute the default SCORM-derived `account.homePage` from the activity
|
|
7
|
+
* IRI. Returns the URL origin when `activityId` is an http(s) URL,
|
|
8
|
+
* otherwise null. Callers that get null and have no `actorAccountHomePage`
|
|
9
|
+
* override should treat it as a config error (the build-time validator
|
|
10
|
+
* already enforces this; this is a runtime fallback for completeness).
|
|
11
|
+
*/
|
|
12
|
+
export function defaultAccountHomePage(activityId: string): string | null {
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(activityId);
|
|
15
|
+
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
|
16
|
+
return url.origin;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Synthesize an Identified Agent for SCORM 1.2 from the LMS data model.
|
|
26
|
+
*
|
|
27
|
+
* { account: { homePage, name: cmi.core.student_id },
|
|
28
|
+
* name: cmi.core.student_name,
|
|
29
|
+
* objectType: 'Agent' }
|
|
30
|
+
*
|
|
31
|
+
* The `account` IFI satisfies xAPI's Identified Agent rule. `homePage`
|
|
32
|
+
* defaults to the activityId origin so analytics keyed on actor identity
|
|
33
|
+
* stay stable across LMS hosts; the author's `actorAccountHomePage`
|
|
34
|
+
* overrides when the authority namespace is elsewhere.
|
|
35
|
+
*
|
|
36
|
+
* Returns null if `student_id` is missing — caller should not construct
|
|
37
|
+
* a publisher in that case (the LRS would 400 on every send anyway).
|
|
38
|
+
*/
|
|
39
|
+
export function synthesizeSCORM12Actor(
|
|
40
|
+
api: SCORM12API,
|
|
41
|
+
activityId: string,
|
|
42
|
+
actorAccountHomePage?: string
|
|
43
|
+
): XAPIAgent | null {
|
|
44
|
+
let id = '';
|
|
45
|
+
let name = '';
|
|
46
|
+
try {
|
|
47
|
+
id = api.LMSGetValue('cmi.core.student_id') || '';
|
|
48
|
+
} catch {}
|
|
49
|
+
try {
|
|
50
|
+
name = api.LMSGetValue('cmi.core.student_name') || '';
|
|
51
|
+
} catch {}
|
|
52
|
+
if (!id) return null;
|
|
53
|
+
const homePage = actorAccountHomePage ?? defaultAccountHomePage(activityId);
|
|
54
|
+
if (!homePage) return null;
|
|
55
|
+
const agent: XAPIAgent = {
|
|
56
|
+
account: { homePage, name: id },
|
|
57
|
+
objectType: 'Agent',
|
|
58
|
+
};
|
|
59
|
+
if (name) agent.name = name;
|
|
60
|
+
return agent;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Synthesize an Identified Agent for SCORM 2004 from the LMS data model.
|
|
65
|
+
* Same structure as SCORM 1.2 but reads from `cmi.learner_id` /
|
|
66
|
+
* `cmi.learner_name` (the renamed 2004 fields).
|
|
67
|
+
*/
|
|
68
|
+
export function synthesizeSCORM2004Actor(
|
|
69
|
+
api: SCORM2004API,
|
|
70
|
+
activityId: string,
|
|
71
|
+
actorAccountHomePage?: string
|
|
72
|
+
): XAPIAgent | null {
|
|
73
|
+
let id = '';
|
|
74
|
+
let name = '';
|
|
75
|
+
try {
|
|
76
|
+
id = api.GetValue('cmi.learner_id') || '';
|
|
77
|
+
} catch {}
|
|
78
|
+
try {
|
|
79
|
+
name = api.GetValue('cmi.learner_name') || '';
|
|
80
|
+
} catch {}
|
|
81
|
+
if (!id) return null;
|
|
82
|
+
const homePage = actorAccountHomePage ?? defaultAccountHomePage(activityId);
|
|
83
|
+
if (!homePage) return null;
|
|
84
|
+
const agent: XAPIAgent = {
|
|
85
|
+
account: { homePage, name: id },
|
|
86
|
+
objectType: 'Agent',
|
|
87
|
+
};
|
|
88
|
+
if (name) agent.name = name;
|
|
89
|
+
return agent;
|
|
90
|
+
}
|