tessera-learn 0.0.1

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 (71) hide show
  1. package/AGENTS.md +1228 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -0
  4. package/dist/plugin/index.d.ts +7 -0
  5. package/dist/plugin/index.d.ts.map +1 -0
  6. package/dist/plugin/index.js +1239 -0
  7. package/dist/plugin/index.js.map +1 -0
  8. package/package.json +77 -0
  9. package/src/archiver.d.ts +27 -0
  10. package/src/components/Accordion.svelte +32 -0
  11. package/src/components/AccordionItem.svelte +144 -0
  12. package/src/components/Audio.svelte +38 -0
  13. package/src/components/Callout.svelte +81 -0
  14. package/src/components/Carousel.svelte +194 -0
  15. package/src/components/CarouselSlide.svelte +32 -0
  16. package/src/components/DefaultLayout.svelte +108 -0
  17. package/src/components/FillInTheBlank.svelte +345 -0
  18. package/src/components/Image.svelte +47 -0
  19. package/src/components/Matching.svelte +513 -0
  20. package/src/components/MultipleChoice.svelte +363 -0
  21. package/src/components/Quiz.svelte +569 -0
  22. package/src/components/RevealModal.svelte +228 -0
  23. package/src/components/Sorting.svelte +663 -0
  24. package/src/components/Video.svelte +118 -0
  25. package/src/components/index.ts +15 -0
  26. package/src/components/quiz-payload.ts +71 -0
  27. package/src/components/util.ts +24 -0
  28. package/src/index.ts +56 -0
  29. package/src/plugin/export.ts +264 -0
  30. package/src/plugin/index.ts +464 -0
  31. package/src/plugin/layout.ts +55 -0
  32. package/src/plugin/manifest.ts +330 -0
  33. package/src/plugin/quiz.ts +65 -0
  34. package/src/plugin/validation.ts +838 -0
  35. package/src/runtime/App.svelte +435 -0
  36. package/src/runtime/ErrorPage.svelte +14 -0
  37. package/src/runtime/LoadingSkeleton.svelte +26 -0
  38. package/src/runtime/Sidebar.svelte +76 -0
  39. package/src/runtime/access.ts +55 -0
  40. package/src/runtime/adapters/cmi5.ts +341 -0
  41. package/src/runtime/adapters/discovery.ts +38 -0
  42. package/src/runtime/adapters/index.ts +99 -0
  43. package/src/runtime/adapters/retry.ts +284 -0
  44. package/src/runtime/adapters/scorm12.ts +172 -0
  45. package/src/runtime/adapters/scorm2004.ts +162 -0
  46. package/src/runtime/adapters/web.ts +62 -0
  47. package/src/runtime/contexts.ts +76 -0
  48. package/src/runtime/duration.ts +29 -0
  49. package/src/runtime/hooks.svelte.ts +543 -0
  50. package/src/runtime/interaction-format.ts +132 -0
  51. package/src/runtime/interaction.ts +96 -0
  52. package/src/runtime/navigation.svelte.ts +117 -0
  53. package/src/runtime/persistence.ts +56 -0
  54. package/src/runtime/progress.svelte.ts +168 -0
  55. package/src/runtime/quiz-policy.ts +227 -0
  56. package/src/runtime/slugify.ts +17 -0
  57. package/src/runtime/types.ts +92 -0
  58. package/src/runtime/xapi/agent-rules.ts +93 -0
  59. package/src/runtime/xapi/client.ts +133 -0
  60. package/src/runtime/xapi/derive-actor.ts +90 -0
  61. package/src/runtime/xapi/publisher.ts +604 -0
  62. package/src/runtime/xapi/registry.ts +38 -0
  63. package/src/runtime/xapi/setup.ts +250 -0
  64. package/src/runtime/xapi/types.ts +106 -0
  65. package/src/runtime/xapi/uuid.ts +21 -0
  66. package/src/runtime/xapi/validation.ts +71 -0
  67. package/src/runtime/xapi/version.ts +23 -0
  68. package/src/virtual.d.ts +16 -0
  69. package/styles/base.css +194 -0
  70. package/styles/layout.css +408 -0
  71. package/styles/theme.css +36 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Quiz config desugaring. Authors drive feedback / retry / submit-gating /
