@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.js CHANGED
@@ -1,64 +1,48 @@
1
- // src/identityTypes.ts
2
- var ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
3
- var ID_MAX_LENGTH = 64;
1
+ import {
2
+ ID_MAX_LENGTH,
3
+ ID_PATTERN,
4
+ SESSION_STORAGE_KEY,
5
+ assertValidId,
6
+ buildCourseStartedTelemetryEvent,
7
+ buildTelemetryEvent,
8
+ completeCourseWithTelemetry,
9
+ completeLessonWithTelemetry,
10
+ createDefaultClock,
11
+ createGlobalTimer,
12
+ createNoopStorage,
13
+ createSessionId,
14
+ createSessionStoragePort,
15
+ getTabSessionId,
16
+ hasCourseStarted,
17
+ hasCourseStartedEmittedToTracking,
18
+ hasCourseStartedPipelineDelivered,
19
+ hasCourseStartedXapiSent,
20
+ isDevEnvironment,
21
+ markCourseStarted,
22
+ markCourseStartedEmittedToTracking,
23
+ markCourseStartedPipelineDelivered,
24
+ markCourseStartedXapiSent,
25
+ migrateCourseStartedMark,
26
+ nowIso,
27
+ parseBlockId,
28
+ parseCheckId,
29
+ parseCourseId,
30
+ parseLessonId,
31
+ resetSharedVolatileSessionIdForTests,
32
+ resetStoragePortForTests,
33
+ resetTelemetryBuilderWarningsForTests,
34
+ resolveSessionId,
35
+ tryBuildTelemetryEvent,
36
+ tryEmitCourseStarted,
37
+ validateId,
38
+ warnDev
39
+ } from "./chunk-KFXFQ6B2.js";
4
40
 
5
41
  // src/assertNever.ts
6
42
  function assertNever(value, message = "Unexpected value") {
7
43
  throw new Error(`${message}: ${String(value)}`);
8
44
  }
9
45
 
10
- // src/validateId.ts
11
- function validateId(input, path = "id") {
12
- if (typeof input !== "string") {
13
- return { ok: false, issues: [{ path, message: "id must be a string" }] };
14
- }
15
- const id = input.trim();
16
- if (!id.length) {
17
- return { ok: false, issues: [{ path, message: "id must not be empty" }] };
18
- }
19
- if (id.length > ID_MAX_LENGTH) {
20
- return {
21
- ok: false,
22
- issues: [{ path, message: `id must be at most ${ID_MAX_LENGTH} characters` }]
23
- };
24
- }
25
- if (!ID_PATTERN.test(id)) {
26
- return {
27
- ok: false,
28
- issues: [
29
- {
30
- path,
31
- message: "id must start with a letter and contain only letters, digits, underscores, and hyphens"
32
- }
33
- ]
34
- };
35
- }
36
- return { ok: true, id };
37
- }
38
- function parseCourseId(input) {
39
- const result = validateId(input, "courseId");
40
- return result.ok ? result.id : null;
41
- }
42
- function parseLessonId(input) {
43
- const result = validateId(input, "lessonId");
44
- return result.ok ? result.id : null;
45
- }
46
- function parseCheckId(input) {
47
- const result = validateId(input, "checkId");
48
- return result.ok ? result.id : null;
49
- }
50
- function parseBlockId(input) {
51
- const result = validateId(input, "blockId");
52
- return result.ok ? result.id : null;
53
- }
54
- function assertValidId(input, path = "id") {
55
- const result = validateId(input, path);
56
- if (!result.ok) {
57
- throw new Error(result.issues.map((i) => `${i.path}: ${i.message}`).join("; "));
58
- }
59
- return result.id;
60
- }
61
-
62
46
  // src/slugify.ts
