@lessonkit/core 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import {
2
+ ID_MAX_LENGTH,
3
+ ID_PATTERN,
2
4
  SESSION_STORAGE_KEY,
5
+ assertValidId,
3
6
  buildCourseStartedTelemetryEvent,
4
7
  buildTelemetryEvent,
5
8
  completeCourseWithTelemetry,
@@ -13,82 +16,33 @@ import {
13
16
  hasCourseStarted,
14
17
  hasCourseStartedEmittedToTracking,
15
18
  hasCourseStartedPipelineDelivered,
19
+ hasCourseStartedXapiSent,
16
20
  isDevEnvironment,
17
21
  markCourseStarted,
18
22
  markCourseStartedEmittedToTracking,
19
23
  markCourseStartedPipelineDelivered,
24
+ markCourseStartedXapiSent,
20
25
  migrateCourseStartedMark,
21
26
  nowIso,
27
+ parseBlockId,
28
+ parseCheckId,
29
+ parseCourseId,
30
+ parseLessonId,
22
31
  resetSharedVolatileSessionIdForTests,
23
32
  resetStoragePortForTests,
24
33
  resetTelemetryBuilderWarningsForTests,
25
34
  resolveSessionId,
26
35
  tryBuildTelemetryEvent,
27
36
  tryEmitCourseStarted,
37
+ validateId,
28
38
  warnDev
29
- } from "./chunk-PEWFPVQ6.js";
30
-
31
- // src/identityTypes.ts
32
- var ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
33
- var ID_MAX_LENGTH = 64;
39
+ } from "./chunk-KFXFQ6B2.js";
34
40
 
35
41
  // src/assertNever.ts
36
42
  function assertNever(value, message = "Unexpected value") {
37
43
  throw new Error(`${message}: ${String(value)}`);
38
44
  }
39
45
 
40
- // src/validateId.ts
41
- function validateId(input, path = "id") {
42
- if (typeof input !== "string") {
43
- return { ok: false, issues: [{ path, message: "id must be a string" }] };
44
- }
45
- const id = input.trim();
46
- if (!id.length) {
47
- return { ok: false, issues: [{ path, message: "id must not be empty" }] };
48
- }
49
- if (id.length > ID_MAX_LENGTH) {
50
- return {
51
- ok: false,
52
- issues: [{ path, message: `id must be at most ${ID_MAX_LENGTH} characters` }]
53
- };
54
- }
55
- if (!ID_PATTERN.test(id)) {
56
- return {
57
- ok: false,
58
- issues: [
59
- {
60
- path,
61
- message: "id must start with a letter and contain only letters, digits, underscores, and hyphens"
62
- }
63
- ]
64
- };
65
- }
66
- return { ok: true, id };
67
- }
68
- function parseCourseId(input) {
69
- const result = validateId(input, "courseId");
70
- return result.ok ? result.id : null;
71
- }
72
- function parseLessonId(input) {
73
- const result = validateId(input, "lessonId");
74
- return result.ok ? result.id : null;
75
- }
76
- function parseCheckId(input) {
77
- const result = validateId(input, "checkId");
78
- return result.ok ? result.id : null;
79
- }
80
- function parseBlockId(input) {
81
- const result = validateId(input, "blockId");
82
- return result.ok ? result.id : null;
83
- }
84
- function assertValidId(input, path = "id") {
85
- const result = validateId(input, path);
86
- if (!result.ok) {
87
- throw new Error(result.issues.map((i) => `${i.path}: ${i.message}`).join("; "));
88
- }
89
- return result.id;
90
- }
91
-
92
46
  // src/slugify.ts
