@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.cjs CHANGED
@@ -20,20 +20,35 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ ACCORDION_FORBIDDEN_CHILD_TYPES: () => ACCORDION_FORBIDDEN_CHILD_TYPES,
24
+ ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES: () => ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
25
+ COMPOUND_MAX_NESTING_DEPTH: () => COMPOUND_MAX_NESTING_DEPTH,
26
+ COMPOUND_RESUME_SCHEMA_VERSION: () => COMPOUND_RESUME_SCHEMA_VERSION,
23
27
  ID_MAX_LENGTH: () => ID_MAX_LENGTH,
24
28
  ID_PATTERN: () => ID_PATTERN,
29
+ INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES: () => INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
30
+ PAGE_ALLOWED_CHILD_TYPES: () => PAGE_ALLOWED_CHILD_TYPES,
25
31
  SESSION_STORAGE_KEY: () => SESSION_STORAGE_KEY,
32
+ SLIDE_ALLOWED_CHILD_TYPES: () => SLIDE_ALLOWED_CHILD_TYPES,
33
+ SLIDE_DECK_ALLOWED_CHILD_TYPES: () => SLIDE_DECK_ALLOWED_CHILD_TYPES,
26
34
  TELEMETRY_EVENT_CATALOG: () => TELEMETRY_EVENT_CATALOG,
27
35
  TELEMETRY_EVENT_CATALOG_V2: () => TELEMETRY_EVENT_CATALOG_V2,
36
+ TELEMETRY_EVENT_CATALOG_V3: () => TELEMETRY_EVENT_CATALOG_V3,
28
37
  assertNever: () => assertNever,
29
38
  assertValidId: () => assertValidId,
30
39
  buildCourseStartedTelemetryEvent: () => buildCourseStartedTelemetryEvent,
31
40
  buildLessonkitUrn: () => buildLessonkitUrn,
41
+ buildPluginContext: () => buildPluginContext,
32
42
  buildTelemetryCatalog: () => buildTelemetryCatalog,
33
43
  buildTelemetryCatalogV2: () => buildTelemetryCatalogV2,
44
+ buildTelemetryCatalogV3: () => buildTelemetryCatalogV3,
34
45
  buildTelemetryEvent: () => buildTelemetryEvent,
46
+ clampCompoundPageIndex: () => clampCompoundPageIndex,
47
+ clearCompoundState: () => clearCompoundState,
35
48
  completeCourseWithTelemetry: () => completeCourseWithTelemetry,
36
49
  completeLessonWithTelemetry: () => completeLessonWithTelemetry,
50
+ compoundStateStorageKey: () => compoundStateStorageKey,
51
+ createCompoundResumeState: () => createCompoundResumeState,
37
52
  createDefaultClock: () => createDefaultClock,
38
53
  createGlobalTimer: () => createGlobalTimer,
39
54
  createLessonkitRuntime: () => createLessonkitRuntime,