63
47
  function shortHash(input) {
64
48
  let h = 0;
@@ -71,12 +55,26 @@ function uniqueFallbackId(input, usedIds) {
71
55
  const hash = shortHash(input);
72
56
  for (let n = 0; n < 100; n++) {
73
57
  const candidate = (n === 0 ? `id-${hash}` : `id-${hash}-${n}`).slice(0, 64);
74
- const validated2 = validateId(candidate);
75
- 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;
76
66
  }
77
67
  const timed = `id-${hash}-${Date.now().toString(36)}`.slice(0, 64);
78
- const validated = validateId(timed);
79
- 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)}`);
80
78
  }
81
79
  function slugifyId(input) {
82
80
  const slug = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-").slice(0, 64);
@@ -118,6 +116,13 @@ function buildLessonkitUrn(parts) {
118
116
  }
119
117
  urn += `:block:${blockId}`;
120
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
+ }
121
126
  return urn;
122
127
  }
123
128
 
@@ -162,19 +167,25 @@ function isPlainSerializableChildState(value) {
162
167
  (entry) => isValidChildResumeValue(entry)
163
168
  );
164
169
  }
165
- function parseCompoundResumeState(raw) {
170
+ function parseCompoundResumeState(raw, opts) {
166
171
  if (!raw || typeof raw !== "object") return null;
167
172
  const obj = raw;
168
173
  if (obj.schemaVersion !== COMPOUND_RESUME_SCHEMA_VERSION) return null;
169
174
  if (typeof obj.activePageIndex !== "number" || !Number.isFinite(obj.activePageIndex)) return null;
170
175
  const childStates = {};
176
+ const droppedChildKeys = [];
171
177
  if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
172
178
  for (const [key, value] of Object.entries(obj.childStates)) {
173
179
  if (isPlainSerializableChildState(value)) {
174
180
  childStates[key] = value;
181
+ } else {
182
+ droppedChildKeys.push(key);
175
183
  }
176
184
  }
177
185
  }
186
+ if (droppedChildKeys.length > 0) {
187
+ opts?.onDroppedChildKeys?.(droppedChildKeys);
188
+ }
178
189
  const activeChapterIndex = typeof obj.activeChapterIndex === "number" && Number.isFinite(obj.activeChapterIndex) ? obj.activeChapterIndex : void 0;
179
190
  return {
180
191
  schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
@@ -184,32 +195,26 @@ function parseCompoundResumeState(raw) {
184
195
  };
185
196
  }
186
197
 
187
- // src/internal/env.ts
188
- function isDevEnvironment() {
189
- const g = globalThis;
190
- return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
191
- }
192
- function warnDev(message, err) {
193
- if (!isDevEnvironment()) return;
194
- console.warn(message, err instanceof Error ? err.message : err);
195
- }
196
-
197
198
  // src/compoundState.ts
198
199
  var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
199
200
  function compoundStateStorageKey(courseId, compoundId) {
200
201
  return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
201
202
  }
202
- function loadCompoundState(storage, courseId, compoundId) {
203
+ function loadCompoundState(storage, courseId, compoundId, opts) {
203
204
  const key = compoundStateStorageKey(courseId, compoundId);
204
205
  const raw = storage.getItem(key);
205
206
  if (!raw) return null;
206
207
  try {
207
- const parsed = parseCompoundResumeState(JSON.parse(raw));
208
- if (parsed === null && isDevEnvironment()) {
209
- 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
+ }
210
214
  }
211
215
  return parsed;
212
216
  } catch {
217
+ opts?.onCorrupt?.();
213
218
  if (isDevEnvironment()) {
214
219
  console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
215
220
  }
@@ -224,10 +229,23 @@ function clearCompoundState(storage, courseId, compoundId) {
224
229
  }
225
230
 
226
231
  // src/compoundAllowlists.ts
232
+ var PAGE_AND_SLIDE_14_BLOCKS = [
233
+ "Video",
234
+ "Summary",
235
+ "ImagePairing",
236
+ "ImageSequencing",
237
+ "MemoryGame",
238
+ "InformationWall",
239
+ "ParallaxSlideshow",
240
+ "Questionnaire",
241
+ "Essay",
242
+ "ArithmeticQuiz"
243
+ ];
227
244
  var PAGE_ALLOWED_CHILD_TYPES = [
228
245
  "Text",
229
246
  "Heading",
230
247
  "Image",
248
+ "Video",
231
249
  "Scenario",
232
250
  "Reflection",
233
251
  "Quiz",
@@ -237,6 +255,15 @@ var PAGE_ALLOWED_CHILD_TYPES = [
237
255
  "DragAndDrop",
238
256
  "DragTheWords",
239
257
  "MarkTheWords",
258
+ "Summary",
259
+ "ImagePairing",
260
+ "ImageSequencing",
261
+ "MemoryGame",
262
+ "InformationWall",
263
+ "ParallaxSlideshow",
264
+ "Questionnaire",
265
+ "Essay",
266
+ "ArithmeticQuiz",
240
267
  "Accordion",
241
268
  "DialogCards",
242
269
  "Flashcards",
@@ -244,13 +271,51 @@ var PAGE_ALLOWED_CHILD_TYPES = [
244
271
  "FindHotspot",
245
272
  "FindMultipleHotspots",
246
273
  "ImageSlider",
274
+ "Embed",
275
+ "Chart",
247
276
  "ProgressTracker"
248
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"];
249
313
  var INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES = ["Page"];
250
314
  var SLIDE_ALLOWED_CHILD_TYPES = [
251
315
  "Text",
252
316
  "Heading",
253
317
  "Image",
318
+ "Video",
254
319
  "Scenario",
255
320
  "Reflection",
256
321
  "Quiz",
@@ -260,15 +325,42 @@ var SLIDE_ALLOWED_CHILD_TYPES = [
260
325
  "DragAndDrop",
261
326
  "DragTheWords",
262
327
  "MarkTheWords",
328
+ "Summary",
329
+ "ImagePairing",
330
+ "ImageSequencing",
331
+ "MemoryGame",
332
+ "InformationWall",
333
+ "ParallaxSlideshow",
334
+ "Questionnaire",
335
+ "Essay",
336
+ "ArithmeticQuiz",
263
337
  "Accordion",
264
338
  "DialogCards",
265
339
  "Flashcards",
266
340
  "ImageHotspots",
267
341
  "FindHotspot",
268
342
  "FindMultipleHotspots",
269
- "ImageSlider"
343
+ "ImageSlider",
344
+ "Embed",
345
+ "Chart"
270
346
  ];
271
347
  var SLIDE_DECK_ALLOWED_CHILD_TYPES = ["Slide"];
348
+ var TIMED_CUE_ALLOWED_CHILD_TYPES = [
349
+ "Text",
350
+ "Heading",
351
+ "Image",
352
+ "Quiz",
353
+ "TrueFalse",
354
+ "FillInTheBlanks",
355
+ "Summary",
356
+ "ImagePairing",
357
+ "ImageSequencing",
358
+ "MemoryGame",
359
+ "Questionnaire",
360
+ "Essay",
361
+ "ArithmeticQuiz"
362
+ ];
363
+ var INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES = ["TimedCue"];
272
364
  var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
273
365
  "TrueFalse",
274
366
  "FillInTheBlanks",
@@ -278,21 +370,34 @@ var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
278
370
  "Quiz",
279
371
  "KnowledgeCheck",
280
372
  "FindHotspot",
281
- "FindMultipleHotspots"
373
+ "FindMultipleHotspots",
374
+ "Summary",
375
+ "ImagePairing",
376
+ "ImageSequencing",
377
+ "ArithmeticQuiz",
378
+ "Essay"
282
379
  ];
283
380
  var ALLOWLISTS = {
284
381
  Page: PAGE_ALLOWED_CHILD_TYPES,
285
382
  InteractiveBook: INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
286
383
  Slide: SLIDE_ALLOWED_CHILD_TYPES,
287
384
  SlideDeck: SLIDE_DECK_ALLOWED_CHILD_TYPES,
288
- AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES
385
+ TimedCue: TIMED_CUE_ALLOWED_CHILD_TYPES,
386
+ InteractiveVideo: INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
387
+ AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
388
+ BranchingScenario: BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
389
+ BranchNode: BRANCH_NODE_ALLOWED_CHILD_TYPES
289
390
  };
290
391
  var COMPOUND_MAX_NESTING_DEPTH = {
291
392
  Page: 1,
292
393
  InteractiveBook: 2,
293
394
  Slide: 1,
294
395
  SlideDeck: 2,
295
- AssessmentSequence: 1
396
+ TimedCue: 1,
397
+ InteractiveVideo: 2,
398
+ AssessmentSequence: 1,
399
+ BranchingScenario: 2,
400
+ BranchNode: 1
296
401
  };
297
402
  function getAllowedChildTypes(parent) {
298
403
  return ALLOWLISTS[parent];
@@ -301,6 +406,83 @@ function isChildTypeAllowed(parent, childType) {
301
406
  return ALLOWLISTS[parent].includes(childType);
302
407
  }
303
408
  var ACCORDION_FORBIDDEN_CHILD_TYPES = ["Accordion"];
409
+ var BLOCKS_14_PAGE_SLIDE = PAGE_AND_SLIDE_14_BLOCKS;
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
+ }
304
486
 
305
487
  // src/telemetryCatalog.ts
306
488
  var telemetryCatalogVersion = 1;
@@ -456,6 +638,70 @@ var TELEMETRY_EVENT_CATALOG_V3 = [
456
638
  dataFields: ["blockId", "slideIndex"],
457
639
  xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
458
640
  urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
641
+ },
642
+ {
643
+ name: "video_cue_reached",
644
+ description: "Learner reached a timed cue in an Interactive Video",
645
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
646
+ dataFields: ["blockId", "cueIndex", "atSeconds", "cueLabel"],
647
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
648
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
649
+ },
650
+ {
651
+ name: "video_segment_completed",
652
+ description: "Learner completed a timed segment in an Interactive Video",
653
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
654
+ dataFields: ["blockId", "segmentIndex", "atSeconds", "segmentLabel"],
655
+ xapiVerb: "http://adlnet.gov/expapi/verbs/completed",
656
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
657
+ },
658
+ {
659
+ name: "memory_card_flipped",
660
+ description: "Learner flipped a memory game card",
661
+ requiredFields: ["courseId", "sessionId", "timestamp"],
662
+ dataFields: ["blockId", "cardIndex", "face"],
663
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
664
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
665
+ },
666
+ {
667
+ name: "information_wall_search",
668
+ description: "Learner searched an information wall",
669
+ requiredFields: ["courseId", "sessionId", "timestamp"],
670
+ dataFields: ["blockId", "query", "resultCount"],
671
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
672
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
673
+ },
674
+ {
675
+ name: "parallax_slide_viewed",
676
+ description: "Learner viewed a slide in a parallax slideshow",
677
+ requiredFields: ["courseId", "sessionId", "timestamp"],
678
+ dataFields: ["blockId", "slideIndex"],
679
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
680
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
681
+ },
682
+ {
683
+ name: "questionnaire_submitted",
684
+ description: "Learner submitted an unscored questionnaire",
685
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
686
+ dataFields: ["blockId", "fieldCount"],
687
+ xapiVerb: "http://adlnet.gov/expapi/verbs/completed",
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}"
459
705
  }
460
706
  ];
461
707
  function buildTelemetryCatalogV3() {
@@ -463,34 +709,36 @@ function buildTelemetryCatalogV3() {
463
709
  }
464
710
 
465
711
  // src/internal/sinkInvoke.ts
466
- function invokeTrackingSink(sink, event) {
467
- let result;
712
+ async function invokeTrackingSinkWithResult(sink, event) {
468
713
  try {
469
- result = sink(event);
714
+ const result = sink(event);
715
+ if (result != null && typeof result.then === "function") {
716
+ await result;
717
+ }
718
+ return true;
470
719
  } catch (err) {
471
720
  warnDev("[lessonkit] tracking sink failed:", err);
472
- throw err;
473
- }
474
- if (result != null && typeof result.catch === "function") {
475
- void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
721
+ return false;
476
722
  }
477
723
  }
478
- function invokePipelineSink(sinkId, emit) {
724
+ function invokeTrackingSink(sink, event) {
479
725
  let result;
480
726
  try {
481
- result = emit();
727
+ result = sink(event);
482
728
  } catch (err) {
483
- warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err);
484
- return;
729
+ warnDev("[lessonkit] tracking sink failed:", err);
730
+ throw err;
485
731
  }
486
732
  if (result != null && typeof result.catch === "function") {
487
- void result.catch(
488
- (err) => warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err)
489
- );
733
+ void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
490
734
  }
491
735
  }
492
736
 
493
737
  // src/trackingClient.ts
738
+ function eventDedupKey(event) {
739
+ const id = event.id?.trim();
740
+ return id || void 0;
741
+ }
494
742
  function createTrackingClient(opts) {
495
743
  const sink = opts?.sink;
496
744
  const batchSink = opts?.batchSink;
@@ -508,8 +756,19 @@ function createTrackingClient(opts) {
508
756
  let disposed2 = false;
509
757
  return {
510
758
  track: (event) => {
511
- if (disposed2) return;
512
- if (sink) invokeTrackingSink(sink, event);
759
+ if (disposed2) return false;
760
+ if (sink) {
761
+ try {
762
+ invokeTrackingSink(sink, event);
763
+ } catch {
764
+ }
765
+ }
766
+ return true;
767
+ },
768
+ deliver: async (event) => {
769
+ if (disposed2) return false;
770
+ if (!sink) return true;
771
+ return invokeTrackingSinkWithResult(sink, event);
513
772
  },
514
773
  dispose: () => {
515
774
  disposed2 = true;
@@ -517,19 +776,28 @@ function createTrackingClient(opts) {
517
776
  };
518
777
  }
519
778
  if (!sink && !batchSink) {
520
- return { track: () => {
521
- } };
779
+ return { track: () => true };
522
780
  }
523
781
  const buffer = [];
782
+ const pendingDeliverIds = /* @__PURE__ */ new Set();
524
783
  let flushInFlight = null;
525
- let inflightExitBatch = null;
526
784
  let disposed = false;
527
785
  let disposing = false;
528
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
+ };
529
798
  const runFlush = () => {
530
799
  if (!buffer.length) return Promise.resolve(true);
531
800
  const events = buffer.splice(0, buffer.length);
532
- inflightExitBatch = events;
533
801
  let succeeded = false;
534
802
  return Promise.resolve().then(async () => {
535
803
  if (batchSink) {
@@ -550,12 +818,13 @@ function createTrackingClient(opts) {
550
818
  buffer.unshift(...events);
551
819
  }
552
820
  }).then(async () => {
821
+ if (succeeded) {
822
+ clearPendingDeliverIds(events);
823
+ }
553
824
  if (succeeded && buffer.length > 0 && !disposed) {
554
825
  return runFlush();
555
826
  }
556
827
  return succeeded;
557
- }).finally(() => {
558
- inflightExitBatch = null;
559
828
  });
560
829
  };
561
830
  const flush = () => {
@@ -576,44 +845,66 @@ function createTrackingClient(opts) {
576
845
  if (!delivered) break;
577
846
  }
578
847
  if (buffer.length > 0) {
848
+ const droppedCount = buffer.length;
579
849
  if (isDevEnvironment()) {
580
850
  console.warn(
581
- `[lessonkit] dropped ${buffer.length} buffered telemetry event(s) after dispose flush cap`
851
+ `[lessonkit] dropped ${droppedCount} buffered telemetry event(s) after dispose flush cap`
582
852
  );
583
853
  }
854
+ for (let i = 0; i < droppedCount; i++) {
855
+ opts?.onBufferDrop?.();
856
+ }
584
857
  buffer.length = 0;
858
+ pendingDeliverIds.clear();
585
859
  }
586
860
  };
587
861
  intervalId = flushIntervalMs > 0 ? globalThis.setInterval(() => void flush(), flushIntervalMs) : void 0;
588
862
  intervalId?.unref?.();
863
+ const track = (event) => {
864
+ if (disposed || disposing) return false;
865
+ const key = eventDedupKey(event);
866
+ if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
867
+ return true;
868
+ }
869
+ if (buffer.length >= maxBufferSize) {
870
+ opts?.onBufferDrop?.();
871
+ if (!warnedBufferCap && isDevEnvironment()) {
872
+ warnedBufferCap = true;
873
+ console.warn(
874
+ `[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
875
+ );
876
+ }
877
+ return false;
878
+ }
879
+ buffer.push(event);
880
+ if (buffer.length >= maxBatchSize) void flush();
881
+ return true;
882
+ };
589
883
  return {
590
- track: (event) => {
591
- if (disposed || disposing) return;
592
- if (buffer.length >= maxBufferSize) {
593
- opts?.onBufferDrop?.();
594
- if (!warnedBufferCap && isDevEnvironment()) {
595
- warnedBufferCap = true;
596
- console.warn(
597
- `[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
598
- );
599
- }
600
- return;
884
+ track,
885
+ deliver: async (event) => {
886
+ const key = eventDedupKey(event);
887
+ if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
888
+ return flush();
601
889
  }
602
- buffer.push(event);
603
- if (buffer.length >= maxBatchSize) void flush();
890
+ if (!track(event)) return false;
891
+ if (key) pendingDeliverIds.add(key);
892
+ return flush();
604
893
  },
605
894
  flush,
606
895
  flushOnExit: opts?.exitBatchSink ? () => {
607
- const fromBuffer = buffer.splice(0, buffer.length);
608
- const fromInflight = inflightExitBatch ? [...inflightExitBatch] : [];
609
- const events = [...fromInflight, ...fromBuffer];
896
+ const events = buffer.splice(0, buffer.length);
610
897
  if (!events.length) return;
611
898
  try {
612
899
  const result = opts.exitBatchSink(events);
613
900
  if (result != null && typeof result.catch === "function") {
614
- void result.catch(() => {
901
+ void result.then(() => {
902
+ clearPendingDeliverIds(events);
903
+ }).catch(() => {
615
904
  buffer.unshift(...events);
616
905
  });
906
+ } else {
907
+ clearPendingDeliverIds(events);
617
908
  }
618
909
  } catch {
619
910
  buffer.unshift(...events);
@@ -634,282 +925,45 @@ function createTrackingClient(opts) {
634
925
  };
635
926
  }
636
927
 
637
- // src/ids.ts
638
- function createSessionId() {
639
- const g = globalThis;
640
- if (g.crypto?.randomUUID) return g.crypto.randomUUID();
641
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
642
- }
643
-
644
- // src/time.ts
645
- function nowIso() {
646
- return (/* @__PURE__ */ new Date()).toISOString();
647
- }
648
-
649
- // src/telemetry/eventRegistry.ts
650
- function resolveLessonId(opts, eventName) {
651
- const lessonId = opts.lessonId ?? opts.data?.lessonId;
652
- if (!lessonId) throw new Error(`${eventName} requires lessonId`);
653
- return lessonId;
654
- }
655
- function withLessonScopedData(name, base, lessonId, data) {
656
- return { name, ...base, lessonId, data: { ...data, lessonId } };
657
- }
658
- var TELEMETRY_EVENT_REGISTRY = {
659
- course_started: {
660
- build: (_opts, base) => ({ name: "course_started", ...base })
661
- },
662
- course_completed: {
663
- build: (_opts, base) => ({ name: "course_completed", ...base })
664
- },
665
- lesson_started: {
666
- requiresLessonId: true,
667
- build: (opts, base) => {
668
- if (opts.name !== "lesson_started") throw new Error("unexpected event");
669
- const lessonId = resolveLessonId(opts, "lesson_started");
670
- return withLessonScopedData("lesson_started", base, lessonId, opts.data);
671
- }
672
- },
673
- lesson_completed: {
674
- requiresLessonId: true,
675
- build: (opts, base) => {
676
- if (opts.name !== "lesson_completed") throw new Error("unexpected event");
677
- const lessonId = resolveLessonId(opts, opts.name);
678
- return withLessonScopedData(opts.name, base, lessonId, opts.data);
679
- }
680
- },
681
- lesson_time_on_task: {
682
- requiresLessonId: true,
683
- build: (opts, base) => {
684
- if (opts.name !== "lesson_time_on_task") throw new Error("unexpected event");
685
- const lessonId = resolveLessonId(opts, opts.name);
686
- return withLessonScopedData(opts.name, base, lessonId, opts.data);
687
- }
688
- },
689
- quiz_answered: {
690
- requiresLessonId: true,
691
- tryBuildMissingLessonWarning: "quiz",
692
- build: (opts, base) => {
693
- if (opts.name !== "quiz_answered") throw new Error("unexpected event");
694
- const lessonId = opts.lessonId;
695
- if (!lessonId) throw new Error("quiz_answered requires active lessonId");
696
- return {
697
- name: "quiz_answered",
698
- ...base,
699
- lessonId,
700
- data: opts.data
701
- };
702
- }
703
- },
704
- quiz_completed: {
705
- requiresLessonId: true,
706
- tryBuildMissingLessonWarning: "quiz",
707
- build: (opts, base) => {
708
- if (opts.name !== "quiz_completed") throw new Error("unexpected event");
709
- const lessonId = opts.lessonId;
710
- if (!lessonId) throw new Error("quiz_completed requires active lessonId");
711
- return {
712
- name: "quiz_completed",
713
- ...base,
714
- lessonId,
715
- data: opts.data
716
- };
717
- }
718
- },
719
- assessment_answered: {
720
- requiresLessonId: true,
721
- tryBuildMissingLessonWarning: "assessment",
722
- build: (opts, base) => {
723
- if (opts.name !== "assessment_answered") throw new Error("unexpected event");
724
- const lessonId = opts.lessonId;
725
- if (!lessonId) throw new Error("assessment_answered requires active lessonId");
726
- return {
727
- name: "assessment_answered",
728
- ...base,
729
- lessonId,
730
- data: opts.data
731
- };
732
- }
733
- },
734
- assessment_completed: {
735
- requiresLessonId: true,
736
- tryBuildMissingLessonWarning: "assessment",
737
- build: (opts, base) => {
738
- if (opts.name !== "assessment_completed") throw new Error("unexpected event");
739
- const lessonId = opts.lessonId;
740
- if (!lessonId) throw new Error("assessment_completed requires active lessonId");
741
- return {
742
- name: "assessment_completed",
743
- ...base,
744
- lessonId,
745
- data: opts.data
746
- };
747
- }
748
- },
749
- interaction: {
750
- build: (opts, base) => {
751
- if (opts.name !== "interaction") throw new Error("unexpected event");
752
- return {
753
- name: "interaction",
754
- ...base,
755
- lessonId: opts.lessonId,
756
- data: opts.data
757
- };
758
- }
759
- },
760
- book_page_viewed: {
761
- requiresLessonId: true,
762
- build: (opts, base) => {
763
- if (opts.name !== "book_page_viewed") throw new Error("unexpected event");
764
- const lessonId = opts.lessonId;
765
- if (!lessonId) throw new Error("book_page_viewed requires active lessonId");
766
- return {
767
- name: "book_page_viewed",
768
- ...base,
769
- lessonId,
770
- data: opts.data
771
- };
772
- }
773
- },
774
- slide_viewed: {
775
- requiresLessonId: true,
776
- build: (opts, base) => {
777
- if (opts.name !== "slide_viewed") throw new Error("unexpected event");
778
- const lessonId = opts.lessonId;
779
- if (!lessonId) throw new Error("slide_viewed requires active lessonId");
780
- return {
781
- name: "slide_viewed",
782
- ...base,
783
- lessonId,
784
- data: opts.data
785
- };
786
- }
787
- },
788
- compound_page_viewed: {
789
- requiresLessonId: true,
790
- build: (opts, base) => {
791
- if (opts.name !== "compound_page_viewed") throw new Error("unexpected event");
792
- const lessonId = opts.lessonId;
793
- if (!lessonId) throw new Error("compound_page_viewed requires active lessonId");
794
- return {
795
- name: "compound_page_viewed",
796
- ...base,
797
- lessonId,
798
- data: opts.data
799
- };
800
- }
801
- },
802
- hotspot_opened: {
803
- build: (opts, base) => {
804
- if (opts.name !== "hotspot_opened") throw new Error("unexpected event");
805
- return {
806
- name: "hotspot_opened",
807
- ...base,
808
- lessonId: opts.lessonId,
809
- data: opts.data
810
- };
811
- }
812
- },
813
- accordion_section_toggled: {
814
- build: (opts, base) => {
815
- if (opts.name !== "accordion_section_toggled") throw new Error("unexpected event");
816
- return {
817
- name: "accordion_section_toggled",
818
- ...base,
819
- lessonId: opts.lessonId,
820
- data: opts.data
821
- };
822
- }
823
- },
824
- flashcard_flipped: {
825
- build: (opts, base) => {
826
- if (opts.name !== "flashcard_flipped") throw new Error("unexpected event");
827
- return {
828
- name: "flashcard_flipped",
829
- ...base,
830
- lessonId: opts.lessonId,
831
- data: opts.data
832
- };
833
- }
834
- },
835
- image_slider_changed: {
836
- build: (opts, base) => {
837
- if (opts.name !== "image_slider_changed") throw new Error("unexpected event");
838
- return {
839
- name: "image_slider_changed",
840
- ...base,
841
- lessonId: opts.lessonId,
842
- data: opts.data
843
- };
844
- }
845
- }
846
- };
847
- function buildTelemetryEventFromRegistry(opts) {
848
- const entry = TELEMETRY_EVENT_REGISTRY[opts.name];
849
- if (!entry) {
850
- throw new Error("Unexpected value");
928
+ // src/telemetryPipeline.ts
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;
851
946
  }
852
- const base = {
853
- timestamp: opts.timestamp ?? nowIso(),
854
- courseId: opts.courseId,
855
- sessionId: opts.sessionId,
856
- attemptId: opts.attemptId,
857
- user: opts.user
858
- };
859
- return entry.build(opts, base);
860
- }
861
- function getTelemetryEventRegistryEntry(name) {
862
- return TELEMETRY_EVENT_REGISTRY[name];
863
- }
864
-
865
- // src/telemetryBuilder.ts
866
- var warnedMissingQuizLesson = false;
867
- var warnedMissingAssessmentLesson = false;
868
- function resetTelemetryBuilderWarningsForTests() {
869
- warnedMissingQuizLesson = false;
870
- warnedMissingAssessmentLesson = false;
871
- }
872
- function buildTelemetryEvent(opts) {
873
- return buildTelemetryEventFromRegistry(opts);
874
- }
875
- function tryBuildTelemetryEvent(opts) {
876
- const entry = getTelemetryEventRegistryEntry(opts.name);
877
- if (entry.requiresLessonId && !opts.lessonId && entry.tryBuildMissingLessonWarning) {
878
- if (isDevEnvironment()) {
879
- if (entry.tryBuildMissingLessonWarning === "quiz" && !warnedMissingQuizLesson) {
880
- warnedMissingQuizLesson = true;
881
- console.warn(
882
- `[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
883
- );
884
- }
885
- if (entry.tryBuildMissingLessonWarning === "assessment" && !warnedMissingAssessmentLesson) {
886
- warnedMissingAssessmentLesson = true;
887
- console.warn(
888
- `[lessonkit] ${opts.name} skipped: wrap assessment blocks in <Lesson> so an active lessonId is available`
889
- );
890
- }
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);
891
952
  }
892
- return null;
893
953
  }
894
- return buildTelemetryEvent(opts);
895
- }
896
-
897
- // src/telemetryPipeline.ts
898
- function invokeSink(sink, event, emitCtx) {
899
- invokePipelineSink(sink.id, () => sink.emit(event, emitCtx));
900
954
  }
