@lessonkit/react 0.9.3 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,8 +3,10 @@ import { useEffect as useEffect2, useId, useMemo as useMemo3, useRef as useRef2,
3
3
  import { visuallyHiddenStyle } from "@lessonkit/accessibility";
4
4
 
5
5
  // src/context.tsx
6
+ import { createContext } from "react";
7
+
8
+ // src/provider/useLessonkitProviderRuntime.ts
6
9
  import {
7
- createContext,
8
10
  useCallback,
9
11
  useEffect,
10
12
  useLayoutEffect,
@@ -12,241 +14,102 @@ import {
12
14
  useRef,
13
15
  useState
14
16
  } from "react";
15
- import { createTrackingClient as createTrackingClient2 } from "@lessonkit/core";
17
+ import { createLessonkitRuntime, createTrackingClient as createTrackingClient2, assertValidId } from "@lessonkit/core";
16
18
  import { createInMemoryXAPIQueue } from "@lessonkit/xapi";
17
- import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement2 } from "@lessonkit/xapi";
19
+ import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement3 } from "@lessonkit/xapi";
18
20
 
19
21
  // src/runtime/emitTelemetry.ts
20
- import { nowIso } from "@lessonkit/core";
22
+ import { buildTelemetryEvent, tryBuildTelemetryEvent } from "@lessonkit/core";
23
+
24
+ // src/runtime/telemetryPipeline.ts
25
+ import {
26
+ createTelemetryPipeline,
27
+ createTrackingPipelineSink
28
+ } from "@lessonkit/core";
21
29
  import { telemetryEventToXAPIStatement } from "@lessonkit/xapi";
22
30
 
23
31
  // src/runtime/lxpackBridge.ts
24
32
  import {
25
- getLxpackBridge as getLxpackBridgeFromSdk,
33
+ dispatchBridgeAction,
34
+ forwardTelemetryToBridge,
35
+ getLxpackBridge,
26
36
  mapLessonkitTelemetryToBridgeAction,
27
- normalizePassingThreshold,
28
- normalizeScore,
29
37
  telemetryEventToLessonkit
30
38
  } from "@lessonkit/lxpack/bridge";
