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