901
955
  function createTelemetryPipeline(sinks) {
902
956
  const list = [...sinks];
903
957
  return {
904
958
  sinks: list,
905
- emit(event, ctx) {
959
+ async emit(event, ctx) {
906
960
  const emitCtx = ctx ?? {
907
961
  courseId: event.courseId,
908
962
  sessionId: event.sessionId,
909
963
  attemptId: event.attemptId
910
964
  };
911
965
  for (const sink of list) {
912
- invokeSink(sink, event, emitCtx);
966
+ await invokeSink(sink, event, emitCtx);
913
967
  }
914
968
  }
915
969
  };
@@ -923,109 +977,6 @@ function createTrackingPipelineSink(id, track) {
923
977
  };
924
978
  }
925
979
 
926
- // src/ports.ts
927
- function createDefaultClock() {
928
- return {
929
- nowMs: () => Date.now(),
930
- nowIso: () => (/* @__PURE__ */ new Date()).toISOString()
931
- };
932
- }
933
- function createNoopStorage() {
934
- return {
935
- getItem: () => null,
936
- setItem: () => true
937
- };
938
- }
939
- function createMemoryBackedSessionStorage(session) {
940
- const memory = /* @__PURE__ */ new Map();
941
- let warnedPersistFailure = false;
942
- const warnPersistFailure = () => {
943
- if (warnedPersistFailure) return;
944
- warnedPersistFailure = true;
945
- const g = globalThis;
946
- if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "development") {
947
- console.warn(
948
- "[lessonkit] sessionStorage is unavailable or failed; using in-memory session dedupe for this tab (may reset on full reload)."
949
- );
950
- }
951
- };
952
- return {
953
- getItem: (key) => {
954
- if (memory.has(key)) return memory.get(key);
955
- try {
956
- const value = session.getItem(key);
957
- if (value !== null) memory.set(key, value);
958
- return value;
959
- } catch {
960
- return memory.get(key) ?? null;
961
- }
962
- },
963
- setItem: (key, value) => {
964
- memory.set(key, value);
965
- try {
966
- session.setItem(key, value);
967
- return true;
968
- } catch {
969
- warnPersistFailure();
970
- return false;
971
- }
972
- },
973
- removeItem: (key) => {
974
- memory.delete(key);
975
- try {
976
- session.removeItem(key);
977
- } catch {
978
- warnPersistFailure();
979
- }
980
- },
981
- resetForTests: () => {
982
- memory.clear();
983
- }
984
- };
985
- }
986
- function resetStoragePortForTests(storage) {
987
- storage.resetForTests?.();
988
- }
989
- function createInMemorySessionStoragePort() {
990
- const memory = /* @__PURE__ */ new Map();
991
- return {
992
- getItem: (key) => memory.get(key) ?? null,
993
- setItem: (key, value) => {
994
- memory.set(key, value);
995
- return true;
996
- },
997
- removeItem: (key) => {
998
- memory.delete(key);
999
- },
1000
- resetForTests: () => {
1001
- memory.clear();
1002
- }
1003
- };
1004
- }
1005
- function resolveBrowserSessionStorage() {
1006
- try {
1007
- if (typeof sessionStorage === "undefined" || sessionStorage == null) {
1008
- return null;
1009
- }
1010
- return sessionStorage;
1011
- } catch {
1012
- return null;
1013
- }
1014
- }
1015
- function createSessionStoragePort() {
1016
- const session = resolveBrowserSessionStorage();
1017
- if (!session) {
1018
- return createInMemorySessionStoragePort();
1019
- }
1020
- return createMemoryBackedSessionStorage(session);
1021
- }
1022
- function createGlobalTimer() {
1023
- return {
1024
- setInterval: (fn, ms) => globalThis.setInterval(fn, ms),
1025
- clearInterval: (id) => globalThis.clearInterval(id)
1026
- };
1027
- }
1028
-
1029
980
  // src/progress.ts
