@lessonkit/core 1.1.0 → 1.3.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
@@ -60,21 +60,40 @@ function assertValidId(input, path = "id") {
60
60
  }
61
61
 
62
62
  // src/slugify.ts
63
+ function shortHash(input) {
64
+ let h = 0;
65
+ for (let i = 0; i < input.length; i++) {
66
+ h = Math.imul(31, h) + input.charCodeAt(i) >>> 0;
67
+ }
68
+ return h.toString(36);
69
+ }
70
+ function uniqueFallbackId(input, usedIds) {
71
+ const hash = shortHash(input);
72
+ for (let n = 0; n < 100; n++) {
73
+ 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;
76
+ }
77
+ const timed = `id-${hash}-${Date.now().toString(36)}`.slice(0, 64);
78
+ const validated = validateId(timed);
79
+ return validated.ok ? validated.id : `id-${hash}`;
80
+ }
63
81
  function slugifyId(input) {
64
82
  const slug = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-").slice(0, 64);
65
- if (!slug.length) return "id";
66
- const candidate = /^[a-z]/.test(slug) ? slug : `id-${slug}`;
83
+ if (!slug.length) return uniqueFallbackId(input, /* @__PURE__ */ new Set());
84
+ const candidate = /^[a-z]/.test(slug) ? slug : `id-${slug}`.slice(0, 64);
67
85
  const validated = validateId(candidate);
68
- return validated.ok ? validated.id : "id";
86
+ return validated.ok ? validated.id : uniqueFallbackId(input, /* @__PURE__ */ new Set());
69
87
  }
70
88
  function deriveId(title, usedIds = /* @__PURE__ */ new Set()) {
71
89
  const base = slugifyId(title);
72
- if (!usedIds.has(base)) return base;
90
+ if (!usedIds.has(base) && validateId(base).ok) return base;
73
91
  for (let n = 2; n < 1e3; n++) {
74
- const candidate = `${base}-${n}`;
75
- if (!usedIds.has(candidate)) return candidate;
92
+ const candidate = `${base}-${n}`.slice(0, 64);
93
+ const validated = validateId(candidate);
94
+ if (validated.ok && !usedIds.has(validated.id)) return validated.id;
76
95
  }
77
- return `${base}-${Date.now()}`;
96
+ return uniqueFallbackId(`${title}-${Date.now()}`, usedIds);
78
97
  }
79
98
 
80
99
  // src/urn.ts
@@ -102,6 +121,187 @@ function buildLessonkitUrn(parts) {
102
121
  return urn;
103
122
  }
104
123
 
124
+ // src/compound.ts
125
+ var COMPOUND_RESUME_SCHEMA_VERSION = 1;
126
+ function createCompoundResumeState(input = {}) {
127
+ const childStates = {};
128
+ if (input.childStates) {
129
+ for (const [key, value] of Object.entries(input.childStates)) {
130
+ childStates[key] = value;
131
+ }
132
+ }
133
+ return {
134
+ schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
135
+ activePageIndex: input.activePageIndex ?? 0,
136
+ ...input.activeChapterIndex !== void 0 ? { activeChapterIndex: input.activeChapterIndex } : {},
137
+ childStates
138
+ };
139
+ }
140
+ function clampCompoundPageIndex(index, pageCount) {
141
+ if (pageCount < 1) return 0;
142
+ return Math.min(Math.max(0, Math.floor(index)), pageCount - 1);
143
+ }
144
+ function isJsonPrimitive(value) {
145
+ return value === null || typeof value === "boolean" || typeof value === "string" || typeof value === "number" && Number.isFinite(value);
146
+ }
147
+ function isPlainStringKeyMap(value) {
148
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
149
+ return Object.entries(value).every(
150
+ ([key, entry]) => typeof key === "string" && isJsonPrimitive(entry)
151
+ );
152
+ }
153
+ function isValidChildResumeValue(value) {
154
+ if (isJsonPrimitive(value)) return true;
155
+ if (Array.isArray(value)) return value.every((item) => isJsonPrimitive(item));
156
+ if (isPlainStringKeyMap(value)) return true;
157
+ return false;
158
+ }
159
+ function isPlainSerializableChildState(value) {
160
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
161
+ return Object.values(value).every(
162
+ (entry) => isValidChildResumeValue(entry)
163
+ );
164
+ }
165
+ function parseCompoundResumeState(raw) {
166
+ if (!raw || typeof raw !== "object") return null;
167
+ const obj = raw;
168
+ if (obj.schemaVersion !== COMPOUND_RESUME_SCHEMA_VERSION) return null;
169
+ if (typeof obj.activePageIndex !== "number" || !Number.isFinite(obj.activePageIndex)) return null;
170
+ const childStates = {};
171
+ if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
172
+ for (const [key, value] of Object.entries(obj.childStates)) {
173
+ if (isPlainSerializableChildState(value)) {
174
+ childStates[key] = value;
175
+ }
176
+ }
177
+ }
178
+ const activeChapterIndex = typeof obj.activeChapterIndex === "number" && Number.isFinite(obj.activeChapterIndex) ? obj.activeChapterIndex : void 0;
179
+ return {
180
+ schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
181
+ activePageIndex: Math.max(0, Math.floor(obj.activePageIndex)),
182
+ ...activeChapterIndex !== void 0 ? { activeChapterIndex: Math.max(0, Math.floor(activeChapterIndex)) } : {},
183
+ childStates
184
+ };
185
+ }
186
+
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
+ // src/compoundState.ts
198
+ var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
199
+ function compoundStateStorageKey(courseId, compoundId) {
200
+ return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
201
+ }
202
+ function loadCompoundState(storage, courseId, compoundId) {
203
+ const key = compoundStateStorageKey(courseId, compoundId);
204
+ const raw = storage.getItem(key);
205
+ if (!raw) return null;
206
+ try {
207
+ const parsed = parseCompoundResumeState(JSON.parse(raw));
208
+ if (parsed === null && isDevEnvironment()) {
209
+ console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
210
+ }
211
+ return parsed;
212
+ } catch {
213
+ if (isDevEnvironment()) {
214
+ console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
215
+ }
216
+ return null;
217
+ }
218
+ }
219
+ function saveCompoundState(storage, courseId, compoundId, state) {
220
+ return storage.setItem(compoundStateStorageKey(courseId, compoundId), JSON.stringify(state));
221
+ }
222
+ function clearCompoundState(storage, courseId, compoundId) {
223
+ storage.removeItem?.(compoundStateStorageKey(courseId, compoundId));
224
+ }
225
+
226
+ // src/compoundAllowlists.ts
227
+ var PAGE_ALLOWED_CHILD_TYPES = [
228
+ "Text",
229
+ "Heading",
230
+ "Image",
231
+ "Scenario",
232
+ "Reflection",
233
+ "Quiz",
234
+ "KnowledgeCheck",
235
+ "TrueFalse",
236
+ "FillInTheBlanks",
237
+ "DragAndDrop",
238
+ "DragTheWords",
239
+ "MarkTheWords",
240
+ "Accordion",
241
+ "DialogCards",
242
+ "Flashcards",
243
+ "ImageHotspots",
244
+ "FindHotspot",
245
+ "FindMultipleHotspots",
246
+ "ImageSlider",
247
+ "ProgressTracker"
248
+ ];
249
+ var INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES = ["Page"];
250
+ var SLIDE_ALLOWED_CHILD_TYPES = [
251
+ "Text",
252
+ "Heading",
253
+ "Image",
254
+ "Scenario",
255
+ "Reflection",
256
+ "Quiz",
257
+ "KnowledgeCheck",
258
+ "TrueFalse",
259
+ "FillInTheBlanks",
260
+ "DragAndDrop",
261
+ "DragTheWords",
262
+ "MarkTheWords",
263
+ "Accordion",
264
+ "DialogCards",
265
+ "Flashcards",
266
+ "ImageHotspots",
267
+ "FindHotspot",
268
+ "FindMultipleHotspots",
269
+ "ImageSlider"
270
+ ];
271
+ var SLIDE_DECK_ALLOWED_CHILD_TYPES = ["Slide"];
272
+ var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
273
+ "TrueFalse",
274
+ "FillInTheBlanks",
275
+ "DragAndDrop",
276
+ "DragTheWords",
277
+ "MarkTheWords",
278
+ "Quiz",
279
+ "KnowledgeCheck",
280
+ "FindHotspot",
281
+ "FindMultipleHotspots"
282
+ ];
283
+ var ALLOWLISTS = {
284
+ Page: PAGE_ALLOWED_CHILD_TYPES,
285
+ InteractiveBook: INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
286
+ Slide: SLIDE_ALLOWED_CHILD_TYPES,
287
+ SlideDeck: SLIDE_DECK_ALLOWED_CHILD_TYPES,
288
+ AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES
289
+ };
290
+ var COMPOUND_MAX_NESTING_DEPTH = {
291
+ Page: 1,
292
+ InteractiveBook: 2,
293
+ Slide: 1,
294
+ SlideDeck: 2,
295
+ AssessmentSequence: 1
296
+ };
297
+ function getAllowedChildTypes(parent) {
298
+ return ALLOWLISTS[parent];
299
+ }
300
+ function isChildTypeAllowed(parent, childType) {
301
+ return ALLOWLISTS[parent].includes(childType);
302
+ }
303
+ var ACCORDION_FORBIDDEN_CHILD_TYPES = ["Accordion"];
304
+
105
305
  // src/telemetryCatalog.ts