93
47
  function shortHash(input) {
94
48
  let h = 0;
@@ -101,12 +55,26 @@ function uniqueFallbackId(input, usedIds) {
101
55
  const hash = shortHash(input);
102
56
  for (let n = 0; n < 100; n++) {
103
57
  const candidate = (n === 0 ? `id-${hash}` : `id-${hash}-${n}`).slice(0, 64);
104
- const validated2 = validateId(candidate);
105
- if (validated2.ok && !usedIds.has(validated2.id)) return validated2.id;
58
+ const validated = validateId(candidate);
59
+ if (validated.ok && !usedIds.has(validated.id)) return validated.id;
60
+ }
61
+ for (let attempt = 0; attempt < 100; attempt++) {
62
+ const randomSuffix = Math.random().toString(36).slice(2, 8);
63
+ const candidate = `id-${hash}-${randomSuffix}`.slice(0, 64);
64
+ const validated = validateId(candidate);
65
+ if (validated.ok && !usedIds.has(validated.id)) return validated.id;
106
66
  }
107
67
  const timed = `id-${hash}-${Date.now().toString(36)}`.slice(0, 64);
108
- const validated = validateId(timed);
109
- return validated.ok ? validated.id : `id-${hash}`;
68
+ const timedValidated = validateId(timed);
69
+ if (timedValidated.ok && !usedIds.has(timedValidated.id)) return timedValidated.id;
70
+ const cryptoApi = globalThis.crypto;
71
+ for (let attempt = 0; attempt < 1e3; attempt++) {
72
+ const suffix = typeof cryptoApi?.randomUUID === "function" ? cryptoApi.randomUUID().replace(/-/g, "").slice(0, 12) : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
73
+ const candidate = `id-${hash}-${suffix}`.slice(0, 64);
74
+ const validated = validateId(candidate);
75
+ if (validated.ok && !usedIds.has(validated.id)) return validated.id;
76
+ }
77
+ throw new Error(`[lessonkit] unable to derive unique id for input: ${input.slice(0, 32)}`);
110
78
  }
111
79
  function slugifyId(input) {
112
80
  const slug = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-").slice(0, 64);
@@ -148,6 +116,13 @@ function buildLessonkitUrn(parts) {
148
116
  }
149
117
  urn += `:block:${blockId}`;
150
118
  }
119
+ if (parts.nodeId !== void 0) {
120
+ const nodeId = assertValidId(parts.nodeId, "blockId");
121
+ if (parts.blockId === void 0) {
122
+ throw new Error("buildLessonkitUrn: nodeId requires blockId");
123
+ }
124
+ urn += `:node:${nodeId}`;
125
+ }
151
126
  return urn;
152
127
  }
153
128
 
@@ -192,19 +167,25 @@ function isPlainSerializableChildState(value) {
192
167
  (entry) => isValidChildResumeValue(entry)
193
168
  );
194
169
  }
