@lessonkit/react 0.9.3 → 1.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.
- package/README.md +40 -88
- package/block-catalog.v1.json +39 -1
- package/dist/index.cjs +650 -428
- package/dist/index.d.cts +256 -45
- package/dist/index.d.ts +256 -45
- package/dist/index.js +638 -395
- package/package.json +19 -6
package/dist/index.js
CHANGED
|
@@ -3,8 +3,10 @@ import { useEffect as useEffect2, useId, useMemo as useMemo3, useRef as useRef2,
|
|
|
3
3
|
import { visuallyHiddenStyle } from "@lessonkit/accessibility";
|
|
4
4
|
|
|
5
5
|
// src/context.tsx
|
|
6
|
+
import { createContext } from "react";
|
|
7
|
+
|
|
8
|
+
// src/provider/useLessonkitProviderRuntime.ts
|
|
6
9
|
import {
|
|
7
|
-
createContext,
|
|
8
10
|
useCallback,
|
|
9
11
|
useEffect,
|
|
10
12
|
useLayoutEffect,
|
|
@@ -12,241 +14,102 @@ import {
|
|
|
12
14
|
useRef,
|
|
13
15
|
useState
|
|
14
16
|
} from "react";
|
|
15
|
-
import { createTrackingClient as createTrackingClient2 } from "@lessonkit/core";
|
|
17
|
+
import { createLessonkitRuntime, createTrackingClient as createTrackingClient2, assertValidId } from "@lessonkit/core";
|
|
16
18
|
import { createInMemoryXAPIQueue } from "@lessonkit/xapi";
|
|
17
|
-
import { telemetryEventToXAPIStatement as
|
|
19
|
+
import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement3 } from "@lessonkit/xapi";
|
|
18
20
|
|
|
19
21
|
// src/runtime/emitTelemetry.ts
|
|
20
|
-
import {
|
|
22
|
+
import { buildTelemetryEvent, tryBuildTelemetryEvent } from "@lessonkit/core";
|
|
23
|
+
|
|
24
|
+
// src/runtime/telemetryPipeline.ts
|
|
25
|
+
import {
|
|
26
|
+
createTelemetryPipeline,
|
|
27
|
+
createTrackingPipelineSink
|
|
28
|
+
} from "@lessonkit/core";
|
|
21
29
|
import { telemetryEventToXAPIStatement } from "@lessonkit/xapi";
|
|
22
30
|
|
|
23
31
|
// src/runtime/lxpackBridge.ts
|
|
24
32
|
import {
|
|
25
|
-
|
|
33
|
+
dispatchBridgeAction,
|
|
34
|
+
forwardTelemetryToBridge,
|
|
35
|
+
getLxpackBridge,
|
|
26
36
|
mapLessonkitTelemetryToBridgeAction,
|
|
27
|
-
normalizePassingThreshold,
|
|
28
|
-
normalizeScore,
|
|
29
37
|
telemetryEventToLessonkit
|
|
30
38
|
} from "@lessonkit/lxpack/bridge";
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
if (fromSdk) return fromSdk;
|
|
34
|
-
if (typeof window === "undefined") return null;
|
|
35
|
-
const parent = window.parent;
|
|
36
|
-
if (!parent || parent === window) return null;
|
|
37
|
-
return parent.lxpack ?? null;
|
|
39
|
+
function forwardTelemetryToLxpack(event, mode = "auto") {
|
|
40
|
+
forwardTelemetryToBridge(event, mode);
|
|
38
41
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return;
|
|
45
|
-
case "completeCourse":
|
|
46
|
-
bridge.completeCourse?.();
|
|
47
|
-
return;
|
|
48
|
-
case "submitAssessment": {
|
|
49
|
-
const scaled = normalizeScore({
|
|
50
|
-
score: action.score,
|
|
51
|
-
maxScore: action.maxScore
|
|
52
|
-
});
|
|
53
|
-
if (scaled === null) return;
|
|
54
|
-
bridge.submitAssessment?.({
|
|
55
|
-
id: action.id,
|
|
56
|
-
score: scaled,
|
|
57
|
-
passingScore: normalizePassingThreshold({
|
|
58
|
-
passingScore: action.passingScore,
|
|
59
|
-
maxScore: action.maxScore
|
|
60
|
-
}),
|
|
61
|
-
maxScore: action.maxScore
|
|
62
|
-
});
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
case "track":
|
|
66
|
-
bridge.track?.(action.event);
|
|
67
|
-
return;
|
|
68
|
-
default:
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
42
|
+
|
|
43
|
+
// src/runtime/telemetryPipeline.ts
|
|
44
|
+
function isDevEnvironment() {
|
|
45
|
+
const g = globalThis;
|
|
46
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
71
47
|
}
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
48
|
+
function createLegacyPipeline(opts, extraSinks = []) {
|
|
49
|
+
return createTelemetryPipeline([
|
|
50
|
+
createTrackingPipelineSink("tracking", (event) => opts.tracking.track(event)),
|
|
51
|
+
{
|
|
52
|
+
id: "xapi",
|
|
53
|
+
emit(event) {
|
|
54
|
+
try {
|
|
55
|
+
const statement = telemetryEventToXAPIStatement(event);
|
|
56
|
+
if (statement) opts.xapi?.send(statement);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (isDevEnvironment()) {
|
|
59
|
+
console.warn(
|
|
60
|
+
"[lessonkit] xAPI mapping skipped:",
|
|
61
|
+
err instanceof Error ? err.message : err
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "lxpack-bridge",
|
|
69
|
+
emit(event) {
|
|
70
|
+
forwardTelemetryToLxpack(event, opts.lxpackBridge);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
...extraSinks
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
function emitThroughPipeline(event, opts, extraSinks) {
|
|
77
|
+
createLegacyPipeline(opts, extraSinks).emit(event);
|
|
80
78
|
}
|
|
81
79
|
|
|
82
80
|
// src/runtime/emitTelemetry.ts
|
|
83
81
|
var warnedMissingCourseId = false;
|
|
84
|
-
|
|
85
|
-
function isDevEnvironment() {
|
|
82
|
+
function isDevEnvironment2() {
|
|
86
83
|
const g = globalThis;
|
|
87
84
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
88
85
|
}
|
|
89
86
|
function emitTelemetry(tracking, xapi, event, opts) {
|
|
90
87
|
if (!event.courseId) {
|
|
91
|
-
if (
|
|
88
|
+
if (isDevEnvironment2() && !warnedMissingCourseId) {
|
|
92
89
|
warnedMissingCourseId = true;
|
|
93
90
|
console.warn("[lessonkit] telemetry event missing courseId");
|
|
94
91
|
}
|
|
95
92
|
return;
|
|
96
93
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
} catch (err) {
|
|
102
|
-
if (isDevEnvironment()) {
|
|
103
|
-
console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
forwardTelemetryToLxpack(event, opts?.lxpackBridge ?? "auto");
|
|
107
|
-
}
|
|
108
|
-
function buildTrackEvent(opts) {
|
|
109
|
-
const base = {
|
|
110
|
-
timestamp: nowIso(),
|
|
111
|
-
courseId: opts.courseId,
|
|
112
|
-
sessionId: opts.sessionId,
|
|
113
|
-
attemptId: opts.attemptId,
|
|
114
|
-
user: opts.user
|
|
94
|
+
const legacy = {
|
|
95
|
+
tracking,
|
|
96
|
+
xapi,
|
|
97
|
+
lxpackBridge: opts?.lxpackBridge ?? "auto"
|
|
115
98
|
};
|
|
116
|
-
|
|
117
|
-
case "course_started":
|
|
118
|
-
return { name: "course_started", ...base };
|
|
119
|
-
case "course_completed":
|
|
120
|
-
return { name: "course_completed", ...base };
|
|
121
|
-
case "lesson_started": {
|
|
122
|
-
const data = opts.data;
|
|
123
|
-
const lessonId = opts.lessonId ?? data?.lessonId;
|
|
124
|
-
if (!lessonId) throw new Error("lesson_started requires lessonId");
|
|
125
|
-
return {
|
|
126
|
-
name: "lesson_started",
|
|
127
|
-
...base,
|
|
128
|
-
lessonId,
|
|
129
|
-
data: { ...data, lessonId }
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
case "lesson_completed":
|
|
133
|
-
case "lesson_time_on_task": {
|
|
134
|
-
const data = opts.data;
|
|
135
|
-
const lessonId = opts.lessonId ?? data?.lessonId;
|
|
136
|
-
if (!lessonId) throw new Error(`${opts.name} requires lessonId`);
|
|
137
|
-
return {
|
|
138
|
-
name: opts.name,
|
|
139
|
-
...base,
|
|
140
|
-
lessonId,
|
|
141
|
-
data: { ...data, lessonId }
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
case "quiz_answered": {
|
|
145
|
-
const data = opts.data;
|
|
146
|
-
const lessonId = opts.lessonId;
|
|
147
|
-
if (!lessonId) throw new Error("quiz_answered requires active lessonId");
|
|
148
|
-
return { name: "quiz_answered", ...base, lessonId, data };
|
|
149
|
-
}
|
|
150
|
-
case "quiz_completed": {
|
|
151
|
-
const data = opts.data;
|
|
152
|
-
const lessonId = opts.lessonId;
|
|
153
|
-
if (!lessonId) throw new Error("quiz_completed requires active lessonId");
|
|
154
|
-
return { name: "quiz_completed", ...base, lessonId, data };
|
|
155
|
-
}
|
|
156
|
-
case "interaction":
|
|
157
|
-
return {
|
|
158
|
-
name: "interaction",
|
|
159
|
-
...base,
|
|
160
|
-
lessonId: opts.lessonId,
|
|
161
|
-
data: opts.data
|
|
162
|
-
};
|
|
163
|
-
default:
|
|
164
|
-
return { name: opts.name, ...base };
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
function tryBuildTrackEvent(opts) {
|
|
168
|
-
const isQuiz = opts.name === "quiz_answered" || opts.name === "quiz_completed";
|
|
169
|
-
if (isQuiz && !opts.lessonId) {
|
|
170
|
-
if (isDevEnvironment() && !warnedMissingQuizLesson) {
|
|
171
|
-
warnedMissingQuizLesson = true;
|
|
172
|
-
console.warn(
|
|
173
|
-
`[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
return null;
|
|
177
|
-
}
|
|
178
|
-
return buildTrackEvent(opts);
|
|
99
|
+
emitThroughPipeline(event, legacy, opts?.extraSinks);
|
|
179
100
|
}
|
|
180
101
|
|
|
181
102
|
// src/runtime/ports.ts
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
function createSessionStoragePort() {
|
|
190
|
-
if (typeof sessionStorage === "undefined") return createNoopStorage();
|
|
191
|
-
return {
|
|
192
|
-
getItem: (key) => {
|
|
193
|
-
try {
|
|
194
|
-
return sessionStorage.getItem(key);
|
|
195
|
-
} catch {
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
198
|
-
},
|
|
199
|
-
setItem: (key, value) => {
|
|
200
|
-
try {
|
|
201
|
-
sessionStorage.setItem(key, value);
|
|
202
|
-
} catch {
|
|
203
|
-
}
|
|
204
|
-
},
|
|
205
|
-
removeItem: (key) => {
|
|
206
|
-
try {
|
|
207
|
-
sessionStorage.removeItem(key);
|
|
208
|
-
} catch {
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
};
|
|
212
|
-
}
|
|
103
|
+
import {
|
|
104
|
+
createDefaultClock,
|
|
105
|
+
createGlobalTimer,
|
|
106
|
+
createNoopStorage,
|
|
107
|
+
createSessionStoragePort,
|
|
108
|
+
resetStoragePortForTests
|
|
109
|
+
} from "@lessonkit/core";
|
|
213
110
|
|
|
214
111
|
// src/runtime/progress.ts
|
|
215
|
-
|
|
216
|
-
let activeLessonId;
|
|
217
|
-
let completedLessonIds = /* @__PURE__ */ new Set();
|
|
218
|
-
let courseCompleted = false;
|
|
219
|
-
const lessonStartTimes = /* @__PURE__ */ new Map();
|
|
220
|
-
return {
|
|
221
|
-
getState: () => ({
|
|
222
|
-
activeLessonId,
|
|
223
|
-
completedLessonIds: new Set(completedLessonIds),
|
|
224
|
-
courseCompleted
|
|
225
|
-
}),
|
|
226
|
-
setActiveLesson: (lessonId, startedAtMs) => {
|
|
227
|
-
const previousLessonId = activeLessonId;
|
|
228
|
-
activeLessonId = lessonId;
|
|
229
|
-
lessonStartTimes.set(lessonId, startedAtMs);
|
|
230
|
-
return { previousLessonId };
|
|
231
|
-
},
|
|
232
|
-
completeLesson: (lessonId, completedAtMs) => {
|
|
233
|
-
if (completedLessonIds.has(lessonId)) return { didComplete: false };
|
|
234
|
-
completedLessonIds = new Set(completedLessonIds).add(lessonId);
|
|
235
|
-
if (activeLessonId === lessonId) {
|
|
236
|
-
activeLessonId = void 0;
|
|
237
|
-
}
|
|
238
|
-
const startedAt = lessonStartTimes.get(lessonId);
|
|
239
|
-
lessonStartTimes.delete(lessonId);
|
|
240
|
-
const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
|
|
241
|
-
return { durationMs, didComplete: true };
|
|
242
|
-
},
|
|
243
|
-
completeCourse: () => {
|
|
244
|
-
if (courseCompleted) return { didComplete: false };
|
|
245
|
-
courseCompleted = true;
|
|
246
|
-
return { didComplete: true };
|
|
247
|
-
}
|
|
248
|
-
};
|
|
249
|
-
}
|
|
112
|
+
import { createProgressController } from "@lessonkit/core";
|
|
250
113
|
|
|
251
114
|
// src/runtime/xapi.ts
|
|
252
115
|
import { createXAPIClient } from "@lessonkit/xapi";
|
|
@@ -264,56 +127,63 @@ function createXapiClientFromConfig(config, queue) {
|
|
|
264
127
|
}
|
|
265
128
|
|
|
266
129
|
// src/runtime/session.ts
|
|
267
|
-
import {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
130
|
+
import {
|
|
131
|
+
SESSION_STORAGE_KEY,
|
|
132
|
+
getTabSessionId,
|
|
133
|
+
resolveSessionId,
|
|
134
|
+
hasCourseStarted,
|
|
135
|
+
markCourseStarted,
|
|
136
|
+
hasCourseStartedEmittedToTracking,
|
|
137
|
+
markCourseStartedEmittedToTracking,
|
|
138
|
+
hasCourseStartedPipelineDelivered,
|
|
139
|
+
markCourseStartedPipelineDelivered,
|
|
140
|
+
migrateCourseStartedMark
|
|
141
|
+
} from "@lessonkit/core";
|
|
142
|
+
|
|
143
|
+
// src/runtime/courseStartedPipeline.ts
|
|
144
|
+
import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement2 } from "@lessonkit/xapi";
|
|
145
|
+
function emitCourseStartedNonTrackingPipeline(opts) {
|
|
146
|
+
let xapiStatementSent = false;
|
|
147
|
+
if (!opts.skipXapi && opts.xapi) {
|
|
148
|
+
const statement = telemetryEventToXAPIStatement2(opts.event);
|
|
149
|
+
if (statement) {
|
|
150
|
+
opts.xapi.send(statement);
|
|
151
|
+
xapiStatementSent = true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
forwardTelemetryToLxpack(opts.event, opts.lxpackBridge);
|
|
155
|
+
const emitCtx = {
|
|
156
|
+
courseId: opts.event.courseId,
|
|
157
|
+
sessionId: opts.event.sessionId,
|
|
158
|
+
attemptId: opts.event.attemptId
|
|
159
|
+
};
|
|
160
|
+
for (const sink of opts.extraSinks ?? []) {
|
|
161
|
+
sink.emit(opts.event, emitCtx);
|
|
297
162
|
}
|
|
163
|
+
return { xapiStatementSent };
|
|
298
164
|
}
|
|
299
165
|
|
|
300
166
|
// src/runtime/plugins.ts
|
|
301
|
-
import {
|
|
167
|
+
import { createPluginRegistry } from "@lessonkit/core";
|
|
302
168
|
function createReactPluginHost(plugins) {
|
|
303
169
|
if (!plugins?.length) return null;
|
|
304
|
-
return
|
|
170
|
+
return createPluginRegistry(plugins);
|
|
305
171
|
}
|
|
306
172
|
function buildPluginContext(opts) {
|
|
307
173
|
return {
|
|
308
174
|
courseId: opts.courseId,
|
|
309
175
|
sessionId: opts.sessionId,
|
|
310
|
-
attemptId: opts.attemptId
|
|
176
|
+
attemptId: opts.attemptId,
|
|
177
|
+
user: opts.user
|
|
311
178
|
};
|
|
312
179
|
}
|
|
313
180
|
function emitTelemetryWithPlugins(opts) {
|
|
314
181
|
const next = opts.pluginHost ? opts.pluginHost.runTelemetry(opts.event, opts.pluginCtx) : opts.event;
|
|
315
182
|
if (next === null) return;
|
|
316
|
-
emitTelemetry(opts.tracking, opts.xapi, next, {
|
|
183
|
+
emitTelemetry(opts.tracking, opts.xapi, next, {
|
|
184
|
+
lxpackBridge: opts.lxpackBridge ?? "auto",
|
|
185
|
+
extraSinks: opts.extraSinks
|
|
186
|
+
});
|
|
317
187
|
}
|
|
318
188
|
|
|
319
189
|
// src/runtime/telemetry.ts
|
|
@@ -338,71 +208,217 @@ async function disposeTrackingClient(client) {
|
|
|
338
208
|
}
|
|
339
209
|
}
|
|
340
210
|
|
|
341
|
-
// src/
|
|
342
|
-
import { jsx } from "react/jsx-runtime";
|
|
343
|
-
var LessonkitContext = createContext(null);
|
|
211
|
+
// src/provider/useLessonkitProviderRuntime.ts
|
|
344
212
|
var useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
|
345
213
|
var defaultStorage = createSessionStoragePort();
|
|
346
214
|
function isTrackingActive(tracking) {
|
|
347
215
|
return tracking?.enabled !== false;
|
|
348
216
|
}
|
|
349
|
-
function
|
|
217
|
+
function isCourseStartedSinkSettled(result) {
|
|
218
|
+
return result === "emitted";
|
|
219
|
+
}
|
|
220
|
+
function buildCourseStartedEvent(opts) {
|
|
350
221
|
const pluginCtx = buildPluginContext({
|
|
351
222
|
courseId: opts.courseId,
|
|
352
223
|
sessionId: opts.sessionId,
|
|
353
|
-
attemptId: opts.attemptId
|
|
224
|
+
attemptId: opts.attemptId,
|
|
225
|
+
user: opts.user
|
|
354
226
|
});
|
|
227
|
+
const built = buildTelemetryEvent({
|
|
228
|
+
name: "course_started",
|
|
229
|
+
courseId: opts.courseId,
|
|
230
|
+
sessionId: opts.sessionId,
|
|
231
|
+
attemptId: opts.attemptId,
|
|
232
|
+
user: opts.user
|
|
233
|
+
});
|
|
234
|
+
return opts.pluginHost ? opts.pluginHost.runTelemetry(built, pluginCtx) : built;
|
|
235
|
+
}
|
|
236
|
+
function emitCourseStartedPipelineOnly(opts) {
|
|
355
237
|
try {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
tracking: opts.tracking,
|
|
238
|
+
const { xapiStatementSent } = emitCourseStartedNonTrackingPipeline({
|
|
239
|
+
event: opts.event,
|
|
359
240
|
xapi: opts.xapi,
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
sessionId: opts.sessionId,
|
|
364
|
-
attemptId: opts.attemptId,
|
|
365
|
-
user: opts.user
|
|
366
|
-
}),
|
|
367
|
-
pluginCtx,
|
|
368
|
-
lxpackBridge: opts.lxpackBridge
|
|
241
|
+
lxpackBridge: opts.lxpackBridge,
|
|
242
|
+
extraSinks: opts.extraSinks,
|
|
243
|
+
skipXapi: opts.skipXapi
|
|
369
244
|
});
|
|
370
245
|
markCourseStarted(opts.storage, opts.sessionId, opts.courseId);
|
|
371
|
-
|
|
246
|
+
markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId);
|
|
247
|
+
if (xapiStatementSent) {
|
|
248
|
+
opts.onXapiStatementSent?.();
|
|
249
|
+
}
|
|
250
|
+
return "emitted";
|
|
372
251
|
} catch {
|
|
373
|
-
return
|
|
252
|
+
return "failed";
|
|
374
253
|
}
|
|
375
254
|
}
|
|
376
|
-
function
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
255
|
+
function emitCourseStarted(opts) {
|
|
256
|
+
const event = buildCourseStartedEvent(opts);
|
|
257
|
+
if (event === null) return "filtered";
|
|
258
|
+
const trackingAlreadyEmitted = hasCourseStartedEmittedToTracking(
|
|
259
|
+
opts.storage,
|
|
260
|
+
opts.sessionId,
|
|
261
|
+
opts.courseId
|
|
262
|
+
);
|
|
263
|
+
if (!trackingAlreadyEmitted) {
|
|
264
|
+
try {
|
|
265
|
+
opts.tracking.track(event);
|
|
266
|
+
markCourseStartedEmittedToTracking(opts.storage, opts.sessionId, opts.courseId);
|
|
267
|
+
} catch {
|
|
268
|
+
return "failed";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return emitCourseStartedPipelineOnly({
|
|
272
|
+
...opts,
|
|
273
|
+
event,
|
|
274
|
+
skipXapi: opts.skipXapi,
|
|
275
|
+
onXapiStatementSent: opts.onXapiStatementSent
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
function emitCourseStartedToTrackingOnly(opts) {
|
|
279
|
+
const event = buildCourseStartedEvent(opts);
|
|
280
|
+
if (event === null) return "filtered";
|
|
281
|
+
const trackingAlreadyEmitted = hasCourseStartedEmittedToTracking(
|
|
282
|
+
opts.storage,
|
|
283
|
+
opts.sessionId,
|
|
284
|
+
opts.courseId
|
|
285
|
+
);
|
|
286
|
+
if (!trackingAlreadyEmitted) {
|
|
287
|
+
try {
|
|
288
|
+
opts.tracking.track(event);
|
|
289
|
+
markCourseStartedEmittedToTracking(opts.storage, opts.sessionId, opts.courseId);
|
|
290
|
+
} catch {
|
|
291
|
+
return "failed";
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
emitCourseStartedNonTrackingPipeline({
|
|
296
|
+
event,
|
|
297
|
+
xapi: null,
|
|
298
|
+
lxpackBridge: opts.lxpackBridge,
|
|
299
|
+
extraSinks: opts.extraSinks,
|
|
300
|
+
skipXapi: true
|
|
301
|
+
});
|
|
302
|
+
markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId);
|
|
303
|
+
return "emitted";
|
|
304
|
+
} catch {
|
|
305
|
+
return "failed";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function emitPendingCourseStarted(opts) {
|
|
309
|
+
const trackingEmitted = hasCourseStartedEmittedToTracking(
|
|
310
|
+
opts.storage,
|
|
311
|
+
opts.sessionId,
|
|
312
|
+
opts.courseId
|
|
313
|
+
);
|
|
314
|
+
const sessionStarted = hasCourseStarted(opts.storage, opts.sessionId, opts.courseId);
|
|
315
|
+
if (sessionStarted && !trackingEmitted) {
|
|
316
|
+
return emitCourseStartedToTrackingOnly(opts);
|
|
317
|
+
}
|
|
318
|
+
if (trackingEmitted && !sessionStarted) {
|
|
319
|
+
const event = buildCourseStartedEvent(opts);
|
|
320
|
+
if (event === null) return "filtered";
|
|
321
|
+
return emitCourseStartedPipelineOnly({ ...opts, event });
|
|
322
|
+
}
|
|
323
|
+
if (!trackingEmitted && !sessionStarted) {
|
|
324
|
+
return emitCourseStarted(opts);
|
|
325
|
+
}
|
|
326
|
+
const pipelineDelivered = hasCourseStartedPipelineDelivered(
|
|
327
|
+
opts.storage,
|
|
328
|
+
opts.sessionId,
|
|
329
|
+
opts.courseId
|
|
330
|
+
);
|
|
331
|
+
if (sessionStarted && trackingEmitted && !pipelineDelivered) {
|
|
332
|
+
const event = buildCourseStartedEvent(opts);
|
|
333
|
+
if (event === null) return "filtered";
|
|
334
|
+
return emitCourseStartedPipelineOnly({
|
|
335
|
+
...opts,
|
|
336
|
+
event,
|
|
337
|
+
skipXapi: opts.skipXapi,
|
|
338
|
+
onXapiStatementSent: opts.onXapiStatementSent
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return "emitted";
|
|
342
|
+
}
|
|
343
|
+
function assertTrackingSinkConfig(tracking) {
|
|
344
|
+
if (!tracking?.sink || !tracking?.batchSink) return;
|
|
345
|
+
throw new Error(
|
|
346
|
+
"[lessonkit] tracking.sink and tracking.batchSink cannot both be set; use batchSink alone for batched delivery"
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
function useLessonkitProviderRuntime(config) {
|
|
350
|
+
const normalizedCourseId = useMemo(
|
|
351
|
+
() => assertValidId(config.courseId, "courseId"),
|
|
352
|
+
[config.courseId]
|
|
353
|
+
);
|
|
354
|
+
const normalizedConfig = useMemo(
|
|
355
|
+
() => ({ ...config, courseId: normalizedCourseId }),
|
|
356
|
+
[config, normalizedCourseId]
|
|
357
|
+
);
|
|
358
|
+
const useV2Runtime = normalizedConfig.runtimeVersion !== "v1";
|
|
359
|
+
const extraSinksRef = useRef(normalizedConfig.sinks);
|
|
360
|
+
extraSinksRef.current = normalizedConfig.sinks;
|
|
361
|
+
const headlessRef = useRef(null);
|
|
362
|
+
const sessionIdRef = useRef(resolveSessionId(defaultStorage, normalizedConfig.session?.sessionId));
|
|
363
|
+
const prevConfiguredSessionIdRef = useRef(normalizedConfig.session?.sessionId);
|
|
364
|
+
if (normalizedConfig.session?.sessionId) {
|
|
365
|
+
sessionIdRef.current = normalizedConfig.session.sessionId;
|
|
382
366
|
} else if (prevConfiguredSessionIdRef.current) {
|
|
383
367
|
sessionIdRef.current = resolveSessionId(defaultStorage, void 0);
|
|
384
368
|
}
|
|
385
|
-
const attemptIdRef = useRef(
|
|
386
|
-
const userRef = useRef(
|
|
387
|
-
attemptIdRef.current =
|
|
388
|
-
userRef.current =
|
|
389
|
-
const courseIdRef = useRef(
|
|
390
|
-
courseIdRef.current =
|
|
391
|
-
const lxpackBridgeModeRef = useRef(
|
|
392
|
-
lxpackBridgeModeRef.current =
|
|
393
|
-
const pluginHost = useMemo(() => createReactPluginHost(
|
|
369
|
+
const attemptIdRef = useRef(normalizedConfig.session?.attemptId);
|
|
370
|
+
const userRef = useRef(normalizedConfig.session?.user);
|
|
371
|
+
attemptIdRef.current = normalizedConfig.session?.attemptId;
|
|
372
|
+
userRef.current = normalizedConfig.session?.user;
|
|
373
|
+
const courseIdRef = useRef(normalizedCourseId);
|
|
374
|
+
courseIdRef.current = normalizedCourseId;
|
|
375
|
+
const lxpackBridgeModeRef = useRef(normalizedConfig.lxpack?.bridge ?? "auto");
|
|
376
|
+
lxpackBridgeModeRef.current = normalizedConfig.lxpack?.bridge ?? "auto";
|
|
377
|
+
const pluginHost = useMemo(() => createReactPluginHost(normalizedConfig.plugins), [normalizedConfig.plugins]);
|
|
394
378
|
const pluginHostRef = useRef(pluginHost);
|
|
395
379
|
pluginHostRef.current = pluginHost;
|
|
396
380
|
const progressRef = useRef(createProgressController());
|
|
397
381
|
const courseStartedEmittedToSinkRef = useRef(false);
|
|
398
|
-
const prevCourseIdForProgressRef = useRef(
|
|
382
|
+
const prevCourseIdForProgressRef = useRef(normalizedCourseId);
|
|
399
383
|
const pendingCourseIdResetRef = useRef(false);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
384
|
+
const prevUseV2RuntimeRef = useRef(useV2Runtime);
|
|
385
|
+
const xapiCourseStartedSentOnClientRef = useRef(false);
|
|
386
|
+
if (prevUseV2RuntimeRef.current !== useV2Runtime) {
|
|
387
|
+
prevUseV2RuntimeRef.current = useV2Runtime;
|
|
388
|
+
if (useV2Runtime) {
|
|
389
|
+
headlessRef.current = createLessonkitRuntime({
|
|
390
|
+
courseId: normalizedCourseId,
|
|
391
|
+
runtimeVersion: "v2",
|
|
392
|
+
session: normalizedConfig.session
|
|
393
|
+
});
|
|
394
|
+
progressRef.current = headlessRef.current.progress;
|
|
395
|
+
} else {
|
|
396
|
+
headlessRef.current = null;
|
|
397
|
+
progressRef.current = createProgressController();
|
|
398
|
+
}
|
|
399
|
+
pendingCourseIdResetRef.current = true;
|
|
400
|
+
courseStartedEmittedToSinkRef.current = false;
|
|
401
|
+
} else if (useV2Runtime && !headlessRef.current) {
|
|
402
|
+
headlessRef.current = createLessonkitRuntime({
|
|
403
|
+
courseId: normalizedCourseId,
|
|
404
|
+
runtimeVersion: "v2",
|
|
405
|
+
session: normalizedConfig.session
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (prevCourseIdForProgressRef.current !== normalizedCourseId) {
|
|
409
|
+
prevCourseIdForProgressRef.current = normalizedCourseId;
|
|
410
|
+
if (useV2Runtime && headlessRef.current) {
|
|
411
|
+
headlessRef.current.resetForCourseChange(normalizedCourseId);
|
|
412
|
+
progressRef.current = headlessRef.current.progress;
|
|
413
|
+
} else {
|
|
414
|
+
progressRef.current = createProgressController();
|
|
415
|
+
}
|
|
403
416
|
pendingCourseIdResetRef.current = true;
|
|
404
417
|
courseStartedEmittedToSinkRef.current = false;
|
|
405
418
|
}
|
|
419
|
+
if (useV2Runtime && headlessRef.current) {
|
|
420
|
+
progressRef.current = headlessRef.current.progress;
|
|
421
|
+
}
|
|
406
422
|
const [progress, setProgress] = useState(() => progressRef.current.getState());
|
|
407
423
|
const syncProgress = useCallback(() => {
|
|
408
424
|
setProgress(progressRef.current.getState());
|
|
@@ -412,16 +428,16 @@ function LessonkitProvider(props) {
|
|
|
412
428
|
const xapiQueueRef = useRef(createInMemoryXAPIQueue());
|
|
413
429
|
const xapiRef = useRef(null);
|
|
414
430
|
const [xapi, setXapi] = useState(null);
|
|
415
|
-
const prevXapiCourseIdRef = useRef(
|
|
416
|
-
const xapiEnabled =
|
|
417
|
-
const xapiClient =
|
|
418
|
-
const xapiTransport =
|
|
419
|
-
const courseId =
|
|
420
|
-
const trackingEnabled =
|
|
431
|
+
const prevXapiCourseIdRef = useRef(normalizedCourseId);
|
|
432
|
+
const xapiEnabled = normalizedConfig.xapi?.enabled;
|
|
433
|
+
const xapiClient = normalizedConfig.xapi?.client;
|
|
434
|
+
const xapiTransport = normalizedConfig.xapi?.transport;
|
|
435
|
+
const courseId = normalizedCourseId;
|
|
436
|
+
const trackingEnabled = normalizedConfig.tracking?.enabled;
|
|
421
437
|
useIsoLayoutEffect(() => {
|
|
422
438
|
const courseChanged = prevXapiCourseIdRef.current !== courseId;
|
|
423
439
|
if (courseChanged) {
|
|
424
|
-
if (
|
|
440
|
+
if (normalizedConfig.xapi?.client) {
|
|
425
441
|
const g = globalThis;
|
|
426
442
|
if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production") {
|
|
427
443
|
console.warn(
|
|
@@ -432,30 +448,40 @@ function LessonkitProvider(props) {
|
|
|
432
448
|
}
|
|
433
449
|
xapiQueueRef.current = createInMemoryXAPIQueue();
|
|
434
450
|
prevXapiCourseIdRef.current = courseId;
|
|
451
|
+
xapiCourseStartedSentOnClientRef.current = false;
|
|
435
452
|
}
|
|
436
453
|
const prev = xapiRef.current;
|
|
437
|
-
const next = createXapiClientFromConfig(
|
|
454
|
+
const next = createXapiClientFromConfig(normalizedConfig, xapiQueueRef.current);
|
|
438
455
|
xapiRef.current = next;
|
|
439
456
|
setXapi(next);
|
|
440
|
-
if (next
|
|
457
|
+
if (next) {
|
|
441
458
|
const sessionId = sessionIdRef.current;
|
|
442
459
|
const cid = courseIdRef.current;
|
|
443
|
-
const trackingActive = isTrackingActive(
|
|
460
|
+
const trackingActive = isTrackingActive(normalizedConfig.tracking);
|
|
444
461
|
const alreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
|
|
445
|
-
|
|
462
|
+
const clientChanged = !prev || prev !== next;
|
|
463
|
+
const skipBootstrap = trackingActive && !alreadyStarted;
|
|
464
|
+
const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && (!alreadyStarted || clientChanged);
|
|
465
|
+
if (needsBootstrap) {
|
|
446
466
|
try {
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
)
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
467
|
+
const event = buildCourseStartedEvent({
|
|
468
|
+
pluginHost: pluginHostRef.current,
|
|
469
|
+
courseId: cid,
|
|
470
|
+
sessionId,
|
|
471
|
+
attemptId: attemptIdRef.current,
|
|
472
|
+
user: userRef.current,
|
|
473
|
+
lxpackBridge: lxpackBridgeModeRef.current
|
|
474
|
+
});
|
|
475
|
+
if (event === null) {
|
|
476
|
+
} else {
|
|
477
|
+
const statement = telemetryEventToXAPIStatement3(event);
|
|
478
|
+
if (statement) {
|
|
479
|
+
next.send(statement);
|
|
480
|
+
if (!alreadyStarted) {
|
|
481
|
+
markCourseStarted(defaultStorage, sessionId, cid);
|
|
482
|
+
}
|
|
483
|
+
xapiCourseStartedSentOnClientRef.current = true;
|
|
484
|
+
}
|
|
459
485
|
}
|
|
460
486
|
} catch {
|
|
461
487
|
}
|
|
@@ -483,43 +509,53 @@ function LessonkitProvider(props) {
|
|
|
483
509
|
const trackingRef = useRef(createTrackingClient2());
|
|
484
510
|
const trackingClientForUnmountRef = useRef(trackingRef.current);
|
|
485
511
|
const [tracking, setTracking] = useState(() => trackingRef.current);
|
|
486
|
-
const trackingSink =
|
|
487
|
-
const trackingBatchSink =
|
|
488
|
-
const batchEnabled =
|
|
489
|
-
const batchFlushIntervalMs =
|
|
490
|
-
const batchMaxBatchSize =
|
|
512
|
+
const trackingSink = normalizedConfig.tracking?.sink;
|
|
513
|
+
const trackingBatchSink = normalizedConfig.tracking?.batchSink;
|
|
514
|
+
const batchEnabled = normalizedConfig.tracking?.batch?.enabled;
|
|
515
|
+
const batchFlushIntervalMs = normalizedConfig.tracking?.batch?.flushIntervalMs;
|
|
516
|
+
const batchMaxBatchSize = normalizedConfig.tracking?.batch?.maxBatchSize;
|
|
491
517
|
const buildCurrentPluginCtx = useCallback(
|
|
492
518
|
() => buildPluginContext({
|
|
493
519
|
courseId: courseIdRef.current,
|
|
494
520
|
sessionId: sessionIdRef.current,
|
|
495
|
-
attemptId: attemptIdRef.current
|
|
521
|
+
attemptId: attemptIdRef.current,
|
|
522
|
+
user: userRef.current
|
|
496
523
|
}),
|
|
497
524
|
[]
|
|
498
525
|
);
|
|
499
526
|
useIsoLayoutEffect(() => {
|
|
500
527
|
const prev = trackingRef.current;
|
|
501
|
-
const baseSink =
|
|
528
|
+
const baseSink = normalizedConfig.tracking?.sink;
|
|
529
|
+
const userBatchSink = normalizedConfig.tracking?.batchSink;
|
|
530
|
+
assertTrackingSinkConfig(normalizedConfig.tracking);
|
|
502
531
|
const sink = pluginHostRef.current && baseSink ? pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink : baseSink;
|
|
503
|
-
const batchSink = pluginHostRef.current &&
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
532
|
+
const batchSink = pluginHostRef.current && userBatchSink ? async (events) => {
|
|
533
|
+
const host = pluginHostRef.current;
|
|
534
|
+
const ctx = buildCurrentPluginCtx();
|
|
535
|
+
const delivered = host.deliverTelemetryBatch(events, ctx);
|
|
536
|
+
const perEventForBatch = [];
|
|
537
|
+
const collector = (event) => {
|
|
538
|
+
perEventForBatch.push(event);
|
|
539
|
+
};
|
|
540
|
+
const composedPerEvent = host.composeTrackingSink(collector, buildCurrentPluginCtx) ?? collector;
|
|
541
|
+
for (const event of delivered) {
|
|
542
|
+
await Promise.resolve(composedPerEvent(event));
|
|
543
|
+
}
|
|
544
|
+
return userBatchSink(perEventForBatch);
|
|
545
|
+
} : userBatchSink;
|
|
510
546
|
const next = createTrackingClientFromConfig({
|
|
511
|
-
tracking: { ...
|
|
547
|
+
tracking: { ...normalizedConfig.tracking, sink, batchSink }
|
|
512
548
|
});
|
|
513
549
|
trackingRef.current = next;
|
|
514
550
|
trackingClientForUnmountRef.current = next;
|
|
515
551
|
setTracking(next);
|
|
516
552
|
const sessionId = sessionIdRef.current;
|
|
517
553
|
const cid = courseIdRef.current;
|
|
518
|
-
const trackingActive = isTrackingActive(
|
|
554
|
+
const trackingActive = isTrackingActive(normalizedConfig.tracking);
|
|
519
555
|
if (!trackingActive) {
|
|
520
556
|
courseStartedEmittedToSinkRef.current = false;
|
|
521
|
-
} else if (!courseStartedEmittedToSinkRef.current
|
|
522
|
-
const
|
|
557
|
+
} else if (!courseStartedEmittedToSinkRef.current) {
|
|
558
|
+
const result = emitPendingCourseStarted({
|
|
523
559
|
pluginHost: pluginHostRef.current,
|
|
524
560
|
tracking: next,
|
|
525
561
|
xapi: xapiRef.current,
|
|
@@ -528,9 +564,14 @@ function LessonkitProvider(props) {
|
|
|
528
564
|
courseId: cid,
|
|
529
565
|
attemptId: attemptIdRef.current,
|
|
530
566
|
user: userRef.current,
|
|
531
|
-
lxpackBridge: lxpackBridgeModeRef.current
|
|
567
|
+
lxpackBridge: lxpackBridgeModeRef.current,
|
|
568
|
+
extraSinks: extraSinksRef.current,
|
|
569
|
+
skipXapi: xapiCourseStartedSentOnClientRef.current,
|
|
570
|
+
onXapiStatementSent: () => {
|
|
571
|
+
xapiCourseStartedSentOnClientRef.current = true;
|
|
572
|
+
}
|
|
532
573
|
});
|
|
533
|
-
courseStartedEmittedToSinkRef.current =
|
|
574
|
+
courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
|
|
534
575
|
} else if (trackingActive) {
|
|
535
576
|
courseStartedEmittedToSinkRef.current = true;
|
|
536
577
|
}
|
|
@@ -546,8 +587,8 @@ function LessonkitProvider(props) {
|
|
|
546
587
|
batchEnabled,
|
|
547
588
|
batchFlushIntervalMs,
|
|
548
589
|
batchMaxBatchSize,
|
|
549
|
-
|
|
550
|
-
|
|
590
|
+
normalizedConfig.plugins,
|
|
591
|
+
normalizedCourseId,
|
|
551
592
|
buildCurrentPluginCtx
|
|
552
593
|
]);
|
|
553
594
|
const emitWithBridge = useCallback((trackingClient, event) => {
|
|
@@ -559,14 +600,32 @@ function LessonkitProvider(props) {
|
|
|
559
600
|
pluginCtx: buildPluginContext({
|
|
560
601
|
courseId: courseIdRef.current,
|
|
561
602
|
sessionId: sessionIdRef.current,
|
|
562
|
-
attemptId: attemptIdRef.current
|
|
603
|
+
attemptId: attemptIdRef.current,
|
|
604
|
+
user: userRef.current
|
|
563
605
|
}),
|
|
564
|
-
lxpackBridge: lxpackBridgeModeRef.current
|
|
606
|
+
lxpackBridge: lxpackBridgeModeRef.current,
|
|
607
|
+
extraSinks: extraSinksRef.current
|
|
565
608
|
});
|
|
566
609
|
}, []);
|
|
610
|
+
const emitLifecycleEvent = useCallback(
|
|
611
|
+
(name, data, lessonId) => {
|
|
612
|
+
const event = tryBuildTelemetryEvent({
|
|
613
|
+
name,
|
|
614
|
+
courseId: courseIdRef.current,
|
|
615
|
+
lessonId: lessonId ?? activeLessonIdRef.current,
|
|
616
|
+
sessionId: sessionIdRef.current,
|
|
617
|
+
attemptId: attemptIdRef.current,
|
|
618
|
+
user: userRef.current,
|
|
619
|
+
data
|
|
620
|
+
});
|
|
621
|
+
if (!event) return;
|
|
622
|
+
emitWithBridge(trackingRef.current, event);
|
|
623
|
+
},
|
|
624
|
+
[emitWithBridge]
|
|
625
|
+
);
|
|
567
626
|
const track = useCallback(
|
|
568
627
|
(name, data, opts) => {
|
|
569
|
-
const event =
|
|
628
|
+
const event = tryBuildTelemetryEvent({
|
|
570
629
|
name,
|
|
571
630
|
courseId: courseIdRef.current,
|
|
572
631
|
lessonId: opts?.lessonId ?? activeLessonIdRef.current,
|
|
@@ -584,7 +643,7 @@ function LessonkitProvider(props) {
|
|
|
584
643
|
if (!pendingCourseIdResetRef.current) return;
|
|
585
644
|
pendingCourseIdResetRef.current = false;
|
|
586
645
|
syncProgress();
|
|
587
|
-
if (!isTrackingActive(
|
|
646
|
+
if (!isTrackingActive(normalizedConfig.tracking)) return;
|
|
588
647
|
const sessionId = sessionIdRef.current;
|
|
589
648
|
const cid = courseIdRef.current;
|
|
590
649
|
void (async () => {
|
|
@@ -592,8 +651,8 @@ function LessonkitProvider(props) {
|
|
|
592
651
|
await trackingRef.current?.flush?.();
|
|
593
652
|
} catch {
|
|
594
653
|
}
|
|
595
|
-
if (!courseStartedEmittedToSinkRef.current
|
|
596
|
-
const
|
|
654
|
+
if (!courseStartedEmittedToSinkRef.current) {
|
|
655
|
+
const result = emitPendingCourseStarted({
|
|
597
656
|
pluginHost: pluginHostRef.current,
|
|
598
657
|
tracking: trackingRef.current,
|
|
599
658
|
xapi: xapiRef.current,
|
|
@@ -602,12 +661,13 @@ function LessonkitProvider(props) {
|
|
|
602
661
|
courseId: cid,
|
|
603
662
|
attemptId: attemptIdRef.current,
|
|
604
663
|
user: userRef.current,
|
|
605
|
-
lxpackBridge: lxpackBridgeModeRef.current
|
|
664
|
+
lxpackBridge: lxpackBridgeModeRef.current,
|
|
665
|
+
extraSinks: extraSinksRef.current
|
|
606
666
|
});
|
|
607
|
-
courseStartedEmittedToSinkRef.current =
|
|
667
|
+
courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
|
|
608
668
|
}
|
|
609
669
|
})();
|
|
610
|
-
}, [
|
|
670
|
+
}, [normalizedCourseId, normalizedConfig.tracking?.enabled, syncProgress]);
|
|
611
671
|
const emitLessonCompleted = useCallback(
|
|
612
672
|
(lessonId, durationMs) => {
|
|
613
673
|
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
@@ -619,13 +679,19 @@ function LessonkitProvider(props) {
|
|
|
619
679
|
);
|
|
620
680
|
const completeLesson = useCallback(
|
|
621
681
|
(lessonId) => {
|
|
682
|
+
if (useV2Runtime && headlessRef.current) {
|
|
683
|
+
headlessRef.current.completeLesson(lessonId, emitLifecycleEvent);
|
|
684
|
+
syncProgress();
|
|
685
|
+
void Promise.resolve(trackingRef.current?.flush?.());
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
622
688
|
const result = progressRef.current.completeLesson(lessonId, Date.now());
|
|
623
689
|
if (!result.didComplete) return;
|
|
624
690
|
syncProgress();
|
|
625
691
|
emitLessonCompleted(lessonId, result.durationMs);
|
|
626
692
|
void Promise.resolve(trackingRef.current?.flush?.());
|
|
627
693
|
},
|
|
628
|
-
[syncProgress, emitLessonCompleted]
|
|
694
|
+
[syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
|
|
629
695
|
);
|
|
630
696
|
useEffect(() => {
|
|
631
697
|
return () => {
|
|
@@ -649,8 +715,19 @@ function LessonkitProvider(props) {
|
|
|
649
715
|
}, []);
|
|
650
716
|
const setActiveLesson = useCallback(
|
|
651
717
|
(lessonId) => {
|
|
718
|
+
if (useV2Runtime && headlessRef.current) {
|
|
719
|
+
headlessRef.current.setActiveLesson(lessonId, emitLifecycleEvent);
|
|
720
|
+
syncProgress();
|
|
721
|
+
void Promise.resolve(trackingRef.current?.flush?.());
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
652
724
|
const current = progressRef.current.getState();
|
|
653
725
|
if (current.activeLessonId === lessonId) return;
|
|
726
|
+
if (current.completedLessonIds.has(lessonId)) {
|
|
727
|
+
progressRef.current.setActiveLesson(lessonId, Date.now());
|
|
728
|
+
syncProgress();
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
654
731
|
const previous = current.activeLessonId;
|
|
655
732
|
if (previous && previous !== lessonId) {
|
|
656
733
|
const completed = progressRef.current.completeLesson(previous, Date.now());
|
|
@@ -663,9 +740,15 @@ function LessonkitProvider(props) {
|
|
|
663
740
|
syncProgress();
|
|
664
741
|
track("lesson_started", { lessonId }, { lessonId });
|
|
665
742
|
},
|
|
666
|
-
[track, syncProgress, emitLessonCompleted]
|
|
743
|
+
[track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
|
|
667
744
|
);
|
|
668
745
|
const completeCourse = useCallback(() => {
|
|
746
|
+
if (useV2Runtime && headlessRef.current) {
|
|
747
|
+
headlessRef.current.completeCourse(emitLifecycleEvent);
|
|
748
|
+
syncProgress();
|
|
749
|
+
void trackingRef.current?.flush?.();
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
669
752
|
const current = progressRef.current.getState();
|
|
670
753
|
if (current.activeLessonId) {
|
|
671
754
|
const lessonResult = progressRef.current.completeLesson(current.activeLessonId, Date.now());
|
|
@@ -678,24 +761,37 @@ function LessonkitProvider(props) {
|
|
|
678
761
|
syncProgress();
|
|
679
762
|
track("course_completed");
|
|
680
763
|
void trackingRef.current?.flush?.();
|
|
681
|
-
}, [track, syncProgress, emitLessonCompleted]);
|
|
682
|
-
const sessionUser =
|
|
683
|
-
const
|
|
684
|
-
|
|
764
|
+
}, [track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]);
|
|
765
|
+
const sessionUser = normalizedConfig.session?.user;
|
|
766
|
+
const sessionUserKey = useMemo(
|
|
767
|
+
() => sessionUser ? JSON.stringify(sessionUser) : "",
|
|
768
|
+
[sessionUser]
|
|
769
|
+
);
|
|
770
|
+
const sessionAttemptId = normalizedConfig.session?.attemptId;
|
|
771
|
+
const sessionConfiguredId = normalizedConfig.session?.sessionId;
|
|
772
|
+
useEffect(() => {
|
|
773
|
+
if (useV2Runtime && headlessRef.current) {
|
|
774
|
+
headlessRef.current.updateConfig({
|
|
775
|
+
courseId: normalizedCourseId,
|
|
776
|
+
session: normalizedConfig.session
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}, [useV2Runtime, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey, normalizedConfig.session]);
|
|
685
780
|
useEffect(() => {
|
|
686
781
|
if (!pluginHost) return;
|
|
687
782
|
const ctx = buildPluginContext({
|
|
688
783
|
courseId: courseIdRef.current,
|
|
689
784
|
sessionId: sessionIdRef.current,
|
|
690
|
-
attemptId: attemptIdRef.current
|
|
785
|
+
attemptId: attemptIdRef.current,
|
|
786
|
+
user: userRef.current
|
|
691
787
|
});
|
|
692
788
|
pluginHost.setupAll(ctx);
|
|
693
789
|
return () => {
|
|
694
790
|
pluginHost.disposeAll();
|
|
695
791
|
};
|
|
696
|
-
}, [pluginHost,
|
|
792
|
+
}, [pluginHost, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey]);
|
|
697
793
|
useEffect(() => {
|
|
698
|
-
const nextConfigured =
|
|
794
|
+
const nextConfigured = normalizedConfig.session?.sessionId;
|
|
699
795
|
const prevConfigured = prevConfiguredSessionIdRef.current;
|
|
700
796
|
if (nextConfigured === prevConfigured) return;
|
|
701
797
|
prevConfiguredSessionIdRef.current = nextConfigured;
|
|
@@ -716,10 +812,10 @@ function LessonkitProvider(props) {
|
|
|
716
812
|
migrateCourseStartedMark(defaultStorage, prevConfigured, nextAuto, cid);
|
|
717
813
|
sessionIdRef.current = nextAuto;
|
|
718
814
|
}
|
|
719
|
-
}, [sessionConfiguredId,
|
|
815
|
+
}, [sessionConfiguredId, normalizedCourseId]);
|
|
720
816
|
const runtime = useMemo(
|
|
721
817
|
() => ({
|
|
722
|
-
config,
|
|
818
|
+
config: normalizedConfig,
|
|
723
819
|
tracking,
|
|
724
820
|
xapi,
|
|
725
821
|
session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
|
|
@@ -731,7 +827,7 @@ function LessonkitProvider(props) {
|
|
|
731
827
|
plugins: pluginHost
|
|
732
828
|
}),
|
|
733
829
|
[
|
|
734
|
-
|
|
830
|
+
normalizedConfig,
|
|
735
831
|
tracking,
|
|
736
832
|
xapi,
|
|
737
833
|
progress,
|
|
@@ -745,6 +841,14 @@ function LessonkitProvider(props) {
|
|
|
745
841
|
sessionConfiguredId
|
|
746
842
|
]
|
|
747
843
|
);
|
|
844
|
+
return runtime;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// src/context.tsx
|
|
848
|
+
import { jsx } from "react/jsx-runtime";
|
|
849
|
+
var LessonkitContext = createContext(null);
|
|
850
|
+
function LessonkitProvider(props) {
|
|
851
|
+
const runtime = useLessonkitProviderRuntime(props.config);
|
|
748
852
|
return /* @__PURE__ */ jsx(LessonkitContext.Provider, { value: runtime, children: props.children });
|
|
749
853
|
}
|
|
750
854
|
|
|
@@ -767,46 +871,78 @@ function useCompletion() {
|
|
|
767
871
|
const { completeLesson, completeCourse } = useLessonkit();
|
|
768
872
|
return useMemo2(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
|
|
769
873
|
}
|
|
770
|
-
function useQuizState() {
|
|
874
|
+
function useQuizState(enclosingLessonId) {
|
|
771
875
|
const { track } = useLessonkit();
|
|
876
|
+
const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
|
|
772
877
|
return useMemo2(
|
|
773
878
|
() => ({
|
|
774
879
|
answer: (opts) => {
|
|
775
|
-
track("quiz_answered", opts);
|
|
880
|
+
track("quiz_answered", opts, trackOpts);
|
|
776
881
|
},
|
|
777
882
|
complete: (opts) => {
|
|
778
|
-
track("quiz_completed", opts);
|
|
883
|
+
track("quiz_completed", opts, trackOpts);
|
|
779
884
|
}
|
|
780
885
|
}),
|
|
781
|
-
[track]
|
|
886
|
+
[track, enclosingLessonId]
|
|
782
887
|
);
|
|
783
888
|
}
|
|
784
889
|
|
|
890
|
+
// src/lessonContext.tsx
|
|
891
|
+
import { createContext as createContext2, useContext as useContext2 } from "react";
|
|
892
|
+
var LessonContext = createContext2(void 0);
|
|
893
|
+
function useEnclosingLessonId() {
|
|
894
|
+
return useContext2(LessonContext);
|
|
895
|
+
}
|
|
896
|
+
|
|
785
897
|
// src/runtime/validateComponentId.ts
|
|
786
|
-
import {
|
|
787
|
-
|
|
788
|
-
function isDevEnvironment2() {
|
|
898
|
+
import { assertValidId as assertValidId2 } from "@lessonkit/core";
|
|
899
|
+
function isDevEnvironment3() {
|
|
789
900
|
const g = globalThis;
|
|
790
901
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
791
902
|
}
|
|
792
|
-
function
|
|
793
|
-
if (
|
|
794
|
-
|
|
795
|
-
if (
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
903
|
+
function normalizeComponentId(id, path) {
|
|
904
|
+
if (path === "courseId") return assertValidId2(id, "courseId");
|
|
905
|
+
if (path === "lessonId") return assertValidId2(id, "lessonId");
|
|
906
|
+
if (path === "checkId") return assertValidId2(id, "checkId");
|
|
907
|
+
if (path === "blockId") return assertValidId2(id, "blockId");
|
|
908
|
+
return assertValidId2(id, path);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// src/runtime/lessonMountRegistry.ts
|
|
912
|
+
var mountCounts = /* @__PURE__ */ new Map();
|
|
913
|
+
var warnedConcurrentLessons = false;
|
|
914
|
+
function registerLessonMount(lessonId) {
|
|
915
|
+
if (isDevEnvironment3() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
|
|
916
|
+
warnedConcurrentLessons = true;
|
|
917
|
+
console.warn(
|
|
918
|
+
"[lessonkit] Multiple <Lesson> components are mounted; only one should be active at a time. Set autoCompleteOnUnmount={false} on routed lessons or unmount the previous lesson before showing the next."
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
mountCounts.set(lessonId, (mountCounts.get(lessonId) ?? 0) + 1);
|
|
922
|
+
return () => {
|
|
923
|
+
const next = (mountCounts.get(lessonId) ?? 1) - 1;
|
|
924
|
+
if (next <= 0) {
|
|
925
|
+
mountCounts.delete(lessonId);
|
|
926
|
+
} else {
|
|
927
|
+
mountCounts.set(lessonId, next);
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
function getLessonMountCount(lessonId) {
|
|
932
|
+
return mountCounts.get(lessonId) ?? 0;
|
|
801
933
|
}
|
|
802
934
|
|
|
803
935
|
// src/components.tsx
|
|
804
936
|
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
937
|
+
var warnedQuizOutsideLesson = false;
|
|
938
|
+
function resetQuizWarningsForTests() {
|
|
939
|
+
warnedQuizOutsideLesson = false;
|
|
940
|
+
}
|
|
805
941
|
function Course(props) {
|
|
806
|
-
|
|
942
|
+
const courseId = useMemo3(() => normalizeComponentId(props.courseId, "courseId"), [props.courseId]);
|
|
807
943
|
const providerConfig = useMemo3(
|
|
808
|
-
() => ({ ...props.config, courseId
|
|
809
|
-
[props.config,
|
|
944
|
+
() => ({ ...props.config, courseId }),
|
|
945
|
+
[props.config, courseId]
|
|
810
946
|
);
|
|
811
947
|
return /* @__PURE__ */ jsx2(LessonkitProvider, { config: providerConfig, children: /* @__PURE__ */ jsxs("section", { "aria-label": props.title, children: [
|
|
812
948
|
/* @__PURE__ */ jsx2("h1", { children: props.title }),
|
|
@@ -814,41 +950,64 @@ function Course(props) {
|
|
|
814
950
|
] }) });
|
|
815
951
|
}
|
|
816
952
|
function Lesson(props) {
|
|
817
|
-
|
|
953
|
+
const lessonId = useMemo3(() => normalizeComponentId(props.lessonId, "lessonId"), [props.lessonId]);
|
|
954
|
+
const autoComplete = props.autoCompleteOnUnmount !== false;
|
|
818
955
|
const { setActiveLesson, config } = useLessonkit();
|
|
819
956
|
const { completeLesson } = useCompletion();
|
|
820
|
-
const id = props.lessonId;
|
|
821
957
|
const lessonMountGenerationRef = useRef2(0);
|
|
822
958
|
useEffect2(() => {
|
|
959
|
+
const unregister = registerLessonMount(lessonId);
|
|
823
960
|
const generation = ++lessonMountGenerationRef.current;
|
|
824
|
-
setActiveLesson(
|
|
961
|
+
setActiveLesson(lessonId);
|
|
825
962
|
return () => {
|
|
826
|
-
|
|
963
|
+
unregister();
|
|
964
|
+
if (getLessonMountCount(lessonId) > 0) {
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (!autoComplete) return;
|
|
827
968
|
queueMicrotask(() => {
|
|
828
969
|
if (lessonMountGenerationRef.current !== generation) return;
|
|
829
970
|
completeLesson(lessonId);
|
|
830
971
|
});
|
|
831
972
|
};
|
|
832
|
-
}, [
|
|
833
|
-
return /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
|
|
973
|
+
}, [lessonId, config.courseId, setActiveLesson, completeLesson, autoComplete]);
|
|
974
|
+
return /* @__PURE__ */ jsx2(LessonContext.Provider, { value: lessonId, children: /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
|
|
834
975
|
/* @__PURE__ */ jsx2("h2", { children: props.title }),
|
|
835
976
|
/* @__PURE__ */ jsx2("div", { children: props.children })
|
|
836
|
-
] });
|
|
977
|
+
] }) });
|
|
837
978
|
}
|
|
838
979
|
function Scenario(props) {
|
|
839
|
-
|
|
840
|
-
|
|
980
|
+
const blockId = useMemo3(
|
|
981
|
+
() => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
|
|
982
|
+
[props.blockId]
|
|
983
|
+
);
|
|
984
|
+
return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", "data-lk-block-id": blockId, children: props.children });
|
|
841
985
|
}
|
|
842
986
|
function Reflection(props) {
|
|
843
|
-
|
|
987
|
+
const blockId = useMemo3(
|
|
988
|
+
() => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
|
|
989
|
+
[props.blockId]
|
|
990
|
+
);
|
|
844
991
|
const promptId = useId();
|
|
845
|
-
|
|
992
|
+
const hintId = useId();
|
|
993
|
+
const [internalValue, setInternalValue] = useState2("");
|
|
994
|
+
const isControlled = props.value !== void 0;
|
|
995
|
+
const value = isControlled ? props.value : internalValue;
|
|
996
|
+
const handleChange = (event) => {
|
|
997
|
+
if (!isControlled) setInternalValue(event.target.value);
|
|
998
|
+
props.onChange?.(event.target.value);
|
|
999
|
+
};
|
|
1000
|
+
return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", "data-lk-block-id": blockId, children: [
|
|
846
1001
|
props.prompt ? /* @__PURE__ */ jsx2("p", { id: promptId, children: props.prompt }) : null,
|
|
1002
|
+
props.hint ? /* @__PURE__ */ jsx2("p", { id: hintId, style: visuallyHiddenStyle, children: props.hint }) : null,
|
|
847
1003
|
props.children,
|
|
848
1004
|
/* @__PURE__ */ jsx2(
|
|
849
1005
|
"textarea",
|
|
850
1006
|
{
|
|
1007
|
+
value,
|
|
1008
|
+
onChange: handleChange,
|
|
851
1009
|
"aria-labelledby": props.prompt ? promptId : void 0,
|
|
1010
|
+
"aria-describedby": props.hint ? hintId : void 0,
|
|
852
1011
|
"aria-label": props.prompt ? void 0 : "Reflection response"
|
|
853
1012
|
}
|
|
854
1013
|
)
|
|
@@ -867,18 +1026,35 @@ function KnowledgeCheck(props) {
|
|
|
867
1026
|
);
|
|
868
1027
|
}
|
|
869
1028
|
function Quiz(props) {
|
|
870
|
-
|
|
871
|
-
const
|
|
872
|
-
const
|
|
1029
|
+
const checkId = useMemo3(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
|
|
1030
|
+
const enclosingLessonId = useEnclosingLessonId();
|
|
1031
|
+
const missingLesson = enclosingLessonId === void 0;
|
|
1032
|
+
useEffect2(() => {
|
|
1033
|
+
if (!missingLesson || isDevEnvironment3()) return;
|
|
1034
|
+
if (!warnedQuizOutsideLesson) {
|
|
1035
|
+
warnedQuizOutsideLesson = true;
|
|
1036
|
+
console.error(
|
|
1037
|
+
"[lessonkit] <Quiz> must be wrapped in <Lesson>; quiz telemetry will not be emitted."
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
}, [missingLesson]);
|
|
1041
|
+
if (missingLesson && isDevEnvironment3()) {
|
|
1042
|
+
throw new Error("[lessonkit] <Quiz> must be wrapped in <Lesson>");
|
|
1043
|
+
}
|
|
1044
|
+
const quiz = useQuizState(enclosingLessonId);
|
|
1045
|
+
const { plugins, config, session } = useLessonkit();
|
|
873
1046
|
const [selected, setSelected] = useState2(null);
|
|
874
1047
|
const [selectionCorrect, setSelectionCorrect] = useState2(null);
|
|
1048
|
+
const [quizPassed, setQuizPassed] = useState2(false);
|
|
875
1049
|
const completedRef = useRef2(false);
|
|
876
1050
|
const questionId = useId();
|
|
1051
|
+
const choicesKey = props.choices.join("\0");
|
|
877
1052
|
useEffect2(() => {
|
|
878
1053
|
completedRef.current = false;
|
|
1054
|
+
setQuizPassed(false);
|
|
879
1055
|
setSelected(null);
|
|
880
1056
|
setSelectionCorrect(null);
|
|
881
|
-
}, [
|
|
1057
|
+
}, [checkId, props.answer, props.question, config.courseId, enclosingLessonId, choicesKey]);
|
|
882
1058
|
const isChoiceCorrect = (choice, custom) => {
|
|
883
1059
|
if (!custom) return choice === props.answer;
|
|
884
1060
|
if (custom.passed !== void 0) return custom.passed;
|
|
@@ -887,7 +1063,11 @@ function Quiz(props) {
|
|
|
887
1063
|
}
|
|
888
1064
|
return choice === props.answer;
|
|
889
1065
|
};
|
|
890
|
-
|
|
1066
|
+
if (missingLesson) {
|
|
1067
|
+
return /* @__PURE__ */ jsx2("section", { role: "alert", "aria-label": "Quiz configuration error", "data-lk-check-id": checkId, children: /* @__PURE__ */ jsx2("p", { children: "Quiz must be placed inside a Lesson." }) });
|
|
1068
|
+
}
|
|
1069
|
+
const passed = quizPassed;
|
|
1070
|
+
return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": checkId, children: [
|
|
891
1071
|
/* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
|
|
892
1072
|
/* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
|
|
893
1073
|
/* @__PURE__ */ jsx2("legend", { style: visuallyHiddenStyle, children: "Quiz choices" }),
|
|
@@ -899,17 +1079,21 @@ function Quiz(props) {
|
|
|
899
1079
|
name: questionId,
|
|
900
1080
|
value: c,
|
|
901
1081
|
checked: selected === c,
|
|
1082
|
+
disabled: passed,
|
|
1083
|
+
"aria-invalid": selected === c && selectionCorrect === false ? true : void 0,
|
|
902
1084
|
onChange: () => {
|
|
1085
|
+
if (passed) return;
|
|
903
1086
|
setSelected(c);
|
|
904
1087
|
const pluginCtx = buildPluginContext({
|
|
905
1088
|
courseId: config.courseId,
|
|
906
1089
|
sessionId: session.sessionId,
|
|
907
|
-
attemptId: session.attemptId
|
|
1090
|
+
attemptId: session.attemptId,
|
|
1091
|
+
user: session.user
|
|
908
1092
|
});
|
|
909
1093
|
const custom = plugins?.scoreAssessment(
|
|
910
1094
|
{
|
|
911
|
-
checkId
|
|
912
|
-
lessonId:
|
|
1095
|
+
checkId,
|
|
1096
|
+
lessonId: enclosingLessonId,
|
|
913
1097
|
response: c
|
|
914
1098
|
},
|
|
915
1099
|
pluginCtx
|
|
@@ -917,18 +1101,20 @@ function Quiz(props) {
|
|
|
917
1101
|
const correct = isChoiceCorrect(c, custom);
|
|
918
1102
|
setSelectionCorrect(correct);
|
|
919
1103
|
quiz.answer({
|
|
920
|
-
checkId
|
|
1104
|
+
checkId,
|
|
921
1105
|
question: props.question,
|
|
922
1106
|
choice: c,
|
|
923
1107
|
correct
|
|
924
1108
|
});
|
|
925
1109
|
if (correct && !completedRef.current) {
|
|
926
1110
|
completedRef.current = true;
|
|
1111
|
+
setQuizPassed(true);
|
|
1112
|
+
const maxScore = custom?.maxScore ?? 1;
|
|
927
1113
|
quiz.complete({
|
|
928
|
-
checkId
|
|
1114
|
+
checkId,
|
|
929
1115
|
score: custom?.score ?? 1,
|
|
930
|
-
maxScore
|
|
931
|
-
passingScore: props.passingScore ??
|
|
1116
|
+
maxScore,
|
|
1117
|
+
passingScore: props.passingScore ?? maxScore
|
|
932
1118
|
});
|
|
933
1119
|
}
|
|
934
1120
|
}
|
|
@@ -940,23 +1126,51 @@ function Quiz(props) {
|
|
|
940
1126
|
selected && selectionCorrect !== null ? /* @__PURE__ */ jsx2("p", { role: "status", "aria-live": "polite", children: selectionCorrect ? "Correct" : "Try again" }) : null
|
|
941
1127
|
] });
|
|
942
1128
|
}
|
|
943
|
-
function ProgressTracker() {
|
|
1129
|
+
function ProgressTracker(props) {
|
|
944
1130
|
const { progress } = useLessonkit();
|
|
945
1131
|
const completed = progress.completedLessonIds.size;
|
|
946
|
-
|
|
1132
|
+
if (props.totalLessons != null) {
|
|
1133
|
+
const total = props.totalLessons;
|
|
1134
|
+
const displayed = Math.min(completed, total);
|
|
1135
|
+
return /* @__PURE__ */ jsx2("aside", { "aria-label": "Progress", children: /* @__PURE__ */ jsx2(
|
|
1136
|
+
"div",
|
|
1137
|
+
{
|
|
1138
|
+
role: "progressbar",
|
|
1139
|
+
"aria-valuemin": 0,
|
|
1140
|
+
"aria-valuemax": total,
|
|
1141
|
+
"aria-valuenow": displayed,
|
|
1142
|
+
"aria-label": "Lessons completed",
|
|
1143
|
+
children: /* @__PURE__ */ jsxs("p", { children: [
|
|
1144
|
+
"Lessons completed: ",
|
|
1145
|
+
displayed,
|
|
1146
|
+
" of ",
|
|
1147
|
+
total
|
|
1148
|
+
] })
|
|
1149
|
+
}
|
|
1150
|
+
) });
|
|
1151
|
+
}
|
|
1152
|
+
return /* @__PURE__ */ jsx2("aside", { "aria-label": "Progress", role: "status", children: /* @__PURE__ */ jsxs("p", { children: [
|
|
947
1153
|
"Lessons completed: ",
|
|
948
1154
|
completed
|
|
949
1155
|
] }) });
|
|
950
1156
|
}
|
|
951
1157
|
|
|
952
1158
|
// src/index.tsx
|
|
953
|
-
import {
|
|
1159
|
+
import {
|
|
1160
|
+
buildTelemetryEvent as buildTelemetryEvent2,
|
|
1161
|
+
createLessonkitRuntime as createLessonkitRuntime2,
|
|
1162
|
+
createPluginRegistry as createPluginRegistry2,
|
|
1163
|
+
createTelemetryPipeline as createTelemetryPipeline2,
|
|
1164
|
+
defineAssessmentPlugin,
|
|
1165
|
+
defineLifecyclePlugin,
|
|
1166
|
+
defineTelemetryPlugin
|
|
1167
|
+
} from "@lessonkit/core";
|
|
954
1168
|
|
|
955
1169
|
// src/theme/ThemeProvider.tsx
|
|
956
1170
|
import React3, {
|
|
957
|
-
createContext as
|
|
1171
|
+
createContext as createContext3,
|
|
958
1172
|
useCallback as useCallback2,
|
|
959
|
-
useContext as
|
|
1173
|
+
useContext as useContext3,
|
|
960
1174
|
useLayoutEffect as useLayoutEffect2,
|
|
961
1175
|
useMemo as useMemo4,
|
|
962
1176
|
useRef as useRef3,
|
|
@@ -988,7 +1202,7 @@ function applyCssVariables(target, vars, previousKeys) {
|
|
|
988
1202
|
|
|
989
1203
|
// src/theme/ThemeProvider.tsx
|
|
990
1204
|
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
991
|
-
var ThemeContext =
|
|
1205
|
+
var ThemeContext = createContext3(null);
|
|
992
1206
|
var useIsoLayoutEffect2 = typeof window !== "undefined" ? useLayoutEffect2 : React3.useEffect;
|
|
993
1207
|
function getSystemMode() {
|
|
994
1208
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
@@ -1068,7 +1282,7 @@ function ThemeProvider(props) {
|
|
|
1068
1282
|
return /* @__PURE__ */ jsx3(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx3("div", { ref: hostRef, "data-lk-theme": dataTheme, children: props.children }) });
|
|
1069
1283
|
}
|
|
1070
1284
|
function useTheme() {
|
|
1071
|
-
const ctx =
|
|
1285
|
+
const ctx = useContext3(ThemeContext);
|
|
1072
1286
|
if (!ctx) {
|
|
1073
1287
|
throw new Error("useTheme must be used within a ThemeProvider");
|
|
1074
1288
|
}
|
|
@@ -1115,6 +1329,12 @@ var BLOCK_CATALOG = [
|
|
|
1115
1329
|
props: [
|
|
1116
1330
|
{ name: "title", type: "string", required: true, description: "Lesson title shown in the h2." },
|
|
1117
1331
|
{ name: "lessonId", type: "LessonId", required: true, description: "Stable lesson identifier for telemetry and packaging." },
|
|
1332
|
+
{
|
|
1333
|
+
name: "autoCompleteOnUnmount",
|
|
1334
|
+
type: "boolean",
|
|
1335
|
+
required: false,
|
|
1336
|
+
description: "When false, unmount does not emit lesson_completed (default true)."
|
|
1337
|
+
},
|
|
1118
1338
|
{ name: "children", type: "ReactNode", required: true, description: "Scenario, Quiz, Reflection, and other blocks." }
|
|
1119
1339
|
],
|
|
1120
1340
|
requiredIds: ["lessonId"],
|
|
@@ -1167,6 +1387,9 @@ var BLOCK_CATALOG = [
|
|
|
1167
1387
|
props: [
|
|
1168
1388
|
{ name: "blockId", type: "BlockId", required: false, description: "Optional stable block id for interaction telemetry URNs." },
|
|
1169
1389
|
{ name: "prompt", type: "string", required: false, description: "Reflection question or instruction." },
|
|
1390
|
+
{ name: "hint", type: "string", required: false, description: "Optional hint linked via aria-describedby." },
|
|
1391
|
+
{ name: "value", type: "string", required: false, description: "Controlled textarea value." },
|
|
1392
|
+
{ name: "onChange", type: "(value: string) => void", required: false, description: "Called when the learner edits the textarea." },
|
|
1170
1393
|
{ name: "children", type: "ReactNode", required: false, description: "Optional content above the textarea." }
|
|
1171
1394
|
],
|
|
1172
1395
|
requiredIds: [],
|
|
@@ -1185,6 +1408,7 @@ var BLOCK_CATALOG = [
|
|
|
1185
1408
|
},
|
|
1186
1409
|
telemetry: {
|
|
1187
1410
|
emits: [],
|
|
1411
|
+
requiresActiveLesson: true,
|
|
1188
1412
|
manualTracking: "useTracking().track('interaction', { kind, blockId, payload }) on submit or blur"
|
|
1189
1413
|
}
|
|
1190
1414
|
},
|
|
@@ -1197,7 +1421,13 @@ var BLOCK_CATALOG = [
|
|
|
1197
1421
|
{ name: "checkId", type: "CheckId", required: true, description: "Stable check identifier for telemetry and LXPack assessments." },
|
|
1198
1422
|
{ name: "question", type: "string", required: true, description: "Question text shown above choices." },
|
|
1199
1423
|
{ name: "choices", type: "string[]", required: true, description: "Radio button choice labels." },
|
|
1200
|
-
{ name: "answer", type: "string", required: true, description: "Correct choice value (must match one choice)." }
|
|
1424
|
+
{ name: "answer", type: "string", required: true, description: "Correct choice value (must match one choice)." },
|
|
1425
|
+
{
|
|
1426
|
+
name: "passingScore",
|
|
1427
|
+
type: "number",
|
|
1428
|
+
required: false,
|
|
1429
|
+
description: "Minimum score required to pass (defaults to maxScore when omitted)."
|
|
1430
|
+
}
|
|
1201
1431
|
],
|
|
1202
1432
|
requiredIds: ["checkId"],
|
|
1203
1433
|
parentConstraints: ["Lesson"],
|
|
@@ -1222,7 +1452,14 @@ var BLOCK_CATALOG = [
|
|
|
1222
1452
|
type: "ProgressTracker",
|
|
1223
1453
|
category: "chrome",
|
|
1224
1454
|
description: "Displays count of completed lessons from runtime progress state.",
|
|
1225
|
-
props: [
|
|
1455
|
+
props: [
|
|
1456
|
+
{
|
|
1457
|
+
name: "totalLessons",
|
|
1458
|
+
type: "number",
|
|
1459
|
+
required: false,
|
|
1460
|
+
description: "When set, renders role=progressbar with aria-valuenow/max."
|
|
1461
|
+
}
|
|
1462
|
+
],
|
|
1226
1463
|
requiredIds: [],
|
|
1227
1464
|
parentConstraints: ["Course"],
|
|
1228
1465
|
a11y: {
|
|
@@ -1274,9 +1511,15 @@ export {
|
|
|
1274
1511
|
ThemeProvider,
|
|
1275
1512
|
blockCatalogVersion,
|
|
1276
1513
|
buildBlockCatalog,
|
|
1277
|
-
|
|
1278
|
-
|
|
1514
|
+
buildTelemetryEvent2 as buildTelemetryEvent,
|
|
1515
|
+
createLessonkitRuntime2 as createLessonkitRuntime,
|
|
1516
|
+
createPluginRegistry2 as createPluginRegistry,
|
|
1517
|
+
createTelemetryPipeline2 as createTelemetryPipeline,
|
|
1518
|
+
defineAssessmentPlugin,
|
|
1519
|
+
defineLifecyclePlugin,
|
|
1520
|
+
defineTelemetryPlugin,
|
|
1279
1521
|
getBlockCatalogEntry,
|
|
1522
|
+
resetQuizWarningsForTests,
|
|
1280
1523
|
useCompletion,
|
|
1281
1524
|
useLessonkit,
|
|
1282
1525
|
useProgress,
|