31
- function getBridge() {
32
- const fromSdk = getLxpackBridgeFromSdk();
33
- if (fromSdk) return fromSdk;
34
- if (typeof window === "undefined") return null;
35
- const parent = window.parent;
36
- if (!parent || parent === window) return null;
37
- return parent.lxpack ?? null;
39
+ function forwardTelemetryToLxpack(event, mode = "auto") {
40
+ forwardTelemetryToBridge(event, mode);
38
41
  }
39
- function applyBridgeAction(bridge, action) {
40
- if (!action) return;
41
- switch (action.kind) {
42
- case "completeLesson":
43
- bridge.completeLesson?.(action.lessonId);
44
- return;
45
- case "completeCourse":
46
- bridge.completeCourse?.();
47
- return;
48
- case "submitAssessment": {
49
- const scaled = normalizeScore({
50
- score: action.score,
51
- maxScore: action.maxScore
52
- });
53
- if (scaled === null) return;
54
- bridge.submitAssessment?.({
55
- id: action.id,
56
- score: scaled,
57
- passingScore: normalizePassingThreshold({
58
- passingScore: action.passingScore,
59
- maxScore: action.maxScore
60
- }),
61
- maxScore: action.maxScore
62
- });
63
- return;
64
- }
65
- case "track":
66
- bridge.track?.(action.event);
67
- return;
68
- default:
69
- return;
70
- }
42
+
43
+ // src/runtime/telemetryPipeline.ts
44
+ function isDevEnvironment() {
45
+ const g = globalThis;
46
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
71
47
  }
72
- function forwardTelemetryToLxpack(event, mode = "auto") {
73
- if (mode === "off") return;
74
- const bridge = getBridge();
75
- if (!bridge) return;
76
- const lessonkitEvent = telemetryEventToLessonkit(event);
77
- if (!lessonkitEvent) return;
78
- const action = mapLessonkitTelemetryToBridgeAction(lessonkitEvent);
79
- applyBridgeAction(bridge, action);
48
+ function createLegacyPipeline(opts, extraSinks = []) {
49
+ return createTelemetryPipeline([
50
+ createTrackingPipelineSink("tracking", (event) => opts.tracking.track(event)),
51
+ {
52
+ id: "xapi",
53
+ emit(event) {
54
+ try {
55
+ const statement = telemetryEventToXAPIStatement(event);
56
+ if (statement) opts.xapi?.send(statement);
57
+ } catch (err) {
58
+ if (isDevEnvironment()) {
59
+ console.warn(
60
+ "[lessonkit] xAPI mapping skipped:",
61
+ err instanceof Error ? err.message : err
62
+ );
63
+ }
64
+ }
65
+ }
66
+ },
67
+ {
68
+ id: "lxpack-bridge",
69
+ emit(event) {
70
+ forwardTelemetryToLxpack(event, opts.lxpackBridge);
71
+ }
72
+ },
73
+ ...extraSinks
74
+ ]);
75
+ }
76
+ function emitThroughPipeline(event, opts, extraSinks) {
77
+ createLegacyPipeline(opts, extraSinks).emit(event);
80
78
  }
81
79
 
82
80
  // src/runtime/emitTelemetry.ts
83
81
  var warnedMissingCourseId = false;
84
- var warnedMissingQuizLesson = false;
85
- function isDevEnvironment() {
82
+ function isDevEnvironment2() {
86
83
  const g = globalThis;
87
84
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
88
85
  }
89
86
  function emitTelemetry(tracking, xapi, event, opts) {
90
87
  if (!event.courseId) {
91
- if (isDevEnvironment() && !warnedMissingCourseId) {
88
+ if (isDevEnvironment2() && !warnedMissingCourseId) {
92
89
  warnedMissingCourseId = true;
93
90
  console.warn("[lessonkit] telemetry event missing courseId");
94
91
  }
95
92
  return;
96
93
  }
97
- tracking.track(event);
98
- try {
99
- const statement = telemetryEventToXAPIStatement(event);
100
- if (statement) xapi?.send(statement);
101
- } catch (err) {
102
- if (isDevEnvironment()) {
103
- console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
104
- }
105
- }
106
- forwardTelemetryToLxpack(event, opts?.lxpackBridge ?? "auto");
107
- }
108
- function buildTrackEvent(opts) {
109
- const base = {
110
- timestamp: nowIso(),
111
- courseId: opts.courseId,
112
- sessionId: opts.sessionId,
113
- attemptId: opts.attemptId,
114
- user: opts.user
94
+ const legacy = {
95
+ tracking,
96
+ xapi,
97
+ lxpackBridge: opts?.lxpackBridge ?? "auto"
115
98
  };
116
- switch (opts.name) {
117
- case "course_started":
118
- return { name: "course_started", ...base };
119
- case "course_completed":
120
- return { name: "course_completed", ...base };
121
- case "lesson_started": {
122
- const data = opts.data;
123
- const lessonId = opts.lessonId ?? data?.lessonId;
124
- if (!lessonId) throw new Error("lesson_started requires lessonId");
125
- return {
126
- name: "lesson_started",
127
- ...base,
128
- lessonId,
129
- data: { ...data, lessonId }
130
- };
131
- }
132
- case "lesson_completed":
133
- case "lesson_time_on_task": {
134
- const data = opts.data;
135
- const lessonId = opts.lessonId ?? data?.lessonId;
136
- if (!lessonId) throw new Error(`${opts.name} requires lessonId`);
137
- return {
138
- name: opts.name,
139
- ...base,
140
- lessonId,
141
- data: { ...data, lessonId }
142
- };
143
- }
144
- case "quiz_answered": {
145
- const data = opts.data;
146
- const lessonId = opts.lessonId;
147
- if (!lessonId) throw new Error("quiz_answered requires active lessonId");
148
- return { name: "quiz_answered", ...base, lessonId, data };
149
- }
150
- case "quiz_completed": {
151
- const data = opts.data;
152
- const lessonId = opts.lessonId;
153
- if (!lessonId) throw new Error("quiz_completed requires active lessonId");
154
- return { name: "quiz_completed", ...base, lessonId, data };
155
- }
156
- case "interaction":
157
- return {
158
- name: "interaction",
159
- ...base,
160
- lessonId: opts.lessonId,
161
- data: opts.data
162
- };
163
- default:
164
- return { name: opts.name, ...base };
165
- }
166
- }
167
- function tryBuildTrackEvent(opts) {
168
- const isQuiz = opts.name === "quiz_answered" || opts.name === "quiz_completed";
169
- if (isQuiz && !opts.lessonId) {
170
- if (isDevEnvironment() && !warnedMissingQuizLesson) {
171
- warnedMissingQuizLesson = true;
172
- console.warn(
173
- `[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
174
- );
175
- }
176
- return null;
177
- }
178
- return buildTrackEvent(opts);
99
+ emitThroughPipeline(event, legacy, opts?.extraSinks);
179
100
  }
180
101
 
181
102
  // src/runtime/ports.ts
182
- function createNoopStorage() {
183
- return {
184
- getItem: () => null,
185
- setItem: () => {
186
- }
187
- };
188
- }
189
- function createSessionStoragePort() {
190
- if (typeof sessionStorage === "undefined") return createNoopStorage();
191
- return {
192
- getItem: (key) => {
193
- try {
194
- return sessionStorage.getItem(key);
195
- } catch {
196
- return null;
197
- }
198
- },
199
- setItem: (key, value) => {
200
- try {
201
- sessionStorage.setItem(key, value);
202
- } catch {
203
- }
204
- },
205
- removeItem: (key) => {
206
- try {
207
- sessionStorage.removeItem(key);
208
- } catch {
209
- }
210
- }
211
- };
212
- }
103
+ import {
104
+ createDefaultClock,
105
+ createGlobalTimer,
106
+ createNoopStorage,
107
+ createSessionStoragePort,
108
+ resetStoragePortForTests
109
+ } from "@lessonkit/core";
213
110
 
214
111
  // src/runtime/progress.ts
215
- function createProgressController() {
216
- let activeLessonId;
217
- let completedLessonIds = /* @__PURE__ */ new Set();
218
- let courseCompleted = false;
219
- const lessonStartTimes = /* @__PURE__ */ new Map();
220
- return {
221
- getState: () => ({
222
- activeLessonId,
223
- completedLessonIds: new Set(completedLessonIds),
224
- courseCompleted
225
- }),
226
- setActiveLesson: (lessonId, startedAtMs) => {
227
- const previousLessonId = activeLessonId;
228
- activeLessonId = lessonId;
229
- lessonStartTimes.set(lessonId, startedAtMs);
230
- return { previousLessonId };
231
- },
232
- completeLesson: (lessonId, completedAtMs) => {
233
- if (completedLessonIds.has(lessonId)) return { didComplete: false };
234
- completedLessonIds = new Set(completedLessonIds).add(lessonId);
235
- if (activeLessonId === lessonId) {
236
- activeLessonId = void 0;
237
- }
238
- const startedAt = lessonStartTimes.get(lessonId);
239
- lessonStartTimes.delete(lessonId);
240
- const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
241
- return { durationMs, didComplete: true };
242
- },
243
- completeCourse: () => {
244
- if (courseCompleted) return { didComplete: false };
245
- courseCompleted = true;
246
- return { didComplete: true };
247
- }
248
- };
249
- }
112
+ import { createProgressController } from "@lessonkit/core";
250
113
 
251
114
  // src/runtime/xapi.ts
252
115
  import { createXAPIClient } from "@lessonkit/xapi";
@@ -264,56 +127,63 @@ function createXapiClientFromConfig(config, queue) {
264
127
  }
265
128
 
266
129
  // src/runtime/session.ts
267
- import { createSessionId } from "@lessonkit/core";
268
- var SESSION_STORAGE_KEY = "lessonkit:sessionId";
269
- function getTabSessionId(storage) {
270
- return storage.getItem(SESSION_STORAGE_KEY);
271
- }
272
- var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
273
- function resolveSessionId(storage, provided) {
274
- if (provided) return provided;
275
- const existing = storage.getItem(SESSION_STORAGE_KEY);
276
- if (existing) return existing;
277
- const id = createSessionId();
278
- storage.setItem(SESSION_STORAGE_KEY, id);
279
- return id;
280
- }
281
- function courseStartedStorageKey(sessionId, courseId) {
282
- return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
283
- }
284
- function hasCourseStarted(storage, sessionId, courseId) {
285
- if (!courseId) return false;
286
- return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
287
- }
288
- function markCourseStarted(storage, sessionId, courseId) {
289
- if (!courseId) return;
290
- storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
291
- }
292
- function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
293
- if (!courseId || fromSessionId === toSessionId) return;
294
- if (hasCourseStarted(storage, fromSessionId, courseId)) {
295
- markCourseStarted(storage, toSessionId, courseId);
296
- storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
130
+ import {
131
+ SESSION_STORAGE_KEY,
132
+ getTabSessionId,
133
+ resolveSessionId,
134
+ hasCourseStarted,
135
+ markCourseStarted,
136
+ hasCourseStartedEmittedToTracking,
137
+ markCourseStartedEmittedToTracking,
138
+ hasCourseStartedPipelineDelivered,
139
+ markCourseStartedPipelineDelivered,
140
+ migrateCourseStartedMark
141
+ } from "@lessonkit/core";
142
+
143
+ // src/runtime/courseStartedPipeline.ts
144
+ import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement2 } from "@lessonkit/xapi";
145
+ function emitCourseStartedNonTrackingPipeline(opts) {
146
+ let xapiStatementSent = false;
147
+ if (!opts.skipXapi && opts.xapi) {
148
+ const statement = telemetryEventToXAPIStatement2(opts.event);
149
+ if (statement) {
150
+ opts.xapi.send(statement);
151
+ xapiStatementSent = true;
152
+ }
153
+ }
154
+ forwardTelemetryToLxpack(opts.event, opts.lxpackBridge);
155
+ const emitCtx = {
156
+ courseId: opts.event.courseId,
157
+ sessionId: opts.event.sessionId,
158
+ attemptId: opts.event.attemptId
159
+ };
160
+ for (const sink of opts.extraSinks ?? []) {
161
+ sink.emit(opts.event, emitCtx);
297
162
  }
163
+ return { xapiStatementSent };
298
164
  }
299
165
 
300
166
  // src/runtime/plugins.ts
301
- import { createPluginHost } from "@lessonkit/core";
167
+ import { createPluginRegistry } from "@lessonkit/core";
302
168
  function createReactPluginHost(plugins) {
303
169
  if (!plugins?.length) return null;
304
- return createPluginHost(plugins);
170
+ return createPluginRegistry(plugins);
305
171
  }
306
172
  function buildPluginContext(opts) {
307
173
  return {
308
174
  courseId: opts.courseId,
309
175
  sessionId: opts.sessionId,
310
- attemptId: opts.attemptId
176
+ attemptId: opts.attemptId,
177
+ user: opts.user
311
178
  };
312
179
  }
313
180
  function emitTelemetryWithPlugins(opts) {
314
181
  const next = opts.pluginHost ? opts.pluginHost.runTelemetry(opts.event, opts.pluginCtx) : opts.event;
315
182
  if (next === null) return;
316
- emitTelemetry(opts.tracking, opts.xapi, next, { lxpackBridge: opts.lxpackBridge ?? "auto" });
183
+ emitTelemetry(opts.tracking, opts.xapi, next, {
184
+ lxpackBridge: opts.lxpackBridge ?? "auto",
185
+ extraSinks: opts.extraSinks
186
+ });
317
187
  }
318
188
 
319
189
  // src/runtime/telemetry.ts
@@ -338,71 +208,217 @@ async function disposeTrackingClient(client) {
338
208
  }
339
209
  }
340
210
 
341
- // src/context.tsx
342
- import { jsx } from "react/jsx-runtime";
343
- var LessonkitContext = createContext(null);
211
+ // src/provider/useLessonkitProviderRuntime.ts
344
212
  var useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
345
213
  var defaultStorage = createSessionStoragePort();
346
214
  function isTrackingActive(tracking) {
347
215
  return tracking?.enabled !== false;
348
216
  }
349
- function emitCourseStarted(opts) {
217
+ function isCourseStartedSinkSettled(result) {
218
+ return result === "emitted";
219
+ }
220
+ function buildCourseStartedEvent(opts) {
350
221
  const pluginCtx = buildPluginContext({
351
222
  courseId: opts.courseId,
352
223
  sessionId: opts.sessionId,
353
- attemptId: opts.attemptId
224
+ attemptId: opts.attemptId,
225
+ user: opts.user
354
226
  });
227
+ const built = buildTelemetryEvent({
228
+ name: "course_started",
229
+ courseId: opts.courseId,
230
+ sessionId: opts.sessionId,
231
+ attemptId: opts.attemptId,
232
+ user: opts.user
233
+ });
234
+ return opts.pluginHost ? opts.pluginHost.runTelemetry(built, pluginCtx) : built;
235
+ }
236
+ function emitCourseStartedPipelineOnly(opts) {
355
237
  try {
356
- emitTelemetryWithPlugins({
357
- pluginHost: opts.pluginHost,
358
- tracking: opts.tracking,
238
+ const { xapiStatementSent } = emitCourseStartedNonTrackingPipeline({
239
+ event: opts.event,
359
240
  xapi: opts.xapi,
360
- event: buildTrackEvent({
361
- name: "course_started",
362
- courseId: opts.courseId,
363
- sessionId: opts.sessionId,
364
- attemptId: opts.attemptId,
365
- user: opts.user
366
- }),
367
- pluginCtx,
368
- lxpackBridge: opts.lxpackBridge
241
+ lxpackBridge: opts.lxpackBridge,
242
+ extraSinks: opts.extraSinks,
243
+ skipXapi: opts.skipXapi
369
244
  });
370
245
  markCourseStarted(opts.storage, opts.sessionId, opts.courseId);
371
- return true;
246
+ markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId);
247
+ if (xapiStatementSent) {
248
+ opts.onXapiStatementSent?.();
249
+ }
250
+ return "emitted";
372
251
  } catch {
373
- return false;
252
+ return "failed";
374
253
  }
375
254
  }
376
- function LessonkitProvider(props) {
377
- const config = props.config;
378
- const sessionIdRef = useRef(resolveSessionId(defaultStorage, config.session?.sessionId));
379
- const prevConfiguredSessionIdRef = useRef(config.session?.sessionId);
380
- if (config.session?.sessionId) {
381
- sessionIdRef.current = config.session.sessionId;
255
+ function emitCourseStarted(opts) {
256
+ const event = buildCourseStartedEvent(opts);
257
+ if (event === null) return "filtered";
258
+ const trackingAlreadyEmitted = hasCourseStartedEmittedToTracking(
259
+ opts.storage,
260
+ opts.sessionId,
261
+ opts.courseId
262
+ );
263
+ if (!trackingAlreadyEmitted) {
264
+ try {
265
+ opts.tracking.track(event);
266
+ markCourseStartedEmittedToTracking(opts.storage, opts.sessionId, opts.courseId);
267
+ } catch {
268
+ return "failed";
269
+ }
270
+ }
271
+ return emitCourseStartedPipelineOnly({
272
+ ...opts,
273
+ event,
274
+ skipXapi: opts.skipXapi,
275
+ onXapiStatementSent: opts.onXapiStatementSent
276
+ });
277
+ }
278
+ function emitCourseStartedToTrackingOnly(opts) {
279
+ const event = buildCourseStartedEvent(opts);
280
+ if (event === null) return "filtered";
281
+ const trackingAlreadyEmitted = hasCourseStartedEmittedToTracking(
282
+ opts.storage,
283
+ opts.sessionId,
284
+ opts.courseId
285
+ );
286
+ if (!trackingAlreadyEmitted) {
287
+ try {
288
+ opts.tracking.track(event);
289
+ markCourseStartedEmittedToTracking(opts.storage, opts.sessionId, opts.courseId);
290
+ } catch {
291
+ return "failed";
292
+ }
293
+ }
294
+ try {
295
+ emitCourseStartedNonTrackingPipeline({
296
+ event,
297
+ xapi: null,
298
+ lxpackBridge: opts.lxpackBridge,
299
+ extraSinks: opts.extraSinks,
300
+ skipXapi: true
301
+ });
302
+ markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId);
303
+ return "emitted";
304
+ } catch {
305
+ return "failed";
306
+ }
307
+ }
308
+ function emitPendingCourseStarted(opts) {
309
+ const trackingEmitted = hasCourseStartedEmittedToTracking(
310
+ opts.storage,
311
+ opts.sessionId,
312
+ opts.courseId
313
+ );
314
+ const sessionStarted = hasCourseStarted(opts.storage, opts.sessionId, opts.courseId);
315
+ if (sessionStarted && !trackingEmitted) {
316
+ return emitCourseStartedToTrackingOnly(opts);
317
+ }
318
+ if (trackingEmitted && !sessionStarted) {
319
+ const event = buildCourseStartedEvent(opts);
320
+ if (event === null) return "filtered";
321
+ return emitCourseStartedPipelineOnly({ ...opts, event });
322
+ }
323
+ if (!trackingEmitted && !sessionStarted) {
324
+ return emitCourseStarted(opts);
325
+ }
326
+ const pipelineDelivered = hasCourseStartedPipelineDelivered(
327
+ opts.storage,
328
+ opts.sessionId,
329
+ opts.courseId
330
+ );
331
+ if (sessionStarted && trackingEmitted && !pipelineDelivered) {
332
+ const event = buildCourseStartedEvent(opts);
333
+ if (event === null) return "filtered";
334
+ return emitCourseStartedPipelineOnly({
335
+ ...opts,
336
+ event,
337
+ skipXapi: opts.skipXapi,
338
+ onXapiStatementSent: opts.onXapiStatementSent
339
+ });
340
+ }
341
+ return "emitted";
342
+ }
343
+ function assertTrackingSinkConfig(tracking) {
344
+ if (!tracking?.sink || !tracking?.batchSink) return;
345
+ throw new Error(
346
+ "[lessonkit] tracking.sink and tracking.batchSink cannot both be set; use batchSink alone for batched delivery"
347
+ );
348
+ }
349
+ function useLessonkitProviderRuntime(config) {
350
+ const normalizedCourseId = useMemo(
351
+ () => assertValidId(config.courseId, "courseId"),
352
+ [config.courseId]
353
+ );
354
+ const normalizedConfig = useMemo(
355
+ () => ({ ...config, courseId: normalizedCourseId }),
356
+ [config, normalizedCourseId]
357
+ );
358
+ const useV2Runtime = normalizedConfig.runtimeVersion !== "v1";
359
+ const extraSinksRef = useRef(normalizedConfig.sinks);
360
+ extraSinksRef.current = normalizedConfig.sinks;
361
+ const headlessRef = useRef(null);
362
+ const sessionIdRef = useRef(resolveSessionId(defaultStorage, normalizedConfig.session?.sessionId));
363
+ const prevConfiguredSessionIdRef = useRef(normalizedConfig.session?.sessionId);
364
+ if (normalizedConfig.session?.sessionId) {
365
+ sessionIdRef.current = normalizedConfig.session.sessionId;
382
366
  } else if (prevConfiguredSessionIdRef.current) {
383
367
  sessionIdRef.current = resolveSessionId(defaultStorage, void 0);
384
368
  }
385
- const attemptIdRef = useRef(config.session?.attemptId);
386
- const userRef = useRef(config.session?.user);
387
- attemptIdRef.current = config.session?.attemptId;
388
- userRef.current = config.session?.user;
389
- const courseIdRef = useRef(config.courseId);
390
- courseIdRef.current = config.courseId;
391
- const lxpackBridgeModeRef = useRef(config.lxpack?.bridge ?? "auto");
392
- lxpackBridgeModeRef.current = config.lxpack?.bridge ?? "auto";
393
- const pluginHost = useMemo(() => createReactPluginHost(config.plugins), [config.plugins]);
369
+ const attemptIdRef = useRef(normalizedConfig.session?.attemptId);
370
+ const userRef = useRef(normalizedConfig.session?.user);
371
+ attemptIdRef.current = normalizedConfig.session?.attemptId;
372
+ userRef.current = normalizedConfig.session?.user;
373
+ const courseIdRef = useRef(normalizedCourseId);
374
+ courseIdRef.current = normalizedCourseId;
375
+ const lxpackBridgeModeRef = useRef(normalizedConfig.lxpack?.bridge ?? "auto");
376
+ lxpackBridgeModeRef.current = normalizedConfig.lxpack?.bridge ?? "auto";
377
+ const pluginHost = useMemo(() => createReactPluginHost(normalizedConfig.plugins), [normalizedConfig.plugins]);
394
378
  const pluginHostRef = useRef(pluginHost);
395
379
  pluginHostRef.current = pluginHost;
396
380
  const progressRef = useRef(createProgressController());
397
381
  const courseStartedEmittedToSinkRef = useRef(false);
398
- const prevCourseIdForProgressRef = useRef(config.courseId);
382
+ const prevCourseIdForProgressRef = useRef(normalizedCourseId);
399
383
  const pendingCourseIdResetRef = useRef(false);
400
- if (prevCourseIdForProgressRef.current !== config.courseId) {
401
- prevCourseIdForProgressRef.current = config.courseId;
402
- progressRef.current = createProgressController();
384
+ const prevUseV2RuntimeRef = useRef(useV2Runtime);
385
+ const xapiCourseStartedSentOnClientRef = useRef(false);
386
+ if (prevUseV2RuntimeRef.current !== useV2Runtime) {
387
+ prevUseV2RuntimeRef.current = useV2Runtime;
388
+ if (useV2Runtime) {
389
+ headlessRef.current = createLessonkitRuntime({
390
+ courseId: normalizedCourseId,
391
+ runtimeVersion: "v2",
392
+ session: normalizedConfig.session
393
+ });
394
+ progressRef.current = headlessRef.current.progress;
395
+ } else {
396
+ headlessRef.current = null;
397
+ progressRef.current = createProgressController();
398
+ }
399
+ pendingCourseIdResetRef.current = true;
400
+ courseStartedEmittedToSinkRef.current = false;
401
+ } else if (useV2Runtime && !headlessRef.current) {
402
+ headlessRef.current = createLessonkitRuntime({
403
+ courseId: normalizedCourseId,
404
+ runtimeVersion: "v2",
405
+ session: normalizedConfig.session
406
+ });
407
+ }
408
+ if (prevCourseIdForProgressRef.current !== normalizedCourseId) {
409
+ prevCourseIdForProgressRef.current = normalizedCourseId;
410
+ if (useV2Runtime && headlessRef.current) {
411
+ headlessRef.current.resetForCourseChange(normalizedCourseId);
412
+ progressRef.current = headlessRef.current.progress;
413
+ } else {
414
+ progressRef.current = createProgressController();
415
+ }
403
416
  pendingCourseIdResetRef.current = true;
404
417
  courseStartedEmittedToSinkRef.current = false;
405
418
  }
419
+ if (useV2Runtime && headlessRef.current) {
420
+ progressRef.current = headlessRef.current.progress;
421
+ }
406
422
  const [progress, setProgress] = useState(() => progressRef.current.getState());
407
423
  const syncProgress = useCallback(() => {
408
424
  setProgress(progressRef.current.getState());
@@ -412,16 +428,16 @@ function LessonkitProvider(props) {
412
428
  const xapiQueueRef = useRef(createInMemoryXAPIQueue());
413
429
  const xapiRef = useRef(null);
414
430
  const [xapi, setXapi] = useState(null);
415
- const prevXapiCourseIdRef = useRef(config.courseId);
416
- const xapiEnabled = config.xapi?.enabled;
417
- const xapiClient = config.xapi?.client;
418
- const xapiTransport = config.xapi?.transport;
419
- const courseId = config.courseId;
420
- const trackingEnabled = config.tracking?.enabled;
431
+ const prevXapiCourseIdRef = useRef(normalizedCourseId);
432
+ const xapiEnabled = normalizedConfig.xapi?.enabled;
433
+ const xapiClient = normalizedConfig.xapi?.client;
434
+ const xapiTransport = normalizedConfig.xapi?.transport;
435
+ const courseId = normalizedCourseId;
436
+ const trackingEnabled = normalizedConfig.tracking?.enabled;
421
437
  useIsoLayoutEffect(() => {
422
438
  const courseChanged = prevXapiCourseIdRef.current !== courseId;
423
439
  if (courseChanged) {
424
- if (config.xapi?.client) {
440
+ if (normalizedConfig.xapi?.client) {
425
441
  const g = globalThis;
426
442
  if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production") {
427
443
  console.warn(
@@ -432,30 +448,40 @@ function LessonkitProvider(props) {
432
448
  }
433
449
  xapiQueueRef.current = createInMemoryXAPIQueue();
434
450
  prevXapiCourseIdRef.current = courseId;
451
+ xapiCourseStartedSentOnClientRef.current = false;
435
452
  }
436
453
  const prev = xapiRef.current;
437
- const next = createXapiClientFromConfig(config, xapiQueueRef.current);
454
+ const next = createXapiClientFromConfig(normalizedConfig, xapiQueueRef.current);
438
455
  xapiRef.current = next;
439
456
  setXapi(next);
440
- if (next && !prev) {
457
+ if (next) {
441
458
  const sessionId = sessionIdRef.current;
442
459
  const cid = courseIdRef.current;
443
- const trackingActive = isTrackingActive(config.tracking);
460
+ const trackingActive = isTrackingActive(normalizedConfig.tracking);
444
461
  const alreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
445
- if (!trackingActive || alreadyStarted) {
462
+ const clientChanged = !prev || prev !== next;
463
+ const skipBootstrap = trackingActive && !alreadyStarted;
464
+ const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && (!alreadyStarted || clientChanged);
465
+ if (needsBootstrap) {
446
466
  try {
447
- const statement = telemetryEventToXAPIStatement2(
448
- buildTrackEvent({
449
- name: "course_started",
450
- courseId: cid,
451
- sessionId,
452
- attemptId: attemptIdRef.current,
453
- user: userRef.current
454
- })
455
- );
456
- if (statement) {
457
- next.send(statement);
458
- markCourseStarted(defaultStorage, sessionId, cid);
467
+ const event = buildCourseStartedEvent({
468
+ pluginHost: pluginHostRef.current,
469
+ courseId: cid,
470
+ sessionId,
471
+ attemptId: attemptIdRef.current,
472
+ user: userRef.current,
473
+ lxpackBridge: lxpackBridgeModeRef.current
474
+ });
475
+ if (event === null) {
476
+ } else {
477
+ const statement = telemetryEventToXAPIStatement3(event);
478
+ if (statement) {
479
+ next.send(statement);
480
+ if (!alreadyStarted) {
481
+ markCourseStarted(defaultStorage, sessionId, cid);
482
+ }
483
+ xapiCourseStartedSentOnClientRef.current = true;
484
+ }
459
485
  }
460
486
  } catch {
461
487
  }
@@ -483,43 +509,53 @@ function LessonkitProvider(props) {
483
509
  const trackingRef = useRef(createTrackingClient2());
484
510
  const trackingClientForUnmountRef = useRef(trackingRef.current);
485
511
  const [tracking, setTracking] = useState(() => trackingRef.current);
486
- const trackingSink = config.tracking?.sink;
487
- const trackingBatchSink = config.tracking?.batchSink;
488
- const batchEnabled = config.tracking?.batch?.enabled;
489
- const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
490
- const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
512
+ const trackingSink = normalizedConfig.tracking?.sink;
513
+ const trackingBatchSink = normalizedConfig.tracking?.batchSink;
514
+ const batchEnabled = normalizedConfig.tracking?.batch?.enabled;
515
+ const batchFlushIntervalMs = normalizedConfig.tracking?.batch?.flushIntervalMs;
516
+ const batchMaxBatchSize = normalizedConfig.tracking?.batch?.maxBatchSize;
491
517
  const buildCurrentPluginCtx = useCallback(
492
518
  () => buildPluginContext({
493
519
  courseId: courseIdRef.current,
494
520
  sessionId: sessionIdRef.current,
495
- attemptId: attemptIdRef.current
521
+ attemptId: attemptIdRef.current,
522
+ user: userRef.current
496
523
  }),
497
524
  []
498
525
  );
499
526
  useIsoLayoutEffect(() => {
500
527
  const prev = trackingRef.current;
501
- const baseSink = config.tracking?.sink;
528
+ const baseSink = normalizedConfig.tracking?.sink;
529
+ const userBatchSink = normalizedConfig.tracking?.batchSink;
530
+ assertTrackingSinkConfig(normalizedConfig.tracking);
502
531
  const sink = pluginHostRef.current && baseSink ? pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink : baseSink;
503
- const batchSink = pluginHostRef.current && config.tracking?.batchSink ? (events) => {
504
- const delivered = pluginHostRef.current.deliverTelemetryBatch(
505
- events,
506
- buildCurrentPluginCtx()
507
- );
508
- return config.tracking.batchSink(delivered);
509
- } : config.tracking?.batchSink;
532
+ const batchSink = pluginHostRef.current && userBatchSink ? async (events) => {
533
+ const host = pluginHostRef.current;
534
+ const ctx = buildCurrentPluginCtx();
535
+ const delivered = host.deliverTelemetryBatch(events, ctx);
536
+ const perEventForBatch = [];
537
+ const collector = (event) => {
538
+ perEventForBatch.push(event);
539
+ };
540
+ const composedPerEvent = host.composeTrackingSink(collector, buildCurrentPluginCtx) ?? collector;
541
+ for (const event of delivered) {
542
+ await Promise.resolve(composedPerEvent(event));
543
+ }
544
+ return userBatchSink(perEventForBatch);
545
+ } : userBatchSink;
510
546
  const next = createTrackingClientFromConfig({
511
- tracking: { ...config.tracking, sink, batchSink }
547
+ tracking: { ...normalizedConfig.tracking, sink, batchSink }
512
548
  });
513
549
  trackingRef.current = next;
514
550
  trackingClientForUnmountRef.current = next;
515
551
  setTracking(next);
516
552
  const sessionId = sessionIdRef.current;
517
553
  const cid = courseIdRef.current;
518
- const trackingActive = isTrackingActive(config.tracking);
554
+ const trackingActive = isTrackingActive(normalizedConfig.tracking);
519
555
  if (!trackingActive) {
520
556
  courseStartedEmittedToSinkRef.current = false;
521
- } else if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
522
- const emitted = emitCourseStarted({
557
+ } else if (!courseStartedEmittedToSinkRef.current) {
558
+ const result = emitPendingCourseStarted({
523
559
  pluginHost: pluginHostRef.current,
524
560
  tracking: next,
525
561
  xapi: xapiRef.current,
@@ -528,9 +564,14 @@ function LessonkitProvider(props) {
528
564
  courseId: cid,
529
565
  attemptId: attemptIdRef.current,
530
566
  user: userRef.current,
531
- lxpackBridge: lxpackBridgeModeRef.current
567
+ lxpackBridge: lxpackBridgeModeRef.current,
568
+ extraSinks: extraSinksRef.current,
569
+ skipXapi: xapiCourseStartedSentOnClientRef.current,
570
+ onXapiStatementSent: () => {
571
+ xapiCourseStartedSentOnClientRef.current = true;
572
+ }
532
573
  });
533
- courseStartedEmittedToSinkRef.current = emitted;
574
+ courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
534
575
  } else if (trackingActive) {
535
576
  courseStartedEmittedToSinkRef.current = true;
536
577
  }
@@ -546,8 +587,8 @@ function LessonkitProvider(props) {
546
587
  batchEnabled,
547
588
  batchFlushIntervalMs,
548
589
  batchMaxBatchSize,
549
- config.plugins,
550
- config.courseId,
590
+ normalizedConfig.plugins,
591
+ normalizedCourseId,
551
592
  buildCurrentPluginCtx
552
593
  ]);
553
594
  const emitWithBridge = useCallback((trackingClient, event) => {
@@ -559,14 +600,32 @@ function LessonkitProvider(props) {
559
600
  pluginCtx: buildPluginContext({
560
601
  courseId: courseIdRef.current,
561
602
  sessionId: sessionIdRef.current,
562
- attemptId: attemptIdRef.current
603
+ attemptId: attemptIdRef.current,
604
+ user: userRef.current
563
605
  }),
564
- lxpackBridge: lxpackBridgeModeRef.current
606
+ lxpackBridge: lxpackBridgeModeRef.current,
607
+ extraSinks: extraSinksRef.current
565
608
  });
566
609
  }, []);
610
+ const emitLifecycleEvent = useCallback(
611
+ (name, data, lessonId) => {
612
+ const event = tryBuildTelemetryEvent({
613
+ name,
614
+ courseId: courseIdRef.current,
615
+ lessonId: lessonId ?? activeLessonIdRef.current,
616
+ sessionId: sessionIdRef.current,
617
+ attemptId: attemptIdRef.current,
618
+ user: userRef.current,
619
+ data
620
+ });
621
+ if (!event) return;
622
+ emitWithBridge(trackingRef.current, event);
623
+ },
624
+ [emitWithBridge]
625
+ );
567
626
  const track = useCallback(
568
627
  (name, data, opts) => {
569
- const event = tryBuildTrackEvent({
628
+ const event = tryBuildTelemetryEvent({
570
629
  name,
571
630
  courseId: courseIdRef.current,
572
631
  lessonId: opts?.lessonId ?? activeLessonIdRef.current,
@@ -584,7 +643,7 @@ function LessonkitProvider(props) {
584
643
  if (!pendingCourseIdResetRef.current) return;
585
644
  pendingCourseIdResetRef.current = false;
586
645
  syncProgress();
587
- if (!isTrackingActive(config.tracking)) return;
646
+ if (!isTrackingActive(normalizedConfig.tracking)) return;
588
647
  const sessionId = sessionIdRef.current;
589
648
  const cid = courseIdRef.current;
590
649
  void (async () => {
@@ -592,8 +651,8 @@ function LessonkitProvider(props) {
592
651
  await trackingRef.current?.flush?.();
593
652
  } catch {
594
653
  }
595
- if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
596
- const emitted = emitCourseStarted({
654
+ if (!courseStartedEmittedToSinkRef.current) {
655
+ const result = emitPendingCourseStarted({
597
656
  pluginHost: pluginHostRef.current,
598
657
  tracking: trackingRef.current,
599
658
  xapi: xapiRef.current,
@@ -602,12 +661,13 @@ function LessonkitProvider(props) {
602
661
  courseId: cid,
603
662
  attemptId: attemptIdRef.current,
604
663
  user: userRef.current,
605
- lxpackBridge: lxpackBridgeModeRef.current
664
+ lxpackBridge: lxpackBridgeModeRef.current,
665
+ extraSinks: extraSinksRef.current
606
666
  });
607
- courseStartedEmittedToSinkRef.current = emitted;
667
+ courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
608
668
  }
609
669
  })();
610
- }, [config.courseId, config.tracking?.enabled, syncProgress]);
670
+ }, [normalizedCourseId, normalizedConfig.tracking?.enabled, syncProgress]);
611
671
  const emitLessonCompleted = useCallback(
612
672
  (lessonId, durationMs) => {
613
673
  track("lesson_completed", { lessonId, durationMs }, { lessonId });
@@ -619,13 +679,19 @@ function LessonkitProvider(props) {
619
679
  );
620
680
  const completeLesson = useCallback(
621
681
  (lessonId) => {
682
+ if (useV2Runtime && headlessRef.current) {
683
+ headlessRef.current.completeLesson(lessonId, emitLifecycleEvent);
684
+ syncProgress();
685
+ void Promise.resolve(trackingRef.current?.flush?.());
686
+ return;
687
+ }
622
688
  const result = progressRef.current.completeLesson(lessonId, Date.now());
623
689
  if (!result.didComplete) return;
624
690
  syncProgress();
625
691
  emitLessonCompleted(lessonId, result.durationMs);
626
692
  void Promise.resolve(trackingRef.current?.flush?.());
627
693
  },
628
- [syncProgress, emitLessonCompleted]
694
+ [syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
629
695
  );
630
696
  useEffect(() => {
631
697
  return () => {
@@ -649,8 +715,19 @@ function LessonkitProvider(props) {
649
715
  }, []);
650
716
  const setActiveLesson = useCallback(
651
717
  (lessonId) => {
718
+ if (useV2Runtime && headlessRef.current) {
719
+ headlessRef.current.setActiveLesson(lessonId, emitLifecycleEvent);
720
+ syncProgress();
721
+ void Promise.resolve(trackingRef.current?.flush?.());
722
+ return;
723
+ }
652
724
  const current = progressRef.current.getState();
653
725
  if (current.activeLessonId === lessonId) return;
726
+ if (current.completedLessonIds.has(lessonId)) {
727
+ progressRef.current.setActiveLesson(lessonId, Date.now());
728
+ syncProgress();
729
+ return;
730
+ }
654
731
  const previous = current.activeLessonId;
655
732
  if (previous && previous !== lessonId) {
656
733
  const completed = progressRef.current.completeLesson(previous, Date.now());
@@ -663,9 +740,15 @@ function LessonkitProvider(props) {
663
740
  syncProgress();
664
741
  track("lesson_started", { lessonId }, { lessonId });
665
742
  },
666
- [track, syncProgress, emitLessonCompleted]
743
+ [track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
667
744
  );
668
745
  const completeCourse = useCallback(() => {
746
+ if (useV2Runtime && headlessRef.current) {
747
+ headlessRef.current.completeCourse(emitLifecycleEvent);
748
+ syncProgress();
749
+ void trackingRef.current?.flush?.();
750
+ return;
751
+ }
669
752
  const current = progressRef.current.getState();
670
753
  if (current.activeLessonId) {
671
754
  const lessonResult = progressRef.current.completeLesson(current.activeLessonId, Date.now());
@@ -678,24 +761,37 @@ function LessonkitProvider(props) {
678
761
  syncProgress();
679
762
  track("course_completed");
680
763
  void trackingRef.current?.flush?.();
681
- }, [track, syncProgress, emitLessonCompleted]);
682
- const sessionUser = config.session?.user;
683
- const sessionAttemptId = config.session?.attemptId;
684
- const sessionConfiguredId = config.session?.sessionId;
764
+ }, [track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]);
765
+ const sessionUser = normalizedConfig.session?.user;
766
+ const sessionUserKey = useMemo(
767
+ () => sessionUser ? JSON.stringify(sessionUser) : "",
768
+ [sessionUser]
769
+ );
770
+ const sessionAttemptId = normalizedConfig.session?.attemptId;
771
+ const sessionConfiguredId = normalizedConfig.session?.sessionId;
772
+ useEffect(() => {
773
+ if (useV2Runtime && headlessRef.current) {
774
+ headlessRef.current.updateConfig({
775
+ courseId: normalizedCourseId,
776
+ session: normalizedConfig.session
777
+ });
778
+ }
779
+ }, [useV2Runtime, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey, normalizedConfig.session]);
685
780
  useEffect(() => {
686
781
  if (!pluginHost) return;
687
782
  const ctx = buildPluginContext({
688
783
  courseId: courseIdRef.current,
689
784
  sessionId: sessionIdRef.current,
690
- attemptId: attemptIdRef.current
785
+ attemptId: attemptIdRef.current,
786
+ user: userRef.current
691
787
  });
692
788
  pluginHost.setupAll(ctx);
693
789
  return () => {
694
790
  pluginHost.disposeAll();
695
791
  };
696
- }, [pluginHost, config.courseId, sessionAttemptId, sessionConfiguredId]);
792
+ }, [pluginHost, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey]);
697
793
  useEffect(() => {
698
- const nextConfigured = config.session?.sessionId;
794
+ const nextConfigured = normalizedConfig.session?.sessionId;
699
795
  const prevConfigured = prevConfiguredSessionIdRef.current;
700
796
  if (nextConfigured === prevConfigured) return;
701
797
  prevConfiguredSessionIdRef.current = nextConfigured;
@@ -716,10 +812,10 @@ function LessonkitProvider(props) {
716
812
  migrateCourseStartedMark(defaultStorage, prevConfigured, nextAuto, cid);
717
813
  sessionIdRef.current = nextAuto;
718
814
  }
719
- }, [sessionConfiguredId, config.courseId]);
815
+ }, [sessionConfiguredId, normalizedCourseId]);
720
816
  const runtime = useMemo(
721
817
  () => ({
722
- config,
818
+ config: normalizedConfig,
723
819
  tracking,
724
820
  xapi,
725
821
  session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
@@ -731,7 +827,7 @@ function LessonkitProvider(props) {
731
827
  plugins: pluginHost
732
828
  }),
733
829
  [
734
- config,
830
+ normalizedConfig,
735
831
  tracking,
736
832
  xapi,
737
833
  progress,
@@ -745,6 +841,14 @@ function LessonkitProvider(props) {
745
841
  sessionConfiguredId
746
842
  ]
747
843
  );
844
+ return runtime;
845
+ }
846
+
847
+ // src/context.tsx
848
+ import { jsx } from "react/jsx-runtime";
849
+ var LessonkitContext = createContext(null);
850
+ function LessonkitProvider(props) {
851
+ const runtime = useLessonkitProviderRuntime(props.config);
748
852
  return /* @__PURE__ */ jsx(LessonkitContext.Provider, { value: runtime, children: props.children });
749
853
  }
750
854
 
@@ -767,46 +871,78 @@ function useCompletion() {
767
871
  const { completeLesson, completeCourse } = useLessonkit();
768
872
  return useMemo2(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
769
873
  }
770
- function useQuizState() {
874
+ function useQuizState(enclosingLessonId) {
771
875
  const { track } = useLessonkit();
876
+ const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
772
877
  return useMemo2(
773
878
  () => ({
774
879
  answer: (opts) => {
775
- track("quiz_answered", opts);
880
+ track("quiz_answered", opts, trackOpts);
776
881
  },
777
882
  complete: (opts) => {
778
- track("quiz_completed", opts);
883
+ track("quiz_completed", opts, trackOpts);
779
884
  }
780
885
  }),
781
- [track]
886
+ [track, enclosingLessonId]
782
887
  );
783
888
  }
784
889
 
890
+ // src/lessonContext.tsx
891
+ import { createContext as createContext2, useContext as useContext2 } from "react";
892
+ var LessonContext = createContext2(void 0);
893
+ function useEnclosingLessonId() {
894
+ return useContext2(LessonContext);
895
+ }
896
+
785
897
  // src/runtime/validateComponentId.ts
786
- import { validateId } from "@lessonkit/core";
787
- var warnedPaths = /* @__PURE__ */ new Set();
788
- function isDevEnvironment2() {
898
+ import { assertValidId as assertValidId2 } from "@lessonkit/core";
899
+ function isDevEnvironment3() {
789
900
  const g = globalThis;
790
901
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
791
902
  }
792
- function warnInvalidComponentId(id, path) {
793
- if (!isDevEnvironment2()) return;
794
- const key = `${path}:${String(id)}`;
795
- if (warnedPaths.has(key)) return;
796
- const result = validateId(id, path);
797
- if (result.ok) return;
798
- warnedPaths.add(key);
799
- const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
800
- console.warn(`[lessonkit] invalid ${path} \u2014 ${detail}`);
903
+ function normalizeComponentId(id, path) {
904
+ if (path === "courseId") return assertValidId2(id, "courseId");
905
+ if (path === "lessonId") return assertValidId2(id, "lessonId");
906
+ if (path === "checkId") return assertValidId2(id, "checkId");
907
+ if (path === "blockId") return assertValidId2(id, "blockId");
908
+ return assertValidId2(id, path);
909
+ }
910
+
911
+ // src/runtime/lessonMountRegistry.ts
912
+ var mountCounts = /* @__PURE__ */ new Map();
913
+ var warnedConcurrentLessons = false;
914
+ function registerLessonMount(lessonId) {
915
+ if (isDevEnvironment3() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
916
+ warnedConcurrentLessons = true;
917
+ console.warn(
918
+ "[lessonkit] Multiple <Lesson> components are mounted; only one should be active at a time. Set autoCompleteOnUnmount={false} on routed lessons or unmount the previous lesson before showing the next."
919
+ );
920
+ }
921
+ mountCounts.set(lessonId, (mountCounts.get(lessonId) ?? 0) + 1);
922
+ return () => {
923
+ const next = (mountCounts.get(lessonId) ?? 1) - 1;
924
+ if (next <= 0) {
925
+ mountCounts.delete(lessonId);
926
+ } else {
927
+ mountCounts.set(lessonId, next);
928
+ }
929
+ };
930
+ }
931
+ function getLessonMountCount(lessonId) {
932
+ return mountCounts.get(lessonId) ?? 0;
801
933
  }
802
934
 
803
935
  // src/components.tsx
804
936
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
937
+ var warnedQuizOutsideLesson = false;
938
+ function resetQuizWarningsForTests() {
939
+ warnedQuizOutsideLesson = false;
940
+ }
805
941
  function Course(props) {
806
- warnInvalidComponentId(props.courseId, "courseId");
942
+ const courseId = useMemo3(() => normalizeComponentId(props.courseId, "courseId"), [props.courseId]);
807
943
  const providerConfig = useMemo3(
808
- () => ({ ...props.config, courseId: props.courseId }),
809
- [props.config, props.courseId]
944
+ () => ({ ...props.config, courseId }),
945
+ [props.config, courseId]
810
946
  );
811
947
  return /* @__PURE__ */ jsx2(LessonkitProvider, { config: providerConfig, children: /* @__PURE__ */ jsxs("section", { "aria-label": props.title, children: [
812
948
  /* @__PURE__ */ jsx2("h1", { children: props.title }),
@@ -814,41 +950,64 @@ function Course(props) {
814
950
  ] }) });
815
951
  }
816
952
  function Lesson(props) {
817
- warnInvalidComponentId(props.lessonId, "lessonId");
953
+ const lessonId = useMemo3(() => normalizeComponentId(props.lessonId, "lessonId"), [props.lessonId]);
954
+ const autoComplete = props.autoCompleteOnUnmount !== false;
818
955
  const { setActiveLesson, config } = useLessonkit();
819
956
  const { completeLesson } = useCompletion();
820
- const id = props.lessonId;
821
957
  const lessonMountGenerationRef = useRef2(0);
822
958
  useEffect2(() => {
959
+ const unregister = registerLessonMount(lessonId);
823
960
  const generation = ++lessonMountGenerationRef.current;
824
- setActiveLesson(id);
961
+ setActiveLesson(lessonId);
825
962
  return () => {
826
- const lessonId = id;
963
+ unregister();
964
+ if (getLessonMountCount(lessonId) > 0) {
965
+ return;
966
+ }
967
+ if (!autoComplete) return;
827
968
  queueMicrotask(() => {
828
969
  if (lessonMountGenerationRef.current !== generation) return;
829
970
  completeLesson(lessonId);
830
971
  });
831
972
  };
832
- }, [id, config.courseId, setActiveLesson, completeLesson]);
833
- return /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
973
+ }, [lessonId, config.courseId, setActiveLesson, completeLesson, autoComplete]);
974
+ return /* @__PURE__ */ jsx2(LessonContext.Provider, { value: lessonId, children: /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
834
975
  /* @__PURE__ */ jsx2("h2", { children: props.title }),
835
976
  /* @__PURE__ */ jsx2("div", { children: props.children })
836
- ] });
977
+ ] }) });
837
978
  }
838
979
  function Scenario(props) {
839
- if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
840
- return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", "data-lk-block-id": props.blockId, children: props.children });
980
+ const blockId = useMemo3(
981
+ () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
982
+ [props.blockId]
983
+ );
984
+ return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", "data-lk-block-id": blockId, children: props.children });
841
985
  }
842
986
  function Reflection(props) {
843
- if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
987
+ const blockId = useMemo3(
988
+ () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
989
+ [props.blockId]
990
+ );
844
991
  const promptId = useId();
845
- return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", "data-lk-block-id": props.blockId, children: [
992
+ const hintId = useId();
993
+ const [internalValue, setInternalValue] = useState2("");
994
+ const isControlled = props.value !== void 0;
995
+ const value = isControlled ? props.value : internalValue;
996
+ const handleChange = (event) => {
997
+ if (!isControlled) setInternalValue(event.target.value);
998
+ props.onChange?.(event.target.value);
999
+ };
1000
+ return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", "data-lk-block-id": blockId, children: [
846
1001
  props.prompt ? /* @__PURE__ */ jsx2("p", { id: promptId, children: props.prompt }) : null,
1002
+ props.hint ? /* @__PURE__ */ jsx2("p", { id: hintId, style: visuallyHiddenStyle, children: props.hint }) : null,
847
1003
  props.children,
848
1004
  /* @__PURE__ */ jsx2(
849
1005
  "textarea",
850
1006
  {
1007
+ value,
1008
+ onChange: handleChange,
851
1009
  "aria-labelledby": props.prompt ? promptId : void 0,
1010
+ "aria-describedby": props.hint ? hintId : void 0,
852
1011
  "aria-label": props.prompt ? void 0 : "Reflection response"
853
1012
  }
854
1013
  )
@@ -867,18 +1026,35 @@ function KnowledgeCheck(props) {
867
1026
  );
868
1027
  }
869
1028
  function Quiz(props) {
870
- warnInvalidComponentId(props.checkId, "checkId");
871
- const quiz = useQuizState();
872
- const { plugins, config, progress, session } = useLessonkit();
1029
+ const checkId = useMemo3(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1030
+ const enclosingLessonId = useEnclosingLessonId();
1031
+ const missingLesson = enclosingLessonId === void 0;
1032
+ useEffect2(() => {
1033
+ if (!missingLesson || isDevEnvironment3()) return;
1034
+ if (!warnedQuizOutsideLesson) {
1035
+ warnedQuizOutsideLesson = true;
1036
+ console.error(
1037
+ "[lessonkit] <Quiz> must be wrapped in <Lesson>; quiz telemetry will not be emitted."
1038
+ );
1039
+ }
1040
+ }, [missingLesson]);
1041
+ if (missingLesson && isDevEnvironment3()) {
1042
+ throw new Error("[lessonkit] <Quiz> must be wrapped in <Lesson>");
1043
+ }
1044
+ const quiz = useQuizState(enclosingLessonId);
1045
+ const { plugins, config, session } = useLessonkit();
873
1046
  const [selected, setSelected] = useState2(null);
874
1047
  const [selectionCorrect, setSelectionCorrect] = useState2(null);
1048
+ const [quizPassed, setQuizPassed] = useState2(false);
875
1049
  const completedRef = useRef2(false);
876
1050
  const questionId = useId();
1051
+ const choicesKey = props.choices.join("\0");
877
1052
  useEffect2(() => {
878
1053
  completedRef.current = false;
1054
+ setQuizPassed(false);
879
1055
  setSelected(null);
880
1056
  setSelectionCorrect(null);
881
- }, [props.checkId, props.answer, props.question, config.courseId]);
1057
+ }, [checkId, props.answer, props.question, config.courseId, enclosingLessonId, choicesKey]);
882
1058
  const isChoiceCorrect = (choice, custom) => {
883
1059
  if (!custom) return choice === props.answer;
884
1060
  if (custom.passed !== void 0) return custom.passed;
@@ -887,7 +1063,11 @@ function Quiz(props) {
887
1063
  }
888
1064
  return choice === props.answer;
889
1065
  };
890
- return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
1066
+ if (missingLesson) {
1067
+ return /* @__PURE__ */ jsx2("section", { role: "alert", "aria-label": "Quiz configuration error", "data-lk-check-id": checkId, children: /* @__PURE__ */ jsx2("p", { children: "Quiz must be placed inside a Lesson." }) });
1068
+ }
1069
+ const passed = quizPassed;
1070
+ return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": checkId, children: [
891
1071
  /* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
892
1072
  /* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
893
1073
  /* @__PURE__ */ jsx2("legend", { style: visuallyHiddenStyle, children: "Quiz choices" }),
@@ -899,17 +1079,21 @@ function Quiz(props) {
899
1079
  name: questionId,
900
1080
  value: c,
901
1081
  checked: selected === c,
1082
+ disabled: passed,
1083
+ "aria-invalid": selected === c && selectionCorrect === false ? true : void 0,
902
1084
  onChange: () => {
1085
+ if (passed) return;
903
1086
  setSelected(c);
904
1087
  const pluginCtx = buildPluginContext({
905
1088
  courseId: config.courseId,
906
1089
  sessionId: session.sessionId,
907
- attemptId: session.attemptId
1090
+ attemptId: session.attemptId,
1091
+ user: session.user
908
1092
  });
909
1093
  const custom = plugins?.scoreAssessment(
910
1094
  {
911
- checkId: props.checkId,
912
- lessonId: progress.activeLessonId,
1095
+ checkId,
1096
+ lessonId: enclosingLessonId,
913
1097
  response: c
914
1098
  },
915
1099
  pluginCtx
@@ -917,18 +1101,20 @@ function Quiz(props) {
917
1101
  const correct = isChoiceCorrect(c, custom);
918
1102
  setSelectionCorrect(correct);
919
1103
  quiz.answer({
920
- checkId: props.checkId,
1104
+ checkId,
921
1105
  question: props.question,
922
1106
  choice: c,
923
1107
  correct
924
1108
  });
925
1109
  if (correct && !completedRef.current) {
926
1110
  completedRef.current = true;
1111
+ setQuizPassed(true);
1112
+ const maxScore = custom?.maxScore ?? 1;
927
1113
  quiz.complete({
928
- checkId: props.checkId,
1114
+ checkId,
929
1115
  score: custom?.score ?? 1,
930
- maxScore: custom?.maxScore ?? 1,
931
- passingScore: props.passingScore ?? 1
1116
+ maxScore,
1117
+ passingScore: props.passingScore ?? maxScore
932
1118
  });
933
1119
  }
934
1120
  }
@@ -940,23 +1126,51 @@ function Quiz(props) {
940
1126
  selected && selectionCorrect !== null ? /* @__PURE__ */ jsx2("p", { role: "status", "aria-live": "polite", children: selectionCorrect ? "Correct" : "Try again" }) : null
941
1127
  ] });
942
1128
  }
943
- function ProgressTracker() {
1129
+ function ProgressTracker(props) {
944
1130
  const { progress } = useLessonkit();
945
1131
  const completed = progress.completedLessonIds.size;
946
- return /* @__PURE__ */ jsx2("aside", { "aria-label": "Progress", children: /* @__PURE__ */ jsxs("p", { children: [
1132
+ if (props.totalLessons != null) {
1133
+ const total = props.totalLessons;
1134
+ const displayed = Math.min(completed, total);
1135
+ return /* @__PURE__ */ jsx2("aside", { "aria-label": "Progress", children: /* @__PURE__ */ jsx2(
1136
+ "div",
1137
+ {
1138
+ role: "progressbar",
1139
+ "aria-valuemin": 0,
1140
+ "aria-valuemax": total,
1141
+ "aria-valuenow": displayed,
1142
+ "aria-label": "Lessons completed",
1143
+ children: /* @__PURE__ */ jsxs("p", { children: [
1144
+ "Lessons completed: ",
1145
+ displayed,
1146
+ " of ",
1147
+ total
1148
+ ] })
1149
+ }
1150
+ ) });
1151
+ }
1152
+ return /* @__PURE__ */ jsx2("aside", { "aria-label": "Progress", role: "status", children: /* @__PURE__ */ jsxs("p", { children: [
947
1153
  "Lessons completed: ",
948
1154
  completed
949
1155
  ] }) });
950
1156
  }
951
1157
 
952
1158
  // src/index.tsx
953
- import { createPluginHost as createPluginHost2, defineLessonkitPlugin } from "@lessonkit/core";
1159
+ import {
1160
+ buildTelemetryEvent as buildTelemetryEvent2,
1161
+ createLessonkitRuntime as createLessonkitRuntime2,
1162
+ createPluginRegistry as createPluginRegistry2,
1163
+ createTelemetryPipeline as createTelemetryPipeline2,
1164
+ defineAssessmentPlugin,
1165
+ defineLifecyclePlugin,
1166
+ defineTelemetryPlugin
1167
+ } from "@lessonkit/core";
954
1168
 
955
1169
  // src/theme/ThemeProvider.tsx
956
1170
  import React3, {
957
- createContext as createContext2,
1171
+ createContext as createContext3,
958
1172
  useCallback as useCallback2,
959
- useContext as useContext2,
1173
+ useContext as useContext3,
960
1174
  useLayoutEffect as useLayoutEffect2,
961
1175
  useMemo as useMemo4,
962
1176
  useRef as useRef3,
@@ -988,7 +1202,7 @@ function applyCssVariables(target, vars, previousKeys) {
988
1202
 
989
1203
  // src/theme/ThemeProvider.tsx
990
1204
  import { jsx as jsx3 } from "react/jsx-runtime";
991
- var ThemeContext = createContext2(null);
1205
+ var ThemeContext = createContext3(null);
992
1206
  var useIsoLayoutEffect2 = typeof window !== "undefined" ? useLayoutEffect2 : React3.useEffect;
993
1207
  function getSystemMode() {
994
1208
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
@@ -1068,7 +1282,7 @@ function ThemeProvider(props) {
1068
1282
  return /* @__PURE__ */ jsx3(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx3("div", { ref: hostRef, "data-lk-theme": dataTheme, children: props.children }) });
1069
1283
  }
1070
1284
  function useTheme() {
1071
- const ctx = useContext2(ThemeContext);
1285
+ const ctx = useContext3(ThemeContext);
1072
1286
  if (!ctx) {
1073
1287
  throw new Error("useTheme must be used within a ThemeProvider");
1074
1288
  }
@@ -1115,6 +1329,12 @@ var BLOCK_CATALOG = [
1115
1329
  props: [
1116
1330
  { name: "title", type: "string", required: true, description: "Lesson title shown in the h2." },
1117
1331
  { name: "lessonId", type: "LessonId", required: true, description: "Stable lesson identifier for telemetry and packaging." },
1332
+ {
1333
+ name: "autoCompleteOnUnmount",
1334
+ type: "boolean",
1335
+ required: false,
1336
+ description: "When false, unmount does not emit lesson_completed (default true)."
1337
+ },
1118
1338
  { name: "children", type: "ReactNode", required: true, description: "Scenario, Quiz, Reflection, and other blocks." }
1119
1339
  ],
1120
1340
  requiredIds: ["lessonId"],
@@ -1167,6 +1387,9 @@ var BLOCK_CATALOG = [
1167
1387
  props: [
1168
1388
  { name: "blockId", type: "BlockId", required: false, description: "Optional stable block id for interaction telemetry URNs." },
1169
1389
  { name: "prompt", type: "string", required: false, description: "Reflection question or instruction." },
1390
+ { name: "hint", type: "string", required: false, description: "Optional hint linked via aria-describedby." },
1391
+ { name: "value", type: "string", required: false, description: "Controlled textarea value." },
1392
+ { name: "onChange", type: "(value: string) => void", required: false, description: "Called when the learner edits the textarea." },
1170
1393
  { name: "children", type: "ReactNode", required: false, description: "Optional content above the textarea." }
1171
1394
  ],
1172
1395
  requiredIds: [],
@@ -1185,6 +1408,7 @@ var BLOCK_CATALOG = [
1185
1408
  },
1186
1409
  telemetry: {
1187
1410
  emits: [],
1411
+ requiresActiveLesson: true,
1188
1412
  manualTracking: "useTracking().track('interaction', { kind, blockId, payload }) on submit or blur"
1189
1413
  }
1190
1414
  },
@@ -1197,7 +1421,13 @@ var BLOCK_CATALOG = [
1197
1421
  { name: "checkId", type: "CheckId", required: true, description: "Stable check identifier for telemetry and LXPack assessments." },
1198
1422
  { name: "question", type: "string", required: true, description: "Question text shown above choices." },
1199
1423
  { name: "choices", type: "string[]", required: true, description: "Radio button choice labels." },
1200
- { name: "answer", type: "string", required: true, description: "Correct choice value (must match one choice)." }
1424
+ { name: "answer", type: "string", required: true, description: "Correct choice value (must match one choice)." },
1425
+ {
1426
+ name: "passingScore",
1427
+ type: "number",
1428
+ required: false,
1429
+ description: "Minimum score required to pass (defaults to maxScore when omitted)."
1430
+ }
1201
1431
  ],
1202
1432
  requiredIds: ["checkId"],
1203
1433
  parentConstraints: ["Lesson"],
@@ -1222,7 +1452,14 @@ var BLOCK_CATALOG = [
1222
1452
  type: "ProgressTracker",
1223
1453
  category: "chrome",
1224
1454
  description: "Displays count of completed lessons from runtime progress state.",
1225
- props: [],
1455
+ props: [
1456
+ {
1457
+ name: "totalLessons",
1458
+ type: "number",
1459
+ required: false,
1460
+ description: "When set, renders role=progressbar with aria-valuenow/max."
1461
+ }
1462
+ ],
1226
1463
  requiredIds: [],
1227
1464
  parentConstraints: ["Course"],
1228
1465
  a11y: {
@@ -1274,9 +1511,15 @@ export {
1274
1511
  ThemeProvider,
1275
1512
  blockCatalogVersion,
1276
1513
  buildBlockCatalog,
1277
- createPluginHost2 as createPluginHost,
1278
- defineLessonkitPlugin,
1514
+ buildTelemetryEvent2 as buildTelemetryEvent,
1515
+ createLessonkitRuntime2 as createLessonkitRuntime,
1516
+ createPluginRegistry2 as createPluginRegistry,
1517
+ createTelemetryPipeline2 as createTelemetryPipeline,
1518
+ defineAssessmentPlugin,
1519
+ defineLifecyclePlugin,
1520
+ defineTelemetryPlugin,
1279
1521
  getBlockCatalogEntry,
1522
+ resetQuizWarningsForTests,
1280
1523
  useCompletion,
1281
1524
  useLessonkit,
1282
1525
  useProgress,