@lessonkit/react 0.8.0 → 0.9.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 +1 -1
- package/dist/index.cjs +248 -80
- package/dist/index.d.cts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +248 -79
- package/package.json +6 -6
package/README.md
CHANGED
package/dist/index.cjs
CHANGED
|
@@ -42,6 +42,8 @@ __export(index_exports, {
|
|
|
42
42
|
ThemeProvider: () => ThemeProvider,
|
|
43
43
|
blockCatalogVersion: () => blockCatalogVersion,
|
|
44
44
|
buildBlockCatalog: () => buildBlockCatalog,
|
|
45
|
+
createPluginHost: () => import_core7.createPluginHost,
|
|
46
|
+
defineLessonkitPlugin: () => import_core7.defineLessonkitPlugin,
|
|
45
47
|
getBlockCatalogEntry: () => getBlockCatalogEntry,
|
|
46
48
|
useCompletion: () => useCompletion,
|
|
47
49
|
useLessonkit: () => useLessonkit,
|
|
@@ -58,7 +60,7 @@ var import_accessibility = require("@lessonkit/accessibility");
|
|
|
58
60
|
|
|
59
61
|
// src/context.tsx
|
|
60
62
|
var import_react = require("react");
|
|
61
|
-
var
|
|
63
|
+
var import_core5 = require("@lessonkit/core");
|
|
62
64
|
var import_xapi3 = require("@lessonkit/xapi");
|
|
63
65
|
var import_xapi4 = require("@lessonkit/xapi");
|
|
64
66
|
|
|
@@ -69,43 +71,55 @@ var import_xapi = require("@lessonkit/xapi");
|
|
|
69
71
|
// src/runtime/lxpackBridge.ts
|
|
70
72
|
var import_bridge = require("@lessonkit/lxpack/bridge");
|
|
71
73
|
function getBridge() {
|
|
74
|
+
const fromSdk = (0, import_bridge.getLxpackBridge)();
|
|
75
|
+
if (fromSdk) return fromSdk;
|
|
72
76
|
if (typeof window === "undefined") return null;
|
|
73
77
|
const parent = window.parent;
|
|
74
78
|
if (!parent || parent === window) return null;
|
|
75
|
-
return parent.
|
|
79
|
+
return parent.lxpack ?? null;
|
|
76
80
|
}
|
|
77
|
-
function
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
case "lesson_completed": {
|
|
83
|
-
const lessonId = event.lessonId;
|
|
84
|
-
if (lessonId) bridge.completeLesson?.(lessonId);
|
|
81
|
+
function applyBridgeAction(bridge, action) {
|
|
82
|
+
if (!action) return;
|
|
83
|
+
switch (action.kind) {
|
|
84
|
+
case "completeLesson":
|
|
85
|
+
bridge.completeLesson?.(action.lessonId);
|
|
85
86
|
return;
|
|
86
|
-
|
|
87
|
-
case "course_completed":
|
|
87
|
+
case "completeCourse":
|
|
88
88
|
bridge.completeCourse?.();
|
|
89
89
|
return;
|
|
90
|
-
case "
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
score: data.score,
|
|
95
|
-
maxScore: data.maxScore
|
|
90
|
+
case "submitAssessment": {
|
|
91
|
+
const scaled = (0, import_bridge.normalizeScore)({
|
|
92
|
+
score: action.score,
|
|
93
|
+
maxScore: action.maxScore
|
|
96
94
|
});
|
|
97
95
|
if (scaled === null) return;
|
|
98
96
|
bridge.submitAssessment?.({
|
|
99
|
-
id:
|
|
97
|
+
id: action.id,
|
|
100
98
|
score: scaled,
|
|
101
|
-
passingScore: (0, import_bridge.
|
|
99
|
+
passingScore: (0, import_bridge.normalizePassingThreshold)({
|
|
100
|
+
passingScore: action.passingScore,
|
|
101
|
+
maxScore: action.maxScore
|
|
102
|
+
}),
|
|
103
|
+
maxScore: action.maxScore
|
|
102
104
|
});
|
|
103
105
|
return;
|
|
104
106
|
}
|
|
107
|
+
case "track":
|
|
108
|
+
bridge.track?.(action.event);
|
|
109
|
+
return;
|
|
105
110
|
default:
|
|
106
111
|
return;
|
|
107
112
|
}
|
|
108
113
|
}
|
|
114
|
+
function forwardTelemetryToLxpack(event, mode = "auto") {
|
|
115
|
+
if (mode === "off") return;
|
|
116
|
+
const bridge = getBridge();
|
|
117
|
+
if (!bridge) return;
|
|
118
|
+
const lessonkitEvent = (0, import_bridge.telemetryEventToLessonkit)(event);
|
|
119
|
+
if (!lessonkitEvent) return;
|
|
120
|
+
const action = (0, import_bridge.mapLessonkitTelemetryToBridgeAction)(lessonkitEvent);
|
|
121
|
+
applyBridgeAction(bridge, action);
|
|
122
|
+
}
|
|
109
123
|
|
|
110
124
|
// src/runtime/emitTelemetry.ts
|
|
111
125
|
var warnedMissingCourseId = false;
|
|
@@ -229,6 +243,12 @@ function createSessionStoragePort() {
|
|
|
229
243
|
sessionStorage.setItem(key, value);
|
|
230
244
|
} catch {
|
|
231
245
|
}
|
|
246
|
+
},
|
|
247
|
+
removeItem: (key) => {
|
|
248
|
+
try {
|
|
249
|
+
sessionStorage.removeItem(key);
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
232
252
|
}
|
|
233
253
|
};
|
|
234
254
|
}
|
|
@@ -276,6 +296,8 @@ function createXapiClientFromConfig(config, queue) {
|
|
|
276
296
|
if (config.xapi?.enabled === false) return null;
|
|
277
297
|
if (config.xapi?.client) return config.xapi.client;
|
|
278
298
|
if (!config.courseId) return null;
|
|
299
|
+
const hasTransport = typeof config.xapi?.transport === "function";
|
|
300
|
+
if (!hasTransport && config.xapi?.enabled !== true) return null;
|
|
279
301
|
return (0, import_xapi2.createXAPIClient)({
|
|
280
302
|
courseId: config.courseId,
|
|
281
303
|
transport: config.xapi?.transport,
|
|
@@ -286,6 +308,9 @@ function createXapiClientFromConfig(config, queue) {
|
|
|
286
308
|
// src/runtime/session.ts
|
|
287
309
|
var import_core2 = require("@lessonkit/core");
|
|
288
310
|
var SESSION_STORAGE_KEY = "lessonkit:sessionId";
|
|
311
|
+
function getTabSessionId(storage) {
|
|
312
|
+
return storage.getItem(SESSION_STORAGE_KEY);
|
|
313
|
+
}
|
|
289
314
|
var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
|
|
290
315
|
function resolveSessionId(storage, provided) {
|
|
291
316
|
if (provided) return provided;
|
|
@@ -306,30 +331,66 @@ function markCourseStarted(storage, sessionId, courseId) {
|
|
|
306
331
|
if (!courseId) return;
|
|
307
332
|
storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
|
|
308
333
|
}
|
|
334
|
+
function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
|
|
335
|
+
if (!courseId || fromSessionId === toSessionId) return;
|
|
336
|
+
if (hasCourseStarted(storage, fromSessionId, courseId)) {
|
|
337
|
+
markCourseStarted(storage, toSessionId, courseId);
|
|
338
|
+
storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
309
341
|
|
|
310
|
-
// src/
|
|
311
|
-
var
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
client?.flush?.();
|
|
316
|
-
client?.dispose?.();
|
|
342
|
+
// src/runtime/plugins.ts
|
|
343
|
+
var import_core3 = require("@lessonkit/core");
|
|
344
|
+
function createReactPluginHost(plugins) {
|
|
345
|
+
if (!plugins?.length) return null;
|
|
346
|
+
return (0, import_core3.createPluginHost)(plugins);
|
|
317
347
|
}
|
|
318
|
-
|
|
348
|
+
function buildPluginContext(opts) {
|
|
349
|
+
return {
|
|
350
|
+
courseId: opts.courseId,
|
|
351
|
+
sessionId: opts.sessionId,
|
|
352
|
+
attemptId: opts.attemptId
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function emitTelemetryWithPlugins(opts) {
|
|
356
|
+
const next = opts.pluginHost ? opts.pluginHost.runTelemetry(opts.event, opts.pluginCtx) : opts.event;
|
|
357
|
+
if (next === null) return;
|
|
358
|
+
emitTelemetry(opts.tracking, opts.xapi, next, { lxpackBridge: opts.lxpackBridge ?? "auto" });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// src/runtime/telemetry.ts
|
|
362
|
+
var import_core4 = require("@lessonkit/core");
|
|
319
363
|
function createTrackingClientFromConfig(config) {
|
|
320
|
-
if (config.tracking?.enabled === false)
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
return (0, import_core3.createTrackingClient)({
|
|
364
|
+
if (config.tracking?.enabled === false) return (0, import_core4.createTrackingClient)();
|
|
365
|
+
if (config.tracking?.createClient) return config.tracking.createClient();
|
|
366
|
+
return (0, import_core4.createTrackingClient)({
|
|
324
367
|
sink: config.tracking?.sink,
|
|
325
368
|
batchSink: config.tracking?.batchSink,
|
|
326
369
|
batch: config.tracking?.batch
|
|
327
370
|
});
|
|
328
371
|
}
|
|
372
|
+
function disposeTrackingClient(client) {
|
|
373
|
+
client?.flush?.();
|
|
374
|
+
client?.dispose?.();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// src/context.tsx
|
|
378
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
379
|
+
var LessonkitContext = (0, import_react.createContext)(null);
|
|
380
|
+
var useIsoLayoutEffect = typeof window !== "undefined" ? import_react.useLayoutEffect : import_react.useEffect;
|
|
381
|
+
var defaultStorage = createSessionStoragePort();
|
|
382
|
+
function isTrackingActive(tracking) {
|
|
383
|
+
return tracking?.enabled !== false;
|
|
384
|
+
}
|
|
329
385
|
function LessonkitProvider(props) {
|
|
330
386
|
const config = props.config;
|
|
331
387
|
const sessionIdRef = (0, import_react.useRef)(resolveSessionId(defaultStorage, config.session?.sessionId));
|
|
332
|
-
|
|
388
|
+
const prevConfiguredSessionIdRef = (0, import_react.useRef)(config.session?.sessionId);
|
|
389
|
+
if (config.session?.sessionId) {
|
|
390
|
+
sessionIdRef.current = config.session.sessionId;
|
|
391
|
+
} else if (prevConfiguredSessionIdRef.current) {
|
|
392
|
+
sessionIdRef.current = resolveSessionId(defaultStorage, void 0);
|
|
393
|
+
}
|
|
333
394
|
const attemptIdRef = (0, import_react.useRef)(config.session?.attemptId);
|
|
334
395
|
const userRef = (0, import_react.useRef)(config.session?.user);
|
|
335
396
|
attemptIdRef.current = config.session?.attemptId;
|
|
@@ -338,7 +399,19 @@ function LessonkitProvider(props) {
|
|
|
338
399
|
courseIdRef.current = config.courseId;
|
|
339
400
|
const lxpackBridgeModeRef = (0, import_react.useRef)(config.lxpack?.bridge ?? "auto");
|
|
340
401
|
lxpackBridgeModeRef.current = config.lxpack?.bridge ?? "auto";
|
|
402
|
+
const pluginHost = (0, import_react.useMemo)(() => createReactPluginHost(config.plugins), [config.plugins]);
|
|
403
|
+
const pluginHostRef = (0, import_react.useRef)(pluginHost);
|
|
404
|
+
pluginHostRef.current = pluginHost;
|
|
341
405
|
const progressRef = (0, import_react.useRef)(createProgressController());
|
|
406
|
+
const courseStartedEmittedToSinkRef = (0, import_react.useRef)(false);
|
|
407
|
+
const prevCourseIdForProgressRef = (0, import_react.useRef)(config.courseId);
|
|
408
|
+
const pendingCourseIdResetRef = (0, import_react.useRef)(false);
|
|
409
|
+
if (prevCourseIdForProgressRef.current !== config.courseId) {
|
|
410
|
+
prevCourseIdForProgressRef.current = config.courseId;
|
|
411
|
+
progressRef.current = createProgressController();
|
|
412
|
+
pendingCourseIdResetRef.current = true;
|
|
413
|
+
courseStartedEmittedToSinkRef.current = false;
|
|
414
|
+
}
|
|
342
415
|
const [progress, setProgress] = (0, import_react.useState)(() => progressRef.current.getState());
|
|
343
416
|
const syncProgress = (0, import_react.useCallback)(() => {
|
|
344
417
|
setProgress(progressRef.current.getState());
|
|
@@ -348,11 +421,17 @@ function LessonkitProvider(props) {
|
|
|
348
421
|
const xapiQueueRef = (0, import_react.useRef)((0, import_xapi3.createInMemoryXAPIQueue)());
|
|
349
422
|
const xapiRef = (0, import_react.useRef)(null);
|
|
350
423
|
const [xapi, setXapi] = (0, import_react.useState)(null);
|
|
424
|
+
const prevXapiCourseIdRef = (0, import_react.useRef)(config.courseId);
|
|
351
425
|
const xapiEnabled = config.xapi?.enabled;
|
|
352
426
|
const xapiClient = config.xapi?.client;
|
|
353
427
|
const xapiTransport = config.xapi?.transport;
|
|
354
428
|
const courseId = config.courseId;
|
|
429
|
+
const trackingEnabled = config.tracking?.enabled;
|
|
355
430
|
useIsoLayoutEffect(() => {
|
|
431
|
+
if (prevXapiCourseIdRef.current !== courseId) {
|
|
432
|
+
xapiQueueRef.current = (0, import_xapi3.createInMemoryXAPIQueue)();
|
|
433
|
+
prevXapiCourseIdRef.current = courseId;
|
|
434
|
+
}
|
|
356
435
|
const prev = xapiRef.current;
|
|
357
436
|
const next = createXapiClientFromConfig(config, xapiQueueRef.current);
|
|
358
437
|
xapiRef.current = next;
|
|
@@ -360,7 +439,9 @@ function LessonkitProvider(props) {
|
|
|
360
439
|
if (next && !prev) {
|
|
361
440
|
const sessionId = sessionIdRef.current;
|
|
362
441
|
const cid = courseIdRef.current;
|
|
363
|
-
|
|
442
|
+
const trackingActive = isTrackingActive(config.tracking);
|
|
443
|
+
const alreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
|
|
444
|
+
if (!trackingActive || alreadyStarted) {
|
|
364
445
|
try {
|
|
365
446
|
const statement = (0, import_xapi4.telemetryEventToXAPIStatement)(
|
|
366
447
|
buildTrackEvent({
|
|
@@ -376,6 +457,7 @@ function LessonkitProvider(props) {
|
|
|
376
457
|
}
|
|
377
458
|
}
|
|
378
459
|
}
|
|
460
|
+
let cancelled = false;
|
|
379
461
|
void (async () => {
|
|
380
462
|
if (prev) {
|
|
381
463
|
try {
|
|
@@ -383,18 +465,20 @@ function LessonkitProvider(props) {
|
|
|
383
465
|
} catch {
|
|
384
466
|
}
|
|
385
467
|
}
|
|
468
|
+
if (cancelled) return;
|
|
386
469
|
try {
|
|
387
470
|
await next?.flush();
|
|
388
471
|
} catch {
|
|
389
472
|
}
|
|
390
473
|
})();
|
|
391
474
|
return () => {
|
|
475
|
+
cancelled = true;
|
|
392
476
|
void prev?.flush();
|
|
393
477
|
};
|
|
394
|
-
}, [xapiEnabled, xapiClient, xapiTransport, courseId]);
|
|
395
|
-
const trackingRef = (0, import_react.useRef)((0,
|
|
478
|
+
}, [xapiEnabled, xapiClient, xapiTransport, courseId, trackingEnabled]);
|
|
479
|
+
const trackingRef = (0, import_react.useRef)((0, import_core5.createTrackingClient)());
|
|
480
|
+
const trackingClientForUnmountRef = (0, import_react.useRef)(trackingRef.current);
|
|
396
481
|
const [tracking, setTracking] = (0, import_react.useState)(() => trackingRef.current);
|
|
397
|
-
const trackingEnabled = config.tracking?.enabled;
|
|
398
482
|
const trackingSink = config.tracking?.sink;
|
|
399
483
|
const trackingBatchSink = config.tracking?.batchSink;
|
|
400
484
|
const batchEnabled = config.tracking?.batch?.enabled;
|
|
@@ -402,25 +486,50 @@ function LessonkitProvider(props) {
|
|
|
402
486
|
const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
|
|
403
487
|
useIsoLayoutEffect(() => {
|
|
404
488
|
const prev = trackingRef.current;
|
|
405
|
-
const
|
|
489
|
+
const pluginCtx = buildPluginContext({
|
|
490
|
+
courseId: courseIdRef.current,
|
|
491
|
+
sessionId: sessionIdRef.current,
|
|
492
|
+
attemptId: attemptIdRef.current
|
|
493
|
+
});
|
|
494
|
+
const sink = pluginHostRef.current?.composeTrackingSink(config.tracking?.sink, pluginCtx) ?? config.tracking?.sink;
|
|
495
|
+
const batchSink = pluginHostRef.current && config.tracking?.batchSink ? (events) => {
|
|
496
|
+
const filtered = pluginHostRef.current.runTelemetryBatch(events, pluginCtx);
|
|
497
|
+
return config.tracking.batchSink(filtered);
|
|
498
|
+
} : config.tracking?.batchSink;
|
|
499
|
+
const next = createTrackingClientFromConfig({
|
|
500
|
+
tracking: { ...config.tracking, sink, batchSink }
|
|
501
|
+
});
|
|
406
502
|
trackingRef.current = next;
|
|
503
|
+
trackingClientForUnmountRef.current = next;
|
|
407
504
|
setTracking(next);
|
|
408
505
|
const sessionId = sessionIdRef.current;
|
|
409
506
|
const cid = courseIdRef.current;
|
|
410
|
-
|
|
507
|
+
const trackingActive = isTrackingActive(config.tracking);
|
|
508
|
+
if (!trackingActive) {
|
|
509
|
+
courseStartedEmittedToSinkRef.current = false;
|
|
510
|
+
} else if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
411
511
|
markCourseStarted(defaultStorage, sessionId, cid);
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
512
|
+
emitTelemetryWithPlugins({
|
|
513
|
+
pluginHost: pluginHostRef.current,
|
|
514
|
+
tracking: next,
|
|
515
|
+
xapi: xapiRef.current,
|
|
516
|
+
event: buildTrackEvent({
|
|
416
517
|
name: "course_started",
|
|
417
518
|
courseId: cid,
|
|
418
519
|
sessionId,
|
|
419
520
|
attemptId: attemptIdRef.current,
|
|
420
521
|
user: userRef.current
|
|
421
522
|
}),
|
|
422
|
-
|
|
423
|
-
|
|
523
|
+
pluginCtx: buildPluginContext({
|
|
524
|
+
courseId: cid,
|
|
525
|
+
sessionId,
|
|
526
|
+
attemptId: attemptIdRef.current
|
|
527
|
+
}),
|
|
528
|
+
lxpackBridge: lxpackBridgeModeRef.current
|
|
529
|
+
});
|
|
530
|
+
courseStartedEmittedToSinkRef.current = true;
|
|
531
|
+
} else if (trackingActive) {
|
|
532
|
+
courseStartedEmittedToSinkRef.current = true;
|
|
424
533
|
}
|
|
425
534
|
return () => {
|
|
426
535
|
if (prev !== trackingRef.current) {
|
|
@@ -433,16 +542,23 @@ function LessonkitProvider(props) {
|
|
|
433
542
|
trackingBatchSink,
|
|
434
543
|
batchEnabled,
|
|
435
544
|
batchFlushIntervalMs,
|
|
436
|
-
batchMaxBatchSize
|
|
545
|
+
batchMaxBatchSize,
|
|
546
|
+
config.plugins
|
|
437
547
|
]);
|
|
438
|
-
const emitWithBridge = (0, import_react.useCallback)(
|
|
439
|
-
(
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
548
|
+
const emitWithBridge = (0, import_react.useCallback)((trackingClient, event) => {
|
|
549
|
+
emitTelemetryWithPlugins({
|
|
550
|
+
pluginHost: pluginHostRef.current,
|
|
551
|
+
tracking: trackingClient,
|
|
552
|
+
xapi: xapiRef.current,
|
|
553
|
+
event,
|
|
554
|
+
pluginCtx: buildPluginContext({
|
|
555
|
+
courseId: courseIdRef.current,
|
|
556
|
+
sessionId: sessionIdRef.current,
|
|
557
|
+
attemptId: attemptIdRef.current
|
|
558
|
+
}),
|
|
559
|
+
lxpackBridge: lxpackBridgeModeRef.current
|
|
560
|
+
});
|
|
561
|
+
}, []);
|
|
446
562
|
const track = (0, import_react.useCallback)(
|
|
447
563
|
(name, data, opts) => {
|
|
448
564
|
const event = tryBuildTrackEvent({
|
|
@@ -459,36 +575,36 @@ function LessonkitProvider(props) {
|
|
|
459
575
|
},
|
|
460
576
|
[emitWithBridge]
|
|
461
577
|
);
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const previousActiveLesson = progressRef.current.getState().activeLessonId;
|
|
466
|
-
prevCourseIdRef.current = config.courseId;
|
|
467
|
-
progressRef.current = createProgressController();
|
|
578
|
+
(0, import_react.useLayoutEffect)(() => {
|
|
579
|
+
if (!pendingCourseIdResetRef.current) return;
|
|
580
|
+
pendingCourseIdResetRef.current = false;
|
|
468
581
|
syncProgress();
|
|
469
|
-
if (
|
|
470
|
-
progressRef.current.setActiveLesson(previousActiveLesson, Date.now());
|
|
471
|
-
syncProgress();
|
|
472
|
-
track("lesson_started", { lessonId: previousActiveLesson }, { lessonId: previousActiveLesson });
|
|
473
|
-
}
|
|
582
|
+
if (!isTrackingActive(config.tracking)) return;
|
|
474
583
|
const sessionId = sessionIdRef.current;
|
|
475
|
-
const cid =
|
|
476
|
-
if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
584
|
+
const cid = courseIdRef.current;
|
|
585
|
+
if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
477
586
|
markCourseStarted(defaultStorage, sessionId, cid);
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
587
|
+
emitTelemetryWithPlugins({
|
|
588
|
+
pluginHost: pluginHostRef.current,
|
|
589
|
+
tracking: trackingRef.current,
|
|
590
|
+
xapi: xapiRef.current,
|
|
591
|
+
event: buildTrackEvent({
|
|
482
592
|
name: "course_started",
|
|
483
593
|
courseId: cid,
|
|
484
594
|
sessionId,
|
|
485
595
|
attemptId: attemptIdRef.current,
|
|
486
596
|
user: userRef.current
|
|
487
597
|
}),
|
|
488
|
-
|
|
489
|
-
|
|
598
|
+
pluginCtx: buildPluginContext({
|
|
599
|
+
courseId: cid,
|
|
600
|
+
sessionId,
|
|
601
|
+
attemptId: attemptIdRef.current
|
|
602
|
+
}),
|
|
603
|
+
lxpackBridge: lxpackBridgeModeRef.current
|
|
604
|
+
});
|
|
605
|
+
courseStartedEmittedToSinkRef.current = true;
|
|
490
606
|
}
|
|
491
|
-
}, [config.courseId,
|
|
607
|
+
}, [config.courseId, config.tracking?.enabled, syncProgress]);
|
|
492
608
|
const emitLessonCompleted = (0, import_react.useCallback)(
|
|
493
609
|
(lessonId, durationMs) => {
|
|
494
610
|
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
@@ -508,16 +624,21 @@ function LessonkitProvider(props) {
|
|
|
508
624
|
},
|
|
509
625
|
[syncProgress, emitLessonCompleted]
|
|
510
626
|
);
|
|
627
|
+
const unmountTimerIdsRef = (0, import_react.useRef)([]);
|
|
511
628
|
(0, import_react.useEffect)(() => {
|
|
512
629
|
return () => {
|
|
513
|
-
const
|
|
630
|
+
for (const id of unmountTimerIdsRef.current) clearTimeout(id);
|
|
631
|
+
unmountTimerIdsRef.current = [];
|
|
632
|
+
const client = trackingClientForUnmountRef.current;
|
|
514
633
|
void xapiRef.current?.flush();
|
|
515
|
-
setTimeout(() => {
|
|
634
|
+
const flushTimer = setTimeout(() => {
|
|
516
635
|
client?.flush?.();
|
|
517
|
-
setTimeout(() => {
|
|
636
|
+
const disposeTimer = setTimeout(() => {
|
|
518
637
|
client?.dispose?.();
|
|
519
638
|
}, 0);
|
|
639
|
+
unmountTimerIdsRef.current.push(disposeTimer);
|
|
520
640
|
}, 0);
|
|
641
|
+
unmountTimerIdsRef.current.push(flushTimer);
|
|
521
642
|
};
|
|
522
643
|
}, []);
|
|
523
644
|
const setActiveLesson = (0, import_react.useCallback)(
|
|
@@ -542,10 +663,46 @@ function LessonkitProvider(props) {
|
|
|
542
663
|
if (!result.didComplete) return;
|
|
543
664
|
syncProgress();
|
|
544
665
|
track("course_completed");
|
|
666
|
+
void trackingRef.current?.flush?.();
|
|
545
667
|
}, [track, syncProgress]);
|
|
546
668
|
const sessionUser = config.session?.user;
|
|
547
669
|
const sessionAttemptId = config.session?.attemptId;
|
|
548
670
|
const sessionConfiguredId = config.session?.sessionId;
|
|
671
|
+
(0, import_react.useEffect)(() => {
|
|
672
|
+
if (!pluginHost) return;
|
|
673
|
+
const ctx = buildPluginContext({
|
|
674
|
+
courseId: courseIdRef.current,
|
|
675
|
+
sessionId: sessionIdRef.current,
|
|
676
|
+
attemptId: attemptIdRef.current
|
|
677
|
+
});
|
|
678
|
+
pluginHost.setupAll(ctx);
|
|
679
|
+
return () => {
|
|
680
|
+
pluginHost.disposeAll();
|
|
681
|
+
};
|
|
682
|
+
}, [pluginHost, config.courseId, sessionAttemptId, sessionConfiguredId]);
|
|
683
|
+
(0, import_react.useEffect)(() => {
|
|
684
|
+
const nextConfigured = config.session?.sessionId;
|
|
685
|
+
const prevConfigured = prevConfiguredSessionIdRef.current;
|
|
686
|
+
if (nextConfigured === prevConfigured) return;
|
|
687
|
+
prevConfiguredSessionIdRef.current = nextConfigured;
|
|
688
|
+
const cid = courseIdRef.current;
|
|
689
|
+
if (nextConfigured) {
|
|
690
|
+
const fromIds = /* @__PURE__ */ new Set();
|
|
691
|
+
if (prevConfigured) fromIds.add(prevConfigured);
|
|
692
|
+
const tabId = getTabSessionId(defaultStorage);
|
|
693
|
+
if (tabId) fromIds.add(tabId);
|
|
694
|
+
for (const fromId of fromIds) {
|
|
695
|
+
if (fromId !== nextConfigured) {
|
|
696
|
+
migrateCourseStartedMark(defaultStorage, fromId, nextConfigured, cid);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
sessionIdRef.current = nextConfigured;
|
|
700
|
+
} else if (prevConfigured) {
|
|
701
|
+
const nextAuto = resolveSessionId(defaultStorage, void 0);
|
|
702
|
+
migrateCourseStartedMark(defaultStorage, prevConfigured, nextAuto, cid);
|
|
703
|
+
sessionIdRef.current = nextAuto;
|
|
704
|
+
}
|
|
705
|
+
}, [sessionConfiguredId, config.courseId]);
|
|
549
706
|
const runtime = (0, import_react.useMemo)(
|
|
550
707
|
() => ({
|
|
551
708
|
config,
|
|
@@ -556,7 +713,8 @@ function LessonkitProvider(props) {
|
|
|
556
713
|
setActiveLesson,
|
|
557
714
|
completeLesson,
|
|
558
715
|
completeCourse,
|
|
559
|
-
track
|
|
716
|
+
track,
|
|
717
|
+
plugins: pluginHost
|
|
560
718
|
}),
|
|
561
719
|
[
|
|
562
720
|
config,
|
|
@@ -567,6 +725,7 @@ function LessonkitProvider(props) {
|
|
|
567
725
|
completeLesson,
|
|
568
726
|
completeCourse,
|
|
569
727
|
track,
|
|
728
|
+
pluginHost,
|
|
570
729
|
sessionUser,
|
|
571
730
|
sessionAttemptId,
|
|
572
731
|
sessionConfiguredId
|
|
@@ -610,7 +769,7 @@ function useQuizState() {
|
|
|
610
769
|
}
|
|
611
770
|
|
|
612
771
|
// src/runtime/validateComponentId.ts
|
|
613
|
-
var
|
|
772
|
+
var import_core6 = require("@lessonkit/core");
|
|
614
773
|
var warnedPaths = /* @__PURE__ */ new Set();
|
|
615
774
|
function isDevEnvironment2() {
|
|
616
775
|
const g = globalThis;
|
|
@@ -620,7 +779,7 @@ function warnInvalidComponentId(id, path) {
|
|
|
620
779
|
if (!isDevEnvironment2()) return;
|
|
621
780
|
const key = `${path}:${String(id)}`;
|
|
622
781
|
if (warnedPaths.has(key)) return;
|
|
623
|
-
const result = (0,
|
|
782
|
+
const result = (0, import_core6.validateId)(id, path);
|
|
624
783
|
if (result.ok) return;
|
|
625
784
|
warnedPaths.add(key);
|
|
626
785
|
const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
|
|
@@ -698,6 +857,10 @@ function Quiz(props) {
|
|
|
698
857
|
const [selected, setSelected] = (0, import_react3.useState)(null);
|
|
699
858
|
const completedRef = (0, import_react3.useRef)(false);
|
|
700
859
|
const questionId = (0, import_react3.useId)();
|
|
860
|
+
(0, import_react3.useEffect)(() => {
|
|
861
|
+
completedRef.current = false;
|
|
862
|
+
setSelected(null);
|
|
863
|
+
}, [props.checkId, props.answer, props.question]);
|
|
701
864
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
|
|
702
865
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: questionId, children: props.question }),
|
|
703
866
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("fieldset", { "aria-labelledby": questionId, children: [
|
|
@@ -741,6 +904,9 @@ function ProgressTracker() {
|
|
|
741
904
|
] }) });
|
|
742
905
|
}
|
|
743
906
|
|
|
907
|
+
// src/index.tsx
|
|
908
|
+
var import_core7 = require("@lessonkit/core");
|
|
909
|
+
|
|
744
910
|
// src/theme/ThemeProvider.tsx
|
|
745
911
|
var import_react4 = __toESM(require("react"), 1);
|
|
746
912
|
var import_themes = require("@lessonkit/themes");
|
|
@@ -1049,6 +1215,8 @@ function getBlockCatalogEntry(type) {
|
|
|
1049
1215
|
ThemeProvider,
|
|
1050
1216
|
blockCatalogVersion,
|
|
1051
1217
|
buildBlockCatalog,
|
|
1218
|
+
createPluginHost,
|
|
1219
|
+
defineLessonkitPlugin,
|
|
1052
1220
|
getBlockCatalogEntry,
|
|
1053
1221
|
useCompletion,
|
|
1054
1222
|
useLessonkit,
|
package/dist/index.d.cts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import * as _lessonkit_core from '@lessonkit/core';
|
|
4
|
-
import { LessonId, CourseId, TelemetryUser, TrackingClient, TelemetryEventName, CheckId, BlockId } from '@lessonkit/core';
|
|
4
|
+
import { LessonId, CourseId, TelemetryUser, TrackingClient, LessonkitPlugin, TelemetryEventName, PluginHost, CheckId, BlockId } from '@lessonkit/core';
|
|
5
|
+
export { AssessmentScoreInput, AssessmentScoreResult, InteractionBlockRegistration, LessonkitPlugin, LessonkitPluginContext, LessonkitPluginKind, PluginHost, createPluginHost, defineLessonkitPlugin } from '@lessonkit/core';
|
|
5
6
|
import { XAPITransport, XAPIClient } from '@lessonkit/xapi';
|
|
6
7
|
import { LessonkitThemeV1, ThemePresetName, PartialLessonkitThemeV1 } from '@lessonkit/themes';
|
|
7
8
|
export { ThemePresetName } from '@lessonkit/themes';
|
|
@@ -38,6 +39,8 @@ type LessonkitConfig = {
|
|
|
38
39
|
/** Forward completion events to `window.parent.lxpackBridge.v1` when embedded (default `auto`). */
|
|
39
40
|
bridge?: "auto" | "off";
|
|
40
41
|
};
|
|
42
|
+
/** Framework plugins (analytics, LMS, assessment, interaction, AI). */
|
|
43
|
+
plugins?: LessonkitPlugin[];
|
|
41
44
|
};
|
|
42
45
|
|
|
43
46
|
type LessonkitRuntime = {
|
|
@@ -56,6 +59,7 @@ type LessonkitRuntime = {
|
|
|
56
59
|
track: (name: TelemetryEventName, data?: unknown, opts?: {
|
|
57
60
|
lessonId?: LessonId;
|
|
58
61
|
}) => void;
|
|
62
|
+
plugins: PluginHost | null;
|
|
59
63
|
};
|
|
60
64
|
declare function LessonkitProvider(props: {
|
|
61
65
|
config: LessonkitConfig;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import * as _lessonkit_core from '@lessonkit/core';
|
|
4
|
-
import { LessonId, CourseId, TelemetryUser, TrackingClient, TelemetryEventName, CheckId, BlockId } from '@lessonkit/core';
|
|
4
|
+
import { LessonId, CourseId, TelemetryUser, TrackingClient, LessonkitPlugin, TelemetryEventName, PluginHost, CheckId, BlockId } from '@lessonkit/core';
|
|
5
|
+
export { AssessmentScoreInput, AssessmentScoreResult, InteractionBlockRegistration, LessonkitPlugin, LessonkitPluginContext, LessonkitPluginKind, PluginHost, createPluginHost, defineLessonkitPlugin } from '@lessonkit/core';
|
|
5
6
|
import { XAPITransport, XAPIClient } from '@lessonkit/xapi';
|
|
6
7
|
import { LessonkitThemeV1, ThemePresetName, PartialLessonkitThemeV1 } from '@lessonkit/themes';
|
|
7
8
|
export { ThemePresetName } from '@lessonkit/themes';
|
|
@@ -38,6 +39,8 @@ type LessonkitConfig = {
|
|
|
38
39
|
/** Forward completion events to `window.parent.lxpackBridge.v1` when embedded (default `auto`). */
|
|
39
40
|
bridge?: "auto" | "off";
|
|
40
41
|
};
|
|
42
|
+
/** Framework plugins (analytics, LMS, assessment, interaction, AI). */
|
|
43
|
+
plugins?: LessonkitPlugin[];
|
|
41
44
|
};
|
|
42
45
|
|
|
43
46
|
type LessonkitRuntime = {
|
|
@@ -56,6 +59,7 @@ type LessonkitRuntime = {
|
|
|
56
59
|
track: (name: TelemetryEventName, data?: unknown, opts?: {
|
|
57
60
|
lessonId?: LessonId;
|
|
58
61
|
}) => void;
|
|
62
|
+
plugins: PluginHost | null;
|
|
59
63
|
};
|
|
60
64
|
declare function LessonkitProvider(props: {
|
|
61
65
|
config: LessonkitConfig;
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
useRef,
|
|
13
13
|
useState
|
|
14
14
|
} from "react";
|
|
15
|
-
import { createTrackingClient } from "@lessonkit/core";
|
|
15
|
+
import { createTrackingClient as createTrackingClient2 } from "@lessonkit/core";
|
|
16
16
|
import { createInMemoryXAPIQueue } from "@lessonkit/xapi";
|
|
17
17
|
import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement2 } from "@lessonkit/xapi";
|
|
18
18
|
|
|
@@ -22,47 +22,62 @@ import { telemetryEventToXAPIStatement } from "@lessonkit/xapi";
|
|
|
22
22
|
|
|
23
23
|
// src/runtime/lxpackBridge.ts
|
|
24
24
|
import {
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
getLxpackBridge as getLxpackBridgeFromSdk,
|
|
26
|
+
mapLessonkitTelemetryToBridgeAction,
|
|
27
|
+
normalizePassingThreshold,
|
|
28
|
+
normalizeScore,
|
|
29
|
+
telemetryEventToLessonkit
|
|
27
30
|
} from "@lessonkit/lxpack/bridge";
|
|
28
31
|
function getBridge() {
|
|
32
|
+
const fromSdk = getLxpackBridgeFromSdk();
|
|
33
|
+
if (fromSdk) return fromSdk;
|
|
29
34
|
if (typeof window === "undefined") return null;
|
|
30
35
|
const parent = window.parent;
|
|
31
36
|
if (!parent || parent === window) return null;
|
|
32
|
-
return parent.
|
|
37
|
+
return parent.lxpack ?? null;
|
|
33
38
|
}
|
|
34
|
-
function
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
case "lesson_completed": {
|
|
40
|
-
const lessonId = event.lessonId;
|
|
41
|
-
if (lessonId) bridge.completeLesson?.(lessonId);
|
|
39
|
+
function applyBridgeAction(bridge, action) {
|
|
40
|
+
if (!action) return;
|
|
41
|
+
switch (action.kind) {
|
|
42
|
+
case "completeLesson":
|
|
43
|
+
bridge.completeLesson?.(action.lessonId);
|
|
42
44
|
return;
|
|
43
|
-
|
|
44
|
-
case "course_completed":
|
|
45
|
+
case "completeCourse":
|
|
45
46
|
bridge.completeCourse?.();
|
|
46
47
|
return;
|
|
47
|
-
case "
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
score: data.score,
|
|
52
|
-
maxScore: data.maxScore
|
|
48
|
+
case "submitAssessment": {
|
|
49
|
+
const scaled = normalizeScore({
|
|
50
|
+
score: action.score,
|
|
51
|
+
maxScore: action.maxScore
|
|
53
52
|
});
|
|
54
53
|
if (scaled === null) return;
|
|
55
54
|
bridge.submitAssessment?.({
|
|
56
|
-
id:
|
|
55
|
+
id: action.id,
|
|
57
56
|
score: scaled,
|
|
58
|
-
passingScore:
|
|
57
|
+
passingScore: normalizePassingThreshold({
|
|
58
|
+
passingScore: action.passingScore,
|
|
59
|
+
maxScore: action.maxScore
|
|
60
|
+
}),
|
|
61
|
+
maxScore: action.maxScore
|
|
59
62
|
});
|
|
60
63
|
return;
|
|
61
64
|
}
|
|
65
|
+
case "track":
|
|
66
|
+
bridge.track?.(action.event);
|
|
67
|
+
return;
|
|
62
68
|
default:
|
|
63
69
|
return;
|
|
64
70
|
}
|
|
65
71
|
}
|
|
72
|
+
function forwardTelemetryToLxpack(event, mode = "auto") {
|
|
73
|
+
if (mode === "off") return;
|
|
74
|
+
const bridge = getBridge();
|
|
75
|
+
if (!bridge) return;
|
|
76
|
+
const lessonkitEvent = telemetryEventToLessonkit(event);
|
|
77
|
+
if (!lessonkitEvent) return;
|
|
78
|
+
const action = mapLessonkitTelemetryToBridgeAction(lessonkitEvent);
|
|
79
|
+
applyBridgeAction(bridge, action);
|
|
80
|
+
}
|
|
66
81
|
|
|
67
82
|
// src/runtime/emitTelemetry.ts
|
|
68
83
|
var warnedMissingCourseId = false;
|
|
@@ -186,6 +201,12 @@ function createSessionStoragePort() {
|
|
|
186
201
|
sessionStorage.setItem(key, value);
|
|
187
202
|
} catch {
|
|
188
203
|
}
|
|
204
|
+
},
|
|
205
|
+
removeItem: (key) => {
|
|
206
|
+
try {
|
|
207
|
+
sessionStorage.removeItem(key);
|
|
208
|
+
} catch {
|
|
209
|
+
}
|
|
189
210
|
}
|
|
190
211
|
};
|
|
191
212
|
}
|
|
@@ -233,6 +254,8 @@ function createXapiClientFromConfig(config, queue) {
|
|
|
233
254
|
if (config.xapi?.enabled === false) return null;
|
|
234
255
|
if (config.xapi?.client) return config.xapi.client;
|
|
235
256
|
if (!config.courseId) return null;
|
|
257
|
+
const hasTransport = typeof config.xapi?.transport === "function";
|
|
258
|
+
if (!hasTransport && config.xapi?.enabled !== true) return null;
|
|
236
259
|
return createXAPIClient({
|
|
237
260
|
courseId: config.courseId,
|
|
238
261
|
transport: config.xapi?.transport,
|
|
@@ -243,6 +266,9 @@ function createXapiClientFromConfig(config, queue) {
|
|
|
243
266
|
// src/runtime/session.ts
|
|
244
267
|
import { createSessionId } from "@lessonkit/core";
|
|
245
268
|
var SESSION_STORAGE_KEY = "lessonkit:sessionId";
|
|
269
|
+
function getTabSessionId(storage) {
|
|
270
|
+
return storage.getItem(SESSION_STORAGE_KEY);
|
|
271
|
+
}
|
|
246
272
|
var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
|
|
247
273
|
function resolveSessionId(storage, provided) {
|
|
248
274
|
if (provided) return provided;
|
|
@@ -263,30 +289,66 @@ function markCourseStarted(storage, sessionId, courseId) {
|
|
|
263
289
|
if (!courseId) return;
|
|
264
290
|
storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
|
|
265
291
|
}
|
|
292
|
+
function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
|
|
293
|
+
if (!courseId || fromSessionId === toSessionId) return;
|
|
294
|
+
if (hasCourseStarted(storage, fromSessionId, courseId)) {
|
|
295
|
+
markCourseStarted(storage, toSessionId, courseId);
|
|
296
|
+
storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
266
299
|
|
|
267
|
-
// src/
|
|
268
|
-
import {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
client?.flush?.();
|
|
273
|
-
client?.dispose?.();
|
|
300
|
+
// src/runtime/plugins.ts
|
|
301
|
+
import { createPluginHost } from "@lessonkit/core";
|
|
302
|
+
function createReactPluginHost(plugins) {
|
|
303
|
+
if (!plugins?.length) return null;
|
|
304
|
+
return createPluginHost(plugins);
|
|
274
305
|
}
|
|
275
|
-
|
|
306
|
+
function buildPluginContext(opts) {
|
|
307
|
+
return {
|
|
308
|
+
courseId: opts.courseId,
|
|
309
|
+
sessionId: opts.sessionId,
|
|
310
|
+
attemptId: opts.attemptId
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function emitTelemetryWithPlugins(opts) {
|
|
314
|
+
const next = opts.pluginHost ? opts.pluginHost.runTelemetry(opts.event, opts.pluginCtx) : opts.event;
|
|
315
|
+
if (next === null) return;
|
|
316
|
+
emitTelemetry(opts.tracking, opts.xapi, next, { lxpackBridge: opts.lxpackBridge ?? "auto" });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/runtime/telemetry.ts
|
|
320
|
+
import { createTrackingClient } from "@lessonkit/core";
|
|
276
321
|
function createTrackingClientFromConfig(config) {
|
|
277
|
-
if (config.tracking?.enabled === false)
|
|
278
|
-
|
|
279
|
-
}
|
|
322
|
+
if (config.tracking?.enabled === false) return createTrackingClient();
|
|
323
|
+
if (config.tracking?.createClient) return config.tracking.createClient();
|
|
280
324
|
return createTrackingClient({
|
|
281
325
|
sink: config.tracking?.sink,
|
|
282
326
|
batchSink: config.tracking?.batchSink,
|
|
283
327
|
batch: config.tracking?.batch
|
|
284
328
|
});
|
|
285
329
|
}
|
|
330
|
+
function disposeTrackingClient(client) {
|
|
331
|
+
client?.flush?.();
|
|
332
|
+
client?.dispose?.();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// src/context.tsx
|
|
336
|
+
import { jsx } from "react/jsx-runtime";
|
|
337
|
+
var LessonkitContext = createContext(null);
|
|
338
|
+
var useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
|
339
|
+
var defaultStorage = createSessionStoragePort();
|
|
340
|
+
function isTrackingActive(tracking) {
|
|
341
|
+
return tracking?.enabled !== false;
|
|
342
|
+
}
|
|
286
343
|
function LessonkitProvider(props) {
|
|
287
344
|
const config = props.config;
|
|
288
345
|
const sessionIdRef = useRef(resolveSessionId(defaultStorage, config.session?.sessionId));
|
|
289
|
-
|
|
346
|
+
const prevConfiguredSessionIdRef = useRef(config.session?.sessionId);
|
|
347
|
+
if (config.session?.sessionId) {
|
|
348
|
+
sessionIdRef.current = config.session.sessionId;
|
|
349
|
+
} else if (prevConfiguredSessionIdRef.current) {
|
|
350
|
+
sessionIdRef.current = resolveSessionId(defaultStorage, void 0);
|
|
351
|
+
}
|
|
290
352
|
const attemptIdRef = useRef(config.session?.attemptId);
|
|
291
353
|
const userRef = useRef(config.session?.user);
|
|
292
354
|
attemptIdRef.current = config.session?.attemptId;
|
|
@@ -295,7 +357,19 @@ function LessonkitProvider(props) {
|
|
|
295
357
|
courseIdRef.current = config.courseId;
|
|
296
358
|
const lxpackBridgeModeRef = useRef(config.lxpack?.bridge ?? "auto");
|
|
297
359
|
lxpackBridgeModeRef.current = config.lxpack?.bridge ?? "auto";
|
|
360
|
+
const pluginHost = useMemo(() => createReactPluginHost(config.plugins), [config.plugins]);
|
|
361
|
+
const pluginHostRef = useRef(pluginHost);
|
|
362
|
+
pluginHostRef.current = pluginHost;
|
|
298
363
|
const progressRef = useRef(createProgressController());
|
|
364
|
+
const courseStartedEmittedToSinkRef = useRef(false);
|
|
365
|
+
const prevCourseIdForProgressRef = useRef(config.courseId);
|
|
366
|
+
const pendingCourseIdResetRef = useRef(false);
|
|
367
|
+
if (prevCourseIdForProgressRef.current !== config.courseId) {
|
|
368
|
+
prevCourseIdForProgressRef.current = config.courseId;
|
|
369
|
+
progressRef.current = createProgressController();
|
|
370
|
+
pendingCourseIdResetRef.current = true;
|
|
371
|
+
courseStartedEmittedToSinkRef.current = false;
|
|
372
|
+
}
|
|
299
373
|
const [progress, setProgress] = useState(() => progressRef.current.getState());
|
|
300
374
|
const syncProgress = useCallback(() => {
|
|
301
375
|
setProgress(progressRef.current.getState());
|
|
@@ -305,11 +379,17 @@ function LessonkitProvider(props) {
|
|
|
305
379
|
const xapiQueueRef = useRef(createInMemoryXAPIQueue());
|
|
306
380
|
const xapiRef = useRef(null);
|
|
307
381
|
const [xapi, setXapi] = useState(null);
|
|
382
|
+
const prevXapiCourseIdRef = useRef(config.courseId);
|
|
308
383
|
const xapiEnabled = config.xapi?.enabled;
|
|
309
384
|
const xapiClient = config.xapi?.client;
|
|
310
385
|
const xapiTransport = config.xapi?.transport;
|
|
311
386
|
const courseId = config.courseId;
|
|
387
|
+
const trackingEnabled = config.tracking?.enabled;
|
|
312
388
|
useIsoLayoutEffect(() => {
|
|
389
|
+
if (prevXapiCourseIdRef.current !== courseId) {
|
|
390
|
+
xapiQueueRef.current = createInMemoryXAPIQueue();
|
|
391
|
+
prevXapiCourseIdRef.current = courseId;
|
|
392
|
+
}
|
|
313
393
|
const prev = xapiRef.current;
|
|
314
394
|
const next = createXapiClientFromConfig(config, xapiQueueRef.current);
|
|
315
395
|
xapiRef.current = next;
|
|
@@ -317,7 +397,9 @@ function LessonkitProvider(props) {
|
|
|
317
397
|
if (next && !prev) {
|
|
318
398
|
const sessionId = sessionIdRef.current;
|
|
319
399
|
const cid = courseIdRef.current;
|
|
320
|
-
|
|
400
|
+
const trackingActive = isTrackingActive(config.tracking);
|
|
401
|
+
const alreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
|
|
402
|
+
if (!trackingActive || alreadyStarted) {
|
|
321
403
|
try {
|
|
322
404
|
const statement = telemetryEventToXAPIStatement2(
|
|
323
405
|
buildTrackEvent({
|
|
@@ -333,6 +415,7 @@ function LessonkitProvider(props) {
|
|
|
333
415
|
}
|
|
334
416
|
}
|
|
335
417
|
}
|
|
418
|
+
let cancelled = false;
|
|
336
419
|
void (async () => {
|
|
337
420
|
if (prev) {
|
|
338
421
|
try {
|
|
@@ -340,18 +423,20 @@ function LessonkitProvider(props) {
|
|
|
340
423
|
} catch {
|
|
341
424
|
}
|
|
342
425
|
}
|
|
426
|
+
if (cancelled) return;
|
|
343
427
|
try {
|
|
344
428
|
await next?.flush();
|
|
345
429
|
} catch {
|
|
346
430
|
}
|
|
347
431
|
})();
|
|
348
432
|
return () => {
|
|
433
|
+
cancelled = true;
|
|
349
434
|
void prev?.flush();
|
|
350
435
|
};
|
|
351
|
-
}, [xapiEnabled, xapiClient, xapiTransport, courseId]);
|
|
352
|
-
const trackingRef = useRef(
|
|
436
|
+
}, [xapiEnabled, xapiClient, xapiTransport, courseId, trackingEnabled]);
|
|
437
|
+
const trackingRef = useRef(createTrackingClient2());
|
|
438
|
+
const trackingClientForUnmountRef = useRef(trackingRef.current);
|
|
353
439
|
const [tracking, setTracking] = useState(() => trackingRef.current);
|
|
354
|
-
const trackingEnabled = config.tracking?.enabled;
|
|
355
440
|
const trackingSink = config.tracking?.sink;
|
|
356
441
|
const trackingBatchSink = config.tracking?.batchSink;
|
|
357
442
|
const batchEnabled = config.tracking?.batch?.enabled;
|
|
@@ -359,25 +444,50 @@ function LessonkitProvider(props) {
|
|
|
359
444
|
const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
|
|
360
445
|
useIsoLayoutEffect(() => {
|
|
361
446
|
const prev = trackingRef.current;
|
|
362
|
-
const
|
|
447
|
+
const pluginCtx = buildPluginContext({
|
|
448
|
+
courseId: courseIdRef.current,
|
|
449
|
+
sessionId: sessionIdRef.current,
|
|
450
|
+
attemptId: attemptIdRef.current
|
|
451
|
+
});
|
|
452
|
+
const sink = pluginHostRef.current?.composeTrackingSink(config.tracking?.sink, pluginCtx) ?? config.tracking?.sink;
|
|
453
|
+
const batchSink = pluginHostRef.current && config.tracking?.batchSink ? (events) => {
|
|
454
|
+
const filtered = pluginHostRef.current.runTelemetryBatch(events, pluginCtx);
|
|
455
|
+
return config.tracking.batchSink(filtered);
|
|
456
|
+
} : config.tracking?.batchSink;
|
|
457
|
+
const next = createTrackingClientFromConfig({
|
|
458
|
+
tracking: { ...config.tracking, sink, batchSink }
|
|
459
|
+
});
|
|
363
460
|
trackingRef.current = next;
|
|
461
|
+
trackingClientForUnmountRef.current = next;
|
|
364
462
|
setTracking(next);
|
|
365
463
|
const sessionId = sessionIdRef.current;
|
|
366
464
|
const cid = courseIdRef.current;
|
|
367
|
-
|
|
465
|
+
const trackingActive = isTrackingActive(config.tracking);
|
|
466
|
+
if (!trackingActive) {
|
|
467
|
+
courseStartedEmittedToSinkRef.current = false;
|
|
468
|
+
} else if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
368
469
|
markCourseStarted(defaultStorage, sessionId, cid);
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
470
|
+
emitTelemetryWithPlugins({
|
|
471
|
+
pluginHost: pluginHostRef.current,
|
|
472
|
+
tracking: next,
|
|
473
|
+
xapi: xapiRef.current,
|
|
474
|
+
event: buildTrackEvent({
|
|
373
475
|
name: "course_started",
|
|
374
476
|
courseId: cid,
|
|
375
477
|
sessionId,
|
|
376
478
|
attemptId: attemptIdRef.current,
|
|
377
479
|
user: userRef.current
|
|
378
480
|
}),
|
|
379
|
-
|
|
380
|
-
|
|
481
|
+
pluginCtx: buildPluginContext({
|
|
482
|
+
courseId: cid,
|
|
483
|
+
sessionId,
|
|
484
|
+
attemptId: attemptIdRef.current
|
|
485
|
+
}),
|
|
486
|
+
lxpackBridge: lxpackBridgeModeRef.current
|
|
487
|
+
});
|
|
488
|
+
courseStartedEmittedToSinkRef.current = true;
|
|
489
|
+
} else if (trackingActive) {
|
|
490
|
+
courseStartedEmittedToSinkRef.current = true;
|
|
381
491
|
}
|
|
382
492
|
return () => {
|
|
383
493
|
if (prev !== trackingRef.current) {
|
|
@@ -390,16 +500,23 @@ function LessonkitProvider(props) {
|
|
|
390
500
|
trackingBatchSink,
|
|
391
501
|
batchEnabled,
|
|
392
502
|
batchFlushIntervalMs,
|
|
393
|
-
batchMaxBatchSize
|
|
503
|
+
batchMaxBatchSize,
|
|
504
|
+
config.plugins
|
|
394
505
|
]);
|
|
395
|
-
const emitWithBridge = useCallback(
|
|
396
|
-
(
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
506
|
+
const emitWithBridge = useCallback((trackingClient, event) => {
|
|
507
|
+
emitTelemetryWithPlugins({
|
|
508
|
+
pluginHost: pluginHostRef.current,
|
|
509
|
+
tracking: trackingClient,
|
|
510
|
+
xapi: xapiRef.current,
|
|
511
|
+
event,
|
|
512
|
+
pluginCtx: buildPluginContext({
|
|
513
|
+
courseId: courseIdRef.current,
|
|
514
|
+
sessionId: sessionIdRef.current,
|
|
515
|
+
attemptId: attemptIdRef.current
|
|
516
|
+
}),
|
|
517
|
+
lxpackBridge: lxpackBridgeModeRef.current
|
|
518
|
+
});
|
|
519
|
+
}, []);
|
|
403
520
|
const track = useCallback(
|
|
404
521
|
(name, data, opts) => {
|
|
405
522
|
const event = tryBuildTrackEvent({
|
|
@@ -416,36 +533,36 @@ function LessonkitProvider(props) {
|
|
|
416
533
|
},
|
|
417
534
|
[emitWithBridge]
|
|
418
535
|
);
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const previousActiveLesson = progressRef.current.getState().activeLessonId;
|
|
423
|
-
prevCourseIdRef.current = config.courseId;
|
|
424
|
-
progressRef.current = createProgressController();
|
|
536
|
+
useLayoutEffect(() => {
|
|
537
|
+
if (!pendingCourseIdResetRef.current) return;
|
|
538
|
+
pendingCourseIdResetRef.current = false;
|
|
425
539
|
syncProgress();
|
|
426
|
-
if (
|
|
427
|
-
progressRef.current.setActiveLesson(previousActiveLesson, Date.now());
|
|
428
|
-
syncProgress();
|
|
429
|
-
track("lesson_started", { lessonId: previousActiveLesson }, { lessonId: previousActiveLesson });
|
|
430
|
-
}
|
|
540
|
+
if (!isTrackingActive(config.tracking)) return;
|
|
431
541
|
const sessionId = sessionIdRef.current;
|
|
432
|
-
const cid =
|
|
433
|
-
if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
542
|
+
const cid = courseIdRef.current;
|
|
543
|
+
if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
434
544
|
markCourseStarted(defaultStorage, sessionId, cid);
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
545
|
+
emitTelemetryWithPlugins({
|
|
546
|
+
pluginHost: pluginHostRef.current,
|
|
547
|
+
tracking: trackingRef.current,
|
|
548
|
+
xapi: xapiRef.current,
|
|
549
|
+
event: buildTrackEvent({
|
|
439
550
|
name: "course_started",
|
|
440
551
|
courseId: cid,
|
|
441
552
|
sessionId,
|
|
442
553
|
attemptId: attemptIdRef.current,
|
|
443
554
|
user: userRef.current
|
|
444
555
|
}),
|
|
445
|
-
|
|
446
|
-
|
|
556
|
+
pluginCtx: buildPluginContext({
|
|
557
|
+
courseId: cid,
|
|
558
|
+
sessionId,
|
|
559
|
+
attemptId: attemptIdRef.current
|
|
560
|
+
}),
|
|
561
|
+
lxpackBridge: lxpackBridgeModeRef.current
|
|
562
|
+
});
|
|
563
|
+
courseStartedEmittedToSinkRef.current = true;
|
|
447
564
|
}
|
|
448
|
-
}, [config.courseId,
|
|
565
|
+
}, [config.courseId, config.tracking?.enabled, syncProgress]);
|
|
449
566
|
const emitLessonCompleted = useCallback(
|
|
450
567
|
(lessonId, durationMs) => {
|
|
451
568
|
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
@@ -465,16 +582,21 @@ function LessonkitProvider(props) {
|
|
|
465
582
|
},
|
|
466
583
|
[syncProgress, emitLessonCompleted]
|
|
467
584
|
);
|
|
585
|
+
const unmountTimerIdsRef = useRef([]);
|
|
468
586
|
useEffect(() => {
|
|
469
587
|
return () => {
|
|
470
|
-
const
|
|
588
|
+
for (const id of unmountTimerIdsRef.current) clearTimeout(id);
|
|
589
|
+
unmountTimerIdsRef.current = [];
|
|
590
|
+
const client = trackingClientForUnmountRef.current;
|
|
471
591
|
void xapiRef.current?.flush();
|
|
472
|
-
setTimeout(() => {
|
|
592
|
+
const flushTimer = setTimeout(() => {
|
|
473
593
|
client?.flush?.();
|
|
474
|
-
setTimeout(() => {
|
|
594
|
+
const disposeTimer = setTimeout(() => {
|
|
475
595
|
client?.dispose?.();
|
|
476
596
|
}, 0);
|
|
597
|
+
unmountTimerIdsRef.current.push(disposeTimer);
|
|
477
598
|
}, 0);
|
|
599
|
+
unmountTimerIdsRef.current.push(flushTimer);
|
|
478
600
|
};
|
|
479
601
|
}, []);
|
|
480
602
|
const setActiveLesson = useCallback(
|
|
@@ -499,10 +621,46 @@ function LessonkitProvider(props) {
|
|
|
499
621
|
if (!result.didComplete) return;
|
|
500
622
|
syncProgress();
|
|
501
623
|
track("course_completed");
|
|
624
|
+
void trackingRef.current?.flush?.();
|
|
502
625
|
}, [track, syncProgress]);
|
|
503
626
|
const sessionUser = config.session?.user;
|
|
504
627
|
const sessionAttemptId = config.session?.attemptId;
|
|
505
628
|
const sessionConfiguredId = config.session?.sessionId;
|
|
629
|
+
useEffect(() => {
|
|
630
|
+
if (!pluginHost) return;
|
|
631
|
+
const ctx = buildPluginContext({
|
|
632
|
+
courseId: courseIdRef.current,
|
|
633
|
+
sessionId: sessionIdRef.current,
|
|
634
|
+
attemptId: attemptIdRef.current
|
|
635
|
+
});
|
|
636
|
+
pluginHost.setupAll(ctx);
|
|
637
|
+
return () => {
|
|
638
|
+
pluginHost.disposeAll();
|
|
639
|
+
};
|
|
640
|
+
}, [pluginHost, config.courseId, sessionAttemptId, sessionConfiguredId]);
|
|
641
|
+
useEffect(() => {
|
|
642
|
+
const nextConfigured = config.session?.sessionId;
|
|
643
|
+
const prevConfigured = prevConfiguredSessionIdRef.current;
|
|
644
|
+
if (nextConfigured === prevConfigured) return;
|
|
645
|
+
prevConfiguredSessionIdRef.current = nextConfigured;
|
|
646
|
+
const cid = courseIdRef.current;
|
|
647
|
+
if (nextConfigured) {
|
|
648
|
+
const fromIds = /* @__PURE__ */ new Set();
|
|
649
|
+
if (prevConfigured) fromIds.add(prevConfigured);
|
|
650
|
+
const tabId = getTabSessionId(defaultStorage);
|
|
651
|
+
if (tabId) fromIds.add(tabId);
|
|
652
|
+
for (const fromId of fromIds) {
|
|
653
|
+
if (fromId !== nextConfigured) {
|
|
654
|
+
migrateCourseStartedMark(defaultStorage, fromId, nextConfigured, cid);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
sessionIdRef.current = nextConfigured;
|
|
658
|
+
} else if (prevConfigured) {
|
|
659
|
+
const nextAuto = resolveSessionId(defaultStorage, void 0);
|
|
660
|
+
migrateCourseStartedMark(defaultStorage, prevConfigured, nextAuto, cid);
|
|
661
|
+
sessionIdRef.current = nextAuto;
|
|
662
|
+
}
|
|
663
|
+
}, [sessionConfiguredId, config.courseId]);
|
|
506
664
|
const runtime = useMemo(
|
|
507
665
|
() => ({
|
|
508
666
|
config,
|
|
@@ -513,7 +671,8 @@ function LessonkitProvider(props) {
|
|
|
513
671
|
setActiveLesson,
|
|
514
672
|
completeLesson,
|
|
515
673
|
completeCourse,
|
|
516
|
-
track
|
|
674
|
+
track,
|
|
675
|
+
plugins: pluginHost
|
|
517
676
|
}),
|
|
518
677
|
[
|
|
519
678
|
config,
|
|
@@ -524,6 +683,7 @@ function LessonkitProvider(props) {
|
|
|
524
683
|
completeLesson,
|
|
525
684
|
completeCourse,
|
|
526
685
|
track,
|
|
686
|
+
pluginHost,
|
|
527
687
|
sessionUser,
|
|
528
688
|
sessionAttemptId,
|
|
529
689
|
sessionConfiguredId
|
|
@@ -655,6 +815,10 @@ function Quiz(props) {
|
|
|
655
815
|
const [selected, setSelected] = useState2(null);
|
|
656
816
|
const completedRef = useRef2(false);
|
|
657
817
|
const questionId = useId();
|
|
818
|
+
useEffect2(() => {
|
|
819
|
+
completedRef.current = false;
|
|
820
|
+
setSelected(null);
|
|
821
|
+
}, [props.checkId, props.answer, props.question]);
|
|
658
822
|
return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
|
|
659
823
|
/* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
|
|
660
824
|
/* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
|
|
@@ -698,6 +862,9 @@ function ProgressTracker() {
|
|
|
698
862
|
] }) });
|
|
699
863
|
}
|
|
700
864
|
|
|
865
|
+
// src/index.tsx
|
|
866
|
+
import { createPluginHost as createPluginHost2, defineLessonkitPlugin } from "@lessonkit/core";
|
|
867
|
+
|
|
701
868
|
// src/theme/ThemeProvider.tsx
|
|
702
869
|
import React3, {
|
|
703
870
|
createContext as createContext2,
|
|
@@ -1020,6 +1187,8 @@ export {
|
|
|
1020
1187
|
ThemeProvider,
|
|
1021
1188
|
blockCatalogVersion,
|
|
1022
1189
|
buildBlockCatalog,
|
|
1190
|
+
createPluginHost2 as createPluginHost,
|
|
1191
|
+
defineLessonkitPlugin,
|
|
1023
1192
|
getBlockCatalogEntry,
|
|
1024
1193
|
useCompletion,
|
|
1025
1194
|
useLessonkit,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "React components and hooks for building learning experiences with LessonKit.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -54,11 +54,11 @@
|
|
|
54
54
|
"react-dom": ">=18"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@lessonkit/accessibility": "0.
|
|
58
|
-
"@lessonkit/core": "0.
|
|
59
|
-
"@lessonkit/lxpack": "0.
|
|
60
|
-
"@lessonkit/themes": "0.
|
|
61
|
-
"@lessonkit/xapi": "0.
|
|
57
|
+
"@lessonkit/accessibility": "0.9.0",
|
|
58
|
+
"@lessonkit/core": "0.9.0",
|
|
59
|
+
"@lessonkit/lxpack": "0.9.0",
|
|
60
|
+
"@lessonkit/themes": "0.9.0",
|
|
61
|
+
"@lessonkit/xapi": "0.9.0"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@testing-library/react": "^16.3.0",
|