@lessonkit/react 0.9.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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
19
  import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement2 } 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,38 @@ 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));
297
- }
298
- }
130
+ import {
131
+ SESSION_STORAGE_KEY,
132
+ getTabSessionId,
133
+ resolveSessionId,
134
+ hasCourseStarted,
135
+ markCourseStarted,
136
+ hasCourseStartedEmittedToTracking,
137
+ markCourseStartedEmittedToTracking,
138
+ migrateCourseStartedMark
139
+ } from "@lessonkit/core";
299
140
 
300
141
  // src/runtime/plugins.ts
301
- import { createPluginHost } from "@lessonkit/core";
142
+ import { createPluginRegistry } from "@lessonkit/core";
302
143
  function createReactPluginHost(plugins) {
303
144
  if (!plugins?.length) return null;
304
- return createPluginHost(plugins);
145
+ return createPluginRegistry(plugins);
305
146
  }
306
147
  function buildPluginContext(opts) {
307
148
  return {
308
149
  courseId: opts.courseId,
309
150
  sessionId: opts.sessionId,
310
- attemptId: opts.attemptId
151
+ attemptId: opts.attemptId,
152
+ user: opts.user
311
153
  };
312
154
  }
313
155
  function emitTelemetryWithPlugins(opts) {
314
156
  const next = opts.pluginHost ? opts.pluginHost.runTelemetry(opts.event, opts.pluginCtx) : opts.event;
315
157
  if (next === null) return;
316
- emitTelemetry(opts.tracking, opts.xapi, next, { lxpackBridge: opts.lxpackBridge ?? "auto" });
158
+ emitTelemetry(opts.tracking, opts.xapi, next, {
159
+ lxpackBridge: opts.lxpackBridge ?? "auto",
160
+ extraSinks: opts.extraSinks
161
+ });
317
162
  }
318
163
 
319
164
  // src/runtime/telemetry.ts
@@ -338,34 +183,46 @@ async function disposeTrackingClient(client) {
338
183
  }
339
184
  }
340
185
 
341
- // src/context.tsx
342
- import { jsx } from "react/jsx-runtime";
343
- var LessonkitContext = createContext(null);
186
+ // src/provider/useLessonkitProviderRuntime.ts
344
187
  var useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
345
188
  var defaultStorage = createSessionStoragePort();
346
189
  function isTrackingActive(tracking) {
347
190
  return tracking?.enabled !== false;
348
191
  }
