@lessonkit/core 1.2.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
@@ -29,6 +29,8 @@ __export(index_exports, {
29
29
  INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES: () => INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
30
30
  PAGE_ALLOWED_CHILD_TYPES: () => PAGE_ALLOWED_CHILD_TYPES,
31
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,
32
34
  TELEMETRY_EVENT_CATALOG: () => TELEMETRY_EVENT_CATALOG,
33
35
  TELEMETRY_EVENT_CATALOG_V2: () => TELEMETRY_EVENT_CATALOG_V2,
34
36
  TELEMETRY_EVENT_CATALOG_V3: () => TELEMETRY_EVENT_CATALOG_V3,
@@ -79,6 +81,7 @@ __export(index_exports, {
79
81
  parseCompoundResumeState: () => parseCompoundResumeState,
80
82
  parseCourseId: () => parseCourseId,
81
83
  parseLessonId: () => parseLessonId,
84
+ resetSharedVolatileSessionIdForTests: () => resetSharedVolatileSessionIdForTests,
82
85
  resetStoragePortForTests: () => resetStoragePortForTests,
83
86
  resetTelemetryBuilderWarningsForTests: () => resetTelemetryBuilderWarningsForTests,
84
87
  resolveSessionId: () => resolveSessionId,
@@ -155,21 +158,40 @@ function assertValidId(input, path = "id") {
155
158
  }
156
159
 
157
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
+ }
158
179
  function slugifyId(input) {
159
180
  const slug = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-").slice(0, 64);
160
- if (!slug.length) return "id";
161
- 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);
162
183
  const validated = validateId(candidate);
163
- return validated.ok ? validated.id : "id";
184
+ return validated.ok ? validated.id : uniqueFallbackId(input, /* @__PURE__ */ new Set());
164
185
  }
165
186
  function deriveId(title, usedIds = /* @__PURE__ */ new Set()) {
166
187
  const base = slugifyId(title);
167
- if (!usedIds.has(base)) return base;
188
+ if (!usedIds.has(base) && validateId(base).ok) return base;
168
189
  for (let n = 2; n < 1e3; n++) {
169
- const candidate = `${base}-${n}`;
170
- 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;
171
193
  }
172
- return `${base}-${Date.now()}`;
194
+ return uniqueFallbackId(`${title}-${Date.now()}`, usedIds);
173
195
  }
174
196
 
175
197
  // src/urn.ts
@@ -217,6 +239,27 @@ function clampCompoundPageIndex(index, pageCount) {
217
239
  if (pageCount < 1) return 0;
218
240
  return Math.min(Math.max(0, Math.floor(index)), pageCount - 1);
219
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
+ }
220
263
  function parseCompoundResumeState(raw) {
221
264
  if (!raw || typeof raw !== "object") return null;
222
265
  const obj = raw;
@@ -225,7 +268,7 @@ function parseCompoundResumeState(raw) {
225
268
  const childStates = {};
226
269
  if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
227
270
  for (const [key, value] of Object.entries(obj.childStates)) {
228
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
271
+ if (isPlainSerializableChildState(value)) {
229
272
  childStates[key] = value;
230
273
  }
231
274
  }
@@ -239,22 +282,40 @@ function parseCompoundResumeState(raw) {
239
282
  };
240
283
  }
241
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
+
242
295
  // src/compoundState.ts
243
296
  var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
244
297
  function compoundStateStorageKey(courseId, compoundId) {
245
298
  return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
246
299
  }
247
300
  function loadCompoundState(storage, courseId, compoundId) {
248
- const raw = storage.getItem(compoundStateStorageKey(courseId, compoundId));
301
+ const key = compoundStateStorageKey(courseId, compoundId);
302
+ const raw = storage.getItem(key);
249
303
  if (!raw) return null;
250
304
  try {
251
- return parseCompoundResumeState(JSON.parse(raw));
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;
252
310
  } catch {
311
+ if (isDevEnvironment()) {
312
+ console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
313
+ }
253
314
  return null;
254
315
  }
255
316
  }
256
317
  function saveCompoundState(storage, courseId, compoundId, state) {
257
- storage.setItem(compoundStateStorageKey(courseId, compoundId), JSON.stringify(state));
318
+ return storage.setItem(compoundStateStorageKey(courseId, compoundId), JSON.stringify(state));
258
319
  }
259
320
  function clearCompoundState(storage, courseId, compoundId) {
260
321
  storage.removeItem?.(compoundStateStorageKey(courseId, compoundId));
@@ -284,6 +345,28 @@ var PAGE_ALLOWED_CHILD_TYPES = [
284
345
  "ProgressTracker"
285
346
  ];
286
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"];
287
370
  var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
288
371
  "TrueFalse",
289
372
  "FillInTheBlanks",
@@ -298,11 +381,15 @@ var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
298
381
  var ALLOWLISTS = {
299
382
  Page: PAGE_ALLOWED_CHILD_TYPES,
300
383
  InteractiveBook: INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
384
+ Slide: SLIDE_ALLOWED_CHILD_TYPES,
385
+ SlideDeck: SLIDE_DECK_ALLOWED_CHILD_TYPES,
301
386
  AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES
302
387
  };
303
388
  var COMPOUND_MAX_NESTING_DEPTH = {
304
389
  Page: 1,
305
390
  InteractiveBook: 2,
391
+ Slide: 1,
392
+ SlideDeck: 2,
306
393
  AssessmentSequence: 1
307
394
  };
308
395
  function getAllowedChildTypes(parent) {
@@ -420,6 +507,14 @@ var TELEMETRY_EVENT_CATALOG_V3 = [
420
507
  xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
421
508
  urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
422
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
+ },
423
518
  {
424
519
  name: "compound_page_viewed",
425
520
  description: "Learner activated a page inside a compound container",
@@ -465,16 +560,6 @@ function buildTelemetryCatalogV3() {
465
560
  return TELEMETRY_EVENT_CATALOG_V3.map((entry) => ({ ...entry }));
466
561
  }
467
562
 
468
- // src/internal/env.ts
469
- function isDevEnvironment() {
470
- const g = globalThis;
471
- return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
472
- }
473
- function warnDev(message, err) {
474
- if (!isDevEnvironment()) return;
475
- console.warn(message, err instanceof Error ? err.message : err);
476
- }
477
-
478
563
  // src/internal/sinkInvoke.ts
479
564
  function invokeTrackingSink(sink, event) {
480
565
  let result;
@@ -539,41 +624,58 @@ function createTrackingClient(opts) {
539
624
  let disposing = false;
540
625
  let intervalId;
541
626
  const runFlush = () => {
542
- if (!buffer.length) return Promise.resolve();
627
+ if (!buffer.length) return Promise.resolve(true);
543
628
  const events = buffer.splice(0, buffer.length);
544
- let sent = 0;
545
629
  let succeeded = false;
546
630
  return Promise.resolve().then(async () => {
547
631
  if (batchSink) {
548
632
  await batchSink(events);
549
633
  } else {
550
- for (const e of events) {
551
- await sink?.(e);
552
- 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
+ }
553
641
  }
554
642
  }
555
643
  succeeded = true;
556
644
  }).catch(() => {
557
- buffer.unshift(...events.slice(sent));
558
- }).then(() => {
645
+ if (batchSink) {
646
+ buffer.unshift(...events);
647
+ }
648
+ }).then(async () => {
559
649
  if (succeeded && buffer.length > 0 && !disposed) {
560
650
  return runFlush();
561
651
  }
652
+ return succeeded;
562
653
  });
563
654
  };
564
655
  const flush = () => {
565
- if (disposed) return Promise.resolve();
656
+ if (disposed) return Promise.resolve(true);
566
657
  if (flushInFlight) return flushInFlight;
567
- if (!buffer.length) return Promise.resolve();
658
+ if (!buffer.length) return Promise.resolve(true);
568
659
  flushInFlight = runFlush().finally(() => {
569
660
  flushInFlight = null;
570
661
  });
571
662
  return flushInFlight;
572
663
  };
664
+ const MAX_DISPOSE_FLUSH_ATTEMPTS = 10;
573
665
  const drainAll = async () => {
574
- await flush();
575
- while (buffer.length > 0) {
576
- 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;
577
679
  }
578
680
  };
579
681
  intervalId = flushIntervalMs > 0 ? globalThis.setInterval(() => void flush(), flushIntervalMs) : void 0;
@@ -582,13 +684,14 @@ function createTrackingClient(opts) {
582
684
  track: (event) => {
583
685
  if (disposed || disposing) return;
584
686
  if (buffer.length >= maxBufferSize) {
585
- buffer.shift();
687
+ opts?.onBufferDrop?.();
586
688
  if (!warnedBufferCap && isDevEnvironment()) {
587
689
  warnedBufferCap = true;
588
690
  console.warn(
589
- `[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.`
590
692
  );
591
693
  }
694
+ return;
592
695
  }
593
696
  buffer.push(event);
594
697
  if (buffer.length >= maxBatchSize) void flush();
@@ -746,6 +849,20 @@ var TELEMETRY_EVENT_REGISTRY = {
746
849
  };
747
850
  }
748
851
  },
852
+ slide_viewed: {
853
+ requiresLessonId: true,
854
+ build: (opts, base) => {
855
+ if (opts.name !== "slide_viewed") throw new Error("unexpected event");
856
+ const lessonId = opts.lessonId;
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
+ };
864
+ }
865
+ },
749
866
  compound_page_viewed: {
750
867
  requiresLessonId: true,
751
868
  build: (opts, base) => {
@@ -894,8 +1011,7 @@ function createDefaultClock() {
894
1011
  function createNoopStorage() {
895
1012
  return {
896
1013
  getItem: () => null,
897
- setItem: () => {
898
- }
1014
+ setItem: () => true
899
1015
  };
900
1016
  }
901
1017
  function createMemoryBackedSessionStorage(session) {
@@ -926,8 +1042,10 @@ function createMemoryBackedSessionStorage(session) {
926
1042
  memory.set(key, value);
927
1043
  try {
928
1044
  session.setItem(key, value);
1045
+ return true;
929
1046
  } catch {
930
1047
  warnPersistFailure();
1048
+ return false;
931
1049
  }
932
1050
  },
933
1051
  removeItem: (key) => {
@@ -952,6 +1070,7 @@ function createInMemorySessionStoragePort() {
952
1070
  getItem: (key) => memory.get(key) ?? null,
953
1071
  setItem: (key, value) => {
954
1072
  memory.set(key, value);
1073
+ return true;
955
1074
  },
956
1075
  removeItem: (key) => {
957
1076
  memory.delete(key);
@@ -1004,7 +1123,12 @@ function createProgressController() {
1004
1123
  return { previousLessonId };
1005
1124
  },
1006
1125
  completeLesson: (lessonId, completedAtMs) => {
1007
- 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
+ }
1008
1132
  completedLessonIds = new Set(completedLessonIds).add(lessonId);
1009
1133
  if (activeLessonId === lessonId) {
1010
1134
  activeLessonId = void 0;
@@ -1024,6 +1148,12 @@ function createProgressController() {
1024
1148
 
1025
1149
  // src/session.ts
1026
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
+ }
1027
1157
  function getTabSessionId(storage) {
1028
1158
  return storage.getItem(SESSION_STORAGE_KEY);
1029
1159
  }
@@ -1031,11 +1161,28 @@ var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
1031
1161
  var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
1032
1162
  var COURSE_STARTED_PIPELINE_PREFIX = "lessonkit:course_started_pipeline:";
1033
1163
  function resolveSessionId(storage, provided) {
1034
- if (provided) return provided;
1164
+ if (provided !== void 0) {
1165
+ const trimmed = provided.trim();
1166
+ if (trimmed.length > 0) return trimmed;
1167
+ }
1035
1168
  const existing = storage.getItem(SESSION_STORAGE_KEY);
1036
1169
  if (existing) return existing;
1170
+ const volatile = volatileSessionIds.get(storage);
1171
+ if (volatile) return volatile;
1037
1172
  const id = createSessionId();
1038
- 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
+ }
1039
1186
  return id;
1040
1187
  }
1041
1188
  function courseStartedStorageKey(sessionId, courseId) {
@@ -1052,8 +1199,8 @@ function hasCourseStarted(storage, sessionId, courseId) {
1052
1199
  return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
1053
1200
  }
1054
1201
  function markCourseStarted(storage, sessionId, courseId) {
1055
- if (!courseId) return;
1056
- storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
1202
+ if (!courseId) return false;
1203
+ return storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
1057
1204
  }
1058
1205
  function hasCourseStartedEmittedToTracking(storage, sessionId, courseId) {
1059
1206
  if (!courseId) return false;
@@ -1071,6 +1218,9 @@ function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
1071
1218
  if (!courseId) return;
1072
1219
  storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
1073
1220
  }
1221
+ function resetSharedVolatileSessionIdForTests() {
1222
+ sharedVolatileSessionId = null;
1223
+ }
1074
1224
  function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
1075
1225
  if (!courseId || fromSessionId === toSessionId) return;
1076
1226
  if (hasCourseStarted(storage, fromSessionId, courseId)) {
@@ -1093,14 +1243,14 @@ function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
1093
1243
  if (alreadyEmittedToSink) {
1094
1244
  return { emitted: true, marked };
1095
1245
  }
1096
- if (marked) {
1097
- return { emitted: false, marked: true };
1098
- }
1099
1246
  const emitted = deps.emitCourseStartedEvent(ctx);
1100
- if (emitted) {
1247
+ if (emitted && !marked) {
1101
1248
  markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
1102
1249
  }
1103
- return { emitted, marked: emitted };
1250
+ return {
1251
+ emitted,
1252
+ marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
1253
+ };
1104
1254
  }
1105
1255
  function buildCourseStartedTelemetryEvent(ctx) {
1106
1256
  return buildTelemetryEvent({
@@ -1190,7 +1340,7 @@ function createPluginRegistry(plugins = []) {
1190
1340
  const composeTrackingSink = (sink, ctxSource) => {
1191
1341
  if (!sink) return void 0;
1192
1342
  const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
1193
- 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 ?? ""}`;
1194
1344
  const layers = [];
1195
1345
  let composed = sink;
1196
1346
  for (const plugin of list) {
@@ -1261,6 +1411,9 @@ function createLessonkitRuntime(config, ports = {}) {
1261
1411
  attemptId,
1262
1412
  user
1263
1413
  });
1414
+ if (!configSnapshot.deferPluginSetup) {
1415
+ pluginHost?.setupAll(getPluginCtx());
1416
+ }
1264
1417
  const getSession = () => ({ sessionId, attemptId, user });
1265
1418
  const syncSessionFromConfig = (next) => {
1266
1419
  sessionId = resolveSessionId(storage, next.session?.sessionId);
@@ -1320,11 +1473,8 @@ function createLessonkitRuntime(config, ports = {}) {
1320
1473
  getProgressState: () => progress.getState(),
1321
1474
  getSession,
1322
1475
  updateConfig(next) {
1323
- if (next.plugins !== void 0 && next.plugins !== pluginHost) {
1324
- pluginHost?.disposeAll();
1325
- configSnapshot.plugins = next.plugins;
1326
- pluginHost = resolvePluginHost(configSnapshot.plugins);
1327
- }
1476
+ const previousCourseId = courseId;
1477
+ const sessionKeyBefore = JSON.stringify({ sessionId, attemptId, user });
1328
1478
  if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
1329
1479
  if (next.runtimeVersion !== void 0) {
1330
1480
  if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
@@ -1334,6 +1484,19 @@ function createLessonkitRuntime(config, ports = {}) {
1334
1484
  configSnapshot.session = { ...configSnapshot.session, ...next.session };
1335
1485
  }
1336
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
+ }
1337
1500
  },
1338
1501
  setActiveLesson(lessonId, emitFn) {
1339
1502
  const wrapped = wrapEmitFn(emitFn);
@@ -1406,6 +1569,8 @@ function defineLifecyclePlugin(plugin) {
1406
1569
  INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
1407
1570
  PAGE_ALLOWED_CHILD_TYPES,
1408
1571
  SESSION_STORAGE_KEY,
1572
+ SLIDE_ALLOWED_CHILD_TYPES,
1573
+ SLIDE_DECK_ALLOWED_CHILD_TYPES,
1409
1574
  TELEMETRY_EVENT_CATALOG,
1410
1575
  TELEMETRY_EVENT_CATALOG_V2,
1411
1576
  TELEMETRY_EVENT_CATALOG_V3,
@@ -1456,6 +1621,7 @@ function defineLifecyclePlugin(plugin) {
1456
1621
  parseCompoundResumeState,
1457
1622
  parseCourseId,
1458
1623
  parseLessonId,
1624
+ resetSharedVolatileSessionIdForTests,
1459
1625
  resetStoragePortForTests,
1460
1626
  resetTelemetryBuilderWarningsForTests,
1461
1627
  resolveSessionId,
package/dist/index.d.cts CHANGED
@@ -92,7 +92,7 @@ type AssessmentBaseProps = AssessmentBehaviour & {
92
92
  passingScore?: number;
93
93
  };
94
94
 
95
- type TelemetryEventName = "course_started" | "course_completed" | "lesson_started" | "lesson_completed" | "lesson_time_on_task" | "quiz_answered" | "quiz_completed" | "assessment_answered" | "assessment_completed" | "interaction" | "book_page_viewed" | "compound_page_viewed" | "hotspot_opened" | "accordion_section_toggled" | "flashcard_flipped" | "image_slider_changed";
95
+ type TelemetryEventName = "course_started" | "course_completed" | "lesson_started" | "lesson_completed" | "lesson_time_on_task" | "quiz_answered" | "quiz_completed" | "assessment_answered" | "assessment_completed" | "interaction" | "book_page_viewed" | "slide_viewed" | "compound_page_viewed" | "hotspot_opened" | "accordion_section_toggled" | "flashcard_flipped" | "image_slider_changed";
96
96
  type TelemetryUser = {
97
97
  id?: string;
98
98
  email?: string;
@@ -150,6 +150,11 @@ type BookPageViewedData = {
150
150
  pageIndex: number;
151
151
  pageTitle?: string;
152
152
  };
153
+ type SlideViewedData = {
154
+ blockId: BlockId;
155
+ slideIndex: number;
156
+ slideTitle?: string;
157
+ };
153
158
  type CompoundPageViewedData = {
154
159
  blockId: BlockId;
155
160
  pageIndex: number;
@@ -217,6 +222,10 @@ type TelemetryEvent = (TelemetryEventBase & {
217
222
  name: "book_page_viewed";
218
223
  lessonId: LessonId;
219
224
  data: BookPageViewedData;
225
+ }) | (TelemetryEventBase & {
226
+ name: "slide_viewed";
227
+ lessonId: LessonId;
228
+ data: SlideViewedData;
220
229
  }) | (TelemetryEventBase & {
221
230
  name: "compound_page_viewed";
222
231
  lessonId: LessonId;
@@ -248,7 +257,8 @@ type TelemetrySink = (event: TelemetryEvent) => void | Promise<void>;
248
257
  type TelemetryBatchSink = (events: TelemetryEvent[]) => void | Promise<void>;
249
258
  type TrackingClient = {
250
259
  track: (event: TelemetryEvent) => void;
251
- flush?: () => void | Promise<void>;
260
+ /** Resolves to true when all buffered events were delivered; false when a sink failure re-queued events. */
261
+ flush?: () => void | Promise<boolean>;
252
262
  dispose?: () => void | Promise<void>;
253
263
  };
254
264
 
@@ -289,7 +299,8 @@ type CompoundBaseProps = {
289
299
 
290
300
  type StoragePort = {
291
301
  getItem: (key: string) => string | null;
292
- setItem: (key: string, value: string) => void;
302
+ /** Returns false when the value could not be durably persisted (e.g. sessionStorage quota). */
303
+ setItem: (key: string, value: string) => boolean;
293
304
  removeItem?: (key: string) => void;
294
305
  /** @internal Test helper to clear in-memory fallback state. */
295
306
  resetForTests?: () => void;
@@ -310,14 +321,17 @@ declare function createGlobalTimer(): TimerPort;
310
321
 
311
322
  declare function compoundStateStorageKey(courseId: CourseId, compoundId: BlockId): string;
312
323
  declare function loadCompoundState(storage: StoragePort, courseId: CourseId, compoundId: BlockId): CompoundResumeState | null;
313
- declare function saveCompoundState(storage: StoragePort, courseId: CourseId, compoundId: BlockId, state: CompoundResumeState): void;
324
+ declare function saveCompoundState(storage: StoragePort, courseId: CourseId, compoundId: BlockId, state: CompoundResumeState): boolean;
314
325
  declare function clearCompoundState(storage: StoragePort, courseId: CourseId, compoundId: BlockId): void;
315
326
 
316
327
  /** Canonical compound child allowlists (H5P sub-content curation). */
317
328
  declare const PAGE_ALLOWED_CHILD_TYPES: readonly ["Text", "Heading", "Image", "Scenario", "Reflection", "Quiz", "KnowledgeCheck", "TrueFalse", "FillInTheBlanks", "DragAndDrop", "DragTheWords", "MarkTheWords", "Accordion", "DialogCards", "Flashcards", "ImageHotspots", "FindHotspot", "FindMultipleHotspots", "ImageSlider", "ProgressTracker"];
318
329
  declare const INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES: readonly ["Page"];
330
+ /** Per-slide content (H5P Course Presentation slide row). Excludes ProgressTracker. */
331
+ declare const SLIDE_ALLOWED_CHILD_TYPES: readonly ["Text", "Heading", "Image", "Scenario", "Reflection", "Quiz", "KnowledgeCheck", "TrueFalse", "FillInTheBlanks", "DragAndDrop", "DragTheWords", "MarkTheWords", "Accordion", "DialogCards", "Flashcards", "ImageHotspots", "FindHotspot", "FindMultipleHotspots", "ImageSlider"];
332
+ declare const SLIDE_DECK_ALLOWED_CHILD_TYPES: readonly ["Slide"];
319
333
  declare const ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES: readonly ["TrueFalse", "FillInTheBlanks", "DragAndDrop", "DragTheWords", "MarkTheWords", "Quiz", "KnowledgeCheck", "FindHotspot", "FindMultipleHotspots"];
320
- type CompoundParentType = "Page" | "InteractiveBook" | "AssessmentSequence";
334
+ type CompoundParentType = "Page" | "InteractiveBook" | "Slide" | "SlideDeck" | "AssessmentSequence";
321
335
  declare const COMPOUND_MAX_NESTING_DEPTH: Record<CompoundParentType, number>;
322
336
  declare function getAllowedChildTypes(parent: CompoundParentType): readonly string[];
323
337
  declare function isChildTypeAllowed(parent: CompoundParentType, childType: string): boolean;
@@ -349,7 +363,7 @@ declare const TELEMETRY_EVENT_CATALOG_V2: TelemetryCatalogV2Entry[];
349
363
  declare function buildTelemetryCatalogV2(): TelemetryCatalogV2Entry[];
350
364
 
351
365
  declare const telemetryCatalogV3Version: 3;
352
- type TelemetryCatalogV3EventName = Extract<TelemetryEventName, "book_page_viewed" | "compound_page_viewed" | "hotspot_opened" | "accordion_section_toggled" | "flashcard_flipped" | "image_slider_changed">;
366
+ type TelemetryCatalogV3EventName = Extract<TelemetryEventName, "book_page_viewed" | "slide_viewed" | "compound_page_viewed" | "hotspot_opened" | "accordion_section_toggled" | "flashcard_flipped" | "image_slider_changed">;
353
367
  type TelemetryCatalogV3Entry = {
354
368
  name: TelemetryCatalogV3EventName;
355
369
  description: string;
@@ -369,6 +383,8 @@ declare function createTrackingClient(opts?: {
369
383
  maxBatchSize?: number;
370
384
  };
371
385
  batchSink?: TelemetryBatchSink;
386
+ /** Called when an event is dropped because the batch buffer is at cap (including in production). */
387
+ onBufferDrop?: () => void;
372
388
  }): TrackingClient;
373
389
 
374
390
  declare function createSessionId(): string;
@@ -426,6 +442,10 @@ type BuildTelemetryEventInput = (BuildTelemetryEventContext & {
426
442
  name: "book_page_viewed";
427
443
  lessonId?: LessonId;
428
444
  data: BookPageViewedData;
445
+ }) | (BuildTelemetryEventContext & {
446
+ name: "slide_viewed";
447
+ lessonId?: LessonId;
448
+ data: SlideViewedData;
429
449
  }) | (BuildTelemetryEventContext & {
430
450
  name: "compound_page_viewed";
431
451
  lessonId?: LessonId;
@@ -501,11 +521,13 @@ declare const SESSION_STORAGE_KEY = "lessonkit:sessionId";
501
521
  declare function getTabSessionId(storage: StoragePort): string | null;
502
522
  declare function resolveSessionId(storage: StoragePort, provided?: string): string;
503
523
  declare function hasCourseStarted(storage: StoragePort, sessionId: string, courseId?: CourseId): boolean;
504
- declare function markCourseStarted(storage: StoragePort, sessionId: string, courseId?: CourseId): void;
524
+ declare function markCourseStarted(storage: StoragePort, sessionId: string, courseId?: CourseId): boolean;
505
525
  declare function hasCourseStartedEmittedToTracking(storage: StoragePort, sessionId: string, courseId?: CourseId): boolean;
506
526
  declare function markCourseStartedEmittedToTracking(storage: StoragePort, sessionId: string, courseId?: CourseId): void;
507
527
  declare function hasCourseStartedPipelineDelivered(storage: StoragePort, sessionId: string, courseId?: CourseId): boolean;
508
528
  declare function markCourseStartedPipelineDelivered(storage: StoragePort, sessionId: string, courseId?: CourseId): void;
529
+ /** @internal Reset shared volatile session id between tests. */
530
+ declare function resetSharedVolatileSessionIdForTests(): void;
509
531
  declare function migrateCourseStartedMark(storage: StoragePort, fromSessionId: string, toSessionId: string, courseId?: CourseId): void;
510
532
 
511
533
  /** Plugin category — aligns with roadmap extension areas. */
@@ -623,6 +645,8 @@ type HeadlessLessonkitConfig = {
623
645
  };
624
646
  /** Plugin list or registry; hooks run on {@link HeadlessLessonkitRuntime.track} and lifecycle emits. */
625
647
  plugins?: HeadlessLessonkitPlugins;
648
+ /** When true, skip initial {@link PluginHost.setupAll}; host caller runs setup (React v2 provider). */
649
+ deferPluginSetup?: boolean;
626
650
  };
627
651
  type HeadlessRuntimePorts = {
628
652
  storage?: StoragePort;
@@ -668,4 +692,4 @@ declare function buildPluginContext(opts: {
668
692
  user?: TelemetryUser;
669
693
  }): LessonkitPluginContext;
670
694
 
671
- export { ACCORDION_FORBIDDEN_CHILD_TYPES, ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES, type AccordionSectionToggledData, type AssessmentAnsweredData, type AssessmentBaseProps, type AssessmentBehaviour, type AssessmentCompletedData, type AssessmentHandle, type AssessmentInteractionType, type AssessmentPlugin, type AssessmentResumeState, type AssessmentScoreInput, type AssessmentScoreResult, type AssessmentXAPIData, type BlockId, type BookPageViewedData, type BuildTelemetryEventInput, COMPOUND_MAX_NESTING_DEPTH, COMPOUND_RESUME_SCHEMA_VERSION, type CheckId, type ClockPort, type CompoundBaseProps, type CompoundHandle, type CompoundPageViewedData, type CompoundParentType, type CompoundResumeInput, type CompoundResumeState, type CourseId, type CourseLifecycleContext, type CourseLifecycleDeps, type EmitContext, type FlashcardFlippedData, type HeadlessLessonkitConfig, type HeadlessLessonkitRuntime, type HeadlessRuntimePorts, type HotspotOpenedData, ID_MAX_LENGTH, ID_PATTERN, INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES, type IdentityIdPath, type IdentityValidationIssue, type IdentityValidationResult, type ImageSliderChangedData, type InteractionBlockRegistration, type InteractionData, type InteractionPlugin, type LessonCompletionEmitter, type LessonId, type LessonLifecycleData, type LessonkitPlugin, type LessonkitPluginContext, type LessonkitPluginKind, type LessonkitRuntimeVersion, type LessonkitUrn, type LessonkitUrnParts, type LifecyclePlugin, PAGE_ALLOWED_CHILD_TYPES, type PluginHost, type PluginIdentity, type PluginRegistry, type ProgressController, type ProgressState, type QuizAnsweredData, type QuizCompletedData, SESSION_STORAGE_KEY, type StoragePort, TELEMETRY_EVENT_CATALOG, TELEMETRY_EVENT_CATALOG_V2, TELEMETRY_EVENT_CATALOG_V3, type TelemetryBatchSink, type TelemetryCatalogEntry, type TelemetryCatalogV2Entry, type TelemetryCatalogV3Entry, type TelemetryDataFor, type TelemetryEmitFn, type TelemetryEvent, type TelemetryEventBase, type TelemetryEventName, type TelemetryPipeline, type TelemetryPipelineSink, type TelemetryPlugin, type TelemetrySink, type TelemetryUser, type TimerPort, type TrackingClient, assertNever, assertValidId, buildCourseStartedTelemetryEvent, buildLessonkitUrn, buildPluginContext, buildTelemetryCatalog, buildTelemetryCatalogV2, buildTelemetryCatalogV3, buildTelemetryEvent, clampCompoundPageIndex, clearCompoundState, completeCourseWithTelemetry, completeLessonWithTelemetry, compoundStateStorageKey, createCompoundResumeState, createDefaultClock, createGlobalTimer, createLessonkitRuntime, createNoopStorage, createPluginRegistry, createProgressController, createSessionId, createSessionStoragePort, createTelemetryPipeline, createTrackingClient, createTrackingPipelineSink, defineAssessmentPlugin, defineLifecyclePlugin, defineTelemetryPlugin, deriveId, getAllowedChildTypes, getTabSessionId, hasCourseStarted, hasCourseStartedEmittedToTracking, hasCourseStartedPipelineDelivered, isChildTypeAllowed, loadCompoundState, markCourseStarted, markCourseStartedEmittedToTracking, markCourseStartedPipelineDelivered, migrateCourseStartedMark, nowIso, parseBlockId, parseCheckId, parseCompoundResumeState, parseCourseId, parseLessonId, resetStoragePortForTests, resetTelemetryBuilderWarningsForTests, resolveSessionId, saveCompoundState, slugifyId, telemetryCatalogV2Version, telemetryCatalogV3Version, telemetryCatalogVersion, tryBuildTelemetryEvent, tryEmitCourseStarted, validateId };
695
+ export { ACCORDION_FORBIDDEN_CHILD_TYPES, ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES, type AccordionSectionToggledData, type AssessmentAnsweredData, type AssessmentBaseProps, type AssessmentBehaviour, type AssessmentCompletedData, type AssessmentHandle, type AssessmentInteractionType, type AssessmentPlugin, type AssessmentResumeState, type AssessmentScoreInput, type AssessmentScoreResult, type AssessmentXAPIData, type BlockId, type BookPageViewedData, type BuildTelemetryEventInput, COMPOUND_MAX_NESTING_DEPTH, COMPOUND_RESUME_SCHEMA_VERSION, type CheckId, type ClockPort, type CompoundBaseProps, type CompoundHandle, type CompoundPageViewedData, type CompoundParentType, type CompoundResumeInput, type CompoundResumeState, type CourseId, type CourseLifecycleContext, type CourseLifecycleDeps, type EmitContext, type FlashcardFlippedData, type HeadlessLessonkitConfig, type HeadlessLessonkitRuntime, type HeadlessRuntimePorts, type HotspotOpenedData, ID_MAX_LENGTH, ID_PATTERN, INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES, type IdentityIdPath, type IdentityValidationIssue, type IdentityValidationResult, type ImageSliderChangedData, type InteractionBlockRegistration, type InteractionData, type InteractionPlugin, type LessonCompletionEmitter, type LessonId, type LessonLifecycleData, type LessonkitPlugin, type LessonkitPluginContext, type LessonkitPluginKind, type LessonkitRuntimeVersion, type LessonkitUrn, type LessonkitUrnParts, type LifecyclePlugin, PAGE_ALLOWED_CHILD_TYPES, type PluginHost, type PluginIdentity, type PluginRegistry, type ProgressController, type ProgressState, type QuizAnsweredData, type QuizCompletedData, SESSION_STORAGE_KEY, SLIDE_ALLOWED_CHILD_TYPES, SLIDE_DECK_ALLOWED_CHILD_TYPES, type SlideViewedData, type StoragePort, TELEMETRY_EVENT_CATALOG, TELEMETRY_EVENT_CATALOG_V2, TELEMETRY_EVENT_CATALOG_V3, type TelemetryBatchSink, type TelemetryCatalogEntry, type TelemetryCatalogV2Entry, type TelemetryCatalogV3Entry, type TelemetryDataFor, type TelemetryEmitFn, type TelemetryEvent, type TelemetryEventBase, type TelemetryEventName, type TelemetryPipeline, type TelemetryPipelineSink, type TelemetryPlugin, type TelemetrySink, type TelemetryUser, type TimerPort, type TrackingClient, assertNever, assertValidId, buildCourseStartedTelemetryEvent, buildLessonkitUrn, buildPluginContext, buildTelemetryCatalog, buildTelemetryCatalogV2, buildTelemetryCatalogV3, buildTelemetryEvent, clampCompoundPageIndex, clearCompoundState, completeCourseWithTelemetry, completeLessonWithTelemetry, compoundStateStorageKey, createCompoundResumeState, createDefaultClock, createGlobalTimer, createLessonkitRuntime, createNoopStorage, createPluginRegistry, createProgressController, createSessionId, createSessionStoragePort, createTelemetryPipeline, createTrackingClient, createTrackingPipelineSink, defineAssessmentPlugin, defineLifecyclePlugin, defineTelemetryPlugin, deriveId, getAllowedChildTypes, getTabSessionId, hasCourseStarted, hasCourseStartedEmittedToTracking, hasCourseStartedPipelineDelivered, isChildTypeAllowed, loadCompoundState, markCourseStarted, markCourseStartedEmittedToTracking, markCourseStartedPipelineDelivered, migrateCourseStartedMark, nowIso, parseBlockId, parseCheckId, parseCompoundResumeState, parseCourseId, parseLessonId, resetSharedVolatileSessionIdForTests, resetStoragePortForTests, resetTelemetryBuilderWarningsForTests, resolveSessionId, saveCompoundState, slugifyId, telemetryCatalogV2Version, telemetryCatalogV3Version, telemetryCatalogVersion, tryBuildTelemetryEvent, tryEmitCourseStarted, validateId };
package/dist/index.d.ts CHANGED
@@ -92,7 +92,7 @@ type AssessmentBaseProps = AssessmentBehaviour & {
92
92
  passingScore?: number;
93
93
  };
94
94
 
95
- type TelemetryEventName = "course_started" | "course_completed" | "lesson_started" | "lesson_completed" | "lesson_time_on_task" | "quiz_answered" | "quiz_completed" | "assessment_answered" | "assessment_completed" | "interaction" | "book_page_viewed" | "compound_page_viewed" | "hotspot_opened" | "accordion_section_toggled" | "flashcard_flipped" | "image_slider_changed";
95
+ type TelemetryEventName = "course_started" | "course_completed" | "lesson_started" | "lesson_completed" | "lesson_time_on_task" | "quiz_answered" | "quiz_completed" | "assessment_answered" | "assessment_completed" | "interaction" | "book_page_viewed" | "slide_viewed" | "compound_page_viewed" | "hotspot_opened" | "accordion_section_toggled" | "flashcard_flipped" | "image_slider_changed";
96
96
  type TelemetryUser = {
97
97
  id?: string;
98
98
  email?: string;
@@ -150,6 +150,11 @@ type BookPageViewedData = {
150
150
  pageIndex: number;
151
151
  pageTitle?: string;
152
152
  };
153
+ type SlideViewedData = {
154
+ blockId: BlockId;
155
+ slideIndex: number;
156
+ slideTitle?: string;
157
+ };
153
158
  type CompoundPageViewedData = {
154
159
  blockId: BlockId;
155
160
  pageIndex: number;
@@ -217,6 +222,10 @@ type TelemetryEvent = (TelemetryEventBase & {
217
222
  name: "book_page_viewed";
218
223
  lessonId: LessonId;
219
224
  data: BookPageViewedData;
225
+ }) | (TelemetryEventBase & {
226
+ name: "slide_viewed";
227
+ lessonId: LessonId;
228
+ data: SlideViewedData;
220
229
  }) | (TelemetryEventBase & {
221
230
  name: "compound_page_viewed";
222
231
  lessonId: LessonId;
@@ -248,7 +257,8 @@ type TelemetrySink = (event: TelemetryEvent) => void | Promise<void>;
248
257
  type TelemetryBatchSink = (events: TelemetryEvent[]) => void | Promise<void>;
249
258
  type TrackingClient = {
250
259
  track: (event: TelemetryEvent) => void;
251
- flush?: () => void | Promise<void>;
260
+ /** Resolves to true when all buffered events were delivered; false when a sink failure re-queued events. */
261
+ flush?: () => void | Promise<boolean>;
252
262
  dispose?: () => void | Promise<void>;
253
263
  };
254
264
 
@@ -289,7 +299,8 @@ type CompoundBaseProps = {
289
299
 
290
300
  type StoragePort = {
291
301
  getItem: (key: string) => string | null;
292
- setItem: (key: string, value: string) => void;
302
+ /** Returns false when the value could not be durably persisted (e.g. sessionStorage quota). */
303
+ setItem: (key: string, value: string) => boolean;
293
304
  removeItem?: (key: string) => void;
294
305
  /** @internal Test helper to clear in-memory fallback state. */
295
306
  resetForTests?: () => void;
@@ -310,14 +321,17 @@ declare function createGlobalTimer(): TimerPort;
310
321
 
311
322
  declare function compoundStateStorageKey(courseId: CourseId, compoundId: BlockId): string;
312
323
  declare function loadCompoundState(storage: StoragePort, courseId: CourseId, compoundId: BlockId): CompoundResumeState | null;
313
- declare function saveCompoundState(storage: StoragePort, courseId: CourseId, compoundId: BlockId, state: CompoundResumeState): void;
324
+ declare function saveCompoundState(storage: StoragePort, courseId: CourseId, compoundId: BlockId, state: CompoundResumeState): boolean;
314
325
  declare function clearCompoundState(storage: StoragePort, courseId: CourseId, compoundId: BlockId): void;
315
326
 
316
327
  /** Canonical compound child allowlists (H5P sub-content curation). */
317
328
  declare const PAGE_ALLOWED_CHILD_TYPES: readonly ["Text", "Heading", "Image", "Scenario", "Reflection", "Quiz", "KnowledgeCheck", "TrueFalse", "FillInTheBlanks", "DragAndDrop", "DragTheWords", "MarkTheWords", "Accordion", "DialogCards", "Flashcards", "ImageHotspots", "FindHotspot", "FindMultipleHotspots", "ImageSlider", "ProgressTracker"];
318
329
  declare const INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES: readonly ["Page"];
330
+ /** Per-slide content (H5P Course Presentation slide row). Excludes ProgressTracker. */
331
+ declare const SLIDE_ALLOWED_CHILD_TYPES: readonly ["Text", "Heading", "Image", "Scenario", "Reflection", "Quiz", "KnowledgeCheck", "TrueFalse", "FillInTheBlanks", "DragAndDrop", "DragTheWords", "MarkTheWords", "Accordion", "DialogCards", "Flashcards", "ImageHotspots", "FindHotspot", "FindMultipleHotspots", "ImageSlider"];
332
+ declare const SLIDE_DECK_ALLOWED_CHILD_TYPES: readonly ["Slide"];
319
333
  declare const ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES: readonly ["TrueFalse", "FillInTheBlanks", "DragAndDrop", "DragTheWords", "MarkTheWords", "Quiz", "KnowledgeCheck", "FindHotspot", "FindMultipleHotspots"];
320
- type CompoundParentType = "Page" | "InteractiveBook" | "AssessmentSequence";
334
+ type CompoundParentType = "Page" | "InteractiveBook" | "Slide" | "SlideDeck" | "AssessmentSequence";
321
335
  declare const COMPOUND_MAX_NESTING_DEPTH: Record<CompoundParentType, number>;
322
336
  declare function getAllowedChildTypes(parent: CompoundParentType): readonly string[];
323
337
  declare function isChildTypeAllowed(parent: CompoundParentType, childType: string): boolean;
@@ -349,7 +363,7 @@ declare const TELEMETRY_EVENT_CATALOG_V2: TelemetryCatalogV2Entry[];
349
363
  declare function buildTelemetryCatalogV2(): TelemetryCatalogV2Entry[];
350
364
 
351
365
  declare const telemetryCatalogV3Version: 3;
352
- type TelemetryCatalogV3EventName = Extract<TelemetryEventName, "book_page_viewed" | "compound_page_viewed" | "hotspot_opened" | "accordion_section_toggled" | "flashcard_flipped" | "image_slider_changed">;
366
+ type TelemetryCatalogV3EventName = Extract<TelemetryEventName, "book_page_viewed" | "slide_viewed" | "compound_page_viewed" | "hotspot_opened" | "accordion_section_toggled" | "flashcard_flipped" | "image_slider_changed">;
353
367
  type TelemetryCatalogV3Entry = {
354
368
  name: TelemetryCatalogV3EventName;
355
369
  description: string;
@@ -369,6 +383,8 @@ declare function createTrackingClient(opts?: {
369
383
  maxBatchSize?: number;
370
384
  };
371
385
  batchSink?: TelemetryBatchSink;
386
+ /** Called when an event is dropped because the batch buffer is at cap (including in production). */
387
+ onBufferDrop?: () => void;
372
388
  }): TrackingClient;
373
389
 
374
390
  declare function createSessionId(): string;
@@ -426,6 +442,10 @@ type BuildTelemetryEventInput = (BuildTelemetryEventContext & {
426
442
  name: "book_page_viewed";
427
443
  lessonId?: LessonId;
428
444
  data: BookPageViewedData;
445
+ }) | (BuildTelemetryEventContext & {
446
+ name: "slide_viewed";
447
+ lessonId?: LessonId;
448
+ data: SlideViewedData;
429
449
  }) | (BuildTelemetryEventContext & {
430
450
  name: "compound_page_viewed";
431
451
  lessonId?: LessonId;
@@ -501,11 +521,13 @@ declare const SESSION_STORAGE_KEY = "lessonkit:sessionId";
501
521
  declare function getTabSessionId(storage: StoragePort): string | null;
502
522
  declare function resolveSessionId(storage: StoragePort, provided?: string): string;
503
523
  declare function hasCourseStarted(storage: StoragePort, sessionId: string, courseId?: CourseId): boolean;
504
- declare function markCourseStarted(storage: StoragePort, sessionId: string, courseId?: CourseId): void;
524
+ declare function markCourseStarted(storage: StoragePort, sessionId: string, courseId?: CourseId): boolean;
505
525
  declare function hasCourseStartedEmittedToTracking(storage: StoragePort, sessionId: string, courseId?: CourseId): boolean;
506
526
  declare function markCourseStartedEmittedToTracking(storage: StoragePort, sessionId: string, courseId?: CourseId): void;
507
527
  declare function hasCourseStartedPipelineDelivered(storage: StoragePort, sessionId: string, courseId?: CourseId): boolean;
508
528
  declare function markCourseStartedPipelineDelivered(storage: StoragePort, sessionId: string, courseId?: CourseId): void;
529
+ /** @internal Reset shared volatile session id between tests. */
530
+ declare function resetSharedVolatileSessionIdForTests(): void;
509
531
  declare function migrateCourseStartedMark(storage: StoragePort, fromSessionId: string, toSessionId: string, courseId?: CourseId): void;
510
532
 
511
533
  /** Plugin category — aligns with roadmap extension areas. */
@@ -623,6 +645,8 @@ type HeadlessLessonkitConfig = {
623
645
  };
624
646
  /** Plugin list or registry; hooks run on {@link HeadlessLessonkitRuntime.track} and lifecycle emits. */
625
647
  plugins?: HeadlessLessonkitPlugins;
648
+ /** When true, skip initial {@link PluginHost.setupAll}; host caller runs setup (React v2 provider). */
649
+ deferPluginSetup?: boolean;
626
650
  };
627
651
  type HeadlessRuntimePorts = {
628
652
  storage?: StoragePort;
@@ -668,4 +692,4 @@ declare function buildPluginContext(opts: {
668
692
  user?: TelemetryUser;
669
693
  }): LessonkitPluginContext;
670
694
 
671
- export { ACCORDION_FORBIDDEN_CHILD_TYPES, ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES, type AccordionSectionToggledData, type AssessmentAnsweredData, type AssessmentBaseProps, type AssessmentBehaviour, type AssessmentCompletedData, type AssessmentHandle, type AssessmentInteractionType, type AssessmentPlugin, type AssessmentResumeState, type AssessmentScoreInput, type AssessmentScoreResult, type AssessmentXAPIData, type BlockId, type BookPageViewedData, type BuildTelemetryEventInput, COMPOUND_MAX_NESTING_DEPTH, COMPOUND_RESUME_SCHEMA_VERSION, type CheckId, type ClockPort, type CompoundBaseProps, type CompoundHandle, type CompoundPageViewedData, type CompoundParentType, type CompoundResumeInput, type CompoundResumeState, type CourseId, type CourseLifecycleContext, type CourseLifecycleDeps, type EmitContext, type FlashcardFlippedData, type HeadlessLessonkitConfig, type HeadlessLessonkitRuntime, type HeadlessRuntimePorts, type HotspotOpenedData, ID_MAX_LENGTH, ID_PATTERN, INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES, type IdentityIdPath, type IdentityValidationIssue, type IdentityValidationResult, type ImageSliderChangedData, type InteractionBlockRegistration, type InteractionData, type InteractionPlugin, type LessonCompletionEmitter, type LessonId, type LessonLifecycleData, type LessonkitPlugin, type LessonkitPluginContext, type LessonkitPluginKind, type LessonkitRuntimeVersion, type LessonkitUrn, type LessonkitUrnParts, type LifecyclePlugin, PAGE_ALLOWED_CHILD_TYPES, type PluginHost, type PluginIdentity, type PluginRegistry, type ProgressController, type ProgressState, type QuizAnsweredData, type QuizCompletedData, SESSION_STORAGE_KEY, type StoragePort, TELEMETRY_EVENT_CATALOG, TELEMETRY_EVENT_CATALOG_V2, TELEMETRY_EVENT_CATALOG_V3, type TelemetryBatchSink, type TelemetryCatalogEntry, type TelemetryCatalogV2Entry, type TelemetryCatalogV3Entry, type TelemetryDataFor, type TelemetryEmitFn, type TelemetryEvent, type TelemetryEventBase, type TelemetryEventName, type TelemetryPipeline, type TelemetryPipelineSink, type TelemetryPlugin, type TelemetrySink, type TelemetryUser, type TimerPort, type TrackingClient, assertNever, assertValidId, buildCourseStartedTelemetryEvent, buildLessonkitUrn, buildPluginContext, buildTelemetryCatalog, buildTelemetryCatalogV2, buildTelemetryCatalogV3, buildTelemetryEvent, clampCompoundPageIndex, clearCompoundState, completeCourseWithTelemetry, completeLessonWithTelemetry, compoundStateStorageKey, createCompoundResumeState, createDefaultClock, createGlobalTimer, createLessonkitRuntime, createNoopStorage, createPluginRegistry, createProgressController, createSessionId, createSessionStoragePort, createTelemetryPipeline, createTrackingClient, createTrackingPipelineSink, defineAssessmentPlugin, defineLifecyclePlugin, defineTelemetryPlugin, deriveId, getAllowedChildTypes, getTabSessionId, hasCourseStarted, hasCourseStartedEmittedToTracking, hasCourseStartedPipelineDelivered, isChildTypeAllowed, loadCompoundState, markCourseStarted, markCourseStartedEmittedToTracking, markCourseStartedPipelineDelivered, migrateCourseStartedMark, nowIso, parseBlockId, parseCheckId, parseCompoundResumeState, parseCourseId, parseLessonId, resetStoragePortForTests, resetTelemetryBuilderWarningsForTests, resolveSessionId, saveCompoundState, slugifyId, telemetryCatalogV2Version, telemetryCatalogV3Version, telemetryCatalogVersion, tryBuildTelemetryEvent, tryEmitCourseStarted, validateId };
695
+ export { ACCORDION_FORBIDDEN_CHILD_TYPES, ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES, type AccordionSectionToggledData, type AssessmentAnsweredData, type AssessmentBaseProps, type AssessmentBehaviour, type AssessmentCompletedData, type AssessmentHandle, type AssessmentInteractionType, type AssessmentPlugin, type AssessmentResumeState, type AssessmentScoreInput, type AssessmentScoreResult, type AssessmentXAPIData, type BlockId, type BookPageViewedData, type BuildTelemetryEventInput, COMPOUND_MAX_NESTING_DEPTH, COMPOUND_RESUME_SCHEMA_VERSION, type CheckId, type ClockPort, type CompoundBaseProps, type CompoundHandle, type CompoundPageViewedData, type CompoundParentType, type CompoundResumeInput, type CompoundResumeState, type CourseId, type CourseLifecycleContext, type CourseLifecycleDeps, type EmitContext, type FlashcardFlippedData, type HeadlessLessonkitConfig, type HeadlessLessonkitRuntime, type HeadlessRuntimePorts, type HotspotOpenedData, ID_MAX_LENGTH, ID_PATTERN, INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES, type IdentityIdPath, type IdentityValidationIssue, type IdentityValidationResult, type ImageSliderChangedData, type InteractionBlockRegistration, type InteractionData, type InteractionPlugin, type LessonCompletionEmitter, type LessonId, type LessonLifecycleData, type LessonkitPlugin, type LessonkitPluginContext, type LessonkitPluginKind, type LessonkitRuntimeVersion, type LessonkitUrn, type LessonkitUrnParts, type LifecyclePlugin, PAGE_ALLOWED_CHILD_TYPES, type PluginHost, type PluginIdentity, type PluginRegistry, type ProgressController, type ProgressState, type QuizAnsweredData, type QuizCompletedData, SESSION_STORAGE_KEY, SLIDE_ALLOWED_CHILD_TYPES, SLIDE_DECK_ALLOWED_CHILD_TYPES, type SlideViewedData, type StoragePort, TELEMETRY_EVENT_CATALOG, TELEMETRY_EVENT_CATALOG_V2, TELEMETRY_EVENT_CATALOG_V3, type TelemetryBatchSink, type TelemetryCatalogEntry, type TelemetryCatalogV2Entry, type TelemetryCatalogV3Entry, type TelemetryDataFor, type TelemetryEmitFn, type TelemetryEvent, type TelemetryEventBase, type TelemetryEventName, type TelemetryPipeline, type TelemetryPipelineSink, type TelemetryPlugin, type TelemetrySink, type TelemetryUser, type TimerPort, type TrackingClient, assertNever, assertValidId, buildCourseStartedTelemetryEvent, buildLessonkitUrn, buildPluginContext, buildTelemetryCatalog, buildTelemetryCatalogV2, buildTelemetryCatalogV3, buildTelemetryEvent, clampCompoundPageIndex, clearCompoundState, completeCourseWithTelemetry, completeLessonWithTelemetry, compoundStateStorageKey, createCompoundResumeState, createDefaultClock, createGlobalTimer, createLessonkitRuntime, createNoopStorage, createPluginRegistry, createProgressController, createSessionId, createSessionStoragePort, createTelemetryPipeline, createTrackingClient, createTrackingPipelineSink, defineAssessmentPlugin, defineLifecyclePlugin, defineTelemetryPlugin, deriveId, getAllowedChildTypes, getTabSessionId, hasCourseStarted, hasCourseStartedEmittedToTracking, hasCourseStartedPipelineDelivered, isChildTypeAllowed, loadCompoundState, markCourseStarted, markCourseStartedEmittedToTracking, markCourseStartedPipelineDelivered, migrateCourseStartedMark, nowIso, parseBlockId, parseCheckId, parseCompoundResumeState, parseCourseId, parseLessonId, resetSharedVolatileSessionIdForTests, resetStoragePortForTests, resetTelemetryBuilderWarningsForTests, resolveSessionId, saveCompoundState, slugifyId, telemetryCatalogV2Version, telemetryCatalogV3Version, telemetryCatalogVersion, tryBuildTelemetryEvent, tryEmitCourseStarted, validateId };
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
@@ -122,6 +141,27 @@ function clampCompoundPageIndex(index, pageCount) {
122
141
  if (pageCount < 1) return 0;
123
142
  return Math.min(Math.max(0, Math.floor(index)), pageCount - 1);
124
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
+ }
125
165
  function parseCompoundResumeState(raw) {
126
166
  if (!raw || typeof raw !== "object") return null;
127
167
  const obj = raw;
@@ -130,7 +170,7 @@ function parseCompoundResumeState(raw) {
130
170
  const childStates = {};
131
171
  if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
132
172
  for (const [key, value] of Object.entries(obj.childStates)) {
133
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
173
+ if (isPlainSerializableChildState(value)) {
134
174
  childStates[key] = value;
135
175
  }
136
176
  }
@@ -144,22 +184,40 @@ function parseCompoundResumeState(raw) {
144
184
  };
145
185
  }
146
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
+
147
197
  // src/compoundState.ts
148
198
  var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
149
199
  function compoundStateStorageKey(courseId, compoundId) {
150
200
  return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
151
201
  }
152
202
  function loadCompoundState(storage, courseId, compoundId) {
153
- const raw = storage.getItem(compoundStateStorageKey(courseId, compoundId));
203
+ const key = compoundStateStorageKey(courseId, compoundId);
204
+ const raw = storage.getItem(key);
154
205
  if (!raw) return null;
155
206
  try {
156
- return parseCompoundResumeState(JSON.parse(raw));
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;
157
212
  } catch {
213
+ if (isDevEnvironment()) {
214
+ console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
215
+ }
158
216
  return null;
159
217
  }
160
218
  }
161
219
  function saveCompoundState(storage, courseId, compoundId, state) {
162
- storage.setItem(compoundStateStorageKey(courseId, compoundId), JSON.stringify(state));
220
+ return storage.setItem(compoundStateStorageKey(courseId, compoundId), JSON.stringify(state));
163
221
  }
164
222
  function clearCompoundState(storage, courseId, compoundId) {
165
223
  storage.removeItem?.(compoundStateStorageKey(courseId, compoundId));
@@ -189,6 +247,28 @@ var PAGE_ALLOWED_CHILD_TYPES = [
189
247
  "ProgressTracker"
190
248
  ];
191
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"];
192
272
  var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
193
273
  "TrueFalse",
194
274
  "FillInTheBlanks",
@@ -203,11 +283,15 @@ var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
203
283
  var ALLOWLISTS = {
204
284
  Page: PAGE_ALLOWED_CHILD_TYPES,
205
285
  InteractiveBook: INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
286
+ Slide: SLIDE_ALLOWED_CHILD_TYPES,
287
+ SlideDeck: SLIDE_DECK_ALLOWED_CHILD_TYPES,
206
288
  AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES
207
289
  };
208
290
  var COMPOUND_MAX_NESTING_DEPTH = {
209
291
  Page: 1,
210
292
  InteractiveBook: 2,
293
+ Slide: 1,
294
+ SlideDeck: 2,
211
295
  AssessmentSequence: 1
212
296
  };
213
297
  function getAllowedChildTypes(parent) {
@@ -325,6 +409,14 @@ var TELEMETRY_EVENT_CATALOG_V3 = [
325
409
  xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
326
410
  urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
327
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
+ },
328
420
  {
329
421
  name: "compound_page_viewed",
330
422
  description: "Learner activated a page inside a compound container",
@@ -370,16 +462,6 @@ function buildTelemetryCatalogV3() {
370
462
  return TELEMETRY_EVENT_CATALOG_V3.map((entry) => ({ ...entry }));
371
463
  }
372
464
 
373
- // src/internal/env.ts
374
- function isDevEnvironment() {
375
- const g = globalThis;
376
- return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
377
- }
378
- function warnDev(message, err) {
379
- if (!isDevEnvironment()) return;
380
- console.warn(message, err instanceof Error ? err.message : err);
381
- }
382
-
383
465
  // src/internal/sinkInvoke.ts
384
466
  function invokeTrackingSink(sink, event) {
385
467
  let result;
@@ -444,41 +526,58 @@ function createTrackingClient(opts) {
444
526
  let disposing = false;
445
527
  let intervalId;
446
528
  const runFlush = () => {
447
- if (!buffer.length) return Promise.resolve();
529
+ if (!buffer.length) return Promise.resolve(true);
448
530
  const events = buffer.splice(0, buffer.length);
449
- let sent = 0;
450
531
  let succeeded = false;
451
532
  return Promise.resolve().then(async () => {
452
533
  if (batchSink) {
453
534
  await batchSink(events);
454
535
  } else {
455
- for (const e of events) {
456
- await sink?.(e);
457
- 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
+ }
458
543
  }
459
544
  }
460
545
  succeeded = true;
461
546
  }).catch(() => {
462
- buffer.unshift(...events.slice(sent));
463
- }).then(() => {
547
+ if (batchSink) {
548
+ buffer.unshift(...events);
549
+ }
550
+ }).then(async () => {
464
551
  if (succeeded && buffer.length > 0 && !disposed) {
465
552
  return runFlush();
466
553
  }
554
+ return succeeded;
467
555
  });
468
556
  };
469
557
  const flush = () => {
470
- if (disposed) return Promise.resolve();
558
+ if (disposed) return Promise.resolve(true);
471
559
  if (flushInFlight) return flushInFlight;
472
- if (!buffer.length) return Promise.resolve();
560
+ if (!buffer.length) return Promise.resolve(true);
473
561
  flushInFlight = runFlush().finally(() => {
474
562
  flushInFlight = null;
475
563
  });
476
564
  return flushInFlight;
477
565
  };
566
+ const MAX_DISPOSE_FLUSH_ATTEMPTS = 10;
478
567
  const drainAll = async () => {
479
- await flush();
480
- while (buffer.length > 0) {
481
- 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;
482
581
  }
483
582
  };
484
583
  intervalId = flushIntervalMs > 0 ? globalThis.setInterval(() => void flush(), flushIntervalMs) : void 0;
@@ -487,13 +586,14 @@ function createTrackingClient(opts) {
487
586
  track: (event) => {
488
587
  if (disposed || disposing) return;
489
588
  if (buffer.length >= maxBufferSize) {
490
- buffer.shift();
589
+ opts?.onBufferDrop?.();
491
590
  if (!warnedBufferCap && isDevEnvironment()) {
492
591
  warnedBufferCap = true;
493
592
  console.warn(
494
- `[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.`
495
594
  );
496
595
  }
596
+ return;
497
597
  }
498
598
  buffer.push(event);
499
599
  if (buffer.length >= maxBatchSize) void flush();
@@ -651,6 +751,20 @@ var TELEMETRY_EVENT_REGISTRY = {
651
751
  };
652
752
  }
653
753
  },
754
+ slide_viewed: {
755
+ requiresLessonId: true,
756
+ build: (opts, base) => {
757
+ if (opts.name !== "slide_viewed") throw new Error("unexpected event");
758
+ const lessonId = opts.lessonId;
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
+ };
766
+ }
767
+ },
654
768
  compound_page_viewed: {
655
769
  requiresLessonId: true,
656
770
  build: (opts, base) => {
@@ -799,8 +913,7 @@ function createDefaultClock() {
799
913
  function createNoopStorage() {
800
914
  return {
801
915
  getItem: () => null,
802
- setItem: () => {
803
- }
916
+ setItem: () => true
804
917
  };
805
918
  }
806
919
  function createMemoryBackedSessionStorage(session) {
@@ -831,8 +944,10 @@ function createMemoryBackedSessionStorage(session) {
831
944
  memory.set(key, value);
832
945
  try {
833
946
  session.setItem(key, value);
947
+ return true;
834
948
  } catch {
835
949
  warnPersistFailure();
950
+ return false;
836
951
  }
837
952
  },
838
953
  removeItem: (key) => {
@@ -857,6 +972,7 @@ function createInMemorySessionStoragePort() {
857
972
  getItem: (key) => memory.get(key) ?? null,
858
973
  setItem: (key, value) => {
859
974
  memory.set(key, value);
975
+ return true;
860
976
  },
861
977
  removeItem: (key) => {
862
978
  memory.delete(key);
@@ -909,7 +1025,12 @@ function createProgressController() {
909
1025
  return { previousLessonId };
910
1026
  },
911
1027
  completeLesson: (lessonId, completedAtMs) => {
912
- 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
+ }
913
1034
  completedLessonIds = new Set(completedLessonIds).add(lessonId);
914
1035
  if (activeLessonId === lessonId) {
915
1036
  activeLessonId = void 0;
@@ -929,6 +1050,12 @@ function createProgressController() {
929
1050
 
930
1051
  // src/session.ts
931
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
+ }
932
1059
  function getTabSessionId(storage) {
933
1060
  return storage.getItem(SESSION_STORAGE_KEY);
934
1061
  }
@@ -936,11 +1063,28 @@ var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
936
1063
  var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
937
1064
  var COURSE_STARTED_PIPELINE_PREFIX = "lessonkit:course_started_pipeline:";
938
1065
  function resolveSessionId(storage, provided) {
939
- if (provided) return provided;
1066
+ if (provided !== void 0) {
1067
+ const trimmed = provided.trim();
1068
+ if (trimmed.length > 0) return trimmed;
1069
+ }
940
1070
  const existing = storage.getItem(SESSION_STORAGE_KEY);
941
1071
  if (existing) return existing;
1072
+ const volatile = volatileSessionIds.get(storage);
1073
+ if (volatile) return volatile;
942
1074
  const id = createSessionId();
943
- 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
+ }
944
1088
  return id;
945
1089
  }
946
1090
  function courseStartedStorageKey(sessionId, courseId) {
@@ -957,8 +1101,8 @@ function hasCourseStarted(storage, sessionId, courseId) {
957
1101
  return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
958
1102
  }
959
1103
  function markCourseStarted(storage, sessionId, courseId) {
960
- if (!courseId) return;
961
- storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
1104
+ if (!courseId) return false;
1105
+ return storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
962
1106
  }
963
1107
  function hasCourseStartedEmittedToTracking(storage, sessionId, courseId) {
964
1108
  if (!courseId) return false;
@@ -976,6 +1120,9 @@ function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
976
1120
  if (!courseId) return;
977
1121
  storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
978
1122
  }
1123
+ function resetSharedVolatileSessionIdForTests() {
1124
+ sharedVolatileSessionId = null;
1125
+ }
979
1126
  function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
980
1127
  if (!courseId || fromSessionId === toSessionId) return;
981
1128
  if (hasCourseStarted(storage, fromSessionId, courseId)) {
@@ -998,14 +1145,14 @@ function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
998
1145
  if (alreadyEmittedToSink) {
999
1146
  return { emitted: true, marked };
1000
1147
  }
1001
- if (marked) {
1002
- return { emitted: false, marked: true };
1003
- }
1004
1148
  const emitted = deps.emitCourseStartedEvent(ctx);
1005
- if (emitted) {
1149
+ if (emitted && !marked) {
1006
1150
  markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
1007
1151
  }
1008
- return { emitted, marked: emitted };
1152
+ return {
1153
+ emitted,
1154
+ marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
1155
+ };
1009
1156
  }
1010
1157
  function buildCourseStartedTelemetryEvent(ctx) {
1011
1158
  return buildTelemetryEvent({
@@ -1095,7 +1242,7 @@ function createPluginRegistry(plugins = []) {
1095
1242
  const composeTrackingSink = (sink, ctxSource) => {
1096
1243
  if (!sink) return void 0;
1097
1244
  const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
1098
- 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 ?? ""}`;
1099
1246
  const layers = [];
1100
1247
  let composed = sink;
1101
1248
  for (const plugin of list) {
@@ -1166,6 +1313,9 @@ function createLessonkitRuntime(config, ports = {}) {
1166
1313
  attemptId,
1167
1314
  user
1168
1315
  });
1316
+ if (!configSnapshot.deferPluginSetup) {
1317
+ pluginHost?.setupAll(getPluginCtx());
1318
+ }
1169
1319
  const getSession = () => ({ sessionId, attemptId, user });
1170
1320
  const syncSessionFromConfig = (next) => {
1171
1321
  sessionId = resolveSessionId(storage, next.session?.sessionId);
@@ -1225,11 +1375,8 @@ function createLessonkitRuntime(config, ports = {}) {
1225
1375
  getProgressState: () => progress.getState(),
1226
1376
  getSession,
1227
1377
  updateConfig(next) {
1228
- if (next.plugins !== void 0 && next.plugins !== pluginHost) {
1229
- pluginHost?.disposeAll();
1230
- configSnapshot.plugins = next.plugins;
1231
- pluginHost = resolvePluginHost(configSnapshot.plugins);
1232
- }
1378
+ const previousCourseId = courseId;
1379
+ const sessionKeyBefore = JSON.stringify({ sessionId, attemptId, user });
1233
1380
  if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
1234
1381
  if (next.runtimeVersion !== void 0) {
1235
1382
  if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
@@ -1239,6 +1386,19 @@ function createLessonkitRuntime(config, ports = {}) {
1239
1386
  configSnapshot.session = { ...configSnapshot.session, ...next.session };
1240
1387
  }
1241
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
+ }
1242
1402
  },
1243
1403
  setActiveLesson(lessonId, emitFn) {
1244
1404
  const wrapped = wrapEmitFn(emitFn);
@@ -1310,6 +1470,8 @@ export {
1310
1470
  INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
1311
1471
  PAGE_ALLOWED_CHILD_TYPES,
1312
1472
  SESSION_STORAGE_KEY,
1473
+ SLIDE_ALLOWED_CHILD_TYPES,
1474
+ SLIDE_DECK_ALLOWED_CHILD_TYPES,
1313
1475
  TELEMETRY_EVENT_CATALOG,
1314
1476
  TELEMETRY_EVENT_CATALOG_V2,
1315
1477
  TELEMETRY_EVENT_CATALOG_V3,
@@ -1360,6 +1522,7 @@ export {
1360
1522
  parseCompoundResumeState,
1361
1523
  parseCourseId,
1362
1524
  parseLessonId,
1525
+ resetSharedVolatileSessionIdForTests,
1363
1526
  resetStoragePortForTests,
1364
1527
  resetTelemetryBuilderWarningsForTests,
1365
1528
  resolveSessionId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/core",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "private": false,
5
5
  "description": "Shared types and telemetry primitives for LessonKit.",
6
6
  "license": "Apache-2.0",
@@ -9,6 +9,14 @@
9
9
  "xapiVerb": "http://adlnet.gov/expapi/verbs/experienced",
10
10
  "urnPattern": "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
11
11
  },
12
+ {
13
+ "name": "slide_viewed",
14
+ "description": "Learner viewed a slide in a SlideDeck (Course Presentation)",
15
+ "requiredFields": ["courseId", "lessonId", "sessionId", "timestamp"],
16
+ "dataFields": ["blockId", "slideIndex", "slideTitle"],
17
+ "xapiVerb": "http://adlnet.gov/expapi/verbs/experienced",
18
+ "urnPattern": "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
19
+ },
12
20
  {
13
21
  "name": "compound_page_viewed",
14
22
  "description": "Learner activated a page inside a compound container",