106
306
  var telemetryCatalogVersion = 1;
107
307
  var TELEMETRY_EVENT_CATALOG = [
@@ -198,35 +398,99 @@ function buildTelemetryCatalogV2() {
198
398
  return TELEMETRY_EVENT_CATALOG_V2.map((entry) => ({ ...entry }));
199
399
  }
200
400
 
201
- // src/trackingClient.ts
202
- function isDevEnvironment() {
203
- const g = globalThis;
204
- return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
401
+ // src/telemetryCatalogV3.ts
402
+ var telemetryCatalogV3Version = 3;
403
+ var TELEMETRY_EVENT_CATALOG_V3 = [
404
+ {
405
+ name: "book_page_viewed",
406
+ description: "Learner viewed a page/chapter in an Interactive Book",
407
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
408
+ dataFields: ["blockId", "pageIndex", "pageTitle"],
409
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
410
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
411
+ },
412
+ {
413
+ name: "slide_viewed",
414
+ description: "Learner viewed a slide in a SlideDeck (Course Presentation)",
415
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
416
+ dataFields: ["blockId", "slideIndex", "slideTitle"],
417
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
418
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
419
+ },
420
+ {
421
+ name: "compound_page_viewed",
422
+ description: "Learner activated a page inside a compound container",
423
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
424
+ dataFields: ["blockId", "pageIndex", "parentType"],
425
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
426
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
427
+ },
428
+ {
429
+ name: "hotspot_opened",
430
+ description: "Learner opened an image hotspot popover",
431
+ requiredFields: ["courseId", "sessionId", "timestamp"],
432
+ dataFields: ["blockId", "hotspotId"],
433
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
434
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
435
+ },
436
+ {
437
+ name: "accordion_section_toggled",
438
+ description: "Learner expanded or collapsed an accordion section",
439
+ requiredFields: ["courseId", "sessionId", "timestamp"],
440
+ dataFields: ["blockId", "sectionId", "expanded"],
441
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
442
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
443
+ },
444
+ {
445
+ name: "flashcard_flipped",
446
+ description: "Learner flipped a flashcard",
447
+ requiredFields: ["courseId", "sessionId", "timestamp"],
448
+ dataFields: ["blockId", "cardIndex", "face"],
449
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
450
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
451
+ },
452
+ {
453
+ name: "image_slider_changed",
454
+ description: "Learner changed the active slide in an image slider",
455
+ requiredFields: ["courseId", "sessionId", "timestamp"],
456
+ dataFields: ["blockId", "slideIndex"],
457
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
458
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
459
+ }
460
+ ];
461
+ function buildTelemetryCatalogV3() {
462
+ return TELEMETRY_EVENT_CATALOG_V3.map((entry) => ({ ...entry }));
205
463
  }
464
+
465
+ // src/internal/sinkInvoke.ts
206
466
  function invokeTrackingSink(sink, event) {
207
467
  let result;
208
468
  try {
209
469
  result = sink(event);
210
470
  } catch (err) {
211
- if (isDevEnvironment()) {
212
- console.warn(
213
- "[lessonkit] tracking sink failed:",
214
- err instanceof Error ? err.message : err
215
- );
216
- }
471
+ warnDev("[lessonkit] tracking sink failed:", err);
217
472
  throw err;
218
473
  }
219
474
  if (result != null && typeof result.catch === "function") {
220
- void result.catch((err) => {
221
- if (isDevEnvironment()) {
222
- console.warn(
223
- "[lessonkit] tracking sink failed:",
224
- err instanceof Error ? err.message : err
225
- );
226
- }
227
- });
475
+ void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
476
+ }
477
+ }
478
+ function invokePipelineSink(sinkId, emit) {
479
+ let result;
480
+ try {
481
+ result = emit();
482
+ } catch (err) {
483
+ warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err);
484
+ return;
485
+ }
486
+ if (result != null && typeof result.catch === "function") {
487
+ void result.catch(
488
+ (err) => warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err)
489
+ );
228
490
  }
229
491
  }