@@ -49,10 +64,13 @@ __export(index_exports, {
49
64
  defineLifecyclePlugin: () => defineLifecyclePlugin,
50
65
  defineTelemetryPlugin: () => defineTelemetryPlugin,
51
66
  deriveId: () => deriveId,
67
+ getAllowedChildTypes: () => getAllowedChildTypes,
52
68
  getTabSessionId: () => getTabSessionId,
53
69
  hasCourseStarted: () => hasCourseStarted,
54
70
  hasCourseStartedEmittedToTracking: () => hasCourseStartedEmittedToTracking,
55
71
  hasCourseStartedPipelineDelivered: () => hasCourseStartedPipelineDelivered,
72
+ isChildTypeAllowed: () => isChildTypeAllowed,
73
+ loadCompoundState: () => loadCompoundState,
56
74
  markCourseStarted: () => markCourseStarted,
57
75
  markCourseStartedEmittedToTracking: () => markCourseStartedEmittedToTracking,
58
76
  markCourseStartedPipelineDelivered: () => markCourseStartedPipelineDelivered,
@@ -60,13 +78,17 @@ __export(index_exports, {
60
78
  nowIso: () => nowIso,
61
79
  parseBlockId: () => parseBlockId,
62
80
  parseCheckId: () => parseCheckId,
81
+ parseCompoundResumeState: () => parseCompoundResumeState,
63
82
  parseCourseId: () => parseCourseId,
64
83
  parseLessonId: () => parseLessonId,
84
+ resetSharedVolatileSessionIdForTests: () => resetSharedVolatileSessionIdForTests,
65
85
  resetStoragePortForTests: () => resetStoragePortForTests,
66
86
  resetTelemetryBuilderWarningsForTests: () => resetTelemetryBuilderWarningsForTests,
67
87
  resolveSessionId: () => resolveSessionId,
88
+ saveCompoundState: () => saveCompoundState,
68
89
  slugifyId: () => slugifyId,
69
90
  telemetryCatalogV2Version: () => telemetryCatalogV2Version,
91
+ telemetryCatalogV3Version: () => telemetryCatalogV3Version,
70
92
  telemetryCatalogVersion: () => telemetryCatalogVersion,
71
93
  tryBuildTelemetryEvent: () => tryBuildTelemetryEvent,
72
94
  tryEmitCourseStarted: () => tryEmitCourseStarted,
@@ -136,21 +158,40 @@ function assertValidId(input, path = "id") {
136
158
  }
137
159
 
138
160
  // src/slugify.ts
161
+ function shortHash(input) {
162
+ let h = 0;
163
+ for (let i = 0; i < input.length; i++) {
164
+ h = Math.imul(31, h) + input.charCodeAt(i) >>> 0;
165
+ }
166
+ return h.toString(36);
167
+ }
168
+ function uniqueFallbackId(input, usedIds) {
169
+ const hash = shortHash(input);
170
+ for (let n = 0; n < 100; n++) {
171
+ const candidate = (n === 0 ? `id-${hash}` : `id-${hash}-${n}`).slice(0, 64);
172
+ const validated2 = validateId(candidate);
173
+ if (validated2.ok && !usedIds.has(validated2.id)) return validated2.id;
174
+ }
175
+ const timed = `id-${hash}-${Date.now().toString(36)}`.slice(0, 64);
176
+ const validated = validateId(timed);
177
+ return validated.ok ? validated.id : `id-${hash}`;
178
+ }
139
179
  function slugifyId(input) {
140
180
  const slug = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-").slice(0, 64);
141
- if (!slug.length) return "id";
142
- const candidate = /^[a-z]/.test(slug) ? slug : `id-${slug}`;
181
+ if (!slug.length) return uniqueFallbackId(input, /* @__PURE__ */ new Set());
182
+ const candidate = /^[a-z]/.test(slug) ? slug : `id-${slug}`.slice(0, 64);
143
183
  const validated = validateId(candidate);
144
- return validated.ok ? validated.id : "id";
184
+ return validated.ok ? validated.id : uniqueFallbackId(input, /* @__PURE__ */ new Set());
145
185
  }
146
186
  function deriveId(title, usedIds = /* @__PURE__ */ new Set()) {
147
187
  const base = slugifyId(title);
148
- if (!usedIds.has(base)) return base;
188
+ if (!usedIds.has(base) && validateId(base).ok) return base;
149
189
  for (let n = 2; n < 1e3; n++) {
150
- const candidate = `${base}-${n}`;
151
- if (!usedIds.has(candidate)) return candidate;
190
+ const candidate = `${base}-${n}`.slice(0, 64);
191
+ const validated = validateId(candidate);
192
+ if (validated.ok && !usedIds.has(validated.id)) return validated.id;
152
193
  }
153
- return `${base}-${Date.now()}`;
194
+ return uniqueFallbackId(`${title}-${Date.now()}`, usedIds);
154
195
  }
155
196
 
156
197
  // src/urn.ts
@@ -178,6 +219,187 @@ function buildLessonkitUrn(parts) {
178
219
  return urn;
179
220
  }
180
221
 
222
+ // src/compound.ts
223
+ var COMPOUND_RESUME_SCHEMA_VERSION = 1;
224
+ function createCompoundResumeState(input = {}) {
225
+ const childStates = {};
226
+ if (input.childStates) {
227
+ for (const [key, value] of Object.entries(input.childStates)) {
228
+ childStates[key] = value;
229
+ }
230
+ }
231
+ return {
232
+ schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
233
+ activePageIndex: input.activePageIndex ?? 0,
234
+ ...input.activeChapterIndex !== void 0 ? { activeChapterIndex: input.activeChapterIndex } : {},
235
+ childStates
236
+ };
237
+ }
238
+ function clampCompoundPageIndex(index, pageCount) {
239
+ if (pageCount < 1) return 0;
240
+ return Math.min(Math.max(0, Math.floor(index)), pageCount - 1);
241
+ }
242
+ function isJsonPrimitive(value) {
243
+ return value === null || typeof value === "boolean" || typeof value === "string" || typeof value === "number" && Number.isFinite(value);
244
+ }
245
+ function isPlainStringKeyMap(value) {
246
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
247
+ return Object.entries(value).every(
248
+ ([key, entry]) => typeof key === "string" && isJsonPrimitive(entry)
249
+ );
250
+ }
251
+ function isValidChildResumeValue(value) {
252
+ if (isJsonPrimitive(value)) return true;
253
+ if (Array.isArray(value)) return value.every((item) => isJsonPrimitive(item));
254
+ if (isPlainStringKeyMap(value)) return true;
255
+ return false;
256
+ }
257
+ function isPlainSerializableChildState(value) {
258
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
259
+ return Object.values(value).every(
260
+ (entry) => isValidChildResumeValue(entry)
261
+ );
262
+ }
263
+ function parseCompoundResumeState(raw) {
264
+ if (!raw || typeof raw !== "object") return null;
265
+ const obj = raw;
266
+ if (obj.schemaVersion !== COMPOUND_RESUME_SCHEMA_VERSION) return null;
267
+ if (typeof obj.activePageIndex !== "number" || !Number.isFinite(obj.activePageIndex)) return null;
268
+ const childStates = {};
269
+ if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
270
+ for (const [key, value] of Object.entries(obj.childStates)) {
271
+ if (isPlainSerializableChildState(value)) {
272
+ childStates[key] = value;
273
+ }
274
+ }
275
+ }
276
+ const activeChapterIndex = typeof obj.activeChapterIndex === "number" && Number.isFinite(obj.activeChapterIndex) ? obj.activeChapterIndex : void 0;
277
+ return {
278
+ schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
279
+ activePageIndex: Math.max(0, Math.floor(obj.activePageIndex)),
280
+ ...activeChapterIndex !== void 0 ? { activeChapterIndex: Math.max(0, Math.floor(activeChapterIndex)) } : {},
281
+ childStates
282
+ };
283
+ }
284
+
285
+ // src/internal/env.ts
286
+ function isDevEnvironment() {
287
+ const g = globalThis;
288
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
289
+ }
290
+ function warnDev(message, err) {
291
+ if (!isDevEnvironment()) return;
292
+ console.warn(message, err instanceof Error ? err.message : err);
293
+ }
294
+
295
+ // src/compoundState.ts
296
+ var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
297
+ function compoundStateStorageKey(courseId, compoundId) {
298
+ return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
299
+ }
300
+ function loadCompoundState(storage, courseId, compoundId) {
301
+ const key = compoundStateStorageKey(courseId, compoundId);
302
+ const raw = storage.getItem(key);
303
+ if (!raw) return null;
304
+ try {
305
+ const parsed = parseCompoundResumeState(JSON.parse(raw));
306
+ if (parsed === null && isDevEnvironment()) {
307
+ console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
308
+ }
309
+ return parsed;
310
+ } catch {
311
+ if (isDevEnvironment()) {
312
+ console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
313
+ }
314
+ return null;
315
+ }
316
+ }
317
+ function saveCompoundState(storage, courseId, compoundId, state) {
318
+ return storage.setItem(compoundStateStorageKey(courseId, compoundId), JSON.stringify(state));
319
+ }
320
+ function clearCompoundState(storage, courseId, compoundId) {
321
+ storage.removeItem?.(compoundStateStorageKey(courseId, compoundId));
322
+ }
323
+
324
+ // src/compoundAllowlists.ts
325
+ var PAGE_ALLOWED_CHILD_TYPES = [
326
+ "Text",
327
+ "Heading",
328
+ "Image",
329
+ "Scenario",
330
+ "Reflection",
331
+ "Quiz",
332
+ "KnowledgeCheck",
333
+ "TrueFalse",
334
+ "FillInTheBlanks",
335
+ "DragAndDrop",
336
+ "DragTheWords",
337
+ "MarkTheWords",
338
+ "Accordion",
339
+ "DialogCards",
340
+ "Flashcards",
341
+ "ImageHotspots",
342
+ "FindHotspot",
343
+ "FindMultipleHotspots",
344
+ "ImageSlider",
345
+ "ProgressTracker"
346
+ ];
347
+ var INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES = ["Page"];
348
+ var SLIDE_ALLOWED_CHILD_TYPES = [
349
+ "Text",
350
+ "Heading",
351
+ "Image",
352
+ "Scenario",
353
+ "Reflection",
354
+ "Quiz",
355
+ "KnowledgeCheck",
356
+ "TrueFalse",
357
+ "FillInTheBlanks",
358
+ "DragAndDrop",
359
+ "DragTheWords",
360
+ "MarkTheWords",
361
+ "Accordion",
362
+ "DialogCards",
363
+ "Flashcards",
364
+ "ImageHotspots",
365
+ "FindHotspot",
366
+ "FindMultipleHotspots",
367
+ "ImageSlider"
368
+ ];
369
+ var SLIDE_DECK_ALLOWED_CHILD_TYPES = ["Slide"];
370
+ var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
371
+ "TrueFalse",
372
+ "FillInTheBlanks",
373
+ "DragAndDrop",
374
+ "DragTheWords",
375
+ "MarkTheWords",
376
+ "Quiz",
377
+ "KnowledgeCheck",
378
+ "FindHotspot",
379
+ "FindMultipleHotspots"
380
+ ];
381
+ var ALLOWLISTS = {
382
+ Page: PAGE_ALLOWED_CHILD_TYPES,
383
+ InteractiveBook: INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
384
+ Slide: SLIDE_ALLOWED_CHILD_TYPES,
385
+ SlideDeck: SLIDE_DECK_ALLOWED_CHILD_TYPES,
386
+ AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES
387
+ };
388
+ var COMPOUND_MAX_NESTING_DEPTH = {
389
+ Page: 1,
390
+ InteractiveBook: 2,
391
+ Slide: 1,
392
+ SlideDeck: 2,
393
+ AssessmentSequence: 1
394
+ };
395
+ function getAllowedChildTypes(parent) {
396
+ return ALLOWLISTS[parent];
397
+ }
398
+ function isChildTypeAllowed(parent, childType) {
399
+ return ALLOWLISTS[parent].includes(childType);
400
+ }
401
+ var ACCORDION_FORBIDDEN_CHILD_TYPES = ["Accordion"];
402
+
181
403
  // src/telemetryCatalog.ts
182
404
  var telemetryCatalogVersion = 1;
183
405
  var TELEMETRY_EVENT_CATALOG = [
@@ -274,35 +496,99 @@ function buildTelemetryCatalogV2() {
274
496
  return TELEMETRY_EVENT_CATALOG_V2.map((entry) => ({ ...entry }));
275
497
  }
276
498
 
277
- // src/trackingClient.ts
278
- function isDevEnvironment() {
279
- const g = globalThis;
280
- return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
499
+ // src/telemetryCatalogV3.ts
500
+ var telemetryCatalogV3Version = 3;
501
+ var TELEMETRY_EVENT_CATALOG_V3 = [
502
+ {
503
+ name: "book_page_viewed",
504
+ description: "Learner viewed a page/chapter in an Interactive Book",
505
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
506
+ dataFields: ["blockId", "pageIndex", "pageTitle"],
507
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
508
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
509
+ },
510
+ {
511
+ name: "slide_viewed",
512
+ description: "Learner viewed a slide in a SlideDeck (Course Presentation)",
513
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
514
+ dataFields: ["blockId", "slideIndex", "slideTitle"],
515
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
516
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
517
+ },
518
+ {
519
+ name: "compound_page_viewed",
520
+ description: "Learner activated a page inside a compound container",
521
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
522
+ dataFields: ["blockId", "pageIndex", "parentType"],
523
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
524
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
525
+ },
526
+ {
527
+ name: "hotspot_opened",
528
+ description: "Learner opened an image hotspot popover",
529
+ requiredFields: ["courseId", "sessionId", "timestamp"],
530
+ dataFields: ["blockId", "hotspotId"],
531
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
532
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
533
+ },
534
+ {
535
+ name: "accordion_section_toggled",
536
+ description: "Learner expanded or collapsed an accordion section",
537
+ requiredFields: ["courseId", "sessionId", "timestamp"],
538
+ dataFields: ["blockId", "sectionId", "expanded"],
539
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
540
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
541
+ },
542
+ {
543
+ name: "flashcard_flipped",
544
+ description: "Learner flipped a flashcard",
545
+ requiredFields: ["courseId", "sessionId", "timestamp"],
546
+ dataFields: ["blockId", "cardIndex", "face"],
547
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
548
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
549
+ },
550
+ {
551
+ name: "image_slider_changed",
552
+ description: "Learner changed the active slide in an image slider",
553
+ requiredFields: ["courseId", "sessionId", "timestamp"],
554
+ dataFields: ["blockId", "slideIndex"],
555
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
556
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
557
+ }
558
+ ];
559
+ function buildTelemetryCatalogV3() {
560
+ return TELEMETRY_EVENT_CATALOG_V3.map((entry) => ({ ...entry }));
281
561
  }
562
+
563
+ // src/internal/sinkInvoke.ts
282
564
  function invokeTrackingSink(sink, event) {
283
565
  let result;
284
566
  try {
285
567
  result = sink(event);
286
568
  } catch (err) {
287
- if (isDevEnvironment()) {
288
- console.warn(
289
- "[lessonkit] tracking sink failed:",
290
- err instanceof Error ? err.message : err
291
- );
292
- }
569
+ warnDev("[lessonkit] tracking sink failed:", err);
293
570
  throw err;
294
571
  }
295
572
  if (result != null && typeof result.catch === "function") {
296
- void result.catch((err) => {
297
- if (isDevEnvironment()) {
298
- console.warn(
299
- "[lessonkit] tracking sink failed:",
300
- err instanceof Error ? err.message : err
301
- );
302
- }
303
- });
573
+ void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
574
+ }
575
+ }
576
+ function invokePipelineSink(sinkId, emit) {
577
+ let result;
578
+ try {
579
+ result = emit();
580
+ } catch (err) {
581
+ warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err);
582
+ return;
583
+ }
584
+ if (result != null && typeof result.catch === "function") {
585
+ void result.catch(
586
+ (err) => warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err)
587
+ );
304
588
  }
305
589
  }
590
+
591
+ // src/trackingClient.ts
306
592
  function createTrackingClient(opts) {
307
593
  const sink = opts?.sink;
308
594
  const batchSink = opts?.batchSink;
@@ -338,41 +624,58 @@ function createTrackingClient(opts) {
338
624
  let disposing = false;
339
625
  let intervalId;
340
626
  const runFlush = () => {
341
- if (!buffer.length) return Promise.resolve();
627
+ if (!buffer.length) return Promise.resolve(true);
342
628
  const events = buffer.splice(0, buffer.length);
343
- let sent = 0;
344
629
  let succeeded = false;
345
630
  return Promise.resolve().then(async () => {
346
631
  if (batchSink) {
347
632
  await batchSink(events);
348
633
  } else {
349
- for (const e of events) {
350
- await sink?.(e);
351
- sent += 1;
634
+ for (let i = 0; i < events.length; i++) {
635
+ try {
636
+ await sink?.(events[i]);
637
+ } catch {
638
+ buffer.unshift(...events.slice(i));
639
+ return;
640
+ }
352
641
  }
353
642
  }
354
643
  succeeded = true;
355
644
  }).catch(() => {
356
- buffer.unshift(...events.slice(sent));
357
- }).then(() => {
645
+ if (batchSink) {
646
+ buffer.unshift(...events);
647
+ }
648
+ }).then(async () => {
358
649
  if (succeeded && buffer.length > 0 && !disposed) {
359
650
  return runFlush();
360
651
  }
652
+ return succeeded;
361
653
  });
362
654
  };
363
655
  const flush = () => {
364
- if (disposed) return Promise.resolve();
656
+ if (disposed) return Promise.resolve(true);
365
657
  if (flushInFlight) return flushInFlight;
366
- if (!buffer.length) return Promise.resolve();
658
+ if (!buffer.length) return Promise.resolve(true);
367
659
  flushInFlight = runFlush().finally(() => {
368
660
  flushInFlight = null;
369
661
  });
370
662
  return flushInFlight;
371
663
  };
664
+ const MAX_DISPOSE_FLUSH_ATTEMPTS = 10;
372
665
  const drainAll = async () => {
373
- await flush();
374
- while (buffer.length > 0) {
375
- await flush();
666
+ let attempts = 0;
667
+ while (buffer.length > 0 && attempts < MAX_DISPOSE_FLUSH_ATTEMPTS) {
668
+ const delivered = await flush();
669
+ attempts += 1;
670
+ if (!delivered) break;
671
+ }
672
+ if (buffer.length > 0) {
673
+ if (isDevEnvironment()) {
674
+ console.warn(
675
+ `[lessonkit] dropped ${buffer.length} buffered telemetry event(s) after dispose flush cap`
676
+ );
677
+ }
678
+ buffer.length = 0;
376
679
  }
377
680
  };
378
681
  intervalId = flushIntervalMs > 0 ? globalThis.setInterval(() => void flush(), flushIntervalMs) : void 0;
@@ -381,13 +684,14 @@ function createTrackingClient(opts) {
381
684
  track: (event) => {
382
685
  if (disposed || disposing) return;
383
686
  if (buffer.length >= maxBufferSize) {
384
- buffer.shift();
687
+ opts?.onBufferDrop?.();
385
688
  if (!warnedBufferCap && isDevEnvironment()) {
386
689
  warnedBufferCap = true;
387
690
  console.warn(
388
- `[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; oldest events are dropped while the sink is unavailable.`
691
+ `[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
389
692
  );
390
693
  }
694
+ return;
391
695
  }
392
696
  buffer.push(event);
393
697
  if (buffer.length >= maxBatchSize) void flush();
@@ -420,96 +724,243 @@ function nowIso() {
420
724
  return (/* @__PURE__ */ new Date()).toISOString();
421
725
  }
422
726
 
423
- // src/telemetryBuilder.ts
424
- var warnedMissingQuizLesson = false;
425
- var warnedMissingAssessmentLesson = false;
426
- function isDevEnvironment2() {
427
- const g = globalThis;
428
- return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
429
- }
430
- function resetTelemetryBuilderWarningsForTests() {
431
- warnedMissingQuizLesson = false;
432
- warnedMissingAssessmentLesson = false;
433
- }
727
+ // src/telemetry/eventRegistry.ts
434
728
  function resolveLessonId(opts, eventName) {
435
729
  const lessonId = opts.lessonId ?? opts.data?.lessonId;
436
730
  if (!lessonId) throw new Error(`${eventName} requires lessonId`);
437
731
  return lessonId;
438
732
  }
439
- function buildTelemetryEvent(opts) {
440
- const base = {
441
- timestamp: opts.timestamp ?? nowIso(),
442
- courseId: opts.courseId,
443
- sessionId: opts.sessionId,
444
- attemptId: opts.attemptId,
445
- user: opts.user
446
- };
447
- switch (opts.name) {
448
- case "course_started":
449
- return { name: "course_started", ...base };
450
- case "course_completed":
451
- return { name: "course_completed", ...base };
452
- case "lesson_started": {
733
+ function withLessonScopedData(name, base, lessonId, data) {
734
+ return { name, ...base, lessonId, data: { ...data, lessonId } };
735
+ }
736
+ var TELEMETRY_EVENT_REGISTRY = {
737
+ course_started: {
738
+ build: (_opts, base) => ({ name: "course_started", ...base })
739
+ },
740
+ course_completed: {
741
+ build: (_opts, base) => ({ name: "course_completed", ...base })
742
+ },
743
+ lesson_started: {
744
+ requiresLessonId: true,
745
+ build: (opts, base) => {
746
+ if (opts.name !== "lesson_started") throw new Error("unexpected event");
453
747
  const lessonId = resolveLessonId(opts, "lesson_started");
748
+ return withLessonScopedData("lesson_started", base, lessonId, opts.data);
749
+ }
750
+ },
751
+ lesson_completed: {
752
+ requiresLessonId: true,
753
+ build: (opts, base) => {
754
+ if (opts.name !== "lesson_completed") throw new Error("unexpected event");
755
+ const lessonId = resolveLessonId(opts, opts.name);
756
+ return withLessonScopedData(opts.name, base, lessonId, opts.data);
757
+ }
758
+ },
759
+ lesson_time_on_task: {
760
+ requiresLessonId: true,
761
+ build: (opts, base) => {
762
+ if (opts.name !== "lesson_time_on_task") throw new Error("unexpected event");
763
+ const lessonId = resolveLessonId(opts, opts.name);
764
+ return withLessonScopedData(opts.name, base, lessonId, opts.data);
765
+ }
766
+ },
767
+ quiz_answered: {
768
+ requiresLessonId: true,
769
+ tryBuildMissingLessonWarning: "quiz",
770
+ build: (opts, base) => {
771
+ if (opts.name !== "quiz_answered") throw new Error("unexpected event");
772
+ const lessonId = opts.lessonId;
773
+ if (!lessonId) throw new Error("quiz_answered requires active lessonId");
454
774
  return {
455
- name: "lesson_started",
775
+ name: "quiz_answered",
456
776
  ...base,
457
777
  lessonId,
458
- data: { ...opts.data, lessonId }
778
+ data: opts.data
459
779
  };
460
780
  }
461
- case "lesson_completed":
462
- case "lesson_time_on_task": {
463
- const lessonId = resolveLessonId(opts, opts.name);
781
+ },
782
+ quiz_completed: {
783
+ requiresLessonId: true,
784
+ tryBuildMissingLessonWarning: "quiz",
785
+ build: (opts, base) => {
786
+ if (opts.name !== "quiz_completed") throw new Error("unexpected event");
787
+ const lessonId = opts.lessonId;
788
+ if (!lessonId) throw new Error("quiz_completed requires active lessonId");
464
789
  return {
465
- name: opts.name,
790
+ name: "quiz_completed",
466
791
  ...base,
467
792
  lessonId,
468
- data: { ...opts.data, lessonId }
793
+ data: opts.data
469
794
  };
470
795
  }
471
- case "quiz_answered": {
796
+ },
797
+ assessment_answered: {
798
+ requiresLessonId: true,
799
+ tryBuildMissingLessonWarning: "assessment",
800
+ build: (opts, base) => {
801
+ if (opts.name !== "assessment_answered") throw new Error("unexpected event");
472
802
  const lessonId = opts.lessonId;
473
- if (!lessonId) throw new Error("quiz_answered requires active lessonId");
474
- return { name: "quiz_answered", ...base, lessonId, data: opts.data };
803
+ if (!lessonId) throw new Error("assessment_answered requires active lessonId");
804
+ return {
805
+ name: "assessment_answered",
806
+ ...base,
807
+ lessonId,
808
+ data: opts.data
809
+ };
475
810
  }
476
- case "quiz_completed": {
811
+ },
812
+ assessment_completed: {
813
+ requiresLessonId: true,
814
+ tryBuildMissingLessonWarning: "assessment",
815
+ build: (opts, base) => {
816
+ if (opts.name !== "assessment_completed") throw new Error("unexpected event");
477
817
  const lessonId = opts.lessonId;
478
- if (!lessonId) throw new Error("quiz_completed requires active lessonId");
479
- return { name: "quiz_completed", ...base, lessonId, data: opts.data };
818
+ if (!lessonId) throw new Error("assessment_completed requires active lessonId");
819
+ return {
820
+ name: "assessment_completed",
821
+ ...base,
822
+ lessonId,
823
+ data: opts.data
824
+ };
825
+ }
826
+ },
827
+ interaction: {
828
+ build: (opts, base) => {
829
+ if (opts.name !== "interaction") throw new Error("unexpected event");
830
+ return {
831
+ name: "interaction",
832
+ ...base,
833
+ lessonId: opts.lessonId,
834
+ data: opts.data
835
+ };
480
836
  }
481
- case "assessment_answered": {
837
+ },
838
+ book_page_viewed: {
839
+ requiresLessonId: true,
840
+ build: (opts, base) => {
841
+ if (opts.name !== "book_page_viewed") throw new Error("unexpected event");
482
842
  const lessonId = opts.lessonId;
483
- if (!lessonId) throw new Error("assessment_answered requires active lessonId");
484
- return { name: "assessment_answered", ...base, lessonId, data: opts.data };
843
+ if (!lessonId) throw new Error("book_page_viewed requires active lessonId");
844
+ return {
845
+ name: "book_page_viewed",
846
+ ...base,
847
+ lessonId,
848
+ data: opts.data
849
+ };
485
850
  }
486
- case "assessment_completed": {
851
+ },
852
+ slide_viewed: {
853
+ requiresLessonId: true,
854
+ build: (opts, base) => {
855
+ if (opts.name !== "slide_viewed") throw new Error("unexpected event");
487
856
  const lessonId = opts.lessonId;
488
- if (!lessonId) throw new Error("assessment_completed requires active lessonId");
489
- return { name: "assessment_completed", ...base, lessonId, data: opts.data };
857
+ if (!lessonId) throw new Error("slide_viewed requires active lessonId");
858
+ return {
859
+ name: "slide_viewed",
860
+ ...base,
861
+ lessonId,
862
+ data: opts.data
863
+ };
490
864
  }
491
- case "interaction":
865
+ },
866
+ compound_page_viewed: {
867
+ requiresLessonId: true,
868
+ build: (opts, base) => {
869
+ if (opts.name !== "compound_page_viewed") throw new Error("unexpected event");
870
+ const lessonId = opts.lessonId;
871
+ if (!lessonId) throw new Error("compound_page_viewed requires active lessonId");
492
872
  return {
493
- name: "interaction",
873
+ name: "compound_page_viewed",
874
+ ...base,
875
+ lessonId,
876
+ data: opts.data
877
+ };
878
+ }
879
+ },
880
+ hotspot_opened: {
881
+ build: (opts, base) => {
882
+ if (opts.name !== "hotspot_opened") throw new Error("unexpected event");
883
+ return {
884
+ name: "hotspot_opened",
885
+ ...base,
886
+ lessonId: opts.lessonId,
887
+ data: opts.data
888
+ };
889
+ }
890
+ },
891
+ accordion_section_toggled: {
892
+ build: (opts, base) => {
893
+ if (opts.name !== "accordion_section_toggled") throw new Error("unexpected event");
894
+ return {
895
+ name: "accordion_section_toggled",
896
+ ...base,
897
+ lessonId: opts.lessonId,
898
+ data: opts.data
899
+ };
900
+ }
901
+ },
902
+ flashcard_flipped: {
903
+ build: (opts, base) => {
904
+ if (opts.name !== "flashcard_flipped") throw new Error("unexpected event");
905
+ return {
906
+ name: "flashcard_flipped",
907
+ ...base,
908
+ lessonId: opts.lessonId,
909
+ data: opts.data
910
+ };
911
+ }
912
+ },
913
+ image_slider_changed: {
914
+ build: (opts, base) => {
915
+ if (opts.name !== "image_slider_changed") throw new Error("unexpected event");
916
+ return {
917
+ name: "image_slider_changed",
494
918
  ...base,
495
919
  lessonId: opts.lessonId,
496
920
  data: opts.data
497
921
  };
498
- default:
499
- return assertNever(opts);
922
+ }
923
+ }
924
+ };
925
+ function buildTelemetryEventFromRegistry(opts) {
926
+ const entry = TELEMETRY_EVENT_REGISTRY[opts.name];
927
+ if (!entry) {
928
+ throw new Error("Unexpected value");
500
929
  }
930
+ const base = {
931
+ timestamp: opts.timestamp ?? nowIso(),
932
+ courseId: opts.courseId,
933
+ sessionId: opts.sessionId,
934
+ attemptId: opts.attemptId,
935
+ user: opts.user
936
+ };
937
+ return entry.build(opts, base);
938
+ }
939
+ function getTelemetryEventRegistryEntry(name) {
940
+ return TELEMETRY_EVENT_REGISTRY[name];
941
+ }
942
+
943
+ // src/telemetryBuilder.ts
944
+ var warnedMissingQuizLesson = false;
945
+ var warnedMissingAssessmentLesson = false;
946
+ function resetTelemetryBuilderWarningsForTests() {
947
+ warnedMissingQuizLesson = false;
948
+ warnedMissingAssessmentLesson = false;
949
+ }
950
+ function buildTelemetryEvent(opts) {
951
+ return buildTelemetryEventFromRegistry(opts);
501
952
  }
502
953
  function tryBuildTelemetryEvent(opts) {
503
- const needsLesson = opts.name === "quiz_answered" || opts.name === "quiz_completed" || opts.name === "assessment_answered" || opts.name === "assessment_completed";
504
- if (needsLesson && !opts.lessonId) {
505
- if (isDevEnvironment2()) {
506
- if (opts.name.startsWith("quiz_") && !warnedMissingQuizLesson) {
954
+ const entry = getTelemetryEventRegistryEntry(opts.name);
955
+ if (entry.requiresLessonId && !opts.lessonId && entry.tryBuildMissingLessonWarning) {
956
+ if (isDevEnvironment()) {
957
+ if (entry.tryBuildMissingLessonWarning === "quiz" && !warnedMissingQuizLesson) {
507
958
  warnedMissingQuizLesson = true;
508
959
  console.warn(
509
960
  `[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
510
961
  );
511
962
  }
512
- if (opts.name.startsWith("assessment_") && !warnedMissingAssessmentLesson) {
963
+ if (entry.tryBuildMissingLessonWarning === "assessment" && !warnedMissingAssessmentLesson) {
513
964
  warnedMissingAssessmentLesson = true;
514
965
  console.warn(
515
966
  `[lessonkit] ${opts.name} skipped: wrap assessment blocks in <Lesson> so an active lessonId is available`
@@ -522,29 +973,8 @@ function tryBuildTelemetryEvent(opts) {
522
973
  }
523
974
 
524
975
  // src/telemetryPipeline.ts
525
- function isDevEnvironment3() {
526
- const g = globalThis;
527
- return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
528
- }
529
- function warnSinkFailure(sinkId, err) {
530
- if (isDevEnvironment3()) {
531
- console.warn(
532
- `[lessonkit] telemetry sink "${sinkId}" failed:`,
533
- err instanceof Error ? err.message : err
534
- );
535
- }
536
- }
537
976
  function invokeSink(sink, event, emitCtx) {
538
- let result;
539
- try {
540
- result = sink.emit(event, emitCtx);
541
- } catch (err) {
542
- warnSinkFailure(sink.id, err);
543
- return;
544
- }
545
- if (result != null && typeof result.catch === "function") {
546
- void result.catch((err) => warnSinkFailure(sink.id, err));
547
- }
977
+ invokePipelineSink(sink.id, () => sink.emit(event, emitCtx));
548
978
  }
549
979
  function createTelemetryPipeline(sinks) {
550
980
  const list = [...sinks];
@@ -581,8 +1011,7 @@ function createDefaultClock() {
581
1011
  function createNoopStorage() {
582
1012
  return {
583
1013
  getItem: () => null,
584
- setItem: () => {
585
- }
1014
+ setItem: () => true
586
1015
  };
587
1016
  }
588
1017
  function createMemoryBackedSessionStorage(session) {
@@ -613,8 +1042,10 @@ function createMemoryBackedSessionStorage(session) {
613
1042
  memory.set(key, value);
614
1043
  try {
615
1044
  session.setItem(key, value);
1045
+ return true;
616
1046
  } catch {
617
1047
  warnPersistFailure();
1048
+ return false;
618
1049
  }
619
1050
  },
620
1051
  removeItem: (key) => {
@@ -639,6 +1070,7 @@ function createInMemorySessionStoragePort() {
639
1070
  getItem: (key) => memory.get(key) ?? null,
640
1071
  setItem: (key, value) => {
641
1072
  memory.set(key, value);
1073
+ return true;
642
1074
  },
643
1075
  removeItem: (key) => {
644
1076
  memory.delete(key);
@@ -691,7 +1123,12 @@ function createProgressController() {
691
1123
  return { previousLessonId };
692
1124
  },
693
1125
  completeLesson: (lessonId, completedAtMs) => {
694
- if (completedLessonIds.has(lessonId)) return { didComplete: false };
1126
+ if (completedLessonIds.has(lessonId)) {
1127
+ if (activeLessonId === lessonId) {
1128
+ activeLessonId = void 0;
1129
+ }
1130
+ return { didComplete: false };
1131
+ }
695
1132
  completedLessonIds = new Set(completedLessonIds).add(lessonId);
696
1133
  if (activeLessonId === lessonId) {
697
1134
  activeLessonId = void 0;
@@ -711,6 +1148,12 @@ function createProgressController() {
711
1148
 
712
1149
  // src/session.ts
713
1150
  var SESSION_STORAGE_KEY = "lessonkit:sessionId";
1151
+ var volatileSessionIds = /* @__PURE__ */ new WeakMap();
1152
+ var sharedVolatileSessionId = null;
1153
+ function isDevEnvironment2() {
1154
+ const g = globalThis;
1155
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
1156
+ }
714
1157
  function getTabSessionId(storage) {
715
1158
  return storage.getItem(SESSION_STORAGE_KEY);
716
1159
  }
@@ -718,11 +1161,28 @@ var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
718
1161
  var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
719
1162
  var COURSE_STARTED_PIPELINE_PREFIX = "lessonkit:course_started_pipeline:";
720
1163
  function resolveSessionId(storage, provided) {
721
- if (provided) return provided;
1164
+ if (provided !== void 0) {
1165
+ const trimmed = provided.trim();
1166
+ if (trimmed.length > 0) return trimmed;
1167
+ }
722
1168
  const existing = storage.getItem(SESSION_STORAGE_KEY);
723
1169
  if (existing) return existing;
1170
+ const volatile = volatileSessionIds.get(storage);
1171
+ if (volatile) return volatile;
724
1172
  const id = createSessionId();
725
- storage.setItem(SESSION_STORAGE_KEY, id);
1173
+ const persisted = storage.setItem(SESSION_STORAGE_KEY, id);
1174
+ if (!persisted) {
1175
+ if (!sharedVolatileSessionId) {
1176
+ sharedVolatileSessionId = id;
1177
+ }
1178
+ volatileSessionIds.set(storage, sharedVolatileSessionId);
1179
+ if (isDevEnvironment2()) {
1180
+ console.warn(
1181
+ "[lessonkit] session id could not be persisted; reusing in-memory id for this tab."
1182
+ );
1183
+ }
1184
+ return sharedVolatileSessionId;
1185
+ }
726
1186
  return id;
727
1187
  }
728
1188
  function courseStartedStorageKey(sessionId, courseId) {
@@ -739,8 +1199,8 @@ function hasCourseStarted(storage, sessionId, courseId) {
739
1199
  return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
740
1200
  }
741
1201
  function markCourseStarted(storage, sessionId, courseId) {
742
- if (!courseId) return;
743
- storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
1202
+ if (!courseId) return false;
1203
+ return storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
744
1204
  }
745
1205
  function hasCourseStartedEmittedToTracking(storage, sessionId, courseId) {
746
1206
  if (!courseId) return false;
@@ -758,6 +1218,9 @@ function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
758
1218
  if (!courseId) return;
759
1219
  storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
760
1220
  }
1221
+ function resetSharedVolatileSessionIdForTests() {
1222
+ sharedVolatileSessionId = null;
1223
+ }
761
1224
  function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
762
1225
  if (!courseId || fromSessionId === toSessionId) return;
763
1226
  if (hasCourseStarted(storage, fromSessionId, courseId)) {
@@ -780,14 +1243,14 @@ function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
780
1243
  if (alreadyEmittedToSink) {
781
1244
  return { emitted: true, marked };
782
1245
  }
783
- if (marked) {
784
- return { emitted: false, marked: true };
785
- }
786
1246
  const emitted = deps.emitCourseStartedEvent(ctx);
787
- if (emitted) {
1247
+ if (emitted && !marked) {
788
1248
  markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
789
1249
  }
790
- return { emitted, marked: emitted };
1250
+ return {
1251
+ emitted,
1252
+ marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
1253
+ };
791
1254
  }
792
1255
  function buildCourseStartedTelemetryEvent(ctx) {
793
1256
  return buildTelemetryEvent({
@@ -820,101 +1283,13 @@ function completeCourseWithTelemetry(opts) {
820
1283
  return true;
821
1284
  }
822
1285
 
823
- // src/runtime/createLessonkitRuntime.ts
824
- function createLessonkitRuntime(config, ports = {}) {
825
- const storage = ports.storage ?? createSessionStoragePort();
826
- const clock = ports.clock ?? createDefaultClock();
827
- const configSnapshot = { ...config };
828
- let sessionId = resolveSessionId(storage, configSnapshot.session?.sessionId);
829
- let attemptId = configSnapshot.session?.attemptId;
830
- let user = configSnapshot.session?.user;
831
- let courseId = configSnapshot.courseId;
832
- let progress = createProgressController();
833
- const getSession = () => ({ sessionId, attemptId, user });
834
- const syncSessionFromConfig = (next) => {
835
- sessionId = resolveSessionId(storage, next.session?.sessionId);
836
- attemptId = next.session?.attemptId;
837
- user = next.session?.user;
838
- courseId = next.courseId;
839
- };
840
- syncSessionFromConfig(configSnapshot);
841
- const track = (name, data, emit, lessonId) => {
842
- const event = tryBuildTelemetryEvent({
843
- name,
844
- courseId,
845
- lessonId: lessonId ?? progress.getState().activeLessonId,
846
- sessionId,
847
- attemptId,
848
- user,
849
- data
850
- });
851
- if (!event) return;
852
- emit(event);
853
- };
854
- const emitLessonCompleted = (lessonId, durationMs, emitFn) => {
855
- emitFn("lesson_completed", { lessonId, durationMs }, lessonId);
856
- if (durationMs !== void 0) {
857
- emitFn("lesson_time_on_task", { lessonId, durationMs }, lessonId);
858
- }
859
- };
1286
+ // src/plugins/context.ts
1287
+ function buildPluginContext(opts) {
860
1288
  return {
861
- get config() {
862
- return configSnapshot;
863
- },
864
- get progress() {
865
- return progress;
866
- },
867
- getProgressState: () => progress.getState(),
868
- getSession,
869
- updateConfig(next) {
870
- if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
871
- if (next.runtimeVersion !== void 0) configSnapshot.runtimeVersion = next.runtimeVersion;
872
- if (next.plugins !== void 0) configSnapshot.plugins = next.plugins;
873
- if (next.session !== void 0) {
874
- configSnapshot.session = { ...configSnapshot.session, ...next.session };
875
- }
876
- syncSessionFromConfig(configSnapshot);
877
- },
878
- setActiveLesson(lessonId, emitFn) {
879
- const current = progress.getState();
880
- if (current.activeLessonId === lessonId) return;
881
- if (current.completedLessonIds.has(lessonId)) {
882
- progress.setActiveLesson(lessonId, clock.nowMs());
883
- return;
884
- }
885
- const previous = current.activeLessonId;
886
- if (previous && previous !== lessonId) {
887
- const completed = progress.completeLesson(previous, clock.nowMs());
888
- if (completed.didComplete) {
889
- emitLessonCompleted(previous, completed.durationMs, emitFn);
890
- }
891
- }
892
- progress.setActiveLesson(lessonId, clock.nowMs());
893
- emitFn("lesson_started", { lessonId }, lessonId);
894
- },
895
- completeLesson(lessonId, emitFn) {
896
- const result = progress.completeLesson(lessonId, clock.nowMs());
897
- if (!result.didComplete) return;
898
- emitLessonCompleted(lessonId, result.durationMs, emitFn);
899
- },
900
- completeCourse(emitFn) {
901
- const current = progress.getState();
902
- if (current.activeLessonId) {
903
- const lessonResult = progress.completeLesson(current.activeLessonId, clock.nowMs());
904
- if (lessonResult.didComplete) {
905
- emitLessonCompleted(current.activeLessonId, lessonResult.durationMs, emitFn);
906
- }
907
- }
908
- const result = progress.completeCourse();
909
- if (!result.didComplete) return;
910
- emitFn("course_completed");
911
- },
912
- track,
913
- resetForCourseChange(nextCourseId) {
914
- configSnapshot.courseId = nextCourseId;
915
- courseId = nextCourseId;
916
- progress = createProgressController();
917
- }
1289
+ courseId: opts.courseId,
1290
+ sessionId: opts.sessionId,
1291
+ attemptId: opts.attemptId,
1292
+ user: opts.user
918
1293
  };
919
1294
  }
920
1295
 
@@ -965,7 +1340,7 @@ function createPluginRegistry(plugins = []) {
965
1340
  const composeTrackingSink = (sink, ctxSource) => {
966
1341
  if (!sink) return void 0;
967
1342
  const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
968
- const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}`;
1343
+ const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${ctx.user?.id ?? ""}`;
969
1344
  const layers = [];
970
1345
  let composed = sink;
971
1346
  for (const plugin of list) {
@@ -1005,6 +1380,174 @@ function createPluginRegistry(plugins = []) {
1005
1380
  };
1006
1381
  }
1007
1382
 
1383
+ // src/runtime/createLessonkitRuntime.ts
1384
+ function resolvePluginHost(plugins) {
1385
+ if (!plugins) return null;
1386
+ if (typeof plugins === "object" && "runTelemetry" in plugins) return plugins;
1387
+ if (Array.isArray(plugins) && plugins.length > 0) return createPluginRegistry(plugins);
1388
+ return null;
1389
+ }
1390
+ function warnRuntimeV1Deprecated() {
1391
+ const g = globalThis;
1392
+ if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
1393
+ console.warn(
1394
+ '[lessonkit] runtimeVersion "v1" is deprecated; use "v2" (default). v1 will be removed in LessonKit 2.0.'
1395
+ );
1396
+ }
1397
+ function createLessonkitRuntime(config, ports = {}) {
1398
+ if (config.runtimeVersion === "v1") warnRuntimeV1Deprecated();
1399
+ const storage = ports.storage ?? createSessionStoragePort();
1400
+ const clock = ports.clock ?? createDefaultClock();
1401
+ const configSnapshot = { ...config };
1402
+ let sessionId = resolveSessionId(storage, configSnapshot.session?.sessionId);
1403
+ let attemptId = configSnapshot.session?.attemptId;
1404
+ let user = configSnapshot.session?.user;
1405
+ let courseId = configSnapshot.courseId;
1406
+ let progress = createProgressController();
1407
+ let pluginHost = resolvePluginHost(configSnapshot.plugins);
1408
+ const getPluginCtx = () => buildPluginContext({
1409
+ courseId,
1410
+ sessionId,
1411
+ attemptId,
1412
+ user
1413
+ });
1414
+ if (!configSnapshot.deferPluginSetup) {
1415
+ pluginHost?.setupAll(getPluginCtx());
1416
+ }
1417
+ const getSession = () => ({ sessionId, attemptId, user });
1418
+ const syncSessionFromConfig = (next) => {
1419
+ sessionId = resolveSessionId(storage, next.session?.sessionId);
1420
+ attemptId = next.session?.attemptId;
1421
+ user = next.session?.user;
1422
+ courseId = next.courseId;
1423
+ };
1424
+ const applyPluginsToEvent = (event) => {
1425
+ if (!pluginHost) return event;
1426
+ return pluginHost.runTelemetry(event, getPluginCtx());
1427
+ };
1428
+ const buildAndApply = (name, data, lessonId) => {
1429
+ const event = tryBuildTelemetryEvent({
1430
+ name,
1431
+ courseId,
1432
+ lessonId: lessonId ?? progress.getState().activeLessonId,
1433
+ sessionId,
1434
+ attemptId,
1435
+ user,
1436
+ data
1437
+ });
1438
+ if (!event) return null;
1439
+ return applyPluginsToEvent(event);
1440
+ };
1441
+ const wrapEmitFn = (emitFn) => {
1442
+ return (name, data, lessonId) => {
1443
+ const event = buildAndApply(name, data, lessonId);
1444
+ if (event === null) return;
1445
+ const eventLessonId = "lessonId" in event ? event.lessonId : lessonId;
1446
+ const eventData = "data" in event ? event.data : data;
1447
+ emitFn(event.name, eventData, eventLessonId);
1448
+ };
1449
+ };
1450
+ syncSessionFromConfig(configSnapshot);
1451
+ const track = (name, data, emit, lessonId) => {
1452
+ const event = buildAndApply(name, data, lessonId);
1453
+ if (!event) return;
1454
+ emit(event);
1455
+ };
1456
+ const emitLessonCompletedEvents = (lessonId, durationMs, emitFn) => {
1457
+ const wrapped = wrapEmitFn(emitFn);
1458
+ wrapped("lesson_completed", { lessonId, durationMs }, lessonId);
1459
+ if (durationMs !== void 0) {
1460
+ wrapped("lesson_time_on_task", { lessonId, durationMs }, lessonId);
1461
+ }
1462
+ };
1463
+ return {
1464
+ get config() {
1465
+ return configSnapshot;
1466
+ },
1467
+ get progress() {
1468
+ return progress;
1469
+ },
1470
+ get pluginHost() {
1471
+ return pluginHost;
1472
+ },
1473
+ getProgressState: () => progress.getState(),
1474
+ getSession,
1475
+ updateConfig(next) {
1476
+ const previousCourseId = courseId;
1477
+ const sessionKeyBefore = JSON.stringify({ sessionId, attemptId, user });
1478
+ if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
1479
+ if (next.runtimeVersion !== void 0) {
1480
+ if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
1481
+ configSnapshot.runtimeVersion = next.runtimeVersion;
1482
+ }
1483
+ if (next.session !== void 0) {
1484
+ configSnapshot.session = { ...configSnapshot.session, ...next.session };
1485
+ }
1486
+ syncSessionFromConfig(configSnapshot);
1487
+ const sessionKeyAfter = JSON.stringify({ sessionId, attemptId, user });
1488
+ if (next.courseId !== void 0 && next.courseId !== previousCourseId) {
1489
+ progress = createProgressController();
1490
+ }
1491
+ if (next.plugins !== void 0 && next.plugins !== pluginHost) {
1492
+ pluginHost?.disposeAll();
1493
+ configSnapshot.plugins = next.plugins;
1494
+ pluginHost = resolvePluginHost(configSnapshot.plugins);
1495
+ pluginHost?.setupAll(getPluginCtx());
1496
+ } else if (next.session !== void 0 && sessionKeyBefore !== sessionKeyAfter && pluginHost && !configSnapshot.deferPluginSetup) {
1497
+ pluginHost.disposeAll();
1498
+ pluginHost.setupAll(getPluginCtx());
1499
+ }
1500
+ },
1501
+ setActiveLesson(lessonId, emitFn) {
1502
+ const wrapped = wrapEmitFn(emitFn);
1503
+ const current = progress.getState();
1504
+ if (current.activeLessonId === lessonId) return;
1505
+ if (current.completedLessonIds.has(lessonId)) {
1506
+ progress.setActiveLesson(lessonId, clock.nowMs());
1507
+ return;
1508
+ }
1509
+ const previous = current.activeLessonId;
1510
+ if (previous && previous !== lessonId) {
1511
+ const completed = progress.completeLesson(previous, clock.nowMs());
1512
+ if (completed.didComplete) {
1513
+ emitLessonCompletedEvents(previous, completed.durationMs, wrapped);
1514
+ }
1515
+ }
1516
+ progress.setActiveLesson(lessonId, clock.nowMs());
1517
+ wrapped("lesson_started", { lessonId }, lessonId);
1518
+ },
1519
+ completeLesson(lessonId, emitFn) {
1520
+ completeLessonWithTelemetry({
1521
+ progress,
1522
+ lessonId,
1523
+ nowMs: clock.nowMs(),
1524
+ emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn))
1525
+ });
1526
+ },
1527
+ completeCourse(emitFn) {
1528
+ completeCourseWithTelemetry({
1529
+ progress,
1530
+ nowMs: clock.nowMs(),
1531
+ emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn)),
1532
+ emitCourseCompleted: () => wrapEmitFn(emitFn)("course_completed")
1533
+ });
1534
+ },
1535
+ track,
1536
+ scoreAssessment(input, _lessonId) {
1537
+ if (!pluginHost) return null;
1538
+ return pluginHost.scoreAssessment(input, getPluginCtx());
1539
+ },
1540
+ resetForCourseChange(nextCourseId) {
1541
+ configSnapshot.courseId = nextCourseId;
1542
+ courseId = nextCourseId;
1543
+ progress = createProgressController();
1544
+ },
1545
+ dispose() {
1546
+ pluginHost?.disposeAll();
1547
+ }
1548
+ };
1549
+ }
1550
+
1008
1551
  // src/plugins/define.ts
1009
1552
  function defineTelemetryPlugin(plugin) {
1010
1553
  return plugin;
@@ -1017,20 +1560,35 @@ function defineLifecyclePlugin(plugin) {
1017
1560
  }
1018
1561
  // Annotate the CommonJS export names for ESM import in node:
1019
1562
  0 && (module.exports = {
1563
+ ACCORDION_FORBIDDEN_CHILD_TYPES,
1564
+ ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
1565
+ COMPOUND_MAX_NESTING_DEPTH,
1566
+ COMPOUND_RESUME_SCHEMA_VERSION,
1020
1567
  ID_MAX_LENGTH,
1021
1568
  ID_PATTERN,
1569
+ INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
1570
+ PAGE_ALLOWED_CHILD_TYPES,
1022
1571
  SESSION_STORAGE_KEY,
1572
+ SLIDE_ALLOWED_CHILD_TYPES,
1573
+ SLIDE_DECK_ALLOWED_CHILD_TYPES,
1023
1574
  TELEMETRY_EVENT_CATALOG,
1024
1575
  TELEMETRY_EVENT_CATALOG_V2,
1576
+ TELEMETRY_EVENT_CATALOG_V3,
1025
1577
  assertNever,
1026
1578
  assertValidId,
1027
1579
  buildCourseStartedTelemetryEvent,
1028
1580
  buildLessonkitUrn,
1581
+ buildPluginContext,
1029
1582
  buildTelemetryCatalog,
1030
1583
  buildTelemetryCatalogV2,
1584
+ buildTelemetryCatalogV3,
1031
1585
  buildTelemetryEvent,
1586
+ clampCompoundPageIndex,
1587
+ clearCompoundState,
1032
1588
  completeCourseWithTelemetry,
1033
1589
  completeLessonWithTelemetry,
1590
+ compoundStateStorageKey,
1591
+ createCompoundResumeState,
1034
1592
  createDefaultClock,
1035
1593
  createGlobalTimer,
1036
1594
  createLessonkitRuntime,
@@ -1046,10 +1604,13 @@ function defineLifecyclePlugin(plugin) {
1046
1604
  defineLifecyclePlugin,
1047
1605
  defineTelemetryPlugin,
1048
1606
  deriveId,
1607
+ getAllowedChildTypes,
1049
1608
  getTabSessionId,
1050
1609
  hasCourseStarted,
1051
1610
  hasCourseStartedEmittedToTracking,
1052
1611
  hasCourseStartedPipelineDelivered,
1612
+ isChildTypeAllowed,
1613
+ loadCompoundState,
1053
1614
  markCourseStarted,
1054
1615
  markCourseStartedEmittedToTracking,
1055
1616
  markCourseStartedPipelineDelivered,
@@ -1057,13 +1618,17 @@ function defineLifecyclePlugin(plugin) {
1057
1618
  nowIso,
1058
1619
  parseBlockId,
1059
1620
  parseCheckId,
1621
+ parseCompoundResumeState,
1060
1622
  parseCourseId,
1061
1623
  parseLessonId,
1624
+ resetSharedVolatileSessionIdForTests,
1062
1625
  resetStoragePortForTests,
1063
1626
  resetTelemetryBuilderWarningsForTests,
1064
1627
  resolveSessionId,
1628
+ saveCompoundState,
1065
1629
  slugifyId,
1066
1630
  telemetryCatalogV2Version,
1631
+ telemetryCatalogV3Version,
1067
1632
  telemetryCatalogVersion,
1068
1633
  tryBuildTelemetryEvent,
1069
1634
  tryEmitCourseStarted,