3
+ * scoring with either string enums or predicate functions; this module
4
+ * normalizes both forms into predicates so `useQuiz` only ever interacts
5
+ * with the predicate API.
6
+ */
7
+ import type { Interaction } from './interaction.js';
8
+
9
+ export interface QuizQuestionResult {
10
+ /** The original interaction reported for the question. */
11
+ interaction: Interaction;
12
+ /** Whether this question's response was correct. */
13
+ correct: boolean;
14
+ /** Per-question weight from `useQuestion({ weight })`. Defaults to 1. */
15
+ weight: number;
16
+ }
17
+
18
+ /**
19
+ * State the feedback predicate is given so it can decide independently of
20
+ * the string-enum branches inside `useQuiz`. The predicate is the single
21
+ * source of truth — the enums (`'immediate'` / `'review'`) desugar into
22
+ * predicates over this same state.
23
+ */
24
+ export interface FeedbackVisibilityState {
25
+ /** Index of the question being asked about. */
26
+ questionIndex: number;
27
+ /** Has `submit()` already fired for the current attempt? */
28
+ submitted: boolean;
29
+ /** Is the quiz currently in review mode? */
30
+ reviewing: boolean;
31
+ /** Has the question been answered (the shell called `setAnswer`)? */
32
+ hasAnswered: boolean;
33
+ /**
34
+ * Has the shell explicitly revealed feedback for this question via
35
+ * `revealFeedback(index)`? Lets `'immediate'` flows distinguish "answered
36
+ * but not yet revealed" from "Check Answer button pressed."
37
+ */
38
+ revealed: boolean;
39
+ /** Number of times `submit()` has fired for this quiz instance. */
40
+ attemptCount: number;
41
+ }
42
+
43
+ export type FeedbackModePredicate = (state: FeedbackVisibilityState) => boolean;
44
+ export type RetryStrategyPredicate = (results: QuizQuestionResult[]) => Set<number>;
45
+ export type CanSubmitPredicate = (answeredCount: number, totalCount: number) => boolean;
46
+ export type ScorePredicate = (results: QuizQuestionResult[]) => number;
47
+
48
+ export interface QuizPolicyConfig {
49
+ /**
50
+ * Show feedback after each answer (`'immediate'`), only on the review screen
51
+ * (`'review'`), or via a custom predicate `(state) => boolean` returning
52
+ * whether feedback should currently be visible. Predicates receive a full
53
+ * `FeedbackVisibilityState` so they can decide independently of the enum
54
+ * branches — the enums themselves desugar to predicates over the same state.
55
+ */
56
+ feedbackMode?: 'immediate' | 'review' | FeedbackModePredicate;
57
+ /**
58
+ * On retry, clear every answer (`'full'`), preserve correct answers
59
+ * (`'incorrect-only'`), or pass a custom predicate that takes the previous
60
+ * attempt's results and returns the set of question indices to keep locked.
61
+ */
62
+ retryMode?: 'full' | 'incorrect-only' | RetryStrategyPredicate;
63
+ /**
64
+ * Custom gate for the Submit button. Defaults to "every registered
65
+ * question has an answer". Predicates take (answered, total).
66
+ */
67
+ canSubmit?: CanSubmitPredicate;
68
+ /**
69
+ * Custom score formula. Defaults to weighted-correct percentage —
70
+ * `Σ(weight × correct) / Σ(weight) × 100`. Authors must return a value in
71
+ * 0–100; values outside that range warn in dev mode.
72
+ */
73
+ score?: ScorePredicate;
74
+ /**
75
+ * If false, feedback never renders even when `feedbackMode` says it should.
76
+ * Mirrors the historical `showFeedback` flag.
77
+ */
78
+ showFeedback?: boolean;
79
+ }
80
+
81
+ /**
82
+ * Resolve the configured feedback policy into a single predicate that owns
83
+ * the "should this question's feedback be visible right now?" decision.
84
+ *
85
+ * The shipping enums desugar to:
86
+ * - `'immediate'` — visible after the shell calls `revealFeedback` for the
87
+ * question, OR while the quiz is in review mode.
88
+ * - `'review'` (default) — visible only while the quiz is in review mode.
89
+ *
90
+ * Predicates receive the full visibility state so they can encode any policy
91
+ * — e.g. "only after first wrong attempt": `(s) => s.attemptCount > 0 && s.submitted`.
92
+ *
93
+ * The `showFeedback: false` global gate is applied separately by `useQuiz`
94
+ * before this predicate runs.
95
+ */
96
+ export function resolveFeedbackMode(cfg: QuizPolicyConfig | undefined | null): FeedbackModePredicate {
97
+ const mode = cfg?.feedbackMode;
98
+ if (typeof mode === 'function') return mode;
99
+ if (mode === 'immediate') {
100
+ return (s) => s.revealed || s.reviewing;
101
+ }
102
+ // Default + 'review'
103
+ return (s) => s.reviewing;
104
+ }
105
+
106
+ function isDevMode(): boolean {
107
+ return import.meta.env?.DEV === true;
108
+ }
109
+
110
+ /**
111
+ * Resolve the configured retry strategy into a predicate that returns the
112
+ * set of question indices to lock as "already correct" on the next attempt.
113
+ *
114
+ * - `'full'` (default) — reset everything.
115
+ * - `'incorrect-only'` — keep questions the learner got right.
116
+ * - function — author decides per result.
117
+ *
118
+ * Author predicates are wrapped: a non-Set return turns into "lock nothing"
119
+ * in production and throws in dev so the bug stays local. An author returning
120
+ * `[0, 1]` instead of `new Set([0, 1])` would otherwise silently no-op the
121
+ * lock and quietly break `'incorrect-only'`-style retries.
122
+ */
123
+ export function resolveRetryStrategy(cfg: QuizPolicyConfig | undefined | null): RetryStrategyPredicate {
124
+ const mode = cfg?.retryMode;
125
+ if (typeof mode === 'function') {
126
+ return (results) => {
127
+ const raw = mode(results);
128
+ if (!(raw instanceof Set)) {
129
+ if (isDevMode()) {
130
+ throw new TypeError(
131
+ `[tessera] quiz retryMode predicate returned ${Object.prototype.toString.call(raw)}; ` +
132
+ `expected a Set<number> of question indices to lock.`
133
+ );
134
+ }
135
+ return new Set<number>();
136
+ }
137
+ return raw;
138
+ };
139
+ }
140
+ if (mode === 'incorrect-only') {
141
+ return (results) => {
142
+ const locked = new Set<number>();
143
+ results.forEach((r, i) => {
144
+ if (r.correct) locked.add(i);
145
+ });
146
+ return locked;
147
+ };
148
+ }
149
+ // Default 'full': clear every answer.
150
+ return () => new Set<number>();
151
+ }
152
+
153
+ /**
154
+ * Resolve the Submit gate. Default — all answered.
155
+ *
156
+ * Author predicates are wrapped: a non-boolean return is coerced with `!!` in
157
+ * production and throws in dev. Authors returning `answered` (a number) would
158
+ * otherwise enable Submit on `0` answered ↔ disable on a count that happens
159
+ * to equal `NaN` — silently wrong gates either way.
160
+ */
161
+ export function resolveCanSubmit(cfg: QuizPolicyConfig | undefined | null): CanSubmitPredicate {
162
+ if (typeof cfg?.canSubmit === 'function') {
163
+ const fn = cfg.canSubmit;
164
+ return (answered, total) => {
165
+ const raw = fn(answered, total);
166
+ if (typeof raw !== 'boolean') {
167
+ if (isDevMode()) {
168
+ throw new TypeError(
169
+ `[tessera] quiz canSubmit predicate returned ${typeof raw}; expected a boolean.`
170
+ );
171
+ }
172
+ return !!raw;
173
+ }
174
+ return raw;
175
+ };
176
+ }
177
+ return (answered, total) => total > 0 && answered >= total;
178
+ }
179
+
180
+ /**
181
+ * Resolve the score formula. Default — weighted-correct percentage. With all
182
+ * weights = 1 (the default for every existing course), the output equals the
183
+ * pre-Phase-5 unweighted formula.
184
+ */
185
+ export function resolveScore(cfg: QuizPolicyConfig | undefined | null): ScorePredicate {
186
+ if (typeof cfg?.score === 'function') {
187
+ return (results) => {
188
+ const raw = cfg.score!(results);
189
+ const isDev = isDevMode();
190
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) {
191
+ // NaN/Infinity/non-number can't ride through to setScore(...) — the LMS
192
+ // either rejects the cmi write or rolls it up to nonsense. Throw in dev
193
+ // so the bug stays local; clamp to 0 in prod so a runaway predicate
194
+ // can't crash the learner's session.
195
+ if (isDev) {
196
+ throw new TypeError(
197
+ `[tessera] quiz score predicate returned ${String(raw)}; expected a finite number in 0–100.`
198
+ );
199
+ }
200
+ return 0;
201
+ }
202
+ if (raw < 0 || raw > 100) {
203
+ if (isDev) {
204
+ // eslint-disable-next-line no-console
205
+ console.warn(
206
+ `[tessera] quiz score predicate returned ${raw}; expected a finite number in 0–100. ` +
207
+ `Clamping to range — LMSes reject out-of-range cmi.score.raw values.`
208
+ );
209
+ }
210
+ return Math.max(0, Math.min(100, raw));
211
+ }
212
+ return raw;
213
+ };
214
+ }
215
+ return (results) => {
216
+ if (results.length === 0) return 0;
217
+ let weighted = 0;
218
+ let totalWeight = 0;
219
+ for (const r of results) {
220
+ const w = r.weight > 0 ? r.weight : 1;
221
+ totalWeight += w;
222
+ if (r.correct) weighted += w;
223
+ }
224
+ if (totalWeight === 0) return 0;
225
+ return Math.round((weighted / totalWeight) * 100);
226
+ };
227
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Slugify a string for use as a URL-safe / filename-safe identifier.
3
+ * "My Course Title" → "my-course-title"
4
+ *
5
+ * Shared by the runtime (`WebAdapter` localStorage key) and the build-time
6
+ * exporter (`runExport` zip filename). Both want identical, deterministic
7
+ * output so a course's storage key matches its package name.
8
+ */
9
+ export function slugify(text: string): string {
10
+ return text
11
+ .toLowerCase()
12
+ .trim()
13
+ .replace(/[^\w\s-]/g, '')
14
+ .replace(/[\s_]+/g, '-')
15
+ .replace(/-+/g, '-')
16
+ .replace(/^-|-$/g, '');
17
+ }
@@ -0,0 +1,92 @@
1
+ import type { AccessFn } from './access.js';
2
+ import type { XAPIAgent } from './xapi/types.js';
3
+
4
+ /**
5
+ * Per-page quiz configuration. Single source of truth — the build plugin
6
+ * extracts this from `pageConfig.quiz` and embeds it in the manifest;
7
+ * the runtime reads it from there. Keep field shapes in sync.
8
+ */
9
+ export interface QuizConfig {
10
+ graded?: boolean;
11
+ gatesProgress?: boolean;
12
+ maxAttempts?: number;
13
+ showFeedback?: boolean;
14
+ feedbackMode?: 'review' | 'immediate';
15
+ retryMode?: 'full' | 'incorrect-only';
16
+ }
17
+
18
+ export interface CourseConfig {
19
+ title: string;
20
+ description?: string;
21
+ author?: string;
22
+ version?: string;
23
+ branding?: {
24
+ logo?: string;
25
+ primaryColor?: string;
26
+ fontFamily?: string;
27
+ };
28
+ navigation: {
29
+ mode: 'free' | 'sequential';
30
+ canAccess?: AccessFn;
31
+ };
32
+ completion: {
33
+ mode: 'quiz' | 'percentage';
34
+ percentageThreshold?: number;
35
+ };
36
+ scoring: {
37
+ passingScore: number;
38
+ };
39
+ export: {
40
+ standard: 'web' | 'scorm12' | 'scorm2004' | 'cmi5';
41
+ };
42
+ /**
43
+ * Optional xAPI destination(s) for custom statement publishing via
44
+ * `useXAPI()`. A single object or an array of destinations. Under cmi5
45
+ * export, the sentinel `endpoint: 'lms'` re-uses the LMS launch's
46
+ * credentials and shares the cmi5 adapter's queue.
47
+ */
48
+ xapi?: XAPIConfig | XAPIConfig[];
49
+ }
50
+
51
+ /**
52
+ * cmi5 launch-inherited destination. Only valid under `export.standard:
53
+ * 'cmi5'`. Auth, actor, activityId, and registration are taken from the
54
+ * launch URL, so no other fields are accepted.
55
+ */
56
+ export interface XAPILMSConfig {
57
+ endpoint: 'lms';
58
+ }
59
+
60
+ /**
61
+ * Explicit LRS destination. The author provides every field. `actor` is
62
+ * optional under SCORM (synthesized from `cmi.core.student_id` /
63
+ * `cmi.learner_id`) and required under web.
64
+ */
65
+ export interface XAPIExplicitConfig {
66
+ /** Absolute http(s) URL of the LRS Statements endpoint base. */
67
+ endpoint: string;
68
+ /**
69
+ * Basic-auth credential value (the part after "Basic "), or a function
70
+ * that resolves one. Function form is re-invoked once on 401 to cover
71
+ * short-lived tokens.
72
+ */
73
+ auth: string | (() => string | Promise<string>);
74
+ /**
75
+ * Identified Agent or a resolver function. Required for web export;
76
+ * optional under SCORM where it can be synthesized from the LMS data
77
+ * model. Optional under cmi5 where it can be inherited from the launch.
78
+ */
79
+ actor?: XAPIAgent | (() => XAPIAgent | Promise<XAPIAgent>);
80
+ /** xAPI activity IRI scoped to this destination. */
81
+ activityId: string;
82
+ /** Optional UUID v4 — primarily a cmi5 launch concept. */
83
+ registration?: string;
84
+ /**
85
+ * Override for the SCORM-derived actor's `account.homePage`. Defaults
86
+ * to the activityId origin when activityId is http(s); required when
87
+ * activityId uses a non-http(s) scheme.
88
+ */
89
+ actorAccountHomePage?: string;
90
+ }
91
+
92
+ export type XAPIConfig = XAPILMSConfig | XAPIExplicitConfig;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * xAPI Identified Agent and Basic-auth credential validation rules.
3
+ *
4
+ * Pure logic — no Svelte/runtime imports. Imported by both `publisher.ts`
5
+ * (runtime validation of resolved actor / auth) and `plugin/validation.ts`
6
+ * (build-time validation of static `course.config.js` actor / auth).
7
+ * Keeping the rules in one place prevents the two callsites from drifting.
8
+ */
9
+
10
+ /**
11
+ * Validate that a candidate is an Identified Agent per xAPI 1.0.3.
12
+ * Returns null on success or a human-readable error suffix on failure.
13
+ *
14
+ * Suffixes are prefix-friendly: callers concatenate their own label
15
+ * (`xapi.actor`, `xapi[0].actor`, etc.) with a single space — no "actor"
16
+ * appears in the suffix to avoid doubling.
17
+ */
18
+ export function validateAgent(actor: unknown): string | null {
19
+ if (!actor || typeof actor !== 'object') {
20
+ return 'must be an object';
21
+ }
22
+ const a = actor as Record<string, unknown>;
23
+ if (Array.isArray(a.member) && a.member.length > 0) {
24
+ return 'is a Group (has `member`); v1 supports Identified Agents only';
25
+ }
26
+ let count = 0;
27
+ if (a.mbox !== undefined) count++;
28
+ if (a.mbox_sha1sum !== undefined) count++;
29
+ if (a.openid !== undefined) count++;
30
+ if (a.account !== undefined) count++;
31
+ if (count === 0) {
32
+ return 'must have one of mbox, mbox_sha1sum, openid, or account (Identified Agent rule)';
33
+ }
34
+ if (count > 1) {
35
+ return 'must have exactly one IFI (mbox / mbox_sha1sum / openid / account), not multiple';
36
+ }
37
+ if (a.mbox !== undefined) {
38
+ if (typeof a.mbox !== 'string' || !a.mbox.startsWith('mailto:')) {
39
+ return '.mbox must be a string starting with "mailto:"';
40
+ }
41
+ }
42
+ if (a.mbox_sha1sum !== undefined) {
43
+ if (typeof a.mbox_sha1sum !== 'string' || !/^[0-9a-f]{40}$/i.test(a.mbox_sha1sum)) {
44
+ return '.mbox_sha1sum must be a 40-character hex string';
45
+ }
46
+ }
47
+ if (a.openid !== undefined) {
48
+ if (typeof a.openid !== 'string' || !a.openid) {
49
+ return '.openid must be a non-empty string';
50
+ }
51
+ try {
52
+ new URL(a.openid);
53
+ } catch {
54
+ return '.openid must be an absolute URI';
55
+ }
56
+ }
57
+ if (a.account !== undefined) {
58
+ const acc = a.account as Record<string, unknown>;
59
+ if (!acc || typeof acc !== 'object') {
60
+ return '.account must be an object with homePage and name';
61
+ }
62
+ if (typeof acc.homePage !== 'string' || !acc.homePage) {
63
+ return '.account.homePage must be a non-empty string';
64
+ }
65
+ try {
66
+ new URL(acc.homePage);
67
+ } catch {
68
+ return '.account.homePage must be an absolute URL';
69
+ }
70
+ if (typeof acc.name !== 'string' || !acc.name) {
71
+ return '.account.name must be a non-empty string';
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Validate a Basic-auth credential string (the value after "Basic ").
79
+ * v1 supports Basic only. Bearer is a hard error so OAuth users see the
80
+ * non-goal explicitly.
81
+ */
82
+ export function validateAuthCredential(auth: string): string | null {
83
+ if (typeof auth !== 'string' || !auth) {
84
+ return 'auth must be a non-empty string';
85
+ }
86
+ if (/^basic\s/i.test(auth)) {
87
+ return "auth must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.";
88
+ }
89
+ if (/^bearer\s/i.test(auth)) {
90
+ return 'Bearer/OAuth credentials are not supported in v1. Use Basic auth, or wrap your token-exchange in an auth function that returns a Basic credential.';
91
+ }
92
+ return null;
93
+ }
@@ -0,0 +1,133 @@
1
+ import type {
2
+ PartialStatement,
3
+ SendStatementOptions,
4
+ SendStatementResult,
5
+ XAPIAgent,
6
+ Statement,
7
+ DestinationOutcome,
8
+ } from './types.js';
9
+ import { XAPIPublisher, XAPIConfigError } from './publisher.js';
10
+ import { validatePartialStatement } from './validation.js';
11
+ import { uuidv4 } from './uuid.js';
12
+
13
+ /**
14
+ * What `useXAPI()` returns. Wraps one or more `XAPIPublisher` destinations
15
+ * and presents a single `sendStatement` API to authors. The single-
16
+ * destination form (`xapi: { ... }`) and the multi-destination form
17
+ * (`xapi: [{...}, {...}]`) flow through the same machinery — single is
18
+ * just a one-element array.
19
+ *
20
+ * Each destination has its own queue, auth resolver, and retry loop;
21
+ * failures are isolated. One UUID is minted per `sendStatement` call and
22
+ * reused across destinations so analytics keyed on `statement.id` see
23
+ * identical statements regardless of which LRS they hit first.
24
+ */
25
+ export class XAPIClient {
26
+ readonly #publishers: XAPIPublisher[];
27
+
28
+ constructor(publishers: XAPIPublisher[]) {
29
+ if (publishers.length === 0) {
30
+ throw new Error('XAPIClient: at least one publisher is required');
31
+ }
32
+ this.#publishers = publishers;
33
+ }
34
+
35
+ /**
36
+ * Send a statement to every configured destination. The returned
37
+ * promise resolves once all destinations have settled (success or
38
+ * final failure). Per-destination outcomes are exposed on
39
+ * `result.destinations` so authors can act on partial failures.
40
+ *
41
+ * Validation failures throw synchronously — the returned promise
42
+ * rejects before any HTTP traffic.
43
+ */
44
+ sendStatement(
45
+ partial: PartialStatement,
46
+ options?: SendStatementOptions
47
+ ): Promise<SendStatementResult> {
48
+ try {
49
+ validatePartialStatement(partial);
50
+ } catch (err) {
51
+ return Promise.reject(err);
52
+ }
53
+ // cmi5 §9.3.6 — Terminated must be the last statement of the session.
54
+ // The constraint is per-destination: only cmi5-mode publishers (the
55
+ // shared-queue cmi5 adapter case) need to block author sends during
56
+ // unload. Independent explicit-LRS destinations have no such ordering
57
+ // requirement and stay healthy until the browser tears them down.
58
+ const blocked = (p: XAPIPublisher) => p.isUnloading() && p.isCmi5Mode();
59
+ if (this.#publishers.every(blocked)) {
60
+ return Promise.reject(
61
+ new XAPIConfigError(
62
+ 'XAPIClient.sendStatement: page is unloading; author statements queued during unload are dropped to keep Terminated last (cmi5 §9.3.6).'
63
+ )
64
+ );
65
+ }
66
+ const id = uuidv4();
67
+ // The first publisher's built statement is what we return as the
68
+ // canonical `statement` in the result. Other destinations may have
69
+ // a different actor/grouping but the verb/object/result/timestamp
70
+ // are author-supplied and identical.
71
+ let primary: Statement | null = null;
72
+ const destinationPromises: Promise<DestinationOutcome>[] = [];
73
+ for (let i = 0; i < this.#publishers.length; i++) {
74
+ const pub = this.#publishers[i];
75
+ const built = pub.buildStatement(partial, { id });
76
+ if (i === 0) primary = built;
77
+ if (blocked(pub)) {
78
+ destinationPromises.push(
79
+ Promise.resolve<DestinationOutcome>({
80
+ endpoint: pub.getEndpoint(),
81
+ ok: false,
82
+ error: new XAPIConfigError(
83
+ 'destination skipped: cmi5 publisher is unloading; statement dropped to keep Terminated last (cmi5 §9.3.6).'
84
+ ),
85
+ })
86
+ );
87
+ continue;
88
+ }
89
+ destinationPromises.push(pub.enqueueBuilt(built, options));
90
+ }
91
+ return Promise.all(destinationPromises).then((destinations) => ({
92
+ statementId: id,
93
+ statement: primary as Statement,
94
+ destinations,
95
+ }));
96
+ }
97
+
98
+ /**
99
+ * Returns the actor of the first destination. For analytics object-id
100
+ * construction (`${xapi.getActivityId()}#widget-1`) this is what
101
+ * authors typically want.
102
+ */
103
+ getActor(): XAPIAgent {
104
+ return this.#publishers[0].getActor();
105
+ }
106
+
107
+ /** Returns the activityId of the first destination. */
108
+ getActivityId(): string {
109
+ return this.#publishers[0].getActivityId();
110
+ }
111
+
112
+ /** Returns the sessionId of the first destination. */
113
+ getSessionId(): string {
114
+ return this.#publishers[0].getSessionId();
115
+ }
116
+
117
+ /** Returns the underlying publishers — mostly useful for tests. */
118
+ getPublishers(): readonly XAPIPublisher[] {
119
+ return this.#publishers;
120
+ }
121
+
122
+ /**
123
+ * Propagate "page is unloading" to every publisher. App.svelte's
124
+ * pagehide / beforeunload handler calls this before
125
+ * `adapter.terminate()` so independent (explicit-endpoint) publishers
126
+ * also stop accepting author sends during the close path. Idempotent;
127
+ * the cmi5 adapter calls `markUnloading()` on its own publisher
128
+ * separately and either order is fine.
129
+ */
130
+ markUnloading(): void {
131
+ for (const p of this.#publishers) p.markUnloading();
132
+ }
133
+ }
@@ -0,0 +1,90 @@
1
+ import type { XAPIAgent } from './types.js';
2
+ import type { SCORM12API } from '../adapters/scorm12.js';
3
+ import type { SCORM2004API } from '../adapters/scorm2004.js';
4
+
5
+ /**
6
+ * Compute the default SCORM-derived `account.homePage` from the activity
7
+ * IRI. Returns the URL origin when `activityId` is an http(s) URL,
8
+ * otherwise null. Callers that get null and have no `actorAccountHomePage`
9
+ * override should treat it as a config error (the build-time validator
10
+ * already enforces this; this is a runtime fallback for completeness).
11
+ */
12
+ export function defaultAccountHomePage(activityId: string): string | null {
13
+ try {
14
+ const url = new URL(activityId);
15
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
16
+ return url.origin;
17
+ }
18
+ return null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Synthesize an Identified Agent for SCORM 1.2 from the LMS data model.
26
+ *
27
+ * { account: { homePage, name: cmi.core.student_id },
28
+ * name: cmi.core.student_name,
29
+ * objectType: 'Agent' }
30
+ *
31
+ * The `account` IFI satisfies xAPI's Identified Agent rule. `homePage`
32
+ * defaults to the activityId origin so analytics keyed on actor identity
33
+ * stay stable across LMS hosts; the author's `actorAccountHomePage`
34
+ * overrides when the authority namespace is elsewhere.
35
+ *
36
+ * Returns null if `student_id` is missing — caller should not construct
37
+ * a publisher in that case (the LRS would 400 on every send anyway).
38
+ */
39
+ export function synthesizeSCORM12Actor(
40
+ api: SCORM12API,
41
+ activityId: string,
42
+ actorAccountHomePage?: string
43
+ ): XAPIAgent | null {
44
+ let id = '';
45
+ let name = '';
46
+ try {
47
+ id = api.LMSGetValue('cmi.core.student_id') || '';
48
+ } catch {}
49
+ try {
50
+ name = api.LMSGetValue('cmi.core.student_name') || '';
51
+ } catch {}
52
+ if (!id) return null;
53
+ const homePage = actorAccountHomePage ?? defaultAccountHomePage(activityId);
54
+ if (!homePage) return null;
55
+ const agent: XAPIAgent = {
56
+ account: { homePage, name: id },
57
+ objectType: 'Agent',
58
+ };
59
+ if (name) agent.name = name;
60
+ return agent;
61
+ }
62
+
63
+ /**
64
+ * Synthesize an Identified Agent for SCORM 2004 from the LMS data model.
65
+ * Same structure as SCORM 1.2 but reads from `cmi.learner_id` /
66
+ * `cmi.learner_name` (the renamed 2004 fields).
67
+ */
68
+ export function synthesizeSCORM2004Actor(
69
+ api: SCORM2004API,
70
+ activityId: string,
71
+ actorAccountHomePage?: string
72
+ ): XAPIAgent | null {
73
+ let id = '';
74
+ let name = '';
75
+ try {
76
+ id = api.GetValue('cmi.learner_id') || '';
77
+ } catch {}
78
+ try {
79
+ name = api.GetValue('cmi.learner_name') || '';
80
+ } catch {}
81
+ if (!id) return null;
82
+ const homePage = actorAccountHomePage ?? defaultAccountHomePage(activityId);
83
+ if (!homePage) return null;
84
+ const agent: XAPIAgent = {
85
+ account: { homePage, name: id },
86
+ objectType: 'Agent',
87
+ };
88
+ if (name) agent.name = name;
89
+ return agent;
90
+ }