tessera-learn 0.2.3 → 0.3.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 +44 -20
- package/README.md +2 -2
- package/dist/{audit--fSWIOgK.js → audit-DkXqQTqn.js} +84 -27
- package/dist/audit-DkXqQTqn.js.map +1 -0
- package/dist/{build-commands-Qyrlsp3n.js → build-commands-CyzuCDXg.js} +2 -2
- package/dist/{build-commands-Qyrlsp3n.js.map → build-commands-CyzuCDXg.js.map} +1 -1
- package/dist/{inline-config-DqAKsCNl.js → inline-config-BEXyRqsJ.js} +2 -2
- package/dist/{inline-config-DqAKsCNl.js.map → inline-config-BEXyRqsJ.js.map} +1 -1
- package/dist/plugin/cli.d.ts.map +1 -1
- package/dist/plugin/cli.js +57 -46
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +280 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +3 -3
- package/dist/{plugin-B-aiL9-V.js → plugin-CFUFgwHB.js} +126 -83
- package/dist/plugin-CFUFgwHB.js.map +1 -0
- package/package.json +7 -7
- package/src/components/DefaultLayout.svelte +2 -5
- package/src/components/Quiz.svelte +18 -26
- package/src/plugin/ast.ts +9 -2
- package/src/plugin/cli.ts +45 -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 +117 -61
- package/src/plugin/manifest.ts +3 -23
- package/src/plugin/new-cli.ts +2 -0
- package/src/plugin/validation.ts +48 -12
- package/src/runtime/App.svelte +10 -8
- 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/scorm2004.ts +2 -1
- package/src/runtime/adapters/web.ts +19 -4
- package/src/runtime/adapters/xapi-launch-base.ts +346 -0
- package/src/runtime/adapters/xapi.ts +26 -0
- package/src/runtime/types.ts +19 -1
- package/src/runtime/xapi/publisher.ts +5 -1
- 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/plugin-B-aiL9-V.js.map +0 -1
|
@@ -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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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): ${
|
|
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
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SCORM2004_INTERACTION_FORMAT } from '../interaction-format.js';
|
|
2
|
+
import type { Interaction } from '../interaction.js';
|
|
2
3
|
import type { SavedState } from '../persistence.js';
|
|
3
4
|
import { BaseScormAdapter, type ScormDialect } from './scorm-base.js';
|
|
4
5
|
import {
|
|
@@ -113,7 +114,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
113
114
|
|
|
114
115
|
reportInteraction(
|
|
115
116
|
questionId: string,
|
|
116
|
-
interaction:
|
|
117
|
+
interaction: Interaction,
|
|
117
118
|
correct: boolean | null,
|
|
118
119
|
): void {
|
|
119
120
|
if (!this.#canWrite) return;
|
|
@@ -1,7 +1,21 @@
|
|
|
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 {
|
|
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
|
+
}
|
|
5
19
|
|
|
6
20
|
/**
|
|
7
21
|
* Web persistence adapter — stores course state in localStorage.
|
|
@@ -11,9 +25,10 @@ export class WebAdapter implements PersistenceAdapter {
|
|
|
11
25
|
#storageKey: string;
|
|
12
26
|
#state: SavedState | null = null;
|
|
13
27
|
|
|
14
|
-
constructor(config: CourseConfig) {
|
|
15
|
-
const
|
|
16
|
-
|
|
28
|
+
constructor(config: CourseConfig, manifest?: Manifest) {
|
|
29
|
+
const base = courseIdentity(config) || 'tessera-course';
|
|
30
|
+
const fp = manifest ? structureFingerprint(manifest) : '';
|
|
31
|
+
this.#storageKey = `tessera-${base}${fp ? `-${fp}` : ''}`;
|
|
17
32
|
}
|
|
18
33
|
|
|
19
34
|
async init(): Promise<void> {
|
|
@@ -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
|
+
}
|
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,6 +33,10 @@ 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;
|
|
@@ -46,7 +59,12 @@ export interface CourseConfig {
|
|
|
46
59
|
passingScore: number;
|
|
47
60
|
};
|
|
48
61
|
export: {
|
|
49
|
-
standard: 'web' | 'scorm12' | 'scorm2004' | 'cmi5';
|
|
62
|
+
standard: 'web' | 'scorm12' | 'scorm2004' | 'cmi5' | 'xapi';
|
|
63
|
+
/** Web export only: extend the baseline Content-Security-Policy. Each key is
|
|
64
|
+
* a directive; its sources are appended (unioned) onto the baseline. `false`
|
|
65
|
+
* drops the CSP meta entirely (for deployments that set a CSP header).
|
|
66
|
+
* Ignored unless `standard` is 'web'. */
|
|
67
|
+
csp?: false | Record<string, string[]>;
|
|
50
68
|
};
|
|
51
69
|
/**
|
|
52
70
|
* 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,7 +489,7 @@ 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
|
}
|