@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 CHANGED
@@ -59,7 +59,7 @@ export default function App() {
59
59
  }
60
60
  ```
61
61
 
62
- ## API (0.8.0)
62
+ ## API (0.9.0)
63
63
 
64
64
  ### Block catalog
65
65
 
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 import_core3 = require("@lessonkit/core");
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.lxpackBridge?.v1 ?? parent.lxpack ?? null;
79
+ return parent.lxpack ?? null;
76
80
  }
77
- function forwardTelemetryToLxpack(event, mode = "auto") {
78
- if (mode === "off") return;
79
- const bridge = getBridge();
80
- if (!bridge) return;
81
- switch (event.name) {
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 "quiz_completed": {
91
- const data = event.data;
92
- if (!data?.checkId) return;
93
- const scaled = (0, import_bridge.normalizeAssessmentScore)({
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: data.checkId,
97
+ id: action.id,
100
98
  score: scaled,
101
- passingScore: (0, import_bridge.normalizeAssessmentPassingScore)(data.passingScore)
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/context.tsx
311
- var import_jsx_runtime = require("react/jsx-runtime");
312
- var LessonkitContext = (0, import_react.createContext)(null);
313
- var useIsoLayoutEffect = typeof window !== "undefined" ? import_react.useLayoutEffect : import_react.useEffect;
314
- function disposeTrackingClient(client) {
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
- var defaultStorage = createSessionStoragePort();
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
- return (0, import_core3.createTrackingClient)();
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
- if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
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
- if (hasCourseStarted(defaultStorage, sessionId, cid)) {
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, import_core3.createTrackingClient)());
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 next = createTrackingClientFromConfig(config);
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
- if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
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
- emitTelemetry(
413
- next,
414
- xapiRef.current,
415
- buildTrackEvent({
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
- { lxpackBridge: lxpackBridgeModeRef.current }
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
- (trackingClient, event) => {
440
- emitTelemetry(trackingClient, xapiRef.current, event, {
441
- lxpackBridge: lxpackBridgeModeRef.current
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
- const prevCourseIdRef = (0, import_react.useRef)(config.courseId);
463
- (0, import_react.useEffect)(() => {
464
- if (prevCourseIdRef.current === config.courseId) return;
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 (previousActiveLesson) {
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 = config.courseId;
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
- emitTelemetry(
479
- trackingRef.current,
480
- xapiRef.current,
481
- buildTrackEvent({
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
- { lxpackBridge: lxpackBridgeModeRef.current }
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, syncProgress, track]);
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 client = trackingRef.current;
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 import_core4 = require("@lessonkit/core");
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, import_core4.validateId)(id, path);
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
- normalizeAssessmentPassingScore,
26
- normalizeAssessmentScore
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.lxpackBridge?.v1 ?? parent.lxpack ?? null;
37
+ return parent.lxpack ?? null;
33
38
  }
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);
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 "quiz_completed": {
48
- const data = event.data;
49
- if (!data?.checkId) return;
50
- const scaled = normalizeAssessmentScore({
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: data.checkId,
55
+ id: action.id,
57
56
  score: scaled,
58
- passingScore: normalizeAssessmentPassingScore(data.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/context.tsx
268
- import { jsx } from "react/jsx-runtime";
269
- var LessonkitContext = createContext(null);
270
- var useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
271
- function disposeTrackingClient(client) {
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
- var defaultStorage = createSessionStoragePort();
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
- return createTrackingClient();
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
- if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
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
- if (hasCourseStarted(defaultStorage, sessionId, cid)) {
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(createTrackingClient());
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 next = createTrackingClientFromConfig(config);
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
- if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
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
- emitTelemetry(
370
- next,
371
- xapiRef.current,
372
- buildTrackEvent({
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
- { lxpackBridge: lxpackBridgeModeRef.current }
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
- (trackingClient, event) => {
397
- emitTelemetry(trackingClient, xapiRef.current, event, {
398
- lxpackBridge: lxpackBridgeModeRef.current
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
- const prevCourseIdRef = useRef(config.courseId);
420
- useEffect(() => {
421
- if (prevCourseIdRef.current === config.courseId) return;
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 (previousActiveLesson) {
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 = config.courseId;
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
- emitTelemetry(
436
- trackingRef.current,
437
- xapiRef.current,
438
- buildTrackEvent({
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
- { lxpackBridge: lxpackBridgeModeRef.current }
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, syncProgress, track]);
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 client = trackingRef.current;
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.8.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.8.0",
58
- "@lessonkit/core": "0.8.0",
59
- "@lessonkit/lxpack": "0.8.0",
60
- "@lessonkit/themes": "0.8.0",
61
- "@lessonkit/xapi": "0.8.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",