195
- function parseCompoundResumeState(raw) {
170
+ function parseCompoundResumeState(raw, opts) {
196
171
  if (!raw || typeof raw !== "object") return null;
197
172
  const obj = raw;
198
173
  if (obj.schemaVersion !== COMPOUND_RESUME_SCHEMA_VERSION) return null;
199
174
  if (typeof obj.activePageIndex !== "number" || !Number.isFinite(obj.activePageIndex)) return null;
200
175
  const childStates = {};
176
+ const droppedChildKeys = [];
201
177
  if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
202
178
  for (const [key, value] of Object.entries(obj.childStates)) {
203
179
  if (isPlainSerializableChildState(value)) {
204
180
  childStates[key] = value;
181
+ } else {
182
+ droppedChildKeys.push(key);
205
183
  }
206
184
  }
207
185
  }
186
+ if (droppedChildKeys.length > 0) {
187
+ opts?.onDroppedChildKeys?.(droppedChildKeys);
188
+ }
208
189
  const activeChapterIndex = typeof obj.activeChapterIndex === "number" && Number.isFinite(obj.activeChapterIndex) ? obj.activeChapterIndex : void 0;
209
190
  return {
210
191
  schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
@@ -219,17 +200,21 @@ var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
219
200
  function compoundStateStorageKey(courseId, compoundId) {
220
201
  return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
221
202
  }
222
- function loadCompoundState(storage, courseId, compoundId) {
203
+ function loadCompoundState(storage, courseId, compoundId, opts) {
223
204
  const key = compoundStateStorageKey(courseId, compoundId);
224
205
  const raw = storage.getItem(key);
225
206
  if (!raw) return null;
226
207
  try {
227
- const parsed = parseCompoundResumeState(JSON.parse(raw));
228
- if (parsed === null && isDevEnvironment()) {
229
- console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
208
+ const parsed = parseCompoundResumeState(JSON.parse(raw), opts);
209
+ if (parsed === null) {
210
+ opts?.onCorrupt?.();
211
+ if (isDevEnvironment()) {
212
+ console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
213
+ }
230
214
  }
231
215
  return parsed;
232
216
  } catch {
217
+ opts?.onCorrupt?.();
233
218
  if (isDevEnvironment()) {
234
219
  console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
235
220
  }
@@ -286,8 +271,45 @@ var PAGE_ALLOWED_CHILD_TYPES = [
286
271
  "FindHotspot",
287
272
  "FindMultipleHotspots",
288
273
  "ImageSlider",
274
+ "Embed",
275
+ "Chart",
289
276
  "ProgressTracker"
290
277
  ];
278
+ var BRANCH_NODE_ALLOWED_CHILD_TYPES = [
279
+ "Text",
280
+ "Heading",
281
+ "Image",
282
+ "Video",
283
+ "Scenario",
284
+ "Reflection",
285
+ "Quiz",
286
+ "KnowledgeCheck",
287
+ "TrueFalse",
288
+ "FillInTheBlanks",
289
+ "DragAndDrop",
290
+ "DragTheWords",
291
+ "MarkTheWords",
292
+ "Summary",
293
+ "ImagePairing",
294
+ "ImageSequencing",
295
+ "MemoryGame",
296
+ "InformationWall",
297
+ "ParallaxSlideshow",
298
+ "Questionnaire",
299
+ "Essay",
300
+ "ArithmeticQuiz",
301
+ "Accordion",
302
+ "DialogCards",
303
+ "Flashcards",
304
+ "ImageHotspots",
305
+ "FindHotspot",
306
+ "FindMultipleHotspots",
307
+ "ImageSlider",
308
+ "Embed",
309
+ "Chart",
310
+ "BranchChoice"
311
+ ];
312
+ var BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES = ["BranchNode"];
291
313
  var INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES = ["Page"];
292
314
  var SLIDE_ALLOWED_CHILD_TYPES = [
293
315
  "Text",
@@ -318,7 +340,9 @@ var SLIDE_ALLOWED_CHILD_TYPES = [
318
340
  "ImageHotspots",
319
341
  "FindHotspot",
320
342
  "FindMultipleHotspots",
321
- "ImageSlider"
343
+ "ImageSlider",
344
+ "Embed",
345
+ "Chart"
322
346
  ];
323
347
  var SLIDE_DECK_ALLOWED_CHILD_TYPES = ["Slide"];
324
348
  var TIMED_CUE_ALLOWED_CHILD_TYPES = [
@@ -360,7 +384,9 @@ var ALLOWLISTS = {
360
384
  SlideDeck: SLIDE_DECK_ALLOWED_CHILD_TYPES,
361
385
  TimedCue: TIMED_CUE_ALLOWED_CHILD_TYPES,
362
386
  InteractiveVideo: INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
363
- AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES
387
+ AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
388
+ BranchingScenario: BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
389
+ BranchNode: BRANCH_NODE_ALLOWED_CHILD_TYPES
364
390
  };
365
391
  var COMPOUND_MAX_NESTING_DEPTH = {
366
392
  Page: 1,
@@ -369,7 +395,9 @@ var COMPOUND_MAX_NESTING_DEPTH = {
369
395
  SlideDeck: 2,
370
396
  TimedCue: 1,
371
397
  InteractiveVideo: 2,
372
- AssessmentSequence: 1
398
+ AssessmentSequence: 1,
399
+ BranchingScenario: 2,
400
+ BranchNode: 1
373
401
  };
374
402
  function getAllowedChildTypes(parent) {
375
403
  return ALLOWLISTS[parent];
@@ -380,6 +408,82 @@ function isChildTypeAllowed(parent, childType) {
380
408
  var ACCORDION_FORBIDDEN_CHILD_TYPES = ["Accordion"];
381
409
  var BLOCKS_14_PAGE_SLIDE = PAGE_AND_SLIDE_14_BLOCKS;
382
410
 
411
+ // src/branchGraph.ts
412
+ function validateBranchGraph(startNodeId, nodes) {
413
+ const issues = [];
414
+ if (nodes.length === 0) {
415
+ issues.push({ code: "empty_graph", message: "Branch graph has no nodes" });
416
+ return { ok: false, issues, reachableNodeIds: [] };
417
+ }
418
+ const nodeIds = /* @__PURE__ */ new Set();
419
+ for (const node of nodes) {
420
+ if (nodeIds.has(node.nodeId)) {
421
+ issues.push({
422
+ code: "duplicate_node_id",
423
+ message: `Duplicate nodeId "${node.nodeId}"`,
424
+ nodeId: node.nodeId
425
+ });
426
+ }
427
+ nodeIds.add(node.nodeId);
428
+ }
429
+ if (!nodeIds.has(startNodeId)) {
430
+ issues.push({
431
+ code: "start_not_found",
432
+ message: `startNodeId "${startNodeId}" does not match any BranchNode`,
433
+ nodeId: startNodeId
434
+ });
435
+ }
436
+ if (nodes.length > 1 && nodeIds.has(startNodeId)) {
437
+ const startNode = nodes.find((n) => n.nodeId === startNodeId);
438
+ if (startNode && startNode.choices.length === 0) {
439
+ issues.push({
440
+ code: "start_no_choices",
441
+ message: `startNodeId "${startNodeId}" has no BranchChoice children in a multi-node scenario`,
442
+ nodeId: startNodeId
443
+ });
444
+ }
445
+ }
446
+ for (const node of nodes) {
447
+ for (const choice of node.choices) {
448
+ if (!nodeIds.has(choice.targetNodeId)) {
449
+ issues.push({
450
+ code: "unknown_target",
451
+ message: `Choice from "${node.nodeId}" references unknown target "${choice.targetNodeId}"`,
452
+ nodeId: node.nodeId
453
+ });
454
+ }
455
+ }
456
+ }
457
+ const reachable = /* @__PURE__ */ new Set();
458
+ if (nodeIds.has(startNodeId)) {
459
+ const queue = [startNodeId];
460
+ while (queue.length > 0) {
461
+ const current = queue.shift();
462
+ if (reachable.has(current)) continue;
463
+ reachable.add(current);
464
+ const node = nodes.find((n) => n.nodeId === current);
465
+ if (!node) continue;
466
+ for (const choice of node.choices) {
467
+ if (!reachable.has(choice.targetNodeId)) queue.push(choice.targetNodeId);
468
+ }
469
+ }
470
+ }
471
+ for (const nodeId of nodeIds) {
472
+ if (!reachable.has(nodeId)) {
473
+ issues.push({
474
+ code: "unreachable_node",
475
+ message: `Node "${nodeId}" is not reachable from startNodeId "${startNodeId}"`,
476
+ nodeId
477
+ });
478
+ }
479
+ }
480
+ return {
481
+ ok: issues.length === 0,
482
+ issues,
483
+ reachableNodeIds: [...reachable]
484
+ };
485
+ }
486
+
383
487
  // src/telemetryCatalog.ts
384
488
  var telemetryCatalogVersion = 1;
385
489
  var TELEMETRY_EVENT_CATALOG = [
@@ -582,6 +686,22 @@ var TELEMETRY_EVENT_CATALOG_V3 = [
582
686
  dataFields: ["blockId", "fieldCount"],
583
687
  xapiVerb: "http://adlnet.gov/expapi/verbs/completed",
584
688
  urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
689
+ },
690
+ {
691
+ name: "branch_node_viewed",
692
+ description: "Learner viewed a node in a BranchingScenario",
693
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
694
+ dataFields: ["blockId", "nodeId", "nodeIndex", "nodeTitle"],
695
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
696
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:node:{nodeId}"
697
+ },
698
+ {
699
+ name: "branch_selected",
700
+ description: "Learner selected a branch choice in a BranchingScenario",
701
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
702
+ dataFields: ["blockId", "fromNodeId", "toNodeId", "label", "scoreWeight"],
703
+ xapiVerb: "http://adlnet.gov/expapi/verbs/answered",
704
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:node:{toNodeId}"
585
705
  }
586
706
  ];
587
707
  function buildTelemetryCatalogV3() {
@@ -613,22 +733,12 @@ function invokeTrackingSink(sink, event) {
613
733
  void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
614
734
  }
615
735
  }
616
- function invokePipelineSink(sinkId, emit) {
617
- let result;
618
- try {
619
- result = emit();
620
- } catch (err) {
621
- warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err);
622
- return;
623
- }
624
- if (result != null && typeof result.catch === "function") {
625
- void result.catch(
626
- (err) => warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err)
627
- );
628
- }
629
- }
630
736
 
631
737
  // src/trackingClient.ts
738
+ function eventDedupKey(event) {
739
+ const id = event.id?.trim();
740
+ return id || void 0;
741
+ }
632
742
  function createTrackingClient(opts) {
633
743
  const sink = opts?.sink;
634
744
  const batchSink = opts?.batchSink;
@@ -646,13 +756,14 @@ function createTrackingClient(opts) {
646
756
  let disposed2 = false;
647
757
  return {
648
758
  track: (event) => {
649
- if (disposed2) return;
759
+ if (disposed2) return false;
650
760
  if (sink) {
651
761
  try {
652
762
  invokeTrackingSink(sink, event);
653
763
  } catch {
654
764
  }
655
765
  }
766
+ return true;
656
767
  },
657
768
  deliver: async (event) => {
658
769
  if (disposed2) return false;
@@ -665,19 +776,28 @@ function createTrackingClient(opts) {
665
776
  };
666
777
  }
667
778
  if (!sink && !batchSink) {
668
- return { track: () => {
669
- } };
779
+ return { track: () => true };
670
780
  }
671
781
  const buffer = [];
782
+ const pendingDeliverIds = /* @__PURE__ */ new Set();
672
783
  let flushInFlight = null;
673
- let inflightExitBatch = null;
674
784
  let disposed = false;
675
785
  let disposing = false;
676
786
  let intervalId;
787
+ const clearPendingDeliverIds = (events) => {
788
+ for (const event of events) {
789
+ const key = eventDedupKey(event);
790
+ if (key) pendingDeliverIds.delete(key);
791
+ }
792
+ };
793
+ const isEventBuffered = (event) => {
794
+ const key = eventDedupKey(event);
795
+ if (!key) return false;
796
+ return buffer.some((buffered) => eventDedupKey(buffered) === key);
797
+ };
677
798
  const runFlush = () => {
678
799
  if (!buffer.length) return Promise.resolve(true);
679
800
  const events = buffer.splice(0, buffer.length);
680
- inflightExitBatch = events;
681
801
  let succeeded = false;
682
802
  return Promise.resolve().then(async () => {
683
803
  if (batchSink) {
@@ -698,12 +818,13 @@ function createTrackingClient(opts) {
698
818
  buffer.unshift(...events);
699
819
  }
700
820
  }).then(async () => {
821
+ if (succeeded) {
822
+ clearPendingDeliverIds(events);
823
+ }
701
824
  if (succeeded && buffer.length > 0 && !disposed) {
702
825
  return runFlush();
703
826
  }
704
827
  return succeeded;
705
- }).finally(() => {
706
- inflightExitBatch = null;
707
828
  });
708
829
  };
709
830
  const flush = () => {
@@ -724,18 +845,27 @@ function createTrackingClient(opts) {
724
845
  if (!delivered) break;
725
846
  }
726
847
  if (buffer.length > 0) {
848
+ const droppedCount = buffer.length;
727
849
  if (isDevEnvironment()) {
728
850
  console.warn(
729
- `[lessonkit] dropped ${buffer.length} buffered telemetry event(s) after dispose flush cap`
851
+ `[lessonkit] dropped ${droppedCount} buffered telemetry event(s) after dispose flush cap`
730
852
  );
731
853
  }
854
+ for (let i = 0; i < droppedCount; i++) {
855
+ opts?.onBufferDrop?.();
856
+ }
732
857
  buffer.length = 0;
858
+ pendingDeliverIds.clear();
733
859
  }
734
860
  };
735
861
  intervalId = flushIntervalMs > 0 ? globalThis.setInterval(() => void flush(), flushIntervalMs) : void 0;
736
862
  intervalId?.unref?.();
737
863
  const track = (event) => {
738
- if (disposed || disposing) return;
864
+ if (disposed || disposing) return false;
865
+ const key = eventDedupKey(event);
866
+ if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
867
+ return true;
868
+ }
739
869
  if (buffer.length >= maxBufferSize) {
740
870
  opts?.onBufferDrop?.();
741
871
  if (!warnedBufferCap && isDevEnvironment()) {
@@ -744,29 +874,37 @@ function createTrackingClient(opts) {
744
874
  `[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
745
875
  );
746
876
  }
747
- return;
877
+ return false;
748
878
  }
749
879
  buffer.push(event);
750
880
  if (buffer.length >= maxBatchSize) void flush();
881
+ return true;
751
882
  };
752
883
  return {
753
884
  track,
754
885
  deliver: async (event) => {
755
- track(event);
886
+ const key = eventDedupKey(event);
887
+ if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
888
+ return flush();
889
+ }
890
+ if (!track(event)) return false;
891
+ if (key) pendingDeliverIds.add(key);
756
892
  return flush();
757
893
  },
758
894
  flush,
759
895
  flushOnExit: opts?.exitBatchSink ? () => {
760
- const fromBuffer = buffer.splice(0, buffer.length);
761
- const fromInflight = inflightExitBatch ? [...inflightExitBatch] : [];
762
- const events = [...fromInflight, ...fromBuffer];
896
+ const events = buffer.splice(0, buffer.length);
763
897
  if (!events.length) return;
764
898
  try {
765
899
  const result = opts.exitBatchSink(events);
766
900
  if (result != null && typeof result.catch === "function") {
767
- void result.catch(() => {
901
+ void result.then(() => {
902
+ clearPendingDeliverIds(events);
903
+ }).catch(() => {
768
904
  buffer.unshift(...events);
769
905
  });
906
+ } else {
907
+ clearPendingDeliverIds(events);
770
908
  }
771
909
  } catch {
772
910
  buffer.unshift(...events);
@@ -788,21 +926,44 @@ function createTrackingClient(opts) {
788
926
  }
789
927
 
790
928
  // src/telemetryPipeline.ts
791
- function invokeSink(sink, event, emitCtx) {
792
- invokePipelineSink(sink.id, () => sink.emit(event, emitCtx));
929
+ var LIFECYCLE_TELEMETRY_EVENTS = /* @__PURE__ */ new Set([
930
+ "course_started",
931
+ "course_completed",
932
+ "lesson_started",
933
+ "lesson_completed",
934
+ "lesson_time_on_task"
935
+ ]);
936
+ function isLifecycleTelemetryEvent(name) {
937
+ return LIFECYCLE_TELEMETRY_EVENTS.has(name);
938
+ }
939
+ async function invokeSink(sink, event, emitCtx) {
940
+ let result;
941
+ try {
942
+ result = sink.emit(event, emitCtx);
943
+ } catch (err) {
944
+ warnDev(`[lessonkit] telemetry sink "${sink.id}" failed:`, err);
945
+ return;
946
+ }
947
+ if (result != null && typeof result.then === "function") {
948
+ try {
949
+ await result;
950
+ } catch (err) {
951
+ warnDev(`[lessonkit] telemetry sink "${sink.id}" failed:`, err);
952
+ }
953
+ }
793
954
  }
794
955
  function createTelemetryPipeline(sinks) {
795
956
  const list = [...sinks];
796
957
  return {
797
958
  sinks: list,
798
- emit(event, ctx) {
959
+ async emit(event, ctx) {
799
960
  const emitCtx = ctx ?? {
800
961
  courseId: event.courseId,
801
962
  sessionId: event.sessionId,
802
963
  attemptId: event.attemptId
803
964
  };
804
965
  for (const sink of list) {
805
- invokeSink(sink, event, emitCtx);
966
+ await invokeSink(sink, event, emitCtx);
806
967
  }
807
968
  }
808
969
  };
@@ -841,6 +1002,11 @@ function createProgressController() {
841
1002
  }
842
1003
  return { didComplete: false };
843
1004
  }
1005
+ if (!lessonStartTimes.has(lessonId) && isDevEnvironment()) {
1006
+ console.warn(
1007
+ `[lessonkit] completeLesson("${lessonId}") called without activating the lesson first`
1008
+ );
1009
+ }
844
1010
  completedLessonIds = new Set(completedLessonIds).add(lessonId);
845
1011
  if (activeLessonId === lessonId) {
846
1012
  activeLessonId = void 0;
@@ -874,6 +1040,20 @@ function warnDuplicatePlugin(id) {
874
1040
  if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
875
1041
  console.warn(`[lessonkit] plugin id "${id}" was registered more than once; using the latest definition`);
876
1042
  }
1043
+ function stableUserHash(user) {
1044
+ if (!user) return "";
1045
+ const keys = Object.keys(user).sort();
1046
+ const normalized = {};
1047
+ for (const key of keys) {
1048
+ normalized[key] = user[key];
1049
+ }
1050
+ let h = 0;
1051
+ const serialized = JSON.stringify(normalized);
1052
+ for (let i = 0; i < serialized.length; i++) {
1053
+ h = Math.imul(31, h) + serialized.charCodeAt(i) >>> 0;
1054
+ }
1055
+ return h.toString(36);
1056
+ }
877
1057
  function createPluginRegistry(plugins = []) {
878
1058
  const registry = /* @__PURE__ */ new Map();
879
1059
  for (const plugin of plugins) {
@@ -915,7 +1095,7 @@ function createPluginRegistry(plugins = []) {
915
1095
  const composeTrackingSink = (sink, ctxSource) => {
916
1096
  if (!sink) return void 0;
917
1097
  const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
918
- const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${ctx.user?.id ?? ""}`;
1098
+ const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${stableUserHash(ctx.user)}`;
919
1099
  const layers = [];
920
1100
  let composed = sink;
921
1101
  for (const plugin of list) {
@@ -980,6 +1160,7 @@ function createLessonkitRuntime(config, ports = {}) {
980
1160
  let courseId = configSnapshot.courseId;
981
1161
  let progress = createProgressController();
982
1162
  let pluginHost = resolvePluginHost(configSnapshot.plugins);
1163
+ let disposed = false;
983
1164
  const getPluginCtx = () => buildPluginContext({
984
1165
  courseId,
985
1166
  sessionId,
@@ -1013,28 +1194,24 @@ function createLessonkitRuntime(config, ports = {}) {
1013
1194
  if (!event) return null;
1014
1195
  return applyPluginsToEvent(event);
1015
1196
  };
1016
- const wrapEmitFn = (emitFn) => {
1017
- return (name, data, lessonId) => {
1018
- const event = buildAndApply(name, data, lessonId);
1019
- if (event === null) return;
1020
- const eventLessonId = "lessonId" in event ? event.lessonId : lessonId;
1021
- const eventData = "data" in event ? event.data : data;
1022
- emitFn(event.name, eventData, eventLessonId);
1023
- };
1197
+ const emitLifecycleEvent = (emitFn, name, data, lessonId) => {
1198
+ const event = buildAndApply(name, data, lessonId);
1199
+ if (event) emitFn(event);
1024
1200
  };
1025
1201
  syncSessionFromConfig(configSnapshot);
1026
1202
  const track = (name, data, emit, lessonId) => {
1203
+ if (disposed) return;
1027
1204
  const event = buildAndApply(name, data, lessonId);
1028
1205
  if (!event) return;
1029
1206
  emit(event);
1030
1207
  };
1031
1208
  const emitLessonCompletedEvents = (lessonId, durationMs, emitFn) => {
1032
- const wrapped = wrapEmitFn(emitFn);
1033
- wrapped("lesson_completed", { lessonId, durationMs }, lessonId);
1209
+ emitLifecycleEvent(emitFn, "lesson_completed", { lessonId, durationMs }, lessonId);
1034
1210
  if (durationMs !== void 0) {
1035
- wrapped("lesson_time_on_task", { lessonId, durationMs }, lessonId);
1211
+ emitLifecycleEvent(emitFn, "lesson_time_on_task", { lessonId, durationMs }, lessonId);
1036
1212
  }
1037
1213
  };
1214
+ const autoCompleteOnLessonSwitch = () => configSnapshot.autoCompleteOnLessonSwitch ?? true;
1038
1215
  return {
1039
1216
  get config() {
1040
1217
  return configSnapshot;
@@ -1047,7 +1224,12 @@ function createLessonkitRuntime(config, ports = {}) {
1047
1224
  },
1048
1225
  getProgressState: () => progress.getState(),
1049
1226
  getSession,
1227
+ migrateSessionMarks(fromSessionId, toSessionId) {
1228
+ if (disposed) return;
1229
+ migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId);
1230
+ },
1050
1231
  updateConfig(next) {
1232
+ if (disposed) return;
1051
1233
  const previousCourseId = courseId;
1052
1234
  const sessionKeyBefore = JSON.stringify({ sessionId, attemptId, user });
1053
1235
  if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
@@ -1055,6 +1237,9 @@ function createLessonkitRuntime(config, ports = {}) {
1055
1237
  if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
1056
1238
  configSnapshot.runtimeVersion = next.runtimeVersion;
1057
1239
  }
1240
+ if (next.autoCompleteOnLessonSwitch !== void 0) {
1241
+ configSnapshot.autoCompleteOnLessonSwitch = next.autoCompleteOnLessonSwitch;
1242
+ }
1058
1243
  if (next.session !== void 0) {
1059
1244
  configSnapshot.session = { ...configSnapshot.session, ...next.session };
1060
1245
  }
@@ -1076,14 +1261,14 @@ function createLessonkitRuntime(config, ports = {}) {
1076
1261
  }
1077
1262
  },
1078
1263
  setActiveLesson(lessonId, emitFn) {
1079
- const wrapped = wrapEmitFn(emitFn);
1264
+ if (disposed) return;
1080
1265
  const current = progress.getState();
1081
1266
  if (current.activeLessonId === lessonId) return;
1082
1267
  const previous = current.activeLessonId;
1083
- if (previous && previous !== lessonId && !current.completedLessonIds.has(previous)) {
1268
+ if (autoCompleteOnLessonSwitch() && previous && previous !== lessonId && !current.completedLessonIds.has(previous)) {
1084
1269
  const completed = progress.completeLesson(previous, clock.nowMs());
1085
1270
  if (completed.didComplete) {
1086
- emitLessonCompletedEvents(previous, completed.durationMs, wrapped);
1271
+ emitLessonCompletedEvents(previous, completed.durationMs, emitFn);
1087
1272
  }
1088
1273
  }
1089
1274
  if (current.completedLessonIds.has(lessonId)) {
@@ -1091,38 +1276,47 @@ function createLessonkitRuntime(config, ports = {}) {
1091
1276
  return;
1092
1277
  }
1093
1278
  progress.setActiveLesson(lessonId, clock.nowMs());
1094
- wrapped("lesson_started", { lessonId }, lessonId);
1279
+ emitLifecycleEvent(emitFn, "lesson_started", { lessonId }, lessonId);
1095
1280
  },
1096
1281
  completeLesson(lessonId, emitFn) {
1282
+ if (disposed) return;
1097
1283
  completeLessonWithTelemetry({
1098
1284
  progress,
1099
1285
  lessonId,
1100
1286
  nowMs: clock.nowMs(),
1101
- emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn))
1287
+ emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn)
1102
1288
  });
1103
1289
  },
1104
1290
  completeCourse(emitFn) {
1291
+ if (disposed) return;
1105
1292
  completeCourseWithTelemetry({
1106
1293
  progress,
1107
1294
  nowMs: clock.nowMs(),
1108
- emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn)),
1109
- emitCourseCompleted: () => wrapEmitFn(emitFn)("course_completed")
1295
+ emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn),
1296
+ emitCourseCompleted: () => emitLifecycleEvent(emitFn, "course_completed")
1110
1297
  });
1111
1298
  },
1112
1299
  track,
1113
1300
  scoreAssessment(input, lessonId) {
1114
- if (!pluginHost) return null;
1301
+ if (disposed || !pluginHost) return null;
1115
1302
  return pluginHost.scoreAssessment(
1116
1303
  { ...input, lessonId: input.lessonId ?? lessonId },
1117
1304
  getPluginCtx()
1118
1305
  );
1119
1306
  },
1120
1307
  resetForCourseChange(nextCourseId) {
1308
+ if (disposed) return;
1121
1309
  configSnapshot.courseId = nextCourseId;
1122
1310
  courseId = nextCourseId;
1123
1311
  progress = createProgressController();
1312
+ pluginHost?.disposeAll();
1313
+ if (!configSnapshot.deferPluginSetup) {
1314
+ pluginHost?.setupAll(getPluginCtx());
1315
+ }
1124
1316
  },
1125
1317
  dispose() {
1318
+ if (disposed) return;
1319
+ disposed = true;
1126
1320
  pluginHost?.disposeAll();
1127
1321
  }
1128
1322
  };
@@ -1142,6 +1336,8 @@ export {
1142
1336
  ACCORDION_FORBIDDEN_CHILD_TYPES,
1143
1337
  ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
1144
1338
  BLOCKS_14_PAGE_SLIDE,
1339
+ BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
1340
+ BRANCH_NODE_ALLOWED_CHILD_TYPES,
1145
1341
  COMPOUND_MAX_NESTING_DEPTH,
1146
1342
  COMPOUND_RESUME_SCHEMA_VERSION,
1147
1343
  ID_MAX_LENGTH,
@@ -1191,11 +1387,14 @@ export {
1191
1387
  hasCourseStarted,
1192
1388
  hasCourseStartedEmittedToTracking,
1193
1389
  hasCourseStartedPipelineDelivered,
1390
+ hasCourseStartedXapiSent,
1194
1391
  isChildTypeAllowed,
1392
+ isLifecycleTelemetryEvent,
1195
1393
  loadCompoundState,
1196
1394
  markCourseStarted,
1197
1395
  markCourseStartedEmittedToTracking,
1198
1396
  markCourseStartedPipelineDelivered,
1397
+ markCourseStartedXapiSent,
1199
1398
  migrateCourseStartedMark,
1200
1399
  nowIso,
1201
1400
  parseBlockId,
@@ -1214,5 +1413,6 @@ export {
1214
1413
  telemetryCatalogVersion,
1215
1414
  tryBuildTelemetryEvent,
1216
1415
  tryEmitCourseStarted,
1416
+ validateBranchGraph,
1217
1417
  validateId
1218
1418
  };