@lessonkit/react 0.8.1 → 0.9.1

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.1)
62
+ ## API (0.9.1)
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_core4 = 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,46 +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)({
102
- passingScore: data.passingScore,
103
- maxScore: data.maxScore
104
- })
99
+ passingScore: (0, import_bridge.normalizePassingThreshold)({
100
+ passingScore: action.passingScore,
101
+ maxScore: action.maxScore
102
+ }),
103
+ maxScore: action.maxScore
105
104
  });
106
105
  return;
107
106
  }
107
+ case "track":
108
+ bridge.track?.(action.event);
109
+ return;
108
110
  default:
109
111
  return;
110
112
  }
111
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
+ }
112
123
 
113
124
  // src/runtime/emitTelemetry.ts
114
125
  var warnedMissingCourseId = false;
@@ -328,12 +339,31 @@ function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId)
328
339
  }
329
340
  }
330
341
 
331
- // src/runtime/telemetry.ts
342
+ // src/runtime/plugins.ts
332
343
  var import_core3 = require("@lessonkit/core");
344
+ function createReactPluginHost(plugins) {
345
+ if (!plugins?.length) return null;
346
+ return (0, import_core3.createPluginHost)(plugins);
347
+ }
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");
333
363
  function createTrackingClientFromConfig(config) {
334
- if (config.tracking?.enabled === false) return (0, import_core3.createTrackingClient)();
364
+ if (config.tracking?.enabled === false) return (0, import_core4.createTrackingClient)();
335
365
  if (config.tracking?.createClient) return config.tracking.createClient();
336
- return (0, import_core3.createTrackingClient)({
366
+ return (0, import_core4.createTrackingClient)({
337
367
  sink: config.tracking?.sink,
338
368
  batchSink: config.tracking?.batchSink,
339
369
  batch: config.tracking?.batch
@@ -369,6 +399,9 @@ function LessonkitProvider(props) {
369
399
  courseIdRef.current = config.courseId;
370
400
  const lxpackBridgeModeRef = (0, import_react.useRef)(config.lxpack?.bridge ?? "auto");
371
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;
372
405
  const progressRef = (0, import_react.useRef)(createProgressController());
373
406
  const courseStartedEmittedToSinkRef = (0, import_react.useRef)(false);
374
407
  const prevCourseIdForProgressRef = (0, import_react.useRef)(config.courseId);
@@ -393,6 +426,7 @@ function LessonkitProvider(props) {
393
426
  const xapiClient = config.xapi?.client;
394
427
  const xapiTransport = config.xapi?.transport;
395
428
  const courseId = config.courseId;
429
+ const trackingEnabled = config.tracking?.enabled;
396
430
  useIsoLayoutEffect(() => {
397
431
  if (prevXapiCourseIdRef.current !== courseId) {
398
432
  xapiQueueRef.current = (0, import_xapi3.createInMemoryXAPIQueue)();
@@ -405,18 +439,22 @@ function LessonkitProvider(props) {
405
439
  if (next && !prev) {
406
440
  const sessionId = sessionIdRef.current;
407
441
  const cid = courseIdRef.current;
408
- try {
409
- const statement = (0, import_xapi4.telemetryEventToXAPIStatement)(
410
- buildTrackEvent({
411
- name: "course_started",
412
- courseId: cid,
413
- sessionId,
414
- attemptId: attemptIdRef.current,
415
- user: userRef.current
416
- })
417
- );
418
- if (statement) next.send(statement);
419
- } catch {
442
+ const trackingActive = isTrackingActive(config.tracking);
443
+ const alreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
444
+ if (!trackingActive || alreadyStarted) {
445
+ try {
446
+ const statement = (0, import_xapi4.telemetryEventToXAPIStatement)(
447
+ buildTrackEvent({
448
+ name: "course_started",
449
+ courseId: cid,
450
+ sessionId,
451
+ attemptId: attemptIdRef.current,
452
+ user: userRef.current
453
+ })
454
+ );
455
+ if (statement) next.send(statement);
456
+ } catch {
457
+ }
420
458
  }
421
459
  }
422
460
  let cancelled = false;
@@ -437,11 +475,10 @@ function LessonkitProvider(props) {
437
475
  cancelled = true;
438
476
  void prev?.flush();
439
477
  };
440
- }, [xapiEnabled, xapiClient, xapiTransport, courseId]);
441
- const trackingRef = (0, import_react.useRef)((0, import_core4.createTrackingClient)());
478
+ }, [xapiEnabled, xapiClient, xapiTransport, courseId, trackingEnabled]);
479
+ const trackingRef = (0, import_react.useRef)((0, import_core5.createTrackingClient)());
442
480
  const trackingClientForUnmountRef = (0, import_react.useRef)(trackingRef.current);
443
481
  const [tracking, setTracking] = (0, import_react.useState)(() => trackingRef.current);
444
- const trackingEnabled = config.tracking?.enabled;
445
482
  const trackingSink = config.tracking?.sink;
446
483
  const trackingBatchSink = config.tracking?.batchSink;
447
484
  const batchEnabled = config.tracking?.batch?.enabled;
@@ -449,7 +486,19 @@ function LessonkitProvider(props) {
449
486
  const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
450
487
  useIsoLayoutEffect(() => {
451
488
  const prev = trackingRef.current;
452
- const next = createTrackingClientFromConfig({ tracking: config.tracking });
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
+ });
453
502
  trackingRef.current = next;
454
503
  trackingClientForUnmountRef.current = next;
455
504
  setTracking(next);
@@ -460,18 +509,24 @@ function LessonkitProvider(props) {
460
509
  courseStartedEmittedToSinkRef.current = false;
461
510
  } else if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
462
511
  markCourseStarted(defaultStorage, sessionId, cid);
463
- emitTelemetry(
464
- next,
465
- xapiRef.current,
466
- buildTrackEvent({
512
+ emitTelemetryWithPlugins({
513
+ pluginHost: pluginHostRef.current,
514
+ tracking: next,
515
+ xapi: xapiRef.current,
516
+ event: buildTrackEvent({
467
517
  name: "course_started",
468
518
  courseId: cid,
469
519
  sessionId,
470
520
  attemptId: attemptIdRef.current,
471
521
  user: userRef.current
472
522
  }),
473
- { lxpackBridge: lxpackBridgeModeRef.current }
474
- );
523
+ pluginCtx: buildPluginContext({
524
+ courseId: cid,
525
+ sessionId,
526
+ attemptId: attemptIdRef.current
527
+ }),
528
+ lxpackBridge: lxpackBridgeModeRef.current
529
+ });
475
530
  courseStartedEmittedToSinkRef.current = true;
476
531
  } else if (trackingActive) {
477
532
  courseStartedEmittedToSinkRef.current = true;
@@ -487,16 +542,23 @@ function LessonkitProvider(props) {
487
542
  trackingBatchSink,
488
543
  batchEnabled,
489
544
  batchFlushIntervalMs,
490
- batchMaxBatchSize
545
+ batchMaxBatchSize,
546
+ config.plugins
491
547
  ]);
492
- const emitWithBridge = (0, import_react.useCallback)(
493
- (trackingClient, event) => {
494
- emitTelemetry(trackingClient, xapiRef.current, event, {
495
- lxpackBridge: lxpackBridgeModeRef.current
496
- });
497
- },
498
- []
499
- );
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
+ }, []);
500
562
  const track = (0, import_react.useCallback)(
501
563
  (name, data, opts) => {
502
564
  const event = tryBuildTrackEvent({
@@ -522,18 +584,24 @@ function LessonkitProvider(props) {
522
584
  const cid = courseIdRef.current;
523
585
  if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
524
586
  markCourseStarted(defaultStorage, sessionId, cid);
525
- emitTelemetry(
526
- trackingRef.current,
527
- xapiRef.current,
528
- buildTrackEvent({
587
+ emitTelemetryWithPlugins({
588
+ pluginHost: pluginHostRef.current,
589
+ tracking: trackingRef.current,
590
+ xapi: xapiRef.current,
591
+ event: buildTrackEvent({
529
592
  name: "course_started",
530
593
  courseId: cid,
531
594
  sessionId,
532
595
  attemptId: attemptIdRef.current,
533
596
  user: userRef.current
534
597
  }),
535
- { lxpackBridge: lxpackBridgeModeRef.current }
536
- );
598
+ pluginCtx: buildPluginContext({
599
+ courseId: cid,
600
+ sessionId,
601
+ attemptId: attemptIdRef.current
602
+ }),
603
+ lxpackBridge: lxpackBridgeModeRef.current
604
+ });
537
605
  courseStartedEmittedToSinkRef.current = true;
538
606
  }
539
607
  }, [config.courseId, config.tracking?.enabled, syncProgress]);
@@ -600,6 +668,18 @@ function LessonkitProvider(props) {
600
668
  const sessionUser = config.session?.user;
601
669
  const sessionAttemptId = config.session?.attemptId;
602
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]);
603
683
  (0, import_react.useEffect)(() => {
604
684
  const nextConfigured = config.session?.sessionId;
605
685
  const prevConfigured = prevConfiguredSessionIdRef.current;
@@ -633,7 +713,8 @@ function LessonkitProvider(props) {
633
713
  setActiveLesson,
634
714
  completeLesson,
635
715
  completeCourse,
636
- track
716
+ track,
717
+ plugins: pluginHost
637
718
  }),
638
719
  [
639
720
  config,
@@ -644,6 +725,7 @@ function LessonkitProvider(props) {
644
725
  completeLesson,
645
726
  completeCourse,
646
727
  track,
728
+ pluginHost,
647
729
  sessionUser,
648
730
  sessionAttemptId,
649
731
  sessionConfiguredId
@@ -687,7 +769,7 @@ function useQuizState() {
687
769
  }
688
770
 
689
771
  // src/runtime/validateComponentId.ts
690
- var import_core5 = require("@lessonkit/core");
772
+ var import_core6 = require("@lessonkit/core");
691
773
  var warnedPaths = /* @__PURE__ */ new Set();
692
774
  function isDevEnvironment2() {
693
775
  const g = globalThis;
@@ -697,7 +779,7 @@ function warnInvalidComponentId(id, path) {
697
779
  if (!isDevEnvironment2()) return;
698
780
  const key = `${path}:${String(id)}`;
699
781
  if (warnedPaths.has(key)) return;
700
- const result = (0, import_core5.validateId)(id, path);
782
+ const result = (0, import_core6.validateId)(id, path);
701
783
  if (result.ok) return;
702
784
  warnedPaths.add(key);
703
785
  const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
@@ -822,6 +904,9 @@ function ProgressTracker() {
822
904
  ] }) });
823
905
  }
824
906
 
907
+ // src/index.tsx
908
+ var import_core7 = require("@lessonkit/core");
909
+
825
910
  // src/theme/ThemeProvider.tsx
826
911
  var import_react4 = __toESM(require("react"), 1);
827
912
  var import_themes = require("@lessonkit/themes");
@@ -1130,6 +1215,8 @@ function getBlockCatalogEntry(type) {
1130
1215
  ThemeProvider,
1131
1216
  blockCatalogVersion,
1132
1217
  buildBlockCatalog,
1218
+ createPluginHost,
1219
+ defineLessonkitPlugin,
1133
1220
  getBlockCatalogEntry,
1134
1221
  useCompletion,
1135
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
@@ -22,50 +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({
59
- passingScore: data.passingScore,
60
- maxScore: data.maxScore
61
- })
57
+ passingScore: normalizePassingThreshold({
58
+ passingScore: action.passingScore,
59
+ maxScore: action.maxScore
60
+ }),
61
+ maxScore: action.maxScore
62
62
  });
63
63
  return;
64
64
  }
65
+ case "track":
66
+ bridge.track?.(action.event);
67
+ return;
65
68
  default:
66
69
  return;
67
70
  }
68
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
+ }
69
81
 
70
82
  // src/runtime/emitTelemetry.ts
71
83
  var warnedMissingCourseId = false;
@@ -285,6 +297,25 @@ function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId)
285
297
  }
286
298
  }
287
299
 
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);
305
+ }
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
+
288
319
  // src/runtime/telemetry.ts
289
320
  import { createTrackingClient } from "@lessonkit/core";
290
321
  function createTrackingClientFromConfig(config) {
@@ -326,6 +357,9 @@ function LessonkitProvider(props) {
326
357
  courseIdRef.current = config.courseId;
327
358
  const lxpackBridgeModeRef = useRef(config.lxpack?.bridge ?? "auto");
328
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;
329
363
  const progressRef = useRef(createProgressController());
330
364
  const courseStartedEmittedToSinkRef = useRef(false);
331
365
  const prevCourseIdForProgressRef = useRef(config.courseId);
@@ -350,6 +384,7 @@ function LessonkitProvider(props) {
350
384
  const xapiClient = config.xapi?.client;
351
385
  const xapiTransport = config.xapi?.transport;
352
386
  const courseId = config.courseId;
387
+ const trackingEnabled = config.tracking?.enabled;
353
388
  useIsoLayoutEffect(() => {
354
389
  if (prevXapiCourseIdRef.current !== courseId) {
355
390
  xapiQueueRef.current = createInMemoryXAPIQueue();
@@ -362,18 +397,22 @@ function LessonkitProvider(props) {
362
397
  if (next && !prev) {
363
398
  const sessionId = sessionIdRef.current;
364
399
  const cid = courseIdRef.current;
365
- try {
366
- const statement = telemetryEventToXAPIStatement2(
367
- buildTrackEvent({
368
- name: "course_started",
369
- courseId: cid,
370
- sessionId,
371
- attemptId: attemptIdRef.current,
372
- user: userRef.current
373
- })
374
- );
375
- if (statement) next.send(statement);
376
- } catch {
400
+ const trackingActive = isTrackingActive(config.tracking);
401
+ const alreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
402
+ if (!trackingActive || alreadyStarted) {
403
+ try {
404
+ const statement = telemetryEventToXAPIStatement2(
405
+ buildTrackEvent({
406
+ name: "course_started",
407
+ courseId: cid,
408
+ sessionId,
409
+ attemptId: attemptIdRef.current,
410
+ user: userRef.current
411
+ })
412
+ );
413
+ if (statement) next.send(statement);
414
+ } catch {
415
+ }
377
416
  }
378
417
  }
379
418
  let cancelled = false;
@@ -394,11 +433,10 @@ function LessonkitProvider(props) {
394
433
  cancelled = true;
395
434
  void prev?.flush();
396
435
  };
397
- }, [xapiEnabled, xapiClient, xapiTransport, courseId]);
436
+ }, [xapiEnabled, xapiClient, xapiTransport, courseId, trackingEnabled]);
398
437
  const trackingRef = useRef(createTrackingClient2());
399
438
  const trackingClientForUnmountRef = useRef(trackingRef.current);
400
439
  const [tracking, setTracking] = useState(() => trackingRef.current);
401
- const trackingEnabled = config.tracking?.enabled;
402
440
  const trackingSink = config.tracking?.sink;
403
441
  const trackingBatchSink = config.tracking?.batchSink;
404
442
  const batchEnabled = config.tracking?.batch?.enabled;
@@ -406,7 +444,19 @@ function LessonkitProvider(props) {
406
444
  const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
407
445
  useIsoLayoutEffect(() => {
408
446
  const prev = trackingRef.current;
409
- const next = createTrackingClientFromConfig({ tracking: config.tracking });
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
+ });
410
460
  trackingRef.current = next;
411
461
  trackingClientForUnmountRef.current = next;
412
462
  setTracking(next);
@@ -417,18 +467,24 @@ function LessonkitProvider(props) {
417
467
  courseStartedEmittedToSinkRef.current = false;
418
468
  } else if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
419
469
  markCourseStarted(defaultStorage, sessionId, cid);
420
- emitTelemetry(
421
- next,
422
- xapiRef.current,
423
- buildTrackEvent({
470
+ emitTelemetryWithPlugins({
471
+ pluginHost: pluginHostRef.current,
472
+ tracking: next,
473
+ xapi: xapiRef.current,
474
+ event: buildTrackEvent({
424
475
  name: "course_started",
425
476
  courseId: cid,
426
477
  sessionId,
427
478
  attemptId: attemptIdRef.current,
428
479
  user: userRef.current
429
480
  }),
430
- { lxpackBridge: lxpackBridgeModeRef.current }
431
- );
481
+ pluginCtx: buildPluginContext({
482
+ courseId: cid,
483
+ sessionId,
484
+ attemptId: attemptIdRef.current
485
+ }),
486
+ lxpackBridge: lxpackBridgeModeRef.current
487
+ });
432
488
  courseStartedEmittedToSinkRef.current = true;
433
489
  } else if (trackingActive) {
434
490
  courseStartedEmittedToSinkRef.current = true;
@@ -444,16 +500,23 @@ function LessonkitProvider(props) {
444
500
  trackingBatchSink,
445
501
  batchEnabled,
446
502
  batchFlushIntervalMs,
447
- batchMaxBatchSize
503
+ batchMaxBatchSize,
504
+ config.plugins
448
505
  ]);
449
- const emitWithBridge = useCallback(
450
- (trackingClient, event) => {
451
- emitTelemetry(trackingClient, xapiRef.current, event, {
452
- lxpackBridge: lxpackBridgeModeRef.current
453
- });
454
- },
455
- []
456
- );
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
+ }, []);
457
520
  const track = useCallback(
458
521
  (name, data, opts) => {
459
522
  const event = tryBuildTrackEvent({
@@ -479,18 +542,24 @@ function LessonkitProvider(props) {
479
542
  const cid = courseIdRef.current;
480
543
  if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
481
544
  markCourseStarted(defaultStorage, sessionId, cid);
482
- emitTelemetry(
483
- trackingRef.current,
484
- xapiRef.current,
485
- buildTrackEvent({
545
+ emitTelemetryWithPlugins({
546
+ pluginHost: pluginHostRef.current,
547
+ tracking: trackingRef.current,
548
+ xapi: xapiRef.current,
549
+ event: buildTrackEvent({
486
550
  name: "course_started",
487
551
  courseId: cid,
488
552
  sessionId,
489
553
  attemptId: attemptIdRef.current,
490
554
  user: userRef.current
491
555
  }),
492
- { lxpackBridge: lxpackBridgeModeRef.current }
493
- );
556
+ pluginCtx: buildPluginContext({
557
+ courseId: cid,
558
+ sessionId,
559
+ attemptId: attemptIdRef.current
560
+ }),
561
+ lxpackBridge: lxpackBridgeModeRef.current
562
+ });
494
563
  courseStartedEmittedToSinkRef.current = true;
495
564
  }
496
565
  }, [config.courseId, config.tracking?.enabled, syncProgress]);
@@ -557,6 +626,18 @@ function LessonkitProvider(props) {
557
626
  const sessionUser = config.session?.user;
558
627
  const sessionAttemptId = config.session?.attemptId;
559
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]);
560
641
  useEffect(() => {
561
642
  const nextConfigured = config.session?.sessionId;
562
643
  const prevConfigured = prevConfiguredSessionIdRef.current;
@@ -590,7 +671,8 @@ function LessonkitProvider(props) {
590
671
  setActiveLesson,
591
672
  completeLesson,
592
673
  completeCourse,
593
- track
674
+ track,
675
+ plugins: pluginHost
594
676
  }),
595
677
  [
596
678
  config,
@@ -601,6 +683,7 @@ function LessonkitProvider(props) {
601
683
  completeLesson,
602
684
  completeCourse,
603
685
  track,
686
+ pluginHost,
604
687
  sessionUser,
605
688
  sessionAttemptId,
606
689
  sessionConfiguredId
@@ -779,6 +862,9 @@ function ProgressTracker() {
779
862
  ] }) });
780
863
  }
781
864
 
865
+ // src/index.tsx
866
+ import { createPluginHost as createPluginHost2, defineLessonkitPlugin } from "@lessonkit/core";
867
+
782
868
  // src/theme/ThemeProvider.tsx
783
869
  import React3, {
784
870
  createContext as createContext2,
@@ -1101,6 +1187,8 @@ export {
1101
1187
  ThemeProvider,
1102
1188
  blockCatalogVersion,
1103
1189
  buildBlockCatalog,
1190
+ createPluginHost2 as createPluginHost,
1191
+ defineLessonkitPlugin,
1104
1192
  getBlockCatalogEntry,
1105
1193
  useCompletion,
1106
1194
  useLessonkit,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/react",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
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.1",
58
- "@lessonkit/core": "0.8.1",
59
- "@lessonkit/lxpack": "0.8.1",
60
- "@lessonkit/themes": "0.8.1",
61
- "@lessonkit/xapi": "0.8.1"
57
+ "@lessonkit/accessibility": "0.9.1",
58
+ "@lessonkit/core": "0.9.1",
59
+ "@lessonkit/lxpack": "0.9.1",
60
+ "@lessonkit/themes": "0.9.1",
61
+ "@lessonkit/xapi": "0.9.1"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@testing-library/react": "^16.3.0",