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.
Files changed (71) hide show
  1. package/AGENTS.md +1228 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -0
  4. package/dist/plugin/index.d.ts +7 -0
  5. package/dist/plugin/index.d.ts.map +1 -0
  6. package/dist/plugin/index.js +1239 -0
  7. package/dist/plugin/index.js.map +1 -0
  8. package/package.json +77 -0
  9. package/src/archiver.d.ts +27 -0
  10. package/src/components/Accordion.svelte +32 -0
  11. package/src/components/AccordionItem.svelte +144 -0
  12. package/src/components/Audio.svelte +38 -0
  13. package/src/components/Callout.svelte +81 -0
  14. package/src/components/Carousel.svelte +194 -0
  15. package/src/components/CarouselSlide.svelte +32 -0
  16. package/src/components/DefaultLayout.svelte +108 -0
  17. package/src/components/FillInTheBlank.svelte +345 -0
  18. package/src/components/Image.svelte +47 -0
  19. package/src/components/Matching.svelte +513 -0
  20. package/src/components/MultipleChoice.svelte +363 -0
  21. package/src/components/Quiz.svelte +569 -0
  22. package/src/components/RevealModal.svelte +228 -0
  23. package/src/components/Sorting.svelte +663 -0
  24. package/src/components/Video.svelte +118 -0
  25. package/src/components/index.ts +15 -0
  26. package/src/components/quiz-payload.ts +71 -0
  27. package/src/components/util.ts +24 -0
  28. package/src/index.ts +56 -0
  29. package/src/plugin/export.ts +264 -0
  30. package/src/plugin/index.ts +464 -0
  31. package/src/plugin/layout.ts +55 -0
  32. package/src/plugin/manifest.ts +330 -0
  33. package/src/plugin/quiz.ts +65 -0
  34. package/src/plugin/validation.ts +838 -0
  35. package/src/runtime/App.svelte +435 -0
  36. package/src/runtime/ErrorPage.svelte +14 -0
  37. package/src/runtime/LoadingSkeleton.svelte +26 -0
  38. package/src/runtime/Sidebar.svelte +76 -0
  39. package/src/runtime/access.ts +55 -0
  40. package/src/runtime/adapters/cmi5.ts +341 -0
  41. package/src/runtime/adapters/discovery.ts +38 -0
  42. package/src/runtime/adapters/index.ts +99 -0
  43. package/src/runtime/adapters/retry.ts +284 -0
  44. package/src/runtime/adapters/scorm12.ts +172 -0
  45. package/src/runtime/adapters/scorm2004.ts +162 -0
  46. package/src/runtime/adapters/web.ts +62 -0
  47. package/src/runtime/contexts.ts +76 -0
  48. package/src/runtime/duration.ts +29 -0
  49. package/src/runtime/hooks.svelte.ts +543 -0
  50. package/src/runtime/interaction-format.ts +132 -0
  51. package/src/runtime/interaction.ts +96 -0
  52. package/src/runtime/navigation.svelte.ts +117 -0
  53. package/src/runtime/persistence.ts +56 -0
  54. package/src/runtime/progress.svelte.ts +168 -0
  55. package/src/runtime/quiz-policy.ts +227 -0
  56. package/src/runtime/slugify.ts +17 -0
  57. package/src/runtime/types.ts +92 -0
  58. package/src/runtime/xapi/agent-rules.ts +93 -0
  59. package/src/runtime/xapi/client.ts +133 -0
  60. package/src/runtime/xapi/derive-actor.ts +90 -0
  61. package/src/runtime/xapi/publisher.ts +604 -0
  62. package/src/runtime/xapi/registry.ts +38 -0
  63. package/src/runtime/xapi/setup.ts +250 -0
  64. package/src/runtime/xapi/types.ts +106 -0
  65. package/src/runtime/xapi/uuid.ts +21 -0
  66. package/src/runtime/xapi/validation.ts +71 -0
  67. package/src/runtime/xapi/version.ts +23 -0
  68. package/src/virtual.d.ts +16 -0
  69. package/styles/base.css +194 -0
  70. package/styles/layout.css +408 -0
  71. 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
+ }