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.
Files changed (40) hide show
  1. package/AGENTS.md +17 -12
  2. package/README.md +1 -1
  3. package/dist/{audit-DkXqQTqn.js → audit-DsYqXbqm.js} +211 -183
  4. package/dist/audit-DsYqXbqm.js.map +1 -0
  5. package/dist/{build-commands-CyzuCDXg.js → build-commands-BFuiAxaR.js} +4 -4
  6. package/dist/build-commands-BFuiAxaR.js.map +1 -0
  7. package/dist/{inline-config-BEXyRqsJ.js → inline-config-DVvOCKht.js} +6 -6
  8. package/dist/inline-config-DVvOCKht.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +5 -1
  10. package/dist/plugin/cli.d.ts.map +1 -1
  11. package/dist/plugin/cli.js +38 -7
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts +7 -1
  14. package/dist/plugin/index.d.ts.map +1 -1
  15. package/dist/plugin/index.js +2 -2
  16. package/dist/{plugin-CFUFgwHB.js → plugin-BuMiDTmU.js} +29 -38
  17. package/dist/plugin-BuMiDTmU.js.map +1 -0
  18. package/package.json +1 -1
  19. package/src/components/MultipleChoice.svelte +1 -2
  20. package/src/plugin/build-commands.ts +7 -4
  21. package/src/plugin/cli.ts +54 -3
  22. package/src/plugin/index.ts +31 -42
  23. package/src/plugin/inline-config.ts +4 -2
  24. package/src/plugin/manifest.ts +21 -0
  25. package/src/plugin/validate-cli.ts +5 -2
  26. package/src/plugin/validation.ts +214 -233
  27. package/src/runtime/App.svelte +4 -1
  28. package/src/runtime/adapters/scorm-base.ts +15 -14
  29. package/src/runtime/adapters/scorm12.ts +6 -25
  30. package/src/runtime/adapters/scorm2004.ts +12 -55
  31. package/src/runtime/adapters/web.ts +5 -13
  32. package/src/runtime/fingerprint.ts +28 -0
  33. package/src/runtime/interaction-format.ts +0 -1
  34. package/src/runtime/persistence.ts +4 -0
  35. package/src/runtime/types.ts +3 -0
  36. package/src/runtime/xapi/publisher.ts +11 -14
  37. package/dist/audit-DkXqQTqn.js.map +0 -1
  38. package/dist/build-commands-CyzuCDXg.js.map +0 -1
  39. package/dist/inline-config-BEXyRqsJ.js.map +0 -1
  40. package/dist/plugin-CFUFgwHB.js.map +0 -1
@@ -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.queue.enqueue(
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
- const formatted = this.dialect.formatDuration(seconds);
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.queue.enqueue(
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.queue.enqueue(
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.queue.enqueue(
77
- () => this.api.LMSSetValue('cmi.core.score.raw', formatReal107(score)),
78
- 'cmi.core.score.raw',
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.queue.enqueue(
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
- const value = mode === 'suspend' ? 'suspend' : '';
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
- get #canWrite(): boolean {
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.queue.enqueue(
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
- if (!this.#canWrite) return;
126
- const raw = formatReal107(score);
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
- const scaled = formatReal107(Math.max(0, Math.min(1, score / 100)));
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
- if (!this.#canWrite) return;
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.queue.enqueue(
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
- if (!this.#canWrite) return;
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
+ }
@@ -165,7 +165,6 @@ export function formatCorrectPattern(
165
165
  export function scorm12Type(type: Interaction['type']): string {
166
166
  switch (type) {
167
167
  case 'long-fill-in':
168
- return 'fill-in';
169
168
  case 'other':
170
169
  return 'fill-in';
171
170
  default:
@@ -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
  }
@@ -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
- #fetchWithToken(
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 };