@lessonkit/core 1.3.1 → 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.cjs CHANGED
@@ -22,11 +22,15 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  ACCORDION_FORBIDDEN_CHILD_TYPES: () => ACCORDION_FORBIDDEN_CHILD_TYPES,
24
24
  ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES: () => ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
25
+ BLOCKS_14_PAGE_SLIDE: () => BLOCKS_14_PAGE_SLIDE,
26
+ BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES: () => BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
27
+ BRANCH_NODE_ALLOWED_CHILD_TYPES: () => BRANCH_NODE_ALLOWED_CHILD_TYPES,
25
28
  COMPOUND_MAX_NESTING_DEPTH: () => COMPOUND_MAX_NESTING_DEPTH,
26
29
  COMPOUND_RESUME_SCHEMA_VERSION: () => COMPOUND_RESUME_SCHEMA_VERSION,
27
30
  ID_MAX_LENGTH: () => ID_MAX_LENGTH,
28
31
  ID_PATTERN: () => ID_PATTERN,
29
32
  INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES: () => INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
33
+ INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES: () => INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
30
34
  PAGE_ALLOWED_CHILD_TYPES: () => PAGE_ALLOWED_CHILD_TYPES,
31
35
  SESSION_STORAGE_KEY: () => SESSION_STORAGE_KEY,
32
36
  SLIDE_ALLOWED_CHILD_TYPES: () => SLIDE_ALLOWED_CHILD_TYPES,