1030
981
  function createProgressController() {
1031
982
  let activeLessonId;
@@ -1051,6 +1002,11 @@ function createProgressController() {
1051
1002
  }
1052
1003
  return { didComplete: false };
1053
1004
  }
1005
+ if (!lessonStartTimes.has(lessonId) && isDevEnvironment()) {
1006
+ console.warn(
1007
+ `[lessonkit] completeLesson("${lessonId}") called without activating the lesson first`
1008
+ );
1009
+ }
1054
1010
  completedLessonIds = new Set(completedLessonIds).add(lessonId);
1055
1011
  if (activeLessonId === lessonId) {
1056
1012
  activeLessonId = void 0;
@@ -1068,153 +1024,6 @@ function createProgressController() {
1068
1024
  };
1069
1025
  }
1070
1026
 
1071
- // src/session.ts
1072
- var SESSION_STORAGE_KEY = "lessonkit:sessionId";
1073
- var volatileSessionIds = /* @__PURE__ */ new WeakMap();
1074
- var sharedVolatileSessionId = null;
1075
- function isDevEnvironment2() {
1076
- const g = globalThis;
1077
- return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
1078
- }
1079
- function getTabSessionId(storage) {
1080
- return storage.getItem(SESSION_STORAGE_KEY);
1081
- }
1082
- var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
1083
- var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
1084
- var COURSE_STARTED_PIPELINE_PREFIX = "lessonkit:course_started_pipeline:";
1085
- function resolveSessionId(storage, provided) {
1086
- if (provided !== void 0) {
1087
- const trimmed = provided.trim();
1088
- if (trimmed.length > 0) return trimmed;
1089
- }
1090
- const existing = storage.getItem(SESSION_STORAGE_KEY);
1091
- if (existing) return existing;
1092
- const volatile = volatileSessionIds.get(storage);
1093
- if (volatile) return volatile;
1094
- const id = createSessionId();
1095
- const persisted = storage.setItem(SESSION_STORAGE_KEY, id);
1096
- if (!persisted) {
1097
- if (!sharedVolatileSessionId) {
1098
- sharedVolatileSessionId = id;
1099
- }
1100
- volatileSessionIds.set(storage, sharedVolatileSessionId);
1101
- if (isDevEnvironment2()) {
1102
- console.warn(
1103
- "[lessonkit] session id could not be persisted; reusing in-memory id for this tab."
1104
- );
1105
- }
1106
- return sharedVolatileSessionId;
1107
- }
1108
- return id;
1109
- }
1110
- function courseStartedStorageKey(sessionId, courseId) {
1111
- return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
1112
- }
1113
- function courseStartedTrackingStorageKey(sessionId, courseId) {
1114
- return `${COURSE_STARTED_TRACKING_PREFIX}${sessionId}:${courseId ?? ""}`;
1115
- }
1116
- function courseStartedPipelineStorageKey(sessionId, courseId) {
1117
- return `${COURSE_STARTED_PIPELINE_PREFIX}${sessionId}:${courseId ?? ""}`;
1118
- }
1119
- function hasCourseStarted(storage, sessionId, courseId) {
1120
- if (!courseId) return false;
1121
- return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
1122
- }
1123
- function markCourseStarted(storage, sessionId, courseId) {
1124
- if (!courseId) return false;
1125
- return storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
1126
- }
1127
- function hasCourseStartedEmittedToTracking(storage, sessionId, courseId) {
1128
- if (!courseId) return false;
1129
- return storage.getItem(courseStartedTrackingStorageKey(sessionId, courseId)) === "1";
1130
- }
1131
- function markCourseStartedEmittedToTracking(storage, sessionId, courseId) {
1132
- if (!courseId) return false;
1133
- return storage.setItem(courseStartedTrackingStorageKey(sessionId, courseId), "1");
1134
- }
1135
- function hasCourseStartedPipelineDelivered(storage, sessionId, courseId) {
1136
- if (!courseId) return false;
1137
- return storage.getItem(courseStartedPipelineStorageKey(sessionId, courseId)) === "1";
1138
- }
1139
- function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
1140
- if (!courseId) return false;
1141
- return storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
1142
- }
1143
- function resetSharedVolatileSessionIdForTests() {
1144
- sharedVolatileSessionId = null;
1145
- }
1146
- function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
1147
- if (!courseId || fromSessionId === toSessionId) return;
1148
- if (hasCourseStarted(storage, fromSessionId, courseId)) {
1149
- markCourseStarted(storage, toSessionId, courseId);
1150
- storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
1151
- }
1152
- if (hasCourseStartedEmittedToTracking(storage, fromSessionId, courseId)) {
1153
- markCourseStartedEmittedToTracking(storage, toSessionId, courseId);
1154
- storage.removeItem?.(courseStartedTrackingStorageKey(fromSessionId, courseId));
1155
- }
1156
- if (hasCourseStartedPipelineDelivered(storage, fromSessionId, courseId)) {
1157
- markCourseStartedPipelineDelivered(storage, toSessionId, courseId);
1158
- storage.removeItem?.(courseStartedPipelineStorageKey(fromSessionId, courseId));
1159
- }
1160
- }
1161
-
1162
- // src/runtime/courseLifecycle.ts
1163
- var courseStartedEmitFlights = /* @__PURE__ */ new Set();
1164
- function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
1165
- const flightKey = `${ctx.sessionId}:${ctx.courseId}`;
1166
- const marked = hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
1167
- if (alreadyEmittedToSink) {
1168
- return { emitted: true, marked };
1169
- }
1170
- if (courseStartedEmitFlights.has(flightKey)) {
1171
- return { emitted: false, marked };
1172
- }
1173
- courseStartedEmitFlights.add(flightKey);
1174
- try {
1175
- const emitted = deps.emitCourseStartedEvent(ctx);
1176
- if (emitted && !marked) {
1177
- markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
1178
- }
1179
- return {
1180
- emitted,
1181
- marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
1182
- };
1183
- } finally {
1184
- courseStartedEmitFlights.delete(flightKey);
1185
- }
1186
- }
1187
- function buildCourseStartedTelemetryEvent(ctx) {
1188
- return buildTelemetryEvent({
1189
- name: "course_started",
1190
- courseId: ctx.courseId,
1191
- sessionId: ctx.sessionId,
1192
- attemptId: ctx.attemptId,
1193
- user: ctx.user
1194
- });
1195
- }
1196
- function completeLessonWithTelemetry(opts) {
1197
- const result = opts.progress.completeLesson(opts.lessonId, opts.nowMs);
1198
- if (!result.didComplete) return false;
1199
- opts.emitLessonCompleted(opts.lessonId, result.durationMs);
1200
- return true;
1201
- }
1202
- function completeCourseWithTelemetry(opts) {
1203
- const current = opts.progress.getState();
1204
- if (current.activeLessonId) {
1205
- completeLessonWithTelemetry({
1206
- progress: opts.progress,
1207
- lessonId: current.activeLessonId,
1208
- nowMs: opts.nowMs,
1209
- emitLessonCompleted: opts.emitLessonCompleted
1210
- });
1211
- }
1212
- const result = opts.progress.completeCourse();
1213
- if (!result.didComplete) return false;
1214
- opts.emitCourseCompleted();
1215
- return true;
1216
- }
1217
-
1218
1027
  // src/plugins/context.ts
