@lessonkit/react 0.5.0 → 0.7.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 +4 -2
- package/dist/index.cjs +197 -53
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +200 -53
- package/package.json +8 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `@lessonkit/react`
|
|
2
2
|
|
|
3
|
-
[](https://github.com/eddiethedean/lessonkit/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@lessonkit/react)
|
|
5
5
|
[](../../LICENSE)
|
|
6
6
|
|
|
@@ -56,7 +56,7 @@ export default function App() {
|
|
|
56
56
|
}
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
## API (0.
|
|
59
|
+
## API (0.6.0)
|
|
60
60
|
|
|
61
61
|
### Components
|
|
62
62
|
|
|
@@ -87,6 +87,8 @@ export default function App() {
|
|
|
87
87
|
- `Course` accepts a `config` prop that is passed through to `LessonkitProvider` (tracking sink,
|
|
88
88
|
optional `xapi.transport` or custom `xapi.client`, session metadata). Hoist `config` with `useMemo`
|
|
89
89
|
so tracking/xAPI clients are not recreated every render.
|
|
90
|
+
- xAPI is enabled by default unless `xapi.enabled: false`. Provide `xapi.transport` or `xapi.client`
|
|
91
|
+
or statements are queued in memory and never sent (dev warns once).
|
|
90
92
|
- A lesson is marked complete when its `<Lesson>` unmounts (for example, wizard navigation) or when
|
|
91
93
|
another lesson becomes active via `setActiveLesson`. Use stable `lessonId` values so completion and
|
|
92
94
|
time-on-task telemetry stay consistent.
|
package/dist/index.cjs
CHANGED
|
@@ -56,16 +56,61 @@ var import_accessibility = require("@lessonkit/accessibility");
|
|
|
56
56
|
var import_react = require("react");
|
|
57
57
|
var import_core3 = require("@lessonkit/core");
|
|
58
58
|
var import_xapi3 = require("@lessonkit/xapi");
|
|
59
|
+
var import_xapi4 = require("@lessonkit/xapi");
|
|
59
60
|
|
|
60
61
|
// src/runtime/emitTelemetry.ts
|
|
61
62
|
var import_core = require("@lessonkit/core");
|
|
62
63
|
var import_xapi = require("@lessonkit/xapi");
|
|
64
|
+
|
|
65
|
+
// src/runtime/lxpackBridge.ts
|
|
66
|
+
var import_bridge = require("@lessonkit/lxpack/bridge");
|
|
67
|
+
function getBridge() {
|
|
68
|
+
if (typeof window === "undefined") return null;
|
|
69
|
+
const parent = window.parent;
|
|
70
|
+
if (!parent || parent === window) return null;
|
|
71
|
+
return parent.lxpackBridge?.v1 ?? parent.lxpack ?? null;
|
|
72
|
+
}
|
|
73
|
+
function forwardTelemetryToLxpack(event, mode = "auto") {
|
|
74
|
+
if (mode === "off") return;
|
|
75
|
+
const bridge = getBridge();
|
|
76
|
+
if (!bridge) return;
|
|
77
|
+
switch (event.name) {
|
|
78
|
+
case "lesson_completed": {
|
|
79
|
+
const lessonId = event.lessonId;
|
|
80
|
+
if (lessonId) bridge.completeLesson?.(lessonId);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
case "course_completed":
|
|
84
|
+
bridge.completeCourse?.();
|
|
85
|
+
return;
|
|
86
|
+
case "quiz_completed": {
|
|
87
|
+
const data = event.data;
|
|
88
|
+
if (!data?.checkId) return;
|
|
89
|
+
const scaled = (0, import_bridge.normalizeAssessmentScore)({
|
|
90
|
+
score: data.score,
|
|
91
|
+
maxScore: data.maxScore
|
|
92
|
+
});
|
|
93
|
+
if (scaled === null) return;
|
|
94
|
+
bridge.submitAssessment?.({
|
|
95
|
+
id: data.checkId,
|
|
96
|
+
score: scaled,
|
|
97
|
+
passingScore: (0, import_bridge.normalizeAssessmentPassingScore)(data.passingScore)
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/runtime/emitTelemetry.ts
|
|
63
107
|
var warnedMissingCourseId = false;
|
|
108
|
+
var warnedMissingQuizLesson = false;
|
|
64
109
|
function isDevEnvironment() {
|
|
65
110
|
const g = globalThis;
|
|
66
111
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
67
112
|
}
|
|
68
|
-
function emitTelemetry(tracking, xapi, event) {
|
|
113
|
+
function emitTelemetry(tracking, xapi, event, opts) {
|
|
69
114
|
if (!event.courseId) {
|
|
70
115
|
if (isDevEnvironment() && !warnedMissingCourseId) {
|
|
71
116
|
warnedMissingCourseId = true;
|
|
@@ -82,6 +127,7 @@ function emitTelemetry(tracking, xapi, event) {
|
|
|
82
127
|
console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
|
|
83
128
|
}
|
|
84
129
|
}
|
|
130
|
+
forwardTelemetryToLxpack(event, opts?.lxpackBridge ?? "auto");
|
|
85
131
|
}
|
|
86
132
|
function buildTrackEvent(opts) {
|
|
87
133
|
const base = {
|
|
@@ -104,7 +150,7 @@ function buildTrackEvent(opts) {
|
|
|
104
150
|
name: "lesson_started",
|
|
105
151
|
...base,
|
|
106
152
|
lessonId,
|
|
107
|
-
data: {
|
|
153
|
+
data: { ...data, lessonId }
|
|
108
154
|
};
|
|
109
155
|
}
|
|
110
156
|
case "lesson_completed":
|
|
@@ -116,7 +162,7 @@ function buildTrackEvent(opts) {
|
|
|
116
162
|
name: opts.name,
|
|
117
163
|
...base,
|
|
118
164
|
lessonId,
|
|
119
|
-
data: {
|
|
165
|
+
data: { ...data, lessonId }
|
|
120
166
|
};
|
|
121
167
|
}
|
|
122
168
|
case "quiz_answered": {
|
|
@@ -142,6 +188,19 @@ function buildTrackEvent(opts) {
|
|
|
142
188
|
return { name: opts.name, ...base };
|
|
143
189
|
}
|
|
144
190
|
}
|
|
191
|
+
function tryBuildTrackEvent(opts) {
|
|
192
|
+
const isQuiz = opts.name === "quiz_answered" || opts.name === "quiz_completed";
|
|
193
|
+
if (isQuiz && !opts.lessonId) {
|
|
194
|
+
if (isDevEnvironment() && !warnedMissingQuizLesson) {
|
|
195
|
+
warnedMissingQuizLesson = true;
|
|
196
|
+
console.warn(
|
|
197
|
+
`[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return buildTrackEvent(opts);
|
|
203
|
+
}
|
|
145
204
|
|
|
146
205
|
// src/runtime/ports.ts
|
|
147
206
|
function createNoopStorage() {
|
|
@@ -191,6 +250,9 @@ function createProgressController() {
|
|
|
191
250
|
completeLesson: (lessonId, completedAtMs) => {
|
|
192
251
|
if (completedLessonIds.has(lessonId)) return { didComplete: false };
|
|
193
252
|
completedLessonIds = new Set(completedLessonIds).add(lessonId);
|
|
253
|
+
if (activeLessonId === lessonId) {
|
|
254
|
+
activeLessonId = void 0;
|
|
255
|
+
}
|
|
194
256
|
const startedAt = lessonStartTimes.get(lessonId);
|
|
195
257
|
lessonStartTimes.delete(lessonId);
|
|
196
258
|
const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
|
|
@@ -270,6 +332,8 @@ function LessonkitProvider(props) {
|
|
|
270
332
|
userRef.current = config.session?.user;
|
|
271
333
|
const courseIdRef = (0, import_react.useRef)(config.courseId);
|
|
272
334
|
courseIdRef.current = config.courseId;
|
|
335
|
+
const lxpackBridgeModeRef = (0, import_react.useRef)(config.lxpack?.bridge ?? "auto");
|
|
336
|
+
lxpackBridgeModeRef.current = config.lxpack?.bridge ?? "auto";
|
|
273
337
|
const progressRef = (0, import_react.useRef)(createProgressController());
|
|
274
338
|
const [progress, setProgress] = (0, import_react.useState)(() => progressRef.current.getState());
|
|
275
339
|
const syncProgress = (0, import_react.useCallback)(() => {
|
|
@@ -277,6 +341,53 @@ function LessonkitProvider(props) {
|
|
|
277
341
|
}, []);
|
|
278
342
|
const activeLessonIdRef = (0, import_react.useRef)(progress.activeLessonId);
|
|
279
343
|
activeLessonIdRef.current = progress.activeLessonId;
|
|
344
|
+
const xapiQueueRef = (0, import_react.useRef)((0, import_xapi3.createInMemoryXAPIQueue)());
|
|
345
|
+
const xapiRef = (0, import_react.useRef)(null);
|
|
346
|
+
const [xapi, setXapi] = (0, import_react.useState)(null);
|
|
347
|
+
const xapiEnabled = config.xapi?.enabled;
|
|
348
|
+
const xapiClient = config.xapi?.client;
|
|
349
|
+
const xapiTransport = config.xapi?.transport;
|
|
350
|
+
const courseId = config.courseId;
|
|
351
|
+
useIsoLayoutEffect(() => {
|
|
352
|
+
const prev = xapiRef.current;
|
|
353
|
+
const next = createXapiClientFromConfig(config, xapiQueueRef.current);
|
|
354
|
+
xapiRef.current = next;
|
|
355
|
+
setXapi(next);
|
|
356
|
+
if (next && !prev) {
|
|
357
|
+
const sessionId = sessionIdRef.current;
|
|
358
|
+
const cid = courseIdRef.current;
|
|
359
|
+
if (hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
360
|
+
try {
|
|
361
|
+
const statement = (0, import_xapi4.telemetryEventToXAPIStatement)(
|
|
362
|
+
buildTrackEvent({
|
|
363
|
+
name: "course_started",
|
|
364
|
+
courseId: cid,
|
|
365
|
+
sessionId,
|
|
366
|
+
attemptId: attemptIdRef.current,
|
|
367
|
+
user: userRef.current
|
|
368
|
+
})
|
|
369
|
+
);
|
|
370
|
+
if (statement) next.send(statement);
|
|
371
|
+
} catch {
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
void (async () => {
|
|
376
|
+
if (prev) {
|
|
377
|
+
try {
|
|
378
|
+
await prev.flush();
|
|
379
|
+
} catch {
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
await next?.flush();
|
|
384
|
+
} catch {
|
|
385
|
+
}
|
|
386
|
+
})();
|
|
387
|
+
return () => {
|
|
388
|
+
void prev?.flush();
|
|
389
|
+
};
|
|
390
|
+
}, [xapiEnabled, xapiClient, xapiTransport, courseId]);
|
|
280
391
|
const trackingRef = (0, import_react.useRef)((0, import_core3.createTrackingClient)());
|
|
281
392
|
const [tracking, setTracking] = (0, import_react.useState)(() => trackingRef.current);
|
|
282
393
|
const trackingEnabled = config.tracking?.enabled;
|
|
@@ -303,11 +414,14 @@ function LessonkitProvider(props) {
|
|
|
303
414
|
sessionId,
|
|
304
415
|
attemptId: attemptIdRef.current,
|
|
305
416
|
user: userRef.current
|
|
306
|
-
})
|
|
417
|
+
}),
|
|
418
|
+
{ lxpackBridge: lxpackBridgeModeRef.current }
|
|
307
419
|
);
|
|
308
420
|
}
|
|
309
421
|
return () => {
|
|
310
|
-
|
|
422
|
+
if (prev !== trackingRef.current) {
|
|
423
|
+
disposeTrackingClient(prev);
|
|
424
|
+
}
|
|
311
425
|
};
|
|
312
426
|
}, [
|
|
313
427
|
trackingEnabled,
|
|
@@ -317,37 +431,17 @@ function LessonkitProvider(props) {
|
|
|
317
431
|
batchFlushIntervalMs,
|
|
318
432
|
batchMaxBatchSize
|
|
319
433
|
]);
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const prev = xapiRef.current;
|
|
329
|
-
const next = createXapiClientFromConfig(config, xapiQueueRef.current);
|
|
330
|
-
xapiRef.current = next;
|
|
331
|
-
setXapi(next);
|
|
332
|
-
void (async () => {
|
|
333
|
-
if (prev) {
|
|
334
|
-
try {
|
|
335
|
-
await prev.flush();
|
|
336
|
-
} catch {
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
try {
|
|
340
|
-
await next?.flush();
|
|
341
|
-
} catch {
|
|
342
|
-
}
|
|
343
|
-
})();
|
|
344
|
-
return () => {
|
|
345
|
-
void prev?.flush();
|
|
346
|
-
};
|
|
347
|
-
}, [xapiEnabled, xapiClient, xapiTransport, courseId]);
|
|
434
|
+
const emitWithBridge = (0, import_react.useCallback)(
|
|
435
|
+
(trackingClient, event) => {
|
|
436
|
+
emitTelemetry(trackingClient, xapiRef.current, event, {
|
|
437
|
+
lxpackBridge: lxpackBridgeModeRef.current
|
|
438
|
+
});
|
|
439
|
+
},
|
|
440
|
+
[]
|
|
441
|
+
);
|
|
348
442
|
const track = (0, import_react.useCallback)(
|
|
349
443
|
(name, data, opts) => {
|
|
350
|
-
const event =
|
|
444
|
+
const event = tryBuildTrackEvent({
|
|
351
445
|
name,
|
|
352
446
|
courseId: courseIdRef.current,
|
|
353
447
|
lessonId: opts?.lessonId ?? activeLessonIdRef.current,
|
|
@@ -356,16 +450,41 @@ function LessonkitProvider(props) {
|
|
|
356
450
|
user: userRef.current,
|
|
357
451
|
data
|
|
358
452
|
});
|
|
359
|
-
|
|
453
|
+
if (!event) return;
|
|
454
|
+
emitWithBridge(trackingRef.current, event);
|
|
360
455
|
},
|
|
361
|
-
[]
|
|
456
|
+
[emitWithBridge]
|
|
362
457
|
);
|
|
458
|
+
const prevCourseIdRef = (0, import_react.useRef)(config.courseId);
|
|
363
459
|
(0, import_react.useEffect)(() => {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
460
|
+
if (prevCourseIdRef.current === config.courseId) return;
|
|
461
|
+
const previousActiveLesson = progressRef.current.getState().activeLessonId;
|
|
462
|
+
prevCourseIdRef.current = config.courseId;
|
|
463
|
+
progressRef.current = createProgressController();
|
|
464
|
+
syncProgress();
|
|
465
|
+
if (previousActiveLesson) {
|
|
466
|
+
progressRef.current.setActiveLesson(previousActiveLesson, Date.now());
|
|
467
|
+
syncProgress();
|
|
468
|
+
track("lesson_started", { lessonId: previousActiveLesson }, { lessonId: previousActiveLesson });
|
|
469
|
+
}
|
|
470
|
+
const sessionId = sessionIdRef.current;
|
|
471
|
+
const cid = config.courseId;
|
|
472
|
+
if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
473
|
+
markCourseStarted(defaultStorage, sessionId, cid);
|
|
474
|
+
emitTelemetry(
|
|
475
|
+
trackingRef.current,
|
|
476
|
+
xapiRef.current,
|
|
477
|
+
buildTrackEvent({
|
|
478
|
+
name: "course_started",
|
|
479
|
+
courseId: cid,
|
|
480
|
+
sessionId,
|
|
481
|
+
attemptId: attemptIdRef.current,
|
|
482
|
+
user: userRef.current
|
|
483
|
+
}),
|
|
484
|
+
{ lxpackBridge: lxpackBridgeModeRef.current }
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}, [config.courseId, syncProgress, track]);
|
|
369
488
|
const emitLessonCompleted = (0, import_react.useCallback)(
|
|
370
489
|
(lessonId, durationMs) => {
|
|
371
490
|
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
@@ -381,9 +500,22 @@ function LessonkitProvider(props) {
|
|
|
381
500
|
if (!result.didComplete) return;
|
|
382
501
|
syncProgress();
|
|
383
502
|
emitLessonCompleted(lessonId, result.durationMs);
|
|
503
|
+
void trackingRef.current?.flush?.();
|
|
384
504
|
},
|
|
385
505
|
[syncProgress, emitLessonCompleted]
|
|
386
506
|
);
|
|
507
|
+
(0, import_react.useEffect)(() => {
|
|
508
|
+
return () => {
|
|
509
|
+
const client = trackingRef.current;
|
|
510
|
+
void xapiRef.current?.flush();
|
|
511
|
+
setTimeout(() => {
|
|
512
|
+
client?.flush?.();
|
|
513
|
+
setTimeout(() => {
|
|
514
|
+
client?.dispose?.();
|
|
515
|
+
}, 0);
|
|
516
|
+
}, 0);
|
|
517
|
+
};
|
|
518
|
+
}, []);
|
|
387
519
|
const setActiveLesson = (0, import_react.useCallback)(
|
|
388
520
|
(lessonId) => {
|
|
389
521
|
const current = progressRef.current.getState();
|
|
@@ -407,6 +539,9 @@ function LessonkitProvider(props) {
|
|
|
407
539
|
syncProgress();
|
|
408
540
|
track("course_completed");
|
|
409
541
|
}, [track, syncProgress]);
|
|
542
|
+
const sessionUser = config.session?.user;
|
|
543
|
+
const sessionAttemptId = config.session?.attemptId;
|
|
544
|
+
const sessionConfiguredId = config.session?.sessionId;
|
|
410
545
|
const runtime = (0, import_react.useMemo)(
|
|
411
546
|
() => ({
|
|
412
547
|
config,
|
|
@@ -419,7 +554,19 @@ function LessonkitProvider(props) {
|
|
|
419
554
|
completeCourse,
|
|
420
555
|
track
|
|
421
556
|
}),
|
|
422
|
-
[
|
|
557
|
+
[
|
|
558
|
+
config,
|
|
559
|
+
tracking,
|
|
560
|
+
xapi,
|
|
561
|
+
progress,
|
|
562
|
+
setActiveLesson,
|
|
563
|
+
completeLesson,
|
|
564
|
+
completeCourse,
|
|
565
|
+
track,
|
|
566
|
+
sessionUser,
|
|
567
|
+
sessionAttemptId,
|
|
568
|
+
sessionConfiguredId
|
|
569
|
+
]
|
|
423
570
|
);
|
|
424
571
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LessonkitContext.Provider, { value: runtime, children: props.children });
|
|
425
572
|
}
|
|
@@ -491,24 +638,21 @@ function Course(props) {
|
|
|
491
638
|
}
|
|
492
639
|
function Lesson(props) {
|
|
493
640
|
warnInvalidComponentId(props.lessonId, "lessonId");
|
|
494
|
-
const { setActiveLesson } = useLessonkit();
|
|
641
|
+
const { setActiveLesson, config } = useLessonkit();
|
|
495
642
|
const { completeLesson } = useCompletion();
|
|
496
643
|
const id = props.lessonId;
|
|
497
|
-
const
|
|
644
|
+
const lessonMountGenerationRef = (0, import_react3.useRef)(0);
|
|
498
645
|
(0, import_react3.useEffect)(() => {
|
|
499
|
-
|
|
500
|
-
clearTimeout(pendingCompleteRef.current);
|
|
501
|
-
pendingCompleteRef.current = null;
|
|
502
|
-
}
|
|
646
|
+
const generation = ++lessonMountGenerationRef.current;
|
|
503
647
|
setActiveLesson(id);
|
|
504
648
|
return () => {
|
|
505
649
|
const lessonId = id;
|
|
506
|
-
|
|
507
|
-
|
|
650
|
+
queueMicrotask(() => {
|
|
651
|
+
if (lessonMountGenerationRef.current !== generation) return;
|
|
508
652
|
completeLesson(lessonId);
|
|
509
|
-
}
|
|
653
|
+
});
|
|
510
654
|
};
|
|
511
|
-
}, [id, setActiveLesson, completeLesson]);
|
|
655
|
+
}, [id, config.courseId, setActiveLesson, completeLesson]);
|
|
512
656
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("article", { "aria-label": props.title, children: [
|
|
513
657
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { children: props.title }),
|
|
514
658
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { children: props.children })
|
|
@@ -573,7 +717,7 @@ function Quiz(props) {
|
|
|
573
717
|
});
|
|
574
718
|
if (correct && !completedRef.current) {
|
|
575
719
|
completedRef.current = true;
|
|
576
|
-
quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1 });
|
|
720
|
+
quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1, passingScore: 1 });
|
|
577
721
|
}
|
|
578
722
|
}
|
|
579
723
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -34,6 +34,10 @@ type LessonkitConfig = {
|
|
|
34
34
|
transport?: XAPITransport;
|
|
35
35
|
client?: XAPIClient;
|
|
36
36
|
};
|
|
37
|
+
lxpack?: {
|
|
38
|
+
/** Forward completion events to `window.parent.lxpackBridge.v1` when embedded (default `auto`). */
|
|
39
|
+
bridge?: "auto" | "off";
|
|
40
|
+
};
|
|
37
41
|
};
|
|
38
42
|
|
|
39
43
|
type LessonkitRuntime = {
|
|
@@ -114,6 +118,7 @@ declare function useQuizState(): {
|
|
|
114
118
|
checkId: CheckId;
|
|
115
119
|
score?: number;
|
|
116
120
|
maxScore?: number;
|
|
121
|
+
passingScore?: number;
|
|
117
122
|
}) => void;
|
|
118
123
|
};
|
|
119
124
|
|
package/dist/index.d.ts
CHANGED
|
@@ -34,6 +34,10 @@ type LessonkitConfig = {
|
|
|
34
34
|
transport?: XAPITransport;
|
|
35
35
|
client?: XAPIClient;
|
|
36
36
|
};
|
|
37
|
+
lxpack?: {
|
|
38
|
+
/** Forward completion events to `window.parent.lxpackBridge.v1` when embedded (default `auto`). */
|
|
39
|
+
bridge?: "auto" | "off";
|
|
40
|
+
};
|
|
37
41
|
};
|
|
38
42
|
|
|
39
43
|
type LessonkitRuntime = {
|
|
@@ -114,6 +118,7 @@ declare function useQuizState(): {
|
|
|
114
118
|
checkId: CheckId;
|
|
115
119
|
score?: number;
|
|
116
120
|
maxScore?: number;
|
|
121
|
+
passingScore?: number;
|
|
117
122
|
}) => void;
|
|
118
123
|
};
|
|
119
124
|
|
package/dist/index.js
CHANGED
|
@@ -14,16 +14,64 @@ import {
|
|
|
14
14
|
} from "react";
|
|
15
15
|
import { createTrackingClient } from "@lessonkit/core";
|
|
16
16
|
import { createInMemoryXAPIQueue } from "@lessonkit/xapi";
|
|
17
|
+
import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement2 } from "@lessonkit/xapi";
|
|
17
18
|
|
|
18
19
|
// src/runtime/emitTelemetry.ts
|
|
19
20
|
import { nowIso } from "@lessonkit/core";
|
|
20
21
|
import { telemetryEventToXAPIStatement } from "@lessonkit/xapi";
|
|
22
|
+
|
|
23
|
+
// src/runtime/lxpackBridge.ts
|
|
24
|
+
import {
|
|
25
|
+
normalizeAssessmentPassingScore,
|
|
26
|
+
normalizeAssessmentScore
|
|
27
|
+
} from "@lessonkit/lxpack/bridge";
|
|
28
|
+
function getBridge() {
|
|
29
|
+
if (typeof window === "undefined") return null;
|
|
30
|
+
const parent = window.parent;
|
|
31
|
+
if (!parent || parent === window) return null;
|
|
32
|
+
return parent.lxpackBridge?.v1 ?? parent.lxpack ?? null;
|
|
33
|
+
}
|
|
34
|
+
function forwardTelemetryToLxpack(event, mode = "auto") {
|
|
35
|
+
if (mode === "off") return;
|
|
36
|
+
const bridge = getBridge();
|
|
37
|
+
if (!bridge) return;
|
|
38
|
+
switch (event.name) {
|
|
39
|
+
case "lesson_completed": {
|
|
40
|
+
const lessonId = event.lessonId;
|
|
41
|
+
if (lessonId) bridge.completeLesson?.(lessonId);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
case "course_completed":
|
|
45
|
+
bridge.completeCourse?.();
|
|
46
|
+
return;
|
|
47
|
+
case "quiz_completed": {
|
|
48
|
+
const data = event.data;
|
|
49
|
+
if (!data?.checkId) return;
|
|
50
|
+
const scaled = normalizeAssessmentScore({
|
|
51
|
+
score: data.score,
|
|
52
|
+
maxScore: data.maxScore
|
|
53
|
+
});
|
|
54
|
+
if (scaled === null) return;
|
|
55
|
+
bridge.submitAssessment?.({
|
|
56
|
+
id: data.checkId,
|
|
57
|
+
score: scaled,
|
|
58
|
+
passingScore: normalizeAssessmentPassingScore(data.passingScore)
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/runtime/emitTelemetry.ts
|
|
21
68
|
var warnedMissingCourseId = false;
|
|
69
|
+
var warnedMissingQuizLesson = false;
|
|
22
70
|
function isDevEnvironment() {
|
|
23
71
|
const g = globalThis;
|
|
24
72
|
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
25
73
|
}
|
|
26
|
-
function emitTelemetry(tracking, xapi, event) {
|
|
74
|
+
function emitTelemetry(tracking, xapi, event, opts) {
|
|
27
75
|
if (!event.courseId) {
|
|
28
76
|
if (isDevEnvironment() && !warnedMissingCourseId) {
|
|
29
77
|
warnedMissingCourseId = true;
|
|
@@ -40,6 +88,7 @@ function emitTelemetry(tracking, xapi, event) {
|
|
|
40
88
|
console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
|
|
41
89
|
}
|
|
42
90
|
}
|
|
91
|
+
forwardTelemetryToLxpack(event, opts?.lxpackBridge ?? "auto");
|
|
43
92
|
}
|
|
44
93
|
function buildTrackEvent(opts) {
|
|
45
94
|
const base = {
|
|
@@ -62,7 +111,7 @@ function buildTrackEvent(opts) {
|
|
|
62
111
|
name: "lesson_started",
|
|
63
112
|
...base,
|
|
64
113
|
lessonId,
|
|
65
|
-
data: {
|
|
114
|
+
data: { ...data, lessonId }
|
|
66
115
|
};
|
|
67
116
|
}
|
|
68
117
|
case "lesson_completed":
|
|
@@ -74,7 +123,7 @@ function buildTrackEvent(opts) {
|
|
|
74
123
|
name: opts.name,
|
|
75
124
|
...base,
|
|
76
125
|
lessonId,
|
|
77
|
-
data: {
|
|
126
|
+
data: { ...data, lessonId }
|
|
78
127
|
};
|
|
79
128
|
}
|
|
80
129
|
case "quiz_answered": {
|
|
@@ -100,6 +149,19 @@ function buildTrackEvent(opts) {
|
|
|
100
149
|
return { name: opts.name, ...base };
|
|
101
150
|
}
|
|
102
151
|
}
|
|
152
|
+
function tryBuildTrackEvent(opts) {
|
|
153
|
+
const isQuiz = opts.name === "quiz_answered" || opts.name === "quiz_completed";
|
|
154
|
+
if (isQuiz && !opts.lessonId) {
|
|
155
|
+
if (isDevEnvironment() && !warnedMissingQuizLesson) {
|
|
156
|
+
warnedMissingQuizLesson = true;
|
|
157
|
+
console.warn(
|
|
158
|
+
`[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
return buildTrackEvent(opts);
|
|
164
|
+
}
|
|
103
165
|
|
|
104
166
|
// src/runtime/ports.ts
|
|
105
167
|
function createNoopStorage() {
|
|
@@ -149,6 +211,9 @@ function createProgressController() {
|
|
|
149
211
|
completeLesson: (lessonId, completedAtMs) => {
|
|
150
212
|
if (completedLessonIds.has(lessonId)) return { didComplete: false };
|
|
151
213
|
completedLessonIds = new Set(completedLessonIds).add(lessonId);
|
|
214
|
+
if (activeLessonId === lessonId) {
|
|
215
|
+
activeLessonId = void 0;
|
|
216
|
+
}
|
|
152
217
|
const startedAt = lessonStartTimes.get(lessonId);
|
|
153
218
|
lessonStartTimes.delete(lessonId);
|
|
154
219
|
const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
|
|
@@ -228,6 +293,8 @@ function LessonkitProvider(props) {
|
|
|
228
293
|
userRef.current = config.session?.user;
|
|
229
294
|
const courseIdRef = useRef(config.courseId);
|
|
230
295
|
courseIdRef.current = config.courseId;
|
|
296
|
+
const lxpackBridgeModeRef = useRef(config.lxpack?.bridge ?? "auto");
|
|
297
|
+
lxpackBridgeModeRef.current = config.lxpack?.bridge ?? "auto";
|
|
231
298
|
const progressRef = useRef(createProgressController());
|
|
232
299
|
const [progress, setProgress] = useState(() => progressRef.current.getState());
|
|
233
300
|
const syncProgress = useCallback(() => {
|
|
@@ -235,6 +302,53 @@ function LessonkitProvider(props) {
|
|
|
235
302
|
}, []);
|
|
236
303
|
const activeLessonIdRef = useRef(progress.activeLessonId);
|
|
237
304
|
activeLessonIdRef.current = progress.activeLessonId;
|
|
305
|
+
const xapiQueueRef = useRef(createInMemoryXAPIQueue());
|
|
306
|
+
const xapiRef = useRef(null);
|
|
307
|
+
const [xapi, setXapi] = useState(null);
|
|
308
|
+
const xapiEnabled = config.xapi?.enabled;
|
|
309
|
+
const xapiClient = config.xapi?.client;
|
|
310
|
+
const xapiTransport = config.xapi?.transport;
|
|
311
|
+
const courseId = config.courseId;
|
|
312
|
+
useIsoLayoutEffect(() => {
|
|
313
|
+
const prev = xapiRef.current;
|
|
314
|
+
const next = createXapiClientFromConfig(config, xapiQueueRef.current);
|
|
315
|
+
xapiRef.current = next;
|
|
316
|
+
setXapi(next);
|
|
317
|
+
if (next && !prev) {
|
|
318
|
+
const sessionId = sessionIdRef.current;
|
|
319
|
+
const cid = courseIdRef.current;
|
|
320
|
+
if (hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
321
|
+
try {
|
|
322
|
+
const statement = telemetryEventToXAPIStatement2(
|
|
323
|
+
buildTrackEvent({
|
|
324
|
+
name: "course_started",
|
|
325
|
+
courseId: cid,
|
|
326
|
+
sessionId,
|
|
327
|
+
attemptId: attemptIdRef.current,
|
|
328
|
+
user: userRef.current
|
|
329
|
+
})
|
|
330
|
+
);
|
|
331
|
+
if (statement) next.send(statement);
|
|
332
|
+
} catch {
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
void (async () => {
|
|
337
|
+
if (prev) {
|
|
338
|
+
try {
|
|
339
|
+
await prev.flush();
|
|
340
|
+
} catch {
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
await next?.flush();
|
|
345
|
+
} catch {
|
|
346
|
+
}
|
|
347
|
+
})();
|
|
348
|
+
return () => {
|
|
349
|
+
void prev?.flush();
|
|
350
|
+
};
|
|
351
|
+
}, [xapiEnabled, xapiClient, xapiTransport, courseId]);
|
|
238
352
|
const trackingRef = useRef(createTrackingClient());
|
|
239
353
|
const [tracking, setTracking] = useState(() => trackingRef.current);
|
|
240
354
|
const trackingEnabled = config.tracking?.enabled;
|
|
@@ -261,11 +375,14 @@ function LessonkitProvider(props) {
|
|
|
261
375
|
sessionId,
|
|
262
376
|
attemptId: attemptIdRef.current,
|
|
263
377
|
user: userRef.current
|
|
264
|
-
})
|
|
378
|
+
}),
|
|
379
|
+
{ lxpackBridge: lxpackBridgeModeRef.current }
|
|
265
380
|
);
|
|
266
381
|
}
|
|
267
382
|
return () => {
|
|
268
|
-
|
|
383
|
+
if (prev !== trackingRef.current) {
|
|
384
|
+
disposeTrackingClient(prev);
|
|
385
|
+
}
|
|
269
386
|
};
|
|
270
387
|
}, [
|
|
271
388
|
trackingEnabled,
|
|
@@ -275,37 +392,17 @@ function LessonkitProvider(props) {
|
|
|
275
392
|
batchFlushIntervalMs,
|
|
276
393
|
batchMaxBatchSize
|
|
277
394
|
]);
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const prev = xapiRef.current;
|
|
287
|
-
const next = createXapiClientFromConfig(config, xapiQueueRef.current);
|
|
288
|
-
xapiRef.current = next;
|
|
289
|
-
setXapi(next);
|
|
290
|
-
void (async () => {
|
|
291
|
-
if (prev) {
|
|
292
|
-
try {
|
|
293
|
-
await prev.flush();
|
|
294
|
-
} catch {
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
try {
|
|
298
|
-
await next?.flush();
|
|
299
|
-
} catch {
|
|
300
|
-
}
|
|
301
|
-
})();
|
|
302
|
-
return () => {
|
|
303
|
-
void prev?.flush();
|
|
304
|
-
};
|
|
305
|
-
}, [xapiEnabled, xapiClient, xapiTransport, courseId]);
|
|
395
|
+
const emitWithBridge = useCallback(
|
|
396
|
+
(trackingClient, event) => {
|
|
397
|
+
emitTelemetry(trackingClient, xapiRef.current, event, {
|
|
398
|
+
lxpackBridge: lxpackBridgeModeRef.current
|
|
399
|
+
});
|
|
400
|
+
},
|
|
401
|
+
[]
|
|
402
|
+
);
|
|
306
403
|
const track = useCallback(
|
|
307
404
|
(name, data, opts) => {
|
|
308
|
-
const event =
|
|
405
|
+
const event = tryBuildTrackEvent({
|
|
309
406
|
name,
|
|
310
407
|
courseId: courseIdRef.current,
|
|
311
408
|
lessonId: opts?.lessonId ?? activeLessonIdRef.current,
|
|
@@ -314,16 +411,41 @@ function LessonkitProvider(props) {
|
|
|
314
411
|
user: userRef.current,
|
|
315
412
|
data
|
|
316
413
|
});
|
|
317
|
-
|
|
414
|
+
if (!event) return;
|
|
415
|
+
emitWithBridge(trackingRef.current, event);
|
|
318
416
|
},
|
|
319
|
-
[]
|
|
417
|
+
[emitWithBridge]
|
|
320
418
|
);
|
|
419
|
+
const prevCourseIdRef = useRef(config.courseId);
|
|
321
420
|
useEffect(() => {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
421
|
+
if (prevCourseIdRef.current === config.courseId) return;
|
|
422
|
+
const previousActiveLesson = progressRef.current.getState().activeLessonId;
|
|
423
|
+
prevCourseIdRef.current = config.courseId;
|
|
424
|
+
progressRef.current = createProgressController();
|
|
425
|
+
syncProgress();
|
|
426
|
+
if (previousActiveLesson) {
|
|
427
|
+
progressRef.current.setActiveLesson(previousActiveLesson, Date.now());
|
|
428
|
+
syncProgress();
|
|
429
|
+
track("lesson_started", { lessonId: previousActiveLesson }, { lessonId: previousActiveLesson });
|
|
430
|
+
}
|
|
431
|
+
const sessionId = sessionIdRef.current;
|
|
432
|
+
const cid = config.courseId;
|
|
433
|
+
if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
434
|
+
markCourseStarted(defaultStorage, sessionId, cid);
|
|
435
|
+
emitTelemetry(
|
|
436
|
+
trackingRef.current,
|
|
437
|
+
xapiRef.current,
|
|
438
|
+
buildTrackEvent({
|
|
439
|
+
name: "course_started",
|
|
440
|
+
courseId: cid,
|
|
441
|
+
sessionId,
|
|
442
|
+
attemptId: attemptIdRef.current,
|
|
443
|
+
user: userRef.current
|
|
444
|
+
}),
|
|
445
|
+
{ lxpackBridge: lxpackBridgeModeRef.current }
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}, [config.courseId, syncProgress, track]);
|
|
327
449
|
const emitLessonCompleted = useCallback(
|
|
328
450
|
(lessonId, durationMs) => {
|
|
329
451
|
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
@@ -339,9 +461,22 @@ function LessonkitProvider(props) {
|
|
|
339
461
|
if (!result.didComplete) return;
|
|
340
462
|
syncProgress();
|
|
341
463
|
emitLessonCompleted(lessonId, result.durationMs);
|
|
464
|
+
void trackingRef.current?.flush?.();
|
|
342
465
|
},
|
|
343
466
|
[syncProgress, emitLessonCompleted]
|
|
344
467
|
);
|
|
468
|
+
useEffect(() => {
|
|
469
|
+
return () => {
|
|
470
|
+
const client = trackingRef.current;
|
|
471
|
+
void xapiRef.current?.flush();
|
|
472
|
+
setTimeout(() => {
|
|
473
|
+
client?.flush?.();
|
|
474
|
+
setTimeout(() => {
|
|
475
|
+
client?.dispose?.();
|
|
476
|
+
}, 0);
|
|
477
|
+
}, 0);
|
|
478
|
+
};
|
|
479
|
+
}, []);
|
|
345
480
|
const setActiveLesson = useCallback(
|
|
346
481
|
(lessonId) => {
|
|
347
482
|
const current = progressRef.current.getState();
|
|
@@ -365,6 +500,9 @@ function LessonkitProvider(props) {
|
|
|
365
500
|
syncProgress();
|
|
366
501
|
track("course_completed");
|
|
367
502
|
}, [track, syncProgress]);
|
|
503
|
+
const sessionUser = config.session?.user;
|
|
504
|
+
const sessionAttemptId = config.session?.attemptId;
|
|
505
|
+
const sessionConfiguredId = config.session?.sessionId;
|
|
368
506
|
const runtime = useMemo(
|
|
369
507
|
() => ({
|
|
370
508
|
config,
|
|
@@ -377,7 +515,19 @@ function LessonkitProvider(props) {
|
|
|
377
515
|
completeCourse,
|
|
378
516
|
track
|
|
379
517
|
}),
|
|
380
|
-
[
|
|
518
|
+
[
|
|
519
|
+
config,
|
|
520
|
+
tracking,
|
|
521
|
+
xapi,
|
|
522
|
+
progress,
|
|
523
|
+
setActiveLesson,
|
|
524
|
+
completeLesson,
|
|
525
|
+
completeCourse,
|
|
526
|
+
track,
|
|
527
|
+
sessionUser,
|
|
528
|
+
sessionAttemptId,
|
|
529
|
+
sessionConfiguredId
|
|
530
|
+
]
|
|
381
531
|
);
|
|
382
532
|
return /* @__PURE__ */ jsx(LessonkitContext.Provider, { value: runtime, children: props.children });
|
|
383
533
|
}
|
|
@@ -449,24 +599,21 @@ function Course(props) {
|
|
|
449
599
|
}
|
|
450
600
|
function Lesson(props) {
|
|
451
601
|
warnInvalidComponentId(props.lessonId, "lessonId");
|
|
452
|
-
const { setActiveLesson } = useLessonkit();
|
|
602
|
+
const { setActiveLesson, config } = useLessonkit();
|
|
453
603
|
const { completeLesson } = useCompletion();
|
|
454
604
|
const id = props.lessonId;
|
|
455
|
-
const
|
|
605
|
+
const lessonMountGenerationRef = useRef2(0);
|
|
456
606
|
useEffect2(() => {
|
|
457
|
-
|
|
458
|
-
clearTimeout(pendingCompleteRef.current);
|
|
459
|
-
pendingCompleteRef.current = null;
|
|
460
|
-
}
|
|
607
|
+
const generation = ++lessonMountGenerationRef.current;
|
|
461
608
|
setActiveLesson(id);
|
|
462
609
|
return () => {
|
|
463
610
|
const lessonId = id;
|
|
464
|
-
|
|
465
|
-
|
|
611
|
+
queueMicrotask(() => {
|
|
612
|
+
if (lessonMountGenerationRef.current !== generation) return;
|
|
466
613
|
completeLesson(lessonId);
|
|
467
|
-
}
|
|
614
|
+
});
|
|
468
615
|
};
|
|
469
|
-
}, [id, setActiveLesson, completeLesson]);
|
|
616
|
+
}, [id, config.courseId, setActiveLesson, completeLesson]);
|
|
470
617
|
return /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
|
|
471
618
|
/* @__PURE__ */ jsx2("h2", { children: props.title }),
|
|
472
619
|
/* @__PURE__ */ jsx2("div", { children: props.children })
|
|
@@ -531,7 +678,7 @@ function Quiz(props) {
|
|
|
531
678
|
});
|
|
532
679
|
if (correct && !completedRef.current) {
|
|
533
680
|
completedRef.current = true;
|
|
534
|
-
quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1 });
|
|
681
|
+
quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1, passingScore: 1 });
|
|
535
682
|
}
|
|
536
683
|
}
|
|
537
684
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "React components and hooks for building learning experiences with LessonKit.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"dist"
|
|
38
38
|
],
|
|
39
39
|
"scripts": {
|
|
40
|
-
"build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/themes",
|
|
41
|
-
"dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/themes",
|
|
40
|
+
"build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/lxpack --external @lessonkit/themes",
|
|
41
|
+
"dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/lxpack --external @lessonkit/themes",
|
|
42
42
|
"prepublishOnly": "npm run build",
|
|
43
43
|
"typecheck": "tsc -p tsconfig.json",
|
|
44
44
|
"test": "vitest run --passWithNoTests",
|
|
@@ -50,10 +50,11 @@
|
|
|
50
50
|
"react-dom": ">=18"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@lessonkit/accessibility": "0.
|
|
54
|
-
"@lessonkit/core": "0.
|
|
55
|
-
"@lessonkit/
|
|
56
|
-
"@lessonkit/
|
|
53
|
+
"@lessonkit/accessibility": "0.7.0",
|
|
54
|
+
"@lessonkit/core": "0.7.0",
|
|
55
|
+
"@lessonkit/lxpack": "0.7.0",
|
|
56
|
+
"@lessonkit/themes": "0.7.0",
|
|
57
|
+
"@lessonkit/xapi": "0.7.0"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
59
60
|
"@testing-library/react": "^16.3.0",
|