@@ -34,6 +38,7 @@ __export(index_exports, {
34
38
  TELEMETRY_EVENT_CATALOG: () => TELEMETRY_EVENT_CATALOG,
35
39
  TELEMETRY_EVENT_CATALOG_V2: () => TELEMETRY_EVENT_CATALOG_V2,
36
40
  TELEMETRY_EVENT_CATALOG_V3: () => TELEMETRY_EVENT_CATALOG_V3,
41
+ TIMED_CUE_ALLOWED_CHILD_TYPES: () => TIMED_CUE_ALLOWED_CHILD_TYPES,
37
42
  assertNever: () => assertNever,
38
43
  assertValidId: () => assertValidId,
39
44
  buildCourseStartedTelemetryEvent: () => buildCourseStartedTelemetryEvent,
@@ -69,11 +74,14 @@ __export(index_exports, {
69
74
  hasCourseStarted: () => hasCourseStarted,
70
75
  hasCourseStartedEmittedToTracking: () => hasCourseStartedEmittedToTracking,
71
76
  hasCourseStartedPipelineDelivered: () => hasCourseStartedPipelineDelivered,
77
+ hasCourseStartedXapiSent: () => hasCourseStartedXapiSent,
72
78
  isChildTypeAllowed: () => isChildTypeAllowed,
79
+ isLifecycleTelemetryEvent: () => isLifecycleTelemetryEvent,
73
80
  loadCompoundState: () => loadCompoundState,
74
81
  markCourseStarted: () => markCourseStarted,
75
82
  markCourseStartedEmittedToTracking: () => markCourseStartedEmittedToTracking,
76
83
  markCourseStartedPipelineDelivered: () => markCourseStartedPipelineDelivered,
84
+ markCourseStartedXapiSent: () => markCourseStartedXapiSent,
77
85
  migrateCourseStartedMark: () => migrateCourseStartedMark,
78
86
  nowIso: () => nowIso,
79
87
  parseBlockId: () => parseBlockId,
@@ -92,6 +100,7 @@ __export(index_exports, {
92
100
  telemetryCatalogVersion: () => telemetryCatalogVersion,
93
101
  tryBuildTelemetryEvent: () => tryBuildTelemetryEvent,
94
102
  tryEmitCourseStarted: () => tryEmitCourseStarted,
103
+ validateBranchGraph: () => validateBranchGraph,
95
104
  validateId: () => validateId
96
105
  });
97
106
  module.exports = __toCommonJS(index_exports);
@@ -169,12 +178,26 @@ function uniqueFallbackId(input, usedIds) {
169
178
  const hash = shortHash(input);
170
179
  for (let n = 0; n < 100; n++) {
171
180
  const candidate = (n === 0 ? `id-${hash}` : `id-${hash}-${n}`).slice(0, 64);
172
- const validated2 = validateId(candidate);
173
- if (validated2.ok && !usedIds.has(validated2.id)) return validated2.id;
181
+ const validated = validateId(candidate);
182
+ if (validated.ok && !usedIds.has(validated.id)) return validated.id;
183
+ }
184
+ for (let attempt = 0; attempt < 100; attempt++) {
185
+ const randomSuffix = Math.random().toString(36).slice(2, 8);
186
+ const candidate = `id-${hash}-${randomSuffix}`.slice(0, 64);
187
+ const validated = validateId(candidate);
188
+ if (validated.ok && !usedIds.has(validated.id)) return validated.id;
174
189
  }
175
190
  const timed = `id-${hash}-${Date.now().toString(36)}`.slice(0, 64);
176
- const validated = validateId(timed);
177
- return validated.ok ? validated.id : `id-${hash}`;
191
+ const timedValidated = validateId(timed);
192
+ if (timedValidated.ok && !usedIds.has(timedValidated.id)) return timedValidated.id;
193
+ const cryptoApi = globalThis.crypto;
194
+ for (let attempt = 0; attempt < 1e3; attempt++) {
195
+ const suffix = typeof cryptoApi?.randomUUID === "function" ? cryptoApi.randomUUID().replace(/-/g, "").slice(0, 12) : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
196
+ const candidate = `id-${hash}-${suffix}`.slice(0, 64);
197
+ const validated = validateId(candidate);
198
+ if (validated.ok && !usedIds.has(validated.id)) return validated.id;
199
+ }
200
+ throw new Error(`[lessonkit] unable to derive unique id for input: ${input.slice(0, 32)}`);
178
201
  }
179
202
  function slugifyId(input) {
180
203
  const slug = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-").slice(0, 64);
@@ -216,6 +239,13 @@ function buildLessonkitUrn(parts) {
216
239
  }
217
240
  urn += `:block:${blockId}`;
218
241
  }
242
+ if (parts.nodeId !== void 0) {
243
+ const nodeId = assertValidId(parts.nodeId, "blockId");
244
+ if (parts.blockId === void 0) {
245
+ throw new Error("buildLessonkitUrn: nodeId requires blockId");
246
+ }
247
+ urn += `:node:${nodeId}`;
248
+ }
219
249
  return urn;
220
250
  }
221
251
 
@@ -260,19 +290,25 @@ function isPlainSerializableChildState(value) {
260
290
  (entry) => isValidChildResumeValue(entry)
261
291
  );
262
292
  }
263
- function parseCompoundResumeState(raw) {
293
+ function parseCompoundResumeState(raw, opts) {
264
294
  if (!raw || typeof raw !== "object") return null;
265
295
  const obj = raw;
266
296
  if (obj.schemaVersion !== COMPOUND_RESUME_SCHEMA_VERSION) return null;
267
297
  if (typeof obj.activePageIndex !== "number" || !Number.isFinite(obj.activePageIndex)) return null;
268
298
  const childStates = {};
299
+ const droppedChildKeys = [];
269
300
  if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
270
301
  for (const [key, value] of Object.entries(obj.childStates)) {
271
302
  if (isPlainSerializableChildState(value)) {
272
303
  childStates[key] = value;
304
+ } else {
305
+ droppedChildKeys.push(key);
273
306
  }
274
307
  }
275
308
  }
309
+ if (droppedChildKeys.length > 0) {
310
+ opts?.onDroppedChildKeys?.(droppedChildKeys);
311
+ }
276
312
  const activeChapterIndex = typeof obj.activeChapterIndex === "number" && Number.isFinite(obj.activeChapterIndex) ? obj.activeChapterIndex : void 0;
277
313
  return {
278
314
  schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
@@ -297,17 +333,21 @@ var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
297
333
  function compoundStateStorageKey(courseId, compoundId) {
298
334
  return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
299
335
  }
300
- function loadCompoundState(storage, courseId, compoundId) {
336
+ function loadCompoundState(storage, courseId, compoundId, opts) {
301
337
  const key = compoundStateStorageKey(courseId, compoundId);
302
338
  const raw = storage.getItem(key);
303
339
  if (!raw) return null;
304
340
  try {
305
- const parsed = parseCompoundResumeState(JSON.parse(raw));
306
- if (parsed === null && isDevEnvironment()) {
307
- console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
341
+ const parsed = parseCompoundResumeState(JSON.parse(raw), opts);
342
+ if (parsed === null) {
343
+ opts?.onCorrupt?.();
344
+ if (isDevEnvironment()) {
345
+ console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
346
+ }
308
347
  }
309
348
  return parsed;
310
349
  } catch {
350
+ opts?.onCorrupt?.();
311
351
  if (isDevEnvironment()) {
312
352
  console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
313
353
  }
@@ -322,10 +362,23 @@ function clearCompoundState(storage, courseId, compoundId) {
322
362
  }
323
363
 
324
364
  // src/compoundAllowlists.ts
365
+ var PAGE_AND_SLIDE_14_BLOCKS = [
366
+ "Video",
367
+ "Summary",
368
+ "ImagePairing",
369
+ "ImageSequencing",
370
+ "MemoryGame",
371
+ "InformationWall",
372
+ "ParallaxSlideshow",
373
+ "Questionnaire",
374
+ "Essay",
375
+ "ArithmeticQuiz"
376
+ ];
325
377
  var PAGE_ALLOWED_CHILD_TYPES = [
326
378
  "Text",
327
379
  "Heading",
328
380
  "Image",
381
+ "Video",
329
382
  "Scenario",
330
383
  "Reflection",
331
384
  "Quiz",
@@ -335,6 +388,15 @@ var PAGE_ALLOWED_CHILD_TYPES = [
335
388
  "DragAndDrop",
336
389
  "DragTheWords",
337
390
  "MarkTheWords",
391
+ "Summary",
392
+ "ImagePairing",
393
+ "ImageSequencing",
394
+ "MemoryGame",
395
+ "InformationWall",
396
+ "ParallaxSlideshow",
397
+ "Questionnaire",
398
+ "Essay",
399
+ "ArithmeticQuiz",
338
400
  "Accordion",
339
401
  "DialogCards",
340
402
  "Flashcards",
@@ -342,13 +404,51 @@ var PAGE_ALLOWED_CHILD_TYPES = [
342
404
  "FindHotspot",
343
405
  "FindMultipleHotspots",
344
406
  "ImageSlider",
407
+ "Embed",
408
+ "Chart",
345
409
  "ProgressTracker"
346
410
  ];
411
+ var BRANCH_NODE_ALLOWED_CHILD_TYPES = [
412
+ "Text",
413
+ "Heading",
414
+ "Image",
415
+ "Video",
416
+ "Scenario",
417
+ "Reflection",
418
+ "Quiz",
419
+ "KnowledgeCheck",
420
+ "TrueFalse",
421
+ "FillInTheBlanks",
422
+ "DragAndDrop",
423
+ "DragTheWords",
424
+ "MarkTheWords",
425
+ "Summary",
426
+ "ImagePairing",
427
+ "ImageSequencing",
428
+ "MemoryGame",
429
+ "InformationWall",
430
+ "ParallaxSlideshow",
431
+ "Questionnaire",
432
+ "Essay",
433
+ "ArithmeticQuiz",
434
+ "Accordion",
435
+ "DialogCards",
436
+ "Flashcards",
437
+ "ImageHotspots",
438
+ "FindHotspot",
439
+ "FindMultipleHotspots",
440
+ "ImageSlider",
441
+ "Embed",
442
+ "Chart",
443
+ "BranchChoice"
444
+ ];
445
+ var BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES = ["BranchNode"];
347
446
  var INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES = ["Page"];
348
447
  var SLIDE_ALLOWED_CHILD_TYPES = [
349
448
  "Text",
350
449
  "Heading",
351
450
  "Image",
451
+ "Video",
352
452
  "Scenario",
353
453
  "Reflection",
354
454
  "Quiz",
@@ -358,15 +458,42 @@ var SLIDE_ALLOWED_CHILD_TYPES = [
358
458
  "DragAndDrop",
359
459
  "DragTheWords",
360
460
  "MarkTheWords",
461
+ "Summary",
462
+ "ImagePairing",
463
+ "ImageSequencing",
464
+ "MemoryGame",
465
+ "InformationWall",
466
+ "ParallaxSlideshow",
467
+ "Questionnaire",
468
+ "Essay",
469
+ "ArithmeticQuiz",
361
470
  "Accordion",
362
471
  "DialogCards",
363
472
  "Flashcards",
364
473
  "ImageHotspots",
365
474
  "FindHotspot",
366
475
  "FindMultipleHotspots",
367
- "ImageSlider"
476
+ "ImageSlider",
477
+ "Embed",
478
+ "Chart"
368
479
  ];
369
480
  var SLIDE_DECK_ALLOWED_CHILD_TYPES = ["Slide"];
481
+ var TIMED_CUE_ALLOWED_CHILD_TYPES = [
482
+ "Text",
483
+ "Heading",
484
+ "Image",
485
+ "Quiz",
486
+ "TrueFalse",
487
+ "FillInTheBlanks",
488
+ "Summary",
489
+ "ImagePairing",
490
+ "ImageSequencing",
491
+ "MemoryGame",
492
+ "Questionnaire",
493
+ "Essay",
494
+ "ArithmeticQuiz"
495
+ ];
496
+ var INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES = ["TimedCue"];
370
497
  var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
371
498
  "TrueFalse",
372
499
  "FillInTheBlanks",
@@ -376,21 +503,34 @@ var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
376
503
  "Quiz",
377
504
  "KnowledgeCheck",
378
505
  "FindHotspot",
379
- "FindMultipleHotspots"
506
+ "FindMultipleHotspots",
507
+ "Summary",
508
+ "ImagePairing",
509
+ "ImageSequencing",
510
+ "ArithmeticQuiz",
511
+ "Essay"
380
512
  ];
381
513
  var ALLOWLISTS = {
382
514
  Page: PAGE_ALLOWED_CHILD_TYPES,
383
515
  InteractiveBook: INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
384
516
  Slide: SLIDE_ALLOWED_CHILD_TYPES,
385
517
  SlideDeck: SLIDE_DECK_ALLOWED_CHILD_TYPES,
386
- AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES
518
+ TimedCue: TIMED_CUE_ALLOWED_CHILD_TYPES,
519
+ InteractiveVideo: INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
520
+ AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
521
+ BranchingScenario: BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
522
+ BranchNode: BRANCH_NODE_ALLOWED_CHILD_TYPES
387
523
  };
388
524
  var COMPOUND_MAX_NESTING_DEPTH = {
389
525
  Page: 1,
390
526
  InteractiveBook: 2,
391
527
  Slide: 1,
392
528
  SlideDeck: 2,
393
- AssessmentSequence: 1
529
+ TimedCue: 1,
530
+ InteractiveVideo: 2,
531
+ AssessmentSequence: 1,
532
+ BranchingScenario: 2,
533
+ BranchNode: 1
394
534
  };
395
535
  function getAllowedChildTypes(parent) {
396
536
  return ALLOWLISTS[parent];
@@ -399,6 +539,83 @@ function isChildTypeAllowed(parent, childType) {
399
539
  return ALLOWLISTS[parent].includes(childType);
400
540
  }
401
541
  var ACCORDION_FORBIDDEN_CHILD_TYPES = ["Accordion"];
542
+ var BLOCKS_14_PAGE_SLIDE = PAGE_AND_SLIDE_14_BLOCKS;
543
+
544
+ // src/branchGraph.ts
545
+ function validateBranchGraph(startNodeId, nodes) {
546
+ const issues = [];
547
+ if (nodes.length === 0) {
548
+ issues.push({ code: "empty_graph", message: "Branch graph has no nodes" });
549
+ return { ok: false, issues, reachableNodeIds: [] };
550
+ }
551
+ const nodeIds = /* @__PURE__ */ new Set();
552
+ for (const node of nodes) {
553
+ if (nodeIds.has(node.nodeId)) {
554
+ issues.push({
555
+ code: "duplicate_node_id",
556
+ message: `Duplicate nodeId "${node.nodeId}"`,
557
+ nodeId: node.nodeId
558
+ });
559
+ }
560
+ nodeIds.add(node.nodeId);
561
+ }
562
+ if (!nodeIds.has(startNodeId)) {
563
+ issues.push({
564
+ code: "start_not_found",
565
+ message: `startNodeId "${startNodeId}" does not match any BranchNode`,
566
+ nodeId: startNodeId
567
+ });
568
+ }
569
+ if (nodes.length > 1 && nodeIds.has(startNodeId)) {
570
+ const startNode = nodes.find((n) => n.nodeId === startNodeId);
571
+ if (startNode && startNode.choices.length === 0) {
572
+ issues.push({
573
+ code: "start_no_choices",
574
+ message: `startNodeId "${startNodeId}" has no BranchChoice children in a multi-node scenario`,
575
+ nodeId: startNodeId
576
+ });
577
+ }
578
+ }
579
+ for (const node of nodes) {
580
+ for (const choice of node.choices) {
581
+ if (!nodeIds.has(choice.targetNodeId)) {
582
+ issues.push({
583
+ code: "unknown_target",
584
+ message: `Choice from "${node.nodeId}" references unknown target "${choice.targetNodeId}"`,
585
+ nodeId: node.nodeId
586
+ });
587
+ }
588
+ }
589
+ }
590
+ const reachable = /* @__PURE__ */ new Set();
591
+ if (nodeIds.has(startNodeId)) {
592
+ const queue = [startNodeId];
593
+ while (queue.length > 0) {
594
+ const current = queue.shift();
595
+ if (reachable.has(current)) continue;
596
+ reachable.add(current);
597
+ const node = nodes.find((n) => n.nodeId === current);
598
+ if (!node) continue;
599
+ for (const choice of node.choices) {
600
+ if (!reachable.has(choice.targetNodeId)) queue.push(choice.targetNodeId);
601
+ }
602
+ }
603
+ }
604
+ for (const nodeId of nodeIds) {
605
+ if (!reachable.has(nodeId)) {
606
+ issues.push({
607
+ code: "unreachable_node",
608
+ message: `Node "${nodeId}" is not reachable from startNodeId "${startNodeId}"`,
609
+ nodeId
610
+ });
611
+ }
612
+ }
613
+ return {
614
+ ok: issues.length === 0,
615
+ issues,
616
+ reachableNodeIds: [...reachable]
617
+ };
618
+ }
402
619
 
403
620
  // src/telemetryCatalog.ts
404
621
  var telemetryCatalogVersion = 1;
@@ -554,6 +771,70 @@ var TELEMETRY_EVENT_CATALOG_V3 = [
554
771
  dataFields: ["blockId", "slideIndex"],
555
772
  xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
556
773
  urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
774
+ },
775
+ {
776
+ name: "video_cue_reached",
777
+ description: "Learner reached a timed cue in an Interactive Video",
778
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
779
+ dataFields: ["blockId", "cueIndex", "atSeconds", "cueLabel"],
780
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
781
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
782
+ },
783
+ {
784
+ name: "video_segment_completed",
785
+ description: "Learner completed a timed segment in an Interactive Video",
786
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
787
+ dataFields: ["blockId", "segmentIndex", "atSeconds", "segmentLabel"],
788
+ xapiVerb: "http://adlnet.gov/expapi/verbs/completed",
789
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
790
+ },
791
+ {
792
+ name: "memory_card_flipped",
793
+ description: "Learner flipped a memory game card",
794
+ requiredFields: ["courseId", "sessionId", "timestamp"],
795
+ dataFields: ["blockId", "cardIndex", "face"],
796
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
797
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
798
+ },
799
+ {
800
+ name: "information_wall_search",
801
+ description: "Learner searched an information wall",
802
+ requiredFields: ["courseId", "sessionId", "timestamp"],
803
+ dataFields: ["blockId", "query", "resultCount"],
804
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
805
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
806
+ },
807
+ {
808
+ name: "parallax_slide_viewed",
809
+ description: "Learner viewed a slide in a parallax slideshow",
810
+ requiredFields: ["courseId", "sessionId", "timestamp"],
811
+ dataFields: ["blockId", "slideIndex"],
812
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
813
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
814
+ },
815
+ {
816
+ name: "questionnaire_submitted",
817
+ description: "Learner submitted an unscored questionnaire",
818
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
819
+ dataFields: ["blockId", "fieldCount"],
820
+ xapiVerb: "http://adlnet.gov/expapi/verbs/completed",
821
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
822
+ },
823
+ {
824
+ name: "branch_node_viewed",
825
+ description: "Learner viewed a node in a BranchingScenario",
826
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
827
+ dataFields: ["blockId", "nodeId", "nodeIndex", "nodeTitle"],
828
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
829
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:node:{nodeId}"
830
+ },
831
+ {
832
+ name: "branch_selected",
833
+ description: "Learner selected a branch choice in a BranchingScenario",
834
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
835
+ dataFields: ["blockId", "fromNodeId", "toNodeId", "label", "scoreWeight"],
836
+ xapiVerb: "http://adlnet.gov/expapi/verbs/answered",
837
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:node:{toNodeId}"
557
838
  }
558
839
  ];
559
840
  function buildTelemetryCatalogV3() {
@@ -561,34 +842,36 @@ function buildTelemetryCatalogV3() {
561
842
  }
562
843
 
563
844
  // src/internal/sinkInvoke.ts
564
- function invokeTrackingSink(sink, event) {
565
- let result;
845
+ async function invokeTrackingSinkWithResult(sink, event) {
566
846
  try {
567
- result = sink(event);
847
+ const result = sink(event);
848
+ if (result != null && typeof result.then === "function") {
849
+ await result;
850
+ }
851
+ return true;
568
852
  } catch (err) {
569
853
  warnDev("[lessonkit] tracking sink failed:", err);
570
- throw err;
571
- }
572
- if (result != null && typeof result.catch === "function") {
573
- void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
854
+ return false;
574
855
  }
575
856
  }
576
- function invokePipelineSink(sinkId, emit) {
857
+ function invokeTrackingSink(sink, event) {
577
858
  let result;
578
859
  try {
579
- result = emit();
860
+ result = sink(event);
580
861
  } catch (err) {
581
- warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err);
582
- return;
862
+ warnDev("[lessonkit] tracking sink failed:", err);
863
+ throw err;
583
864
  }
584
865
  if (result != null && typeof result.catch === "function") {
585
- void result.catch(
586
- (err) => warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err)
587
- );
866
+ void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
588
867
  }
589
868
  }
590
869
 
591
870
  // src/trackingClient.ts
871
+ function eventDedupKey(event) {
872
+ const id = event.id?.trim();
873
+ return id || void 0;
874
+ }
592
875
  function createTrackingClient(opts) {
593
876
  const sink = opts?.sink;
594
877
  const batchSink = opts?.batchSink;
@@ -606,8 +889,19 @@ function createTrackingClient(opts) {
606
889
  let disposed2 = false;
607
890
  return {
608
891
  track: (event) => {
609
- if (disposed2) return;
610
- if (sink) invokeTrackingSink(sink, event);
892
+ if (disposed2) return false;
893
+ if (sink) {
894
+ try {
895
+ invokeTrackingSink(sink, event);
896
+ } catch {
897
+ }
898
+ }
899
+ return true;
900
+ },
901
+ deliver: async (event) => {
902
+ if (disposed2) return false;
903
+ if (!sink) return true;
904
+ return invokeTrackingSinkWithResult(sink, event);
611
905
  },
612
906
  dispose: () => {
613
907
  disposed2 = true;
@@ -615,19 +909,28 @@ function createTrackingClient(opts) {
615
909
  };
616
910
  }
617
911
  if (!sink && !batchSink) {
618
- return { track: () => {
619
- } };
912
+ return { track: () => true };
620
913
  }
621
914
  const buffer = [];
915
+ const pendingDeliverIds = /* @__PURE__ */ new Set();
622
916
  let flushInFlight = null;
623
- let inflightExitBatch = null;
624
917
  let disposed = false;
625
918
  let disposing = false;
626
919
  let intervalId;
920
+ const clearPendingDeliverIds = (events) => {
921
+ for (const event of events) {
922
+ const key = eventDedupKey(event);
923
+ if (key) pendingDeliverIds.delete(key);
924
+ }
925
+ };
926
+ const isEventBuffered = (event) => {
927
+ const key = eventDedupKey(event);
928
+ if (!key) return false;
929
+ return buffer.some((buffered) => eventDedupKey(buffered) === key);
930
+ };
627
931
  const runFlush = () => {
628
932
  if (!buffer.length) return Promise.resolve(true);
629
933
  const events = buffer.splice(0, buffer.length);
630
- inflightExitBatch = events;
631
934
  let succeeded = false;
632
935
  return Promise.resolve().then(async () => {
633
936
  if (batchSink) {
@@ -648,12 +951,13 @@ function createTrackingClient(opts) {
648
951
  buffer.unshift(...events);
649
952
  }
650
953
  }).then(async () => {
954
+ if (succeeded) {
955
+ clearPendingDeliverIds(events);
956
+ }
651
957
  if (succeeded && buffer.length > 0 && !disposed) {
652
958
  return runFlush();
653
959
  }
654
960
  return succeeded;
655
- }).finally(() => {
656
- inflightExitBatch = null;
657
961
  });
658
962
  };
659
963
  const flush = () => {
@@ -674,44 +978,66 @@ function createTrackingClient(opts) {
674
978
  if (!delivered) break;
675
979
  }
676
980
  if (buffer.length > 0) {
981
+ const droppedCount = buffer.length;
677
982
  if (isDevEnvironment()) {
678
983
  console.warn(
679
- `[lessonkit] dropped ${buffer.length} buffered telemetry event(s) after dispose flush cap`
984
+ `[lessonkit] dropped ${droppedCount} buffered telemetry event(s) after dispose flush cap`
680
985
  );
681
986
  }
987
+ for (let i = 0; i < droppedCount; i++) {
988
+ opts?.onBufferDrop?.();
989
+ }
682
990
  buffer.length = 0;
991
+ pendingDeliverIds.clear();
683
992
  }
684
993
  };
685
994
  intervalId = flushIntervalMs > 0 ? globalThis.setInterval(() => void flush(), flushIntervalMs) : void 0;
686
995
  intervalId?.unref?.();
996
+ const track = (event) => {
997
+ if (disposed || disposing) return false;
998
+ const key = eventDedupKey(event);
999
+ if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
1000
+ return true;
1001
+ }
1002
+ if (buffer.length >= maxBufferSize) {
1003
+ opts?.onBufferDrop?.();
1004
+ if (!warnedBufferCap && isDevEnvironment()) {
1005
+ warnedBufferCap = true;
1006
+ console.warn(
1007
+ `[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
1008
+ );
1009
+ }
1010
+ return false;
1011
+ }
1012
+ buffer.push(event);
1013
+ if (buffer.length >= maxBatchSize) void flush();
1014
+ return true;
1015
+ };
687
1016
  return {
688
- track: (event) => {
689
- if (disposed || disposing) return;
690
- if (buffer.length >= maxBufferSize) {
691
- opts?.onBufferDrop?.();
692
- if (!warnedBufferCap && isDevEnvironment()) {
693
- warnedBufferCap = true;
694
- console.warn(
695
- `[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
696
- );
697
- }
698
- return;
1017
+ track,
1018
+ deliver: async (event) => {
1019
+ const key = eventDedupKey(event);
1020
+ if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
1021
+ return flush();
699
1022
  }
700
- buffer.push(event);
701
- if (buffer.length >= maxBatchSize) void flush();
1023
+ if (!track(event)) return false;
1024
+ if (key) pendingDeliverIds.add(key);
1025
+ return flush();
702
1026
  },
703
1027
  flush,
704
1028
  flushOnExit: opts?.exitBatchSink ? () => {
705
- const fromBuffer = buffer.splice(0, buffer.length);
706
- const fromInflight = inflightExitBatch ? [...inflightExitBatch] : [];
707
- const events = [...fromInflight, ...fromBuffer];
1029
+ const events = buffer.splice(0, buffer.length);
708
1030
  if (!events.length) return;
709
1031
  try {
710
1032
  const result = opts.exitBatchSink(events);
711
1033
  if (result != null && typeof result.catch === "function") {
712
- void result.catch(() => {
1034
+ void result.then(() => {
1035
+ clearPendingDeliverIds(events);
1036
+ }).catch(() => {
713
1037
  buffer.unshift(...events);
714
1038
  });
1039
+ } else {
1040
+ clearPendingDeliverIds(events);
715
1041
  }
716
1042
  } catch {
717
1043
  buffer.unshift(...events);
@@ -733,10 +1059,21 @@ function createTrackingClient(opts) {
733
1059
  }
734
1060
 
735
1061
  // src/ids.ts
1062
+ function randomSessionIdFallback() {
1063
+ const g = globalThis;
1064
+ if (g.crypto?.getRandomValues) {
1065
+ const bytes = new Uint8Array(16);
1066
+ g.crypto.getRandomValues(bytes);
1067
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
1068
+ }
1069
+ throw new Error(
1070
+ "[lessonkit] createSessionId requires crypto.randomUUID or crypto.getRandomValues"
1071
+ );
1072
+ }
736
1073
  function createSessionId() {
737
1074
  const g = globalThis;
738
1075
  if (g.crypto?.randomUUID) return g.crypto.randomUUID();
739
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
1076
+ return randomSessionIdFallback();
740
1077
  }
741
1078
 
742
1079
  // src/time.ts
@@ -940,6 +1277,109 @@ var TELEMETRY_EVENT_REGISTRY = {
940
1277
  data: opts.data
941
1278
  };
942
1279
  }
1280
+ },
1281
+ video_cue_reached: {
1282
+ requiresLessonId: true,
1283
+ build: (opts, base) => {
1284
+ if (opts.name !== "video_cue_reached") throw new Error("unexpected event");
1285
+ const lessonId = opts.lessonId;
1286
+ if (!lessonId) throw new Error("video_cue_reached requires active lessonId");
1287
+ return {
1288
+ name: "video_cue_reached",
1289
+ ...base,
1290
+ lessonId,
1291
+ data: opts.data
1292
+ };
1293
+ }
1294
+ },
1295
+ video_segment_completed: {
1296
+ requiresLessonId: true,
1297
+ build: (opts, base) => {
1298
+ if (opts.name !== "video_segment_completed") throw new Error("unexpected event");
1299
+ const lessonId = opts.lessonId;
1300
+ if (!lessonId) throw new Error("video_segment_completed requires active lessonId");
1301
+ return {
1302
+ name: "video_segment_completed",
1303
+ ...base,
1304
+ lessonId,
1305
+ data: opts.data
1306
+ };
1307
+ }
1308
+ },
1309
+ memory_card_flipped: {
1310
+ build: (opts, base) => {
1311
+ if (opts.name !== "memory_card_flipped") throw new Error("unexpected event");
1312
+ return {
1313
+ name: "memory_card_flipped",
1314
+ ...base,
1315
+ lessonId: opts.lessonId,
1316
+ data: opts.data
1317
+ };
1318
+ }
1319
+ },
1320
+ information_wall_search: {
1321
+ build: (opts, base) => {
1322
+ if (opts.name !== "information_wall_search") throw new Error("unexpected event");
1323
+ return {
1324
+ name: "information_wall_search",
1325
+ ...base,
1326
+ lessonId: opts.lessonId,
1327
+ data: opts.data
1328
+ };
1329
+ }
1330
+ },
1331
+ parallax_slide_viewed: {
1332
+ build: (opts, base) => {
1333
+ if (opts.name !== "parallax_slide_viewed") throw new Error("unexpected event");
1334
+ return {
1335
+ name: "parallax_slide_viewed",
1336
+ ...base,
1337
+ lessonId: opts.lessonId,
1338
+ data: opts.data
1339
+ };
1340
+ }
1341
+ },
1342
+ questionnaire_submitted: {
1343
+ requiresLessonId: true,
1344
+ build: (opts, base) => {
1345
+ if (opts.name !== "questionnaire_submitted") throw new Error("unexpected event");
1346
+ const lessonId = opts.lessonId;
1347
+ if (!lessonId) throw new Error("questionnaire_submitted requires active lessonId");
1348
+ return {
1349
+ name: "questionnaire_submitted",
1350
+ ...base,
1351
+ lessonId,
1352
+ data: opts.data
1353
+ };
1354
+ }
1355
+ },
1356
+ branch_node_viewed: {
1357
+ requiresLessonId: true,
1358
+ build: (opts, base) => {
1359
+ if (opts.name !== "branch_node_viewed") throw new Error("unexpected event");
1360
+ const lessonId = opts.lessonId;
1361
+ if (!lessonId) throw new Error("branch_node_viewed requires active lessonId");
1362
+ return {
1363
+ name: "branch_node_viewed",
1364
+ ...base,
1365
+ lessonId,
1366
+ data: opts.data
1367
+ };
1368
+ }
1369
+ },
1370
+ branch_selected: {
1371
+ requiresLessonId: true,
1372
+ build: (opts, base) => {
1373
+ if (opts.name !== "branch_selected") throw new Error("unexpected event");
1374
+ const lessonId = opts.lessonId;
1375
+ if (!lessonId) throw new Error("branch_selected requires active lessonId");
1376
+ return {
1377
+ name: "branch_selected",
1378
+ ...base,
1379
+ lessonId,
1380
+ data: opts.data
1381
+ };
1382
+ }
943
1383
  }
944
1384
  };
945
1385
  function buildTelemetryEventFromRegistry(opts) {
@@ -972,8 +1412,8 @@ function buildTelemetryEvent(opts) {
972
1412
  }
973
1413
  function tryBuildTelemetryEvent(opts) {
974
1414
  const entry = getTelemetryEventRegistryEntry(opts.name);
975
- if (entry.requiresLessonId && !opts.lessonId && entry.tryBuildMissingLessonWarning) {
976
- if (isDevEnvironment()) {
1415
+ if (entry.requiresLessonId && !opts.lessonId) {
1416
+ if (isDevEnvironment() && entry.tryBuildMissingLessonWarning) {
977
1417
  if (entry.tryBuildMissingLessonWarning === "quiz" && !warnedMissingQuizLesson) {
978
1418
  warnedMissingQuizLesson = true;
979
1419
  console.warn(
@@ -993,21 +1433,44 @@ function tryBuildTelemetryEvent(opts) {
993
1433
  }
994
1434
 
995
1435
  // src/telemetryPipeline.ts
996
- function invokeSink(sink, event, emitCtx) {
997
- invokePipelineSink(sink.id, () => sink.emit(event, emitCtx));
1436
+ var LIFECYCLE_TELEMETRY_EVENTS = /* @__PURE__ */ new Set([
1437
+ "course_started",
1438
+ "course_completed",
1439
+ "lesson_started",
1440
+ "lesson_completed",
1441
+ "lesson_time_on_task"
1442
+ ]);
1443
+ function isLifecycleTelemetryEvent(name) {
1444
+ return LIFECYCLE_TELEMETRY_EVENTS.has(name);
1445
+ }
1446
+ async function invokeSink(sink, event, emitCtx) {
1447
+ let result;
1448
+ try {
1449
+ result = sink.emit(event, emitCtx);
1450
+ } catch (err) {
1451
+ warnDev(`[lessonkit] telemetry sink "${sink.id}" failed:`, err);
1452
+ return;
1453
+ }
1454
+ if (result != null && typeof result.then === "function") {
1455
+ try {
1456
+ await result;
1457
+ } catch (err) {
1458
+ warnDev(`[lessonkit] telemetry sink "${sink.id}" failed:`, err);
1459
+ }
1460
+ }
998
1461
  }
999
1462
  function createTelemetryPipeline(sinks) {
1000
1463
  const list = [...sinks];
1001
1464
  return {
1002
1465
  sinks: list,
1003
- emit(event, ctx) {
1466
+ async emit(event, ctx) {
1004
1467
  const emitCtx = ctx ?? {
1005
1468
  courseId: event.courseId,
1006
1469
  sessionId: event.sessionId,
1007
1470
  attemptId: event.attemptId
1008
1471
  };
1009
1472
  for (const sink of list) {
1010
- invokeSink(sink, event, emitCtx);
1473
+ await invokeSink(sink, event, emitCtx);
1011
1474
  }
1012
1475
  }
1013
1476
  };
@@ -1029,14 +1492,44 @@ function createDefaultClock() {
1029
1492
  };
1030
1493
  }
1031
1494
  function createNoopStorage() {
1495
+ const memory = /* @__PURE__ */ new Map();
1032
1496
  return {
1033
- getItem: () => null,
1034
- setItem: () => true
1497
+ getItem: (key) => memory.get(key) ?? null,
1498
+ setItem: (key, value) => {
1499
+ memory.set(key, value);
1500
+ return true;
1501
+ },
1502
+ removeItem: (key) => {
1503
+ memory.delete(key);
1504
+ },
1505
+ resetForTests: () => {
1506
+ memory.clear();
1507
+ }
1035
1508
  };
1036
1509
  }
1037
1510
  function createMemoryBackedSessionStorage(session) {
1038
1511
  const memory = /* @__PURE__ */ new Map();
1512
+ const tombstones = /* @__PURE__ */ new Set();
1039
1513
  let warnedPersistFailure = false;
1514
+ const syncFromStorageEvent = (key, newValue) => {
1515
+ if (key === null) {
1516
+ memory.clear();
1517
+ tombstones.clear();
1518
+ return;
1519
+ }
1520
+ tombstones.delete(key);
1521
+ if (newValue === null) {
1522
+ memory.delete(key);
1523
+ } else {
1524
+ memory.set(key, newValue);
1525
+ }
1526
+ };
1527
+ if (typeof window !== "undefined") {
1528
+ window.addEventListener("storage", (event) => {
1529
+ if (event.storageArea !== sessionStorage) return;
1530
+ syncFromStorageEvent(event.key, event.newValue);
1531
+ });
1532
+ }
1040
1533
  const warnPersistFailure = () => {
1041
1534
  if (warnedPersistFailure) return;
1042
1535
  warnedPersistFailure = true;
@@ -1049,6 +1542,7 @@ function createMemoryBackedSessionStorage(session) {
1049
1542
  };
1050
1543
  return {
1051
1544
  getItem: (key) => {
1545
+ if (tombstones.has(key)) return null;
1052
1546
  if (memory.has(key)) return memory.get(key);
1053
1547
  try {
1054
1548
  const value = session.getItem(key);
@@ -1059,6 +1553,7 @@ function createMemoryBackedSessionStorage(session) {
1059
1553
  }
1060
1554
  },
1061
1555
  setItem: (key, value) => {
1556
+ tombstones.delete(key);
1062
1557
  memory.set(key, value);
1063
1558
  try {
1064
1559
  session.setItem(key, value);
@@ -1072,12 +1567,15 @@ function createMemoryBackedSessionStorage(session) {
1072
1567
  memory.delete(key);
1073
1568
  try {
1074
1569
  session.removeItem(key);
1570
+ tombstones.delete(key);
1075
1571
  } catch {
1076
1572
  warnPersistFailure();
1573
+ tombstones.add(key);
1077
1574
  }
1078
1575
  },
1079
1576
  resetForTests: () => {
1080
1577
  memory.clear();
1578
+ tombstones.clear();
1081
1579
  }
1082
1580
  };
1083
1581
  }
@@ -1149,6 +1647,11 @@ function createProgressController() {
1149
1647
  }
1150
1648
  return { didComplete: false };
1151
1649
  }
1650
+ if (!lessonStartTimes.has(lessonId) && isDevEnvironment()) {
1651
+ console.warn(
1652
+ `[lessonkit] completeLesson("${lessonId}") called without activating the lesson first`
1653
+ );
1654
+ }
1152
1655
  completedLessonIds = new Set(completedLessonIds).add(lessonId);
1153
1656
  if (activeLessonId === lessonId) {
1154
1657
  activeLessonId = void 0;
@@ -1169,7 +1672,6 @@ function createProgressController() {
1169
1672
  // src/session.ts
1170
1673
  var SESSION_STORAGE_KEY = "lessonkit:sessionId";
1171
1674
  var volatileSessionIds = /* @__PURE__ */ new WeakMap();
1172
- var sharedVolatileSessionId = null;
1173
1675
  function isDevEnvironment2() {
1174
1676
  const g = globalThis;
1175
1677
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
@@ -1180,10 +1682,23 @@ function getTabSessionId(storage) {
1180
1682
  var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
1181
1683
  var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
1182
1684
  var COURSE_STARTED_PIPELINE_PREFIX = "lessonkit:course_started_pipeline:";
1685
+ var COURSE_STARTED_XAPI_PREFIX = "lessonkit:course_started_xapi:";
1686
+ function sessionKeySegment(sessionId) {
1687
+ const validated = validateId(sessionId);
1688
+ return validated.ok ? validated.id : encodeURIComponent(sessionId);
1689
+ }
1183
1690
  function resolveSessionId(storage, provided) {
1184
1691
  if (provided !== void 0) {
1185
1692
  const trimmed = provided.trim();
1186
- if (trimmed.length > 0) return trimmed;
1693
+ if (trimmed.length > 0) {
1694
+ const validated = validateId(trimmed);
1695
+ if (validated.ok) return validated.id;
1696
+ if (isDevEnvironment2()) {
1697
+ console.warn(
1698
+ `[lessonkit] Invalid sessionId "${trimmed}"; falling back to tab or generated id.`
1699
+ );
1700
+ }
1701
+ }
1187
1702
  }
1188
1703
  const existing = storage.getItem(SESSION_STORAGE_KEY);
1189
1704
  if (existing) return existing;
@@ -1192,27 +1707,27 @@ function resolveSessionId(storage, provided) {
1192
1707
  const id = createSessionId();
1193
1708
  const persisted = storage.setItem(SESSION_STORAGE_KEY, id);
1194
1709
  if (!persisted) {
1195
- if (!sharedVolatileSessionId) {
1196
- sharedVolatileSessionId = id;
1197
- }
1198
- volatileSessionIds.set(storage, sharedVolatileSessionId);
1710
+ volatileSessionIds.set(storage, id);
1199
1711
  if (isDevEnvironment2()) {
1200
1712
  console.warn(
1201
- "[lessonkit] session id could not be persisted; reusing in-memory id for this tab."
1713
+ "[lessonkit] session id could not be persisted; using in-memory id for this storage."
1202
1714
  );
1203
1715
  }
1204
- return sharedVolatileSessionId;
1716
+ return id;
1205
1717
  }
1206
1718
  return id;
1207
1719
  }
1208
1720
  function courseStartedStorageKey(sessionId, courseId) {
1209
- return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
1721
+ return `${COURSE_STARTED_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
1210
1722
  }
1211
1723
  function courseStartedTrackingStorageKey(sessionId, courseId) {
1212
- return `${COURSE_STARTED_TRACKING_PREFIX}${sessionId}:${courseId ?? ""}`;
1724
+ return `${COURSE_STARTED_TRACKING_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
1213
1725
  }
1214
1726
  function courseStartedPipelineStorageKey(sessionId, courseId) {
1215
- return `${COURSE_STARTED_PIPELINE_PREFIX}${sessionId}:${courseId ?? ""}`;
1727
+ return `${COURSE_STARTED_PIPELINE_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
1728
+ }
1729
+ function courseStartedXapiStorageKey(sessionId, courseId) {
1730
+ return `${COURSE_STARTED_XAPI_PREFIX}${sessionKeySegment(sessionId)}:${courseId ?? ""}`;
1216
1731
  }
1217
1732
  function hasCourseStarted(storage, sessionId, courseId) {
1218
1733
  if (!courseId) return false;
@@ -1238,49 +1753,82 @@ function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
1238
1753
  if (!courseId) return false;
1239
1754
  return storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
1240
1755
  }
1756
+ function hasCourseStartedXapiSent(storage, sessionId, courseId) {
1757
+ if (!courseId) return false;
1758
+ return storage.getItem(courseStartedXapiStorageKey(sessionId, courseId)) === "1";
1759
+ }
1760
+ function markCourseStartedXapiSent(storage, sessionId, courseId) {
1761
+ if (!courseId) return false;
1762
+ return storage.setItem(courseStartedXapiStorageKey(sessionId, courseId), "1");
1763
+ }
1241
1764
  function resetSharedVolatileSessionIdForTests() {
1242
- sharedVolatileSessionId = null;
1765
+ }
1766
+ function migrateStorageMark(storage, fromKey, toKey, hasMark) {
1767
+ if (!hasMark) return;
1768
+ if (storage.setItem(toKey, "1")) {
1769
+ storage.removeItem?.(fromKey);
1770
+ }
1243
1771
  }
1244
1772
  function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
1245
1773
  if (!courseId || fromSessionId === toSessionId) return;
1246
- if (hasCourseStarted(storage, fromSessionId, courseId)) {
1247
- markCourseStarted(storage, toSessionId, courseId);
1248
- storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
1249
- }
1250
- if (hasCourseStartedEmittedToTracking(storage, fromSessionId, courseId)) {
1251
- markCourseStartedEmittedToTracking(storage, toSessionId, courseId);
1252
- storage.removeItem?.(courseStartedTrackingStorageKey(fromSessionId, courseId));
1253
- }
1254
- if (hasCourseStartedPipelineDelivered(storage, fromSessionId, courseId)) {
1255
- markCourseStartedPipelineDelivered(storage, toSessionId, courseId);
1256
- storage.removeItem?.(courseStartedPipelineStorageKey(fromSessionId, courseId));
1257
- }
1774
+ migrateStorageMark(
1775
+ storage,
1776
+ courseStartedStorageKey(fromSessionId, courseId),
1777
+ courseStartedStorageKey(toSessionId, courseId),
1778
+ hasCourseStarted(storage, fromSessionId, courseId)
1779
+ );
1780
+ migrateStorageMark(
1781
+ storage,
1782
+ courseStartedTrackingStorageKey(fromSessionId, courseId),
1783
+ courseStartedTrackingStorageKey(toSessionId, courseId),
1784
+ hasCourseStartedEmittedToTracking(storage, fromSessionId, courseId)
1785
+ );
1786
+ migrateStorageMark(
1787
+ storage,
1788
+ courseStartedPipelineStorageKey(fromSessionId, courseId),
1789
+ courseStartedPipelineStorageKey(toSessionId, courseId),
1790
+ hasCourseStartedPipelineDelivered(storage, fromSessionId, courseId)
1791
+ );
1792
+ migrateStorageMark(
1793
+ storage,
1794
+ courseStartedXapiStorageKey(fromSessionId, courseId),
1795
+ courseStartedXapiStorageKey(toSessionId, courseId),
1796
+ hasCourseStartedXapiSent(storage, fromSessionId, courseId)
1797
+ );
1258
1798
  }
1259
1799
 
1260
1800
  // src/runtime/courseLifecycle.ts
1261
- var courseStartedEmitFlights = /* @__PURE__ */ new Set();
1801
+ var courseStartedEmitFlights = /* @__PURE__ */ new Map();
1262
1802
  function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
1263
1803
  const flightKey = `${ctx.sessionId}:${ctx.courseId}`;
1264
1804
  const marked = hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
1265
1805
  if (alreadyEmittedToSink) {
1266
- return { emitted: true, marked };
1806
+ return Promise.resolve({ emitted: true, marked });
1267
1807
  }
1268
- if (courseStartedEmitFlights.has(flightKey)) {
1269
- return { emitted: false, marked };
1808
+ const existing = courseStartedEmitFlights.get(flightKey);
1809
+ if (existing) {
1810
+ return existing;
1270
1811
  }
1271
- courseStartedEmitFlights.add(flightKey);
1272
- try {
1273
- const emitted = deps.emitCourseStartedEvent(ctx);
1274
- if (emitted && !marked) {
1275
- markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
1812
+ const flight = Promise.resolve().then(() => {
1813
+ try {
1814
+ const emitted = deps.emitCourseStartedEvent(ctx);
1815
+ if (emitted && !marked) {
1816
+ markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
1817
+ }
1818
+ return {
1819
+ emitted,
1820
+ marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
1821
+ };
1822
+ } catch {
1823
+ return { emitted: false, marked };
1824
+ } finally {
1825
+ if (courseStartedEmitFlights.get(flightKey) === flight) {
1826
+ courseStartedEmitFlights.delete(flightKey);
1827
+ }
1276
1828
  }
1277
- return {
1278
- emitted,
1279
- marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
1280
- };
1281
- } finally {
1282
- courseStartedEmitFlights.delete(flightKey);
1283
- }
1829
+ });
1830
+ courseStartedEmitFlights.set(flightKey, flight);
1831
+ return flight;
1284
1832
  }
1285
1833
  function buildCourseStartedTelemetryEvent(ctx) {
1286
1834
  return buildTelemetryEvent({
@@ -1329,6 +1877,20 @@ function warnDuplicatePlugin(id) {
1329
1877
  if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
1330
1878
  console.warn(`[lessonkit] plugin id "${id}" was registered more than once; using the latest definition`);
1331
1879
  }
1880
+ function stableUserHash(user) {
1881
+ if (!user) return "";
1882
+ const keys = Object.keys(user).sort();
1883
+ const normalized = {};
1884
+ for (const key of keys) {
1885
+ normalized[key] = user[key];
1886
+ }
1887
+ let h = 0;
1888
+ const serialized = JSON.stringify(normalized);
1889
+ for (let i = 0; i < serialized.length; i++) {
1890
+ h = Math.imul(31, h) + serialized.charCodeAt(i) >>> 0;
1891
+ }
1892
+ return h.toString(36);
1893
+ }
1332
1894
  function createPluginRegistry(plugins = []) {
1333
1895
  const registry = /* @__PURE__ */ new Map();
1334
1896
  for (const plugin of plugins) {
@@ -1370,7 +1932,7 @@ function createPluginRegistry(plugins = []) {
1370
1932
  const composeTrackingSink = (sink, ctxSource) => {
1371
1933
  if (!sink) return void 0;
1372
1934
  const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
1373
- const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${ctx.user?.id ?? ""}`;
1935
+ const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${stableUserHash(ctx.user)}`;
1374
1936
  const layers = [];
1375
1937
  let composed = sink;
1376
1938
  for (const plugin of list) {
@@ -1435,6 +1997,7 @@ function createLessonkitRuntime(config, ports = {}) {
1435
1997
  let courseId = configSnapshot.courseId;
1436
1998
  let progress = createProgressController();
1437
1999
  let pluginHost = resolvePluginHost(configSnapshot.plugins);
2000
+ let disposed = false;
1438
2001
  const getPluginCtx = () => buildPluginContext({
1439
2002
  courseId,
1440
2003
  sessionId,
@@ -1468,28 +2031,24 @@ function createLessonkitRuntime(config, ports = {}) {
1468
2031
  if (!event) return null;
1469
2032
  return applyPluginsToEvent(event);
1470
2033
  };
1471
- const wrapEmitFn = (emitFn) => {
1472
- return (name, data, lessonId) => {
1473
- const event = buildAndApply(name, data, lessonId);
1474
- if (event === null) return;
1475
- const eventLessonId = "lessonId" in event ? event.lessonId : lessonId;
1476
- const eventData = "data" in event ? event.data : data;
1477
- emitFn(event.name, eventData, eventLessonId);
1478
- };
2034
+ const emitLifecycleEvent = (emitFn, name, data, lessonId) => {
2035
+ const event = buildAndApply(name, data, lessonId);
2036
+ if (event) emitFn(event);
1479
2037
  };
1480
2038
  syncSessionFromConfig(configSnapshot);
1481
2039
  const track = (name, data, emit, lessonId) => {
2040
+ if (disposed) return;
1482
2041
  const event = buildAndApply(name, data, lessonId);
1483
2042
  if (!event) return;
1484
2043
  emit(event);
1485
2044
  };
1486
2045
  const emitLessonCompletedEvents = (lessonId, durationMs, emitFn) => {
1487
- const wrapped = wrapEmitFn(emitFn);
1488
- wrapped("lesson_completed", { lessonId, durationMs }, lessonId);
2046
+ emitLifecycleEvent(emitFn, "lesson_completed", { lessonId, durationMs }, lessonId);
1489
2047
  if (durationMs !== void 0) {
1490
- wrapped("lesson_time_on_task", { lessonId, durationMs }, lessonId);
2048
+ emitLifecycleEvent(emitFn, "lesson_time_on_task", { lessonId, durationMs }, lessonId);
1491
2049
  }
1492
2050
  };
2051
+ const autoCompleteOnLessonSwitch = () => configSnapshot.autoCompleteOnLessonSwitch ?? true;
1493
2052
  return {
1494
2053
  get config() {
1495
2054
  return configSnapshot;
@@ -1502,7 +2061,12 @@ function createLessonkitRuntime(config, ports = {}) {
1502
2061
  },
1503
2062
  getProgressState: () => progress.getState(),
1504
2063
  getSession,
2064
+ migrateSessionMarks(fromSessionId, toSessionId) {
2065
+ if (disposed) return;
2066
+ migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId);
2067
+ },
1505
2068
  updateConfig(next) {
2069
+ if (disposed) return;
1506
2070
  const previousCourseId = courseId;
1507
2071
  const sessionKeyBefore = JSON.stringify({ sessionId, attemptId, user });
1508
2072
  if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
@@ -1510,6 +2074,9 @@ function createLessonkitRuntime(config, ports = {}) {
1510
2074
  if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
1511
2075
  configSnapshot.runtimeVersion = next.runtimeVersion;
1512
2076
  }
2077
+ if (next.autoCompleteOnLessonSwitch !== void 0) {
2078
+ configSnapshot.autoCompleteOnLessonSwitch = next.autoCompleteOnLessonSwitch;
2079
+ }
1513
2080
  if (next.session !== void 0) {
1514
2081
  configSnapshot.session = { ...configSnapshot.session, ...next.session };
1515
2082
  }
@@ -1518,61 +2085,75 @@ function createLessonkitRuntime(config, ports = {}) {
1518
2085
  if (next.courseId !== void 0 && next.courseId !== previousCourseId) {
1519
2086
  progress = createProgressController();
1520
2087
  }
1521
- if (next.plugins !== void 0 && next.plugins !== pluginHost) {
2088
+ if (next.plugins !== void 0 && next.plugins !== configSnapshot.plugins) {
1522
2089
  pluginHost?.disposeAll();
1523
2090
  configSnapshot.plugins = next.plugins;
1524
2091
  pluginHost = resolvePluginHost(configSnapshot.plugins);
1525
- pluginHost?.setupAll(getPluginCtx());
2092
+ if (!configSnapshot.deferPluginSetup) {
2093
+ pluginHost?.setupAll(getPluginCtx());
2094
+ }
1526
2095
  } else if (next.session !== void 0 && sessionKeyBefore !== sessionKeyAfter && pluginHost && !configSnapshot.deferPluginSetup) {
1527
2096
  pluginHost.disposeAll();
1528
2097
  pluginHost.setupAll(getPluginCtx());
1529
2098
  }
1530
2099
  },
1531
2100
  setActiveLesson(lessonId, emitFn) {
1532
- const wrapped = wrapEmitFn(emitFn);
2101
+ if (disposed) return;
1533
2102
  const current = progress.getState();
1534
2103
  if (current.activeLessonId === lessonId) return;
1535
- if (current.completedLessonIds.has(lessonId)) {
1536
- progress.setActiveLesson(lessonId, clock.nowMs());
1537
- return;
1538
- }
1539
2104
  const previous = current.activeLessonId;
1540
- if (previous && previous !== lessonId) {
2105
+ if (autoCompleteOnLessonSwitch() && previous && previous !== lessonId && !current.completedLessonIds.has(previous)) {
1541
2106
  const completed = progress.completeLesson(previous, clock.nowMs());
1542
2107
  if (completed.didComplete) {
1543
- emitLessonCompletedEvents(previous, completed.durationMs, wrapped);
2108
+ emitLessonCompletedEvents(previous, completed.durationMs, emitFn);
1544
2109
  }
1545
2110
  }
2111
+ if (current.completedLessonIds.has(lessonId)) {
2112
+ progress.setActiveLesson(lessonId, clock.nowMs());
2113
+ return;
2114
+ }
1546
2115
  progress.setActiveLesson(lessonId, clock.nowMs());
1547
- wrapped("lesson_started", { lessonId }, lessonId);
2116
+ emitLifecycleEvent(emitFn, "lesson_started", { lessonId }, lessonId);
1548
2117
  },
1549
2118
  completeLesson(lessonId, emitFn) {
2119
+ if (disposed) return;
1550
2120
  completeLessonWithTelemetry({
1551
2121
  progress,
1552
2122
  lessonId,
1553
2123
  nowMs: clock.nowMs(),
1554
- emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn))
2124
+ emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn)
1555
2125
  });
1556
2126
  },
1557
2127
  completeCourse(emitFn) {
2128
+ if (disposed) return;
1558
2129
  completeCourseWithTelemetry({
1559
2130
  progress,
1560
2131
  nowMs: clock.nowMs(),
1561
- emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn)),
1562
- emitCourseCompleted: () => wrapEmitFn(emitFn)("course_completed")
2132
+ emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn),
2133
+ emitCourseCompleted: () => emitLifecycleEvent(emitFn, "course_completed")
1563
2134
  });
1564
2135
  },
1565
2136
  track,
1566
- scoreAssessment(input, _lessonId) {
1567
- if (!pluginHost) return null;
1568
- return pluginHost.scoreAssessment(input, getPluginCtx());
2137
+ scoreAssessment(input, lessonId) {
2138
+ if (disposed || !pluginHost) return null;
2139
+ return pluginHost.scoreAssessment(
2140
+ { ...input, lessonId: input.lessonId ?? lessonId },
2141
+ getPluginCtx()
2142
+ );
1569
2143
  },
1570
2144
  resetForCourseChange(nextCourseId) {
2145
+ if (disposed) return;
1571
2146
  configSnapshot.courseId = nextCourseId;
1572
2147
  courseId = nextCourseId;
1573
2148
  progress = createProgressController();
2149
+ pluginHost?.disposeAll();
2150
+ if (!configSnapshot.deferPluginSetup) {
2151
+ pluginHost?.setupAll(getPluginCtx());
2152
+ }
1574
2153
  },
1575
2154
  dispose() {
2155
+ if (disposed) return;
2156
+ disposed = true;
1576
2157
  pluginHost?.disposeAll();
1577
2158
  }
1578
2159
  };
@@ -1592,11 +2173,15 @@ function defineLifecyclePlugin(plugin) {
1592
2173
  0 && (module.exports = {
1593
2174
  ACCORDION_FORBIDDEN_CHILD_TYPES,
1594
2175
  ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
2176
+ BLOCKS_14_PAGE_SLIDE,
2177
+ BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
2178
+ BRANCH_NODE_ALLOWED_CHILD_TYPES,
1595
2179
  COMPOUND_MAX_NESTING_DEPTH,
1596
2180
  COMPOUND_RESUME_SCHEMA_VERSION,
1597
2181
  ID_MAX_LENGTH,
1598
2182
  ID_PATTERN,
1599
2183
  INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
2184
+ INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
1600
2185
  PAGE_ALLOWED_CHILD_TYPES,
1601
2186
  SESSION_STORAGE_KEY,
1602
2187
  SLIDE_ALLOWED_CHILD_TYPES,
@@ -1604,6 +2189,7 @@ function defineLifecyclePlugin(plugin) {
1604
2189
  TELEMETRY_EVENT_CATALOG,
1605
2190
  TELEMETRY_EVENT_CATALOG_V2,
1606
2191
  TELEMETRY_EVENT_CATALOG_V3,
2192
+ TIMED_CUE_ALLOWED_CHILD_TYPES,
1607
2193
  assertNever,
1608
2194
  assertValidId,
1609
2195
  buildCourseStartedTelemetryEvent,
@@ -1639,11 +2225,14 @@ function defineLifecyclePlugin(plugin) {
1639
2225
  hasCourseStarted,
1640
2226
  hasCourseStartedEmittedToTracking,
1641
2227
  hasCourseStartedPipelineDelivered,
2228
+ hasCourseStartedXapiSent,
1642
2229
  isChildTypeAllowed,
2230
+ isLifecycleTelemetryEvent,
1643
2231
  loadCompoundState,
1644
2232
  markCourseStarted,
1645
2233
  markCourseStartedEmittedToTracking,
1646
2234
  markCourseStartedPipelineDelivered,
2235
+ markCourseStartedXapiSent,
1647
2236
  migrateCourseStartedMark,
1648
2237
  nowIso,
1649
2238
  parseBlockId,
@@ -1662,5 +2251,6 @@ function defineLifecyclePlugin(plugin) {
1662
2251
  telemetryCatalogVersion,
1663
2252
  tryBuildTelemetryEvent,
1664
2253
  tryEmitCourseStarted,
2254
+ validateBranchGraph,
1665
2255
  validateId
1666
2256
  });