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.
Files changed (57) hide show
  1. package/AGENTS.md +50 -21
  2. package/README.md +2 -2
  3. package/dist/{audit--fSWIOgK.js → audit-DsYqXbqm.js} +282 -197
  4. package/dist/audit-DsYqXbqm.js.map +1 -0
  5. package/dist/{build-commands-Qyrlsp3n.js → build-commands-BFuiAxaR.js} +4 -4
  6. package/dist/build-commands-BFuiAxaR.js.map +1 -0
  7. package/dist/{inline-config-DqAKsCNl.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 +91 -49
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts +287 -2
  14. package/dist/plugin/index.d.ts.map +1 -1
  15. package/dist/plugin/index.js +3 -3
  16. package/dist/{plugin-B-aiL9-V.js → plugin-BuMiDTmU.js} +145 -111
  17. package/dist/plugin-BuMiDTmU.js.map +1 -0
  18. package/package.json +7 -7
  19. package/src/components/DefaultLayout.svelte +2 -5
  20. package/src/components/MultipleChoice.svelte +1 -2
  21. package/src/components/Quiz.svelte +18 -26
  22. package/src/plugin/ast.ts +9 -2
  23. package/src/plugin/build-commands.ts +7 -4
  24. package/src/plugin/cli.ts +96 -46
  25. package/src/plugin/csp.ts +59 -0
  26. package/src/plugin/duplicate-cli.ts +37 -1
  27. package/src/plugin/export.ts +56 -27
  28. package/src/plugin/index.ts +138 -93
  29. package/src/plugin/inline-config.ts +4 -2
  30. package/src/plugin/manifest.ts +24 -23
  31. package/src/plugin/new-cli.ts +2 -0
  32. package/src/plugin/validate-cli.ts +5 -2
  33. package/src/plugin/validation.ts +255 -238
  34. package/src/runtime/App.svelte +14 -9
  35. package/src/runtime/Sidebar.svelte +3 -1
  36. package/src/runtime/adapters/cmi5.ts +59 -402
  37. package/src/runtime/adapters/discovery.ts +11 -0
  38. package/src/runtime/adapters/index.ts +27 -60
  39. package/src/runtime/adapters/lms-error.ts +61 -0
  40. package/src/runtime/adapters/scorm-base.ts +15 -14
  41. package/src/runtime/adapters/scorm12.ts +6 -25
  42. package/src/runtime/adapters/scorm2004.ts +12 -54
  43. package/src/runtime/adapters/web.ts +11 -4
  44. package/src/runtime/adapters/xapi-launch-base.ts +346 -0
  45. package/src/runtime/adapters/xapi.ts +26 -0
  46. package/src/runtime/fingerprint.ts +28 -0
  47. package/src/runtime/interaction-format.ts +0 -1
  48. package/src/runtime/persistence.ts +4 -0
  49. package/src/runtime/types.ts +22 -1
  50. package/src/runtime/xapi/publisher.ts +16 -15
  51. package/src/runtime/xapi/setup.ts +24 -15
  52. package/src/virtual.d.ts +4 -1
  53. package/templates/course/course.config.js +1 -0
  54. package/dist/audit--fSWIOgK.js.map +0 -1
  55. package/dist/build-commands-Qyrlsp3n.js.map +0 -1
  56. package/dist/inline-config-DqAKsCNl.js.map +0 -1
  57. package/dist/plugin-B-aiL9-V.js.map +0 -1
@@ -36,3 +36,14 @@ export function hasCMI5LaunchParams(): boolean {
36
36
  params.get('actor')
37
37
  );
38
38
  }
39
+
40
+ /** Plain xAPI ("Tin Can") launch params on the URL. No fetch token required; `auth` is the Basic credential the LMS supplies in the launch link. */
41
+ export function hasXAPILaunchParams(): boolean {
42
+ const params = new URLSearchParams(window.location.search);
43
+ return !!(
44
+ params.get('endpoint') &&
45
+ params.get('auth') &&
46
+ params.get('actor') &&
47
+ params.get('activity_id')
48
+ );
49
+ }
@@ -1,23 +1,25 @@
1
1
  import type { CourseConfig } from '../types.js';
2
+ import type { Manifest } from '../../plugin/manifest.js';
2
3
  import type { PersistenceAdapter } from '../persistence.js';
