tessera-learn 0.3.0 → 0.4.0
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 +17 -12
- package/README.md +1 -1
- package/dist/{audit-DkXqQTqn.js → audit-DsYqXbqm.js} +211 -183
- package/dist/audit-DsYqXbqm.js.map +1 -0
- package/dist/{build-commands-CyzuCDXg.js → build-commands-BFuiAxaR.js} +4 -4
- package/dist/build-commands-BFuiAxaR.js.map +1 -0
- package/dist/{inline-config-BEXyRqsJ.js → inline-config-DVvOCKht.js} +6 -6
- package/dist/inline-config-DVvOCKht.js.map +1 -0
- package/dist/plugin/cli.d.ts +5 -1
- package/dist/plugin/cli.d.ts.map +1 -1
- package/dist/plugin/cli.js +38 -7
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +7 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +2 -2
- package/dist/{plugin-CFUFgwHB.js → plugin-BuMiDTmU.js} +29 -38
- package/dist/plugin-BuMiDTmU.js.map +1 -0
- package/package.json +1 -1
- package/src/components/MultipleChoice.svelte +1 -2
- package/src/plugin/build-commands.ts +7 -4
- package/src/plugin/cli.ts +54 -3
- package/src/plugin/index.ts +31 -42
- package/src/plugin/inline-config.ts +4 -2
- package/src/plugin/manifest.ts +21 -0
- package/src/plugin/validate-cli.ts +5 -2
- package/src/plugin/validation.ts +214 -233
- package/src/runtime/App.svelte +4 -1
- package/src/runtime/adapters/scorm-base.ts +15 -14
- package/src/runtime/adapters/scorm12.ts +6 -25
- package/src/runtime/adapters/scorm2004.ts +12 -55
- package/src/runtime/adapters/web.ts +5 -13
- package/src/runtime/fingerprint.ts +28 -0
- package/src/runtime/interaction-format.ts +0 -1
- package/src/runtime/persistence.ts +4 -0
- package/src/runtime/types.ts +3 -0
- package/src/runtime/xapi/publisher.ts +11 -14
- package/dist/audit-DkXqQTqn.js.map +0 -1
- package/dist/build-commands-CyzuCDXg.js.map +0 -1
- package/dist/inline-config-BEXyRqsJ.js.map +0 -1
- package/dist/plugin-CFUFgwHB.js.map +0 -1
package/src/runtime/App.svelte
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { applyBranding } from './branding.js';
|
|
15
15
|
import { DurationTracker } from './duration.js';
|
|
16
16
|
import { createAdapter } from 'virtual:tessera-adapter';
|
|
17
|
+
import { structureFingerprint, shouldRestore } from './fingerprint.js';
|
|
17
18
|
import { buildXAPIClient } from 'virtual:tessera-xapi-setup';
|
|
18
19
|
import { registerXAPIClient } from './xapi/registry.js';
|
|
19
20
|
import {
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
|
|
26
27
|
// ---- Persistence ----
|
|
27
28
|
const adapter = createAdapter(config, { manifest });
|
|
29
|
+
const currentFingerprint = structureFingerprint(manifest);
|
|
28
30
|
let persistenceReady = $state(false);
|
|
29
31
|
// Holds the resolved xAPI client for unload-time markUnloading. Set
|
|
30
32
|
// after adapter.init() resolves and registered globally so useXAPI()
|
|
@@ -206,6 +208,7 @@
|
|
|
206
208
|
}
|
|
207
209
|
return {
|
|
208
210
|
b: nav.currentPageIndex,
|
|
211
|
+
f: currentFingerprint,
|
|
209
212
|
v: [...progress.visitedPages],
|
|
210
213
|
q,
|
|
211
214
|
d: duration.totalSeconds,
|
|
@@ -403,7 +406,7 @@
|
|
|
403
406
|
}
|
|
404
407
|
|
|
405
408
|
const saved = adapter.getState();
|
|
406
|
-
if (saved) {
|
|
409
|
+
if (saved && shouldRestore(saved, currentFingerprint, config.resume)) {
|
|
407
410
|
restoreState(saved);
|
|
408
411
|
prevCompletionStatus = progress.completionStatus;
|
|
409
412
|
prevSuccessStatus = progress.successStatus;
|
|
@@ -66,6 +66,16 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
66
66
|
return this.api;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// SCORM 2004 overrides this to block writes in browse/review mode (§4.2.1.5).
|
|
70
|
+
protected canWrite(): boolean {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
protected set(key: string, value: string): void {
|
|
75
|
+
if (!this.canWrite()) return;
|
|
76
|
+
this.queue.enqueue(() => this.dialect.setValue(this.api, key, value), key);
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
async init(): Promise<void> {
|
|
70
80
|
const initialized = await withRetry(
|
|
71
81
|
() => this.dialect.initialize(this.api),
|
|
@@ -129,6 +139,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
saveState(state: SavedState): void {
|
|
142
|
+
if (!this.canWrite()) return;
|
|
132
143
|
this.#state = state;
|
|
133
144
|
const json = JSON.stringify(state);
|
|
134
145
|
if (
|
|
@@ -144,19 +155,11 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
144
155
|
`larger-limit standard (scorm2004/cmi5).`,
|
|
145
156
|
);
|
|
146
157
|
}
|
|
147
|
-
this.
|
|
148
|
-
() => this.dialect.setValue(this.api, 'cmi.suspend_data', json),
|
|
149
|
-
'cmi.suspend_data',
|
|
150
|
-
);
|
|
158
|
+
this.set('cmi.suspend_data', json);
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
setDuration(seconds: number): void {
|
|
154
|
-
|
|
155
|
-
this.queue.enqueue(
|
|
156
|
-
() =>
|
|
157
|
-
this.dialect.setValue(this.api, this.dialect.sessionTimeKey, formatted),
|
|
158
|
-
this.dialect.sessionTimeKey,
|
|
159
|
-
);
|
|
162
|
+
this.set(this.dialect.sessionTimeKey, this.dialect.formatDuration(seconds));
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
reportInteraction(
|
|
@@ -164,6 +167,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
164
167
|
interaction: Interaction,
|
|
165
168
|
correct: boolean | null,
|
|
166
169
|
): void {
|
|
170
|
+
if (!this.canWrite()) return;
|
|
167
171
|
const n = this.interactionCount++;
|
|
168
172
|
const fields = buildScormInteractionFields(
|
|
169
173
|
`cmi.interactions.${n}`,
|
|
@@ -180,10 +184,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
|
|
|
180
184
|
},
|
|
181
185
|
);
|
|
182
186
|
for (const [key, value] of fields) {
|
|
183
|
-
this.
|
|
184
|
-
() => this.dialect.setValue(this.api, key, value),
|
|
185
|
-
key,
|
|
186
|
-
);
|
|
187
|
+
this.set(key, value);
|
|
187
188
|
}
|
|
188
189
|
}
|
|
189
190
|
|
|
@@ -66,25 +66,13 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
|
|
|
66
66
|
saveState(state: SavedState): void {
|
|
67
67
|
super.saveState(state);
|
|
68
68
|
// §3.4.5.3 — bookmark for LMS "Resume from page N" affordances.
|
|
69
|
-
this.
|
|
70
|
-
() => this.api.LMSSetValue('cmi.core.lesson_location', String(state.b)),
|
|
71
|
-
'cmi.core.lesson_location',
|
|
72
|
-
);
|
|
69
|
+
this.set('cmi.core.lesson_location', String(state.b));
|
|
73
70
|
}
|
|
74
71
|
|
|
75
72
|
setScore(score: number): void {
|
|
76
|
-
this.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
);
|
|
80
|
-
this.queue.enqueue(
|
|
81
|
-
() => this.api.LMSSetValue('cmi.core.score.min', '0'),
|
|
82
|
-
'cmi.core.score.min',
|
|
83
|
-
);
|
|
84
|
-
this.queue.enqueue(
|
|
85
|
-
() => this.api.LMSSetValue('cmi.core.score.max', '100'),
|
|
86
|
-
'cmi.core.score.max',
|
|
87
|
-
);
|
|
73
|
+
this.set('cmi.core.score.raw', formatReal107(score));
|
|
74
|
+
this.set('cmi.core.score.min', '0');
|
|
75
|
+
this.set('cmi.core.score.max', '100');
|
|
88
76
|
}
|
|
89
77
|
|
|
90
78
|
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
@@ -101,18 +89,11 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
|
|
|
101
89
|
|
|
102
90
|
#flushLessonStatus(): void {
|
|
103
91
|
const value = this.#successStatus ?? this.#completionStatus;
|
|
104
|
-
this.
|
|
105
|
-
() => this.api.LMSSetValue('cmi.core.lesson_status', value),
|
|
106
|
-
'cmi.core.lesson_status',
|
|
107
|
-
);
|
|
92
|
+
this.set('cmi.core.lesson_status', value);
|
|
108
93
|
}
|
|
109
94
|
|
|
110
95
|
setExit(mode: 'suspend' | 'normal'): void {
|
|
111
96
|
// SCORM 1.2 §4.2.2 vocabulary: time-out, suspend, logout, "" (normal).
|
|
112
|
-
|
|
113
|
-
this.queue.enqueue(
|
|
114
|
-
() => this.api.LMSSetValue('cmi.core.exit', value),
|
|
115
|
-
'cmi.core.exit',
|
|
116
|
-
);
|
|
97
|
+
this.set('cmi.core.exit', mode === 'suspend' ? 'suspend' : '');
|
|
117
98
|
}
|
|
118
99
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { SCORM2004_INTERACTION_FORMAT } from '../interaction-format.js';
|
|
2
|
-
import type { Interaction } from '../interaction.js';
|
|
3
2
|
import type { SavedState } from '../persistence.js';
|
|
4
3
|
import { BaseScormAdapter, type ScormDialect } from './scorm-base.js';
|
|
5
4
|
import {
|
|
@@ -75,7 +74,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
75
74
|
return this.#masteryScore;
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
|
|
77
|
+
protected canWrite(): boolean {
|
|
79
78
|
return this.#mode === 'normal';
|
|
80
79
|
}
|
|
81
80
|
|
|
@@ -98,80 +97,38 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
98
97
|
}
|
|
99
98
|
|
|
100
99
|
saveState(state: SavedState): void {
|
|
101
|
-
if (!this.#canWrite) return;
|
|
102
100
|
super.saveState(state);
|
|
103
101
|
// §4.2.1.4 — bookmark for LMS "Resume from page N" affordances.
|
|
104
|
-
this.
|
|
105
|
-
() => this.api.SetValue('cmi.location', String(state.b)),
|
|
106
|
-
'cmi.location',
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
setDuration(seconds: number): void {
|
|
111
|
-
if (!this.#canWrite) return;
|
|
112
|
-
super.setDuration(seconds);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
reportInteraction(
|
|
116
|
-
questionId: string,
|
|
117
|
-
interaction: Interaction,
|
|
118
|
-
correct: boolean | null,
|
|
119
|
-
): void {
|
|
120
|
-
if (!this.#canWrite) return;
|
|
121
|
-
super.reportInteraction(questionId, interaction, correct);
|
|
102
|
+
this.set('cmi.location', String(state.b));
|
|
122
103
|
}
|
|
123
104
|
|
|
124
105
|
setScore(score: number): void {
|
|
125
|
-
|
|
126
|
-
|
|
106
|
+
this.set('cmi.score.raw', formatReal107(score));
|
|
107
|
+
this.set('cmi.score.min', '0');
|
|
108
|
+
this.set('cmi.score.max', '100');
|
|
127
109
|
// §4.2.4.3.5 — score.scaled is bounded to [-1, 1].
|
|
128
|
-
|
|
129
|
-
this.queue.enqueue(
|
|
130
|
-
() => this.api.SetValue('cmi.score.raw', raw),
|
|
131
|
-
'cmi.score.raw',
|
|
132
|
-
);
|
|
133
|
-
this.queue.enqueue(
|
|
134
|
-
() => this.api.SetValue('cmi.score.min', '0'),
|
|
135
|
-
'cmi.score.min',
|
|
136
|
-
);
|
|
137
|
-
this.queue.enqueue(
|
|
138
|
-
() => this.api.SetValue('cmi.score.max', '100'),
|
|
139
|
-
'cmi.score.max',
|
|
140
|
-
);
|
|
141
|
-
this.queue.enqueue(
|
|
142
|
-
() => this.api.SetValue('cmi.score.scaled', scaled),
|
|
110
|
+
this.set(
|
|
143
111
|
'cmi.score.scaled',
|
|
112
|
+
formatReal107(Math.max(0, Math.min(1, score / 100))),
|
|
144
113
|
);
|
|
145
114
|
}
|
|
146
115
|
|
|
147
116
|
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
148
|
-
|
|
149
|
-
const value = status === 'complete' ? 'completed' : 'incomplete';
|
|
150
|
-
this.queue.enqueue(
|
|
151
|
-
() => this.api.SetValue('cmi.completion_status', value),
|
|
117
|
+
this.set(
|
|
152
118
|
'cmi.completion_status',
|
|
119
|
+
status === 'complete' ? 'completed' : 'incomplete',
|
|
153
120
|
);
|
|
154
121
|
// §4.2.4.2 — writing 1.0 surfaces a "100%" reading on LMS dashboards.
|
|
155
|
-
if (status === 'complete')
|
|
156
|
-
this.queue.enqueue(
|
|
157
|
-
() => this.api.SetValue('cmi.progress_measure', '1'),
|
|
158
|
-
'cmi.progress_measure',
|
|
159
|
-
);
|
|
160
|
-
}
|
|
122
|
+
if (status === 'complete') this.set('cmi.progress_measure', '1');
|
|
161
123
|
}
|
|
162
124
|
|
|
163
125
|
setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
|
|
164
|
-
if (!this.#canWrite) return;
|
|
165
126
|
// Setting "unknown" explicitly prevents SCORM Cloud from rolling up
|
|
166
127
|
// a null status to "passed".
|
|
167
|
-
this.
|
|
168
|
-
() => this.api.SetValue('cmi.success_status', status),
|
|
169
|
-
'cmi.success_status',
|
|
170
|
-
);
|
|
128
|
+
this.set('cmi.success_status', status);
|
|
171
129
|
}
|
|
172
130
|
|
|
173
131
|
setExit(mode: 'suspend' | 'normal'): void {
|
|
174
|
-
|
|
175
|
-
this.queue.enqueue(() => this.api.SetValue('cmi.exit', mode), 'cmi.exit');
|
|
132
|
+
this.set('cmi.exit', mode);
|
|
176
133
|
}
|
|
177
134
|
}
|
|
@@ -3,19 +3,7 @@ import type { CourseConfig } from '../types.js';
|
|
|
3
3
|
import { courseIdentity } from '../types.js';
|
|
4
4
|
import type { Interaction } from '../interaction.js';
|
|
5
5
|
import type { Manifest } from '../../plugin/manifest.js';
|
|
6
|
-
|
|
7
|
-
// FNV-1a over the ordered page slugs. SavedState is keyed by page index, so a
|
|
8
|
-
// structure change must change the key — else stale state restores onto the
|
|
9
|
-
// wrong pages. Slugs can't contain a NUL, so it's a collision-proof delimiter.
|
|
10
|
-
function structureFingerprint(manifest: Manifest): string {
|
|
11
|
-
const slugs = manifest.pages.map((p) => p.slug).join('\0');
|
|
12
|
-
let h = 0x811c9dc5;
|
|
13
|
-
for (let i = 0; i < slugs.length; i++) {
|
|
14
|
-
h ^= slugs.charCodeAt(i);
|
|
15
|
-
h = Math.imul(h, 0x01000193);
|
|
16
|
-
}
|
|
17
|
-
return (h >>> 0).toString(36);
|
|
18
|
-
}
|
|
6
|
+
import { structureFingerprint } from '../fingerprint.js';
|
|
19
7
|
|
|
20
8
|
/**
|
|
21
9
|
* Web persistence adapter — stores course state in localStorage.
|
|
@@ -27,6 +15,10 @@ export class WebAdapter implements PersistenceAdapter {
|
|
|
27
15
|
|
|
28
16
|
constructor(config: CourseConfig, manifest?: Manifest) {
|
|
29
17
|
const base = courseIdentity(config) || 'tessera-course';
|
|
18
|
+
// Fingerprint in the key invalidates web resume on a structure change (a
|
|
19
|
+
// changed key misses, so getState() returns null). LMS adapters can't key
|
|
20
|
+
// their storage, so they rely on SavedState.f + shouldRestore instead. Keep
|
|
21
|
+
// both — neither mechanism covers the other's adapters.
|
|
30
22
|
const fp = manifest ? structureFingerprint(manifest) : '';
|
|
31
23
|
this.#storageKey = `tessera-${base}${fp ? `-${fp}` : ''}`;
|
|
32
24
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Manifest } from '../plugin/manifest.js';
|
|
2
|
+
import type { SavedState } from './persistence.js';
|
|
3
|
+
|
|
4
|
+
// FNV-1a over the ordered page slugs. SavedState is keyed by page index, so a
|
|
5
|
+
// structure change must change the fingerprint — else stale state restores onto
|
|
6
|
+
// the wrong pages. Slugs can't contain a NUL, so it's a collision-proof delimiter.
|
|
7
|
+
export function structureFingerprint(manifest: Manifest): string {
|
|
8
|
+
const slugs = manifest.pages.map((p) => p.slug).join('\0');
|
|
9
|
+
let h = 0x811c9dc5;
|
|
10
|
+
for (let i = 0; i < slugs.length; i++) {
|
|
11
|
+
h ^= slugs.charCodeAt(i);
|
|
12
|
+
h = Math.imul(h, 0x01000193);
|
|
13
|
+
}
|
|
14
|
+
return (h >>> 0).toString(36);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// `never` always starts fresh; otherwise a saved fingerprint that no longer
|
|
18
|
+
// matches the current structure is discarded. State saved before fingerprinting
|
|
19
|
+
// (no `f`) is trusted so upgrading the runtime never wipes an in-progress learner.
|
|
20
|
+
export function shouldRestore(
|
|
21
|
+
saved: SavedState,
|
|
22
|
+
currentFingerprint: string,
|
|
23
|
+
resume: 'auto' | 'never' = 'auto',
|
|
24
|
+
): boolean {
|
|
25
|
+
if (resume === 'never') return false;
|
|
26
|
+
if (saved.f !== undefined && saved.f !== currentFingerprint) return false;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
@@ -60,4 +60,8 @@ export interface SavedState {
|
|
|
60
60
|
gs?: number[];
|
|
61
61
|
/** Manual completion latch. 1 if the learner triggered manual completion. Absent otherwise. */
|
|
62
62
|
m?: 1;
|
|
63
|
+
/** Structure fingerprint (FNV-1a over ordered page slugs) at save time.
|
|
64
|
+
* On resume, a mismatch discards the blob — the course structure changed.
|
|
65
|
+
* Absent on state saved before fingerprinting; treated as a match. */
|
|
66
|
+
f?: string;
|
|
63
67
|
}
|
package/src/runtime/types.ts
CHANGED
|
@@ -40,6 +40,9 @@ export interface CourseConfig {
|
|
|
40
40
|
description?: string;
|
|
41
41
|
author?: string;
|
|
42
42
|
version?: string;
|
|
43
|
+
/** Resume policy. 'auto' (default) restores saved progress unless the page
|
|
44
|
+
* structure changed since it was saved; 'never' always starts fresh. */
|
|
45
|
+
resume?: 'auto' | 'never';
|
|
43
46
|
/** BCP-47 language tag for <html lang>. Defaults to 'en'. WCAG 3.1.1. */
|
|
44
47
|
language?: string;
|
|
45
48
|
/** Accessibility checker configuration. */
|
|
@@ -494,17 +494,21 @@ export class XAPIPublisher {
|
|
|
494
494
|
return headers;
|
|
495
495
|
}
|
|
496
496
|
|
|
497
|
-
#
|
|
498
|
-
token: string,
|
|
499
|
-
body: string,
|
|
500
|
-
keepalive: boolean,
|
|
501
|
-
): Promise<SendOutcome> {
|
|
497
|
+
#post(token: string, body: string, keepalive: boolean): Promise<Response> {
|
|
502
498
|
return fetch(this.#statementsUrl, {
|
|
503
499
|
method: 'POST',
|
|
504
500
|
headers: this.#buildHeaders(token),
|
|
505
501
|
body,
|
|
506
502
|
keepalive,
|
|
507
|
-
})
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
#fetchWithToken(
|
|
507
|
+
token: string,
|
|
508
|
+
body: string,
|
|
509
|
+
keepalive: boolean,
|
|
510
|
+
): Promise<SendOutcome> {
|
|
511
|
+
return this.#post(token, body, keepalive)
|
|
508
512
|
.then((resp) => this.#handleResponse(resp, body, keepalive))
|
|
509
513
|
.catch((err) => ({
|
|
510
514
|
ok: false,
|
|
@@ -529,14 +533,7 @@ export class XAPIPublisher {
|
|
|
529
533
|
) {
|
|
530
534
|
this.#cachedAuth = null;
|
|
531
535
|
return this.#resolveAuth(true)
|
|
532
|
-
.then((newToken) =>
|
|
533
|
-
fetch(this.#statementsUrl, {
|
|
534
|
-
method: 'POST',
|
|
535
|
-
headers: this.#buildHeaders(newToken),
|
|
536
|
-
body,
|
|
537
|
-
keepalive,
|
|
538
|
-
}),
|
|
539
|
-
)
|
|
536
|
+
.then((newToken) => this.#post(newToken, body, keepalive))
|
|
540
537
|
.then((retryResp): SendOutcome => {
|
|
541
538
|
if (retryResp.ok || retryResp.status === 409) {
|
|
542
539
|
return { ok: true, status: retryResp.status };
|