349
- function emitCourseStarted(opts) {
192
+ var noopTrackingClient = { track: () => {
193
+ } };
194
+ function buildCourseStartedEvent(opts) {
350
195
  const pluginCtx = buildPluginContext({
351
196
  courseId: opts.courseId,
352
197
  sessionId: opts.sessionId,
353
- attemptId: opts.attemptId
198
+ attemptId: opts.attemptId,
199
+ user: opts.user
200
+ });
201
+ const built = buildTelemetryEvent({
202
+ name: "course_started",
203
+ courseId: opts.courseId,
204
+ sessionId: opts.sessionId,
205
+ attemptId: opts.attemptId,
206
+ user: opts.user
207
+ });
208
+ return opts.pluginHost ? opts.pluginHost.runTelemetry(built, pluginCtx) : built;
209
+ }
210
+ function emitCourseStartedPipelineOnly(opts) {
211
+ const pluginCtx = buildPluginContext({
212
+ courseId: opts.courseId,
213
+ sessionId: opts.sessionId,
214
+ attemptId: opts.attemptId,
215
+ user: opts.user
354
216
  });
355
217
  try {
356
218
  emitTelemetryWithPlugins({
357
- pluginHost: opts.pluginHost,
358
- tracking: opts.tracking,
219
+ pluginHost: null,
220
+ tracking: noopTrackingClient,
359
221
  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
- }),
222
+ event: opts.event,
367
223
  pluginCtx,
368
- lxpackBridge: opts.lxpackBridge
224
+ lxpackBridge: opts.lxpackBridge,
225
+ extraSinks: opts.extraSinks
369
226
  });
370
227
  markCourseStarted(opts.storage, opts.sessionId, opts.courseId);
371
228
  return true;
@@ -373,36 +230,160 @@ function emitCourseStarted(opts) {
373
230
  return false;
374
231
  }
375
232
  }
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;
233
+ function emitCourseStarted(opts) {
234
+ const event = buildCourseStartedEvent(opts);
235
+ if (event === null) return true;
236
+ const trackingAlreadyEmitted = hasCourseStartedEmittedToTracking(
237
+ opts.storage,
238
+ opts.sessionId,
239
+ opts.courseId
240
+ );
241
+ if (!trackingAlreadyEmitted) {
242
+ try {
243
+ opts.tracking.track(event);
244
+ markCourseStartedEmittedToTracking(opts.storage, opts.sessionId, opts.courseId);
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+ return emitCourseStartedPipelineOnly({ ...opts, event });
250
+ }
251
+ function emitCourseStartedToTrackingOnly(opts) {
252
+ const event = buildCourseStartedEvent(opts);
253
+ if (event === null) return true;
254
+ const trackingAlreadyEmitted = hasCourseStartedEmittedToTracking(
255
+ opts.storage,
256
+ opts.sessionId,
257
+ opts.courseId
258
+ );
259
+ if (!trackingAlreadyEmitted) {
260
+ try {
261
+ opts.tracking.track(event);
262
+ markCourseStartedEmittedToTracking(opts.storage, opts.sessionId, opts.courseId);
263
+ } catch {
264
+ return false;
265
+ }
266
+ }
267
+ const pluginCtx = buildPluginContext({
268
+ courseId: opts.courseId,
269
+ sessionId: opts.sessionId,
270
+ attemptId: opts.attemptId,
271
+ user: opts.user
272
+ });
273
+ try {
274
+ emitTelemetryWithPlugins({
275
+ pluginHost: null,
276
+ tracking: noopTrackingClient,
277
+ xapi: null,
278
+ event,
279
+ pluginCtx,
280
+ lxpackBridge: opts.lxpackBridge,
281
+ extraSinks: opts.extraSinks
282
+ });
283
+ return true;
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+ function emitPendingCourseStarted(opts) {
289
+ const trackingEmitted = hasCourseStartedEmittedToTracking(
290
+ opts.storage,
291
+ opts.sessionId,
292
+ opts.courseId
293
+ );
294
+ const sessionStarted = hasCourseStarted(opts.storage, opts.sessionId, opts.courseId);
295
+ if (sessionStarted && !trackingEmitted) {
296
+ return emitCourseStartedToTrackingOnly(opts);
297
+ }
298
+ if (trackingEmitted && !sessionStarted) {
299
+ const event = buildCourseStartedEvent(opts);
300
+ if (event === null) return true;
301
+ return emitCourseStartedPipelineOnly({ ...opts, event });
302
+ }
303
+ if (!trackingEmitted && !sessionStarted) {
304
+ return emitCourseStarted(opts);
305
+ }
306
+ return true;
307
+ }
308
+ function assertTrackingSinkConfig(tracking) {
309
+ if (!tracking?.sink || !tracking?.batchSink) return;
310
+ throw new Error(
311
+ "[lessonkit] tracking.sink and tracking.batchSink cannot both be set; use batchSink alone for batched delivery"
312
+ );
313
+ }
314
+ function useLessonkitProviderRuntime(config) {
315
+ const normalizedCourseId = useMemo(
316
+ () => assertValidId(config.courseId, "courseId"),
317
+ [config.courseId]
318
+ );
319
+ const normalizedConfig = useMemo(
320
+ () => ({ ...config, courseId: normalizedCourseId }),
321
+ [config, normalizedCourseId]
322
+ );
323
+ const useV2Runtime = normalizedConfig.runtimeVersion !== "v1";
324
+ const extraSinksRef = useRef(normalizedConfig.sinks);
325
+ extraSinksRef.current = normalizedConfig.sinks;
326
+ const headlessRef = useRef(null);
327
+ const sessionIdRef = useRef(resolveSessionId(defaultStorage, normalizedConfig.session?.sessionId));
328
+ const prevConfiguredSessionIdRef = useRef(normalizedConfig.session?.sessionId);
329
+ if (normalizedConfig.session?.sessionId) {
330
+ sessionIdRef.current = normalizedConfig.session.sessionId;
382
331
  } else if (prevConfiguredSessionIdRef.current) {
383
332
  sessionIdRef.current = resolveSessionId(defaultStorage, void 0);
384
333
  }
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]);
334
+ const attemptIdRef = useRef(normalizedConfig.session?.attemptId);
335
+ const userRef = useRef(normalizedConfig.session?.user);
336
+ attemptIdRef.current = normalizedConfig.session?.attemptId;
337
+ userRef.current = normalizedConfig.session?.user;
338
+ const courseIdRef = useRef(normalizedCourseId);
339
+ courseIdRef.current = normalizedCourseId;
340
+ const lxpackBridgeModeRef = useRef(normalizedConfig.lxpack?.bridge ?? "auto");
341
+ lxpackBridgeModeRef.current = normalizedConfig.lxpack?.bridge ?? "auto";
342
+ const pluginHost = useMemo(() => createReactPluginHost(normalizedConfig.plugins), [normalizedConfig.plugins]);
394
343
  const pluginHostRef = useRef(pluginHost);
395
344
  pluginHostRef.current = pluginHost;
396
345
  const progressRef = useRef(createProgressController());
397
346
  const courseStartedEmittedToSinkRef = useRef(false);
398
- const prevCourseIdForProgressRef = useRef(config.courseId);
347
+ const prevCourseIdForProgressRef = useRef(normalizedCourseId);
399
348
  const pendingCourseIdResetRef = useRef(false);
400
- if (prevCourseIdForProgressRef.current !== config.courseId) {
401
- prevCourseIdForProgressRef.current = config.courseId;
402
- progressRef.current = createProgressController();
349
+ const prevUseV2RuntimeRef = useRef(useV2Runtime);
350
+ const xapiCourseStartedSentOnClientRef = useRef(false);
351
+ if (prevUseV2RuntimeRef.current !== useV2Runtime) {
352
+ prevUseV2RuntimeRef.current = useV2Runtime;
353
+ if (useV2Runtime) {
354
+ headlessRef.current = createLessonkitRuntime({
355
+ courseId: normalizedCourseId,
356
+ runtimeVersion: "v2",
357
+ session: normalizedConfig.session
358
+ });
359
+ progressRef.current = headlessRef.current.progress;
360
+ } else {
361
+ headlessRef.current = null;
362
+ progressRef.current = createProgressController();
363
+ }
364
+ pendingCourseIdResetRef.current = true;
365
+ courseStartedEmittedToSinkRef.current = false;
366
+ } else if (useV2Runtime && !headlessRef.current) {
367
+ headlessRef.current = createLessonkitRuntime({
368
+ courseId: normalizedCourseId,
369
+ runtimeVersion: "v2",
370
+ session: normalizedConfig.session
371
+ });
372
+ }
373
+ if (prevCourseIdForProgressRef.current !== normalizedCourseId) {
374
+ prevCourseIdForProgressRef.current = normalizedCourseId;
375
+ if (useV2Runtime && headlessRef.current) {
376
+ headlessRef.current.resetForCourseChange(normalizedCourseId);
377
+ progressRef.current = headlessRef.current.progress;
378
+ } else {
379
+ progressRef.current = createProgressController();
380
+ }
403
381
  pendingCourseIdResetRef.current = true;
404
382
  courseStartedEmittedToSinkRef.current = false;
405
383
  }
384
+ if (useV2Runtime && headlessRef.current) {
385
+ progressRef.current = headlessRef.current.progress;
386
+ }
406
387
  const [progress, setProgress] = useState(() => progressRef.current.getState());
407
388
  const syncProgress = useCallback(() => {
408
389
  setProgress(progressRef.current.getState());
@@ -412,16 +393,16 @@ function LessonkitProvider(props) {
412
393
  const xapiQueueRef = useRef(createInMemoryXAPIQueue());
413
394
  const xapiRef = useRef(null);
414
395
  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;
396
+ const prevXapiCourseIdRef = useRef(normalizedCourseId);
397
+ const xapiEnabled = normalizedConfig.xapi?.enabled;
398
+ const xapiClient = normalizedConfig.xapi?.client;
399
+ const xapiTransport = normalizedConfig.xapi?.transport;
400
+ const courseId = normalizedCourseId;
401
+ const trackingEnabled = normalizedConfig.tracking?.enabled;
421
402
  useIsoLayoutEffect(() => {
422
403
  const courseChanged = prevXapiCourseIdRef.current !== courseId;
423
404
  if (courseChanged) {
424
- if (config.xapi?.client) {
405
+ if (normalizedConfig.xapi?.client) {
425
406
  const g = globalThis;
426
407
  if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production") {
427
408
  console.warn(
@@ -432,20 +413,24 @@ function LessonkitProvider(props) {
432
413
  }
433
414
  xapiQueueRef.current = createInMemoryXAPIQueue();
434
415
  prevXapiCourseIdRef.current = courseId;
416
+ xapiCourseStartedSentOnClientRef.current = false;
435
417
  }
436
418
  const prev = xapiRef.current;
437
- const next = createXapiClientFromConfig(config, xapiQueueRef.current);
419
+ const next = createXapiClientFromConfig(normalizedConfig, xapiQueueRef.current);
438
420
  xapiRef.current = next;
439
421
  setXapi(next);
440
- if (next && !prev) {
422
+ if (next) {
441
423
  const sessionId = sessionIdRef.current;
442
424
  const cid = courseIdRef.current;
443
- const trackingActive = isTrackingActive(config.tracking);
425
+ const trackingActive = isTrackingActive(normalizedConfig.tracking);
444
426
  const alreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
445
- if (!trackingActive || alreadyStarted) {
427
+ const clientChanged = !prev || prev !== next;
428
+ const skipBootstrap = trackingActive && !alreadyStarted;
429
+ const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && (!alreadyStarted || clientChanged);
430
+ if (needsBootstrap) {
446
431
  try {
447
432
  const statement = telemetryEventToXAPIStatement2(
448
- buildTrackEvent({
433
+ buildTelemetryEvent({
449
434
  name: "course_started",
450
435
  courseId: cid,
451
436
  sessionId,
@@ -455,7 +440,10 @@ function LessonkitProvider(props) {
455
440
  );
456
441
  if (statement) {
457
442
  next.send(statement);
458
- markCourseStarted(defaultStorage, sessionId, cid);
443
+ if (!alreadyStarted) {
444
+ markCourseStarted(defaultStorage, sessionId, cid);
445
+ }
446
+ xapiCourseStartedSentOnClientRef.current = true;
459
447
  }
460
448
  } catch {
461
449
  }
@@ -483,46 +471,53 @@ function LessonkitProvider(props) {
483
471
  const trackingRef = useRef(createTrackingClient2());
484
472
  const trackingClientForUnmountRef = useRef(trackingRef.current);
485
473
  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;
474
+ const trackingSink = normalizedConfig.tracking?.sink;
475
+ const trackingBatchSink = normalizedConfig.tracking?.batchSink;
476
+ const batchEnabled = normalizedConfig.tracking?.batch?.enabled;
477
+ const batchFlushIntervalMs = normalizedConfig.tracking?.batch?.flushIntervalMs;
478
+ const batchMaxBatchSize = normalizedConfig.tracking?.batch?.maxBatchSize;
491
479
  const buildCurrentPluginCtx = useCallback(
492
480
  () => buildPluginContext({
493
481
  courseId: courseIdRef.current,
494
482
  sessionId: sessionIdRef.current,
495
- attemptId: attemptIdRef.current
483
+ attemptId: attemptIdRef.current,
484
+ user: userRef.current
496
485
  }),
497
486
  []
498
487
  );
499
488
  useIsoLayoutEffect(() => {
500
489
  const prev = trackingRef.current;
501
- const baseSink = config.tracking?.sink;
502
- const sink = pluginHostRef.current && baseSink ? (event) => {
503
- const composed = pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx()) ?? baseSink;
504
- return composed(event);
505
- } : baseSink;
506
- const batchSink = pluginHostRef.current && config.tracking?.batchSink ? (events) => {
507
- const delivered = pluginHostRef.current.deliverTelemetryBatch(
508
- events,
509
- buildCurrentPluginCtx()
510
- );
511
- return config.tracking.batchSink(delivered);
512
- } : config.tracking?.batchSink;
490
+ const baseSink = normalizedConfig.tracking?.sink;
491
+ const userBatchSink = normalizedConfig.tracking?.batchSink;
492
+ assertTrackingSinkConfig(normalizedConfig.tracking);
493
+ const sink = pluginHostRef.current && baseSink ? pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink : baseSink;
494
+ const batchSink = pluginHostRef.current && userBatchSink ? async (events) => {
495
+ const host = pluginHostRef.current;
496
+ const ctx = buildCurrentPluginCtx();
497
+ const delivered = host.deliverTelemetryBatch(events, ctx);
498
+ const perEventForBatch = [];
499
+ const collector = (event) => {
500
+ perEventForBatch.push(event);
501
+ };
502
+ const composedPerEvent = host.composeTrackingSink(collector, buildCurrentPluginCtx) ?? collector;
503
+ for (const event of delivered) {
504
+ await Promise.resolve(composedPerEvent(event));
505
+ }
506
+ return userBatchSink(perEventForBatch);
507
+ } : userBatchSink;
513
508
  const next = createTrackingClientFromConfig({
514
- tracking: { ...config.tracking, sink, batchSink }
509
+ tracking: { ...normalizedConfig.tracking, sink, batchSink }
515
510
  });
516
511
  trackingRef.current = next;
517
512
  trackingClientForUnmountRef.current = next;
518
513
  setTracking(next);
519
514
  const sessionId = sessionIdRef.current;
520
515
  const cid = courseIdRef.current;
521
- const trackingActive = isTrackingActive(config.tracking);
516
+ const trackingActive = isTrackingActive(normalizedConfig.tracking);
522
517
  if (!trackingActive) {
523
518
  courseStartedEmittedToSinkRef.current = false;
524
- } else if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
525
- const emitted = emitCourseStarted({
519
+ } else if (!courseStartedEmittedToSinkRef.current) {
520
+ const emitted = emitPendingCourseStarted({
526
521
  pluginHost: pluginHostRef.current,
527
522
  tracking: next,
528
523
  xapi: xapiRef.current,
@@ -531,8 +526,12 @@ function LessonkitProvider(props) {
531
526
  courseId: cid,
532
527
  attemptId: attemptIdRef.current,
533
528
  user: userRef.current,
534
- lxpackBridge: lxpackBridgeModeRef.current
529
+ lxpackBridge: lxpackBridgeModeRef.current,
530
+ extraSinks: extraSinksRef.current
535
531
  });
532
+ if (emitted) {
533
+ markCourseStartedEmittedToTracking(defaultStorage, sessionId, cid);
534
+ }
536
535
  courseStartedEmittedToSinkRef.current = emitted;
537
536
  } else if (trackingActive) {
538
537
  courseStartedEmittedToSinkRef.current = true;
@@ -549,8 +548,8 @@ function LessonkitProvider(props) {
549
548
  batchEnabled,
550
549
  batchFlushIntervalMs,
551
550
  batchMaxBatchSize,
552
- config.plugins,
553
- config.courseId,
551
+ normalizedConfig.plugins,
552
+ normalizedCourseId,
554
553
  buildCurrentPluginCtx
555
554
  ]);
556
555
  const emitWithBridge = useCallback((trackingClient, event) => {
@@ -562,14 +561,32 @@ function LessonkitProvider(props) {
562
561
  pluginCtx: buildPluginContext({
563
562
  courseId: courseIdRef.current,
564
563
  sessionId: sessionIdRef.current,
565
- attemptId: attemptIdRef.current
564
+ attemptId: attemptIdRef.current,
565
+ user: userRef.current
566
566
  }),
567
- lxpackBridge: lxpackBridgeModeRef.current
567
+ lxpackBridge: lxpackBridgeModeRef.current,
568
+ extraSinks: extraSinksRef.current
568
569
  });
569
570
  }, []);
571
+ const emitLifecycleEvent = useCallback(
572
+ (name, data, lessonId) => {
573
+ const event = tryBuildTelemetryEvent({
574
+ name,
575
+ courseId: courseIdRef.current,
576
+ lessonId: lessonId ?? activeLessonIdRef.current,
577
+ sessionId: sessionIdRef.current,
578
+ attemptId: attemptIdRef.current,
579
+ user: userRef.current,
580
+ data
581
+ });
582
+ if (!event) return;
583
+ emitWithBridge(trackingRef.current, event);
584
+ },
585
+ [emitWithBridge]
586
+ );
570
587
  const track = useCallback(
571
588
  (name, data, opts) => {
572
- const event = tryBuildTrackEvent({
589
+ const event = tryBuildTelemetryEvent({
573
590
  name,
574
591
  courseId: courseIdRef.current,
575
592
  lessonId: opts?.lessonId ?? activeLessonIdRef.current,
@@ -587,7 +604,7 @@ function LessonkitProvider(props) {
587
604
  if (!pendingCourseIdResetRef.current) return;
588
605
  pendingCourseIdResetRef.current = false;
589
606
  syncProgress();
590
- if (!isTrackingActive(config.tracking)) return;
607
+ if (!isTrackingActive(normalizedConfig.tracking)) return;
591
608
  const sessionId = sessionIdRef.current;
592
609
  const cid = courseIdRef.current;
593
610
  void (async () => {
@@ -595,8 +612,8 @@ function LessonkitProvider(props) {
595
612
  await trackingRef.current?.flush?.();
596
613
  } catch {
597
614
  }
598
- if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
599
- const emitted = emitCourseStarted({
615
+ if (!courseStartedEmittedToSinkRef.current) {
616
+ const emitted = emitPendingCourseStarted({
600
617
  pluginHost: pluginHostRef.current,
601
618
  tracking: trackingRef.current,
602
619
  xapi: xapiRef.current,
@@ -605,12 +622,13 @@ function LessonkitProvider(props) {
605
622
  courseId: cid,
606
623
  attemptId: attemptIdRef.current,
607
624
  user: userRef.current,
608
- lxpackBridge: lxpackBridgeModeRef.current
625
+ lxpackBridge: lxpackBridgeModeRef.current,
626
+ extraSinks: extraSinksRef.current
609
627
  });
610
628
  courseStartedEmittedToSinkRef.current = emitted;
611
629
  }
612
630
  })();
613
- }, [config.courseId, config.tracking?.enabled, syncProgress]);
631
+ }, [normalizedCourseId, normalizedConfig.tracking?.enabled, syncProgress]);
614
632
  const emitLessonCompleted = useCallback(
615
633
  (lessonId, durationMs) => {
616
634
  track("lesson_completed", { lessonId, durationMs }, { lessonId });
@@ -622,20 +640,27 @@ function LessonkitProvider(props) {
622
640
  );
623
641
  const completeLesson = useCallback(
624
642
  (lessonId) => {
643
+ if (useV2Runtime && headlessRef.current) {
644
+ headlessRef.current.completeLesson(lessonId, emitLifecycleEvent);
645
+ syncProgress();
646
+ void Promise.resolve(trackingRef.current?.flush?.());
647
+ return;
648
+ }
625
649
  const result = progressRef.current.completeLesson(lessonId, Date.now());
626
650
  if (!result.didComplete) return;
627
651
  syncProgress();
628
652
  emitLessonCompleted(lessonId, result.durationMs);
629
653
  void Promise.resolve(trackingRef.current?.flush?.());
630
654
  },
631
- [syncProgress, emitLessonCompleted]
655
+ [syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
632
656
  );
633
657
  useEffect(() => {
634
658
  return () => {
635
659
  const client = trackingClientForUnmountRef.current;
660
+ const xapi2 = xapiRef.current;
636
661
  void (async () => {
637
662
  try {
638
- await xapiRef.current?.flush();
663
+ await xapi2?.flush();
639
664
  } catch {
640
665
  }
641
666
  try {
@@ -651,8 +676,19 @@ function LessonkitProvider(props) {
651
676
  }, []);
652
677
  const setActiveLesson = useCallback(
653
678
  (lessonId) => {
679
+ if (useV2Runtime && headlessRef.current) {
680
+ headlessRef.current.setActiveLesson(lessonId, emitLifecycleEvent);
681
+ syncProgress();
682
+ void Promise.resolve(trackingRef.current?.flush?.());
683
+ return;
684
+ }
654
685
  const current = progressRef.current.getState();
655
686
  if (current.activeLessonId === lessonId) return;
687
+ if (current.completedLessonIds.has(lessonId)) {
688
+ progressRef.current.setActiveLesson(lessonId, Date.now());
689
+ syncProgress();
690
+ return;
691
+ }
656
692
  const previous = current.activeLessonId;
657
693
  if (previous && previous !== lessonId) {
658
694
  const completed = progressRef.current.completeLesson(previous, Date.now());
@@ -665,32 +701,58 @@ function LessonkitProvider(props) {
665
701
  syncProgress();
666
702
  track("lesson_started", { lessonId }, { lessonId });
667
703
  },
668
- [track, syncProgress, emitLessonCompleted]
704
+ [track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
669
705
  );
670
706
  const completeCourse = useCallback(() => {
707
+ if (useV2Runtime && headlessRef.current) {
708
+ headlessRef.current.completeCourse(emitLifecycleEvent);
709
+ syncProgress();
710
+ void trackingRef.current?.flush?.();
711
+ return;
712
+ }
713
+ const current = progressRef.current.getState();
714
+ if (current.activeLessonId) {
715
+ const lessonResult = progressRef.current.completeLesson(current.activeLessonId, Date.now());
716
+ if (lessonResult.didComplete) {
717
+ emitLessonCompleted(current.activeLessonId, lessonResult.durationMs);
718
+ }
719
+ }
671
720
  const result = progressRef.current.completeCourse();
672
721
  if (!result.didComplete) return;
673
722
  syncProgress();
674
723
  track("course_completed");
675
724
  void trackingRef.current?.flush?.();
676
- }, [track, syncProgress]);
677
- const sessionUser = config.session?.user;
678
- const sessionAttemptId = config.session?.attemptId;
679
- const sessionConfiguredId = config.session?.sessionId;
725
+ }, [track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]);
726
+ const sessionUser = normalizedConfig.session?.user;
727
+ const sessionUserKey = useMemo(
728
+ () => sessionUser ? JSON.stringify(sessionUser) : "",
729
+ [sessionUser]
730
+ );
731
+ const sessionAttemptId = normalizedConfig.session?.attemptId;
732
+ const sessionConfiguredId = normalizedConfig.session?.sessionId;
733
+ useEffect(() => {
734
+ if (useV2Runtime && headlessRef.current) {
735
+ headlessRef.current.updateConfig({
736
+ courseId: normalizedCourseId,
737
+ session: normalizedConfig.session
738
+ });
739
+ }
740
+ }, [useV2Runtime, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey, normalizedConfig.session]);
680
741
  useEffect(() => {
681
742
  if (!pluginHost) return;
682
743
  const ctx = buildPluginContext({
683
744
  courseId: courseIdRef.current,
684
745
  sessionId: sessionIdRef.current,
685
- attemptId: attemptIdRef.current
746
+ attemptId: attemptIdRef.current,
747
+ user: userRef.current
686
748
  });
687
749
  pluginHost.setupAll(ctx);
688
750
  return () => {
689
751
  pluginHost.disposeAll();
690
752
  };
691
- }, [pluginHost, config.courseId, sessionAttemptId, sessionConfiguredId]);
753
+ }, [pluginHost, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey]);
692
754
  useEffect(() => {
693
- const nextConfigured = config.session?.sessionId;
755
+ const nextConfigured = normalizedConfig.session?.sessionId;
694
756
  const prevConfigured = prevConfiguredSessionIdRef.current;
695
757
  if (nextConfigured === prevConfigured) return;
696
758
  prevConfiguredSessionIdRef.current = nextConfigured;
@@ -711,10 +773,10 @@ function LessonkitProvider(props) {
711
773
  migrateCourseStartedMark(defaultStorage, prevConfigured, nextAuto, cid);
712
774
  sessionIdRef.current = nextAuto;
713
775
  }
714
- }, [sessionConfiguredId, config.courseId]);
776
+ }, [sessionConfiguredId, normalizedCourseId]);
715
777
  const runtime = useMemo(
716
778
  () => ({
717
- config,
779
+ config: normalizedConfig,
718
780
  tracking,
719
781
  xapi,
720
782
  session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
@@ -726,7 +788,7 @@ function LessonkitProvider(props) {
726
788
  plugins: pluginHost
727
789
  }),
728
790
  [
729
- config,
791
+ normalizedConfig,
730
792
  tracking,
731
793
  xapi,
732
794
  progress,
@@ -740,6 +802,14 @@ function LessonkitProvider(props) {
740
802
  sessionConfiguredId
741
803
  ]
742
804
  );
805
+ return runtime;
806
+ }
807
+
808
+ // src/context.tsx
809
+ import { jsx } from "react/jsx-runtime";
810
+ var LessonkitContext = createContext(null);
811
+ function LessonkitProvider(props) {
812
+ const runtime = useLessonkitProviderRuntime(props.config);
743
813
  return /* @__PURE__ */ jsx(LessonkitContext.Provider, { value: runtime, children: props.children });
744
814
  }
745
815
 
@@ -762,46 +832,74 @@ function useCompletion() {
762
832
  const { completeLesson, completeCourse } = useLessonkit();
763
833
  return useMemo2(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
764
834
  }
765
- function useQuizState() {
835
+ function useQuizState(enclosingLessonId) {
766
836
  const { track } = useLessonkit();
837
+ const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
767
838
  return useMemo2(
768
839
  () => ({
769
840
  answer: (opts) => {
770
- track("quiz_answered", opts);
841
+ track("quiz_answered", opts, trackOpts);
771
842
  },
772
843
  complete: (opts) => {
773
- track("quiz_completed", opts);
844
+ track("quiz_completed", opts, trackOpts);
774
845
  }
775
846
  }),
776
- [track]
847
+ [track, enclosingLessonId]
777
848
  );
778
849
  }
779
850
 
851
+ // src/lessonContext.tsx
852
+ import { createContext as createContext2, useContext as useContext2 } from "react";
853
+ var LessonContext = createContext2(void 0);
854
+ function useEnclosingLessonId() {
855
+ return useContext2(LessonContext);
856
+ }
857
+
780
858
  // src/runtime/validateComponentId.ts
781
- import { validateId } from "@lessonkit/core";
782
- var warnedPaths = /* @__PURE__ */ new Set();
783
- function isDevEnvironment2() {
859
+ import { assertValidId as assertValidId2 } from "@lessonkit/core";
860
+ function isDevEnvironment3() {
784
861
  const g = globalThis;
785
862
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
786
863
  }
787
- function warnInvalidComponentId(id, path) {
788
- if (!isDevEnvironment2()) return;
789
- const key = `${path}:${String(id)}`;
790
- if (warnedPaths.has(key)) return;
791
- const result = validateId(id, path);
792
- if (result.ok) return;
793
- warnedPaths.add(key);
794
- const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
795
- console.warn(`[lessonkit] invalid ${path} \u2014 ${detail}`);
864
+ function normalizeComponentId(id, path) {
865
+ return assertValidId2(id, path);
866
+ }
867
+
868
+ // src/runtime/lessonMountRegistry.ts
869
+ var mountCounts = /* @__PURE__ */ new Map();
870
+ var warnedConcurrentLessons = false;
871
+ function registerLessonMount(lessonId) {
872
+ if (isDevEnvironment3() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
873
+ warnedConcurrentLessons = true;
874
+ console.warn(
875
+ "[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."
876
+ );
877
+ }
878
+ mountCounts.set(lessonId, (mountCounts.get(lessonId) ?? 0) + 1);
879
+ return () => {
880
+ const next = (mountCounts.get(lessonId) ?? 1) - 1;
881
+ if (next <= 0) {
882
+ mountCounts.delete(lessonId);
883
+ } else {
884
+ mountCounts.set(lessonId, next);
885
+ }
886
+ };
887
+ }
888
+ function getLessonMountCount(lessonId) {
889
+ return mountCounts.get(lessonId) ?? 0;
796
890
  }
797
891
 
798
892
  // src/components.tsx
799
893
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
894
+ var warnedQuizOutsideLesson = false;
895
+ function resetQuizWarningsForTests() {
896
+ warnedQuizOutsideLesson = false;
897
+ }
800
898
  function Course(props) {
801
- warnInvalidComponentId(props.courseId, "courseId");
899
+ const courseId = useMemo3(() => normalizeComponentId(props.courseId, "courseId"), [props.courseId]);
802
900
  const providerConfig = useMemo3(
803
- () => ({ ...props.config, courseId: props.courseId }),
804
- [props.config, props.courseId]
901
+ () => ({ ...props.config, courseId }),
902
+ [props.config, courseId]
805
903
  );
806
904
  return /* @__PURE__ */ jsx2(LessonkitProvider, { config: providerConfig, children: /* @__PURE__ */ jsxs("section", { "aria-label": props.title, children: [
807
905
  /* @__PURE__ */ jsx2("h1", { children: props.title }),
@@ -809,41 +907,64 @@ function Course(props) {
809
907
  ] }) });
810
908
  }
811
909
  function Lesson(props) {
812
- warnInvalidComponentId(props.lessonId, "lessonId");
910
+ const lessonId = useMemo3(() => normalizeComponentId(props.lessonId, "lessonId"), [props.lessonId]);
911
+ const autoComplete = props.autoCompleteOnUnmount !== false;
813
912
  const { setActiveLesson, config } = useLessonkit();
814
913
  const { completeLesson } = useCompletion();
815
- const id = props.lessonId;
816
914
  const lessonMountGenerationRef = useRef2(0);
817
915
  useEffect2(() => {
916
+ const unregister = registerLessonMount(lessonId);
818
917
  const generation = ++lessonMountGenerationRef.current;
819
- setActiveLesson(id);
918
+ setActiveLesson(lessonId);
820
919
  return () => {
821
- const lessonId = id;
920
+ unregister();
921
+ if (getLessonMountCount(lessonId) > 0) {
922
+ return;
923
+ }
924
+ if (!autoComplete) return;
822
925
  queueMicrotask(() => {
823
926
  if (lessonMountGenerationRef.current !== generation) return;
824
927
  completeLesson(lessonId);
825
928
  });
826
929
  };
827
- }, [id, config.courseId, setActiveLesson, completeLesson]);
828
- return /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
930
+ }, [lessonId, config.courseId, setActiveLesson, completeLesson, autoComplete]);
931
+ return /* @__PURE__ */ jsx2(LessonContext.Provider, { value: lessonId, children: /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
829
932
  /* @__PURE__ */ jsx2("h2", { children: props.title }),
830
933
  /* @__PURE__ */ jsx2("div", { children: props.children })
831
- ] });
934
+ ] }) });
832
935
  }
833
936
  function Scenario(props) {
834
- if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
835
- return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", "data-lk-block-id": props.blockId, children: props.children });
937
+ const blockId = useMemo3(
938
+ () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
939
+ [props.blockId]
940
+ );
941
+ return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", "data-lk-block-id": blockId, children: props.children });
836
942
  }
837
943
  function Reflection(props) {
838
- if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
944
+ const blockId = useMemo3(
945
+ () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
946
+ [props.blockId]
947
+ );
839
948
  const promptId = useId();
840
- return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", "data-lk-block-id": props.blockId, children: [
949
+ const hintId = useId();
950
+ const [internalValue, setInternalValue] = useState2("");
951
+ const isControlled = props.value !== void 0;
952
+ const value = isControlled ? props.value : internalValue;
953
+ const handleChange = (event) => {
954
+ if (!isControlled) setInternalValue(event.target.value);
955
+ props.onChange?.(event.target.value);
956
+ };
957
+ return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", "data-lk-block-id": blockId, children: [
841
958
  props.prompt ? /* @__PURE__ */ jsx2("p", { id: promptId, children: props.prompt }) : null,
959
+ props.hint ? /* @__PURE__ */ jsx2("p", { id: hintId, style: visuallyHiddenStyle, children: props.hint }) : null,
842
960
  props.children,
843
961
  /* @__PURE__ */ jsx2(
844
962
  "textarea",
845
963
  {
964
+ value,
965
+ onChange: handleChange,
846
966
  "aria-labelledby": props.prompt ? promptId : void 0,
967
+ "aria-describedby": props.hint ? hintId : void 0,
847
968
  "aria-label": props.prompt ? void 0 : "Reflection response"
848
969
  }
849
970
  )
@@ -856,23 +977,41 @@ function KnowledgeCheck(props) {
856
977
  checkId: props.checkId,
857
978
  question: props.question,
858
979
  choices: props.choices,
859
- answer: props.answer
980
+ answer: props.answer,
981
+ passingScore: props.passingScore
860
982
  }
861
983
  );
862
984
  }
863
985
  function Quiz(props) {
864
- warnInvalidComponentId(props.checkId, "checkId");
865
- const quiz = useQuizState();
866
- const { plugins, config, progress, session } = useLessonkit();
986
+ const checkId = useMemo3(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
987
+ const enclosingLessonId = useEnclosingLessonId();
988
+ const missingLesson = enclosingLessonId === void 0;
989
+ useEffect2(() => {
990
+ if (!missingLesson || isDevEnvironment3()) return;
991
+ if (!warnedQuizOutsideLesson) {
992
+ warnedQuizOutsideLesson = true;
993
+ console.error(
994
+ "[lessonkit] <Quiz> must be wrapped in <Lesson>; quiz telemetry will not be emitted."
995
+ );
996
+ }
997
+ }, [missingLesson]);
998
+ if (missingLesson && isDevEnvironment3()) {
999
+ throw new Error("[lessonkit] <Quiz> must be wrapped in <Lesson>");
1000
+ }
1001
+ const quiz = useQuizState(enclosingLessonId);
1002
+ const { plugins, config, session } = useLessonkit();
867
1003
  const [selected, setSelected] = useState2(null);
868
1004
  const [selectionCorrect, setSelectionCorrect] = useState2(null);
1005
+ const [quizPassed, setQuizPassed] = useState2(false);
869
1006
  const completedRef = useRef2(false);
870
1007
  const questionId = useId();
1008
+ const choicesKey = props.choices.join("\0");
871
1009
  useEffect2(() => {
872
1010
  completedRef.current = false;
1011
+ setQuizPassed(false);
873
1012
  setSelected(null);
874
1013
  setSelectionCorrect(null);
875
- }, [props.checkId, props.answer, props.question]);
1014
+ }, [checkId, props.answer, props.question, config.courseId, enclosingLessonId, choicesKey]);
876
1015
  const isChoiceCorrect = (choice, custom) => {
877
1016
  if (!custom) return choice === props.answer;
878
1017
  if (custom.passed !== void 0) return custom.passed;
@@ -881,7 +1020,11 @@ function Quiz(props) {
881
1020
  }
882
1021
  return choice === props.answer;
883
1022
  };
884
- return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
1023
+ if (missingLesson) {
1024
+ 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." }) });
1025
+ }
1026
+ const passed = quizPassed;
1027
+ return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": checkId, children: [
885
1028
  /* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
886
1029
  /* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
887
1030
  /* @__PURE__ */ jsx2("legend", { style: visuallyHiddenStyle, children: "Quiz choices" }),
@@ -893,17 +1036,21 @@ function Quiz(props) {
893
1036
  name: questionId,
894
1037
  value: c,
895
1038
  checked: selected === c,
1039
+ disabled: passed,
1040
+ "aria-invalid": selected === c && selectionCorrect === false ? true : void 0,
896
1041
  onChange: () => {
1042
+ if (passed) return;
897
1043
  setSelected(c);
898
1044
  const pluginCtx = buildPluginContext({
899
1045
  courseId: config.courseId,
900
1046
  sessionId: session.sessionId,
901
- attemptId: session.attemptId
1047
+ attemptId: session.attemptId,
1048
+ user: session.user
902
1049
  });
903
1050
  const custom = plugins?.scoreAssessment(
904
1051
  {
905
- checkId: props.checkId,
906
- lessonId: progress.activeLessonId,
1052
+ checkId,
1053
+ lessonId: enclosingLessonId,
907
1054
  response: c
908
1055
  },
909
1056
  pluginCtx
@@ -911,18 +1058,20 @@ function Quiz(props) {
911
1058
  const correct = isChoiceCorrect(c, custom);
912
1059
  setSelectionCorrect(correct);
913
1060
  quiz.answer({
914
- checkId: props.checkId,
1061
+ checkId,
915
1062
  question: props.question,
916
1063
  choice: c,
917
1064
  correct
918
1065
  });
919
1066
  if (correct && !completedRef.current) {
920
1067
  completedRef.current = true;
1068
+ setQuizPassed(true);
1069
+ const maxScore = custom?.maxScore ?? 1;
921
1070
  quiz.complete({
922
- checkId: props.checkId,
1071
+ checkId,
923
1072
  score: custom?.score ?? 1,
924
- maxScore: custom?.maxScore ?? 1,
925
- passingScore: 1
1073
+ maxScore,
1074
+ passingScore: props.passingScore ?? maxScore
926
1075
  });
927
1076
  }
928
1077
  }
@@ -934,23 +1083,51 @@ function Quiz(props) {
934
1083
  selected && selectionCorrect !== null ? /* @__PURE__ */ jsx2("p", { role: "status", "aria-live": "polite", children: selectionCorrect ? "Correct" : "Try again" }) : null
935
1084
  ] });
936
1085
  }
937
- function ProgressTracker() {
1086
+ function ProgressTracker(props) {
938
1087
  const { progress } = useLessonkit();
939
1088
  const completed = progress.completedLessonIds.size;
940
- return /* @__PURE__ */ jsx2("aside", { "aria-label": "Progress", children: /* @__PURE__ */ jsxs("p", { children: [
1089
+ if (props.totalLessons != null) {
1090
+ const total = props.totalLessons;
1091
+ const displayed = Math.min(completed, total);
1092
+ return /* @__PURE__ */ jsx2("aside", { "aria-label": "Progress", children: /* @__PURE__ */ jsx2(
1093
+ "div",
1094
+ {
1095
+ role: "progressbar",
1096
+ "aria-valuemin": 0,
1097
+ "aria-valuemax": total,
1098
+ "aria-valuenow": displayed,
1099
+ "aria-label": "Lessons completed",
1100
+ children: /* @__PURE__ */ jsxs("p", { children: [
1101
+ "Lessons completed: ",
1102
+ displayed,
1103
+ " of ",
1104
+ total
1105
+ ] })
1106
+ }
1107
+ ) });
1108
+ }
1109
+ return /* @__PURE__ */ jsx2("aside", { "aria-label": "Progress", role: "status", children: /* @__PURE__ */ jsxs("p", { children: [
941
1110
  "Lessons completed: ",
942
1111
  completed
943
1112
  ] }) });
944
1113
  }
945
1114
 
946
1115
  // src/index.tsx
947
- import { createPluginHost as createPluginHost2, defineLessonkitPlugin } from "@lessonkit/core";
1116
+ import {
1117
+ buildTelemetryEvent as buildTelemetryEvent2,
1118
+ createLessonkitRuntime as createLessonkitRuntime2,
1119
+ createPluginRegistry as createPluginRegistry2,
1120
+ createTelemetryPipeline as createTelemetryPipeline2,
1121
+ defineAssessmentPlugin,
1122
+ defineLifecyclePlugin,
1123
+ defineTelemetryPlugin
1124
+ } from "@lessonkit/core";
948
1125
 
949
1126
  // src/theme/ThemeProvider.tsx
950
1127
  import React3, {
951
- createContext as createContext2,
1128
+ createContext as createContext3,
952
1129
  useCallback as useCallback2,
953
- useContext as useContext2,
1130
+ useContext as useContext3,
954
1131
  useLayoutEffect as useLayoutEffect2,
955
1132
  useMemo as useMemo4,
956
1133
  useRef as useRef3,
@@ -982,7 +1159,7 @@ function applyCssVariables(target, vars, previousKeys) {
982
1159
 
983
1160
  // src/theme/ThemeProvider.tsx
984
1161
  import { jsx as jsx3 } from "react/jsx-runtime";
985
- var ThemeContext = createContext2(null);
1162
+ var ThemeContext = createContext3(null);
986
1163
  var useIsoLayoutEffect2 = typeof window !== "undefined" ? useLayoutEffect2 : React3.useEffect;
987
1164
  function getSystemMode() {
988
1165
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
@@ -1062,7 +1239,7 @@ function ThemeProvider(props) {
1062
1239
  return /* @__PURE__ */ jsx3(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx3("div", { ref: hostRef, "data-lk-theme": dataTheme, children: props.children }) });
1063
1240
  }
1064
1241
  function useTheme() {
1065
- const ctx = useContext2(ThemeContext);
1242
+ const ctx = useContext3(ThemeContext);
1066
1243
  if (!ctx) {
1067
1244
  throw new Error("useTheme must be used within a ThemeProvider");
1068
1245
  }
@@ -1109,6 +1286,12 @@ var BLOCK_CATALOG = [
1109
1286
  props: [
1110
1287
  { name: "title", type: "string", required: true, description: "Lesson title shown in the h2." },
1111
1288
  { name: "lessonId", type: "LessonId", required: true, description: "Stable lesson identifier for telemetry and packaging." },
1289
+ {
1290
+ name: "autoCompleteOnUnmount",
1291
+ type: "boolean",
1292
+ required: false,
1293
+ description: "When false, unmount does not emit lesson_completed (default true)."
1294
+ },
1112
1295
  { name: "children", type: "ReactNode", required: true, description: "Scenario, Quiz, Reflection, and other blocks." }
1113
1296
  ],
1114
1297
  requiredIds: ["lessonId"],
@@ -1161,6 +1344,9 @@ var BLOCK_CATALOG = [
1161
1344
  props: [
1162
1345
  { name: "blockId", type: "BlockId", required: false, description: "Optional stable block id for interaction telemetry URNs." },
1163
1346
  { name: "prompt", type: "string", required: false, description: "Reflection question or instruction." },
1347
+ { name: "hint", type: "string", required: false, description: "Optional hint linked via aria-describedby." },
1348
+ { name: "value", type: "string", required: false, description: "Controlled textarea value." },
1349
+ { name: "onChange", type: "(value: string) => void", required: false, description: "Called when the learner edits the textarea." },
1164
1350
  { name: "children", type: "ReactNode", required: false, description: "Optional content above the textarea." }
1165
1351
  ],
1166
1352
  requiredIds: [],
@@ -1179,6 +1365,7 @@ var BLOCK_CATALOG = [
1179
1365
  },
1180
1366
  telemetry: {
1181
1367
  emits: [],
1368
+ requiresActiveLesson: true,
1182
1369
  manualTracking: "useTracking().track('interaction', { kind, blockId, payload }) on submit or blur"
1183
1370
  }
1184
1371
  },
@@ -1216,7 +1403,14 @@ var BLOCK_CATALOG = [
1216
1403
  type: "ProgressTracker",
1217
1404
  category: "chrome",
1218
1405
  description: "Displays count of completed lessons from runtime progress state.",
1219
- props: [],
1406
+ props: [
1407
+ {
1408
+ name: "totalLessons",
1409
+ type: "number",
1410
+ required: false,
1411
+ description: "When set, renders role=progressbar with aria-valuenow/max."
1412
+ }
1413
+ ],
1220
1414
  requiredIds: [],
1221
1415
  parentConstraints: ["Course"],
1222
1416
  a11y: {
@@ -1268,9 +1462,15 @@ export {
1268
1462
  ThemeProvider,
1269
1463
  blockCatalogVersion,
1270
1464
  buildBlockCatalog,
1271
- createPluginHost2 as createPluginHost,
1272
- defineLessonkitPlugin,
1465
+ buildTelemetryEvent2 as buildTelemetryEvent,
1466
+ createLessonkitRuntime2 as createLessonkitRuntime,
1467
+ createPluginRegistry2 as createPluginRegistry,
1468
+ createTelemetryPipeline2 as createTelemetryPipeline,
1469
+ defineAssessmentPlugin,
1470
+ defineLifecyclePlugin,
1471
+ defineTelemetryPlugin,
1273
1472
  getBlockCatalogEntry,
1473
+ resetQuizWarningsForTests,
1274
1474
  useCompletion,
1275
1475
  useLessonkit,
1276
1476
  useProgress,