3
4
  import { WebAdapter } from './web.js';
4
5
  import { SCORM12Adapter } from './scorm12.js';
5
6
  import { SCORM2004Adapter } from './scorm2004.js';
6
7
  import { CMI5Adapter } from './cmi5.js';
8
+ import { XAPIAdapter } from './xapi.js';
7
9
  import {
8
10
  findSCORM12API,
9
11
  findSCORM2004API,
10
12
  hasCMI5LaunchParams,
13
+ hasXAPILaunchParams,
11
14
  } from './discovery.js';
15
+ import {
16
+ LMSAdapterError,
17
+ lmsWarnLabel,
18
+ missingApiError,
19
+ type LMSStandard,
20
+ } from './lms-error.js';
12
21
 
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
- }
22
+ export { LMSAdapterError, missingApiError };
21
23
 
22
24
  export interface CreateAdapterOptions {
23
25
  /**
@@ -26,59 +28,24 @@ export interface CreateAdapterOptions {
26
28
  * so dev builds stay forgiving and production builds fail loud.
27
29
  */
28
30
  allowFallback?: boolean;
31
+ /** Course manifest — lets the WebAdapter fingerprint its page structure into the storage key. */
32
+ manifest?: Manifest;
29
33
  }
30
34
 
31
- type LMSStandard = 'scorm12' | 'scorm2004' | 'cmi5';
32
-
33
- /** Per-standard LMS wiring: `detect` returns an adapter when the LMS runtime is reachable, else null. Labels are the single source for the dev warning and production error. */
34
- const LMS_ADAPTERS: Record<
35
- LMSStandard,
36
- {
37
- detect: () => PersistenceAdapter | null;
38
- warnLabel: string;
39
- name: string;
40
- missingDetail: string;
41
- }
42
- > = {
43
- scorm12: {
44
- detect: () => {
45
- const api = findSCORM12API();
46
- return api ? new SCORM12Adapter(api) : null;
47
- },
48
- warnLabel: 'SCORM 1.2 API',
49
- name: 'SCORM 1.2',
50
- missingDetail:
51
- 'No SCORM 1.2 API object found in the window.parent or window.opener chain.',
52
- },
53
- scorm2004: {
54
- detect: () => {
55
- const api = findSCORM2004API();
56
- return api ? new SCORM2004Adapter(api) : null;
57
- },
58
- warnLabel: 'SCORM 2004 API',
59
- name: 'SCORM 2004',
60
- missingDetail:
61
- 'No SCORM 2004 API object found in the window.parent or window.opener chain.',
35
+ /** Per-standard LMS wiring: `detect` returns an adapter when the LMS runtime is reachable, else null. Labels and the fail-loud error live in `./lms-error.js`. */
36
+ const LMS_ADAPTERS: Record<LMSStandard, () => PersistenceAdapter | null> = {
37
+ scorm12: () => {
38
+ const api = findSCORM12API();
39
+ return api ? new SCORM12Adapter(api) : null;
62
40
  },
63
- cmi5: {
64
- detect: () => (hasCMI5LaunchParams() ? new CMI5Adapter() : null),
65
- warnLabel: 'cmi5 launch parameters',
66
- name: 'cmi5',
67
- missingDetail:
68
- 'No cmi5 launch parameters (fetch / endpoint / activityId / actor) on the URL.',
41
+ scorm2004: () => {
42
+ const api = findSCORM2004API();
43
+ return api ? new SCORM2004Adapter(api) : null;
69
44
  },
45
+ cmi5: () => (hasCMI5LaunchParams() ? new CMI5Adapter() : null),
46
+ xapi: () => (hasXAPILaunchParams() ? new XAPIAdapter() : null),
70
47
  };
71
48
 
72
- function missingApiError(standard: LMSStandard): LMSAdapterError {
73
- const { name, missingDetail } = LMS_ADAPTERS[standard];
74
- return new LMSAdapterError(
75
- standard,
76
- `Tessera: this course is configured for ${name} but ${missingDetail} ` +
77
- `The course must be launched from an LMS that provides the ${name} runtime. ` +
78
- `If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`,
79
- );
80
- }
81
-
82
49
  /**
83
50
  * Select the appropriate persistence adapter based on course config.
84
51
  *
@@ -99,15 +66,15 @@ export function createAdapter(
99
66
  if (
100
67
  standard === 'scorm12' ||
101
68
  standard === 'scorm2004' ||
102
- standard === 'cmi5'
69
+ standard === 'cmi5' ||
70
+ standard === 'xapi'
103
71
  ) {
104
- const entry = LMS_ADAPTERS[standard];
105
- const adapter = entry.detect();
72
+ const adapter = LMS_ADAPTERS[standard]();
106
73
  if (adapter) return adapter;
107
74
  if (!allowFallback) throw missingApiError(standard);
108
75
  console.warn(
109
- `Tessera (dev): ${entry.warnLabel} not found — falling back to localStorage`,
76
+ `Tessera (dev): ${lmsWarnLabel(standard)} not found — falling back to localStorage`,
110
77
  );
111
78
  }
112
- return new WebAdapter(config);
79
+ return new WebAdapter(config, options.manifest);
113
80
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Per-standard LMS labels and the missing-API error, kept free of any adapter
3
+ * imports so both the runtime selector (`createAdapter`) and the build-time
4
+ * generated single-adapter modules can share one source of truth without
5
+ * pulling every adapter into a production bundle.
6
+ */
7
+
8
+ export type LMSStandard = 'scorm12' | 'scorm2004' | 'cmi5' | 'xapi';
9
+
10
+ export class LMSAdapterError extends Error {
11
+ standard: LMSStandard;
12
+ constructor(standard: LMSStandard, message: string) {
13
+ super(message);
14
+ this.name = 'LMSAdapterError';
15
+ this.standard = standard;
16
+ }
17
+ }
18
+
19
+ const STANDARD_INFO: Record<
20
+ LMSStandard,
21
+ { name: string; warnLabel: string; missingDetail: string }
22
+ > = {
23
+ scorm12: {
24
+ name: 'SCORM 1.2',
25
+ warnLabel: 'SCORM 1.2 API',
26
+ missingDetail:
27
+ 'No SCORM 1.2 API object found in the window.parent or window.opener chain.',
28
+ },
29
+ scorm2004: {
30
+ name: 'SCORM 2004',
31
+ warnLabel: 'SCORM 2004 API',
32
+ missingDetail:
33
+ 'No SCORM 2004 API object found in the window.parent or window.opener chain.',
34
+ },
35
+ cmi5: {
36
+ name: 'cmi5',
37
+ warnLabel: 'cmi5 launch parameters',
38
+ missingDetail:
39
+ 'No cmi5 launch parameters (fetch / endpoint / activityId / actor) on the URL.',
40
+ },
41
+ xapi: {
42
+ name: 'xAPI 1.0.3',
43
+ warnLabel: 'xAPI launch parameters',
44
+ missingDetail:
45
+ 'No xAPI launch parameters (endpoint / auth / actor / activity_id) on the URL.',
46
+ },
47
+ };
48
+
49
+ export function lmsWarnLabel(standard: LMSStandard): string {
50
+ return STANDARD_INFO[standard].warnLabel;
51
+ }
52
+
53
+ export function missingApiError(standard: LMSStandard): LMSAdapterError {
54
+ const { name, missingDetail } = STANDARD_INFO[standard];
55
+ return new LMSAdapterError(
56
+ standard,
57
+ `Tessera: this course is configured for ${name} but ${missingDetail} ` +
58
+ `The course must be launched from an LMS that provides the ${name} runtime. ` +
59
+ `If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`,
60
+ );
61
+ }
@@ -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
  }
@@ -74,7 +74,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
74
74
  return this.#masteryScore;
75
75
  }
76
76
 
77
- get #canWrite(): boolean {
77
+ protected canWrite(): boolean {
78
78
  return this.#mode === 'normal';
79
79
  }
80
80
 
@@ -97,80 +97,38 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
97
97
  }
