@lessonkit/core 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -102,6 +102,122 @@ function buildLessonkitUrn(parts) {
102
102
  return urn;
103
103
  }
104
104
 
105
+ // src/compound.ts
106
+ var COMPOUND_RESUME_SCHEMA_VERSION = 1;
107
+ function createCompoundResumeState(input = {}) {
108
+ const childStates = {};
109
+ if (input.childStates) {
110
+ for (const [key, value] of Object.entries(input.childStates)) {
111
+ childStates[key] = value;
112
+ }
113
+ }
114
+ return {
115
+ schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
116
+ activePageIndex: input.activePageIndex ?? 0,
117
+ ...input.activeChapterIndex !== void 0 ? { activeChapterIndex: input.activeChapterIndex } : {},
118
+ childStates
119
+ };
120
+ }
121
+ function clampCompoundPageIndex(index, pageCount) {
122
+ if (pageCount < 1) return 0;
123
+ return Math.min(Math.max(0, Math.floor(index)), pageCount - 1);
124
+ }
125
+ function parseCompoundResumeState(raw) {
126
+ if (!raw || typeof raw !== "object") return null;
127
+ const obj = raw;
128
+ if (obj.schemaVersion !== COMPOUND_RESUME_SCHEMA_VERSION) return null;
129
+ if (typeof obj.activePageIndex !== "number" || !Number.isFinite(obj.activePageIndex)) return null;
130
+ const childStates = {};
131
+ if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
132
+ for (const [key, value] of Object.entries(obj.childStates)) {
133
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
134
+ childStates[key] = value;
135
+ }
136
+ }
137
+ }
138
+ const activeChapterIndex = typeof obj.activeChapterIndex === "number" && Number.isFinite(obj.activeChapterIndex) ? obj.activeChapterIndex : void 0;
139
+ return {
140
+ schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
141
+ activePageIndex: Math.max(0, Math.floor(obj.activePageIndex)),
142
+ ...activeChapterIndex !== void 0 ? { activeChapterIndex: Math.max(0, Math.floor(activeChapterIndex)) } : {},
143
+ childStates
144
+ };
145
+ }
146
+
147
+ // src/compoundState.ts
148
+ var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
149
+ function compoundStateStorageKey(courseId, compoundId) {
150
+ return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
151
+ }
152
+ function loadCompoundState(storage, courseId, compoundId) {
153
+ const raw = storage.getItem(compoundStateStorageKey(courseId, compoundId));
154
+ if (!raw) return null;
155
+ try {
156
+ return parseCompoundResumeState(JSON.parse(raw));
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+ function saveCompoundState(storage, courseId, compoundId, state) {
162
+ storage.setItem(compoundStateStorageKey(courseId, compoundId), JSON.stringify(state));
163
+ }
164
+ function clearCompoundState(storage, courseId, compoundId) {
165
+ storage.removeItem?.(compoundStateStorageKey(courseId, compoundId));
166
+ }
167
+
168
+ // src/compoundAllowlists.ts
169
+ var PAGE_ALLOWED_CHILD_TYPES = [
170
+ "Text",
171
+ "Heading",
172
+ "Image",
173
+ "Scenario",
174
+ "Reflection",
175
+ "Quiz",
176
+ "KnowledgeCheck",
177
+ "TrueFalse",
178
+ "FillInTheBlanks",
179
+ "DragAndDrop",
180
+ "DragTheWords",
181
+ "MarkTheWords",
182
+ "Accordion",
183
+ "DialogCards",
184
+ "Flashcards",
185
+ "ImageHotspots",
186
+ "FindHotspot",
187
+ "FindMultipleHotspots",
188
+ "ImageSlider",
189
+ "ProgressTracker"
190
+ ];
191
+ var INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES = ["Page"];
192
+ var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
193
+ "TrueFalse",
194
+ "FillInTheBlanks",
195
+ "DragAndDrop",
196
+ "DragTheWords",
197
+ "MarkTheWords",
198
+ "Quiz",
199
+ "KnowledgeCheck",
200
+ "FindHotspot",
201
+ "FindMultipleHotspots"
202
+ ];
203
+ var ALLOWLISTS = {
204
+ Page: PAGE_ALLOWED_CHILD_TYPES,
205
+ InteractiveBook: INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
206
+ AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES
207
+ };
208
+ var COMPOUND_MAX_NESTING_DEPTH = {
209
+ Page: 1,
210
+ InteractiveBook: 2,
211
+ AssessmentSequence: 1
212
+ };
213
+ function getAllowedChildTypes(parent) {
214
+ return ALLOWLISTS[parent];
215
+ }
216
+ function isChildTypeAllowed(parent, childType) {
217
+ return ALLOWLISTS[parent].includes(childType);
218
+ }
219
+ var ACCORDION_FORBIDDEN_CHILD_TYPES = ["Accordion"];
220
+
105
221
  // src/telemetryCatalog.ts
106
222
  var telemetryCatalogVersion = 1;
107
223
  var TELEMETRY_EVENT_CATALOG = [
@@ -198,35 +314,101 @@ function buildTelemetryCatalogV2() {
198
314
  return TELEMETRY_EVENT_CATALOG_V2.map((entry) => ({ ...entry }));
199
315
  }
200
316
 
201
- // src/trackingClient.ts
317
+ // src/telemetryCatalogV3.ts
318
+ var telemetryCatalogV3Version = 3;
319
+ var TELEMETRY_EVENT_CATALOG_V3 = [
320
+ {
321
+ name: "book_page_viewed",
322
+ description: "Learner viewed a page/chapter in an Interactive Book",
323
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
324
+ dataFields: ["blockId", "pageIndex", "pageTitle"],
325
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
326
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
327
+ },
328
+ {
329
+ name: "compound_page_viewed",
330
+ description: "Learner activated a page inside a compound container",
331
+ requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
332
+ dataFields: ["blockId", "pageIndex", "parentType"],
333
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
334
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
335
+ },
336
+ {
337
+ name: "hotspot_opened",
338
+ description: "Learner opened an image hotspot popover",
339
+ requiredFields: ["courseId", "sessionId", "timestamp"],
340
+ dataFields: ["blockId", "hotspotId"],
341
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
342
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
343
+ },
344
+ {
345
+ name: "accordion_section_toggled",
346
+ description: "Learner expanded or collapsed an accordion section",
347
+ requiredFields: ["courseId", "sessionId", "timestamp"],
348
+ dataFields: ["blockId", "sectionId", "expanded"],
349
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
350
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
351
+ },
352
+ {
353
+ name: "flashcard_flipped",
354
+ description: "Learner flipped a flashcard",
355
+ requiredFields: ["courseId", "sessionId", "timestamp"],
356
+ dataFields: ["blockId", "cardIndex", "face"],
357
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
358
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
359
+ },
360
+ {
361
+ name: "image_slider_changed",
362
+ description: "Learner changed the active slide in an image slider",
363
+ requiredFields: ["courseId", "sessionId", "timestamp"],
364
+ dataFields: ["blockId", "slideIndex"],
365
+ xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
366
+ urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
367
+ }
368
+ ];
369
+ function buildTelemetryCatalogV3() {
370
+ return TELEMETRY_EVENT_CATALOG_V3.map((entry) => ({ ...entry }));
371
+ }
372
+
373
+ // src/internal/env.ts
202
374
  function isDevEnvironment() {
203
375
  const g = globalThis;
204
376
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
205
377
  }
378
+ function warnDev(message, err) {
379
+ if (!isDevEnvironment()) return;
380
+ console.warn(message, err instanceof Error ? err.message : err);
381
+ }
382
+
383
+ // src/internal/sinkInvoke.ts
206
384
  function invokeTrackingSink(sink, event) {
207
385
  let result;
208
386
  try {
209
387
  result = sink(event);
210
388
  } catch (err) {
211
- if (isDevEnvironment()) {
212
- console.warn(
213
- "[lessonkit] tracking sink failed:",
214
- err instanceof Error ? err.message : err
215
- );
216
- }
389
+ warnDev("[lessonkit] tracking sink failed:", err);
217
390
  throw err;
218
391
  }
219
392
  if (result != null && typeof result.catch === "function") {
220
- void result.catch((err) => {
221
- if (isDevEnvironment()) {
222
- console.warn(
223
- "[lessonkit] tracking sink failed:",
224
- err instanceof Error ? err.message : err
225
- );
226
- }
227
- });
393
+ void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
228
394
  }
229
395
  }
396
+ function invokePipelineSink(sinkId, emit) {
397
+ let result;
398
+ try {
399
+ result = emit();
400
+ } catch (err) {
401
+ warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err);
402
+ return;
403
+ }
404
+ if (result != null && typeof result.catch === "function") {
405
+ void result.catch(
406
+ (err) => warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err)
407
+ );
408
+ }
409
+ }
410
+
411
+ // src/trackingClient.ts
230
412
  function createTrackingClient(opts) {
231
413
  const sink = opts?.sink;
232
414
  const batchSink = opts?.batchSink;
@@ -344,96 +526,229 @@ function nowIso() {
344
526
  return (/* @__PURE__ */ new Date()).toISOString();
345
527
  }
346
528
 
347
- // src/telemetryBuilder.ts
348
- var warnedMissingQuizLesson = false;
349
- var warnedMissingAssessmentLesson = false;
350
- function isDevEnvironment2() {
351
- const g = globalThis;
352
- return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
353
- }
354
- function resetTelemetryBuilderWarningsForTests() {
355
- warnedMissingQuizLesson = false;
356
- warnedMissingAssessmentLesson = false;
357
- }
529
+ // src/telemetry/eventRegistry.ts
358
530
  function resolveLessonId(opts, eventName) {
359
531
  const lessonId = opts.lessonId ?? opts.data?.lessonId;
360
532
  if (!lessonId) throw new Error(`${eventName} requires lessonId`);
361
533
  return lessonId;
362
534
  }
363
- function buildTelemetryEvent(opts) {
364
- const base = {
365
- timestamp: opts.timestamp ?? nowIso(),
366
- courseId: opts.courseId,
367
- sessionId: opts.sessionId,
368
- attemptId: opts.attemptId,
369
- user: opts.user
370
- };
371
- switch (opts.name) {
372
- case "course_started":
373
- return { name: "course_started", ...base };
374
- case "course_completed":
375
- return { name: "course_completed", ...base };
376
- case "lesson_started": {
535
+ function withLessonScopedData(name, base, lessonId, data) {
536
+ return { name, ...base, lessonId, data: { ...data, lessonId } };
537
+ }
538
+ var TELEMETRY_EVENT_REGISTRY = {
539
+ course_started: {
540
+ build: (_opts, base) => ({ name: "course_started", ...base })
541
+ },
542
+ course_completed: {
543
+ build: (_opts, base) => ({ name: "course_completed", ...base })
544
+ },
545
+ lesson_started: {
546
+ requiresLessonId: true,
547
+ build: (opts, base) => {
548
+ if (opts.name !== "lesson_started") throw new Error("unexpected event");
377
549
  const lessonId = resolveLessonId(opts, "lesson_started");
550
+ return withLessonScopedData("lesson_started", base, lessonId, opts.data);
551
+ }
552
+ },
553
+ lesson_completed: {
554
+ requiresLessonId: true,
555
+ build: (opts, base) => {
556
+ if (opts.name !== "lesson_completed") throw new Error("unexpected event");
557
+ const lessonId = resolveLessonId(opts, opts.name);
558
+ return withLessonScopedData(opts.name, base, lessonId, opts.data);
559
+ }
560
+ },
561
+ lesson_time_on_task: {
562
+ requiresLessonId: true,
563
+ build: (opts, base) => {
564
+ if (opts.name !== "lesson_time_on_task") throw new Error("unexpected event");
565
+ const lessonId = resolveLessonId(opts, opts.name);
566
+ return withLessonScopedData(opts.name, base, lessonId, opts.data);
567
+ }
568
+ },
569
+ quiz_answered: {
570
+ requiresLessonId: true,
571
+ tryBuildMissingLessonWarning: "quiz",
572
+ build: (opts, base) => {
573
+ if (opts.name !== "quiz_answered") throw new Error("unexpected event");
574
+ const lessonId = opts.lessonId;
575
+ if (!lessonId) throw new Error("quiz_answered requires active lessonId");
378
576
  return {
379
- name: "lesson_started",
577
+ name: "quiz_answered",
380
578
  ...base,
381
579
  lessonId,
382
- data: { ...opts.data, lessonId }
580
+ data: opts.data
383
581
  };
384
582
  }
385
- case "lesson_completed":
386
- case "lesson_time_on_task": {
387
- const lessonId = resolveLessonId(opts, opts.name);
583
+ },
584
+ quiz_completed: {
585
+ requiresLessonId: true,
586
+ tryBuildMissingLessonWarning: "quiz",
587
+ build: (opts, base) => {
588
+ if (opts.name !== "quiz_completed") throw new Error("unexpected event");
589
+ const lessonId = opts.lessonId;
590
+ if (!lessonId) throw new Error("quiz_completed requires active lessonId");
388
591
  return {
389
- name: opts.name,
592
+ name: "quiz_completed",
390
593
  ...base,
391
594
  lessonId,
392
- data: { ...opts.data, lessonId }
595
+ data: opts.data
393
596
  };
394
597
  }
395
- case "quiz_answered": {
598
+ },
599
+ assessment_answered: {
600
+ requiresLessonId: true,
601
+ tryBuildMissingLessonWarning: "assessment",
602
+ build: (opts, base) => {
603
+ if (opts.name !== "assessment_answered") throw new Error("unexpected event");
396
604
  const lessonId = opts.lessonId;
397
- if (!lessonId) throw new Error("quiz_answered requires active lessonId");
398
- return { name: "quiz_answered", ...base, lessonId, data: opts.data };
605
+ if (!lessonId) throw new Error("assessment_answered requires active lessonId");
606
+ return {
607
+ name: "assessment_answered",
608
+ ...base,
609
+ lessonId,
610
+ data: opts.data
611
+ };
399
612
  }
400
- case "quiz_completed": {
613
+ },
614
+ assessment_completed: {
615
+ requiresLessonId: true,
616
+ tryBuildMissingLessonWarning: "assessment",
617
+ build: (opts, base) => {
618
+ if (opts.name !== "assessment_completed") throw new Error("unexpected event");
401
619
  const lessonId = opts.lessonId;
402
- if (!lessonId) throw new Error("quiz_completed requires active lessonId");
403
- return { name: "quiz_completed", ...base, lessonId, data: opts.data };
620
+ if (!lessonId) throw new Error("assessment_completed requires active lessonId");
621
+ return {
622
+ name: "assessment_completed",
623
+ ...base,
624
+ lessonId,
625
+ data: opts.data
626
+ };
627
+ }
628
+ },
629
+ interaction: {
630
+ build: (opts, base) => {
631
+ if (opts.name !== "interaction") throw new Error("unexpected event");
632
+ return {
633
+ name: "interaction",
634
+ ...base,
635
+ lessonId: opts.lessonId,
636
+ data: opts.data
637
+ };
404
638
  }
405
- case "assessment_answered": {
639
+ },
640
+ book_page_viewed: {
641
+ requiresLessonId: true,
642
+ build: (opts, base) => {
643
+ if (opts.name !== "book_page_viewed") throw new Error("unexpected event");
406
644
  const lessonId = opts.lessonId;
407
- if (!lessonId) throw new Error("assessment_answered requires active lessonId");
408
- return { name: "assessment_answered", ...base, lessonId, data: opts.data };
645
+ if (!lessonId) throw new Error("book_page_viewed requires active lessonId");
646
+ return {
647
+ name: "book_page_viewed",
648
+ ...base,
649
+ lessonId,
650
+ data: opts.data
651
+ };
409
652
  }
410
- case "assessment_completed": {
653
+ },
654
+ compound_page_viewed: {
655
+ requiresLessonId: true,
656
+ build: (opts, base) => {
657
+ if (opts.name !== "compound_page_viewed") throw new Error("unexpected event");
411
658
  const lessonId = opts.lessonId;
412
- if (!lessonId) throw new Error("assessment_completed requires active lessonId");
413
- return { name: "assessment_completed", ...base, lessonId, data: opts.data };
659
+ if (!lessonId) throw new Error("compound_page_viewed requires active lessonId");
660
+ return {
661
+ name: "compound_page_viewed",
662
+ ...base,
663
+ lessonId,
664
+ data: opts.data
665
+ };
414
666
  }
415
- case "interaction":
667
+ },
668
+ hotspot_opened: {
669
+ build: (opts, base) => {
670
+ if (opts.name !== "hotspot_opened") throw new Error("unexpected event");
416
671
  return {
417
- name: "interaction",
672
+ name: "hotspot_opened",
418
673
  ...base,
419
674
  lessonId: opts.lessonId,
420
675
  data: opts.data
421
676
  };
422
- default:
423
- return assertNever(opts);
677
+ }
678
+ },
679
+ accordion_section_toggled: {
680
+ build: (opts, base) => {
681
+ if (opts.name !== "accordion_section_toggled") throw new Error("unexpected event");
682
+ return {
683
+ name: "accordion_section_toggled",
684
+ ...base,
685
+ lessonId: opts.lessonId,
686
+ data: opts.data
687
+ };
688
+ }
689
+ },
690
+ flashcard_flipped: {
691
+ build: (opts, base) => {
692
+ if (opts.name !== "flashcard_flipped") throw new Error("unexpected event");
693
+ return {
694
+ name: "flashcard_flipped",
695
+ ...base,
696
+ lessonId: opts.lessonId,
697
+ data: opts.data
698
+ };
699
+ }
700
+ },
701
+ image_slider_changed: {
702
+ build: (opts, base) => {
703
+ if (opts.name !== "image_slider_changed") throw new Error("unexpected event");
704
+ return {
705
+ name: "image_slider_changed",
706
+ ...base,
707
+ lessonId: opts.lessonId,
708
+ data: opts.data
709
+ };
710
+ }
711
+ }
712
+ };
713
+ function buildTelemetryEventFromRegistry(opts) {
714
+ const entry = TELEMETRY_EVENT_REGISTRY[opts.name];
715
+ if (!entry) {
716
+ throw new Error("Unexpected value");
424
717
  }
718
+ const base = {
719
+ timestamp: opts.timestamp ?? nowIso(),
720
+ courseId: opts.courseId,
721
+ sessionId: opts.sessionId,
722
+ attemptId: opts.attemptId,
723
+ user: opts.user
724
+ };
725
+ return entry.build(opts, base);
726
+ }
727
+ function getTelemetryEventRegistryEntry(name) {
728
+ return TELEMETRY_EVENT_REGISTRY[name];
729
+ }
730
+
731
+ // src/telemetryBuilder.ts
732
+ var warnedMissingQuizLesson = false;
733
+ var warnedMissingAssessmentLesson = false;
734
+ function resetTelemetryBuilderWarningsForTests() {
735
+ warnedMissingQuizLesson = false;
736
+ warnedMissingAssessmentLesson = false;
737
+ }
738
+ function buildTelemetryEvent(opts) {
739
+ return buildTelemetryEventFromRegistry(opts);
425
740
  }
426
741
  function tryBuildTelemetryEvent(opts) {
427
- const needsLesson = opts.name === "quiz_answered" || opts.name === "quiz_completed" || opts.name === "assessment_answered" || opts.name === "assessment_completed";
428
- if (needsLesson && !opts.lessonId) {
429
- if (isDevEnvironment2()) {
430
- if (opts.name.startsWith("quiz_") && !warnedMissingQuizLesson) {
742
+ const entry = getTelemetryEventRegistryEntry(opts.name);
743
+ if (entry.requiresLessonId && !opts.lessonId && entry.tryBuildMissingLessonWarning) {
744
+ if (isDevEnvironment()) {
745
+ if (entry.tryBuildMissingLessonWarning === "quiz" && !warnedMissingQuizLesson) {
431
746
  warnedMissingQuizLesson = true;
432
747
  console.warn(
433
748
  `[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
434
749
  );
435
750
  }
436
- if (opts.name.startsWith("assessment_") && !warnedMissingAssessmentLesson) {
751
+ if (entry.tryBuildMissingLessonWarning === "assessment" && !warnedMissingAssessmentLesson) {
437
752
  warnedMissingAssessmentLesson = true;
438
753
  console.warn(
439
754
  `[lessonkit] ${opts.name} skipped: wrap assessment blocks in <Lesson> so an active lessonId is available`
@@ -446,29 +761,8 @@ function tryBuildTelemetryEvent(opts) {
446
761
  }
447
762
 
448
763
  // src/telemetryPipeline.ts
449
- function isDevEnvironment3() {
450
- const g = globalThis;
451
- return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
452
- }
453
- function warnSinkFailure(sinkId, err) {
454
- if (isDevEnvironment3()) {
455
- console.warn(
456
- `[lessonkit] telemetry sink "${sinkId}" failed:`,
457
- err instanceof Error ? err.message : err
458
- );
459
- }
460
- }
461
764
  function invokeSink(sink, event, emitCtx) {
462
- let result;
463
- try {
464
- result = sink.emit(event, emitCtx);
465
- } catch (err) {
466
- warnSinkFailure(sink.id, err);
467
- return;
468
- }
469
- if (result != null && typeof result.catch === "function") {
470
- void result.catch((err) => warnSinkFailure(sink.id, err));
471
- }
765
+ invokePipelineSink(sink.id, () => sink.emit(event, emitCtx));
472
766
  }
473
767
  function createTelemetryPipeline(sinks) {
474
768
  const list = [...sinks];
@@ -744,101 +1038,13 @@ function completeCourseWithTelemetry(opts) {
744
1038
  return true;
745
1039
  }
746
1040
 
747
- // src/runtime/createLessonkitRuntime.ts
748
- function createLessonkitRuntime(config, ports = {}) {
749
- const storage = ports.storage ?? createSessionStoragePort();
750
- const clock = ports.clock ?? createDefaultClock();
751
- const configSnapshot = { ...config };
752
- let sessionId = resolveSessionId(storage, configSnapshot.session?.sessionId);
753
- let attemptId = configSnapshot.session?.attemptId;
754
- let user = configSnapshot.session?.user;
755
- let courseId = configSnapshot.courseId;
756
- let progress = createProgressController();
757
- const getSession = () => ({ sessionId, attemptId, user });
758
- const syncSessionFromConfig = (next) => {
759
- sessionId = resolveSessionId(storage, next.session?.sessionId);
760
- attemptId = next.session?.attemptId;
761
- user = next.session?.user;
762
- courseId = next.courseId;
763
- };
764
- syncSessionFromConfig(configSnapshot);
765
- const track = (name, data, emit, lessonId) => {
766
- const event = tryBuildTelemetryEvent({
767
- name,
768
- courseId,
769
- lessonId: lessonId ?? progress.getState().activeLessonId,
770
- sessionId,
771
- attemptId,
772
- user,
773
- data
774
- });
775
- if (!event) return;
776
- emit(event);
777
- };
778
- const emitLessonCompleted = (lessonId, durationMs, emitFn) => {
779
- emitFn("lesson_completed", { lessonId, durationMs }, lessonId);
780
- if (durationMs !== void 0) {
781
- emitFn("lesson_time_on_task", { lessonId, durationMs }, lessonId);
782
- }
783
- };
1041
+ // src/plugins/context.ts
1042
+ function buildPluginContext(opts) {
784
1043
  return {
785
- get config() {
786
- return configSnapshot;
787
- },
788
- get progress() {
789
- return progress;
790
- },
791
- getProgressState: () => progress.getState(),
792
- getSession,
793
- updateConfig(next) {
794
- if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
795
- if (next.runtimeVersion !== void 0) configSnapshot.runtimeVersion = next.runtimeVersion;
796
- if (next.plugins !== void 0) configSnapshot.plugins = next.plugins;
797
- if (next.session !== void 0) {
798
- configSnapshot.session = { ...configSnapshot.session, ...next.session };
799
- }
800
- syncSessionFromConfig(configSnapshot);
801
- },
802
- setActiveLesson(lessonId, emitFn) {
803
- const current = progress.getState();
804
- if (current.activeLessonId === lessonId) return;
805
- if (current.completedLessonIds.has(lessonId)) {
806
- progress.setActiveLesson(lessonId, clock.nowMs());
807
- return;
808
- }
809
- const previous = current.activeLessonId;
810
- if (previous && previous !== lessonId) {
811
- const completed = progress.completeLesson(previous, clock.nowMs());
812
- if (completed.didComplete) {
813
- emitLessonCompleted(previous, completed.durationMs, emitFn);
814
- }
815
- }
816
- progress.setActiveLesson(lessonId, clock.nowMs());
817
- emitFn("lesson_started", { lessonId }, lessonId);
818
- },
819
- completeLesson(lessonId, emitFn) {
820
- const result = progress.completeLesson(lessonId, clock.nowMs());
821
- if (!result.didComplete) return;
822
- emitLessonCompleted(lessonId, result.durationMs, emitFn);
823
- },
824
- completeCourse(emitFn) {
825
- const current = progress.getState();
826
- if (current.activeLessonId) {
827
- const lessonResult = progress.completeLesson(current.activeLessonId, clock.nowMs());
828
- if (lessonResult.didComplete) {
829
- emitLessonCompleted(current.activeLessonId, lessonResult.durationMs, emitFn);
830
- }
831
- }
832
- const result = progress.completeCourse();
833
- if (!result.didComplete) return;
834
- emitFn("course_completed");
835
- },
836
- track,
837
- resetForCourseChange(nextCourseId) {
838
- configSnapshot.courseId = nextCourseId;
839
- courseId = nextCourseId;
840
- progress = createProgressController();
841
- }
1044
+ courseId: opts.courseId,
1045
+ sessionId: opts.sessionId,
1046
+ attemptId: opts.attemptId,
1047
+ user: opts.user
842
1048
  };
843
1049
  }
844
1050
 
@@ -929,6 +1135,161 @@ function createPluginRegistry(plugins = []) {
929
1135
  };
930
1136
  }
931
1137
 
1138
+ // src/runtime/createLessonkitRuntime.ts
1139
+ function resolvePluginHost(plugins) {
1140
+ if (!plugins) return null;
1141
+ if (typeof plugins === "object" && "runTelemetry" in plugins) return plugins;
1142
+ if (Array.isArray(plugins) && plugins.length > 0) return createPluginRegistry(plugins);
1143
+ return null;
1144
+ }
1145
+ function warnRuntimeV1Deprecated() {
1146
+ const g = globalThis;
1147
+ if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
1148
+ console.warn(
1149
+ '[lessonkit] runtimeVersion "v1" is deprecated; use "v2" (default). v1 will be removed in LessonKit 2.0.'
1150
+ );
1151
+ }
1152
+ function createLessonkitRuntime(config, ports = {}) {
1153
+ if (config.runtimeVersion === "v1") warnRuntimeV1Deprecated();
1154
+ const storage = ports.storage ?? createSessionStoragePort();
1155
+ const clock = ports.clock ?? createDefaultClock();
1156
+ const configSnapshot = { ...config };
1157
+ let sessionId = resolveSessionId(storage, configSnapshot.session?.sessionId);
1158
+ let attemptId = configSnapshot.session?.attemptId;
1159
+ let user = configSnapshot.session?.user;
1160
+ let courseId = configSnapshot.courseId;
1161
+ let progress = createProgressController();
1162
+ let pluginHost = resolvePluginHost(configSnapshot.plugins);
1163
+ const getPluginCtx = () => buildPluginContext({
1164
+ courseId,
1165
+ sessionId,
1166
+ attemptId,
1167
+ user
1168
+ });
1169
+ const getSession = () => ({ sessionId, attemptId, user });
1170
+ const syncSessionFromConfig = (next) => {
1171
+ sessionId = resolveSessionId(storage, next.session?.sessionId);
1172
+ attemptId = next.session?.attemptId;
1173
+ user = next.session?.user;
1174
+ courseId = next.courseId;
1175
+ };
1176
+ const applyPluginsToEvent = (event) => {
1177
+ if (!pluginHost) return event;
1178
+ return pluginHost.runTelemetry(event, getPluginCtx());
1179
+ };
1180
+ const buildAndApply = (name, data, lessonId) => {
1181
+ const event = tryBuildTelemetryEvent({
1182
+ name,
1183
+ courseId,
1184
+ lessonId: lessonId ?? progress.getState().activeLessonId,
1185
+ sessionId,
1186
+ attemptId,
1187
+ user,
1188
+ data
1189
+ });
1190
+ if (!event) return null;
1191
+ return applyPluginsToEvent(event);
1192
+ };
1193
+ const wrapEmitFn = (emitFn) => {
1194
+ return (name, data, lessonId) => {
1195
+ const event = buildAndApply(name, data, lessonId);
1196
+ if (event === null) return;
1197
+ const eventLessonId = "lessonId" in event ? event.lessonId : lessonId;
1198
+ const eventData = "data" in event ? event.data : data;
1199
+ emitFn(event.name, eventData, eventLessonId);
1200
+ };
1201
+ };
1202
+ syncSessionFromConfig(configSnapshot);
1203
+ const track = (name, data, emit, lessonId) => {
1204
+ const event = buildAndApply(name, data, lessonId);
1205
+ if (!event) return;
1206
+ emit(event);
1207
+ };
1208
+ const emitLessonCompletedEvents = (lessonId, durationMs, emitFn) => {
1209
+ const wrapped = wrapEmitFn(emitFn);
1210
+ wrapped("lesson_completed", { lessonId, durationMs }, lessonId);
1211
+ if (durationMs !== void 0) {
1212
+ wrapped("lesson_time_on_task", { lessonId, durationMs }, lessonId);
1213
+ }
1214
+ };
1215
+ return {
1216
+ get config() {
1217
+ return configSnapshot;
1218
+ },
1219
+ get progress() {
1220
+ return progress;
1221
+ },
1222
+ get pluginHost() {
1223
+ return pluginHost;
1224
+ },
1225
+ getProgressState: () => progress.getState(),
1226
+ getSession,
1227
+ 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
+ }
1233
+ if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
1234
+ if (next.runtimeVersion !== void 0) {
1235
+ if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
1236
+ configSnapshot.runtimeVersion = next.runtimeVersion;
1237
+ }
1238
+ if (next.session !== void 0) {
1239
+ configSnapshot.session = { ...configSnapshot.session, ...next.session };
1240
+ }
1241
+ syncSessionFromConfig(configSnapshot);
1242
+ },
1243
+ setActiveLesson(lessonId, emitFn) {
1244
+ const wrapped = wrapEmitFn(emitFn);
1245
+ const current = progress.getState();
1246
+ if (current.activeLessonId === lessonId) return;
1247
+ if (current.completedLessonIds.has(lessonId)) {
1248
+ progress.setActiveLesson(lessonId, clock.nowMs());
1249
+ return;
1250
+ }
1251
+ const previous = current.activeLessonId;
1252
+ if (previous && previous !== lessonId) {
1253
+ const completed = progress.completeLesson(previous, clock.nowMs());
1254
+ if (completed.didComplete) {
1255
+ emitLessonCompletedEvents(previous, completed.durationMs, wrapped);
1256
+ }
1257
+ }
1258
+ progress.setActiveLesson(lessonId, clock.nowMs());
1259
+ wrapped("lesson_started", { lessonId }, lessonId);
1260
+ },
1261
+ completeLesson(lessonId, emitFn) {
1262
+ completeLessonWithTelemetry({
1263
+ progress,
1264
+ lessonId,
1265
+ nowMs: clock.nowMs(),
1266
+ emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn))
1267
+ });
1268
+ },
1269
+ completeCourse(emitFn) {
1270
+ completeCourseWithTelemetry({
1271
+ progress,
1272
+ nowMs: clock.nowMs(),
1273
+ emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn)),
1274
+ emitCourseCompleted: () => wrapEmitFn(emitFn)("course_completed")
1275
+ });
1276
+ },
1277
+ track,
1278
+ scoreAssessment(input, _lessonId) {
1279
+ if (!pluginHost) return null;
1280
+ return pluginHost.scoreAssessment(input, getPluginCtx());
1281
+ },
1282
+ resetForCourseChange(nextCourseId) {
1283
+ configSnapshot.courseId = nextCourseId;
1284
+ courseId = nextCourseId;
1285
+ progress = createProgressController();
1286
+ },
1287
+ dispose() {
1288
+ pluginHost?.disposeAll();
1289
+ }
1290
+ };
1291
+ }
1292
+
932
1293
  // src/plugins/define.ts
933
1294
  function defineTelemetryPlugin(plugin) {
934
1295
  return plugin;
@@ -940,20 +1301,33 @@ function defineLifecyclePlugin(plugin) {
940
1301
  return plugin;
941
1302
  }
942
1303
  export {
1304
+ ACCORDION_FORBIDDEN_CHILD_TYPES,
1305
+ ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
1306
+ COMPOUND_MAX_NESTING_DEPTH,
1307
+ COMPOUND_RESUME_SCHEMA_VERSION,
943
1308
  ID_MAX_LENGTH,
944
1309
  ID_PATTERN,
1310
+ INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
1311
+ PAGE_ALLOWED_CHILD_TYPES,
945
1312
  SESSION_STORAGE_KEY,
946
1313
  TELEMETRY_EVENT_CATALOG,
947
1314
  TELEMETRY_EVENT_CATALOG_V2,
1315
+ TELEMETRY_EVENT_CATALOG_V3,
948
1316
  assertNever,
949
1317
  assertValidId,
950
1318
  buildCourseStartedTelemetryEvent,
951
1319
  buildLessonkitUrn,
1320
+ buildPluginContext,
952
1321
  buildTelemetryCatalog,
953
1322
  buildTelemetryCatalogV2,
1323
+ buildTelemetryCatalogV3,
954
1324
  buildTelemetryEvent,
1325
+ clampCompoundPageIndex,
1326
+ clearCompoundState,
955
1327
  completeCourseWithTelemetry,
956
1328
  completeLessonWithTelemetry,
1329
+ compoundStateStorageKey,
1330
+ createCompoundResumeState,
957
1331
  createDefaultClock,
958
1332
  createGlobalTimer,
959
1333
  createLessonkitRuntime,
@@ -969,10 +1343,13 @@ export {
969
1343
  defineLifecyclePlugin,
970
1344
  defineTelemetryPlugin,
971
1345
  deriveId,
1346
+ getAllowedChildTypes,
972
1347
  getTabSessionId,
973
1348
  hasCourseStarted,
974
1349
  hasCourseStartedEmittedToTracking,
975
1350
  hasCourseStartedPipelineDelivered,
1351
+ isChildTypeAllowed,
1352
+ loadCompoundState,
976
1353
  markCourseStarted,
977
1354
  markCourseStartedEmittedToTracking,
978
1355
  markCourseStartedPipelineDelivered,
@@ -980,13 +1357,16 @@ export {
980
1357
  nowIso,
981
1358
  parseBlockId,
982
1359
  parseCheckId,
1360
+ parseCompoundResumeState,
983
1361
  parseCourseId,
984
1362
  parseLessonId,
985
1363
  resetStoragePortForTests,
986
1364
  resetTelemetryBuilderWarningsForTests,
987
1365
  resolveSessionId,
1366
+ saveCompoundState,
988
1367
  slugifyId,
989
1368
  telemetryCatalogV2Version,
1369
+ telemetryCatalogV3Version,
990
1370
  telemetryCatalogVersion,
991
1371
  tryBuildTelemetryEvent,
992
1372
  tryEmitCourseStarted,