1219
1028
  function buildPluginContext(opts) {
1220
1029
  return {
@@ -1231,6 +1040,20 @@ function warnDuplicatePlugin(id) {
1231
1040
  if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
1232
1041
  console.warn(`[lessonkit] plugin id "${id}" was registered more than once; using the latest definition`);
1233
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
+ }
1234
1057
  function createPluginRegistry(plugins = []) {
1235
1058
  const registry = /* @__PURE__ */ new Map();
1236
1059
  for (const plugin of plugins) {
@@ -1272,7 +1095,7 @@ function createPluginRegistry(plugins = []) {
1272
1095
  const composeTrackingSink = (sink, ctxSource) => {
1273
1096
  if (!sink) return void 0;
1274
1097
  const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
1275
- 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)}`;
1276
1099
  const layers = [];
1277
1100
  let composed = sink;
1278
1101
  for (const plugin of list) {
@@ -1337,6 +1160,7 @@ function createLessonkitRuntime(config, ports = {}) {
1337
1160
  let courseId = configSnapshot.courseId;
1338
1161
  let progress = createProgressController();
1339
1162
  let pluginHost = resolvePluginHost(configSnapshot.plugins);
1163
+ let disposed = false;
1340
1164
  const getPluginCtx = () => buildPluginContext({
1341
1165
  courseId,
1342
1166
  sessionId,
@@ -1370,28 +1194,24 @@ function createLessonkitRuntime(config, ports = {}) {
1370
1194
  if (!event) return null;
1371
1195
  return applyPluginsToEvent(event);
1372
1196
  };
1373
- const wrapEmitFn = (emitFn) => {
1374
- return (name, data, lessonId) => {
1375
- const event = buildAndApply(name, data, lessonId);
1376
- if (event === null) return;
1377
- const eventLessonId = "lessonId" in event ? event.lessonId : lessonId;
1378
- const eventData = "data" in event ? event.data : data;
1379
- emitFn(event.name, eventData, eventLessonId);
1380
- };
1197
+ const emitLifecycleEvent = (emitFn, name, data, lessonId) => {
1198
+ const event = buildAndApply(name, data, lessonId);
1199
+ if (event) emitFn(event);
1381
1200
  };
1382
1201
  syncSessionFromConfig(configSnapshot);
1383
1202
  const track = (name, data, emit, lessonId) => {
1203
+ if (disposed) return;
1384
1204
  const event = buildAndApply(name, data, lessonId);
1385
1205
  if (!event) return;
1386
1206
  emit(event);
1387
1207
  };
1388
1208
  const emitLessonCompletedEvents = (lessonId, durationMs, emitFn) => {
1389
- const wrapped = wrapEmitFn(emitFn);
1390
- wrapped("lesson_completed", { lessonId, durationMs }, lessonId);
1209
+ emitLifecycleEvent(emitFn, "lesson_completed", { lessonId, durationMs }, lessonId);
1391
1210
  if (durationMs !== void 0) {
1392
- wrapped("lesson_time_on_task", { lessonId, durationMs }, lessonId);
1211
+ emitLifecycleEvent(emitFn, "lesson_time_on_task", { lessonId, durationMs }, lessonId);
1393
1212
  }
1394
1213
  };
1214
+ const autoCompleteOnLessonSwitch = () => configSnapshot.autoCompleteOnLessonSwitch ?? true;
1395
1215
  return {
1396
1216
  get config() {
1397
1217
  return configSnapshot;
@@ -1404,7 +1224,12 @@ function createLessonkitRuntime(config, ports = {}) {
1404
1224
  },
1405
1225
  getProgressState: () => progress.getState(),
1406
1226
  getSession,
1227
+ migrateSessionMarks(fromSessionId, toSessionId) {
1228
+ if (disposed) return;
1229
+ migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId);
1230
+ },
1407
1231
  updateConfig(next) {
1232
+ if (disposed) return;
1408
1233
  const previousCourseId = courseId;
1409
1234
  const sessionKeyBefore = JSON.stringify({ sessionId, attemptId, user });
1410
1235
  if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
@@ -1412,6 +1237,9 @@ function createLessonkitRuntime(config, ports = {}) {
1412
1237
  if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
1413
1238
  configSnapshot.runtimeVersion = next.runtimeVersion;
1414
1239
  }
1240
+ if (next.autoCompleteOnLessonSwitch !== void 0) {
1241
+ configSnapshot.autoCompleteOnLessonSwitch = next.autoCompleteOnLessonSwitch;
1242
+ }
1415
1243
  if (next.session !== void 0) {
1416
1244
  configSnapshot.session = { ...configSnapshot.session, ...next.session };
1417
1245
  }
@@ -1420,61 +1248,75 @@ function createLessonkitRuntime(config, ports = {}) {
1420
1248
  if (next.courseId !== void 0 && next.courseId !== previousCourseId) {
1421
1249
  progress = createProgressController();
1422
1250
  }
1423
- if (next.plugins !== void 0 && next.plugins !== pluginHost) {
1251
+ if (next.plugins !== void 0 && next.plugins !== configSnapshot.plugins) {
1424
1252
  pluginHost?.disposeAll();
1425
1253
  configSnapshot.plugins = next.plugins;
1426
1254
  pluginHost = resolvePluginHost(configSnapshot.plugins);
1427
- pluginHost?.setupAll(getPluginCtx());
1255
+ if (!configSnapshot.deferPluginSetup) {
1256
+ pluginHost?.setupAll(getPluginCtx());
1257
+ }
1428
1258
  } else if (next.session !== void 0 && sessionKeyBefore !== sessionKeyAfter && pluginHost && !configSnapshot.deferPluginSetup) {
1429
1259
  pluginHost.disposeAll();
1430
1260
  pluginHost.setupAll(getPluginCtx());
1431
1261
  }
1432
1262
  },
1433
1263
  setActiveLesson(lessonId, emitFn) {
1434
- const wrapped = wrapEmitFn(emitFn);
1264
+ if (disposed) return;
1435
1265
  const current = progress.getState();
1436
1266
  if (current.activeLessonId === lessonId) return;
1437
- if (current.completedLessonIds.has(lessonId)) {
1438
- progress.setActiveLesson(lessonId, clock.nowMs());
1439
- return;
1440
- }
1441
1267
  const previous = current.activeLessonId;
1442
- if (previous && previous !== lessonId) {
1268
+ if (autoCompleteOnLessonSwitch() && previous && previous !== lessonId && !current.completedLessonIds.has(previous)) {
1443
1269
  const completed = progress.completeLesson(previous, clock.nowMs());
1444
1270
  if (completed.didComplete) {
1445
- emitLessonCompletedEvents(previous, completed.durationMs, wrapped);
1271
+ emitLessonCompletedEvents(previous, completed.durationMs, emitFn);
1446
1272
  }
1447
1273
  }
1274
+ if (current.completedLessonIds.has(lessonId)) {
1275
+ progress.setActiveLesson(lessonId, clock.nowMs());
1276
+ return;
1277
+ }
1448
1278
  progress.setActiveLesson(lessonId, clock.nowMs());
1449
- wrapped("lesson_started", { lessonId }, lessonId);
1279
+ emitLifecycleEvent(emitFn, "lesson_started", { lessonId }, lessonId);
1450
1280
  },
1451
1281
  completeLesson(lessonId, emitFn) {
1282
+ if (disposed) return;
1452
1283
  completeLessonWithTelemetry({
1453
1284
  progress,
1454
1285
  lessonId,
1455
1286
  nowMs: clock.nowMs(),
1456
- emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn))
1287
+ emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn)
1457
1288
  });
1458
1289
  },
1459
1290
  completeCourse(emitFn) {
1291
+ if (disposed) return;
1460
1292
  completeCourseWithTelemetry({
1461
1293
  progress,
1462
1294
  nowMs: clock.nowMs(),
1463
- emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn)),
1464
- emitCourseCompleted: () => wrapEmitFn(emitFn)("course_completed")
1295
+ emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn),
1296
+ emitCourseCompleted: () => emitLifecycleEvent(emitFn, "course_completed")
1465
1297
  });
1466
1298
  },
1467
1299
  track,
1468
- scoreAssessment(input, _lessonId) {
1469
- if (!pluginHost) return null;
1470
- return pluginHost.scoreAssessment(input, getPluginCtx());
1300
+ scoreAssessment(input, lessonId) {
1301
+ if (disposed || !pluginHost) return null;
1302
+ return pluginHost.scoreAssessment(
1303
+ { ...input, lessonId: input.lessonId ?? lessonId },
1304
+ getPluginCtx()
1305
+ );
1471
1306
  },
1472
1307
  resetForCourseChange(nextCourseId) {
1308
+ if (disposed) return;
1473
1309
  configSnapshot.courseId = nextCourseId;
1474
1310
  courseId = nextCourseId;
1475
1311
  progress = createProgressController();
1312
+ pluginHost?.disposeAll();
1313
+ if (!configSnapshot.deferPluginSetup) {
1314
+ pluginHost?.setupAll(getPluginCtx());
1315
+ }
1476
1316
  },
1477
1317
  dispose() {
1318
+ if (disposed) return;
1319
+ disposed = true;
1478
1320
  pluginHost?.disposeAll();
1479
1321
  }
1480
1322
  };
@@ -1493,11 +1335,15 @@ function defineLifecyclePlugin(plugin) {
1493
1335
  export {
1494
1336
  ACCORDION_FORBIDDEN_CHILD_TYPES,
1495
1337
  ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
1338
+ BLOCKS_14_PAGE_SLIDE,
1339
+ BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
1340
+ BRANCH_NODE_ALLOWED_CHILD_TYPES,
1496
1341
  COMPOUND_MAX_NESTING_DEPTH,
1497
1342
  COMPOUND_RESUME_SCHEMA_VERSION,
1498
1343
  ID_MAX_LENGTH,
1499
1344
  ID_PATTERN,
1500
1345
  INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
1346
+ INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
1501
1347
  PAGE_ALLOWED_CHILD_TYPES,
1502
1348
  SESSION_STORAGE_KEY,
1503
1349
  SLIDE_ALLOWED_CHILD_TYPES,
@@ -1505,6 +1351,7 @@ export {
1505
1351
  TELEMETRY_EVENT_CATALOG,
1506
1352
  TELEMETRY_EVENT_CATALOG_V2,
1507
1353
  TELEMETRY_EVENT_CATALOG_V3,
1354
+ TIMED_CUE_ALLOWED_CHILD_TYPES,
1508
1355
  assertNever,
1509
1356
  assertValidId,
1510
1357
  buildCourseStartedTelemetryEvent,
@@ -1540,11 +1387,14 @@ export {
1540
1387
  hasCourseStarted,
1541
1388
  hasCourseStartedEmittedToTracking,
1542
1389
  hasCourseStartedPipelineDelivered,
1390
+ hasCourseStartedXapiSent,
1543
1391
  isChildTypeAllowed,
1392
+ isLifecycleTelemetryEvent,
1544
1393
  loadCompoundState,
1545
1394
  markCourseStarted,
1546
1395
  markCourseStartedEmittedToTracking,
1547
1396
  markCourseStartedPipelineDelivered,
1397
+ markCourseStartedXapiSent,
1548
1398
  migrateCourseStartedMark,
1549
1399
  nowIso,
1550
1400
  parseBlockId,
@@ -1563,5 +1413,6 @@ export {
1563
1413
  telemetryCatalogVersion,
1564
1414
  tryBuildTelemetryEvent,
1565
1415
  tryEmitCourseStarted,
1416
+ validateBranchGraph,
1566
1417
  validateId
1567
1418
  };