98
98
 
99
99
  saveState(state: SavedState): void {
100
- if (!this.#canWrite) return;
101
100
  super.saveState(state);
102
101
  // §4.2.1.4 — bookmark for LMS "Resume from page N" affordances.
103
- this.queue.enqueue(
104
- () => this.api.SetValue('cmi.location', String(state.b)),
105
- 'cmi.location',
106
- );
107
- }
108
-
109
- setDuration(seconds: number): void {
110
- if (!this.#canWrite) return;
111
- super.setDuration(seconds);
112
- }
113
-
114
- reportInteraction(
115
- questionId: string,
116
- interaction: import('../interaction.js').Interaction,
117
- correct: boolean | null,
118
- ): void {
119
- if (!this.#canWrite) return;
120
- super.reportInteraction(questionId, interaction, correct);
102
+ this.set('cmi.location', String(state.b));
121
103
  }
122
104
 
123
105
  setScore(score: number): void {
124
- if (!this.#canWrite) return;
125
- 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');
126
109
  // §4.2.4.3.5 — score.scaled is bounded to [-1, 1].
127
- const scaled = formatReal107(Math.max(0, Math.min(1, score / 100)));
128
- this.queue.enqueue(
129
- () => this.api.SetValue('cmi.score.raw', raw),
130
- 'cmi.score.raw',
131
- );
132
- this.queue.enqueue(
133
- () => this.api.SetValue('cmi.score.min', '0'),
134
- 'cmi.score.min',
135
- );
136
- this.queue.enqueue(
137
- () => this.api.SetValue('cmi.score.max', '100'),
138
- 'cmi.score.max',
139
- );
140
- this.queue.enqueue(
141
- () => this.api.SetValue('cmi.score.scaled', scaled),
110
+ this.set(
142
111
  'cmi.score.scaled',
112
+ formatReal107(Math.max(0, Math.min(1, score / 100))),
143
113
  );
144
114
  }
145
115
 
146
116
  setCompletionStatus(status: 'incomplete' | 'complete'): void {
147
- if (!this.#canWrite) return;
148
- const value = status === 'complete' ? 'completed' : 'incomplete';
149
- this.queue.enqueue(
150
- () => this.api.SetValue('cmi.completion_status', value),
117
+ this.set(
151
118
  'cmi.completion_status',
119
+ status === 'complete' ? 'completed' : 'incomplete',
152
120
  );
153
121
  // §4.2.4.2 — writing 1.0 surfaces a "100%" reading on LMS dashboards.
154
- if (status === 'complete') {
155
- this.queue.enqueue(
156
- () => this.api.SetValue('cmi.progress_measure', '1'),
157
- 'cmi.progress_measure',
158
- );
159
- }
122
+ if (status === 'complete') this.set('cmi.progress_measure', '1');
160
123
  }
161
124
 
162
125
  setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
163
- if (!this.#canWrite) return;
164
126
  // Setting "unknown" explicitly prevents SCORM Cloud from rolling up
165
127
  // a null status to "passed".
166
- this.queue.enqueue(
167
- () => this.api.SetValue('cmi.success_status', status),
168
- 'cmi.success_status',
169
- );
128
+ this.set('cmi.success_status', status);
170
129
  }
171
130
 
172
131
  setExit(mode: 'suspend' | 'normal'): void {
173
- if (!this.#canWrite) return;
174
- this.queue.enqueue(() => this.api.SetValue('cmi.exit', mode), 'cmi.exit');
132
+ this.set('cmi.exit', mode);
175
133
  }
176
134
  }
@@ -1,7 +1,9 @@
1
1
  import type { PersistenceAdapter, SavedState } from '../persistence.js';
2
2
  import type { CourseConfig } from '../types.js';
3
+ import { courseIdentity } from '../types.js';
3
4
  import type { Interaction } from '../interaction.js';
4
- import { slugify } from '../slugify.js';
5
+ import type { Manifest } from '../../plugin/manifest.js';
6
+ import { structureFingerprint } from '../fingerprint.js';
5
7
 
6
8
  /**
7
9
  * Web persistence adapter — stores course state in localStorage.
@@ -11,9 +13,14 @@ export class WebAdapter implements PersistenceAdapter {
11
13
  #storageKey: string;
12
14
  #state: SavedState | null = null;
13
15
 
14
- constructor(config: CourseConfig) {
15
- const courseId = slugify(config.title || '') || 'tessera-course';
16
- this.#storageKey = `tessera-${courseId}`;
16
+ constructor(config: CourseConfig, manifest?: Manifest) {
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.
22
+ const fp = manifest ? structureFingerprint(manifest) : '';
23
+ this.#storageKey = `tessera-${base}${fp ? `-${fp}` : ''}`;
17
24
  }
18
25
 
19
26
  async init(): Promise<void> {