@lessonkit/react 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/block-catalog.v3.json +1005 -107
- package/dist/AssessmentLessonGuard-D2Plzybb.d.cts +21 -0
- package/dist/AssessmentLessonGuard-D2Plzybb.d.ts +21 -0
- package/dist/blocks-entry.cjs +4563 -0
- package/dist/blocks-entry.d.cts +411 -0
- package/dist/blocks-entry.d.ts +411 -0
- package/dist/blocks-entry.js +69 -0
- package/dist/chunk-4LQ4TTEE.js +4018 -0
- package/dist/chunk-TDM3ARE7.js +1775 -0
- package/dist/chunk-UUTXECVW.js +252 -0
- package/dist/index.cjs +2329 -318
- package/dist/index.d.cts +31 -282
- package/dist/index.d.ts +31 -282
- package/dist/index.js +433 -4295
- package/dist/testing.cjs +540 -0
- package/dist/testing.d.cts +16 -0
- package/dist/testing.d.ts +16 -0
- package/dist/testing.js +18 -0
- package/package.json +33 -16
|
@@ -0,0 +1,1775 @@
|
|
|
1
|
+
// src/assessment/AssessmentLessonGuard.tsx
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
|
|
4
|
+
// src/lessonContext.tsx
|
|
5
|
+
import { createContext, useContext } from "react";
|
|
6
|
+
var LessonContext = createContext(void 0);
|
|
7
|
+
function useEnclosingLessonId() {
|
|
8
|
+
return useContext(LessonContext);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/runtime/validateComponentId.ts
|
|
12
|
+
import { assertValidId } from "@lessonkit/core";
|
|
13
|
+
function isDevEnvironment() {
|
|
14
|
+
const g = globalThis;
|
|
15
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
16
|
+
}
|
|
17
|
+
function normalizeComponentId(id, path) {
|
|
18
|
+
if (path === "courseId") return assertValidId(id, "courseId");
|
|
19
|
+
if (path === "lessonId") return assertValidId(id, "lessonId");
|
|
20
|
+
if (path === "checkId") return assertValidId(id, "checkId");
|
|
21
|
+
if (path === "blockId") return assertValidId(id, "blockId");
|
|
22
|
+
return assertValidId(id, path);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/assessment/AssessmentLessonGuard.tsx
|
|
26
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
27
|
+
var warnedAssessmentOutsideLesson = false;
|
|
28
|
+
function resetAssessmentWarningsForTests() {
|
|
29
|
+
warnedAssessmentOutsideLesson = false;
|
|
30
|
+
}
|
|
31
|
+
function AssessmentLessonGuard(props) {
|
|
32
|
+
const enclosingLessonId = useEnclosingLessonId();
|
|
33
|
+
const missingLesson = enclosingLessonId === void 0;
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!missingLesson || isDevEnvironment()) return;
|
|
36
|
+
if (!warnedAssessmentOutsideLesson) {
|
|
37
|
+
warnedAssessmentOutsideLesson = true;
|
|
38
|
+
console.error(
|
|
39
|
+
`[lessonkit] <${props.blockLabel}> must be wrapped in <Lesson>; assessment telemetry will not be emitted.`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}, [missingLesson, props.blockLabel]);
|
|
43
|
+
if (missingLesson && isDevEnvironment()) {
|
|
44
|
+
throw new Error(`[lessonkit] <${props.blockLabel}> must be wrapped in <Lesson>`);
|
|
45
|
+
}
|
|
46
|
+
if (missingLesson) {
|
|
47
|
+
return /* @__PURE__ */ jsx("section", { role: "alert", "aria-label": `${props.blockLabel} configuration error`, "data-lk-check-id": props.checkId, children: /* @__PURE__ */ jsxs("p", { children: [
|
|
48
|
+
props.blockLabel,
|
|
49
|
+
" must be placed inside a Lesson."
|
|
50
|
+
] }) });
|
|
51
|
+
}
|
|
52
|
+
return /* @__PURE__ */ jsx(Fragment, { children: props.children(enclosingLessonId) });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/runtime/productionGuard.ts
|
|
56
|
+
function isProductionEnvironment() {
|
|
57
|
+
try {
|
|
58
|
+
if (import.meta.env?.PROD === true) return true;
|
|
59
|
+
} catch {
|
|
60
|
+
}
|
|
61
|
+
const g = globalThis;
|
|
62
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
|
|
63
|
+
}
|
|
64
|
+
function shouldEnforceProductionGuard() {
|
|
65
|
+
try {
|
|
66
|
+
if (import.meta.env?.MODE === "test") return false;
|
|
67
|
+
} catch {
|
|
68
|
+
}
|
|
69
|
+
return isProductionEnvironment();
|
|
70
|
+
}
|
|
71
|
+
function looksLikeConsoleSink(fn) {
|
|
72
|
+
if (typeof fn !== "function") return false;
|
|
73
|
+
const src = Function.prototype.toString.call(fn);
|
|
74
|
+
return /console\.(log|debug|info)\s*\(/.test(src);
|
|
75
|
+
}
|
|
76
|
+
function isTrackingDeliveryConfigured(tracking) {
|
|
77
|
+
if (!tracking || tracking.enabled === false) return false;
|
|
78
|
+
return Boolean(tracking.sink || tracking.batchSink);
|
|
79
|
+
}
|
|
80
|
+
function isXapiDeliveryConfigured(xapi) {
|
|
81
|
+
if (!xapi || xapi.enabled === false) return false;
|
|
82
|
+
if (xapi.client) return true;
|
|
83
|
+
return typeof xapi.transport === "function";
|
|
84
|
+
}
|
|
85
|
+
function trackingUsesConsole(config) {
|
|
86
|
+
const tracking = config.tracking;
|
|
87
|
+
if (!tracking || tracking.enabled === false) return false;
|
|
88
|
+
if (tracking.batchSink && looksLikeConsoleSink(tracking.batchSink)) return true;
|
|
89
|
+
if (tracking.sink && looksLikeConsoleSink(tracking.sink)) return true;
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
function xapiUsesConsole(config) {
|
|
93
|
+
const xapi = config.xapi;
|
|
94
|
+
if (!xapi || xapi.enabled === false || xapi.client) return false;
|
|
95
|
+
return typeof xapi.transport === "function" && looksLikeConsoleSink(xapi.transport);
|
|
96
|
+
}
|
|
97
|
+
function observabilityIncomplete(observability, opts) {
|
|
98
|
+
if (!opts.trackingEnabled && !opts.xapiEnabled) return false;
|
|
99
|
+
const required = [observability?.onLxpackBridgeMiss];
|
|
100
|
+
if (opts.trackingEnabled) {
|
|
101
|
+
required.push(observability?.onTelemetrySinkError, observability?.onTelemetryBufferDrop);
|
|
102
|
+
}
|
|
103
|
+
if (opts.xapiEnabled) {
|
|
104
|
+
required.push(
|
|
105
|
+
observability?.onXapiQueueDepth,
|
|
106
|
+
observability?.onXapiQueueCap,
|
|
107
|
+
observability?.onXapiTransportError
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return required.some((hook) => !hook);
|
|
111
|
+
}
|
|
112
|
+
function requiredObservabilityHookCount(opts) {
|
|
113
|
+
let count = 1;
|
|
114
|
+
if (opts.trackingEnabled) count += 2;
|
|
115
|
+
if (opts.xapiEnabled) count += 3;
|
|
116
|
+
return count;
|
|
117
|
+
}
|
|
118
|
+
function assertProductionCourseConfig(config) {
|
|
119
|
+
if (!isProductionEnvironment()) return;
|
|
120
|
+
if (config.tracking && config.tracking.enabled !== false && !isTrackingDeliveryConfigured(config.tracking)) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
"[lessonkit] Production build has tracking enabled but no sink or batchSink configured."
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const trackingEnabled = isTrackingDeliveryConfigured(config.tracking);
|
|
126
|
+
const xapiEnabled = isXapiDeliveryConfigured(config.xapi);
|
|
127
|
+
if (trackingUsesConsole(config)) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"[lessonkit] Production build uses console telemetry sinks. Wire createFetchBatchSink or a real sink. See production checklist."
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (xapiUsesConsole(config)) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
"[lessonkit] Production build uses console xAPI transport. Wire createFetchTransport to your LRS proxy. See production checklist."
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (observabilityIncomplete(config.observability, { trackingEnabled, xapiEnabled })) {
|
|
138
|
+
const hookCount = requiredObservabilityHookCount({ trackingEnabled, xapiEnabled });
|
|
139
|
+
throw new Error(
|
|
140
|
+
`[lessonkit] Production build missing observability hooks. Wire all ${hookCount} config.observability callbacks before go-live.`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/runtime/emitTelemetry.ts
|
|
146
|
+
import { buildTelemetryEvent, tryBuildTelemetryEvent } from "@lessonkit/core";
|
|
147
|
+
|
|
148
|
+
// src/runtime/telemetryPipeline.ts
|
|
149
|
+
import {
|
|
150
|
+
createTelemetryPipeline,
|
|
151
|
+
createTrackingPipelineSink
|
|
152
|
+
} from "@lessonkit/core";
|
|
153
|
+
import { telemetryEventToXAPIStatement } from "@lessonkit/xapi";
|
|
154
|
+
|
|
155
|
+
// src/runtime/lxpackBridge.ts
|
|
156
|
+
import {
|
|
157
|
+
dispatchBridgeAction,
|
|
158
|
+
forwardTelemetryToBridge,
|
|
159
|
+
getLxpackBridge,
|
|
160
|
+
mapLessonkitTelemetryToBridgeAction,
|
|
161
|
+
telemetryEventToLessonkit
|
|
162
|
+
} from "@lessonkit/lxpack/bridge";
|
|
163
|
+
var BRIDGE_MISS_EVENT_NAMES = /* @__PURE__ */ new Set([
|
|
164
|
+
"course_completed",
|
|
165
|
+
"lesson_completed",
|
|
166
|
+
"assessment_completed",
|
|
167
|
+
"quiz_completed"
|
|
168
|
+
]);
|
|
169
|
+
function forwardTelemetryToLxpack(event, mode = "auto", opts) {
|
|
170
|
+
if (mode === "auto" && opts?.onBridgeMiss && BRIDGE_MISS_EVENT_NAMES.has(event.name) && !getLxpackBridge()) {
|
|
171
|
+
opts.onBridgeMiss(event);
|
|
172
|
+
}
|
|
173
|
+
forwardTelemetryToBridge(event, mode);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/runtime/telemetryPipeline.ts
|
|
177
|
+
function isDevEnvironment2() {
|
|
178
|
+
const g = globalThis;
|
|
179
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
180
|
+
}
|
|
181
|
+
function createLegacyPipeline(opts, extraSinks = []) {
|
|
182
|
+
return createTelemetryPipeline([
|
|
183
|
+
createTrackingPipelineSink("tracking", (event) => opts.tracking.track(event)),
|
|
184
|
+
{
|
|
185
|
+
id: "xapi",
|
|
186
|
+
emit(event) {
|
|
187
|
+
try {
|
|
188
|
+
const statement = telemetryEventToXAPIStatement(event);
|
|
189
|
+
if (statement) opts.xapi?.send(statement);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (isDevEnvironment2()) {
|
|
192
|
+
console.warn(
|
|
193
|
+
"[lessonkit] xAPI mapping skipped:",
|
|
194
|
+
err instanceof Error ? err.message : err
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "lxpack-bridge",
|
|
202
|
+
emit(event) {
|
|
203
|
+
forwardTelemetryToLxpack(event, opts.lxpackBridge, {
|
|
204
|
+
onBridgeMiss: opts.onLxpackBridgeMiss
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
...extraSinks
|
|
209
|
+
]);
|
|
210
|
+
}
|
|
211
|
+
function emitThroughPipeline(event, opts, extraSinks) {
|
|
212
|
+
createLegacyPipeline(opts, extraSinks).emit(event);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/runtime/emitTelemetry.ts
|
|
216
|
+
var warnedMissingCourseId = false;
|
|
217
|
+
function isDevEnvironment3() {
|
|
218
|
+
const g = globalThis;
|
|
219
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
220
|
+
}
|
|
221
|
+
function emitTelemetry(tracking, xapi, event, opts) {
|
|
222
|
+
if (!event.courseId) {
|
|
223
|
+
if (isDevEnvironment3() && !warnedMissingCourseId) {
|
|
224
|
+
warnedMissingCourseId = true;
|
|
225
|
+
console.warn("[lessonkit] telemetry event missing courseId");
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const legacy = {
|
|
230
|
+
tracking,
|
|
231
|
+
xapi,
|
|
232
|
+
lxpackBridge: opts?.lxpackBridge ?? "auto",
|
|
233
|
+
onLxpackBridgeMiss: opts?.onLxpackBridgeMiss
|
|
234
|
+
};
|
|
235
|
+
emitThroughPipeline(event, legacy, opts?.extraSinks);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/runtime/courseStartedPipeline.ts
|
|
239
|
+
import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement2 } from "@lessonkit/xapi";
|
|
240
|
+
function isDevEnvironment4() {
|
|
241
|
+
const g = globalThis;
|
|
242
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
243
|
+
}
|
|
244
|
+
function warnExtraSinkFailure(sinkId, err) {
|
|
245
|
+
if (isDevEnvironment4()) {
|
|
246
|
+
console.warn(
|
|
247
|
+
`[lessonkit] course_started extra sink "${sinkId}" failed:`,
|
|
248
|
+
err instanceof Error ? err.message : err
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function emitExtraSinks(sinks, event, emitCtx) {
|
|
253
|
+
await Promise.all(
|
|
254
|
+
sinks.map(async (sink) => {
|
|
255
|
+
let result;
|
|
256
|
+
try {
|
|
257
|
+
result = sink.emit(event, emitCtx);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
warnExtraSinkFailure(sink.id, err);
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
if (result != null && typeof result.then === "function") {
|
|
263
|
+
try {
|
|
264
|
+
await result;
|
|
265
|
+
} catch (err) {
|
|
266
|
+
warnExtraSinkFailure(sink.id, err);
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
async function emitCourseStartedNonTrackingPipeline(opts) {
|
|
274
|
+
let xapiStatementSent = false;
|
|
275
|
+
if (!opts.skipXapi && opts.xapi) {
|
|
276
|
+
const statement = telemetryEventToXAPIStatement2(opts.event);
|
|
277
|
+
if (statement) {
|
|
278
|
+
opts.xapi.send(statement);
|
|
279
|
+
await opts.xapi.flush();
|
|
280
|
+
xapiStatementSent = true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
forwardTelemetryToLxpack(opts.event, opts.lxpackBridge, {
|
|
284
|
+
onBridgeMiss: opts.onLxpackBridgeMiss
|
|
285
|
+
});
|
|
286
|
+
const emitCtx = {
|
|
287
|
+
courseId: opts.event.courseId,
|
|
288
|
+
sessionId: opts.event.sessionId,
|
|
289
|
+
attemptId: opts.event.attemptId
|
|
290
|
+
};
|
|
291
|
+
await emitExtraSinks(opts.extraSinks ?? [], opts.event, emitCtx);
|
|
292
|
+
return { xapiStatementSent };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/runtime/plugins.ts
|
|
296
|
+
import { buildPluginContext as buildPluginContextFromCore, createPluginRegistry } from "@lessonkit/core";
|
|
297
|
+
function createReactPluginHost(plugins) {
|
|
298
|
+
if (!plugins?.length) return null;
|
|
299
|
+
return createPluginRegistry(plugins);
|
|
300
|
+
}
|
|
301
|
+
function buildPluginContext(opts) {
|
|
302
|
+
return buildPluginContextFromCore(opts);
|
|
303
|
+
}
|
|
304
|
+
function emitTelemetryWithPlugins(opts) {
|
|
305
|
+
const next = opts.pluginHost ? opts.pluginHost.runTelemetry(opts.event, opts.pluginCtx) : opts.event;
|
|
306
|
+
if (next === null) return;
|
|
307
|
+
emitTelemetry(opts.tracking, opts.xapi, next, {
|
|
308
|
+
lxpackBridge: opts.lxpackBridge ?? "auto",
|
|
309
|
+
extraSinks: opts.extraSinks,
|
|
310
|
+
onLxpackBridgeMiss: opts.onLxpackBridgeMiss
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/runtime/session.ts
|
|
315
|
+
import {
|
|
316
|
+
SESSION_STORAGE_KEY,
|
|
317
|
+
getTabSessionId,
|
|
318
|
+
resolveSessionId,
|
|
319
|
+
hasCourseStarted,
|
|
320
|
+
markCourseStarted,
|
|
321
|
+
hasCourseStartedEmittedToTracking,
|
|
322
|
+
markCourseStartedEmittedToTracking,
|
|
323
|
+
hasCourseStartedPipelineDelivered,
|
|
324
|
+
markCourseStartedPipelineDelivered,
|
|
325
|
+
migrateCourseStartedMark
|
|
326
|
+
} from "@lessonkit/core";
|
|
327
|
+
|
|
328
|
+
// src/provider/courseStarted/emit.ts
|
|
329
|
+
function resolveTrackingClient(source) {
|
|
330
|
+
return typeof source === "function" ? source() : source;
|
|
331
|
+
}
|
|
332
|
+
var courseStartedTrackingFlights = /* @__PURE__ */ new Map();
|
|
333
|
+
var courseStartedEmitFlights = /* @__PURE__ */ new Map();
|
|
334
|
+
function resetCourseStartedTrackingFlightForTests() {
|
|
335
|
+
courseStartedTrackingFlights.clear();
|
|
336
|
+
courseStartedEmitFlights.clear();
|
|
337
|
+
}
|
|
338
|
+
function isTrackingActive(tracking) {
|
|
339
|
+
return tracking?.enabled !== false;
|
|
340
|
+
}
|
|
341
|
+
function isCourseStartedSinkSettled(result) {
|
|
342
|
+
return result === "emitted" || result === "filtered";
|
|
343
|
+
}
|
|
344
|
+
async function deliverToTracking(client, event) {
|
|
345
|
+
if (client.deliver) {
|
|
346
|
+
return client.deliver(event);
|
|
347
|
+
}
|
|
348
|
+
client.track(event);
|
|
349
|
+
const flushed = await client.flush?.();
|
|
350
|
+
if (flushed === false) return false;
|
|
351
|
+
if (flushed === true) return true;
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
function buildCourseStartedEvent(opts) {
|
|
355
|
+
const pluginCtx = buildPluginContext({
|
|
356
|
+
courseId: opts.courseId,
|
|
357
|
+
sessionId: opts.sessionId,
|
|
358
|
+
attemptId: opts.attemptId,
|
|
359
|
+
user: opts.user
|
|
360
|
+
});
|
|
361
|
+
const built = buildTelemetryEvent({
|
|
362
|
+
name: "course_started",
|
|
363
|
+
courseId: opts.courseId,
|
|
364
|
+
sessionId: opts.sessionId,
|
|
365
|
+
attemptId: opts.attemptId,
|
|
366
|
+
user: opts.user
|
|
367
|
+
});
|
|
368
|
+
return opts.pluginHost ? opts.pluginHost.runTelemetry(built, pluginCtx) : built;
|
|
369
|
+
}
|
|
370
|
+
async function emitCourseStartedToTracking(tracking, storage, sessionId, courseId, event, shouldCommit) {
|
|
371
|
+
const flightKey = `${sessionId}:${courseId}`;
|
|
372
|
+
if (hasCourseStartedEmittedToTracking(storage, sessionId, courseId)) {
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
const existing = courseStartedTrackingFlights.get(flightKey);
|
|
376
|
+
if (existing) {
|
|
377
|
+
return existing;
|
|
378
|
+
}
|
|
379
|
+
let resolveFlight;
|
|
380
|
+
const flight = new Promise((resolve) => {
|
|
381
|
+
resolveFlight = resolve;
|
|
382
|
+
});
|
|
383
|
+
courseStartedTrackingFlights.set(flightKey, flight);
|
|
384
|
+
void (async () => {
|
|
385
|
+
try {
|
|
386
|
+
if (shouldCommit && !shouldCommit()) {
|
|
387
|
+
resolveFlight(false);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const client = resolveTrackingClient(tracking);
|
|
391
|
+
const delivered = await deliverToTracking(client, event);
|
|
392
|
+
if (shouldCommit && !shouldCommit()) {
|
|
393
|
+
resolveFlight(false);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (!delivered) {
|
|
397
|
+
resolveFlight(false);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (markCourseStartedEmittedToTracking(storage, sessionId, courseId) === false) {
|
|
401
|
+
resolveFlight(false);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
resolveFlight(true);
|
|
405
|
+
} catch {
|
|
406
|
+
resolveFlight(false);
|
|
407
|
+
} finally {
|
|
408
|
+
if (courseStartedTrackingFlights.get(flightKey) === flight) {
|
|
409
|
+
courseStartedTrackingFlights.delete(flightKey);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
})();
|
|
413
|
+
return flight;
|
|
414
|
+
}
|
|
415
|
+
async function emitCourseStartedPipelineOnly(opts) {
|
|
416
|
+
try {
|
|
417
|
+
if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
|
|
418
|
+
const { xapiStatementSent } = await emitCourseStartedNonTrackingPipeline({
|
|
419
|
+
event: opts.event,
|
|
420
|
+
xapi: opts.xapi,
|
|
421
|
+
lxpackBridge: opts.lxpackBridge,
|
|
422
|
+
onLxpackBridgeMiss: opts.onLxpackBridgeMiss,
|
|
423
|
+
extraSinks: opts.extraSinks,
|
|
424
|
+
skipXapi: opts.skipXapi
|
|
425
|
+
});
|
|
426
|
+
if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
|
|
427
|
+
if (markCourseStarted(opts.storage, opts.sessionId, opts.courseId) === false) return "failed";
|
|
428
|
+
if (markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId) === false) {
|
|
429
|
+
return "failed";
|
|
430
|
+
}
|
|
431
|
+
if (xapiStatementSent) {
|
|
432
|
+
opts.onXapiStatementSent?.();
|
|
433
|
+
}
|
|
434
|
+
return "emitted";
|
|
435
|
+
} catch {
|
|
436
|
+
return "failed";
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async function emitCourseStarted(opts) {
|
|
440
|
+
const event = buildCourseStartedEvent(opts);
|
|
441
|
+
if (event === null) return "filtered";
|
|
442
|
+
const tracked = await emitCourseStartedToTracking(
|
|
443
|
+
opts.tracking,
|
|
444
|
+
opts.storage,
|
|
445
|
+
opts.sessionId,
|
|
446
|
+
opts.courseId,
|
|
447
|
+
event,
|
|
448
|
+
opts.shouldCommit
|
|
449
|
+
);
|
|
450
|
+
if (!tracked) return "failed";
|
|
451
|
+
return emitCourseStartedPipelineOnly({
|
|
452
|
+
...opts,
|
|
453
|
+
event,
|
|
454
|
+
skipXapi: opts.skipXapi,
|
|
455
|
+
onXapiStatementSent: opts.onXapiStatementSent,
|
|
456
|
+
shouldCommit: opts.shouldCommit
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
async function emitCourseStartedToTrackingOnly(opts) {
|
|
460
|
+
const event = buildCourseStartedEvent(opts);
|
|
461
|
+
if (event === null) return "filtered";
|
|
462
|
+
const tracked = await emitCourseStartedToTracking(
|
|
463
|
+
opts.tracking,
|
|
464
|
+
opts.storage,
|
|
465
|
+
opts.sessionId,
|
|
466
|
+
opts.courseId,
|
|
467
|
+
event,
|
|
468
|
+
opts.shouldCommit
|
|
469
|
+
);
|
|
470
|
+
if (!tracked) return "failed";
|
|
471
|
+
try {
|
|
472
|
+
if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
|
|
473
|
+
await emitCourseStartedNonTrackingPipeline({
|
|
474
|
+
event,
|
|
475
|
+
xapi: null,
|
|
476
|
+
lxpackBridge: opts.lxpackBridge,
|
|
477
|
+
onLxpackBridgeMiss: opts.onLxpackBridgeMiss,
|
|
478
|
+
extraSinks: opts.extraSinks,
|
|
479
|
+
skipXapi: true
|
|
480
|
+
});
|
|
481
|
+
if (markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId) === false) {
|
|
482
|
+
return "failed";
|
|
483
|
+
}
|
|
484
|
+
return "emitted";
|
|
485
|
+
} catch {
|
|
486
|
+
return "failed";
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
async function emitPendingCourseStarted(opts) {
|
|
490
|
+
const flightKey = `${opts.sessionId}:${opts.courseId}`;
|
|
491
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
492
|
+
const existing = courseStartedEmitFlights.get(flightKey);
|
|
493
|
+
const flight = existing ?? startPendingCourseStartedFlight(opts, flightKey);
|
|
494
|
+
const result = await flight;
|
|
495
|
+
if (result !== "failed") return result;
|
|
496
|
+
const sessionStarted = hasCourseStarted(opts.storage, opts.sessionId, opts.courseId);
|
|
497
|
+
const trackingEmitted = hasCourseStartedEmittedToTracking(
|
|
498
|
+
opts.storage,
|
|
499
|
+
opts.sessionId,
|
|
500
|
+
opts.courseId
|
|
501
|
+
);
|
|
502
|
+
const pipelineDelivered = hasCourseStartedPipelineDelivered(
|
|
503
|
+
opts.storage,
|
|
504
|
+
opts.sessionId,
|
|
505
|
+
opts.courseId
|
|
506
|
+
);
|
|
507
|
+
if (sessionStarted && trackingEmitted && pipelineDelivered) {
|
|
508
|
+
return "emitted";
|
|
509
|
+
}
|
|
510
|
+
if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
|
|
511
|
+
}
|
|
512
|
+
return "failed";
|
|
513
|
+
}
|
|
514
|
+
function startPendingCourseStartedFlight(opts, flightKey) {
|
|
515
|
+
const flight = emitPendingCourseStartedInner(opts);
|
|
516
|
+
courseStartedEmitFlights.set(flightKey, flight);
|
|
517
|
+
void flight.finally(() => {
|
|
518
|
+
if (courseStartedEmitFlights.get(flightKey) === flight) {
|
|
519
|
+
courseStartedEmitFlights.delete(flightKey);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
return flight;
|
|
523
|
+
}
|
|
524
|
+
async function emitPendingCourseStartedInner(opts) {
|
|
525
|
+
const trackingEmitted = hasCourseStartedEmittedToTracking(
|
|
526
|
+
opts.storage,
|
|
527
|
+
opts.sessionId,
|
|
528
|
+
opts.courseId
|
|
529
|
+
);
|
|
530
|
+
const sessionStarted = hasCourseStarted(opts.storage, opts.sessionId, opts.courseId);
|
|
531
|
+
const pipelineDelivered = hasCourseStartedPipelineDelivered(
|
|
532
|
+
opts.storage,
|
|
533
|
+
opts.sessionId,
|
|
534
|
+
opts.courseId
|
|
535
|
+
);
|
|
536
|
+
if (sessionStarted && trackingEmitted && pipelineDelivered) {
|
|
537
|
+
return "emitted";
|
|
538
|
+
}
|
|
539
|
+
if (sessionStarted && !trackingEmitted) {
|
|
540
|
+
return emitCourseStartedToTrackingOnly(opts);
|
|
541
|
+
}
|
|
542
|
+
if (trackingEmitted && !sessionStarted) {
|
|
543
|
+
const event = buildCourseStartedEvent(opts);
|
|
544
|
+
if (event === null) return "filtered";
|
|
545
|
+
return emitCourseStartedPipelineOnly({ ...opts, event });
|
|
546
|
+
}
|
|
547
|
+
if (!trackingEmitted && !sessionStarted) {
|
|
548
|
+
return emitCourseStarted(opts);
|
|
549
|
+
}
|
|
550
|
+
if (sessionStarted && trackingEmitted && !pipelineDelivered) {
|
|
551
|
+
const event = buildCourseStartedEvent(opts);
|
|
552
|
+
if (event === null) return "filtered";
|
|
553
|
+
return emitCourseStartedPipelineOnly({
|
|
554
|
+
...opts,
|
|
555
|
+
event,
|
|
556
|
+
skipXapi: opts.skipXapi,
|
|
557
|
+
onXapiStatementSent: opts.onXapiStatementSent
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
return "failed";
|
|
561
|
+
}
|
|
562
|
+
function assertTrackingSinkConfig(tracking) {
|
|
563
|
+
if (!tracking?.sink || !tracking?.batchSink) return;
|
|
564
|
+
throw new Error(
|
|
565
|
+
"[lessonkit] tracking.sink and tracking.batchSink cannot both be set; use batchSink alone for batched delivery"
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// src/provider/useLessonkitProviderRuntime.ts
|
|
570
|
+
import {
|
|
571
|
+
useCallback,
|
|
572
|
+
useEffect as useEffect2,
|
|
573
|
+
useLayoutEffect,
|
|
574
|
+
useMemo,
|
|
575
|
+
useRef,
|
|
576
|
+
useState
|
|
577
|
+
} from "react";
|
|
578
|
+
import { createLessonkitRuntime, createTrackingClient as createTrackingClient2, assertValidId as assertValidId2 } from "@lessonkit/core";
|
|
579
|
+
|
|
580
|
+
// src/runtime/observability.ts
|
|
581
|
+
import { createInMemoryXAPIQueue } from "@lessonkit/xapi";
|
|
582
|
+
function createXapiQueueFromObservability(getObservability) {
|
|
583
|
+
const opts = {
|
|
584
|
+
onDepth: (size) => getObservability?.()?.onXapiQueueDepth?.(size),
|
|
585
|
+
onCap: () => getObservability?.()?.onXapiQueueCap?.()
|
|
586
|
+
};
|
|
587
|
+
return createInMemoryXAPIQueue(opts);
|
|
588
|
+
}
|
|
589
|
+
function wrapBatchSink(batchSink, observability) {
|
|
590
|
+
if (!batchSink || !observability?.onTelemetrySinkError) return batchSink;
|
|
591
|
+
const onError = observability.onTelemetrySinkError;
|
|
592
|
+
return async (events) => {
|
|
593
|
+
try {
|
|
594
|
+
await batchSink(events);
|
|
595
|
+
} catch (err) {
|
|
596
|
+
onError(err, { sinkId: "tracking-batch" });
|
|
597
|
+
throw err;
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
function wrapTrackingSink(sink, observability) {
|
|
602
|
+
if (!sink || !observability?.onTelemetrySinkError) return sink;
|
|
603
|
+
const onError = observability.onTelemetrySinkError;
|
|
604
|
+
return ((event) => {
|
|
605
|
+
try {
|
|
606
|
+
const result = sink(event);
|
|
607
|
+
if (result != null && typeof result.catch === "function") {
|
|
608
|
+
return result.catch((err) => {
|
|
609
|
+
onError(err, { sinkId: "tracking" });
|
|
610
|
+
throw err;
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
return result;
|
|
614
|
+
} catch (err) {
|
|
615
|
+
onError(err, { sinkId: "tracking" });
|
|
616
|
+
throw err;
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/provider/useLessonkitProviderRuntime.ts
|
|
622
|
+
import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement3 } from "@lessonkit/xapi";
|
|
623
|
+
|
|
624
|
+
// src/runtime/ports.ts
|
|
625
|
+
import {
|
|
626
|
+
createDefaultClock,
|
|
627
|
+
createGlobalTimer,
|
|
628
|
+
createNoopStorage,
|
|
629
|
+
createSessionStoragePort,
|
|
630
|
+
resetStoragePortForTests
|
|
631
|
+
} from "@lessonkit/core";
|
|
632
|
+
|
|
633
|
+
// src/provider/useLessonkitProviderRuntime.ts
|
|
634
|
+
import { resetSharedVolatileSessionIdForTests } from "@lessonkit/core";
|
|
635
|
+
|
|
636
|
+
// src/runtime/progress.ts
|
|
637
|
+
import { createProgressController } from "@lessonkit/core";
|
|
638
|
+
|
|
639
|
+
// src/runtime/xapi.ts
|
|
640
|
+
import { createXAPIClient } from "@lessonkit/xapi";
|
|
641
|
+
function createXapiClientFromConfig(config, queue, observability) {
|
|
642
|
+
if (config.xapi?.enabled === false) return null;
|
|
643
|
+
if (config.xapi?.client) return config.xapi.client;
|
|
644
|
+
if (!config.courseId) return null;
|
|
645
|
+
const hasTransport = typeof config.xapi?.transport === "function";
|
|
646
|
+
if (!hasTransport && config.xapi?.enabled !== true) return null;
|
|
647
|
+
return createXAPIClient({
|
|
648
|
+
courseId: config.courseId,
|
|
649
|
+
transport: config.xapi?.transport,
|
|
650
|
+
exitTransport: config.xapi?.exitTransport,
|
|
651
|
+
abortInFlight: config.xapi?.abortInFlight,
|
|
652
|
+
queue,
|
|
653
|
+
onTransportError: observability?.onXapiTransportError
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// src/runtime/telemetry.ts
|
|
658
|
+
import { createTrackingClient } from "@lessonkit/core";
|
|
659
|
+
function createTrackingClientFromConfig(config, observability) {
|
|
660
|
+
if (config.tracking?.enabled === false) return createTrackingClient();
|
|
661
|
+
if (config.tracking?.createClient) return config.tracking.createClient();
|
|
662
|
+
return createTrackingClient({
|
|
663
|
+
sink: config.tracking?.sink,
|
|
664
|
+
batchSink: config.tracking?.batchSink,
|
|
665
|
+
batch: config.tracking?.batch,
|
|
666
|
+
exitBatchSink: config.tracking?.exitBatchSink,
|
|
667
|
+
onBufferDrop: observability?.onTelemetryBufferDrop
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
async function disposeTrackingClient(client) {
|
|
671
|
+
try {
|
|
672
|
+
await client?.flush?.();
|
|
673
|
+
} catch {
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
await client?.dispose?.();
|
|
677
|
+
} catch {
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// src/provider/useLessonkitProviderRuntime.ts
|
|
682
|
+
var useIsoLayoutEffect = (
|
|
683
|
+
/* v8 ignore next -- SSR uses useEffect when window is unavailable */
|
|
684
|
+
typeof window !== "undefined" ? useLayoutEffect : useEffect2
|
|
685
|
+
);
|
|
686
|
+
var defaultStorage = createSessionStoragePort();
|
|
687
|
+
function resetLessonkitProviderStorageForTests() {
|
|
688
|
+
resetStoragePortForTests(defaultStorage);
|
|
689
|
+
resetSharedVolatileSessionIdForTests();
|
|
690
|
+
}
|
|
691
|
+
function useLessonkitProviderRuntime(config) {
|
|
692
|
+
const normalizedCourseId = useMemo(
|
|
693
|
+
() => assertValidId2(config.courseId, "courseId"),
|
|
694
|
+
[config.courseId]
|
|
695
|
+
);
|
|
696
|
+
const normalizedConfig = useMemo(
|
|
697
|
+
() => ({ ...config, courseId: normalizedCourseId }),
|
|
698
|
+
[config, normalizedCourseId]
|
|
699
|
+
);
|
|
700
|
+
if (shouldEnforceProductionGuard()) {
|
|
701
|
+
assertProductionCourseConfig(normalizedConfig);
|
|
702
|
+
}
|
|
703
|
+
const useV2Runtime = normalizedConfig.runtimeVersion !== "v1";
|
|
704
|
+
useEffect2(() => {
|
|
705
|
+
if (useV2Runtime) return;
|
|
706
|
+
const g = globalThis;
|
|
707
|
+
if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
|
|
708
|
+
console.warn(
|
|
709
|
+
'[lessonkit] LessonkitProvider runtimeVersion "v1" is deprecated; omit or use "v2" (default). v1 will be removed in LessonKit 2.0.'
|
|
710
|
+
);
|
|
711
|
+
}, [useV2Runtime]);
|
|
712
|
+
const extraSinksRef = useRef(normalizedConfig.sinks);
|
|
713
|
+
extraSinksRef.current = normalizedConfig.sinks;
|
|
714
|
+
const headlessRef = useRef(null);
|
|
715
|
+
const sessionIdRef = useRef(resolveSessionId(defaultStorage, normalizedConfig.session?.sessionId));
|
|
716
|
+
const prevConfiguredSessionIdRef = useRef(normalizedConfig.session?.sessionId);
|
|
717
|
+
if (normalizedConfig.session?.sessionId) {
|
|
718
|
+
sessionIdRef.current = normalizedConfig.session.sessionId;
|
|
719
|
+
} else if (prevConfiguredSessionIdRef.current) {
|
|
720
|
+
sessionIdRef.current = resolveSessionId(defaultStorage, void 0);
|
|
721
|
+
}
|
|
722
|
+
const attemptIdRef = useRef(normalizedConfig.session?.attemptId);
|
|
723
|
+
const userRef = useRef(normalizedConfig.session?.user);
|
|
724
|
+
attemptIdRef.current = normalizedConfig.session?.attemptId;
|
|
725
|
+
userRef.current = normalizedConfig.session?.user;
|
|
726
|
+
const courseIdRef = useRef(normalizedCourseId);
|
|
727
|
+
courseIdRef.current = normalizedCourseId;
|
|
728
|
+
const lxpackBridgeModeRef = useRef(normalizedConfig.lxpack?.bridge ?? "auto");
|
|
729
|
+
lxpackBridgeModeRef.current = normalizedConfig.lxpack?.bridge ?? "auto";
|
|
730
|
+
const observabilityRef = useRef(normalizedConfig.observability);
|
|
731
|
+
observabilityRef.current = normalizedConfig.observability;
|
|
732
|
+
const onLxpackBridgeMiss = useCallback((event) => {
|
|
733
|
+
observabilityRef.current?.onLxpackBridgeMiss?.(event);
|
|
734
|
+
}, []);
|
|
735
|
+
const pluginsFingerprint = normalizedConfig.plugins?.map((p) => `${p.id}\0${p.version}`).join("|") ?? "";
|
|
736
|
+
const pluginHost = useMemo(
|
|
737
|
+
() => createReactPluginHost(normalizedConfig.plugins),
|
|
738
|
+
[pluginsFingerprint]
|
|
739
|
+
);
|
|
740
|
+
const pluginHostRef = useRef(pluginHost);
|
|
741
|
+
pluginHostRef.current = pluginHost;
|
|
742
|
+
const progressRef = useRef(createProgressController());
|
|
743
|
+
const courseStartedEmittedToSinkRef = useRef(false);
|
|
744
|
+
const courseStartedEmitGenerationRef = useRef(0);
|
|
745
|
+
const prevCourseIdForProgressRef = useRef(normalizedCourseId);
|
|
746
|
+
const pendingCourseIdResetRef = useRef(false);
|
|
747
|
+
const prevUseV2RuntimeRef = useRef(useV2Runtime);
|
|
748
|
+
const xapiCourseStartedSentOnClientRef = useRef(false);
|
|
749
|
+
const xapiBootstrapSendRef = useRef(false);
|
|
750
|
+
if (prevUseV2RuntimeRef.current !== useV2Runtime) {
|
|
751
|
+
prevUseV2RuntimeRef.current = useV2Runtime;
|
|
752
|
+
if (useV2Runtime) {
|
|
753
|
+
headlessRef.current = createLessonkitRuntime({
|
|
754
|
+
courseId: normalizedCourseId,
|
|
755
|
+
runtimeVersion: "v2",
|
|
756
|
+
session: normalizedConfig.session,
|
|
757
|
+
plugins: pluginHostRef.current ?? normalizedConfig.plugins,
|
|
758
|
+
deferPluginSetup: true
|
|
759
|
+
});
|
|
760
|
+
progressRef.current = headlessRef.current.progress;
|
|
761
|
+
} else {
|
|
762
|
+
headlessRef.current = null;
|
|
763
|
+
progressRef.current = createProgressController();
|
|
764
|
+
}
|
|
765
|
+
pendingCourseIdResetRef.current = true;
|
|
766
|
+
courseStartedEmittedToSinkRef.current = false;
|
|
767
|
+
courseStartedEmitGenerationRef.current += 1;
|
|
768
|
+
} else if (useV2Runtime && !headlessRef.current) {
|
|
769
|
+
headlessRef.current = createLessonkitRuntime({
|
|
770
|
+
courseId: normalizedCourseId,
|
|
771
|
+
runtimeVersion: "v2",
|
|
772
|
+
session: normalizedConfig.session,
|
|
773
|
+
plugins: pluginHostRef.current ?? normalizedConfig.plugins,
|
|
774
|
+
deferPluginSetup: true
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
if (prevCourseIdForProgressRef.current !== normalizedCourseId) {
|
|
778
|
+
prevCourseIdForProgressRef.current = normalizedCourseId;
|
|
779
|
+
if (useV2Runtime && headlessRef.current) {
|
|
780
|
+
headlessRef.current.resetForCourseChange(normalizedCourseId);
|
|
781
|
+
progressRef.current = headlessRef.current.progress;
|
|
782
|
+
} else {
|
|
783
|
+
progressRef.current = createProgressController();
|
|
784
|
+
}
|
|
785
|
+
pendingCourseIdResetRef.current = true;
|
|
786
|
+
courseStartedEmittedToSinkRef.current = false;
|
|
787
|
+
courseStartedEmitGenerationRef.current += 1;
|
|
788
|
+
}
|
|
789
|
+
if (useV2Runtime && headlessRef.current) {
|
|
790
|
+
progressRef.current = headlessRef.current.progress;
|
|
791
|
+
}
|
|
792
|
+
const [progress, setProgress] = useState(() => progressRef.current.getState());
|
|
793
|
+
const syncProgress = useCallback(() => {
|
|
794
|
+
setProgress(progressRef.current.getState());
|
|
795
|
+
}, []);
|
|
796
|
+
const activeLessonIdRef = useRef(progress.activeLessonId);
|
|
797
|
+
activeLessonIdRef.current = progress.activeLessonId;
|
|
798
|
+
const xapiQueueRef = useRef(createXapiQueueFromObservability(() => observabilityRef.current));
|
|
799
|
+
const xapiRef = useRef(null);
|
|
800
|
+
const [xapi, setXapi] = useState(null);
|
|
801
|
+
const prevXapiCourseIdRef = useRef(normalizedCourseId);
|
|
802
|
+
const xapiEnabled = normalizedConfig.xapi?.enabled;
|
|
803
|
+
const xapiClient = normalizedConfig.xapi?.client;
|
|
804
|
+
const xapiTransport = normalizedConfig.xapi?.transport;
|
|
805
|
+
const courseId = normalizedCourseId;
|
|
806
|
+
const trackingEnabled = normalizedConfig.tracking?.enabled;
|
|
807
|
+
useIsoLayoutEffect(() => {
|
|
808
|
+
const courseChanged = prevXapiCourseIdRef.current !== courseId;
|
|
809
|
+
if (courseChanged) {
|
|
810
|
+
if (normalizedConfig.xapi?.client) {
|
|
811
|
+
const g = globalThis;
|
|
812
|
+
if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production") {
|
|
813
|
+
console.warn(
|
|
814
|
+
"[lessonkit] courseId changed while using config.xapi.client; flush the client between courses or use config.xapi.transport so the provider can manage the queue."
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
void xapiRef.current?.flush();
|
|
818
|
+
}
|
|
819
|
+
xapiQueueRef.current = createXapiQueueFromObservability(() => observabilityRef.current);
|
|
820
|
+
prevXapiCourseIdRef.current = courseId;
|
|
821
|
+
xapiCourseStartedSentOnClientRef.current = false;
|
|
822
|
+
xapiBootstrapSendRef.current = false;
|
|
823
|
+
}
|
|
824
|
+
const prev = xapiRef.current;
|
|
825
|
+
const next = createXapiClientFromConfig(
|
|
826
|
+
normalizedConfig,
|
|
827
|
+
xapiQueueRef.current,
|
|
828
|
+
observabilityRef.current
|
|
829
|
+
);
|
|
830
|
+
xapiRef.current = next;
|
|
831
|
+
setXapi(next);
|
|
832
|
+
let bootstrapSent = false;
|
|
833
|
+
let bootstrapAlreadyStarted = false;
|
|
834
|
+
if (next) {
|
|
835
|
+
const sessionId = sessionIdRef.current;
|
|
836
|
+
const cid = courseIdRef.current;
|
|
837
|
+
const trackingActive = isTrackingActive(normalizedConfig.tracking);
|
|
838
|
+
bootstrapAlreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
|
|
839
|
+
const clientChanged = !prev || prev !== next;
|
|
840
|
+
const skipBootstrap = trackingActive && !bootstrapAlreadyStarted;
|
|
841
|
+
const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && !xapiBootstrapSendRef.current && (!bootstrapAlreadyStarted || clientChanged);
|
|
842
|
+
if (needsBootstrap) {
|
|
843
|
+
try {
|
|
844
|
+
const event = buildCourseStartedEvent({
|
|
845
|
+
pluginHost: pluginHostRef.current,
|
|
846
|
+
courseId: cid,
|
|
847
|
+
sessionId,
|
|
848
|
+
attemptId: attemptIdRef.current,
|
|
849
|
+
user: userRef.current,
|
|
850
|
+
lxpackBridge: lxpackBridgeModeRef.current
|
|
851
|
+
});
|
|
852
|
+
if (event !== null) {
|
|
853
|
+
const statement = telemetryEventToXAPIStatement3(event);
|
|
854
|
+
if (statement) {
|
|
855
|
+
next.send(statement);
|
|
856
|
+
xapiBootstrapSendRef.current = true;
|
|
857
|
+
bootstrapSent = true;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
} catch {
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
let cancelled = false;
|
|
865
|
+
void (async () => {
|
|
866
|
+
if (prev) {
|
|
867
|
+
try {
|
|
868
|
+
await prev.flush();
|
|
869
|
+
} catch {
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (cancelled) return;
|
|
873
|
+
try {
|
|
874
|
+
await next?.flush();
|
|
875
|
+
if (bootstrapSent && !cancelled) {
|
|
876
|
+
if (!bootstrapAlreadyStarted) {
|
|
877
|
+
markCourseStarted(defaultStorage, sessionIdRef.current, courseIdRef.current);
|
|
878
|
+
}
|
|
879
|
+
xapiCourseStartedSentOnClientRef.current = true;
|
|
880
|
+
}
|
|
881
|
+
} catch {
|
|
882
|
+
}
|
|
883
|
+
})();
|
|
884
|
+
return () => {
|
|
885
|
+
cancelled = true;
|
|
886
|
+
void (async () => {
|
|
887
|
+
try {
|
|
888
|
+
await prev?.flush();
|
|
889
|
+
} catch {
|
|
890
|
+
}
|
|
891
|
+
})();
|
|
892
|
+
};
|
|
893
|
+
}, [xapiEnabled, xapiClient, xapiTransport, courseId, trackingEnabled]);
|
|
894
|
+
const trackingRef = useRef(createTrackingClient2());
|
|
895
|
+
const trackingClientForUnmountRef = useRef(trackingRef.current);
|
|
896
|
+
const [tracking, setTracking] = useState(() => trackingRef.current);
|
|
897
|
+
const trackingSink = normalizedConfig.tracking?.sink;
|
|
898
|
+
const trackingBatchSink = normalizedConfig.tracking?.batchSink;
|
|
899
|
+
const batchEnabled = normalizedConfig.tracking?.batch?.enabled;
|
|
900
|
+
const batchFlushIntervalMs = normalizedConfig.tracking?.batch?.flushIntervalMs;
|
|
901
|
+
const batchMaxBatchSize = normalizedConfig.tracking?.batch?.maxBatchSize;
|
|
902
|
+
const buildCurrentPluginCtx = useCallback(
|
|
903
|
+
() => buildPluginContext({
|
|
904
|
+
courseId: courseIdRef.current,
|
|
905
|
+
sessionId: sessionIdRef.current,
|
|
906
|
+
attemptId: attemptIdRef.current,
|
|
907
|
+
user: userRef.current
|
|
908
|
+
}),
|
|
909
|
+
[]
|
|
910
|
+
);
|
|
911
|
+
useIsoLayoutEffect(() => {
|
|
912
|
+
const prev = trackingRef.current;
|
|
913
|
+
const baseSink = wrapTrackingSink(normalizedConfig.tracking?.sink, observabilityRef.current);
|
|
914
|
+
const userBatchSink = wrapBatchSink(
|
|
915
|
+
normalizedConfig.tracking?.batchSink,
|
|
916
|
+
observabilityRef.current
|
|
917
|
+
);
|
|
918
|
+
assertTrackingSinkConfig(normalizedConfig.tracking);
|
|
919
|
+
const sink = pluginHostRef.current && baseSink ? (
|
|
920
|
+
/* v8 ignore next -- composeTrackingSink may return null; fall back to base sink */
|
|
921
|
+
pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink
|
|
922
|
+
) : baseSink;
|
|
923
|
+
const batchSink = pluginHostRef.current && userBatchSink ? async (events) => {
|
|
924
|
+
const host = pluginHostRef.current;
|
|
925
|
+
const ctx = buildCurrentPluginCtx();
|
|
926
|
+
const delivered = host.deliverTelemetryBatch(events, ctx);
|
|
927
|
+
const perEventForBatch = [];
|
|
928
|
+
const collector = (event) => {
|
|
929
|
+
perEventForBatch.push(event);
|
|
930
|
+
};
|
|
931
|
+
const composedPerEvent = host.composeTrackingSink(collector, buildCurrentPluginCtx) ?? collector;
|
|
932
|
+
for (const event of delivered) {
|
|
933
|
+
await Promise.resolve(composedPerEvent(event));
|
|
934
|
+
}
|
|
935
|
+
return userBatchSink(perEventForBatch);
|
|
936
|
+
} : userBatchSink;
|
|
937
|
+
const next = createTrackingClientFromConfig(
|
|
938
|
+
{
|
|
939
|
+
tracking: { ...normalizedConfig.tracking, sink, batchSink }
|
|
940
|
+
},
|
|
941
|
+
observabilityRef.current
|
|
942
|
+
);
|
|
943
|
+
trackingRef.current = next;
|
|
944
|
+
trackingClientForUnmountRef.current = next;
|
|
945
|
+
setTracking(next);
|
|
946
|
+
const sessionId = sessionIdRef.current;
|
|
947
|
+
const cid = courseIdRef.current;
|
|
948
|
+
const trackingActive = isTrackingActive(normalizedConfig.tracking);
|
|
949
|
+
const courseStartedFullySettled = hasCourseStartedEmittedToTracking(defaultStorage, sessionId, cid) && hasCourseStarted(defaultStorage, sessionId, cid) && hasCourseStartedPipelineDelivered(defaultStorage, sessionId, cid);
|
|
950
|
+
if (!trackingActive) {
|
|
951
|
+
courseStartedEmittedToSinkRef.current = false;
|
|
952
|
+
} else if (courseStartedFullySettled) {
|
|
953
|
+
courseStartedEmittedToSinkRef.current = true;
|
|
954
|
+
} else if (!courseStartedEmittedToSinkRef.current) {
|
|
955
|
+
const generation = courseStartedEmitGenerationRef.current;
|
|
956
|
+
const shouldCommit = () => generation === courseStartedEmitGenerationRef.current;
|
|
957
|
+
void (async () => {
|
|
958
|
+
if (generation !== courseStartedEmitGenerationRef.current) return;
|
|
959
|
+
const result = await emitPendingCourseStarted({
|
|
960
|
+
pluginHost: pluginHostRef.current,
|
|
961
|
+
tracking: () => trackingRef.current,
|
|
962
|
+
xapi: xapiRef.current,
|
|
963
|
+
storage: defaultStorage,
|
|
964
|
+
sessionId,
|
|
965
|
+
courseId: cid,
|
|
966
|
+
attemptId: attemptIdRef.current,
|
|
967
|
+
user: userRef.current,
|
|
968
|
+
lxpackBridge: lxpackBridgeModeRef.current,
|
|
969
|
+
onLxpackBridgeMiss,
|
|
970
|
+
extraSinks: extraSinksRef.current,
|
|
971
|
+
skipXapi: xapiCourseStartedSentOnClientRef.current || xapiBootstrapSendRef.current,
|
|
972
|
+
onXapiStatementSent: () => {
|
|
973
|
+
xapiCourseStartedSentOnClientRef.current = true;
|
|
974
|
+
},
|
|
975
|
+
shouldCommit
|
|
976
|
+
});
|
|
977
|
+
if (generation !== courseStartedEmitGenerationRef.current) return;
|
|
978
|
+
courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
|
|
979
|
+
})();
|
|
980
|
+
}
|
|
981
|
+
return () => {
|
|
982
|
+
if (prev !== trackingRef.current) {
|
|
983
|
+
void disposeTrackingClient(prev);
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
}, [
|
|
987
|
+
trackingEnabled,
|
|
988
|
+
trackingSink,
|
|
989
|
+
trackingBatchSink,
|
|
990
|
+
batchEnabled,
|
|
991
|
+
batchFlushIntervalMs,
|
|
992
|
+
batchMaxBatchSize,
|
|
993
|
+
normalizedConfig.plugins,
|
|
994
|
+
normalizedCourseId,
|
|
995
|
+
buildCurrentPluginCtx
|
|
996
|
+
]);
|
|
997
|
+
const emitWithBridge = useCallback((trackingClient, event) => {
|
|
998
|
+
emitTelemetryWithPlugins({
|
|
999
|
+
pluginHost: pluginHostRef.current,
|
|
1000
|
+
tracking: trackingClient,
|
|
1001
|
+
xapi: xapiRef.current,
|
|
1002
|
+
event,
|
|
1003
|
+
pluginCtx: buildPluginContext({
|
|
1004
|
+
courseId: courseIdRef.current,
|
|
1005
|
+
sessionId: sessionIdRef.current,
|
|
1006
|
+
attemptId: attemptIdRef.current,
|
|
1007
|
+
user: userRef.current
|
|
1008
|
+
}),
|
|
1009
|
+
lxpackBridge: lxpackBridgeModeRef.current,
|
|
1010
|
+
onLxpackBridgeMiss,
|
|
1011
|
+
extraSinks: extraSinksRef.current
|
|
1012
|
+
});
|
|
1013
|
+
}, [onLxpackBridgeMiss]);
|
|
1014
|
+
const emitLifecycleEvent = useCallback(
|
|
1015
|
+
(name, data, lessonId) => {
|
|
1016
|
+
const event = tryBuildTelemetryEvent({
|
|
1017
|
+
name,
|
|
1018
|
+
courseId: courseIdRef.current,
|
|
1019
|
+
lessonId: lessonId ?? activeLessonIdRef.current,
|
|
1020
|
+
sessionId: sessionIdRef.current,
|
|
1021
|
+
attemptId: attemptIdRef.current,
|
|
1022
|
+
user: userRef.current,
|
|
1023
|
+
data
|
|
1024
|
+
});
|
|
1025
|
+
if (!event) return;
|
|
1026
|
+
emitWithBridge(trackingRef.current, event);
|
|
1027
|
+
},
|
|
1028
|
+
[emitWithBridge]
|
|
1029
|
+
);
|
|
1030
|
+
const track = useCallback(
|
|
1031
|
+
(name, data, opts) => {
|
|
1032
|
+
const event = tryBuildTelemetryEvent({
|
|
1033
|
+
name,
|
|
1034
|
+
courseId: courseIdRef.current,
|
|
1035
|
+
lessonId: opts?.lessonId ?? activeLessonIdRef.current,
|
|
1036
|
+
sessionId: sessionIdRef.current,
|
|
1037
|
+
attemptId: attemptIdRef.current,
|
|
1038
|
+
user: userRef.current,
|
|
1039
|
+
data
|
|
1040
|
+
});
|
|
1041
|
+
if (!event) return;
|
|
1042
|
+
emitWithBridge(trackingRef.current, event);
|
|
1043
|
+
},
|
|
1044
|
+
[emitWithBridge]
|
|
1045
|
+
);
|
|
1046
|
+
useLayoutEffect(() => {
|
|
1047
|
+
if (!pendingCourseIdResetRef.current) return;
|
|
1048
|
+
pendingCourseIdResetRef.current = false;
|
|
1049
|
+
syncProgress();
|
|
1050
|
+
if (!isTrackingActive(normalizedConfig.tracking)) return;
|
|
1051
|
+
const sessionId = sessionIdRef.current;
|
|
1052
|
+
const cid = courseIdRef.current;
|
|
1053
|
+
void (async () => {
|
|
1054
|
+
try {
|
|
1055
|
+
await trackingRef.current?.flush?.();
|
|
1056
|
+
} catch {
|
|
1057
|
+
}
|
|
1058
|
+
if (!courseStartedEmittedToSinkRef.current) {
|
|
1059
|
+
const generation = courseStartedEmitGenerationRef.current;
|
|
1060
|
+
const shouldCommit = () => generation === courseStartedEmitGenerationRef.current;
|
|
1061
|
+
const result = await emitPendingCourseStarted({
|
|
1062
|
+
pluginHost: pluginHostRef.current,
|
|
1063
|
+
tracking: () => trackingRef.current,
|
|
1064
|
+
xapi: xapiRef.current,
|
|
1065
|
+
storage: defaultStorage,
|
|
1066
|
+
sessionId,
|
|
1067
|
+
courseId: cid,
|
|
1068
|
+
attemptId: attemptIdRef.current,
|
|
1069
|
+
user: userRef.current,
|
|
1070
|
+
lxpackBridge: lxpackBridgeModeRef.current,
|
|
1071
|
+
onLxpackBridgeMiss,
|
|
1072
|
+
extraSinks: extraSinksRef.current,
|
|
1073
|
+
skipXapi: xapiCourseStartedSentOnClientRef.current || xapiBootstrapSendRef.current,
|
|
1074
|
+
onXapiStatementSent: () => {
|
|
1075
|
+
xapiCourseStartedSentOnClientRef.current = true;
|
|
1076
|
+
},
|
|
1077
|
+
shouldCommit
|
|
1078
|
+
});
|
|
1079
|
+
if (generation !== courseStartedEmitGenerationRef.current) return;
|
|
1080
|
+
courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
|
|
1081
|
+
}
|
|
1082
|
+
})();
|
|
1083
|
+
}, [normalizedCourseId, normalizedConfig.tracking?.enabled, syncProgress, onLxpackBridgeMiss]);
|
|
1084
|
+
const emitLessonCompleted = useCallback(
|
|
1085
|
+
(lessonId, durationMs) => {
|
|
1086
|
+
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
1087
|
+
if (durationMs !== void 0) {
|
|
1088
|
+
track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
|
|
1089
|
+
}
|
|
1090
|
+
},
|
|
1091
|
+
[track]
|
|
1092
|
+
);
|
|
1093
|
+
const completeLesson = useCallback(
|
|
1094
|
+
(lessonId, opts) => {
|
|
1095
|
+
if (opts?.courseId !== void 0 && opts.courseId !== courseIdRef.current) {
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
if (useV2Runtime && headlessRef.current) {
|
|
1099
|
+
headlessRef.current.completeLesson(lessonId, emitLifecycleEvent);
|
|
1100
|
+
syncProgress();
|
|
1101
|
+
void Promise.resolve(trackingRef.current?.flush?.());
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
const result = progressRef.current.completeLesson(lessonId, Date.now());
|
|
1105
|
+
if (!result.didComplete) return;
|
|
1106
|
+
syncProgress();
|
|
1107
|
+
emitLessonCompleted(lessonId, result.durationMs);
|
|
1108
|
+
void Promise.resolve(trackingRef.current?.flush?.());
|
|
1109
|
+
},
|
|
1110
|
+
[syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
|
|
1111
|
+
);
|
|
1112
|
+
useEffect2(() => {
|
|
1113
|
+
return () => {
|
|
1114
|
+
const client = trackingClientForUnmountRef.current;
|
|
1115
|
+
const xapi2 = xapiRef.current;
|
|
1116
|
+
void (async () => {
|
|
1117
|
+
try {
|
|
1118
|
+
await xapi2?.flush();
|
|
1119
|
+
} catch {
|
|
1120
|
+
}
|
|
1121
|
+
try {
|
|
1122
|
+
await client?.flush?.();
|
|
1123
|
+
} catch {
|
|
1124
|
+
}
|
|
1125
|
+
try {
|
|
1126
|
+
await client?.dispose?.();
|
|
1127
|
+
} catch {
|
|
1128
|
+
}
|
|
1129
|
+
})();
|
|
1130
|
+
};
|
|
1131
|
+
}, []);
|
|
1132
|
+
useEffect2(() => {
|
|
1133
|
+
if (typeof document === "undefined") return;
|
|
1134
|
+
const flushOnPageExit = () => {
|
|
1135
|
+
try {
|
|
1136
|
+
xapiRef.current?.flushOnExit?.();
|
|
1137
|
+
trackingRef.current?.flushOnExit?.();
|
|
1138
|
+
} finally {
|
|
1139
|
+
void xapiRef.current?.flush();
|
|
1140
|
+
void trackingRef.current?.flush?.();
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
const onVisibilityChange = () => {
|
|
1144
|
+
if (document.visibilityState === "hidden") flushOnPageExit();
|
|
1145
|
+
};
|
|
1146
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
1147
|
+
window.addEventListener("pagehide", flushOnPageExit);
|
|
1148
|
+
return () => {
|
|
1149
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
1150
|
+
window.removeEventListener("pagehide", flushOnPageExit);
|
|
1151
|
+
};
|
|
1152
|
+
}, []);
|
|
1153
|
+
const setActiveLesson = useCallback(
|
|
1154
|
+
(lessonId) => {
|
|
1155
|
+
if (useV2Runtime && headlessRef.current) {
|
|
1156
|
+
headlessRef.current.setActiveLesson(lessonId, emitLifecycleEvent);
|
|
1157
|
+
syncProgress();
|
|
1158
|
+
void Promise.resolve(trackingRef.current?.flush?.());
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
const current = progressRef.current.getState();
|
|
1162
|
+
if (current.activeLessonId === lessonId) return;
|
|
1163
|
+
if (current.completedLessonIds.has(lessonId)) {
|
|
1164
|
+
progressRef.current.setActiveLesson(lessonId, Date.now());
|
|
1165
|
+
syncProgress();
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
const previous = current.activeLessonId;
|
|
1169
|
+
if (previous && previous !== lessonId) {
|
|
1170
|
+
const completed = progressRef.current.completeLesson(previous, Date.now());
|
|
1171
|
+
if (completed.didComplete) {
|
|
1172
|
+
emitLessonCompleted(previous, completed.durationMs);
|
|
1173
|
+
void Promise.resolve(trackingRef.current?.flush?.());
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
progressRef.current.setActiveLesson(lessonId, Date.now());
|
|
1177
|
+
syncProgress();
|
|
1178
|
+
track("lesson_started", { lessonId }, { lessonId });
|
|
1179
|
+
},
|
|
1180
|
+
[track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
|
|
1181
|
+
);
|
|
1182
|
+
const completeCourse = useCallback(() => {
|
|
1183
|
+
if (useV2Runtime && headlessRef.current) {
|
|
1184
|
+
headlessRef.current.completeCourse(emitLifecycleEvent);
|
|
1185
|
+
syncProgress();
|
|
1186
|
+
void trackingRef.current?.flush?.();
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const current = progressRef.current.getState();
|
|
1190
|
+
if (current.activeLessonId) {
|
|
1191
|
+
const lessonResult = progressRef.current.completeLesson(current.activeLessonId, Date.now());
|
|
1192
|
+
if (lessonResult.didComplete) {
|
|
1193
|
+
emitLessonCompleted(current.activeLessonId, lessonResult.durationMs);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
const result = progressRef.current.completeCourse();
|
|
1197
|
+
if (!result.didComplete) return;
|
|
1198
|
+
syncProgress();
|
|
1199
|
+
track("course_completed");
|
|
1200
|
+
void trackingRef.current?.flush?.();
|
|
1201
|
+
}, [track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]);
|
|
1202
|
+
const sessionUser = normalizedConfig.session?.user;
|
|
1203
|
+
const sessionUserKey = useMemo(
|
|
1204
|
+
() => sessionUser ? JSON.stringify(sessionUser) : "",
|
|
1205
|
+
[sessionUser]
|
|
1206
|
+
);
|
|
1207
|
+
const sessionAttemptId = normalizedConfig.session?.attemptId;
|
|
1208
|
+
const sessionConfiguredId = normalizedConfig.session?.sessionId;
|
|
1209
|
+
useEffect2(() => {
|
|
1210
|
+
if (useV2Runtime && headlessRef.current) {
|
|
1211
|
+
headlessRef.current.updateConfig({
|
|
1212
|
+
courseId: normalizedCourseId,
|
|
1213
|
+
session: normalizedConfig.session
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
}, [
|
|
1217
|
+
useV2Runtime,
|
|
1218
|
+
normalizedCourseId,
|
|
1219
|
+
sessionAttemptId,
|
|
1220
|
+
sessionConfiguredId,
|
|
1221
|
+
sessionUserKey,
|
|
1222
|
+
normalizedConfig.session
|
|
1223
|
+
]);
|
|
1224
|
+
useEffect2(() => {
|
|
1225
|
+
if (!useV2Runtime || !headlessRef.current) return;
|
|
1226
|
+
headlessRef.current.updateConfig({
|
|
1227
|
+
plugins: pluginHostRef.current ?? normalizedConfig.plugins
|
|
1228
|
+
});
|
|
1229
|
+
}, [useV2Runtime, pluginHost]);
|
|
1230
|
+
useEffect2(() => {
|
|
1231
|
+
const host = useV2Runtime ? headlessRef.current?.pluginHost ?? null : pluginHost;
|
|
1232
|
+
if (!host) return;
|
|
1233
|
+
const ctx = buildPluginContext({
|
|
1234
|
+
courseId: courseIdRef.current,
|
|
1235
|
+
sessionId: sessionIdRef.current,
|
|
1236
|
+
attemptId: attemptIdRef.current,
|
|
1237
|
+
user: userRef.current
|
|
1238
|
+
});
|
|
1239
|
+
host.setupAll(ctx);
|
|
1240
|
+
return () => {
|
|
1241
|
+
host.disposeAll();
|
|
1242
|
+
};
|
|
1243
|
+
}, [pluginHost, useV2Runtime, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey]);
|
|
1244
|
+
useEffect2(() => {
|
|
1245
|
+
const nextConfigured = normalizedConfig.session?.sessionId;
|
|
1246
|
+
const prevConfigured = prevConfiguredSessionIdRef.current;
|
|
1247
|
+
if (nextConfigured === prevConfigured) return;
|
|
1248
|
+
prevConfiguredSessionIdRef.current = nextConfigured;
|
|
1249
|
+
const cid = courseIdRef.current;
|
|
1250
|
+
if (nextConfigured) {
|
|
1251
|
+
const fromIds = /* @__PURE__ */ new Set();
|
|
1252
|
+
if (prevConfigured) fromIds.add(prevConfigured);
|
|
1253
|
+
const tabId = getTabSessionId(defaultStorage);
|
|
1254
|
+
if (tabId) fromIds.add(tabId);
|
|
1255
|
+
for (const fromId of fromIds) {
|
|
1256
|
+
if (fromId !== nextConfigured) {
|
|
1257
|
+
migrateCourseStartedMark(defaultStorage, fromId, nextConfigured, cid);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
sessionIdRef.current = nextConfigured;
|
|
1261
|
+
} else if (prevConfigured) {
|
|
1262
|
+
const nextAuto = resolveSessionId(defaultStorage, void 0);
|
|
1263
|
+
migrateCourseStartedMark(defaultStorage, prevConfigured, nextAuto, cid);
|
|
1264
|
+
sessionIdRef.current = nextAuto;
|
|
1265
|
+
}
|
|
1266
|
+
}, [sessionConfiguredId, normalizedCourseId]);
|
|
1267
|
+
const runtime = useMemo(
|
|
1268
|
+
() => ({
|
|
1269
|
+
config: normalizedConfig,
|
|
1270
|
+
tracking,
|
|
1271
|
+
xapi,
|
|
1272
|
+
storage: defaultStorage,
|
|
1273
|
+
session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
|
|
1274
|
+
progress,
|
|
1275
|
+
setActiveLesson,
|
|
1276
|
+
completeLesson,
|
|
1277
|
+
completeCourse,
|
|
1278
|
+
track,
|
|
1279
|
+
plugins: pluginHost
|
|
1280
|
+
}),
|
|
1281
|
+
[
|
|
1282
|
+
normalizedConfig,
|
|
1283
|
+
tracking,
|
|
1284
|
+
xapi,
|
|
1285
|
+
progress,
|
|
1286
|
+
setActiveLesson,
|
|
1287
|
+
completeLesson,
|
|
1288
|
+
completeCourse,
|
|
1289
|
+
track,
|
|
1290
|
+
pluginHost,
|
|
1291
|
+
sessionUser,
|
|
1292
|
+
sessionAttemptId,
|
|
1293
|
+
sessionConfiguredId
|
|
1294
|
+
]
|
|
1295
|
+
);
|
|
1296
|
+
return runtime;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// src/context.tsx
|
|
1300
|
+
import { createContext as createContext2 } from "react";
|
|
1301
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
1302
|
+
var LessonkitContext = createContext2(null);
|
|
1303
|
+
function LessonkitProvider(props) {
|
|
1304
|
+
const runtime = useLessonkitProviderRuntime(props.config);
|
|
1305
|
+
return /* @__PURE__ */ jsx2(LessonkitContext.Provider, { value: runtime, children: props.children });
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// src/assessment/useAssessmentState.ts
|
|
1309
|
+
import { useMemo as useMemo3 } from "react";
|
|
1310
|
+
|
|
1311
|
+
// src/hooks.ts
|
|
1312
|
+
import { useContext as useContext2, useMemo as useMemo2 } from "react";
|
|
1313
|
+
function useLessonkit() {
|
|
1314
|
+
const ctx = useContext2(LessonkitContext);
|
|
1315
|
+
if (!ctx) throw new Error("LessonKit: missing LessonkitProvider");
|
|
1316
|
+
return ctx;
|
|
1317
|
+
}
|
|
1318
|
+
function useProgress() {
|
|
1319
|
+
const { progress } = useLessonkit();
|
|
1320
|
+
return progress;
|
|
1321
|
+
}
|
|
1322
|
+
function useTracking() {
|
|
1323
|
+
const { track } = useLessonkit();
|
|
1324
|
+
return useMemo2(() => ({ track }), [track]);
|
|
1325
|
+
}
|
|
1326
|
+
function useCompletion() {
|
|
1327
|
+
const { completeLesson, completeCourse } = useLessonkit();
|
|
1328
|
+
return useMemo2(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
|
|
1329
|
+
}
|
|
1330
|
+
function useQuizState(enclosingLessonId) {
|
|
1331
|
+
const { track } = useLessonkit();
|
|
1332
|
+
const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
|
|
1333
|
+
return useMemo2(
|
|
1334
|
+
() => ({
|
|
1335
|
+
answer: (opts) => {
|
|
1336
|
+
track("quiz_answered", opts, trackOpts);
|
|
1337
|
+
},
|
|
1338
|
+
complete: (opts) => {
|
|
1339
|
+
track("quiz_completed", opts, trackOpts);
|
|
1340
|
+
}
|
|
1341
|
+
}),
|
|
1342
|
+
[track, enclosingLessonId]
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// src/assessment/useAssessmentState.ts
|
|
1347
|
+
function useAssessmentState(enclosingLessonId) {
|
|
1348
|
+
const { track } = useLessonkit();
|
|
1349
|
+
const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
|
|
1350
|
+
return useMemo3(
|
|
1351
|
+
() => ({
|
|
1352
|
+
answer: (data) => {
|
|
1353
|
+
track("assessment_answered", data, trackOpts);
|
|
1354
|
+
},
|
|
1355
|
+
complete: (data) => {
|
|
1356
|
+
track("assessment_completed", data, trackOpts);
|
|
1357
|
+
}
|
|
1358
|
+
}),
|
|
1359
|
+
[track, enclosingLessonId]
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// src/compound/validateChildren.ts
|
|
1364
|
+
import React3 from "react";
|
|
1365
|
+
import {
|
|
1366
|
+
ACCORDION_FORBIDDEN_CHILD_TYPES,
|
|
1367
|
+
COMPOUND_MAX_NESTING_DEPTH,
|
|
1368
|
+
isChildTypeAllowed
|
|
1369
|
+
} from "@lessonkit/core";
|
|
1370
|
+
|
|
1371
|
+
// src/compound/blockType.ts
|
|
1372
|
+
var LESSONKIT_BLOCK_TYPE = /* @__PURE__ */ Symbol.for("lessonkit.blockType");
|
|
1373
|
+
function setLessonkitBlockType(component, blockType) {
|
|
1374
|
+
component[LESSONKIT_BLOCK_TYPE] = blockType;
|
|
1375
|
+
if (!component.displayName) {
|
|
1376
|
+
component.displayName = blockType;
|
|
1377
|
+
}
|
|
1378
|
+
return component;
|
|
1379
|
+
}
|
|
1380
|
+
function getLessonkitBlockType(component) {
|
|
1381
|
+
if (!component || typeof component !== "object" && typeof component !== "function") {
|
|
1382
|
+
return void 0;
|
|
1383
|
+
}
|
|
1384
|
+
const typed = component;
|
|
1385
|
+
return typed[LESSONKIT_BLOCK_TYPE] ?? typed.displayName;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// src/compound/validateChildren.ts
|
|
1389
|
+
var warnedPairs = /* @__PURE__ */ new Set();
|
|
1390
|
+
var COMPOUND_CONTAINER_TYPES = /* @__PURE__ */ new Set([
|
|
1391
|
+
"Page",
|
|
1392
|
+
"InteractiveBook",
|
|
1393
|
+
"Slide",
|
|
1394
|
+
"SlideDeck",
|
|
1395
|
+
"TimedCue",
|
|
1396
|
+
"InteractiveVideo",
|
|
1397
|
+
"AssessmentSequence"
|
|
1398
|
+
]);
|
|
1399
|
+
function warnOrThrow(msg, strict) {
|
|
1400
|
+
if (strict) throw new Error(msg);
|
|
1401
|
+
if (!warnedPairs.has(msg)) {
|
|
1402
|
+
warnedPairs.add(msg);
|
|
1403
|
+
console.warn(msg);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
function validateNode(parent, node, depth, strict) {
|
|
1407
|
+
React3.Children.forEach(node, (child) => {
|
|
1408
|
+
if (!React3.isValidElement(child)) return;
|
|
1409
|
+
const blockType = getLessonkitBlockType(child.type);
|
|
1410
|
+
if (!blockType) {
|
|
1411
|
+
if (child.props && typeof child.props === "object" && "children" in child.props) {
|
|
1412
|
+
validateNode(parent, child.props.children, depth, strict);
|
|
1413
|
+
}
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
if (!isChildTypeAllowed(parent, blockType)) {
|
|
1417
|
+
const key = `${parent}:${blockType}`;
|
|
1418
|
+
if (!warnedPairs.has(key)) {
|
|
1419
|
+
warnedPairs.add(key);
|
|
1420
|
+
const msg = `[lessonkit] Block "${blockType}" is not in the allowlist for "${parent}"`;
|
|
1421
|
+
if (strict) throw new Error(msg);
|
|
1422
|
+
console.warn(msg);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
if (COMPOUND_CONTAINER_TYPES.has(blockType)) {
|
|
1426
|
+
const maxDepth = COMPOUND_MAX_NESTING_DEPTH[parent];
|
|
1427
|
+
if (depth >= maxDepth) {
|
|
1428
|
+
warnOrThrow(
|
|
1429
|
+
`[lessonkit] Block "${blockType}" exceeds max nesting depth (${maxDepth}) for "${parent}"`,
|
|
1430
|
+
strict
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
const nestedParent = blockType;
|
|
1434
|
+
validateNode(nestedParent, child.props.children, depth + 1, strict);
|
|
1435
|
+
} else if (blockType === "Accordion") {
|
|
1436
|
+
const sections = child.props.sections;
|
|
1437
|
+
if (sections) validateAccordionSections(sections, strict);
|
|
1438
|
+
} else if (child.props && typeof child.props === "object" && "children" in child.props) {
|
|
1439
|
+
validateSubtreeForForbidden(
|
|
1440
|
+
child.props.children,
|
|
1441
|
+
ACCORDION_FORBIDDEN_CHILD_TYPES,
|
|
1442
|
+
strict
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
function validateSubtreeForForbidden(node, forbidden, strict) {
|
|
1448
|
+
React3.Children.forEach(node, (child) => {
|
|
1449
|
+
if (!React3.isValidElement(child)) return;
|
|
1450
|
+
const blockType = getLessonkitBlockType(child.type);
|
|
1451
|
+
if (blockType && forbidden.includes(blockType)) {
|
|
1452
|
+
warnOrThrow(`[lessonkit] Block "${blockType}" must not nest inside Accordion`, strict);
|
|
1453
|
+
}
|
|
1454
|
+
if (blockType === "Accordion") {
|
|
1455
|
+
const sections = child.props.sections;
|
|
1456
|
+
if (sections) validateAccordionSections(sections, strict);
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
if (child.props && typeof child.props === "object" && "children" in child.props) {
|
|
1460
|
+
validateSubtreeForForbidden(
|
|
1461
|
+
child.props.children,
|
|
1462
|
+
forbidden,
|
|
1463
|
+
strict
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
function validateAccordionSections(sections, strict) {
|
|
1469
|
+
if (!isDevEnvironment() && !strict) return;
|
|
1470
|
+
for (const section of sections) {
|
|
1471
|
+
validateSubtreeForForbidden(section.content, ACCORDION_FORBIDDEN_CHILD_TYPES, strict);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
function validateCompoundChildren(parent, children, strict) {
|
|
1475
|
+
if (!isDevEnvironment() && !strict) return;
|
|
1476
|
+
validateNode(parent, children, 0, strict);
|
|
1477
|
+
}
|
|
1478
|
+
function resetCompoundValidationWarningsForTests() {
|
|
1479
|
+
warnedPairs.clear();
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// src/assessment/internal/buildAssessmentHandle.ts
|
|
1483
|
+
function buildAssessmentHandle(opts) {
|
|
1484
|
+
return {
|
|
1485
|
+
getScore: opts.getScore,
|
|
1486
|
+
getMaxScore: opts.getMaxScore,
|
|
1487
|
+
getAnswerGiven: opts.getAnswerGiven,
|
|
1488
|
+
resetTask: opts.resetTask,
|
|
1489
|
+
showSolutions: opts.showSolutions,
|
|
1490
|
+
getXAPIData: opts.getXAPIData,
|
|
1491
|
+
...opts.getCurrentState ? { getCurrentState: opts.getCurrentState } : {},
|
|
1492
|
+
...opts.resume ? { resume: opts.resume } : {}
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// src/assessment/internal/resumeState.ts
|
|
1497
|
+
function readBooleanField(state, key) {
|
|
1498
|
+
const value = state[key];
|
|
1499
|
+
if (value === true || value === false || value === null) return value;
|
|
1500
|
+
return void 0;
|
|
1501
|
+
}
|
|
1502
|
+
function readStringField(state, key) {
|
|
1503
|
+
const value = state[key];
|
|
1504
|
+
if (typeof value === "string" || value === null) return value;
|
|
1505
|
+
return void 0;
|
|
1506
|
+
}
|
|
1507
|
+
function readNumberField(state, key) {
|
|
1508
|
+
const value = state[key];
|
|
1509
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
1510
|
+
if (value === null) return null;
|
|
1511
|
+
return void 0;
|
|
1512
|
+
}
|
|
1513
|
+
function readBooleanStateField(state, key, apply) {
|
|
1514
|
+
const value = state[key];
|
|
1515
|
+
if (typeof value === "boolean") apply(value);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// src/compound/CompoundPageIndexContext.tsx
|
|
1519
|
+
import { createContext as createContext3, useContext as useContext3 } from "react";
|
|
1520
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
1521
|
+
var CompoundPageIndexContext = createContext3(void 0);
|
|
1522
|
+
function CompoundPageIndexProvider({
|
|
1523
|
+
pageIndex,
|
|
1524
|
+
children
|
|
1525
|
+
}) {
|
|
1526
|
+
return /* @__PURE__ */ jsx3(CompoundPageIndexContext.Provider, { value: pageIndex, children });
|
|
1527
|
+
}
|
|
1528
|
+
function useCompoundPageIndex() {
|
|
1529
|
+
return useContext3(CompoundPageIndexContext);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// src/compound/CompoundProvider.tsx
|
|
1533
|
+
import React6, { createContext as createContext5, useCallback as useCallback2, useContext as useContext5, useImperativeHandle, useMemo as useMemo4, useRef as useRef3, useState as useState2 } from "react";
|
|
1534
|
+
import { clampCompoundPageIndex, createCompoundResumeState } from "@lessonkit/core";
|
|
1535
|
+
|
|
1536
|
+
// src/compound/aggregateScores.ts
|
|
1537
|
+
function aggregateAssessmentScores(handles, opts) {
|
|
1538
|
+
let score = 0;
|
|
1539
|
+
let maxScore = 0;
|
|
1540
|
+
let allAnswered = true;
|
|
1541
|
+
for (const entry of handles) {
|
|
1542
|
+
const handle = "handle" in entry ? entry.handle : entry;
|
|
1543
|
+
const pageIndex = "handle" in entry ? entry.pageIndex : void 0;
|
|
1544
|
+
score += handle.getScore();
|
|
1545
|
+
maxScore += handle.getMaxScore();
|
|
1546
|
+
const countsForAnswerGiven = opts?.answerPageIndex === void 0 || pageIndex === void 0 || pageIndex === opts.answerPageIndex;
|
|
1547
|
+
if (countsForAnswerGiven && !handle.getAnswerGiven()) allAnswered = false;
|
|
1548
|
+
}
|
|
1549
|
+
return { score, maxScore, allAnswered };
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// src/compound/CompoundHydrationBridge.tsx
|
|
1553
|
+
import { createContext as createContext4, useContext as useContext4, useRef as useRef2 } from "react";
|
|
1554
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
1555
|
+
var CompoundHydrationBridgeContext = createContext4(
|
|
1556
|
+
null
|
|
1557
|
+
);
|
|
1558
|
+
function CompoundHydrationBridgeProvider({ children }) {
|
|
1559
|
+
const bridgeRef = useRef2(null);
|
|
1560
|
+
return /* @__PURE__ */ jsx4(CompoundHydrationBridgeContext.Provider, { value: bridgeRef, children });
|
|
1561
|
+
}
|
|
1562
|
+
function useCompoundHydrationBridgeRef() {
|
|
1563
|
+
return useContext4(CompoundHydrationBridgeContext);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// src/compound/CompoundProvider.tsx
|
|
1567
|
+
import { jsx as jsx5 } from "react/jsx-runtime";
|
|
1568
|
+
var CompoundRegistryContext = createContext5(null);
|
|
1569
|
+
var CompoundHandlesVersionContext = createContext5(0);
|
|
1570
|
+
function CompoundProvider({
|
|
1571
|
+
children,
|
|
1572
|
+
activePageIndex: _activePageIndex,
|
|
1573
|
+
onActivePageIndexChange: _onActivePageIndexChange
|
|
1574
|
+
}) {
|
|
1575
|
+
const registryRef = useRef3(/* @__PURE__ */ new Map());
|
|
1576
|
+
const [handlesVersion, setHandlesVersion] = useState2(0);
|
|
1577
|
+
const register = useCallback2((checkId, handle, pageIndex) => {
|
|
1578
|
+
const prev = registryRef.current.get(checkId);
|
|
1579
|
+
if (prev && prev.handle !== handle) {
|
|
1580
|
+
const message = `[lessonkit] duplicate checkId "${checkId}" registered in the same compound container; the previous handle was replaced.`;
|
|
1581
|
+
if (isDevEnvironment()) {
|
|
1582
|
+
console.error(message);
|
|
1583
|
+
} else {
|
|
1584
|
+
console.warn(message);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
registryRef.current.set(checkId, { handle, pageIndex });
|
|
1588
|
+
if (prev?.handle !== handle || prev?.pageIndex !== pageIndex) {
|
|
1589
|
+
setHandlesVersion((v) => v + 1);
|
|
1590
|
+
}
|
|
1591
|
+
return () => {
|
|
1592
|
+
const current = registryRef.current.get(checkId);
|
|
1593
|
+
if (current?.handle === handle) {
|
|
1594
|
+
registryRef.current.delete(checkId);
|
|
1595
|
+
setHandlesVersion((v) => v + 1);
|
|
1596
|
+
}
|
|
1597
|
+
};
|
|
1598
|
+
}, []);
|
|
1599
|
+
const registryValue = useMemo4(
|
|
1600
|
+
() => ({
|
|
1601
|
+
register,
|
|
1602
|
+
getHandles: () => {
|
|
1603
|
+
const handles = /* @__PURE__ */ new Map();
|
|
1604
|
+
for (const [checkId, entry] of registryRef.current) {
|
|
1605
|
+
handles.set(checkId, entry.handle);
|
|
1606
|
+
}
|
|
1607
|
+
return handles;
|
|
1608
|
+
},
|
|
1609
|
+
getRegisteredHandles: () => registryRef.current
|
|
1610
|
+
}),
|
|
1611
|
+
[register]
|
|
1612
|
+
);
|
|
1613
|
+
return /* @__PURE__ */ jsx5(CompoundHydrationBridgeProvider, { children: /* @__PURE__ */ jsx5(CompoundRegistryContext.Provider, { value: registryValue, children: /* @__PURE__ */ jsx5(CompoundHandlesVersionContext.Provider, { value: handlesVersion, children }) }) });
|
|
1614
|
+
}
|
|
1615
|
+
function useCompoundRegistry() {
|
|
1616
|
+
const registry = useContext5(CompoundRegistryContext);
|
|
1617
|
+
const handlesVersion = useContext5(CompoundHandlesVersionContext);
|
|
1618
|
+
if (!registry) return null;
|
|
1619
|
+
return { ...registry, handlesVersion };
|
|
1620
|
+
}
|
|
1621
|
+
function useCompoundHandlesVersion() {
|
|
1622
|
+
return useContext5(CompoundHandlesVersionContext);
|
|
1623
|
+
}
|
|
1624
|
+
function useRegisterAssessmentHandle(checkId, handle) {
|
|
1625
|
+
const registry = useContext5(CompoundRegistryContext);
|
|
1626
|
+
const pageIndex = useCompoundPageIndex();
|
|
1627
|
+
React6.useLayoutEffect(() => {
|
|
1628
|
+
if (!registry || !handle) return;
|
|
1629
|
+
return registry.register(checkId, handle, pageIndex);
|
|
1630
|
+
}, [registry, checkId, handle, pageIndex]);
|
|
1631
|
+
}
|
|
1632
|
+
function useCompoundHandleRef(ref, opts) {
|
|
1633
|
+
const { activePageIndex, setActivePageIndex, getHandles, getRegisteredHandles, pageCount } = opts;
|
|
1634
|
+
const bridgeRef = useCompoundHydrationBridgeRef();
|
|
1635
|
+
const setIndexClamped = useCallback2(
|
|
1636
|
+
(index) => {
|
|
1637
|
+
const next = pageCount !== void 0 ? clampCompoundPageIndex(index, pageCount) : Math.max(0, Math.floor(index));
|
|
1638
|
+
setActivePageIndex(next);
|
|
1639
|
+
},
|
|
1640
|
+
[pageCount, setActivePageIndex]
|
|
1641
|
+
);
|
|
1642
|
+
useImperativeHandle(
|
|
1643
|
+
ref,
|
|
1644
|
+
() => ({
|
|
1645
|
+
getScore: () => aggregateAssessmentScores(getRegisteredHandles().values()).score,
|
|
1646
|
+
getMaxScore: () => aggregateAssessmentScores(getRegisteredHandles().values()).maxScore,
|
|
1647
|
+
getAnswerGiven: () => aggregateAssessmentScores(getRegisteredHandles().values(), {
|
|
1648
|
+
answerPageIndex: activePageIndex
|
|
1649
|
+
}).allAnswered,
|
|
1650
|
+
resetTask: () => {
|
|
1651
|
+
for (const entry of getRegisteredHandles().values()) entry.handle.resetTask();
|
|
1652
|
+
},
|
|
1653
|
+
showSolutions: () => {
|
|
1654
|
+
if (!opts.enableSolutionsButton) return;
|
|
1655
|
+
for (const entry of getRegisteredHandles().values()) entry.handle.showSolutions();
|
|
1656
|
+
},
|
|
1657
|
+
getCurrentState: () => {
|
|
1658
|
+
const childStates = {};
|
|
1659
|
+
for (const [checkId, entry] of getRegisteredHandles()) {
|
|
1660
|
+
if (entry.handle.getCurrentState) {
|
|
1661
|
+
childStates[checkId] = entry.handle.getCurrentState();
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
return createCompoundResumeState({ activePageIndex, childStates });
|
|
1665
|
+
},
|
|
1666
|
+
resume: (state) => {
|
|
1667
|
+
bridgeRef?.current?.notifyImperativeResume(state);
|
|
1668
|
+
}
|
|
1669
|
+
}),
|
|
1670
|
+
[activePageIndex, setIndexClamped, getHandles, getRegisteredHandles, opts.enableSolutionsButton, bridgeRef]
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// src/assessment/internal/useAssessmentHandleRegistration.ts
|
|
1675
|
+
import { useImperativeHandle as useImperativeHandle2 } from "react";
|
|
1676
|
+
function useAssessmentHandleRegistration(checkId, handle, ref) {
|
|
1677
|
+
useImperativeHandle2(ref, () => handle, [handle]);
|
|
1678
|
+
useRegisterAssessmentHandle(checkId, handle);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// src/assessment/scoring.ts
|
|
1682
|
+
function resolvePassingThreshold(passingScore, maxScore) {
|
|
1683
|
+
return passingScore ?? maxScore;
|
|
1684
|
+
}
|
|
1685
|
+
function meetsPassingThreshold(score, maxScore, passingScore) {
|
|
1686
|
+
const threshold = resolvePassingThreshold(passingScore, maxScore);
|
|
1687
|
+
return score >= threshold;
|
|
1688
|
+
}
|
|
1689
|
+
function scoreFromCustom(custom, fallbackCorrect, fallbackMax = 1, passingScore) {
|
|
1690
|
+
const maxScore = custom?.maxScore ?? fallbackMax;
|
|
1691
|
+
if (custom?.passed !== void 0) {
|
|
1692
|
+
const score2 = custom.passed ? custom.score ?? maxScore : custom.score ?? 0;
|
|
1693
|
+
return { score: score2, maxScore, passed: custom.passed };
|
|
1694
|
+
}
|
|
1695
|
+
if (custom?.maxScore != null && custom.maxScore > 0 && custom.score != null) {
|
|
1696
|
+
const passed2 = meetsPassingThreshold(custom.score, custom.maxScore, passingScore);
|
|
1697
|
+
return { score: custom.score, maxScore: custom.maxScore, passed: passed2 };
|
|
1698
|
+
}
|
|
1699
|
+
const score = fallbackCorrect ? maxScore : 0;
|
|
1700
|
+
const passed = meetsPassingThreshold(score, maxScore, passingScore);
|
|
1701
|
+
return { score, maxScore, passed };
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// src/assessment/internal/usePluginScoring.ts
|
|
1705
|
+
import { useCallback as useCallback3 } from "react";
|
|
1706
|
+
function usePluginScoring(checkId, lessonId) {
|
|
1707
|
+
const { plugins, config, session } = useLessonkit();
|
|
1708
|
+
const getPluginScore = useCallback3(
|
|
1709
|
+
(response) => {
|
|
1710
|
+
const pluginCtx = buildPluginContext({
|
|
1711
|
+
courseId: config.courseId,
|
|
1712
|
+
sessionId: session.sessionId,
|
|
1713
|
+
attemptId: session.attemptId,
|
|
1714
|
+
user: session.user
|
|
1715
|
+
});
|
|
1716
|
+
return plugins?.scoreAssessment({ checkId, lessonId, response }, pluginCtx) ?? null;
|
|
1717
|
+
},
|
|
1718
|
+
[checkId, config.courseId, lessonId, plugins, session.attemptId, session.sessionId, session.user]
|
|
1719
|
+
);
|
|
1720
|
+
const scoreResponse = useCallback3(
|
|
1721
|
+
(response, defaultCorrect, maxScore = 1, passingScore) => scoreFromCustom(getPluginScore(response), defaultCorrect, maxScore, passingScore),
|
|
1722
|
+
[getPluginScore]
|
|
1723
|
+
);
|
|
1724
|
+
const isChoiceCorrect = useCallback3(
|
|
1725
|
+
(choice, answer, custom, passingScore) => {
|
|
1726
|
+
if (!custom) return choice === answer;
|
|
1727
|
+
if (custom.passed !== void 0) return custom.passed;
|
|
1728
|
+
if (custom.maxScore != null && custom.maxScore > 0 && custom.score != null) {
|
|
1729
|
+
return meetsPassingThreshold(custom.score, custom.maxScore, passingScore);
|
|
1730
|
+
}
|
|
1731
|
+
return choice === answer;
|
|
1732
|
+
},
|
|
1733
|
+
[]
|
|
1734
|
+
);
|
|
1735
|
+
return { getPluginScore, scoreResponse, isChoiceCorrect };
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
export {
|
|
1739
|
+
LessonContext,
|
|
1740
|
+
useEnclosingLessonId,
|
|
1741
|
+
isDevEnvironment,
|
|
1742
|
+
normalizeComponentId,
|
|
1743
|
+
resetAssessmentWarningsForTests,
|
|
1744
|
+
AssessmentLessonGuard,
|
|
1745
|
+
buildAssessmentHandle,
|
|
1746
|
+
readBooleanField,
|
|
1747
|
+
readStringField,
|
|
1748
|
+
readNumberField,
|
|
1749
|
+
readBooleanStateField,
|
|
1750
|
+
useCompoundHydrationBridgeRef,
|
|
1751
|
+
CompoundPageIndexProvider,
|
|
1752
|
+
CompoundProvider,
|
|
1753
|
+
useCompoundRegistry,
|
|
1754
|
+
useCompoundHandlesVersion,
|
|
1755
|
+
useCompoundHandleRef,
|
|
1756
|
+
useAssessmentHandleRegistration,
|
|
1757
|
+
shouldEnforceProductionGuard,
|
|
1758
|
+
assertProductionCourseConfig,
|
|
1759
|
+
resetCourseStartedTrackingFlightForTests,
|
|
1760
|
+
resetLessonkitProviderStorageForTests,
|
|
1761
|
+
LessonkitContext,
|
|
1762
|
+
LessonkitProvider,
|
|
1763
|
+
useAssessmentState,
|
|
1764
|
+
useLessonkit,
|
|
1765
|
+
useProgress,
|
|
1766
|
+
useTracking,
|
|
1767
|
+
useCompletion,
|
|
1768
|
+
useQuizState,
|
|
1769
|
+
meetsPassingThreshold,
|
|
1770
|
+
usePluginScoring,
|
|
1771
|
+
setLessonkitBlockType,
|
|
1772
|
+
validateAccordionSections,
|
|
1773
|
+
validateCompoundChildren,
|
|
1774
|
+
resetCompoundValidationWarningsForTests
|
|
1775
|
+
};
|