492
+
493
+ // src/trackingClient.ts
230
494
  function createTrackingClient(opts) {
231
495
  const sink = opts?.sink;
232
496
  const batchSink = opts?.batchSink;
@@ -262,41 +526,58 @@ function createTrackingClient(opts) {
262
526
  let disposing = false;
263
527
  let intervalId;
264
528
  const runFlush = () => {
265
- if (!buffer.length) return Promise.resolve();
529
+ if (!buffer.length) return Promise.resolve(true);
266
530
  const events = buffer.splice(0, buffer.length);
267
- let sent = 0;
268
531
  let succeeded = false;
269
532
  return Promise.resolve().then(async () => {
270
533
  if (batchSink) {
271
534
  await batchSink(events);
272
535
  } else {
273
- for (const e of events) {
274
- await sink?.(e);
275
- sent += 1;
536
+ for (let i = 0; i < events.length; i++) {
537
+ try {
538
+ await sink?.(events[i]);
539
+ } catch {
540
+ buffer.unshift(...events.slice(i));
541
+ return;
542
+ }
276
543
  }
277
544
  }
278
545
  succeeded = true;
279
546
  }).catch(() => {
280
- buffer.unshift(...events.slice(sent));
281
- }).then(() => {
547
+ if (batchSink) {
548
+ buffer.unshift(...events);
549
+ }
550
+ }).then(async () => {
282
551
  if (succeeded && buffer.length > 0 && !disposed) {
283
552
  return runFlush();
284
553
  }
554
+ return succeeded;
285
555
  });
286
556
  };
287
557
  const flush = () => {
288
- if (disposed) return Promise.resolve();
558
+ if (disposed) return Promise.resolve(true);
289
559
  if (flushInFlight) return flushInFlight;
290
- if (!buffer.length) return Promise.resolve();
560
+ if (!buffer.length) return Promise.resolve(true);
291
561
  flushInFlight = runFlush().finally(() => {
292
562
  flushInFlight = null;
293
563
  });
294
564
  return flushInFlight;
295
565
  };
566
+ const MAX_DISPOSE_FLUSH_ATTEMPTS = 10;
296
567
  const drainAll = async () => {
297
- await flush();
298
- while (buffer.length > 0) {
299
- await flush();
568
+ let attempts = 0;
569
+ while (buffer.length > 0 && attempts < MAX_DISPOSE_FLUSH_ATTEMPTS) {
570
+ const delivered = await flush();
571
+ attempts += 1;
572
+ if (!delivered) break;
573
+ }
574
+ if (buffer.length > 0) {
575
+ if (isDevEnvironment()) {
576
+ console.warn(
577
+ `[lessonkit] dropped ${buffer.length} buffered telemetry event(s) after dispose flush cap`
578
+ );
579
+ }
580
+ buffer.length = 0;
300
581
  }
301
582
  };
302
583
  intervalId = flushIntervalMs > 0 ? globalThis.setInterval(() => void flush(), flushIntervalMs) : void 0;
@@ -305,13 +586,14 @@ function createTrackingClient(opts) {
305
586
  track: (event) => {
306
587
  if (disposed || disposing) return;
307
588
  if (buffer.length >= maxBufferSize) {
308
- buffer.shift();
589
+ opts?.onBufferDrop?.();
309
590
  if (!warnedBufferCap && isDevEnvironment()) {
310
591
  warnedBufferCap = true;
311
592
  console.warn(
312
- `[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; oldest events are dropped while the sink is unavailable.`
593
+ `[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
313
594
  );
314
595
  }
596
+ return;
315
597
  }
316
598
  buffer.push(event);
317
599
  if (buffer.length >= maxBatchSize) void flush();
@@ -344,96 +626,243 @@ function nowIso() {
344
626
  return (/* @__PURE__ */ new Date()).toISOString();
345
627
  }
346
628
 
347
- // src/telemetryBuilder.ts
348
- var warnedMissingQuizLesson = false;
349
- var warnedMissingAssessmentLesson = false;
350
- function isDevEnvironment2() {
351
- const g = globalThis;
352
- return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
353
- }
354
- function resetTelemetryBuilderWarningsForTests() {
355
- warnedMissingQuizLesson = false;
356
- warnedMissingAssessmentLesson = false;
357
- }
629
+ // src/telemetry/eventRegistry.ts
358
630
  function resolveLessonId(opts, eventName) {
359
631
  const lessonId = opts.lessonId ?? opts.data?.lessonId;
360
632
  if (!lessonId) throw new Error(`${eventName} requires lessonId`);
361
633
  return lessonId;
362
634
  }
363
- function buildTelemetryEvent(opts) {
364
- const base = {
365
- timestamp: opts.timestamp ?? nowIso(),
366
- courseId: opts.courseId,
367
- sessionId: opts.sessionId,
368
- attemptId: opts.attemptId,
369
- user: opts.user
370
- };
371
- switch (opts.name) {
372
- case "course_started":
373
- return { name: "course_started", ...base };
374
- case "course_completed":
375
- return { name: "course_completed", ...base };
376
- case "lesson_started": {
635
+ function withLessonScopedData(name, base, lessonId, data) {
636
+ return { name, ...base, lessonId, data: { ...data, lessonId } };
637
+ }
638
+ var TELEMETRY_EVENT_REGISTRY = {
639
+ course_started: {
640
+ build: (_opts, base) => ({ name: "course_started", ...base })
641
+ },
642
+ course_completed: {
643
+ build: (_opts, base) => ({ name: "course_completed", ...base })
644
+ },
645
+ lesson_started: {
646
+ requiresLessonId: true,
647
+ build: (opts, base) => {
648
+ if (opts.name !== "lesson_started") throw new Error("unexpected event");
377
649
  const lessonId = resolveLessonId(opts, "lesson_started");
650
+ return withLessonScopedData("lesson_started", base, lessonId, opts.data);
651
+ }
652
+ },
653
+ lesson_completed: {
654
+ requiresLessonId: true,
655
+ build: (opts, base) => {
656
+ if (opts.name !== "lesson_completed") throw new Error("unexpected event");
657
+ const lessonId = resolveLessonId(opts, opts.name);
658
+ return withLessonScopedData(opts.name, base, lessonId, opts.data);
659
+ }
660
+ },
661
+ lesson_time_on_task: {
662
+ requiresLessonId: true,
663
+ build: (opts, base) => {
664
+ if (opts.name !== "lesson_time_on_task") throw new Error("unexpected event");
665
+ const lessonId = resolveLessonId(opts, opts.name);
666
+ return withLessonScopedData(opts.name, base, lessonId, opts.data);
667
+ }
668
+ },
669
+ quiz_answered: {
670
+ requiresLessonId: true,
671
+ tryBuildMissingLessonWarning: "quiz",
672
+ build: (opts, base) => {
673
+ if (opts.name !== "quiz_answered") throw new Error("unexpected event");
674
+ const lessonId = opts.lessonId;
675
+ if (!lessonId) throw new Error("quiz_answered requires active lessonId");
378
676
  return {
379
- name: "lesson_started",
677
+ name: "quiz_answered",
380
678
  ...base,
381
679
  lessonId,
382
- data: { ...opts.data, lessonId }
680
+ data: opts.data
383
681
  };
384
682
  }
385
- case "lesson_completed":
386
- case "lesson_time_on_task": {
387
- const lessonId = resolveLessonId(opts, opts.name);
683
+ },
684
+ quiz_completed: {
685
+ requiresLessonId: true,
686
+ tryBuildMissingLessonWarning: "quiz",
687
+ build: (opts, base) => {
688
+ if (opts.name !== "quiz_completed") throw new Error("unexpected event");
689
+ const lessonId = opts.lessonId;
690
+ if (!lessonId) throw new Error("quiz_completed requires active lessonId");
388
691
  return {
389
- name: opts.name,
692
+ name: "quiz_completed",
390
693
  ...base,
391
694
  lessonId,
392
- data: { ...opts.data, lessonId }
695
+ data: opts.data
393
696
  };
394
697
  }
395
- case "quiz_answered": {
698
+ },
699
+ assessment_answered: {
700
+ requiresLessonId: true,
701
+ tryBuildMissingLessonWarning: "assessment",
702
+ build: (opts, base) => {
703
+ if (opts.name !== "assessment_answered") throw new Error("unexpected event");
396
704
  const lessonId = opts.lessonId;
397
- if (!lessonId) throw new Error("quiz_answered requires active lessonId");
398
- return { name: "quiz_answered", ...base, lessonId, data: opts.data };
705
+ if (!lessonId) throw new Error("assessment_answered requires active lessonId");
706
+ return {
707
+ name: "assessment_answered",
708
+ ...base,
709
+ lessonId,
710
+ data: opts.data
711
+ };
399
712
  }
400
- case "quiz_completed": {
713
+ },
714
+ assessment_completed: {
715
+ requiresLessonId: true,
716
+ tryBuildMissingLessonWarning: "assessment",
717
+ build: (opts, base) => {
718
+ if (opts.name !== "assessment_completed") throw new Error("unexpected event");
401
719
  const lessonId = opts.lessonId;
402
- if (!lessonId) throw new Error("quiz_completed requires active lessonId");
403
- return { name: "quiz_completed", ...base, lessonId, data: opts.data };
720
+ if (!lessonId) throw new Error("assessment_completed requires active lessonId");
721
+ return {
722
+ name: "assessment_completed",
723
+ ...base,
724
+ lessonId,
725
+ data: opts.data
726
+ };
727
+ }
728
+ },
729
+ interaction: {
730
+ build: (opts, base) => {
731
+ if (opts.name !== "interaction") throw new Error("unexpected event");
732
+ return {
733
+ name: "interaction",
734
+ ...base,
735
+ lessonId: opts.lessonId,
736
+ data: opts.data
737
+ };
404
738
  }
405
- case "assessment_answered": {
739
+ },
740
+ book_page_viewed: {
741
+ requiresLessonId: true,
742
+ build: (opts, base) => {
743
+ if (opts.name !== "book_page_viewed") throw new Error("unexpected event");
406
744
  const lessonId = opts.lessonId;
407
- if (!lessonId) throw new Error("assessment_answered requires active lessonId");
408
- return { name: "assessment_answered", ...base, lessonId, data: opts.data };
745
+ if (!lessonId) throw new Error("book_page_viewed requires active lessonId");
746
+ return {
747
+ name: "book_page_viewed",
748
+ ...base,
749
+ lessonId,
750
+ data: opts.data
751
+ };
409
752
  }
410
- case "assessment_completed": {
753
+ },
754
+ slide_viewed: {
755
+ requiresLessonId: true,
756
+ build: (opts, base) => {
757
+ if (opts.name !== "slide_viewed") throw new Error("unexpected event");
411
758
  const lessonId = opts.lessonId;
412
- if (!lessonId) throw new Error("assessment_completed requires active lessonId");
413
- return { name: "assessment_completed", ...base, lessonId, data: opts.data };
759
+ if (!lessonId) throw new Error("slide_viewed requires active lessonId");
760
+ return {
761
+ name: "slide_viewed",
762
+ ...base,
763
+ lessonId,
764
+ data: opts.data
765
+ };
414
766
  }
415
- case "interaction":
767
+ },
768
+ compound_page_viewed: {
769
+ requiresLessonId: true,
770
+ build: (opts, base) => {
771
+ if (opts.name !== "compound_page_viewed") throw new Error("unexpected event");
772
+ const lessonId = opts.lessonId;
773
+ if (!lessonId) throw new Error("compound_page_viewed requires active lessonId");
416
774
  return {
417
- name: "interaction",
775
+ name: "compound_page_viewed",
776
+ ...base,
777
+ lessonId,
778
+ data: opts.data
779
+ };
780
+ }
781
+ },
782
+ hotspot_opened: {
783
+ build: (opts, base) => {
784
+ if (opts.name !== "hotspot_opened") throw new Error("unexpected event");
785
+ return {
786
+ name: "hotspot_opened",
787
+ ...base,
788
+ lessonId: opts.lessonId,
789
+ data: opts.data
790
+ };
791
+ }
792
+ },
793
+ accordion_section_toggled: {
794
+ build: (opts, base) => {
795
+ if (opts.name !== "accordion_section_toggled") throw new Error("unexpected event");
796
+ return {
797
+ name: "accordion_section_toggled",
798
+ ...base,
799
+ lessonId: opts.lessonId,
800
+ data: opts.data
801
+ };
802
+ }
803
+ },
804
+ flashcard_flipped: {
805
+ build: (opts, base) => {
806
+ if (opts.name !== "flashcard_flipped") throw new Error("unexpected event");
807
+ return {
808
+ name: "flashcard_flipped",
809
+ ...base,
810
+ lessonId: opts.lessonId,
811
+ data: opts.data
812
+ };
813
+ }
814
+ },
815
+ image_slider_changed: {
816
+ build: (opts, base) => {
817
+ if (opts.name !== "image_slider_changed") throw new Error("unexpected event");
818
+ return {
819
+ name: "image_slider_changed",
418
820
  ...base,
419
821
  lessonId: opts.lessonId,
420
822
  data: opts.data
421
823
  };
422
- default:
423
- return assertNever(opts);
824
+ }
825
+ }
826
+ };
827
+ function buildTelemetryEventFromRegistry(opts) {
828
+ const entry = TELEMETRY_EVENT_REGISTRY[opts.name];
829
+ if (!entry) {
830
+ throw new Error("Unexpected value");
424
831
  }
832
+ const base = {
833
+ timestamp: opts.timestamp ?? nowIso(),
834
+ courseId: opts.courseId,
835
+ sessionId: opts.sessionId,
836
+ attemptId: opts.attemptId,
837
+ user: opts.user
838
+ };
839
+ return entry.build(opts, base);
840
+ }
841
+ function getTelemetryEventRegistryEntry(name) {
842
+ return TELEMETRY_EVENT_REGISTRY[name];
843
+ }
844
+
845
+ // src/telemetryBuilder.ts
846
+ var warnedMissingQuizLesson = false;
847
+ var warnedMissingAssessmentLesson = false;
848
+ function resetTelemetryBuilderWarningsForTests() {
849
+ warnedMissingQuizLesson = false;
850
+ warnedMissingAssessmentLesson = false;
851
+ }
852
+ function buildTelemetryEvent(opts) {
853
+ return buildTelemetryEventFromRegistry(opts);
425
854
  }
426
855
  function tryBuildTelemetryEvent(opts) {
427
- const needsLesson = opts.name === "quiz_answered" || opts.name === "quiz_completed" || opts.name === "assessment_answered" || opts.name === "assessment_completed";
428
- if (needsLesson && !opts.lessonId) {
429
- if (isDevEnvironment2()) {
430
- if (opts.name.startsWith("quiz_") && !warnedMissingQuizLesson) {
856
+ const entry = getTelemetryEventRegistryEntry(opts.name);
857
+ if (entry.requiresLessonId && !opts.lessonId && entry.tryBuildMissingLessonWarning) {
858
+ if (isDevEnvironment()) {
859
+ if (entry.tryBuildMissingLessonWarning === "quiz" && !warnedMissingQuizLesson) {
431
860
  warnedMissingQuizLesson = true;
432
861
  console.warn(
433
862
  `[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
434
863
  );
435
864
  }
436
- if (opts.name.startsWith("assessment_") && !warnedMissingAssessmentLesson) {
865
+ if (entry.tryBuildMissingLessonWarning === "assessment" && !warnedMissingAssessmentLesson) {
437
866
  warnedMissingAssessmentLesson = true;
438
867
  console.warn(
439
868
  `[lessonkit] ${opts.name} skipped: wrap assessment blocks in <Lesson> so an active lessonId is available`
@@ -446,29 +875,8 @@ function tryBuildTelemetryEvent(opts) {
446
875
  }
447
876
 
448
877
  // src/telemetryPipeline.ts
449
- function isDevEnvironment3() {
450
- const g = globalThis;
451
- return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
452
- }
453
- function warnSinkFailure(sinkId, err) {
454
- if (isDevEnvironment3()) {
455
- console.warn(
456
- `[lessonkit] telemetry sink "${sinkId}" failed:`,
457
- err instanceof Error ? err.message : err
458
- );
459
- }
460
- }
461
878
  function invokeSink(sink, event, emitCtx) {
462
- let result;
463
- try {
464
- result = sink.emit(event, emitCtx);
465
- } catch (err) {
466
- warnSinkFailure(sink.id, err);
467
- return;
468
- }
469
- if (result != null && typeof result.catch === "function") {
470
- void result.catch((err) => warnSinkFailure(sink.id, err));
471
- }
879
+ invokePipelineSink(sink.id, () => sink.emit(event, emitCtx));
472
880
  }
473
881
  function createTelemetryPipeline(sinks) {
474
882
  const list = [...sinks];
@@ -505,8 +913,7 @@ function createDefaultClock() {
505
913
  function createNoopStorage() {
506
914
  return {
507
915
  getItem: () => null,
508
- setItem: () => {
509
- }
916
+ setItem: () => true
510
917
  };
511
918
  }
512
919
  function createMemoryBackedSessionStorage(session) {
@@ -537,8 +944,10 @@ function createMemoryBackedSessionStorage(session) {
537
944
  memory.set(key, value);
538
945
  try {
539
946
  session.setItem(key, value);
947
+ return true;
540
948
  } catch {
541
949
  warnPersistFailure();
950
+ return false;
542
951
  }
543
952
  },
544
953
  removeItem: (key) => {
@@ -563,6 +972,7 @@ function createInMemorySessionStoragePort() {
563
972
  getItem: (key) => memory.get(key) ?? null,
564
973
  setItem: (key, value) => {
565
974
  memory.set(key, value);
975
+ return true;
566
976
  },
567
977
  removeItem: (key) => {
568
978
  memory.delete(key);
@@ -615,7 +1025,12 @@ function createProgressController() {
615
1025
  return { previousLessonId };
616
1026
  },
617
1027
  completeLesson: (lessonId, completedAtMs) => {
618
- if (completedLessonIds.has(lessonId)) return { didComplete: false };
1028
+ if (completedLessonIds.has(lessonId)) {
1029
+ if (activeLessonId === lessonId) {
1030
+ activeLessonId = void 0;
1031
+ }
1032
+ return { didComplete: false };
1033
+ }
619
1034
  completedLessonIds = new Set(completedLessonIds).add(lessonId);
620
1035
  if (activeLessonId === lessonId) {
621
1036
  activeLessonId = void 0;
@@ -635,6 +1050,12 @@ function createProgressController() {
635
1050
 
636
1051
  // src/session.ts
637
1052
  var SESSION_STORAGE_KEY = "lessonkit:sessionId";
1053
+ var volatileSessionIds = /* @__PURE__ */ new WeakMap();
1054
+ var sharedVolatileSessionId = null;
1055
+ function isDevEnvironment2() {
1056
+ const g = globalThis;
1057
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
1058
+ }
638
1059
  function getTabSessionId(storage) {
639
1060
  return storage.getItem(SESSION_STORAGE_KEY);
640
1061
  }
@@ -642,11 +1063,28 @@ var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
642
1063
  var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
643
1064
  var COURSE_STARTED_PIPELINE_PREFIX = "lessonkit:course_started_pipeline:";
644
1065
  function resolveSessionId(storage, provided) {
645
- if (provided) return provided;
1066
+ if (provided !== void 0) {
1067
+ const trimmed = provided.trim();
1068
+ if (trimmed.length > 0) return trimmed;
1069
+ }
646
1070
  const existing = storage.getItem(SESSION_STORAGE_KEY);
647
1071
  if (existing) return existing;
1072
+ const volatile = volatileSessionIds.get(storage);
1073
+ if (volatile) return volatile;
648
1074
  const id = createSessionId();
649
- storage.setItem(SESSION_STORAGE_KEY, id);
1075
+ const persisted = storage.setItem(SESSION_STORAGE_KEY, id);
1076
+ if (!persisted) {
1077
+ if (!sharedVolatileSessionId) {
1078
+ sharedVolatileSessionId = id;
1079
+ }
1080
+ volatileSessionIds.set(storage, sharedVolatileSessionId);
1081
+ if (isDevEnvironment2()) {
1082
+ console.warn(
1083
+ "[lessonkit] session id could not be persisted; reusing in-memory id for this tab."
1084
+ );
1085
+ }
1086
+ return sharedVolatileSessionId;
1087
+ }
650
1088
  return id;
651
1089
  }
652
1090
  function courseStartedStorageKey(sessionId, courseId) {
@@ -663,8 +1101,8 @@ function hasCourseStarted(storage, sessionId, courseId) {
663
1101
  return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
664
1102
  }
665
1103
  function markCourseStarted(storage, sessionId, courseId) {
666
- if (!courseId) return;
667
- storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
1104
+ if (!courseId) return false;
1105
+ return storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
668
1106
  }
669
1107
  function hasCourseStartedEmittedToTracking(storage, sessionId, courseId) {
670
1108
  if (!courseId) return false;
@@ -682,6 +1120,9 @@ function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
682
1120
  if (!courseId) return;
683
1121
  storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
684
1122
  }
1123
+ function resetSharedVolatileSessionIdForTests() {
1124
+ sharedVolatileSessionId = null;
1125
+ }
685
1126
  function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
686
1127
  if (!courseId || fromSessionId === toSessionId) return;
687
1128
  if (hasCourseStarted(storage, fromSessionId, courseId)) {
@@ -704,14 +1145,14 @@ function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
704
1145
  if (alreadyEmittedToSink) {
705
1146
  return { emitted: true, marked };
706
1147
  }
707
- if (marked) {
708
- return { emitted: false, marked: true };
709
- }
710
1148
  const emitted = deps.emitCourseStartedEvent(ctx);
711
- if (emitted) {
1149
+ if (emitted && !marked) {
712
1150
  markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
713
1151
  }
714
- return { emitted, marked: emitted };
1152
+ return {
1153
+ emitted,
1154
+ marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
1155
+ };
715
1156
  }
716
1157
  function buildCourseStartedTelemetryEvent(ctx) {
717
1158
  return buildTelemetryEvent({
@@ -744,101 +1185,13 @@ function completeCourseWithTelemetry(opts) {
744
1185
  return true;
745
1186
  }
746
1187
 
747
- // src/runtime/createLessonkitRuntime.ts
748
- function createLessonkitRuntime(config, ports = {}) {
749
- const storage = ports.storage ?? createSessionStoragePort();
750
- const clock = ports.clock ?? createDefaultClock();
751
- const configSnapshot = { ...config };
752
- let sessionId = resolveSessionId(storage, configSnapshot.session?.sessionId);
753
- let attemptId = configSnapshot.session?.attemptId;
754
- let user = configSnapshot.session?.user;
755
- let courseId = configSnapshot.courseId;
756
- let progress = createProgressController();
757
- const getSession = () => ({ sessionId, attemptId, user });
758
- const syncSessionFromConfig = (next) => {
759
- sessionId = resolveSessionId(storage, next.session?.sessionId);
760
- attemptId = next.session?.attemptId;
761
- user = next.session?.user;
762
- courseId = next.courseId;
763
- };
764
- syncSessionFromConfig(configSnapshot);
765
- const track = (name, data, emit, lessonId) => {
766
- const event = tryBuildTelemetryEvent({
767
- name,
768
- courseId,
769
- lessonId: lessonId ?? progress.getState().activeLessonId,
770
- sessionId,
771
- attemptId,
772
- user,
773
- data
774
- });
775
- if (!event) return;
776
- emit(event);
777
- };
778
- const emitLessonCompleted = (lessonId, durationMs, emitFn) => {
779
- emitFn("lesson_completed", { lessonId, durationMs }, lessonId);
780
- if (durationMs !== void 0) {
781
- emitFn("lesson_time_on_task", { lessonId, durationMs }, lessonId);
782
- }
783
- };
1188
+ // src/plugins/context.ts
1189
+ function buildPluginContext(opts) {
784
1190
  return {
785
- get config() {
786
- return configSnapshot;
787
- },
788
- get progress() {
789
- return progress;
790
- },
791
- getProgressState: () => progress.getState(),
792
- getSession,
793
- updateConfig(next) {
794
- if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
795
- if (next.runtimeVersion !== void 0) configSnapshot.runtimeVersion = next.runtimeVersion;
796
- if (next.plugins !== void 0) configSnapshot.plugins = next.plugins;
797
- if (next.session !== void 0) {
798
- configSnapshot.session = { ...configSnapshot.session, ...next.session };
799
- }
800
- syncSessionFromConfig(configSnapshot);
801
- },
802
- setActiveLesson(lessonId, emitFn) {
803
- const current = progress.getState();
804
- if (current.activeLessonId === lessonId) return;
805
- if (current.completedLessonIds.has(lessonId)) {
806
- progress.setActiveLesson(lessonId, clock.nowMs());
807
- return;
808
- }
809
- const previous = current.activeLessonId;
810
- if (previous && previous !== lessonId) {
811
- const completed = progress.completeLesson(previous, clock.nowMs());
812
- if (completed.didComplete) {
813
- emitLessonCompleted(previous, completed.durationMs, emitFn);
814
- }
815
- }
816
- progress.setActiveLesson(lessonId, clock.nowMs());
817
- emitFn("lesson_started", { lessonId }, lessonId);
818
- },
819
- completeLesson(lessonId, emitFn) {
820
- const result = progress.completeLesson(lessonId, clock.nowMs());
821
- if (!result.didComplete) return;
822
- emitLessonCompleted(lessonId, result.durationMs, emitFn);
823
- },
824
- completeCourse(emitFn) {
825
- const current = progress.getState();
826
- if (current.activeLessonId) {
827
- const lessonResult = progress.completeLesson(current.activeLessonId, clock.nowMs());
828
- if (lessonResult.didComplete) {
829
- emitLessonCompleted(current.activeLessonId, lessonResult.durationMs, emitFn);
830
- }
831
- }
832
- const result = progress.completeCourse();
833
- if (!result.didComplete) return;
834
- emitFn("course_completed");
835
- },
836
- track,
837
- resetForCourseChange(nextCourseId) {
838
- configSnapshot.courseId = nextCourseId;
839
- courseId = nextCourseId;
840
- progress = createProgressController();
841
- }
1191
+ courseId: opts.courseId,
1192
+ sessionId: opts.sessionId,
1193
+ attemptId: opts.attemptId,
1194
+ user: opts.user
842
1195
  };
843
1196
  }
844
1197
 
@@ -889,7 +1242,7 @@ function createPluginRegistry(plugins = []) {
889
1242
  const composeTrackingSink = (sink, ctxSource) => {
890
1243
  if (!sink) return void 0;
891
1244
  const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
892
- const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}`;
1245
+ const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${ctx.user?.id ?? ""}`;
893
1246
  const layers = [];
894
1247
  let composed = sink;
895
1248
  for (const plugin of list) {
@@ -929,6 +1282,174 @@ function createPluginRegistry(plugins = []) {
929
1282
  };
930
1283
  }
931
1284
 
1285
+ // src/runtime/createLessonkitRuntime.ts
1286
+ function resolvePluginHost(plugins) {
1287
+ if (!plugins) return null;
1288
+ if (typeof plugins === "object" && "runTelemetry" in plugins) return plugins;
1289
+ if (Array.isArray(plugins) && plugins.length > 0) return createPluginRegistry(plugins);
1290
+ return null;
1291
+ }
1292
+ function warnRuntimeV1Deprecated() {
1293
+ const g = globalThis;
1294
+ if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
1295
+ console.warn(
1296
+ '[lessonkit] runtimeVersion "v1" is deprecated; use "v2" (default). v1 will be removed in LessonKit 2.0.'
1297
+ );
1298
+ }
1299
+ function createLessonkitRuntime(config, ports = {}) {
1300
+ if (config.runtimeVersion === "v1") warnRuntimeV1Deprecated();
1301
+ const storage = ports.storage ?? createSessionStoragePort();
1302
+ const clock = ports.clock ?? createDefaultClock();
1303
+ const configSnapshot = { ...config };
1304
+ let sessionId = resolveSessionId(storage, configSnapshot.session?.sessionId);
1305
+ let attemptId = configSnapshot.session?.attemptId;
1306
+ let user = configSnapshot.session?.user;
1307
+ let courseId = configSnapshot.courseId;
1308
+ let progress = createProgressController();
1309
+ let pluginHost = resolvePluginHost(configSnapshot.plugins);
1310
+ const getPluginCtx = () => buildPluginContext({
1311
+ courseId,
1312
+ sessionId,
1313
+ attemptId,
1314
+ user
1315
+ });
1316
+ if (!configSnapshot.deferPluginSetup) {
1317
+ pluginHost?.setupAll(getPluginCtx());
1318
+ }
1319
+ const getSession = () => ({ sessionId, attemptId, user });
1320
+ const syncSessionFromConfig = (next) => {
1321
+ sessionId = resolveSessionId(storage, next.session?.sessionId);
1322
+ attemptId = next.session?.attemptId;
1323
+ user = next.session?.user;
1324
+ courseId = next.courseId;
1325
+ };
1326
+ const applyPluginsToEvent = (event) => {
1327
+ if (!pluginHost) return event;
1328
+ return pluginHost.runTelemetry(event, getPluginCtx());
1329
+ };
1330
+ const buildAndApply = (name, data, lessonId) => {
1331
+ const event = tryBuildTelemetryEvent({
1332
+ name,
1333
+ courseId,
1334
+ lessonId: lessonId ?? progress.getState().activeLessonId,
1335
+ sessionId,
1336
+ attemptId,
1337
+ user,
1338
+ data
1339
+ });
1340
+ if (!event) return null;
1341
+ return applyPluginsToEvent(event);
1342
+ };
1343
+ const wrapEmitFn = (emitFn) => {
1344
+ return (name, data, lessonId) => {
1345
+ const event = buildAndApply(name, data, lessonId);
1346
+ if (event === null) return;
1347
+ const eventLessonId = "lessonId" in event ? event.lessonId : lessonId;
1348
+ const eventData = "data" in event ? event.data : data;
1349
+ emitFn(event.name, eventData, eventLessonId);
1350
+ };
1351
+ };
1352
+ syncSessionFromConfig(configSnapshot);
1353
+ const track = (name, data, emit, lessonId) => {
1354
+ const event = buildAndApply(name, data, lessonId);
1355
+ if (!event) return;
1356
+ emit(event);
1357
+ };
1358
+ const emitLessonCompletedEvents = (lessonId, durationMs, emitFn) => {
1359
+ const wrapped = wrapEmitFn(emitFn);
1360
+ wrapped("lesson_completed", { lessonId, durationMs }, lessonId);
1361
+ if (durationMs !== void 0) {
1362
+ wrapped("lesson_time_on_task", { lessonId, durationMs }, lessonId);
1363
+ }
1364
+ };
1365
+ return {
1366
+ get config() {
1367
+ return configSnapshot;
1368
+ },
1369
+ get progress() {
1370
+ return progress;
1371
+ },
1372
+ get pluginHost() {
1373
+ return pluginHost;
1374
+ },
1375
+ getProgressState: () => progress.getState(),
1376
+ getSession,
1377
+ updateConfig(next) {
1378
+ const previousCourseId = courseId;
1379
+ const sessionKeyBefore = JSON.stringify({ sessionId, attemptId, user });
1380
+ if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
1381
+ if (next.runtimeVersion !== void 0) {
1382
+ if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
1383
+ configSnapshot.runtimeVersion = next.runtimeVersion;
1384
+ }
1385
+ if (next.session !== void 0) {
1386
+ configSnapshot.session = { ...configSnapshot.session, ...next.session };
1387
+ }
1388
+ syncSessionFromConfig(configSnapshot);
1389
+ const sessionKeyAfter = JSON.stringify({ sessionId, attemptId, user });
1390
+ if (next.courseId !== void 0 && next.courseId !== previousCourseId) {
1391
+ progress = createProgressController();
1392
+ }
1393
+ if (next.plugins !== void 0 && next.plugins !== pluginHost) {
1394
+ pluginHost?.disposeAll();
1395
+ configSnapshot.plugins = next.plugins;
1396
+ pluginHost = resolvePluginHost(configSnapshot.plugins);
1397
+ pluginHost?.setupAll(getPluginCtx());
1398
+ } else if (next.session !== void 0 && sessionKeyBefore !== sessionKeyAfter && pluginHost && !configSnapshot.deferPluginSetup) {
1399
+ pluginHost.disposeAll();
1400
+ pluginHost.setupAll(getPluginCtx());
1401
+ }
1402
+ },
1403
+ setActiveLesson(lessonId, emitFn) {
1404
+ const wrapped = wrapEmitFn(emitFn);
1405
+ const current = progress.getState();
1406
+ if (current.activeLessonId === lessonId) return;
1407
+ if (current.completedLessonIds.has(lessonId)) {
1408
+ progress.setActiveLesson(lessonId, clock.nowMs());
1409
+ return;
1410
+ }
1411
+ const previous = current.activeLessonId;
1412
+ if (previous && previous !== lessonId) {
1413
+ const completed = progress.completeLesson(previous, clock.nowMs());
1414
+ if (completed.didComplete) {
1415
+ emitLessonCompletedEvents(previous, completed.durationMs, wrapped);
1416
+ }
1417
+ }
1418
+ progress.setActiveLesson(lessonId, clock.nowMs());
1419
+ wrapped("lesson_started", { lessonId }, lessonId);
1420
+ },
1421
+ completeLesson(lessonId, emitFn) {
1422
+ completeLessonWithTelemetry({
1423
+ progress,
1424
+ lessonId,
1425
+ nowMs: clock.nowMs(),
1426
+ emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn))
1427
+ });
1428
+ },
1429
+ completeCourse(emitFn) {
1430
+ completeCourseWithTelemetry({
1431
+ progress,
1432
+ nowMs: clock.nowMs(),
1433
+ emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn)),
1434
+ emitCourseCompleted: () => wrapEmitFn(emitFn)("course_completed")
1435
+ });
1436
+ },
1437
+ track,
1438
+ scoreAssessment(input, _lessonId) {
1439
+ if (!pluginHost) return null;
1440
+ return pluginHost.scoreAssessment(input, getPluginCtx());
1441
+ },
1442
+ resetForCourseChange(nextCourseId) {
1443
+ configSnapshot.courseId = nextCourseId;
1444
+ courseId = nextCourseId;
1445
+ progress = createProgressController();
1446
+ },
1447
+ dispose() {
1448
+ pluginHost?.disposeAll();
1449
+ }
1450
+ };
1451
+ }
1452
+
932
1453
  // src/plugins/define.ts
933
1454
  function defineTelemetryPlugin(plugin) {
934
1455
  return plugin;
@@ -940,20 +1461,35 @@ function defineLifecyclePlugin(plugin) {
940
1461
  return plugin;
941
1462
  }
942
1463
  export {
1464
+ ACCORDION_FORBIDDEN_CHILD_TYPES,
1465
+ ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
1466
+ COMPOUND_MAX_NESTING_DEPTH,
1467
+ COMPOUND_RESUME_SCHEMA_VERSION,
943
1468
  ID_MAX_LENGTH,
944
1469
  ID_PATTERN,
1470
+ INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
1471
+ PAGE_ALLOWED_CHILD_TYPES,
945
1472
  SESSION_STORAGE_KEY,
1473
+ SLIDE_ALLOWED_CHILD_TYPES,
1474
+ SLIDE_DECK_ALLOWED_CHILD_TYPES,
946
1475
  TELEMETRY_EVENT_CATALOG,
947
1476
  TELEMETRY_EVENT_CATALOG_V2,
1477
+ TELEMETRY_EVENT_CATALOG_V3,
948
1478
  assertNever,
949
1479
  assertValidId,
950
1480
  buildCourseStartedTelemetryEvent,
951
1481
  buildLessonkitUrn,
1482
+ buildPluginContext,
952
1483
  buildTelemetryCatalog,
953
1484
  buildTelemetryCatalogV2,
1485
+ buildTelemetryCatalogV3,
954
1486
  buildTelemetryEvent,
1487
+ clampCompoundPageIndex,
1488
+ clearCompoundState,
955
1489
  completeCourseWithTelemetry,
956
1490
  completeLessonWithTelemetry,
1491
+ compoundStateStorageKey,
1492
+ createCompoundResumeState,
957
1493
  createDefaultClock,
958
1494
  createGlobalTimer,
959
1495
  createLessonkitRuntime,
@@ -969,10 +1505,13 @@ export {
969
1505
  defineLifecyclePlugin,
970
1506
  defineTelemetryPlugin,
971
1507
  deriveId,
1508
+ getAllowedChildTypes,
972
1509
  getTabSessionId,
973
1510
  hasCourseStarted,
974
1511
  hasCourseStartedEmittedToTracking,
975
1512
  hasCourseStartedPipelineDelivered,
1513
+ isChildTypeAllowed,
1514
+ loadCompoundState,
976
1515
  markCourseStarted,
977
1516
  markCourseStartedEmittedToTracking,
978
1517
  markCourseStartedPipelineDelivered,
@@ -980,13 +1519,17 @@ export {
980
1519
  nowIso,
981
1520
  parseBlockId,
982
1521
  parseCheckId,
1522
+ parseCompoundResumeState,
983
1523
  parseCourseId,
984
1524
  parseLessonId,
1525
+ resetSharedVolatileSessionIdForTests,
985
1526
  resetStoragePortForTests,
986
1527
  resetTelemetryBuilderWarningsForTests,
987
1528
  resolveSessionId,
1529
+ saveCompoundState,
988
1530
  slugifyId,
989
1531
  telemetryCatalogV2Version,
1532
+ telemetryCatalogV3Version,
990
1533
  telemetryCatalogVersion,
991
1534
  tryBuildTelemetryEvent,
992
1535
  tryEmitCourseStarted,