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