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,341 @@
|
|
|
1
|
+
import type { PersistenceAdapter, SavedState } from '../persistence.js';
|
|
2
|
+
import type { Interaction } from '../interaction.js';
|
|
3
|
+
import { formatResponse, formatCorrectPattern } from '../interaction-format.js';
|
|
4
|
+
import { formatISO8601Duration } from './retry.js';
|
|
5
|
+
import { XAPIPublisher } from '../xapi/publisher.js';
|
|
6
|
+
import { X_API_VERSION } from '../xapi/version.js';
|
|
7
|
+
import type { XAPIAgent } from '../xapi/types.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* xAPI verb IRIs used by CMI5.
|
|
11
|
+
*/
|
|
12
|
+
const VERBS = {
|
|
13
|
+
initialized: 'http://adlnet.gov/expapi/verbs/initialized',
|
|
14
|
+
answered: 'http://adlnet.gov/expapi/verbs/answered',
|
|
15
|
+
completed: 'http://adlnet.gov/expapi/verbs/completed',
|
|
16
|
+
passed: 'http://adlnet.gov/expapi/verbs/passed',
|
|
17
|
+
failed: 'http://adlnet.gov/expapi/verbs/failed',
|
|
18
|
+
suspended: 'http://adlnet.gov/expapi/verbs/suspended',
|
|
19
|
+
terminated: 'http://adlnet.gov/expapi/verbs/terminated',
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
const CMI_INTERACTION_TYPE = 'http://adlnet.gov/expapi/activities/cmi.interaction';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* CMI5 persistence adapter using xAPI.
|
|
26
|
+
*
|
|
27
|
+
* Lifecycle statements (Initialized, Completed, Passed/Failed, Terminated)
|
|
28
|
+
* and per-interaction Answered statements all flow through a single
|
|
29
|
+
* `XAPIPublisher` configured with `cmi5Mode: true`. The publisher's
|
|
30
|
+
* sequential queue is what guarantees Terminated lands last (cmi5 §9.3.6),
|
|
31
|
+
* and `cmi5Mode` injects the required `sessionid` context extension on
|
|
32
|
+
* every statement (cmi5 §9.6.1.1).
|
|
33
|
+
*
|
|
34
|
+
* State API GET/PUT cannot be expressed as `sendStatement` calls (different
|
|
35
|
+
* URL, different verbs), so they go through `chainTask` so a state PUT is
|
|
36
|
+
* still ordered relative to neighboring statements.
|
|
37
|
+
*/
|
|
38
|
+
export class CMI5Adapter implements PersistenceAdapter {
|
|
39
|
+
#publisher: XAPIPublisher | null = null;
|
|
40
|
+
#endpoint = '';
|
|
41
|
+
#activityId = '';
|
|
42
|
+
#actor: XAPIAgent | null = null;
|
|
43
|
+
#registration: string | undefined;
|
|
44
|
+
#authToken = '';
|
|
45
|
+
|
|
46
|
+
// Stored internally for inclusion in statements
|
|
47
|
+
#score: number | null = null;
|
|
48
|
+
#durationSeconds = 0;
|
|
49
|
+
#state: SavedState | null = null;
|
|
50
|
+
#completedSent = false;
|
|
51
|
+
#completionStatus: 'incomplete' | 'complete' = 'incomplete';
|
|
52
|
+
#successSent = false;
|
|
53
|
+
#terminated = false;
|
|
54
|
+
|
|
55
|
+
async init(): Promise<void> {
|
|
56
|
+
const params = new URLSearchParams(window.location.search);
|
|
57
|
+
const fetchUrl = params.get('fetch');
|
|
58
|
+
// Normalize endpoint to always have a trailing slash so URL concatenation is safe
|
|
59
|
+
this.#endpoint = (params.get('endpoint') || '').replace(/\/?$/, '/');
|
|
60
|
+
const reg = params.get('registration') || '';
|
|
61
|
+
// xAPI requires `context.registration` to be a UUID; sending an empty
|
|
62
|
+
// string makes LRSes 400. Omit when the LMS didn't provide one.
|
|
63
|
+
this.#registration = reg ? reg : undefined;
|
|
64
|
+
this.#activityId = params.get('activityId') || '';
|
|
65
|
+
|
|
66
|
+
// Malformed actor JSON is a launch-time failure: an empty {} actor
|
|
67
|
+
// would fail every Identified-Agent check downstream and produce
|
|
68
|
+
// confusing 400s on every send. Fail loud here instead.
|
|
69
|
+
const rawActor = params.get('actor') || '';
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(rawActor);
|
|
72
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
73
|
+
throw new Error('actor must be an object');
|
|
74
|
+
}
|
|
75
|
+
this.#actor = parsed as XAPIAgent;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Tessera cmi5: launch parameter 'actor' is malformed (${err instanceof Error ? err.message : String(err)}). The LMS did not send a valid Identified Agent JSON.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The cmi5 fetch URL is single-use (§6.2): if it fails we can't retry,
|
|
83
|
+
// and continuing with no token will 401-loop until auth is marked dead.
|
|
84
|
+
// Fail loud at launch instead of dribbling errors per statement.
|
|
85
|
+
if (!fetchUrl) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
"Tessera cmi5: launch parameter 'fetch' is missing. Cannot acquire LMS auth token."
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
let resp: Response;
|
|
91
|
+
try {
|
|
92
|
+
resp = await fetch(fetchUrl, { method: 'POST' });
|
|
93
|
+
} catch (err) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Tessera cmi5: fetch token request failed (${err instanceof Error ? err.message : String(err)}). The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (!resp.ok) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Tessera cmi5: fetch token request returned ${resp.status}. The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const text = await resp.text();
|
|
104
|
+
// The fetch URL returns the token, possibly with "auth-token=" prefix
|
|
105
|
+
// (cmi5 §6.2). The credential itself is the value used as the
|
|
106
|
+
// "Basic" Authorization header — NOT a Bearer token.
|
|
107
|
+
this.#authToken = text.replace(/^auth-token=/, '').trim();
|
|
108
|
+
if (!this.#authToken) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
'Tessera cmi5: fetch token request returned an empty body. Expected an "auth-token=..." or bare token.'
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.#publisher = new XAPIPublisher({
|
|
115
|
+
endpoint: this.#endpoint,
|
|
116
|
+
auth: this.#authToken,
|
|
117
|
+
actor: this.#actor,
|
|
118
|
+
activityId: this.#activityId,
|
|
119
|
+
registration: this.#registration,
|
|
120
|
+
cmi5Mode: true,
|
|
121
|
+
});
|
|
122
|
+
await this.#publisher.init();
|
|
123
|
+
|
|
124
|
+
// Retrieve saved state from xAPI State API. The State API is a different
|
|
125
|
+
// URL than statements/, so it doesn't go through the publisher's send
|
|
126
|
+
// path — but we still use the same auth/headers.
|
|
127
|
+
try {
|
|
128
|
+
const stateUrl = this.#buildStateUrl();
|
|
129
|
+
const resp = await this.#xapiFetch(stateUrl, { method: 'GET' });
|
|
130
|
+
if (resp.ok) {
|
|
131
|
+
this.#state = await resp.json();
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
this.#state = null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Send Initialized statement (queued through publisher). Log failures
|
|
138
|
+
// here too — the publisher's per-destination outcome covers transport
|
|
139
|
+
// errors but won't surface to console; lifecycle statements are rare
|
|
140
|
+
// enough that an explicit warning helps production triage.
|
|
141
|
+
await this.#publisher
|
|
142
|
+
.sendStatement({
|
|
143
|
+
verb: { id: VERBS.initialized, display: { 'en-US': 'initialized' } },
|
|
144
|
+
})
|
|
145
|
+
.catch((err) => {
|
|
146
|
+
console.warn('Tessera cmi5: failed to send Initialized statement', err);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Returns the underlying publisher so the xAPI client can fan author-
|
|
152
|
+
* issued statements to the LMS-launched LRS via `endpoint: 'lms'`. Null
|
|
153
|
+
* when init() hasn't run yet.
|
|
154
|
+
*/
|
|
155
|
+
getPublisher(): XAPIPublisher | null {
|
|
156
|
+
return this.#publisher;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
getState(): SavedState | null {
|
|
160
|
+
return this.#state;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
saveState(state: SavedState): void {
|
|
164
|
+
this.#state = state;
|
|
165
|
+
if (!this.#publisher) return;
|
|
166
|
+
// Chain the State PUT onto the publisher's queue so it lands before
|
|
167
|
+
// Terminated. We can't use sendStatement here (different URL/verb).
|
|
168
|
+
this.#publisher.chainTask(async () => {
|
|
169
|
+
try {
|
|
170
|
+
await this.#xapiFetch(this.#buildStateUrl(), {
|
|
171
|
+
method: 'PUT',
|
|
172
|
+
headers: { 'Content-Type': 'application/json' },
|
|
173
|
+
body: JSON.stringify(state),
|
|
174
|
+
});
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.warn('Tessera: Failed to save CMI5 state', err);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
setScore(score: number): void {
|
|
182
|
+
this.#score = score;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
186
|
+
this.#completionStatus = status;
|
|
187
|
+
if (status !== 'complete' || this.#completedSent || !this.#publisher) return;
|
|
188
|
+
this.#completedSent = true;
|
|
189
|
+
const result: Record<string, unknown> = {
|
|
190
|
+
completion: true,
|
|
191
|
+
duration: formatISO8601Duration(this.#durationSeconds),
|
|
192
|
+
};
|
|
193
|
+
if (this.#score !== null) {
|
|
194
|
+
result.score = { scaled: this.#score / 100 };
|
|
195
|
+
}
|
|
196
|
+
this.#publisher
|
|
197
|
+
.sendStatement({
|
|
198
|
+
verb: { id: VERBS.completed, display: { 'en-US': 'completed' } },
|
|
199
|
+
result,
|
|
200
|
+
})
|
|
201
|
+
.catch((err) => {
|
|
202
|
+
console.warn('Tessera cmi5: failed to send Completed statement', err);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
|
|
207
|
+
if (status === 'unknown' || this.#successSent || !this.#publisher) return;
|
|
208
|
+
this.#successSent = true;
|
|
209
|
+
|
|
210
|
+
const verb = status === 'passed' ? VERBS.passed : VERBS.failed;
|
|
211
|
+
const verbName = status === 'passed' ? 'passed' : 'failed';
|
|
212
|
+
const result: Record<string, unknown> = {
|
|
213
|
+
success: status === 'passed',
|
|
214
|
+
duration: formatISO8601Duration(this.#durationSeconds),
|
|
215
|
+
};
|
|
216
|
+
if (this.#score !== null) {
|
|
217
|
+
result.score = { scaled: this.#score / 100 };
|
|
218
|
+
}
|
|
219
|
+
this.#publisher
|
|
220
|
+
.sendStatement({
|
|
221
|
+
verb: { id: verb, display: { 'en-US': verbName } },
|
|
222
|
+
result,
|
|
223
|
+
})
|
|
224
|
+
.catch((err) => {
|
|
225
|
+
console.warn(`Tessera cmi5: failed to send ${verbName} statement`, err);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
setDuration(seconds: number): void {
|
|
230
|
+
this.#durationSeconds = seconds;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
setExit(_mode: 'suspend' | 'normal'): void {
|
|
234
|
+
// cmi5 has no analogue to SCORM cmi.exit; suspend semantics are carried
|
|
235
|
+
// by *not* sending Completed/Terminated yet. No-op.
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
reportInteraction(
|
|
239
|
+
questionId: string,
|
|
240
|
+
interaction: Interaction,
|
|
241
|
+
correct: boolean | null
|
|
242
|
+
): void {
|
|
243
|
+
if (!this.#publisher) return;
|
|
244
|
+
const response = formatResponse(interaction);
|
|
245
|
+
const pattern = formatCorrectPattern(interaction);
|
|
246
|
+
const definition: Record<string, unknown> = {
|
|
247
|
+
type: CMI_INTERACTION_TYPE,
|
|
248
|
+
interactionType: interaction.type,
|
|
249
|
+
};
|
|
250
|
+
if (pattern !== null) {
|
|
251
|
+
definition.correctResponsesPattern = [pattern];
|
|
252
|
+
}
|
|
253
|
+
const result: Record<string, unknown> = { response };
|
|
254
|
+
if (correct !== null) {
|
|
255
|
+
result.success = correct;
|
|
256
|
+
}
|
|
257
|
+
this.#publisher
|
|
258
|
+
.sendStatement({
|
|
259
|
+
verb: { id: VERBS.answered, display: { 'en-US': 'answered' } },
|
|
260
|
+
object: {
|
|
261
|
+
id: `${this.#activityId}#${questionId}`,
|
|
262
|
+
objectType: 'Activity',
|
|
263
|
+
definition,
|
|
264
|
+
},
|
|
265
|
+
result,
|
|
266
|
+
})
|
|
267
|
+
.catch(() => {});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
commit(): void {
|
|
271
|
+
// No-op — xAPI calls are sent individually per statement.
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
terminate(): void {
|
|
275
|
+
if (this.#terminated) return;
|
|
276
|
+
this.#terminated = true;
|
|
277
|
+
if (!this.#publisher) return;
|
|
278
|
+
// Mark unloading so all subsequent (queued) requests use keepalive,
|
|
279
|
+
// and so the XAPIClient stops accepting new author sends — Terminated
|
|
280
|
+
// must be the last statement of the cmi5 session (§9.3.6).
|
|
281
|
+
this.#publisher.markUnloading();
|
|
282
|
+
const duration = formatISO8601Duration(this.#durationSeconds);
|
|
283
|
+
// cmi5 §10.1: when the AU exits without Completed, send Suspended
|
|
284
|
+
// first so the LMS distinguishes a deliberate pause from abandonment.
|
|
285
|
+
if (!this.#completedSent && this.#completionStatus !== 'complete') {
|
|
286
|
+
this.#publisher
|
|
287
|
+
.sendStatement({
|
|
288
|
+
verb: { id: VERBS.suspended, display: { 'en-US': 'suspended' } },
|
|
289
|
+
result: { duration },
|
|
290
|
+
})
|
|
291
|
+
.catch((err) => {
|
|
292
|
+
console.warn('Tessera cmi5: failed to send Suspended statement', err);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
// cmi5 §9.5.4.1: Terminated MUST include result.duration.
|
|
296
|
+
this.#publisher
|
|
297
|
+
.sendStatement({
|
|
298
|
+
verb: { id: VERBS.terminated, display: { 'en-US': 'terminated' } },
|
|
299
|
+
result: { duration },
|
|
300
|
+
})
|
|
301
|
+
.catch((err) => {
|
|
302
|
+
console.warn('Tessera cmi5: failed to send Terminated statement', err);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- Private helpers ----
|
|
307
|
+
|
|
308
|
+
#buildStateUrl(): string {
|
|
309
|
+
const agentJson = JSON.stringify(this.#actor);
|
|
310
|
+
const params = new URLSearchParams({
|
|
311
|
+
activityId: this.#activityId,
|
|
312
|
+
agent: agentJson,
|
|
313
|
+
stateId: 'tessera-state',
|
|
314
|
+
});
|
|
315
|
+
// registration is optional per CMI5 spec — omit it when not provided
|
|
316
|
+
if (this.#registration) {
|
|
317
|
+
params.set('registration', this.#registration);
|
|
318
|
+
}
|
|
319
|
+
return `${this.#endpoint}activities/state?${params.toString()}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async #xapiFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
|
323
|
+
const headers = new Headers(options.headers);
|
|
324
|
+
if (this.#authToken) {
|
|
325
|
+
// Basic, not Bearer — cmi5 §6.2 specifies the LMS-issued token is
|
|
326
|
+
// used as a Basic Authorization credential.
|
|
327
|
+
headers.set('Authorization', `Basic ${this.#authToken}`);
|
|
328
|
+
}
|
|
329
|
+
headers.set('X-Experience-API-Version', X_API_VERSION);
|
|
330
|
+
|
|
331
|
+
// Mirror the publisher: once the page is unloading, every State API
|
|
332
|
+
// write needs keepalive or the browser will cancel it during teardown.
|
|
333
|
+
// saveState is the suspend payload — losing it costs the resume.
|
|
334
|
+
const keepalive = this.#publisher?.isUnloading() ?? false;
|
|
335
|
+
return fetch(url, {
|
|
336
|
+
...options,
|
|
337
|
+
headers,
|
|
338
|
+
...(keepalive ? { keepalive: true } : {}),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LMS-runtime discovery helpers. Internal to the adapters layer — these
|
|
3
|
+
* decide which `PersistenceAdapter` `createAdapter()` returns for a given
|
|
4
|
+
* `export.standard`. Not part of the public package API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { findLMSAPI } from './retry.js';
|
|
8
|
+
import type { SCORM12API } from './scorm12.js';
|
|
9
|
+
import type { SCORM2004API } from './scorm2004.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Walk up window.opener and window.parent chain to find the SCORM 1.2 API.
|
|
13
|
+
* Returns null if not found within 10 levels.
|
|
14
|
+
*/
|
|
15
|
+
export function findSCORM12API(): SCORM12API | null {
|
|
16
|
+
return findLMSAPI('API') as SCORM12API | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Walk up window.opener and window.parent chain to find the SCORM 2004 API.
|
|
21
|
+
* Returns null if not found within 10 levels.
|
|
22
|
+
*/
|
|
23
|
+
export function findSCORM2004API(): SCORM2004API | null {
|
|
24
|
+
return findLMSAPI('API_1484_11') as SCORM2004API | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if CMI5 launch parameters are present in the URL.
|
|
29
|
+
*/
|
|
30
|
+
export function hasCMI5LaunchParams(): boolean {
|
|
31
|
+
const params = new URLSearchParams(window.location.search);
|
|
32
|
+
return !!(
|
|
33
|
+
params.get('fetch') &&
|
|
34
|
+
params.get('endpoint') &&
|
|
35
|
+
params.get('activityId') &&
|
|
36
|
+
params.get('actor')
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { CourseConfig } from '../types.js';
|
|
2
|
+
import type { PersistenceAdapter } from '../persistence.js';
|
|
3
|
+
import { WebAdapter } from './web.js';
|
|
4
|
+
import { SCORM12Adapter } from './scorm12.js';
|
|
5
|
+
import { SCORM2004Adapter } from './scorm2004.js';
|
|
6
|
+
import { CMI5Adapter } from './cmi5.js';
|
|
7
|
+
import {
|
|
8
|
+
findSCORM12API,
|
|
9
|
+
findSCORM2004API,
|
|
10
|
+
hasCMI5LaunchParams,
|
|
11
|
+
} from './discovery.js';
|
|
12
|
+
|
|
13
|
+
export class LMSAdapterError extends Error {
|
|
14
|
+
standard: 'scorm12' | 'scorm2004' | 'cmi5';
|
|
15
|
+
constructor(standard: 'scorm12' | 'scorm2004' | 'cmi5', message: string) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'LMSAdapterError';
|
|
18
|
+
this.standard = standard;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function missingApiError(
|
|
23
|
+
standard: 'scorm12' | 'scorm2004' | 'cmi5'
|
|
24
|
+
): LMSAdapterError {
|
|
25
|
+
const label =
|
|
26
|
+
standard === 'scorm12'
|
|
27
|
+
? 'SCORM 1.2'
|
|
28
|
+
: standard === 'scorm2004'
|
|
29
|
+
? 'SCORM 2004'
|
|
30
|
+
: 'cmi5';
|
|
31
|
+
const detail =
|
|
32
|
+
standard === 'cmi5'
|
|
33
|
+
? 'No cmi5 launch parameters (fetch / endpoint / activityId / actor) on the URL.'
|
|
34
|
+
: `No ${label} API object found in the window.parent or window.opener chain.`;
|
|
35
|
+
return new LMSAdapterError(
|
|
36
|
+
standard,
|
|
37
|
+
`Tessera: this course is configured for ${label} but ${detail} ` +
|
|
38
|
+
`The course must be launched from an LMS that provides the ${label} runtime. ` +
|
|
39
|
+
`If you are testing locally, run \`npm run preview\` instead, or set export.standard to "web".`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CreateAdapterOptions {
|
|
44
|
+
/**
|
|
45
|
+
* When true, a missing LMS API falls back to `WebAdapter` with a console
|
|
46
|
+
* warning instead of throwing. Defaults to Vite's `import.meta.env.DEV`,
|
|
47
|
+
* so dev builds stay forgiving and production builds fail loud.
|
|
48
|
+
*/
|
|
49
|
+
allowFallback?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Select the appropriate persistence adapter based on course config.
|
|
54
|
+
*
|
|
55
|
+
* In production builds, an LMS-configured course (scorm12/scorm2004/cmi5)
|
|
56
|
+
* will throw `LMSAdapterError` if the matching LMS API isn't reachable —
|
|
57
|
+
* we fail loud so a misconfigured launch is visible immediately rather
|
|
58
|
+
* than silently losing tracking to localStorage.
|
|
59
|
+
*
|
|
60
|
+
* In dev mode, missing APIs warn and fall back to `WebAdapter` so authors
|
|
61
|
+
* can still iterate locally.
|
|
62
|
+
*/
|
|
63
|
+
export function createAdapter(
|
|
64
|
+
config: CourseConfig,
|
|
65
|
+
options: CreateAdapterOptions = {}
|
|
66
|
+
): PersistenceAdapter {
|
|
67
|
+
const allowFallback =
|
|
68
|
+
options.allowFallback ?? import.meta.env?.DEV === true;
|
|
69
|
+
switch (config.export?.standard) {
|
|
70
|
+
case 'scorm12': {
|
|
71
|
+
const api = findSCORM12API();
|
|
72
|
+
if (api) return new SCORM12Adapter(api);
|
|
73
|
+
if (!allowFallback) throw missingApiError('scorm12');
|
|
74
|
+
console.warn(
|
|
75
|
+
'Tessera (dev): SCORM 1.2 API not found — falling back to localStorage'
|
|
76
|
+
);
|
|
77
|
+
return new WebAdapter(config);
|
|
78
|
+
}
|
|
79
|
+
case 'scorm2004': {
|
|
80
|
+
const api = findSCORM2004API();
|
|
81
|
+
if (api) return new SCORM2004Adapter(api);
|
|
82
|
+
if (!allowFallback) throw missingApiError('scorm2004');
|
|
83
|
+
console.warn(
|
|
84
|
+
'Tessera (dev): SCORM 2004 API not found — falling back to localStorage'
|
|
85
|
+
);
|
|
86
|
+
return new WebAdapter(config);
|
|
87
|
+
}
|
|
88
|
+
case 'cmi5': {
|
|
89
|
+
if (hasCMI5LaunchParams()) return new CMI5Adapter();
|
|
90
|
+
if (!allowFallback) throw missingApiError('cmi5');
|
|
91
|
+
console.warn(
|
|
92
|
+
'Tessera (dev): cmi5 launch parameters not found — falling back to localStorage'
|
|
93
|
+
);
|
|
94
|
+
return new WebAdapter(config);
|
|
95
|
+
}
|
|
96
|
+
default:
|
|
97
|
+
return new WebAdapter(config);
|
|
98
|
+
}
|
|
99
|
+
}
|