@lessonkit/react 1.3.1 → 1.4.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.
@@ -0,0 +1,1775 @@
1
+ // src/assessment/AssessmentLessonGuard.tsx
2
+ import { useEffect } from "react";
3
+
4
+ // src/lessonContext.tsx
5
+ import { createContext, useContext } from "react";
6
+ var LessonContext = createContext(void 0);
7
+ function useEnclosingLessonId() {
8
+ return useContext(LessonContext);
9
+ }
10
+
11
+ // src/runtime/validateComponentId.ts
12
+ import { assertValidId } from "@lessonkit/core";
13
+ function isDevEnvironment() {
14
+ const g = globalThis;
15
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
16
+ }
17
+ function normalizeComponentId(id, path) {
18
+ if (path === "courseId") return assertValidId(id, "courseId");
19
+ if (path === "lessonId") return assertValidId(id, "lessonId");
20
+ if (path === "checkId") return assertValidId(id, "checkId");
21
+ if (path === "blockId") return assertValidId(id, "blockId");
22
+ return assertValidId(id, path);
23
+ }
24
+
25
+ // src/assessment/AssessmentLessonGuard.tsx
26
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
27
+ var warnedAssessmentOutsideLesson = false;
28
+ function resetAssessmentWarningsForTests() {
29
+ warnedAssessmentOutsideLesson = false;
30
+ }
31
+ function AssessmentLessonGuard(props) {
32
+ const enclosingLessonId = useEnclosingLessonId();
33
+ const missingLesson = enclosingLessonId === void 0;
34
+ useEffect(() => {
35
+ if (!missingLesson || isDevEnvironment()) return;
36
+ if (!warnedAssessmentOutsideLesson) {
37
+ warnedAssessmentOutsideLesson = true;
38
+ console.error(
39
+ `[lessonkit] <${props.blockLabel}> must be wrapped in <Lesson>; assessment telemetry will not be emitted.`
40
+ );
41
+ }
42
+ }, [missingLesson, props.blockLabel]);
43
+ if (missingLesson && isDevEnvironment()) {
44
+ throw new Error(`[lessonkit] <${props.blockLabel}> must be wrapped in <Lesson>`);
45
+ }
46
+ if (missingLesson) {
47
+ return /* @__PURE__ */ jsx("section", { role: "alert", "aria-label": `${props.blockLabel} configuration error`, "data-lk-check-id": props.checkId, children: /* @__PURE__ */ jsxs("p", { children: [
48
+ props.blockLabel,
49
+ " must be placed inside a Lesson."
50
+ ] }) });
51
+ }
52
+ return /* @__PURE__ */ jsx(Fragment, { children: props.children(enclosingLessonId) });
53
+ }
54
+
55
+ // src/runtime/productionGuard.ts
56
+ function isProductionEnvironment() {
57
+ try {
58
+ if (import.meta.env?.PROD === true) return true;
59
+ } catch {
60
+ }
61
+ const g = globalThis;
62
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
63
+ }
64
+ function shouldEnforceProductionGuard() {
65
+ try {
66
+ if (import.meta.env?.MODE === "test") return false;
67
+ } catch {
68
+ }
69
+ return isProductionEnvironment();
70
+ }
71
+ function looksLikeConsoleSink(fn) {
72
+ if (typeof fn !== "function") return false;
73
+ const src = Function.prototype.toString.call(fn);
74
+ return /console\.(log|debug|info)\s*\(/.test(src);
75
+ }
76
+ function isTrackingDeliveryConfigured(tracking) {
77
+ if (!tracking || tracking.enabled === false) return false;
78
+ return Boolean(tracking.sink || tracking.batchSink);
79
+ }
80
+ function isXapiDeliveryConfigured(xapi) {
81
+ if (!xapi || xapi.enabled === false) return false;
82
+ if (xapi.client) return true;
83
+ return typeof xapi.transport === "function";
84
+ }
85
+ function trackingUsesConsole(config) {
86
+ const tracking = config.tracking;
87
+ if (!tracking || tracking.enabled === false) return false;
88
+ if (tracking.batchSink && looksLikeConsoleSink(tracking.batchSink)) return true;
89
+ if (tracking.sink && looksLikeConsoleSink(tracking.sink)) return true;
90
+ return false;
91
+ }
92
+ function xapiUsesConsole(config) {
93
+ const xapi = config.xapi;
94
+ if (!xapi || xapi.enabled === false || xapi.client) return false;
95
+ return typeof xapi.transport === "function" && looksLikeConsoleSink(xapi.transport);
96
+ }
97
+ function observabilityIncomplete(observability, opts) {
98
+ if (!opts.trackingEnabled && !opts.xapiEnabled) return false;
99
+ const required = [observability?.onLxpackBridgeMiss];
100
+ if (opts.trackingEnabled) {
101
+ required.push(observability?.onTelemetrySinkError, observability?.onTelemetryBufferDrop);
102
+ }
103
+ if (opts.xapiEnabled) {
104
+ required.push(
105
+ observability?.onXapiQueueDepth,
106
+ observability?.onXapiQueueCap,
107
+ observability?.onXapiTransportError
108
+ );
109
+ }
110
+ return required.some((hook) => !hook);
111
+ }
112
+ function requiredObservabilityHookCount(opts) {
113
+ let count = 1;
114
+ if (opts.trackingEnabled) count += 2;
115
+ if (opts.xapiEnabled) count += 3;
116
+ return count;
117
+ }
118
+ function assertProductionCourseConfig(config) {
119
+ if (!isProductionEnvironment()) return;
120
+ if (config.tracking && config.tracking.enabled !== false && !isTrackingDeliveryConfigured(config.tracking)) {
121
+ throw new Error(
122
+ "[lessonkit] Production build has tracking enabled but no sink or batchSink configured."
123
+ );
124
+ }
125
+ const trackingEnabled = isTrackingDeliveryConfigured(config.tracking);
126
+ const xapiEnabled = isXapiDeliveryConfigured(config.xapi);
127
+ if (trackingUsesConsole(config)) {
128
+ throw new Error(
129
+ "[lessonkit] Production build uses console telemetry sinks. Wire createFetchBatchSink or a real sink. See production checklist."
130
+ );
131
+ }
132
+ if (xapiUsesConsole(config)) {
133
+ throw new Error(
134
+ "[lessonkit] Production build uses console xAPI transport. Wire createFetchTransport to your LRS proxy. See production checklist."
135
+ );
136
+ }
137
+ if (observabilityIncomplete(config.observability, { trackingEnabled, xapiEnabled })) {
138
+ const hookCount = requiredObservabilityHookCount({ trackingEnabled, xapiEnabled });
139
+ throw new Error(
140
+ `[lessonkit] Production build missing observability hooks. Wire all ${hookCount} config.observability callbacks before go-live.`
141
+ );
142
+ }
143
+ }
144
+
145
+ // src/runtime/emitTelemetry.ts
146
+ import { buildTelemetryEvent, tryBuildTelemetryEvent } from "@lessonkit/core";
147
+
148
+ // src/runtime/telemetryPipeline.ts
149
+ import {
150
+ createTelemetryPipeline,
151
+ createTrackingPipelineSink
152
+ } from "@lessonkit/core";
153
+ import { telemetryEventToXAPIStatement } from "@lessonkit/xapi";
154
+
155
+ // src/runtime/lxpackBridge.ts
156
+ import {
157
+ dispatchBridgeAction,
158
+ forwardTelemetryToBridge,
159
+ getLxpackBridge,
160
+ mapLessonkitTelemetryToBridgeAction,
161
+ telemetryEventToLessonkit
162
+ } from "@lessonkit/lxpack/bridge";
163
+ var BRIDGE_MISS_EVENT_NAMES = /* @__PURE__ */ new Set([
164
+ "course_completed",
165
+ "lesson_completed",
166
+ "assessment_completed",
167
+ "quiz_completed"
168
+ ]);
169
+ function forwardTelemetryToLxpack(event, mode = "auto", opts) {
170
+ if (mode === "auto" && opts?.onBridgeMiss && BRIDGE_MISS_EVENT_NAMES.has(event.name) && !getLxpackBridge()) {
171
+ opts.onBridgeMiss(event);
172
+ }
173
+ forwardTelemetryToBridge(event, mode);
174
+ }
175
+
176
+ // src/runtime/telemetryPipeline.ts
177
+ function isDevEnvironment2() {
178
+ const g = globalThis;
179
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
180
+ }
181
+ function createLegacyPipeline(opts, extraSinks = []) {
182
+ return createTelemetryPipeline([
183
+ createTrackingPipelineSink("tracking", (event) => opts.tracking.track(event)),
184
+ {
185
+ id: "xapi",
186
+ emit(event) {
187
+ try {
188
+ const statement = telemetryEventToXAPIStatement(event);
189
+ if (statement) opts.xapi?.send(statement);
190
+ } catch (err) {
191
+ if (isDevEnvironment2()) {
192
+ console.warn(
193
+ "[lessonkit] xAPI mapping skipped:",
194
+ err instanceof Error ? err.message : err
195
+ );
196
+ }
197
+ }
198
+ }
199
+ },
200
+ {
201
+ id: "lxpack-bridge",
202
+ emit(event) {
203
+ forwardTelemetryToLxpack(event, opts.lxpackBridge, {
204
+ onBridgeMiss: opts.onLxpackBridgeMiss
205
+ });
206
+ }
207
+ },
208
+ ...extraSinks
209
+ ]);
210
+ }
211
+ function emitThroughPipeline(event, opts, extraSinks) {
212
+ createLegacyPipeline(opts, extraSinks).emit(event);
213
+ }
214
+
215
+ // src/runtime/emitTelemetry.ts
216
+ var warnedMissingCourseId = false;
217
+ function isDevEnvironment3() {
218
+ const g = globalThis;
219
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
220
+ }
221
+ function emitTelemetry(tracking, xapi, event, opts) {
222
+ if (!event.courseId) {
223
+ if (isDevEnvironment3() && !warnedMissingCourseId) {
224
+ warnedMissingCourseId = true;
225
+ console.warn("[lessonkit] telemetry event missing courseId");
226
+ }
227
+ return;
228
+ }
229
+ const legacy = {
230
+ tracking,
231
+ xapi,
232
+ lxpackBridge: opts?.lxpackBridge ?? "auto",
233
+ onLxpackBridgeMiss: opts?.onLxpackBridgeMiss
234
+ };
235
+ emitThroughPipeline(event, legacy, opts?.extraSinks);
236
+ }
237
+
238
+ // src/runtime/courseStartedPipeline.ts
239
+ import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement2 } from "@lessonkit/xapi";
240
+ function isDevEnvironment4() {
241
+ const g = globalThis;
242
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
243
+ }
244
+ function warnExtraSinkFailure(sinkId, err) {
245
+ if (isDevEnvironment4()) {
246
+ console.warn(
247
+ `[lessonkit] course_started extra sink "${sinkId}" failed:`,
248
+ err instanceof Error ? err.message : err
249
+ );
250
+ }
251
+ }
252
+ async function emitExtraSinks(sinks, event, emitCtx) {
253
+ await Promise.all(
254
+ sinks.map(async (sink) => {
255
+ let result;
256
+ try {
257
+ result = sink.emit(event, emitCtx);
258
+ } catch (err) {
259
+ warnExtraSinkFailure(sink.id, err);
260
+ throw err;
261
+ }
262
+ if (result != null && typeof result.then === "function") {
263
+ try {
264
+ await result;
265
+ } catch (err) {
266
+ warnExtraSinkFailure(sink.id, err);
267
+ throw err;
268
+ }
269
+ }
270
+ })
271
+ );
272
+ }
273
+ async function emitCourseStartedNonTrackingPipeline(opts) {
274
+ let xapiStatementSent = false;
275
+ if (!opts.skipXapi && opts.xapi) {
276
+ const statement = telemetryEventToXAPIStatement2(opts.event);
277
+ if (statement) {
278
+ opts.xapi.send(statement);
279
+ await opts.xapi.flush();
280
+ xapiStatementSent = true;
281
+ }
282
+ }
283
+ forwardTelemetryToLxpack(opts.event, opts.lxpackBridge, {
284
+ onBridgeMiss: opts.onLxpackBridgeMiss
285
+ });
286
+ const emitCtx = {
287
+ courseId: opts.event.courseId,
288
+ sessionId: opts.event.sessionId,
289
+ attemptId: opts.event.attemptId
290
+ };
291
+ await emitExtraSinks(opts.extraSinks ?? [], opts.event, emitCtx);
292
+ return { xapiStatementSent };
293
+ }
294
+
295
+ // src/runtime/plugins.ts
296
+ import { buildPluginContext as buildPluginContextFromCore, createPluginRegistry } from "@lessonkit/core";
297
+ function createReactPluginHost(plugins) {
298
+ if (!plugins?.length) return null;
299
+ return createPluginRegistry(plugins);
300
+ }
301
+ function buildPluginContext(opts) {
302
+ return buildPluginContextFromCore(opts);
303
+ }
304
+ function emitTelemetryWithPlugins(opts) {
305
+ const next = opts.pluginHost ? opts.pluginHost.runTelemetry(opts.event, opts.pluginCtx) : opts.event;
306
+ if (next === null) return;
307
+ emitTelemetry(opts.tracking, opts.xapi, next, {
308
+ lxpackBridge: opts.lxpackBridge ?? "auto",
309
+ extraSinks: opts.extraSinks,
310
+ onLxpackBridgeMiss: opts.onLxpackBridgeMiss
311
+ });
312
+ }
313
+
314
+ // src/runtime/session.ts
315
+ import {
316
+ SESSION_STORAGE_KEY,
317
+ getTabSessionId,
318
+ resolveSessionId,
319
+ hasCourseStarted,
320
+ markCourseStarted,
321
+ hasCourseStartedEmittedToTracking,
322
+ markCourseStartedEmittedToTracking,
323
+ hasCourseStartedPipelineDelivered,
324
+ markCourseStartedPipelineDelivered,
325
+ migrateCourseStartedMark
326
+ } from "@lessonkit/core";
327
+
328
+ // src/provider/courseStarted/emit.ts
329
+ function resolveTrackingClient(source) {
330
+ return typeof source === "function" ? source() : source;
331
+ }
332
+ var courseStartedTrackingFlights = /* @__PURE__ */ new Map();
333
+ var courseStartedEmitFlights = /* @__PURE__ */ new Map();
334
+ function resetCourseStartedTrackingFlightForTests() {
335
+ courseStartedTrackingFlights.clear();
336
+ courseStartedEmitFlights.clear();
337
+ }
338
+ function isTrackingActive(tracking) {
339
+ return tracking?.enabled !== false;
340
+ }
341
+ function isCourseStartedSinkSettled(result) {
342
+ return result === "emitted" || result === "filtered";
343
+ }
344
+ async function deliverToTracking(client, event) {
345
+ if (client.deliver) {
346
+ return client.deliver(event);
347
+ }
348
+ client.track(event);
349
+ const flushed = await client.flush?.();
350
+ if (flushed === false) return false;
351
+ if (flushed === true) return true;
352
+ return false;
353
+ }
354
+ function buildCourseStartedEvent(opts) {
355
+ const pluginCtx = buildPluginContext({
356
+ courseId: opts.courseId,
357
+ sessionId: opts.sessionId,
358
+ attemptId: opts.attemptId,
359
+ user: opts.user
360
+ });
361
+ const built = buildTelemetryEvent({
362
+ name: "course_started",
363
+ courseId: opts.courseId,
364
+ sessionId: opts.sessionId,
365
+ attemptId: opts.attemptId,
366
+ user: opts.user
367
+ });
368
+ return opts.pluginHost ? opts.pluginHost.runTelemetry(built, pluginCtx) : built;
369
+ }
370
+ async function emitCourseStartedToTracking(tracking, storage, sessionId, courseId, event, shouldCommit) {
371
+ const flightKey = `${sessionId}:${courseId}`;
372
+ if (hasCourseStartedEmittedToTracking(storage, sessionId, courseId)) {
373
+ return true;
374
+ }
375
+ const existing = courseStartedTrackingFlights.get(flightKey);
376
+ if (existing) {
377
+ return existing;
378
+ }
379
+ let resolveFlight;
380
+ const flight = new Promise((resolve) => {
381
+ resolveFlight = resolve;
382
+ });
383
+ courseStartedTrackingFlights.set(flightKey, flight);
384
+ void (async () => {
385
+ try {
386
+ if (shouldCommit && !shouldCommit()) {
387
+ resolveFlight(false);
388
+ return;
389
+ }
390
+ const client = resolveTrackingClient(tracking);
391
+ const delivered = await deliverToTracking(client, event);
392
+ if (shouldCommit && !shouldCommit()) {
393
+ resolveFlight(false);
394
+ return;
395
+ }
396
+ if (!delivered) {
397
+ resolveFlight(false);
398
+ return;
399
+ }
400
+ if (markCourseStartedEmittedToTracking(storage, sessionId, courseId) === false) {
401
+ resolveFlight(false);
402
+ return;
403
+ }
404
+ resolveFlight(true);
405
+ } catch {
406
+ resolveFlight(false);
407
+ } finally {
408
+ if (courseStartedTrackingFlights.get(flightKey) === flight) {
409
+ courseStartedTrackingFlights.delete(flightKey);
410
+ }
411
+ }
412
+ })();
413
+ return flight;
414
+ }
415
+ async function emitCourseStartedPipelineOnly(opts) {
416
+ try {
417
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
418
+ const { xapiStatementSent } = await emitCourseStartedNonTrackingPipeline({
419
+ event: opts.event,
420
+ xapi: opts.xapi,
421
+ lxpackBridge: opts.lxpackBridge,
422
+ onLxpackBridgeMiss: opts.onLxpackBridgeMiss,
423
+ extraSinks: opts.extraSinks,
424
+ skipXapi: opts.skipXapi
425
+ });
426
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
427
+ if (markCourseStarted(opts.storage, opts.sessionId, opts.courseId) === false) return "failed";
428
+ if (markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId) === false) {
429
+ return "failed";
430
+ }
431
+ if (xapiStatementSent) {
432
+ opts.onXapiStatementSent?.();
433
+ }
434
+ return "emitted";
435
+ } catch {
436
+ return "failed";
437
+ }
438
+ }
439
+ async function emitCourseStarted(opts) {
440
+ const event = buildCourseStartedEvent(opts);
441
+ if (event === null) return "filtered";
442
+ const tracked = await emitCourseStartedToTracking(
443
+ opts.tracking,
444
+ opts.storage,
445
+ opts.sessionId,
446
+ opts.courseId,
447
+ event,
448
+ opts.shouldCommit
449
+ );
450
+ if (!tracked) return "failed";
451
+ return emitCourseStartedPipelineOnly({
452
+ ...opts,
453
+ event,
454
+ skipXapi: opts.skipXapi,
455
+ onXapiStatementSent: opts.onXapiStatementSent,
456
+ shouldCommit: opts.shouldCommit
457
+ });
458
+ }
459
+ async function emitCourseStartedToTrackingOnly(opts) {
460
+ const event = buildCourseStartedEvent(opts);
461
+ if (event === null) return "filtered";
462
+ const tracked = await emitCourseStartedToTracking(
463
+ opts.tracking,
464
+ opts.storage,
465
+ opts.sessionId,
466
+ opts.courseId,
467
+ event,
468
+ opts.shouldCommit
469
+ );
470
+ if (!tracked) return "failed";
471
+ try {
472
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
473
+ await emitCourseStartedNonTrackingPipeline({
474
+ event,
475
+ xapi: null,
476
+ lxpackBridge: opts.lxpackBridge,
477
+ onLxpackBridgeMiss: opts.onLxpackBridgeMiss,
478
+ extraSinks: opts.extraSinks,
479
+ skipXapi: true
480
+ });
481
+ if (markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId) === false) {
482
+ return "failed";
483
+ }
484
+ return "emitted";
485
+ } catch {
486
+ return "failed";
487
+ }
488
+ }
489
+ async function emitPendingCourseStarted(opts) {
490
+ const flightKey = `${opts.sessionId}:${opts.courseId}`;
491
+ for (let attempt = 0; attempt < 2; attempt += 1) {
492
+ const existing = courseStartedEmitFlights.get(flightKey);
493
+ const flight = existing ?? startPendingCourseStartedFlight(opts, flightKey);
494
+ const result = await flight;
495
+ if (result !== "failed") return result;
496
+ const sessionStarted = hasCourseStarted(opts.storage, opts.sessionId, opts.courseId);
497
+ const trackingEmitted = hasCourseStartedEmittedToTracking(
498
+ opts.storage,
499
+ opts.sessionId,
500
+ opts.courseId
501
+ );
502
+ const pipelineDelivered = hasCourseStartedPipelineDelivered(
503
+ opts.storage,
504
+ opts.sessionId,
505
+ opts.courseId
506
+ );
507
+ if (sessionStarted && trackingEmitted && pipelineDelivered) {
508
+ return "emitted";
509
+ }
510
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
511
+ }
512
+ return "failed";
513
+ }
514
+ function startPendingCourseStartedFlight(opts, flightKey) {
515
+ const flight = emitPendingCourseStartedInner(opts);
516
+ courseStartedEmitFlights.set(flightKey, flight);
517
+ void flight.finally(() => {
518
+ if (courseStartedEmitFlights.get(flightKey) === flight) {
519
+ courseStartedEmitFlights.delete(flightKey);
520
+ }
521
+ });
522
+ return flight;
523
+ }
524
+ async function emitPendingCourseStartedInner(opts) {
525
+ const trackingEmitted = hasCourseStartedEmittedToTracking(
526
+ opts.storage,
527
+ opts.sessionId,
528
+ opts.courseId
529
+ );
530
+ const sessionStarted = hasCourseStarted(opts.storage, opts.sessionId, opts.courseId);
531
+ const pipelineDelivered = hasCourseStartedPipelineDelivered(
532
+ opts.storage,
533
+ opts.sessionId,
534
+ opts.courseId
535
+ );
536
+ if (sessionStarted && trackingEmitted && pipelineDelivered) {
537
+ return "emitted";
538
+ }
539
+ if (sessionStarted && !trackingEmitted) {
540
+ return emitCourseStartedToTrackingOnly(opts);
541
+ }
542
+ if (trackingEmitted && !sessionStarted) {
543
+ const event = buildCourseStartedEvent(opts);
544
+ if (event === null) return "filtered";
545
+ return emitCourseStartedPipelineOnly({ ...opts, event });
546
+ }
547
+ if (!trackingEmitted && !sessionStarted) {
548
+ return emitCourseStarted(opts);
549
+ }
550
+ if (sessionStarted && trackingEmitted && !pipelineDelivered) {
551
+ const event = buildCourseStartedEvent(opts);
552
+ if (event === null) return "filtered";
553
+ return emitCourseStartedPipelineOnly({
554
+ ...opts,
555
+ event,
556
+ skipXapi: opts.skipXapi,
557
+ onXapiStatementSent: opts.onXapiStatementSent
558
+ });
559
+ }
560
+ return "failed";
561
+ }
562
+ function assertTrackingSinkConfig(tracking) {
563
+ if (!tracking?.sink || !tracking?.batchSink) return;
564
+ throw new Error(
565
+ "[lessonkit] tracking.sink and tracking.batchSink cannot both be set; use batchSink alone for batched delivery"
566
+ );
567
+ }
568
+
569
+ // src/provider/useLessonkitProviderRuntime.ts
570
+ import {
571
+ useCallback,
572
+ useEffect as useEffect2,
573
+ useLayoutEffect,
574
+ useMemo,
575
+ useRef,
576
+ useState
577
+ } from "react";
578
+ import { createLessonkitRuntime, createTrackingClient as createTrackingClient2, assertValidId as assertValidId2 } from "@lessonkit/core";
579
+
580
+ // src/runtime/observability.ts
581
+ import { createInMemoryXAPIQueue } from "@lessonkit/xapi";
582
+ function createXapiQueueFromObservability(getObservability) {
583
+ const opts = {
584
+ onDepth: (size) => getObservability?.()?.onXapiQueueDepth?.(size),
585
+ onCap: () => getObservability?.()?.onXapiQueueCap?.()
586
+ };
587
+ return createInMemoryXAPIQueue(opts);
588
+ }
589
+ function wrapBatchSink(batchSink, observability) {
590
+ if (!batchSink || !observability?.onTelemetrySinkError) return batchSink;
591
+ const onError = observability.onTelemetrySinkError;
592
+ return async (events) => {
593
+ try {
594
+ await batchSink(events);
595
+ } catch (err) {
596
+ onError(err, { sinkId: "tracking-batch" });
597
+ throw err;
598
+ }
599
+ };
600
+ }
601
+ function wrapTrackingSink(sink, observability) {
602
+ if (!sink || !observability?.onTelemetrySinkError) return sink;
603
+ const onError = observability.onTelemetrySinkError;
604
+ return ((event) => {
605
+ try {
606
+ const result = sink(event);
607
+ if (result != null && typeof result.catch === "function") {
608
+ return result.catch((err) => {
609
+ onError(err, { sinkId: "tracking" });
610
+ throw err;
611
+ });
612
+ }
613
+ return result;
614
+ } catch (err) {
615
+ onError(err, { sinkId: "tracking" });
616
+ throw err;
617
+ }
618
+ });
619
+ }
620
+
621
+ // src/provider/useLessonkitProviderRuntime.ts
622
+ import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement3 } from "@lessonkit/xapi";
623
+
624
+ // src/runtime/ports.ts
625
+ import {
626
+ createDefaultClock,
627
+ createGlobalTimer,
628
+ createNoopStorage,
629
+ createSessionStoragePort,
630
+ resetStoragePortForTests
631
+ } from "@lessonkit/core";
632
+
633
+ // src/provider/useLessonkitProviderRuntime.ts
634
+ import { resetSharedVolatileSessionIdForTests } from "@lessonkit/core";
635
+
636
+ // src/runtime/progress.ts
637
+ import { createProgressController } from "@lessonkit/core";
638
+
639
+ // src/runtime/xapi.ts
640
+ import { createXAPIClient } from "@lessonkit/xapi";
641
+ function createXapiClientFromConfig(config, queue, observability) {
642
+ if (config.xapi?.enabled === false) return null;
643
+ if (config.xapi?.client) return config.xapi.client;
644
+ if (!config.courseId) return null;
645
+ const hasTransport = typeof config.xapi?.transport === "function";
646
+ if (!hasTransport && config.xapi?.enabled !== true) return null;
647
+ return createXAPIClient({
648
+ courseId: config.courseId,
649
+ transport: config.xapi?.transport,
650
+ exitTransport: config.xapi?.exitTransport,
651
+ abortInFlight: config.xapi?.abortInFlight,
652
+ queue,
653
+ onTransportError: observability?.onXapiTransportError
654
+ });
655
+ }
656
+
657
+ // src/runtime/telemetry.ts
658
+ import { createTrackingClient } from "@lessonkit/core";
659
+ function createTrackingClientFromConfig(config, observability) {
660
+ if (config.tracking?.enabled === false) return createTrackingClient();
661
+ if (config.tracking?.createClient) return config.tracking.createClient();
662
+ return createTrackingClient({
663
+ sink: config.tracking?.sink,
664
+ batchSink: config.tracking?.batchSink,
665
+ batch: config.tracking?.batch,
666
+ exitBatchSink: config.tracking?.exitBatchSink,
667
+ onBufferDrop: observability?.onTelemetryBufferDrop
668
+ });
669
+ }
670
+ async function disposeTrackingClient(client) {
671
+ try {
672
+ await client?.flush?.();
673
+ } catch {
674
+ }
675
+ try {
676
+ await client?.dispose?.();
677
+ } catch {
678
+ }
679
+ }
680
+
681
+ // src/provider/useLessonkitProviderRuntime.ts
682
+ var useIsoLayoutEffect = (
683
+ /* v8 ignore next -- SSR uses useEffect when window is unavailable */
684
+ typeof window !== "undefined" ? useLayoutEffect : useEffect2
685
+ );
686
+ var defaultStorage = createSessionStoragePort();
687
+ function resetLessonkitProviderStorageForTests() {
688
+ resetStoragePortForTests(defaultStorage);
689
+ resetSharedVolatileSessionIdForTests();
690
+ }
691
+ function useLessonkitProviderRuntime(config) {
692
+ const normalizedCourseId = useMemo(
693
+ () => assertValidId2(config.courseId, "courseId"),
694
+ [config.courseId]
695
+ );
696
+ const normalizedConfig = useMemo(
697
+ () => ({ ...config, courseId: normalizedCourseId }),
698
+ [config, normalizedCourseId]
699
+ );
700
+ if (shouldEnforceProductionGuard()) {
701
+ assertProductionCourseConfig(normalizedConfig);
702
+ }
703
+ const useV2Runtime = normalizedConfig.runtimeVersion !== "v1";
704
+ useEffect2(() => {
705
+ if (useV2Runtime) return;
706
+ const g = globalThis;
707
+ if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
708
+ console.warn(
709
+ '[lessonkit] LessonkitProvider runtimeVersion "v1" is deprecated; omit or use "v2" (default). v1 will be removed in LessonKit 2.0.'
710
+ );
711
+ }, [useV2Runtime]);
712
+ const extraSinksRef = useRef(normalizedConfig.sinks);
713
+ extraSinksRef.current = normalizedConfig.sinks;
714
+ const headlessRef = useRef(null);
715
+ const sessionIdRef = useRef(resolveSessionId(defaultStorage, normalizedConfig.session?.sessionId));
716
+ const prevConfiguredSessionIdRef = useRef(normalizedConfig.session?.sessionId);
717
+ if (normalizedConfig.session?.sessionId) {
718
+ sessionIdRef.current = normalizedConfig.session.sessionId;
719
+ } else if (prevConfiguredSessionIdRef.current) {
720
+ sessionIdRef.current = resolveSessionId(defaultStorage, void 0);
721
+ }
722
+ const attemptIdRef = useRef(normalizedConfig.session?.attemptId);
723
+ const userRef = useRef(normalizedConfig.session?.user);
724
+ attemptIdRef.current = normalizedConfig.session?.attemptId;
725
+ userRef.current = normalizedConfig.session?.user;
726
+ const courseIdRef = useRef(normalizedCourseId);
727
+ courseIdRef.current = normalizedCourseId;
728
+ const lxpackBridgeModeRef = useRef(normalizedConfig.lxpack?.bridge ?? "auto");
729
+ lxpackBridgeModeRef.current = normalizedConfig.lxpack?.bridge ?? "auto";
730
+ const observabilityRef = useRef(normalizedConfig.observability);
731
+ observabilityRef.current = normalizedConfig.observability;
732
+ const onLxpackBridgeMiss = useCallback((event) => {
733
+ observabilityRef.current?.onLxpackBridgeMiss?.(event);
734
+ }, []);
735
+ const pluginsFingerprint = normalizedConfig.plugins?.map((p) => `${p.id}\0${p.version}`).join("|") ?? "";
736
+ const pluginHost = useMemo(
737
+ () => createReactPluginHost(normalizedConfig.plugins),
738
+ [pluginsFingerprint]
739
+ );
740
+ const pluginHostRef = useRef(pluginHost);
741
+ pluginHostRef.current = pluginHost;
742
+ const progressRef = useRef(createProgressController());
743
+ const courseStartedEmittedToSinkRef = useRef(false);
744
+ const courseStartedEmitGenerationRef = useRef(0);
745
+ const prevCourseIdForProgressRef = useRef(normalizedCourseId);
746
+ const pendingCourseIdResetRef = useRef(false);
747
+ const prevUseV2RuntimeRef = useRef(useV2Runtime);
748
+ const xapiCourseStartedSentOnClientRef = useRef(false);
749
+ const xapiBootstrapSendRef = useRef(false);
750
+ if (prevUseV2RuntimeRef.current !== useV2Runtime) {
751
+ prevUseV2RuntimeRef.current = useV2Runtime;
752
+ if (useV2Runtime) {
753
+ headlessRef.current = createLessonkitRuntime({
754
+ courseId: normalizedCourseId,
755
+ runtimeVersion: "v2",
756
+ session: normalizedConfig.session,
757
+ plugins: pluginHostRef.current ?? normalizedConfig.plugins,
758
+ deferPluginSetup: true
759
+ });
760
+ progressRef.current = headlessRef.current.progress;
761
+ } else {
762
+ headlessRef.current = null;
763
+ progressRef.current = createProgressController();
764
+ }
765
+ pendingCourseIdResetRef.current = true;
766
+ courseStartedEmittedToSinkRef.current = false;
767
+ courseStartedEmitGenerationRef.current += 1;
768
+ } else if (useV2Runtime && !headlessRef.current) {
769
+ headlessRef.current = createLessonkitRuntime({
770
+ courseId: normalizedCourseId,
771
+ runtimeVersion: "v2",
772
+ session: normalizedConfig.session,
773
+ plugins: pluginHostRef.current ?? normalizedConfig.plugins,
774
+ deferPluginSetup: true
775
+ });
776
+ }
777
+ if (prevCourseIdForProgressRef.current !== normalizedCourseId) {
778
+ prevCourseIdForProgressRef.current = normalizedCourseId;
779
+ if (useV2Runtime && headlessRef.current) {
780
+ headlessRef.current.resetForCourseChange(normalizedCourseId);
781
+ progressRef.current = headlessRef.current.progress;
782
+ } else {
783
+ progressRef.current = createProgressController();
784
+ }
785
+ pendingCourseIdResetRef.current = true;
786
+ courseStartedEmittedToSinkRef.current = false;
787
+ courseStartedEmitGenerationRef.current += 1;
788
+ }
789
+ if (useV2Runtime && headlessRef.current) {
790
+ progressRef.current = headlessRef.current.progress;
791
+ }
792
+ const [progress, setProgress] = useState(() => progressRef.current.getState());
793
+ const syncProgress = useCallback(() => {
794
+ setProgress(progressRef.current.getState());
795
+ }, []);
796
+ const activeLessonIdRef = useRef(progress.activeLessonId);
797
+ activeLessonIdRef.current = progress.activeLessonId;
798
+ const xapiQueueRef = useRef(createXapiQueueFromObservability(() => observabilityRef.current));
799
+ const xapiRef = useRef(null);
800
+ const [xapi, setXapi] = useState(null);
801
+ const prevXapiCourseIdRef = useRef(normalizedCourseId);
802
+ const xapiEnabled = normalizedConfig.xapi?.enabled;
803
+ const xapiClient = normalizedConfig.xapi?.client;
804
+ const xapiTransport = normalizedConfig.xapi?.transport;
805
+ const courseId = normalizedCourseId;
806
+ const trackingEnabled = normalizedConfig.tracking?.enabled;
807
+ useIsoLayoutEffect(() => {
808
+ const courseChanged = prevXapiCourseIdRef.current !== courseId;
809
+ if (courseChanged) {
810
+ if (normalizedConfig.xapi?.client) {
811
+ const g = globalThis;
812
+ if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production") {
813
+ console.warn(
814
+ "[lessonkit] courseId changed while using config.xapi.client; flush the client between courses or use config.xapi.transport so the provider can manage the queue."
815
+ );
816
+ }
817
+ void xapiRef.current?.flush();
818
+ }
819
+ xapiQueueRef.current = createXapiQueueFromObservability(() => observabilityRef.current);
820
+ prevXapiCourseIdRef.current = courseId;
821
+ xapiCourseStartedSentOnClientRef.current = false;
822
+ xapiBootstrapSendRef.current = false;
823
+ }
824
+ const prev = xapiRef.current;
825
+ const next = createXapiClientFromConfig(
826
+ normalizedConfig,
827
+ xapiQueueRef.current,
828
+ observabilityRef.current
829
+ );
830
+ xapiRef.current = next;
831
+ setXapi(next);
832
+ let bootstrapSent = false;
833
+ let bootstrapAlreadyStarted = false;
834
+ if (next) {
835
+ const sessionId = sessionIdRef.current;
836
+ const cid = courseIdRef.current;
837
+ const trackingActive = isTrackingActive(normalizedConfig.tracking);
838
+ bootstrapAlreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
839
+ const clientChanged = !prev || prev !== next;
840
+ const skipBootstrap = trackingActive && !bootstrapAlreadyStarted;
841
+ const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && !xapiBootstrapSendRef.current && (!bootstrapAlreadyStarted || clientChanged);
842
+ if (needsBootstrap) {
843
+ try {
844
+ const event = buildCourseStartedEvent({
845
+ pluginHost: pluginHostRef.current,
846
+ courseId: cid,
847
+ sessionId,
848
+ attemptId: attemptIdRef.current,
849
+ user: userRef.current,
850
+ lxpackBridge: lxpackBridgeModeRef.current
851
+ });
852
+ if (event !== null) {
853
+ const statement = telemetryEventToXAPIStatement3(event);
854
+ if (statement) {
855
+ next.send(statement);
856
+ xapiBootstrapSendRef.current = true;
857
+ bootstrapSent = true;
858
+ }
859
+ }
860
+ } catch {
861
+ }
862
+ }
863
+ }
864
+ let cancelled = false;
865
+ void (async () => {
866
+ if (prev) {
867
+ try {
868
+ await prev.flush();
869
+ } catch {
870
+ }
871
+ }
872
+ if (cancelled) return;
873
+ try {
874
+ await next?.flush();
875
+ if (bootstrapSent && !cancelled) {
876
+ if (!bootstrapAlreadyStarted) {
877
+ markCourseStarted(defaultStorage, sessionIdRef.current, courseIdRef.current);
878
+ }
879
+ xapiCourseStartedSentOnClientRef.current = true;
880
+ }
881
+ } catch {
882
+ }
883
+ })();
884
+ return () => {
885
+ cancelled = true;
886
+ void (async () => {
887
+ try {
888
+ await prev?.flush();
889
+ } catch {
890
+ }
891
+ })();
892
+ };
893
+ }, [xapiEnabled, xapiClient, xapiTransport, courseId, trackingEnabled]);
894
+ const trackingRef = useRef(createTrackingClient2());
895
+ const trackingClientForUnmountRef = useRef(trackingRef.current);
896
+ const [tracking, setTracking] = useState(() => trackingRef.current);
897
+ const trackingSink = normalizedConfig.tracking?.sink;
898
+ const trackingBatchSink = normalizedConfig.tracking?.batchSink;
899
+ const batchEnabled = normalizedConfig.tracking?.batch?.enabled;
900
+ const batchFlushIntervalMs = normalizedConfig.tracking?.batch?.flushIntervalMs;
901
+ const batchMaxBatchSize = normalizedConfig.tracking?.batch?.maxBatchSize;
902
+ const buildCurrentPluginCtx = useCallback(
903
+ () => buildPluginContext({
904
+ courseId: courseIdRef.current,
905
+ sessionId: sessionIdRef.current,
906
+ attemptId: attemptIdRef.current,
907
+ user: userRef.current
908
+ }),
909
+ []
910
+ );
911
+ useIsoLayoutEffect(() => {
912
+ const prev = trackingRef.current;
913
+ const baseSink = wrapTrackingSink(normalizedConfig.tracking?.sink, observabilityRef.current);
914
+ const userBatchSink = wrapBatchSink(
915
+ normalizedConfig.tracking?.batchSink,
916
+ observabilityRef.current
917
+ );
918
+ assertTrackingSinkConfig(normalizedConfig.tracking);
919
+ const sink = pluginHostRef.current && baseSink ? (
920
+ /* v8 ignore next -- composeTrackingSink may return null; fall back to base sink */
921
+ pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink
922
+ ) : baseSink;
923
+ const batchSink = pluginHostRef.current && userBatchSink ? async (events) => {
924
+ const host = pluginHostRef.current;
925
+ const ctx = buildCurrentPluginCtx();
926
+ const delivered = host.deliverTelemetryBatch(events, ctx);
927
+ const perEventForBatch = [];
928
+ const collector = (event) => {
929
+ perEventForBatch.push(event);
930
+ };
931
+ const composedPerEvent = host.composeTrackingSink(collector, buildCurrentPluginCtx) ?? collector;
932
+ for (const event of delivered) {
933
+ await Promise.resolve(composedPerEvent(event));
934
+ }
935
+ return userBatchSink(perEventForBatch);
936
+ } : userBatchSink;
937
+ const next = createTrackingClientFromConfig(
938
+ {
939
+ tracking: { ...normalizedConfig.tracking, sink, batchSink }
940
+ },
941
+ observabilityRef.current
942
+ );
943
+ trackingRef.current = next;
944
+ trackingClientForUnmountRef.current = next;
945
+ setTracking(next);
946
+ const sessionId = sessionIdRef.current;
947
+ const cid = courseIdRef.current;
948
+ const trackingActive = isTrackingActive(normalizedConfig.tracking);
949
+ const courseStartedFullySettled = hasCourseStartedEmittedToTracking(defaultStorage, sessionId, cid) && hasCourseStarted(defaultStorage, sessionId, cid) && hasCourseStartedPipelineDelivered(defaultStorage, sessionId, cid);
950
+ if (!trackingActive) {
951
+ courseStartedEmittedToSinkRef.current = false;
952
+ } else if (courseStartedFullySettled) {
953
+ courseStartedEmittedToSinkRef.current = true;
954
+ } else if (!courseStartedEmittedToSinkRef.current) {
955
+ const generation = courseStartedEmitGenerationRef.current;
956
+ const shouldCommit = () => generation === courseStartedEmitGenerationRef.current;
957
+ void (async () => {
958
+ if (generation !== courseStartedEmitGenerationRef.current) return;
959
+ const result = await emitPendingCourseStarted({
960
+ pluginHost: pluginHostRef.current,
961
+ tracking: () => trackingRef.current,
962
+ xapi: xapiRef.current,
963
+ storage: defaultStorage,
964
+ sessionId,
965
+ courseId: cid,
966
+ attemptId: attemptIdRef.current,
967
+ user: userRef.current,
968
+ lxpackBridge: lxpackBridgeModeRef.current,
969
+ onLxpackBridgeMiss,
970
+ extraSinks: extraSinksRef.current,
971
+ skipXapi: xapiCourseStartedSentOnClientRef.current || xapiBootstrapSendRef.current,
972
+ onXapiStatementSent: () => {
973
+ xapiCourseStartedSentOnClientRef.current = true;
974
+ },
975
+ shouldCommit
976
+ });
977
+ if (generation !== courseStartedEmitGenerationRef.current) return;
978
+ courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
979
+ })();
980
+ }
981
+ return () => {
982
+ if (prev !== trackingRef.current) {
983
+ void disposeTrackingClient(prev);
984
+ }
985
+ };
986
+ }, [
987
+ trackingEnabled,
988
+ trackingSink,
989
+ trackingBatchSink,
990
+ batchEnabled,
991
+ batchFlushIntervalMs,
992
+ batchMaxBatchSize,
993
+ normalizedConfig.plugins,
994
+ normalizedCourseId,
995
+ buildCurrentPluginCtx
996
+ ]);
997
+ const emitWithBridge = useCallback((trackingClient, event) => {
998
+ emitTelemetryWithPlugins({
999
+ pluginHost: pluginHostRef.current,
1000
+ tracking: trackingClient,
1001
+ xapi: xapiRef.current,
1002
+ event,
1003
+ pluginCtx: buildPluginContext({
1004
+ courseId: courseIdRef.current,
1005
+ sessionId: sessionIdRef.current,
1006
+ attemptId: attemptIdRef.current,
1007
+ user: userRef.current
1008
+ }),
1009
+ lxpackBridge: lxpackBridgeModeRef.current,
1010
+ onLxpackBridgeMiss,
1011
+ extraSinks: extraSinksRef.current
1012
+ });
1013
+ }, [onLxpackBridgeMiss]);
1014
+ const emitLifecycleEvent = useCallback(
1015
+ (name, data, lessonId) => {
1016
+ const event = tryBuildTelemetryEvent({
1017
+ name,
1018
+ courseId: courseIdRef.current,
1019
+ lessonId: lessonId ?? activeLessonIdRef.current,
1020
+ sessionId: sessionIdRef.current,
1021
+ attemptId: attemptIdRef.current,
1022
+ user: userRef.current,
1023
+ data
1024
+ });
1025
+ if (!event) return;
1026
+ emitWithBridge(trackingRef.current, event);
1027
+ },
1028
+ [emitWithBridge]
1029
+ );
1030
+ const track = useCallback(
1031
+ (name, data, opts) => {
1032
+ const event = tryBuildTelemetryEvent({
1033
+ name,
1034
+ courseId: courseIdRef.current,
1035
+ lessonId: opts?.lessonId ?? activeLessonIdRef.current,
1036
+ sessionId: sessionIdRef.current,
1037
+ attemptId: attemptIdRef.current,
1038
+ user: userRef.current,
1039
+ data
1040
+ });
1041
+ if (!event) return;
1042
+ emitWithBridge(trackingRef.current, event);
1043
+ },
1044
+ [emitWithBridge]
1045
+ );
1046
+ useLayoutEffect(() => {
1047
+ if (!pendingCourseIdResetRef.current) return;
1048
+ pendingCourseIdResetRef.current = false;
1049
+ syncProgress();
1050
+ if (!isTrackingActive(normalizedConfig.tracking)) return;
1051
+ const sessionId = sessionIdRef.current;
1052
+ const cid = courseIdRef.current;
1053
+ void (async () => {
1054
+ try {
1055
+ await trackingRef.current?.flush?.();
1056
+ } catch {
1057
+ }
1058
+ if (!courseStartedEmittedToSinkRef.current) {
1059
+ const generation = courseStartedEmitGenerationRef.current;
1060
+ const shouldCommit = () => generation === courseStartedEmitGenerationRef.current;
1061
+ const result = await emitPendingCourseStarted({
1062
+ pluginHost: pluginHostRef.current,
1063
+ tracking: () => trackingRef.current,
1064
+ xapi: xapiRef.current,
1065
+ storage: defaultStorage,
1066
+ sessionId,
1067
+ courseId: cid,
1068
+ attemptId: attemptIdRef.current,
1069
+ user: userRef.current,
1070
+ lxpackBridge: lxpackBridgeModeRef.current,
1071
+ onLxpackBridgeMiss,
1072
+ extraSinks: extraSinksRef.current,
1073
+ skipXapi: xapiCourseStartedSentOnClientRef.current || xapiBootstrapSendRef.current,
1074
+ onXapiStatementSent: () => {
1075
+ xapiCourseStartedSentOnClientRef.current = true;
1076
+ },
1077
+ shouldCommit
1078
+ });
1079
+ if (generation !== courseStartedEmitGenerationRef.current) return;
1080
+ courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
1081
+ }
1082
+ })();
1083
+ }, [normalizedCourseId, normalizedConfig.tracking?.enabled, syncProgress, onLxpackBridgeMiss]);
1084
+ const emitLessonCompleted = useCallback(
1085
+ (lessonId, durationMs) => {
1086
+ track("lesson_completed", { lessonId, durationMs }, { lessonId });
1087
+ if (durationMs !== void 0) {
1088
+ track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
1089
+ }
1090
+ },
1091
+ [track]
1092
+ );
1093
+ const completeLesson = useCallback(
1094
+ (lessonId, opts) => {
1095
+ if (opts?.courseId !== void 0 && opts.courseId !== courseIdRef.current) {
1096
+ return;
1097
+ }
1098
+ if (useV2Runtime && headlessRef.current) {
1099
+ headlessRef.current.completeLesson(lessonId, emitLifecycleEvent);
1100
+ syncProgress();
1101
+ void Promise.resolve(trackingRef.current?.flush?.());
1102
+ return;
1103
+ }
1104
+ const result = progressRef.current.completeLesson(lessonId, Date.now());
1105
+ if (!result.didComplete) return;
1106
+ syncProgress();
1107
+ emitLessonCompleted(lessonId, result.durationMs);
1108
+ void Promise.resolve(trackingRef.current?.flush?.());
1109
+ },
1110
+ [syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
1111
+ );
1112
+ useEffect2(() => {
1113
+ return () => {
1114
+ const client = trackingClientForUnmountRef.current;
1115
+ const xapi2 = xapiRef.current;
1116
+ void (async () => {
1117
+ try {
1118
+ await xapi2?.flush();
1119
+ } catch {
1120
+ }
1121
+ try {
1122
+ await client?.flush?.();
1123
+ } catch {
1124
+ }
1125
+ try {
1126
+ await client?.dispose?.();
1127
+ } catch {
1128
+ }
1129
+ })();
1130
+ };
1131
+ }, []);
1132
+ useEffect2(() => {
1133
+ if (typeof document === "undefined") return;
1134
+ const flushOnPageExit = () => {
1135
+ try {
1136
+ xapiRef.current?.flushOnExit?.();
1137
+ trackingRef.current?.flushOnExit?.();
1138
+ } finally {
1139
+ void xapiRef.current?.flush();
1140
+ void trackingRef.current?.flush?.();
1141
+ }
1142
+ };
1143
+ const onVisibilityChange = () => {
1144
+ if (document.visibilityState === "hidden") flushOnPageExit();
1145
+ };
1146
+ document.addEventListener("visibilitychange", onVisibilityChange);
1147
+ window.addEventListener("pagehide", flushOnPageExit);
1148
+ return () => {
1149
+ document.removeEventListener("visibilitychange", onVisibilityChange);
1150
+ window.removeEventListener("pagehide", flushOnPageExit);
1151
+ };
1152
+ }, []);
1153
+ const setActiveLesson = useCallback(
1154
+ (lessonId) => {
1155
+ if (useV2Runtime && headlessRef.current) {
1156
+ headlessRef.current.setActiveLesson(lessonId, emitLifecycleEvent);
1157
+ syncProgress();
1158
+ void Promise.resolve(trackingRef.current?.flush?.());
1159
+ return;
1160
+ }
1161
+ const current = progressRef.current.getState();
1162
+ if (current.activeLessonId === lessonId) return;
1163
+ if (current.completedLessonIds.has(lessonId)) {
1164
+ progressRef.current.setActiveLesson(lessonId, Date.now());
1165
+ syncProgress();
1166
+ return;
1167
+ }
1168
+ const previous = current.activeLessonId;
1169
+ if (previous && previous !== lessonId) {
1170
+ const completed = progressRef.current.completeLesson(previous, Date.now());
1171
+ if (completed.didComplete) {
1172
+ emitLessonCompleted(previous, completed.durationMs);
1173
+ void Promise.resolve(trackingRef.current?.flush?.());
1174
+ }
1175
+ }
1176
+ progressRef.current.setActiveLesson(lessonId, Date.now());
1177
+ syncProgress();
1178
+ track("lesson_started", { lessonId }, { lessonId });
1179
+ },
1180
+ [track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
1181
+ );
1182
+ const completeCourse = useCallback(() => {
1183
+ if (useV2Runtime && headlessRef.current) {
1184
+ headlessRef.current.completeCourse(emitLifecycleEvent);
1185
+ syncProgress();
1186
+ void trackingRef.current?.flush?.();
1187
+ return;
1188
+ }
1189
+ const current = progressRef.current.getState();
1190
+ if (current.activeLessonId) {
1191
+ const lessonResult = progressRef.current.completeLesson(current.activeLessonId, Date.now());
1192
+ if (lessonResult.didComplete) {
1193
+ emitLessonCompleted(current.activeLessonId, lessonResult.durationMs);
1194
+ }
1195
+ }
1196
+ const result = progressRef.current.completeCourse();
1197
+ if (!result.didComplete) return;
1198
+ syncProgress();
1199
+ track("course_completed");
1200
+ void trackingRef.current?.flush?.();
1201
+ }, [track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]);
1202
+ const sessionUser = normalizedConfig.session?.user;
1203
+ const sessionUserKey = useMemo(
1204
+ () => sessionUser ? JSON.stringify(sessionUser) : "",
1205
+ [sessionUser]
1206
+ );
1207
+ const sessionAttemptId = normalizedConfig.session?.attemptId;
1208
+ const sessionConfiguredId = normalizedConfig.session?.sessionId;
1209
+ useEffect2(() => {
1210
+ if (useV2Runtime && headlessRef.current) {
1211
+ headlessRef.current.updateConfig({
1212
+ courseId: normalizedCourseId,
1213
+ session: normalizedConfig.session
1214
+ });
1215
+ }
1216
+ }, [
1217
+ useV2Runtime,
1218
+ normalizedCourseId,
1219
+ sessionAttemptId,
1220
+ sessionConfiguredId,
1221
+ sessionUserKey,
1222
+ normalizedConfig.session
1223
+ ]);
1224
+ useEffect2(() => {
1225
+ if (!useV2Runtime || !headlessRef.current) return;
1226
+ headlessRef.current.updateConfig({
1227
+ plugins: pluginHostRef.current ?? normalizedConfig.plugins
1228
+ });
1229
+ }, [useV2Runtime, pluginHost]);
1230
+ useEffect2(() => {
1231
+ const host = useV2Runtime ? headlessRef.current?.pluginHost ?? null : pluginHost;
1232
+ if (!host) return;
1233
+ const ctx = buildPluginContext({
1234
+ courseId: courseIdRef.current,
1235
+ sessionId: sessionIdRef.current,
1236
+ attemptId: attemptIdRef.current,
1237
+ user: userRef.current
1238
+ });
1239
+ host.setupAll(ctx);
1240
+ return () => {
1241
+ host.disposeAll();
1242
+ };
1243
+ }, [pluginHost, useV2Runtime, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey]);
1244
+ useEffect2(() => {
1245
+ const nextConfigured = normalizedConfig.session?.sessionId;
1246
+ const prevConfigured = prevConfiguredSessionIdRef.current;
1247
+ if (nextConfigured === prevConfigured) return;
1248
+ prevConfiguredSessionIdRef.current = nextConfigured;
1249
+ const cid = courseIdRef.current;
1250
+ if (nextConfigured) {
1251
+ const fromIds = /* @__PURE__ */ new Set();
1252
+ if (prevConfigured) fromIds.add(prevConfigured);
1253
+ const tabId = getTabSessionId(defaultStorage);
1254
+ if (tabId) fromIds.add(tabId);
1255
+ for (const fromId of fromIds) {
1256
+ if (fromId !== nextConfigured) {
1257
+ migrateCourseStartedMark(defaultStorage, fromId, nextConfigured, cid);
1258
+ }
1259
+ }
1260
+ sessionIdRef.current = nextConfigured;
1261
+ } else if (prevConfigured) {
1262
+ const nextAuto = resolveSessionId(defaultStorage, void 0);
1263
+ migrateCourseStartedMark(defaultStorage, prevConfigured, nextAuto, cid);
1264
+ sessionIdRef.current = nextAuto;
1265
+ }
1266
+ }, [sessionConfiguredId, normalizedCourseId]);
1267
+ const runtime = useMemo(
1268
+ () => ({
1269
+ config: normalizedConfig,
1270
+ tracking,
1271
+ xapi,
1272
+ storage: defaultStorage,
1273
+ session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
1274
+ progress,
1275
+ setActiveLesson,
1276
+ completeLesson,
1277
+ completeCourse,
1278
+ track,
1279
+ plugins: pluginHost
1280
+ }),
1281
+ [
1282
+ normalizedConfig,
1283
+ tracking,
1284
+ xapi,
1285
+ progress,
1286
+ setActiveLesson,
1287
+ completeLesson,
1288
+ completeCourse,
1289
+ track,
1290
+ pluginHost,
1291
+ sessionUser,
1292
+ sessionAttemptId,
1293
+ sessionConfiguredId
1294
+ ]
1295
+ );
1296
+ return runtime;
1297
+ }
1298
+
1299
+ // src/context.tsx
1300
+ import { createContext as createContext2 } from "react";
1301
+ import { jsx as jsx2 } from "react/jsx-runtime";
1302
+ var LessonkitContext = createContext2(null);
1303
+ function LessonkitProvider(props) {
1304
+ const runtime = useLessonkitProviderRuntime(props.config);
1305
+ return /* @__PURE__ */ jsx2(LessonkitContext.Provider, { value: runtime, children: props.children });
1306
+ }
1307
+
1308
+ // src/assessment/useAssessmentState.ts
1309
+ import { useMemo as useMemo3 } from "react";
1310
+
1311
+ // src/hooks.ts
1312
+ import { useContext as useContext2, useMemo as useMemo2 } from "react";
1313
+ function useLessonkit() {
1314
+ const ctx = useContext2(LessonkitContext);
1315
+ if (!ctx) throw new Error("LessonKit: missing LessonkitProvider");
1316
+ return ctx;
1317
+ }
1318
+ function useProgress() {
1319
+ const { progress } = useLessonkit();
1320
+ return progress;
1321
+ }
1322
+ function useTracking() {
1323
+ const { track } = useLessonkit();
1324
+ return useMemo2(() => ({ track }), [track]);
1325
+ }
1326
+ function useCompletion() {
1327
+ const { completeLesson, completeCourse } = useLessonkit();
1328
+ return useMemo2(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
1329
+ }
1330
+ function useQuizState(enclosingLessonId) {
1331
+ const { track } = useLessonkit();
1332
+ const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
1333
+ return useMemo2(
1334
+ () => ({
1335
+ answer: (opts) => {
1336
+ track("quiz_answered", opts, trackOpts);
1337
+ },
1338
+ complete: (opts) => {
1339
+ track("quiz_completed", opts, trackOpts);
1340
+ }
1341
+ }),
1342
+ [track, enclosingLessonId]
1343
+ );
1344
+ }
1345
+
1346
+ // src/assessment/useAssessmentState.ts
1347
+ function useAssessmentState(enclosingLessonId) {
1348
+ const { track } = useLessonkit();
1349
+ const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
1350
+ return useMemo3(
1351
+ () => ({
1352
+ answer: (data) => {
1353
+ track("assessment_answered", data, trackOpts);
1354
+ },
1355
+ complete: (data) => {
1356
+ track("assessment_completed", data, trackOpts);
1357
+ }
1358
+ }),
1359
+ [track, enclosingLessonId]
1360
+ );
1361
+ }
1362
+
1363
+ // src/compound/validateChildren.ts
1364
+ import React3 from "react";
1365
+ import {
1366
+ ACCORDION_FORBIDDEN_CHILD_TYPES,
1367
+ COMPOUND_MAX_NESTING_DEPTH,
1368
+ isChildTypeAllowed
1369
+ } from "@lessonkit/core";
1370
+
1371
+ // src/compound/blockType.ts
1372
+ var LESSONKIT_BLOCK_TYPE = /* @__PURE__ */ Symbol.for("lessonkit.blockType");
1373
+ function setLessonkitBlockType(component, blockType) {
1374
+ component[LESSONKIT_BLOCK_TYPE] = blockType;
1375
+ if (!component.displayName) {
1376
+ component.displayName = blockType;
1377
+ }
1378
+ return component;
1379
+ }
1380
+ function getLessonkitBlockType(component) {
1381
+ if (!component || typeof component !== "object" && typeof component !== "function") {
1382
+ return void 0;
1383
+ }
1384
+ const typed = component;
1385
+ return typed[LESSONKIT_BLOCK_TYPE] ?? typed.displayName;
1386
+ }
1387
+
1388
+ // src/compound/validateChildren.ts
1389
+ var warnedPairs = /* @__PURE__ */ new Set();
1390
+ var COMPOUND_CONTAINER_TYPES = /* @__PURE__ */ new Set([
1391
+ "Page",
1392
+ "InteractiveBook",
1393
+ "Slide",
1394
+ "SlideDeck",
1395
+ "TimedCue",
1396
+ "InteractiveVideo",
1397
+ "AssessmentSequence"
1398
+ ]);
1399
+ function warnOrThrow(msg, strict) {
1400
+ if (strict) throw new Error(msg);
1401
+ if (!warnedPairs.has(msg)) {
1402
+ warnedPairs.add(msg);
1403
+ console.warn(msg);
1404
+ }
1405
+ }
1406
+ function validateNode(parent, node, depth, strict) {
1407
+ React3.Children.forEach(node, (child) => {
1408
+ if (!React3.isValidElement(child)) return;
1409
+ const blockType = getLessonkitBlockType(child.type);
1410
+ if (!blockType) {
1411
+ if (child.props && typeof child.props === "object" && "children" in child.props) {
1412
+ validateNode(parent, child.props.children, depth, strict);
1413
+ }
1414
+ return;
1415
+ }
1416
+ if (!isChildTypeAllowed(parent, blockType)) {
1417
+ const key = `${parent}:${blockType}`;
1418
+ if (!warnedPairs.has(key)) {
1419
+ warnedPairs.add(key);
1420
+ const msg = `[lessonkit] Block "${blockType}" is not in the allowlist for "${parent}"`;
1421
+ if (strict) throw new Error(msg);
1422
+ console.warn(msg);
1423
+ }
1424
+ }
1425
+ if (COMPOUND_CONTAINER_TYPES.has(blockType)) {
1426
+ const maxDepth = COMPOUND_MAX_NESTING_DEPTH[parent];
1427
+ if (depth >= maxDepth) {
1428
+ warnOrThrow(
1429
+ `[lessonkit] Block "${blockType}" exceeds max nesting depth (${maxDepth}) for "${parent}"`,
1430
+ strict
1431
+ );
1432
+ }
1433
+ const nestedParent = blockType;
1434
+ validateNode(nestedParent, child.props.children, depth + 1, strict);
1435
+ } else if (blockType === "Accordion") {
1436
+ const sections = child.props.sections;
1437
+ if (sections) validateAccordionSections(sections, strict);
1438
+ } else if (child.props && typeof child.props === "object" && "children" in child.props) {
1439
+ validateSubtreeForForbidden(
1440
+ child.props.children,
1441
+ ACCORDION_FORBIDDEN_CHILD_TYPES,
1442
+ strict
1443
+ );
1444
+ }
1445
+ });
1446
+ }
1447
+ function validateSubtreeForForbidden(node, forbidden, strict) {
1448
+ React3.Children.forEach(node, (child) => {
1449
+ if (!React3.isValidElement(child)) return;
1450
+ const blockType = getLessonkitBlockType(child.type);
1451
+ if (blockType && forbidden.includes(blockType)) {
1452
+ warnOrThrow(`[lessonkit] Block "${blockType}" must not nest inside Accordion`, strict);
1453
+ }
1454
+ if (blockType === "Accordion") {
1455
+ const sections = child.props.sections;
1456
+ if (sections) validateAccordionSections(sections, strict);
1457
+ return;
1458
+ }
1459
+ if (child.props && typeof child.props === "object" && "children" in child.props) {
1460
+ validateSubtreeForForbidden(
1461
+ child.props.children,
1462
+ forbidden,
1463
+ strict
1464
+ );
1465
+ }
1466
+ });
1467
+ }
1468
+ function validateAccordionSections(sections, strict) {
1469
+ if (!isDevEnvironment() && !strict) return;
1470
+ for (const section of sections) {
1471
+ validateSubtreeForForbidden(section.content, ACCORDION_FORBIDDEN_CHILD_TYPES, strict);
1472
+ }
1473
+ }
1474
+ function validateCompoundChildren(parent, children, strict) {
1475
+ if (!isDevEnvironment() && !strict) return;
1476
+ validateNode(parent, children, 0, strict);
1477
+ }
1478
+ function resetCompoundValidationWarningsForTests() {
1479
+ warnedPairs.clear();
1480
+ }
1481
+
1482
+ // src/assessment/internal/buildAssessmentHandle.ts
1483
+ function buildAssessmentHandle(opts) {
1484
+ return {
1485
+ getScore: opts.getScore,
1486
+ getMaxScore: opts.getMaxScore,
1487
+ getAnswerGiven: opts.getAnswerGiven,
1488
+ resetTask: opts.resetTask,
1489
+ showSolutions: opts.showSolutions,
1490
+ getXAPIData: opts.getXAPIData,
1491
+ ...opts.getCurrentState ? { getCurrentState: opts.getCurrentState } : {},
1492
+ ...opts.resume ? { resume: opts.resume } : {}
1493
+ };
1494
+ }
1495
+
1496
+ // src/assessment/internal/resumeState.ts
1497
+ function readBooleanField(state, key) {
1498
+ const value = state[key];
1499
+ if (value === true || value === false || value === null) return value;
1500
+ return void 0;
1501
+ }
1502
+ function readStringField(state, key) {
1503
+ const value = state[key];
1504
+ if (typeof value === "string" || value === null) return value;
1505
+ return void 0;
1506
+ }
1507
+ function readNumberField(state, key) {
1508
+ const value = state[key];
1509
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1510
+ if (value === null) return null;
1511
+ return void 0;
1512
+ }
1513
+ function readBooleanStateField(state, key, apply) {
1514
+ const value = state[key];
1515
+ if (typeof value === "boolean") apply(value);
1516
+ }
1517
+
1518
+ // src/compound/CompoundPageIndexContext.tsx
1519
+ import { createContext as createContext3, useContext as useContext3 } from "react";
1520
+ import { jsx as jsx3 } from "react/jsx-runtime";
1521
+ var CompoundPageIndexContext = createContext3(void 0);
1522
+ function CompoundPageIndexProvider({
1523
+ pageIndex,
1524
+ children
1525
+ }) {
1526
+ return /* @__PURE__ */ jsx3(CompoundPageIndexContext.Provider, { value: pageIndex, children });
1527
+ }
1528
+ function useCompoundPageIndex() {
1529
+ return useContext3(CompoundPageIndexContext);
1530
+ }
1531
+
1532
+ // src/compound/CompoundProvider.tsx
1533
+ import React6, { createContext as createContext5, useCallback as useCallback2, useContext as useContext5, useImperativeHandle, useMemo as useMemo4, useRef as useRef3, useState as useState2 } from "react";
1534
+ import { clampCompoundPageIndex, createCompoundResumeState } from "@lessonkit/core";
1535
+
1536
+ // src/compound/aggregateScores.ts
1537
+ function aggregateAssessmentScores(handles, opts) {
1538
+ let score = 0;
1539
+ let maxScore = 0;
1540
+ let allAnswered = true;
1541
+ for (const entry of handles) {
1542
+ const handle = "handle" in entry ? entry.handle : entry;
1543
+ const pageIndex = "handle" in entry ? entry.pageIndex : void 0;
1544
+ score += handle.getScore();
1545
+ maxScore += handle.getMaxScore();
1546
+ const countsForAnswerGiven = opts?.answerPageIndex === void 0 || pageIndex === void 0 || pageIndex === opts.answerPageIndex;
1547
+ if (countsForAnswerGiven && !handle.getAnswerGiven()) allAnswered = false;
1548
+ }
1549
+ return { score, maxScore, allAnswered };
1550
+ }
1551
+
1552
+ // src/compound/CompoundHydrationBridge.tsx
1553
+ import { createContext as createContext4, useContext as useContext4, useRef as useRef2 } from "react";
1554
+ import { jsx as jsx4 } from "react/jsx-runtime";
1555
+ var CompoundHydrationBridgeContext = createContext4(
1556
+ null
1557
+ );
1558
+ function CompoundHydrationBridgeProvider({ children }) {
1559
+ const bridgeRef = useRef2(null);
1560
+ return /* @__PURE__ */ jsx4(CompoundHydrationBridgeContext.Provider, { value: bridgeRef, children });
1561
+ }
1562
+ function useCompoundHydrationBridgeRef() {
1563
+ return useContext4(CompoundHydrationBridgeContext);
1564
+ }
1565
+
1566
+ // src/compound/CompoundProvider.tsx
1567
+ import { jsx as jsx5 } from "react/jsx-runtime";
1568
+ var CompoundRegistryContext = createContext5(null);
1569
+ var CompoundHandlesVersionContext = createContext5(0);
1570
+ function CompoundProvider({
1571
+ children,
1572
+ activePageIndex: _activePageIndex,
1573
+ onActivePageIndexChange: _onActivePageIndexChange
1574
+ }) {
1575
+ const registryRef = useRef3(/* @__PURE__ */ new Map());
1576
+ const [handlesVersion, setHandlesVersion] = useState2(0);
1577
+ const register = useCallback2((checkId, handle, pageIndex) => {
1578
+ const prev = registryRef.current.get(checkId);
1579
+ if (prev && prev.handle !== handle) {
1580
+ const message = `[lessonkit] duplicate checkId "${checkId}" registered in the same compound container; the previous handle was replaced.`;
1581
+ if (isDevEnvironment()) {
1582
+ console.error(message);
1583
+ } else {
1584
+ console.warn(message);
1585
+ }
1586
+ }
1587
+ registryRef.current.set(checkId, { handle, pageIndex });
1588
+ if (prev?.handle !== handle || prev?.pageIndex !== pageIndex) {
1589
+ setHandlesVersion((v) => v + 1);
1590
+ }
1591
+ return () => {
1592
+ const current = registryRef.current.get(checkId);
1593
+ if (current?.handle === handle) {
1594
+ registryRef.current.delete(checkId);
1595
+ setHandlesVersion((v) => v + 1);
1596
+ }
1597
+ };
1598
+ }, []);
1599
+ const registryValue = useMemo4(
1600
+ () => ({
1601
+ register,
1602
+ getHandles: () => {
1603
+ const handles = /* @__PURE__ */ new Map();
1604
+ for (const [checkId, entry] of registryRef.current) {
1605
+ handles.set(checkId, entry.handle);
1606
+ }
1607
+ return handles;
1608
+ },
1609
+ getRegisteredHandles: () => registryRef.current
1610
+ }),
1611
+ [register]
1612
+ );
1613
+ return /* @__PURE__ */ jsx5(CompoundHydrationBridgeProvider, { children: /* @__PURE__ */ jsx5(CompoundRegistryContext.Provider, { value: registryValue, children: /* @__PURE__ */ jsx5(CompoundHandlesVersionContext.Provider, { value: handlesVersion, children }) }) });
1614
+ }
1615
+ function useCompoundRegistry() {
1616
+ const registry = useContext5(CompoundRegistryContext);
1617
+ const handlesVersion = useContext5(CompoundHandlesVersionContext);
1618
+ if (!registry) return null;
1619
+ return { ...registry, handlesVersion };
1620
+ }
1621
+ function useCompoundHandlesVersion() {
1622
+ return useContext5(CompoundHandlesVersionContext);
1623
+ }
1624
+ function useRegisterAssessmentHandle(checkId, handle) {
1625
+ const registry = useContext5(CompoundRegistryContext);
1626
+ const pageIndex = useCompoundPageIndex();
1627
+ React6.useLayoutEffect(() => {
1628
+ if (!registry || !handle) return;
1629
+ return registry.register(checkId, handle, pageIndex);
1630
+ }, [registry, checkId, handle, pageIndex]);
1631
+ }
1632
+ function useCompoundHandleRef(ref, opts) {
1633
+ const { activePageIndex, setActivePageIndex, getHandles, getRegisteredHandles, pageCount } = opts;
1634
+ const bridgeRef = useCompoundHydrationBridgeRef();
1635
+ const setIndexClamped = useCallback2(
1636
+ (index) => {
1637
+ const next = pageCount !== void 0 ? clampCompoundPageIndex(index, pageCount) : Math.max(0, Math.floor(index));
1638
+ setActivePageIndex(next);
1639
+ },
1640
+ [pageCount, setActivePageIndex]
1641
+ );
1642
+ useImperativeHandle(
1643
+ ref,
1644
+ () => ({
1645
+ getScore: () => aggregateAssessmentScores(getRegisteredHandles().values()).score,
1646
+ getMaxScore: () => aggregateAssessmentScores(getRegisteredHandles().values()).maxScore,
1647
+ getAnswerGiven: () => aggregateAssessmentScores(getRegisteredHandles().values(), {
1648
+ answerPageIndex: activePageIndex
1649
+ }).allAnswered,
1650
+ resetTask: () => {
1651
+ for (const entry of getRegisteredHandles().values()) entry.handle.resetTask();
1652
+ },
1653
+ showSolutions: () => {
1654
+ if (!opts.enableSolutionsButton) return;
1655
+ for (const entry of getRegisteredHandles().values()) entry.handle.showSolutions();
1656
+ },
1657
+ getCurrentState: () => {
1658
+ const childStates = {};
1659
+ for (const [checkId, entry] of getRegisteredHandles()) {
1660
+ if (entry.handle.getCurrentState) {
1661
+ childStates[checkId] = entry.handle.getCurrentState();
1662
+ }
1663
+ }
1664
+ return createCompoundResumeState({ activePageIndex, childStates });
1665
+ },
1666
+ resume: (state) => {
1667
+ bridgeRef?.current?.notifyImperativeResume(state);
1668
+ }
1669
+ }),
1670
+ [activePageIndex, setIndexClamped, getHandles, getRegisteredHandles, opts.enableSolutionsButton, bridgeRef]
1671
+ );
1672
+ }
1673
+
1674
+ // src/assessment/internal/useAssessmentHandleRegistration.ts
1675
+ import { useImperativeHandle as useImperativeHandle2 } from "react";
1676
+ function useAssessmentHandleRegistration(checkId, handle, ref) {
1677
+ useImperativeHandle2(ref, () => handle, [handle]);
1678
+ useRegisterAssessmentHandle(checkId, handle);
1679
+ }
1680
+
1681
+ // src/assessment/scoring.ts
1682
+ function resolvePassingThreshold(passingScore, maxScore) {
1683
+ return passingScore ?? maxScore;
1684
+ }
1685
+ function meetsPassingThreshold(score, maxScore, passingScore) {
1686
+ const threshold = resolvePassingThreshold(passingScore, maxScore);
1687
+ return score >= threshold;
1688
+ }
1689
+ function scoreFromCustom(custom, fallbackCorrect, fallbackMax = 1, passingScore) {
1690
+ const maxScore = custom?.maxScore ?? fallbackMax;
1691
+ if (custom?.passed !== void 0) {
1692
+ const score2 = custom.passed ? custom.score ?? maxScore : custom.score ?? 0;
1693
+ return { score: score2, maxScore, passed: custom.passed };
1694
+ }
1695
+ if (custom?.maxScore != null && custom.maxScore > 0 && custom.score != null) {
1696
+ const passed2 = meetsPassingThreshold(custom.score, custom.maxScore, passingScore);
1697
+ return { score: custom.score, maxScore: custom.maxScore, passed: passed2 };
1698
+ }
1699
+ const score = fallbackCorrect ? maxScore : 0;
1700
+ const passed = meetsPassingThreshold(score, maxScore, passingScore);
1701
+ return { score, maxScore, passed };
1702
+ }
1703
+
1704
+ // src/assessment/internal/usePluginScoring.ts
1705
+ import { useCallback as useCallback3 } from "react";
1706
+ function usePluginScoring(checkId, lessonId) {
1707
+ const { plugins, config, session } = useLessonkit();
1708
+ const getPluginScore = useCallback3(
1709
+ (response) => {
1710
+ const pluginCtx = buildPluginContext({
1711
+ courseId: config.courseId,
1712
+ sessionId: session.sessionId,
1713
+ attemptId: session.attemptId,
1714
+ user: session.user
1715
+ });
1716
+ return plugins?.scoreAssessment({ checkId, lessonId, response }, pluginCtx) ?? null;
1717
+ },
1718
+ [checkId, config.courseId, lessonId, plugins, session.attemptId, session.sessionId, session.user]
1719
+ );
1720
+ const scoreResponse = useCallback3(
1721
+ (response, defaultCorrect, maxScore = 1, passingScore) => scoreFromCustom(getPluginScore(response), defaultCorrect, maxScore, passingScore),
1722
+ [getPluginScore]
1723
+ );
1724
+ const isChoiceCorrect = useCallback3(
1725
+ (choice, answer, custom, passingScore) => {
1726
+ if (!custom) return choice === answer;
1727
+ if (custom.passed !== void 0) return custom.passed;
1728
+ if (custom.maxScore != null && custom.maxScore > 0 && custom.score != null) {
1729
+ return meetsPassingThreshold(custom.score, custom.maxScore, passingScore);
1730
+ }
1731
+ return choice === answer;
1732
+ },
1733
+ []
1734
+ );
1735
+ return { getPluginScore, scoreResponse, isChoiceCorrect };
1736
+ }
1737
+
1738
+ export {
1739
+ LessonContext,
1740
+ useEnclosingLessonId,
1741
+ isDevEnvironment,
1742
+ normalizeComponentId,
1743
+ resetAssessmentWarningsForTests,
1744
+ AssessmentLessonGuard,
1745
+ buildAssessmentHandle,
1746
+ readBooleanField,
1747
+ readStringField,
1748
+ readNumberField,
1749
+ readBooleanStateField,
1750
+ useCompoundHydrationBridgeRef,
1751
+ CompoundPageIndexProvider,
1752
+ CompoundProvider,
1753
+ useCompoundRegistry,
1754
+ useCompoundHandlesVersion,
1755
+ useCompoundHandleRef,
1756
+ useAssessmentHandleRegistration,
1757
+ shouldEnforceProductionGuard,
1758
+ assertProductionCourseConfig,
1759
+ resetCourseStartedTrackingFlightForTests,
1760
+ resetLessonkitProviderStorageForTests,
1761
+ LessonkitContext,
1762
+ LessonkitProvider,
1763
+ useAssessmentState,
1764
+ useLessonkit,
1765
+ useProgress,
1766
+ useTracking,
1767
+ useCompletion,
1768
+ useQuizState,
1769
+ meetsPassingThreshold,
1770
+ usePluginScoring,
1771
+ setLessonkitBlockType,
1772
+ validateAccordionSections,
1773
+ validateCompoundChildren,
1774
+ resetCompoundValidationWarningsForTests
1775
+ };