tessera-learn 0.2.3 → 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 +50 -21
- package/README.md +2 -2
- package/dist/{audit--fSWIOgK.js → audit-DsYqXbqm.js} +282 -197
- package/dist/audit-DsYqXbqm.js.map +1 -0
- package/dist/{build-commands-Qyrlsp3n.js → build-commands-BFuiAxaR.js} +4 -4
- package/dist/build-commands-BFuiAxaR.js.map +1 -0
- package/dist/{inline-config-DqAKsCNl.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 +91 -49
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +287 -2
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +3 -3
- package/dist/{plugin-B-aiL9-V.js → plugin-BuMiDTmU.js} +145 -111
- package/dist/plugin-BuMiDTmU.js.map +1 -0
- package/package.json +7 -7
- package/src/components/DefaultLayout.svelte +2 -5
- package/src/components/MultipleChoice.svelte +1 -2
- package/src/components/Quiz.svelte +18 -26
- package/src/plugin/ast.ts +9 -2
- package/src/plugin/build-commands.ts +7 -4
- package/src/plugin/cli.ts +96 -46
- package/src/plugin/csp.ts +59 -0
- package/src/plugin/duplicate-cli.ts +37 -1
- package/src/plugin/export.ts +56 -27
- package/src/plugin/index.ts +138 -93
- package/src/plugin/inline-config.ts +4 -2
- package/src/plugin/manifest.ts +24 -23
- package/src/plugin/new-cli.ts +2 -0
- package/src/plugin/validate-cli.ts +5 -2
- package/src/plugin/validation.ts +255 -238
- package/src/runtime/App.svelte +14 -9
- package/src/runtime/Sidebar.svelte +3 -1
- package/src/runtime/adapters/cmi5.ts +59 -402
- package/src/runtime/adapters/discovery.ts +11 -0
- package/src/runtime/adapters/index.ts +27 -60
- package/src/runtime/adapters/lms-error.ts +61 -0
- package/src/runtime/adapters/scorm-base.ts +15 -14
- package/src/runtime/adapters/scorm12.ts +6 -25
- package/src/runtime/adapters/scorm2004.ts +12 -54
- package/src/runtime/adapters/web.ts +11 -4
- package/src/runtime/adapters/xapi-launch-base.ts +346 -0
- package/src/runtime/adapters/xapi.ts +26 -0
- 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 +22 -1
- package/src/runtime/xapi/publisher.ts +16 -15
- package/src/runtime/xapi/setup.ts +24 -15
- package/src/virtual.d.ts +4 -1
- package/templates/course/course.config.js +1 -0
- package/dist/audit--fSWIOgK.js.map +0 -1
- package/dist/build-commands-Qyrlsp3n.js.map +0 -1
- package/dist/inline-config-DqAKsCNl.js.map +0 -1
- package/dist/plugin-B-aiL9-V.js.map +0 -1
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import type { PersistenceAdapter, SavedState } from '../persistence.js';
|
|
2
|
+
import type { Interaction } from '../interaction.js';
|
|
3
|
+
import {
|
|
4
|
+
formatResponse,
|
|
5
|
+
formatCorrectPattern,
|
|
6
|
+
XAPI_INTERACTION_FORMAT,
|
|
7
|
+
} from '../interaction-format.js';
|
|
8
|
+
import { formatISO8601Duration } from './format.js';
|
|
9
|
+
import { XAPIPublisher } from '../xapi/publisher.js';
|
|
10
|
+
import type { XAPIAgent, PartialStatement } from '../xapi/types.js';
|
|
11
|
+
|
|
12
|
+
export 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
|
+
terminated: 'http://adlnet.gov/expapi/verbs/terminated',
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
const CMI_INTERACTION_TYPE =
|
|
22
|
+
'http://adlnet.gov/expapi/activities/cmi.interaction';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Version-neutral xAPI launch lifecycle shared by the cmi5 and plain-xAPI
|
|
26
|
+
* adapters. Subclasses set the protected fields in init() and may override
|
|
27
|
+
* buildContext()/isDefinedStatementAllowed()/scoreForSuccess() to layer
|
|
28
|
+
* profile rules on top.
|
|
29
|
+
*/
|
|
30
|
+
export abstract class BaseXAPILaunchAdapter implements PersistenceAdapter {
|
|
31
|
+
protected publisher: XAPIPublisher | null = null;
|
|
32
|
+
protected endpoint = '';
|
|
33
|
+
protected activityId = '';
|
|
34
|
+
protected actor: XAPIAgent | null = null;
|
|
35
|
+
protected registration: string | undefined;
|
|
36
|
+
protected authToken = '';
|
|
37
|
+
protected version = '1.0.3';
|
|
38
|
+
/** Prefix for this adapter's console warnings (e.g. "cmi5", "xAPI"). */
|
|
39
|
+
protected logName = 'xAPI';
|
|
40
|
+
|
|
41
|
+
protected score: number | null = null;
|
|
42
|
+
protected durationSeconds = 0;
|
|
43
|
+
protected state: SavedState | null = null;
|
|
44
|
+
protected completedEmitted = false;
|
|
45
|
+
protected lastSuccessEmitted: 'unknown' | 'passed' | 'failed' = 'unknown';
|
|
46
|
+
protected terminated = false;
|
|
47
|
+
protected returnURL: string | undefined;
|
|
48
|
+
|
|
49
|
+
abstract init(): Promise<void>;
|
|
50
|
+
|
|
51
|
+
/** Profile context for a Defined Statement. Plain xAPI adds nothing — the publisher injects context.registration on its own. */
|
|
52
|
+
protected buildContext(
|
|
53
|
+
_opts: { moveOn?: boolean; mastery?: boolean } = {},
|
|
54
|
+
): Record<string, unknown> | undefined {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** cmi5 Browse/Review gating hook. Plain xAPI always allows. */
|
|
59
|
+
protected isDefinedStatementAllowed(): boolean {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Scaled score to attach to Passed/Failed, or null to omit. cmi5 overrides for masteryScore gating. */
|
|
64
|
+
protected scoreForSuccess(_status: 'passed' | 'failed'): number | null {
|
|
65
|
+
return this.score !== null ? this.score / 100 : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getPublisher(): XAPIPublisher | null {
|
|
69
|
+
return this.publisher;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getState(): SavedState | null {
|
|
73
|
+
return this.state;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
saveState(state: SavedState): void {
|
|
77
|
+
this.state = state;
|
|
78
|
+
if (!this.publisher) return;
|
|
79
|
+
void this.publisher.chainTask(async () => {
|
|
80
|
+
try {
|
|
81
|
+
const resp = await this.xapiFetch(this.buildStateUrl(), {
|
|
82
|
+
method: 'PUT',
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
body: JSON.stringify(state),
|
|
85
|
+
});
|
|
86
|
+
if (!resp.ok) {
|
|
87
|
+
console.warn(
|
|
88
|
+
`Tessera ${this.logName}: State API PUT returned ${resp.status}; learner progress did not persist.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.warn(`Tessera ${this.logName}: Failed to save state`, err);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
setScore(score: number): void {
|
|
98
|
+
if (!Number.isFinite(score)) {
|
|
99
|
+
this.score = null;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.score = Math.max(0, Math.min(100, score));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
setDuration(seconds: number): void {
|
|
106
|
+
this.durationSeconds = seconds;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setExit(_mode: 'suspend' | 'normal'): void {
|
|
110
|
+
// No cmi.exit analogue in xAPI; suspend is implicit. No-op.
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
commit(): void {
|
|
114
|
+
// Statements are sent individually. No-op.
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
seedLifecycle(
|
|
118
|
+
completion: 'incomplete' | 'complete',
|
|
119
|
+
success: 'unknown' | 'passed' | 'failed',
|
|
120
|
+
): void {
|
|
121
|
+
if (completion === 'complete') this.completedEmitted = true;
|
|
122
|
+
if (success === 'passed' || success === 'failed') {
|
|
123
|
+
this.lastSuccessEmitted = success;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
128
|
+
if (status !== 'complete' || this.completedEmitted || !this.publisher)
|
|
129
|
+
return;
|
|
130
|
+
if (!this.isDefinedStatementAllowed()) return;
|
|
131
|
+
this.completedEmitted = true;
|
|
132
|
+
const result: Record<string, unknown> = {
|
|
133
|
+
completion: true,
|
|
134
|
+
duration: formatISO8601Duration(this.durationSeconds),
|
|
135
|
+
};
|
|
136
|
+
this.dispatch('Completed', {
|
|
137
|
+
verb: { id: VERBS.completed, display: { 'en-US': 'completed' } },
|
|
138
|
+
result,
|
|
139
|
+
context: this.buildContext({ moveOn: true }),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
|
|
144
|
+
if (status === 'unknown' || !this.publisher) return;
|
|
145
|
+
if (status === this.lastSuccessEmitted) return;
|
|
146
|
+
if (!this.isDefinedStatementAllowed()) return;
|
|
147
|
+
this.lastSuccessEmitted = status;
|
|
148
|
+
|
|
149
|
+
const verb = status === 'passed' ? VERBS.passed : VERBS.failed;
|
|
150
|
+
const verbName = status === 'passed' ? 'passed' : 'failed';
|
|
151
|
+
const result: Record<string, unknown> = {
|
|
152
|
+
success: status === 'passed',
|
|
153
|
+
duration: formatISO8601Duration(this.durationSeconds),
|
|
154
|
+
};
|
|
155
|
+
const scaled = this.scoreForSuccess(status);
|
|
156
|
+
if (scaled !== null) result.score = { scaled };
|
|
157
|
+
this.dispatch(status === 'passed' ? 'Passed' : 'Failed', {
|
|
158
|
+
verb: { id: verb, display: { 'en-US': verbName } },
|
|
159
|
+
result,
|
|
160
|
+
context: this.buildContext({ moveOn: true, mastery: true }),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
reportInteraction(
|
|
165
|
+
questionId: string,
|
|
166
|
+
interaction: Interaction,
|
|
167
|
+
correct: boolean | null,
|
|
168
|
+
): void {
|
|
169
|
+
if (!this.publisher) return;
|
|
170
|
+
const response = formatResponse(interaction, XAPI_INTERACTION_FORMAT);
|
|
171
|
+
const pattern = formatCorrectPattern(interaction, XAPI_INTERACTION_FORMAT);
|
|
172
|
+
const definition: Record<string, unknown> = {
|
|
173
|
+
type: CMI_INTERACTION_TYPE,
|
|
174
|
+
interactionType: interaction.type,
|
|
175
|
+
};
|
|
176
|
+
if (pattern !== null) {
|
|
177
|
+
definition.correctResponsesPattern = [pattern];
|
|
178
|
+
}
|
|
179
|
+
const result: Record<string, unknown> = { response };
|
|
180
|
+
if (correct !== null) {
|
|
181
|
+
result.success = correct;
|
|
182
|
+
}
|
|
183
|
+
this.dispatch('Answered', {
|
|
184
|
+
verb: { id: VERBS.answered, display: { 'en-US': 'answered' } },
|
|
185
|
+
object: {
|
|
186
|
+
id: `${this.activityId}#${questionId}`,
|
|
187
|
+
objectType: 'Activity',
|
|
188
|
+
definition,
|
|
189
|
+
},
|
|
190
|
+
result,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
terminate(): void {
|
|
195
|
+
if (this.terminated) return;
|
|
196
|
+
this.terminated = true;
|
|
197
|
+
if (!this.publisher) return;
|
|
198
|
+
this.publisher.markUnloading();
|
|
199
|
+
const duration = formatISO8601Duration(this.durationSeconds);
|
|
200
|
+
this.dispatch('Terminated', {
|
|
201
|
+
verb: { id: VERBS.terminated, display: { 'en-US': 'terminated' } },
|
|
202
|
+
result: { duration },
|
|
203
|
+
context: this.buildContext(),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async exit(): Promise<void> {
|
|
208
|
+
this.terminate();
|
|
209
|
+
if (this.publisher) {
|
|
210
|
+
try {
|
|
211
|
+
await this.publisher.chainTask(async () => {});
|
|
212
|
+
} catch {
|
|
213
|
+
// never rejects today; don't block redirect.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (this.returnURL && typeof window !== 'undefined') {
|
|
217
|
+
window.location.assign(this.returnURL);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Parse the launch `actor` param into an Identified Agent, failing loud on malformed JSON. */
|
|
222
|
+
protected parseActorParam(raw: string): void {
|
|
223
|
+
try {
|
|
224
|
+
const parsed = JSON.parse(raw);
|
|
225
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
226
|
+
throw new Error('actor must be an object');
|
|
227
|
+
}
|
|
228
|
+
this.actor = parsed as XAPIAgent;
|
|
229
|
+
} catch (err) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Tessera ${this.logName}: launch parameter 'actor' is malformed (${err instanceof Error ? err.message : String(err)}). The LMS did not send a valid Identified Agent JSON.`,
|
|
232
|
+
{ cause: err },
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Construct the publisher from the resolved launch fields plus per-profile options. */
|
|
238
|
+
protected createPublisher(opts: {
|
|
239
|
+
sessionId?: string;
|
|
240
|
+
cmi5Mode?: boolean;
|
|
241
|
+
}): XAPIPublisher {
|
|
242
|
+
if (!this.actor) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Tessera ${this.logName}: cannot create publisher before the launch actor is resolved.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
this.publisher = new XAPIPublisher({
|
|
248
|
+
endpoint: this.endpoint,
|
|
249
|
+
auth: this.authToken,
|
|
250
|
+
actor: this.actor,
|
|
251
|
+
activityId: this.activityId,
|
|
252
|
+
registration: this.registration,
|
|
253
|
+
version: this.version,
|
|
254
|
+
...opts,
|
|
255
|
+
});
|
|
256
|
+
return this.publisher;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Fire-and-forget Initialized statement (the first Defined Statement of the session). */
|
|
260
|
+
protected sendInitialized(): void {
|
|
261
|
+
this.dispatch('Initialized', {
|
|
262
|
+
verb: { id: VERBS.initialized, display: { 'en-US': 'initialized' } },
|
|
263
|
+
context: this.buildContext(),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Enqueue a lifecycle statement fire-and-forget. `label` names it in both the LRS-reject and send-failure warnings. */
|
|
268
|
+
protected dispatch(label: string, partial: PartialStatement): void {
|
|
269
|
+
if (!this.publisher) return;
|
|
270
|
+
this.publisher
|
|
271
|
+
.sendStatement(partial)
|
|
272
|
+
.then(this.warnOnLRSReject(label))
|
|
273
|
+
.catch((err) => {
|
|
274
|
+
console.warn(
|
|
275
|
+
`Tessera ${this.logName}: failed to send ${label} statement`,
|
|
276
|
+
err,
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** `.then` handler that warns on LRS non-2xx. The publisher resolves successfully on 4xx/5xx (failure is in the destination outcome), so `.catch` alone misses them. */
|
|
282
|
+
protected warnOnLRSReject(
|
|
283
|
+
label: string,
|
|
284
|
+
): (res: {
|
|
285
|
+
destinations?: Array<{ ok?: boolean; status?: number; error?: Error }>;
|
|
286
|
+
}) => void {
|
|
287
|
+
const logName = this.logName;
|
|
288
|
+
return (res) => {
|
|
289
|
+
const dest = res.destinations?.[0];
|
|
290
|
+
if (dest && !dest.ok) {
|
|
291
|
+
console.warn(
|
|
292
|
+
`Tessera ${logName}: ${label} statement rejected by LRS (${dest.status ?? 'network error'})`,
|
|
293
|
+
dest.error,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
protected buildStateUrl(stateId: string = 'tessera-state'): string {
|
|
300
|
+
const params = new URLSearchParams({
|
|
301
|
+
activityId: this.activityId,
|
|
302
|
+
agent: JSON.stringify(this.actor),
|
|
303
|
+
stateId,
|
|
304
|
+
});
|
|
305
|
+
if (this.registration) params.set('registration', this.registration);
|
|
306
|
+
return `${this.endpoint}activities/state?${params.toString()}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
protected async xapiFetch(
|
|
310
|
+
url: string,
|
|
311
|
+
options: RequestInit = {},
|
|
312
|
+
): Promise<Response> {
|
|
313
|
+
const headers = new Headers(options.headers);
|
|
314
|
+
if (this.authToken) {
|
|
315
|
+
headers.set('Authorization', `Basic ${this.authToken}`);
|
|
316
|
+
}
|
|
317
|
+
headers.set('X-Experience-API-Version', this.version);
|
|
318
|
+
const keepalive = this.publisher?.isUnloading() ?? false;
|
|
319
|
+
return fetch(url, {
|
|
320
|
+
...options,
|
|
321
|
+
headers,
|
|
322
|
+
...(keepalive ? { keepalive: true } : {}),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Shared resume GET — call from a subclass init() after the publisher exists. */
|
|
327
|
+
protected async loadResumeState(): Promise<void> {
|
|
328
|
+
try {
|
|
329
|
+
const resp = await this.xapiFetch(this.buildStateUrl(), {
|
|
330
|
+
method: 'GET',
|
|
331
|
+
});
|
|
332
|
+
if (resp.ok) {
|
|
333
|
+
this.state = await resp.json();
|
|
334
|
+
} else if (resp.status !== 404) {
|
|
335
|
+
console.warn(
|
|
336
|
+
`Tessera ${this.logName}: State API GET returned ${resp.status}; resume disabled for this launch.`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
} catch (err) {
|
|
340
|
+
console.warn(
|
|
341
|
+
`Tessera ${this.logName}: State API GET failed (${err instanceof Error ? err.message : String(err)}); resume disabled for this launch.`,
|
|
342
|
+
);
|
|
343
|
+
this.state = null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { BaseXAPILaunchAdapter } from './xapi-launch-base.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plain xAPI ("Tin Can") launch adapter. Reads launch params straight off the
|
|
5
|
+
* URL — no cmi5 fetch-token, no LMS.LaunchData, no cmi5 context.
|
|
6
|
+
*/
|
|
7
|
+
export class XAPIAdapter extends BaseXAPILaunchAdapter {
|
|
8
|
+
async init(): Promise<void> {
|
|
9
|
+
const params = new URLSearchParams(window.location.search);
|
|
10
|
+
this.endpoint = (params.get('endpoint') || '').replace(/\/?$/, '/');
|
|
11
|
+
// Tin Can uses snake_case `activity_id` (NOT cmi5's camelCase `activityId`).
|
|
12
|
+
this.activityId = params.get('activity_id') || '';
|
|
13
|
+
const reg = params.get('registration') || '';
|
|
14
|
+
this.registration = reg ? reg : undefined;
|
|
15
|
+
// Tin Can launch passes `auth` as the full "Basic <base64>" header value;
|
|
16
|
+
// strip the scheme so we don't double-prefix it when sending.
|
|
17
|
+
this.authToken = (params.get('auth') || '').replace(/^Basic\s+/i, '');
|
|
18
|
+
this.parseActorParam(params.get('actor') || '');
|
|
19
|
+
|
|
20
|
+
const publisher = this.createPublisher({});
|
|
21
|
+
await publisher.init();
|
|
22
|
+
|
|
23
|
+
this.sendInitialized();
|
|
24
|
+
await this.loadResumeState();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -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
|
@@ -9,6 +9,15 @@ import type { XAPIAgent } from './xapi/types.js';
|
|
|
9
9
|
export const FEEDBACK_MODES = ['review', 'immediate', 'never'] as const;
|
|
10
10
|
export const RETRY_MODES = ['full', 'incorrect-only'] as const;
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Trimmed course identity, or '' when absent. Single source of truth for the
|
|
14
|
+
* "is there a usable id?" check shared by the web storage key, the cmi5/xAPI
|
|
15
|
+
* id derivation, and the config validator.
|
|
16
|
+
*/
|
|
17
|
+
export function courseIdentity(config: { id?: unknown }): string {
|
|
18
|
+
return (typeof config.id === 'string' && config.id.trim()) || '';
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
/**
|
|
13
22
|
* Per-page quiz configuration. Single source of truth — the build plugin
|
|
14
23
|
* extracts this from `pageConfig.quiz` and embeds it in the manifest;
|
|
@@ -24,9 +33,16 @@ export interface QuizConfig {
|
|
|
24
33
|
|
|
25
34
|
export interface CourseConfig {
|
|
26
35
|
title: string;
|
|
36
|
+
/** Stable, unique course identity (e.g. 'urn:uuid:…'). Seeds the web
|
|
37
|
+
* localStorage key and the cmi5/xAPI LRS activity id; scaffolders generate one.
|
|
38
|
+
* Absent → both fall back to a fixed value, colliding across courses. */
|
|
39
|
+
id?: string;
|
|
27
40
|
description?: string;
|
|
28
41
|
author?: string;
|
|
29
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';
|
|
30
46
|
/** BCP-47 language tag for <html lang>. Defaults to 'en'. WCAG 3.1.1. */
|
|
31
47
|
language?: string;
|
|
32
48
|
/** Accessibility checker configuration. */
|
|
@@ -46,7 +62,12 @@ export interface CourseConfig {
|
|
|
46
62
|
passingScore: number;
|
|
47
63
|
};
|
|
48
64
|
export: {
|
|
49
|
-
standard: 'web' | 'scorm12' | 'scorm2004' | 'cmi5';
|
|
65
|
+
standard: 'web' | 'scorm12' | 'scorm2004' | 'cmi5' | 'xapi';
|
|
66
|
+
/** Web export only: extend the baseline Content-Security-Policy. Each key is
|
|
67
|
+
* a directive; its sources are appended (unioned) onto the baseline. `false`
|
|
68
|
+
* drops the CSP meta entirely (for deployments that set a CSP header).
|
|
69
|
+
* Ignored unless `standard` is 'web'. */
|
|
70
|
+
csp?: false | Record<string, string[]>;
|
|
50
71
|
};
|
|
51
72
|
/**
|
|
52
73
|
* Optional xAPI destination(s) for custom statement publishing via
|
|
@@ -46,6 +46,8 @@ export interface XAPIPublisherOptions {
|
|
|
46
46
|
* Set by the cmi5 adapter and by 'lms'-inherited destinations under cmi5.
|
|
47
47
|
*/
|
|
48
48
|
cmi5Mode?: boolean;
|
|
49
|
+
/** xAPI version for the X-Experience-API-Version header. Defaults to X_API_VERSION (1.0.3). */
|
|
50
|
+
version?: string;
|
|
49
51
|
/** When set, every send method rejects with the returned Error without hitting the network. */
|
|
50
52
|
unavailableReason?: () => Error;
|
|
51
53
|
}
|
|
@@ -94,6 +96,7 @@ export class XAPIPublisher {
|
|
|
94
96
|
readonly #registration?: string;
|
|
95
97
|
readonly #sessionId: string;
|
|
96
98
|
readonly #cmi5Mode: boolean;
|
|
99
|
+
readonly #version: string;
|
|
97
100
|
|
|
98
101
|
// When set, every send method short-circuits with a rejected promise.
|
|
99
102
|
readonly #unavailableReason: (() => Error) | null;
|
|
@@ -138,6 +141,7 @@ export class XAPIPublisher {
|
|
|
138
141
|
this.#activityId = opts.activityId;
|
|
139
142
|
this.#registration = opts.registration;
|
|
140
143
|
this.#cmi5Mode = !!opts.cmi5Mode;
|
|
144
|
+
this.#version = opts.version ?? X_API_VERSION;
|
|
141
145
|
this.#authValue = opts.auth;
|
|
142
146
|
this.#actorValue = opts.actor;
|
|
143
147
|
this.#sessionId = opts.sessionId ?? uuidv4();
|
|
@@ -485,22 +489,26 @@ export class XAPIPublisher {
|
|
|
485
489
|
#buildHeaders(token: string): Headers {
|
|
486
490
|
const headers = new Headers();
|
|
487
491
|
if (token) headers.set('Authorization', `Basic ${token}`);
|
|
488
|
-
headers.set('X-Experience-API-Version',
|
|
492
|
+
headers.set('X-Experience-API-Version', this.#version);
|
|
489
493
|
headers.set('Content-Type', 'application/json');
|
|
490
494
|
return headers;
|
|
491
495
|
}
|
|
492
496
|
|
|
493
|
-
#
|
|
494
|
-
token: string,
|
|
495
|
-
body: string,
|
|
496
|
-
keepalive: boolean,
|
|
497
|
-
): Promise<SendOutcome> {
|
|
497
|
+
#post(token: string, body: string, keepalive: boolean): Promise<Response> {
|
|
498
498
|
return fetch(this.#statementsUrl, {
|
|
499
499
|
method: 'POST',
|
|
500
500
|
headers: this.#buildHeaders(token),
|
|
501
501
|
body,
|
|
502
502
|
keepalive,
|
|
503
|
-
})
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
#fetchWithToken(
|
|
507
|
+
token: string,
|
|
508
|
+
body: string,
|
|
509
|
+
keepalive: boolean,
|
|
510
|
+
): Promise<SendOutcome> {
|
|
511
|
+
return this.#post(token, body, keepalive)
|
|
504
512
|
.then((resp) => this.#handleResponse(resp, body, keepalive))
|
|
505
513
|
.catch((err) => ({
|
|
506
514
|
ok: false,
|
|
@@ -525,14 +533,7 @@ export class XAPIPublisher {
|
|
|
525
533
|
) {
|
|
526
534
|
this.#cachedAuth = null;
|
|
527
535
|
return this.#resolveAuth(true)
|
|
528
|
-
.then((newToken) =>
|
|
529
|
-
fetch(this.#statementsUrl, {
|
|
530
|
-
method: 'POST',
|
|
531
|
-
headers: this.#buildHeaders(newToken),
|
|
532
|
-
body,
|
|
533
|
-
keepalive,
|
|
534
|
-
}),
|
|
535
|
-
)
|
|
536
|
+
.then((newToken) => this.#post(newToken, body, keepalive))
|
|
536
537
|
.then((retryResp): SendOutcome => {
|
|
537
538
|
if (retryResp.ok || retryResp.status === 409) {
|
|
538
539
|
return { ok: true, status: retryResp.status };
|
|
@@ -7,18 +7,19 @@ import {
|
|
|
7
7
|
synthesizeSCORM12Actor,
|
|
8
8
|
synthesizeSCORM2004Actor,
|
|
9
9
|
} from './derive-actor.js';
|
|
10
|
-
import {
|
|
10
|
+
import { BaseXAPILaunchAdapter } from '../adapters/xapi-launch-base.js';
|
|
11
11
|
import { SCORM12Adapter } from '../adapters/scorm12.js';
|
|
12
12
|
import { SCORM2004Adapter } from '../adapters/scorm2004.js';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Wraps a value that the runtime knows how to materialize into an
|
|
16
16
|
* `XAPIPublisher`. Either a fresh publisher constructed for an explicit
|
|
17
|
-
* destination, or a reference to
|
|
18
|
-
* (for the `endpoint: 'lms'` sentinel — same instance, shared queue).
|
|
17
|
+
* destination, or a reference to a launch adapter's existing publisher
|
|
18
|
+
* (for the `endpoint: 'lms'` sentinel — same instance, shared queue). Both
|
|
19
|
+
* the cmi5 and plain-xAPI adapters expose that publisher via the base.
|
|
19
20
|
*/
|
|
20
21
|
type DestinationSource =
|
|
21
|
-
| { kind: 'lms-shared'; adapter:
|
|
22
|
+
| { kind: 'lms-shared'; adapter: BaseXAPILaunchAdapter }
|
|
22
23
|
| { kind: 'explicit'; publisher: XAPIPublisher };
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -29,10 +30,13 @@ type DestinationSource =
|
|
|
29
30
|
* produces the "works in dev, silently broken in prod" footgun.
|
|
30
31
|
*/
|
|
31
32
|
class XAPIDevFallbackError extends Error {
|
|
32
|
-
constructor() {
|
|
33
|
+
constructor(standard: 'cmi5' | 'xapi') {
|
|
34
|
+
const missing =
|
|
35
|
+
standard === 'cmi5'
|
|
36
|
+
? 'cmi5 launch parameters (fetch / endpoint / activityId / actor)'
|
|
37
|
+
: 'xAPI launch parameters (endpoint / auth / actor / activity_id)';
|
|
33
38
|
super(
|
|
34
|
-
|
|
35
|
-
'(fetch / endpoint / activityId / actor) were present on the URL. ' +
|
|
39
|
+
`Tessera xAPI: xapi.endpoint is 'lms' but no ${missing} were present on the URL. ` +
|
|
36
40
|
'Either launch this course from a real LMS / SCORM Cloud, or ' +
|
|
37
41
|
'temporarily change xapi.endpoint to an explicit URL pointed at a ' +
|
|
38
42
|
'local LRS (e.g. http://localhost:8080/data/xAPI/) for dev work.',
|
|
@@ -59,8 +63,8 @@ function makeRejectingPublisher(error: () => Error): XAPIPublisher {
|
|
|
59
63
|
});
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
function makeDevFallbackPublisher(): XAPIPublisher {
|
|
63
|
-
return makeRejectingPublisher(() => new XAPIDevFallbackError());
|
|
66
|
+
function makeDevFallbackPublisher(standard: 'cmi5' | 'xapi'): XAPIPublisher {
|
|
67
|
+
return makeRejectingPublisher(() => new XAPIDevFallbackError(standard));
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
class XAPISCORMDevFallbackError extends Error {
|
|
@@ -100,20 +104,21 @@ function resolveDestination(
|
|
|
100
104
|
adapter: PersistenceAdapter | null,
|
|
101
105
|
): DestinationSource | null {
|
|
102
106
|
if (entry.endpoint === 'lms') {
|
|
103
|
-
|
|
107
|
+
const standard = config.export?.standard;
|
|
108
|
+
if (standard !== 'cmi5' && standard !== 'xapi') {
|
|
104
109
|
// Build-time validator should reject this; defense in depth at runtime.
|
|
105
110
|
console.warn(
|
|
106
|
-
"Tessera xAPI: ignoring xapi entry with endpoint: 'lms' under non-
|
|
111
|
+
"Tessera xAPI: ignoring xapi entry with endpoint: 'lms' under a non-launch export.",
|
|
107
112
|
);
|
|
108
113
|
return null;
|
|
109
114
|
}
|
|
110
|
-
if (adapter instanceof
|
|
115
|
+
if (adapter instanceof BaseXAPILaunchAdapter) {
|
|
111
116
|
return { kind: 'lms-shared', adapter };
|
|
112
117
|
}
|
|
113
|
-
// Dev fallback —
|
|
118
|
+
// Dev fallback — launch params absent, adapter is the WebAdapter
|
|
114
119
|
// fallback. Materialize a publisher whose sends reject with an
|
|
115
120
|
// explicit error so author code surfaces the dev/prod gap.
|
|
116
|
-
return { kind: 'explicit', publisher: makeDevFallbackPublisher() };
|
|
121
|
+
return { kind: 'explicit', publisher: makeDevFallbackPublisher(standard) };
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
// Explicit endpoint.
|
|
@@ -151,7 +156,11 @@ function resolveExplicitActor(
|
|
|
151
156
|
if (explicit.actor !== undefined) {
|
|
152
157
|
return { kind: 'actor', value: explicit.actor };
|
|
153
158
|
}
|
|
154
|
-
|
|
159
|
+
const standard = config.export?.standard;
|
|
160
|
+
if (
|
|
161
|
+
(standard === 'cmi5' || standard === 'xapi') &&
|
|
162
|
+
adapter instanceof BaseXAPILaunchAdapter
|
|
163
|
+
) {
|
|
155
164
|
const inner = adapter.getPublisher();
|
|
156
165
|
if (!inner) return null;
|
|
157
166
|
try {
|
package/src/virtual.d.ts
CHANGED
|
@@ -7,7 +7,10 @@ declare module 'virtual:tessera-layout' {
|
|
|
7
7
|
declare module 'virtual:tessera-adapter' {
|
|
8
8
|
import type { PersistenceAdapter } from 'tessera-learn/runtime/persistence.js';
|
|
9
9
|
import type { CourseConfig } from 'tessera-learn/runtime/types.js';
|
|
10
|
-
export function createAdapter(
|
|
10
|
+
export function createAdapter(
|
|
11
|
+
config: CourseConfig,
|
|
12
|
+
options?: { manifest?: unknown; allowFallback?: boolean },
|
|
13
|
+
): PersistenceAdapter;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
declare module 'virtual:tessera-xapi-setup' {
|