@lessonkit/react 0.4.0 → 0.6.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.cjs CHANGED
@@ -54,8 +54,153 @@ var import_accessibility = require("@lessonkit/accessibility");
54
54
 
55
55
  // src/context.tsx
56
56
  var import_react = require("react");
57
- var import_core2 = require("@lessonkit/core");
58
- var import_xapi2 = require("@lessonkit/xapi");
57
+ var import_core3 = require("@lessonkit/core");
58
+ var import_xapi3 = require("@lessonkit/xapi");
59
+ var import_xapi4 = require("@lessonkit/xapi");
60
+
61
+ // src/runtime/emitTelemetry.ts
62
+ var import_core = require("@lessonkit/core");
63
+ var import_xapi = require("@lessonkit/xapi");
64
+
65
+ // src/runtime/lxpackBridge.ts
66
+ var import_bridge = require("@lessonkit/lxpack/bridge");
67
+ function getBridge() {
68
+ if (typeof window === "undefined") return null;
69
+ const parent = window.parent;
70
+ if (!parent || parent === window) return null;
71
+ return parent.lxpackBridge?.v1 ?? parent.lxpack ?? null;
72
+ }
73
+ function forwardTelemetryToLxpack(event, mode = "auto") {
74
+ if (mode === "off") return;
75
+ const bridge = getBridge();
76
+ if (!bridge) return;
77
+ switch (event.name) {
78
+ case "lesson_completed": {
79
+ const lessonId = event.lessonId;
80
+ if (lessonId) bridge.completeLesson?.(lessonId);
81
+ return;
82
+ }
83
+ case "course_completed":
84
+ bridge.completeCourse?.();
85
+ return;
86
+ case "quiz_completed": {
87
+ const data = event.data;
88
+ if (!data?.checkId) return;
89
+ const scaled = (0, import_bridge.normalizeAssessmentScore)({
90
+ score: data.score,
91
+ maxScore: data.maxScore
92
+ });
93
+ if (scaled === null) return;
94
+ bridge.submitAssessment?.({
95
+ id: data.checkId,
96
+ score: scaled,
97
+ passingScore: (0, import_bridge.normalizeAssessmentPassingScore)(data.passingScore)
98
+ });
99
+ return;
100
+ }
101
+ default:
102
+ return;
103
+ }
104
+ }
105
+
106
+ // src/runtime/emitTelemetry.ts
107
+ var warnedMissingCourseId = false;
108
+ var warnedMissingQuizLesson = false;
109
+ function isDevEnvironment() {
110
+ const g = globalThis;
111
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
112
+ }
113
+ function emitTelemetry(tracking, xapi, event, opts) {
114
+ if (!event.courseId) {
115
+ if (isDevEnvironment() && !warnedMissingCourseId) {
116
+ warnedMissingCourseId = true;
117
+ console.warn("[lessonkit] telemetry event missing courseId");
118
+ }
119
+ return;
120
+ }
121
+ tracking.track(event);
122
+ try {
123
+ const statement = (0, import_xapi.telemetryEventToXAPIStatement)(event);
124
+ if (statement) xapi?.send(statement);
125
+ } catch (err) {
126
+ if (isDevEnvironment()) {
127
+ console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
128
+ }
129
+ }
130
+ forwardTelemetryToLxpack(event, opts?.lxpackBridge ?? "auto");
131
+ }
132
+ function buildTrackEvent(opts) {
133
+ const base = {
134
+ timestamp: (0, import_core.nowIso)(),
135
+ courseId: opts.courseId,
136
+ sessionId: opts.sessionId,
137
+ attemptId: opts.attemptId,
138
+ user: opts.user
139
+ };
140
+ switch (opts.name) {
141
+ case "course_started":
142
+ return { name: "course_started", ...base };
143
+ case "course_completed":
144
+ return { name: "course_completed", ...base };
145
+ case "lesson_started": {
146
+ const data = opts.data;
147
+ const lessonId = opts.lessonId ?? data?.lessonId;
148
+ if (!lessonId) throw new Error("lesson_started requires lessonId");
149
+ return {
150
+ name: "lesson_started",
151
+ ...base,
152
+ lessonId,
153
+ data: { ...data, lessonId }
154
+ };
155
+ }
156
+ case "lesson_completed":
157
+ case "lesson_time_on_task": {
158
+ const data = opts.data;
159
+ const lessonId = opts.lessonId ?? data?.lessonId;
160
+ if (!lessonId) throw new Error(`${opts.name} requires lessonId`);
161
+ return {
162
+ name: opts.name,
163
+ ...base,
164
+ lessonId,
165
+ data: { ...data, lessonId }
166
+ };
167
+ }
168
+ case "quiz_answered": {
169
+ const data = opts.data;
170
+ const lessonId = opts.lessonId;
171
+ if (!lessonId) throw new Error("quiz_answered requires active lessonId");
172
+ return { name: "quiz_answered", ...base, lessonId, data };
173
+ }
174
+ case "quiz_completed": {
175
+ const data = opts.data;
176
+ const lessonId = opts.lessonId;
177
+ if (!lessonId) throw new Error("quiz_completed requires active lessonId");
178
+ return { name: "quiz_completed", ...base, lessonId, data };
179
+ }
180
+ case "interaction":
181
+ return {
182
+ name: "interaction",
183
+ ...base,
184
+ lessonId: opts.lessonId,
185
+ data: opts.data
186
+ };
187
+ default:
188
+ return { name: opts.name, ...base };
189
+ }
190
+ }
191
+ function tryBuildTrackEvent(opts) {
192
+ const isQuiz = opts.name === "quiz_answered" || opts.name === "quiz_completed";
193
+ if (isQuiz && !opts.lessonId) {
194
+ if (isDevEnvironment() && !warnedMissingQuizLesson) {
195
+ warnedMissingQuizLesson = true;
196
+ console.warn(
197
+ `[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
198
+ );
199
+ }
200
+ return null;
201
+ }
202
+ return buildTrackEvent(opts);
203
+ }
59
204
 
60
205
  // src/runtime/ports.ts
61
206
  function createNoopStorage() {
@@ -84,24 +229,62 @@ function createSessionStoragePort() {
84
229
  };
85
230
  }
86
231
 
232
+ // src/runtime/progress.ts
233
+ function createProgressController() {
234
+ let activeLessonId;
235
+ let completedLessonIds = /* @__PURE__ */ new Set();
236
+ let courseCompleted = false;
237
+ const lessonStartTimes = /* @__PURE__ */ new Map();
238
+ return {
239
+ getState: () => ({
240
+ activeLessonId,
241
+ completedLessonIds: new Set(completedLessonIds),
242
+ courseCompleted
243
+ }),
244
+ setActiveLesson: (lessonId, startedAtMs) => {
245
+ const previousLessonId = activeLessonId;
246
+ activeLessonId = lessonId;
247
+ lessonStartTimes.set(lessonId, startedAtMs);
248
+ return { previousLessonId };
249
+ },
250
+ completeLesson: (lessonId, completedAtMs) => {
251
+ if (completedLessonIds.has(lessonId)) return { didComplete: false };
252
+ completedLessonIds = new Set(completedLessonIds).add(lessonId);
253
+ const startedAt = lessonStartTimes.get(lessonId);
254
+ lessonStartTimes.delete(lessonId);
255
+ const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
256
+ return { durationMs, didComplete: true };
257
+ },
258
+ completeCourse: () => {
259
+ if (courseCompleted) return { didComplete: false };
260
+ courseCompleted = true;
261
+ return { didComplete: true };
262
+ }
263
+ };
264
+ }
265
+
87
266
  // src/runtime/xapi.ts
88
- var import_xapi = require("@lessonkit/xapi");
267
+ var import_xapi2 = require("@lessonkit/xapi");
89
268
  function createXapiClientFromConfig(config, queue) {
90
269
  if (config.xapi?.enabled === false) return null;
91
270
  if (config.xapi?.client) return config.xapi.client;
92
- const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
93
- return (0, import_xapi.createXAPIClient)({ baseId, transport: config.xapi?.transport, queue });
271
+ if (!config.courseId) return null;
272
+ return (0, import_xapi2.createXAPIClient)({
273
+ courseId: config.courseId,
274
+ transport: config.xapi?.transport,
275
+ queue
276
+ });
94
277
  }
95
278
 
96
279
  // src/runtime/session.ts
97
- var import_core = require("@lessonkit/core");
280
+ var import_core2 = require("@lessonkit/core");
98
281
  var SESSION_STORAGE_KEY = "lessonkit:sessionId";
99
282
  var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
100
283
  function resolveSessionId(storage, provided) {
101
284
  if (provided) return provided;
102
285
  const existing = storage.getItem(SESSION_STORAGE_KEY);
103
286
  if (existing) return existing;
104
- const id = (0, import_core.createSessionId)();
287
+ const id = (0, import_core2.createSessionId)();
105
288
  storage.setItem(SESSION_STORAGE_KEY, id);
106
289
  return id;
107
290
  }
@@ -128,16 +311,16 @@ function disposeTrackingClient(client) {
128
311
  var defaultStorage = createSessionStoragePort();
129
312
  function createTrackingClientFromConfig(config) {
130
313
  if (config.tracking?.enabled === false) {
131
- return (0, import_core2.createTrackingClient)();
314
+ return (0, import_core3.createTrackingClient)();
132
315
  }
133
- return (0, import_core2.createTrackingClient)({
316
+ return (0, import_core3.createTrackingClient)({
134
317
  sink: config.tracking?.sink,
135
318
  batchSink: config.tracking?.batchSink,
136
319
  batch: config.tracking?.batch
137
320
  });
138
321
  }
139
322
  function LessonkitProvider(props) {
140
- const config = props.config ?? {};
323
+ const config = props.config;
141
324
  const sessionIdRef = (0, import_react.useRef)(resolveSessionId(defaultStorage, config.session?.sessionId));
142
325
  if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
143
326
  const attemptIdRef = (0, import_react.useRef)(config.session?.attemptId);
@@ -146,50 +329,16 @@ function LessonkitProvider(props) {
146
329
  userRef.current = config.session?.user;
147
330
  const courseIdRef = (0, import_react.useRef)(config.courseId);
148
331
  courseIdRef.current = config.courseId;
149
- const trackingRef = (0, import_react.useRef)((0, import_core2.createTrackingClient)());
150
- const [tracking, setTracking] = (0, import_react.useState)(() => trackingRef.current);
151
- const courseStartedInProviderRef = (0, import_react.useRef)(false);
152
- const trackingEnabled = config.tracking?.enabled;
153
- const trackingSink = config.tracking?.sink;
154
- const trackingBatchSink = config.tracking?.batchSink;
155
- const batchEnabled = config.tracking?.batch?.enabled;
156
- const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
157
- const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
158
- useIsoLayoutEffect(() => {
159
- const prev = trackingRef.current;
160
- const next = createTrackingClientFromConfig(config);
161
- trackingRef.current = next;
162
- setTracking(next);
163
- const sessionId = sessionIdRef.current;
164
- const cid = courseIdRef.current;
165
- const shouldEmitCourseStarted = cid ? !hasCourseStarted(defaultStorage, sessionId, cid) : !courseStartedInProviderRef.current;
166
- if (shouldEmitCourseStarted) {
167
- if (cid) {
168
- markCourseStarted(defaultStorage, sessionId, cid);
169
- } else {
170
- courseStartedInProviderRef.current = true;
171
- }
172
- next.track({
173
- name: "course_started",
174
- timestamp: (0, import_core2.nowIso)(),
175
- courseId: cid,
176
- sessionId,
177
- attemptId: attemptIdRef.current,
178
- user: userRef.current
179
- });
180
- }
181
- return () => {
182
- disposeTrackingClient(prev);
183
- };
184
- }, [
185
- trackingEnabled,
186
- trackingSink,
187
- trackingBatchSink,
188
- batchEnabled,
189
- batchFlushIntervalMs,
190
- batchMaxBatchSize
191
- ]);
192
- const xapiQueueRef = (0, import_react.useRef)((0, import_xapi2.createInMemoryXAPIQueue)());
332
+ const lxpackBridgeModeRef = (0, import_react.useRef)(config.lxpack?.bridge ?? "auto");
333
+ lxpackBridgeModeRef.current = config.lxpack?.bridge ?? "auto";
334
+ const progressRef = (0, import_react.useRef)(createProgressController());
335
+ const [progress, setProgress] = (0, import_react.useState)(() => progressRef.current.getState());
336
+ const syncProgress = (0, import_react.useCallback)(() => {
337
+ setProgress(progressRef.current.getState());
338
+ }, []);
339
+ const activeLessonIdRef = (0, import_react.useRef)(progress.activeLessonId);
340
+ activeLessonIdRef.current = progress.activeLessonId;
341
+ const xapiQueueRef = (0, import_react.useRef)((0, import_xapi3.createInMemoryXAPIQueue)());
193
342
  const xapiRef = (0, import_react.useRef)(null);
194
343
  const [xapi, setXapi] = (0, import_react.useState)(null);
195
344
  const xapiEnabled = config.xapi?.enabled;
@@ -201,6 +350,25 @@ function LessonkitProvider(props) {
201
350
  const next = createXapiClientFromConfig(config, xapiQueueRef.current);
202
351
  xapiRef.current = next;
203
352
  setXapi(next);
353
+ if (next && !prev) {
354
+ const sessionId = sessionIdRef.current;
355
+ const cid = courseIdRef.current;
356
+ if (hasCourseStarted(defaultStorage, sessionId, cid)) {
357
+ try {
358
+ const statement = (0, import_xapi4.telemetryEventToXAPIStatement)(
359
+ buildTrackEvent({
360
+ name: "course_started",
361
+ courseId: cid,
362
+ sessionId,
363
+ attemptId: attemptIdRef.current,
364
+ user: userRef.current
365
+ })
366
+ );
367
+ if (statement) next.send(statement);
368
+ } catch {
369
+ }
370
+ }
371
+ }
204
372
  void (async () => {
205
373
  if (prev) {
206
374
  try {
@@ -217,21 +385,59 @@ function LessonkitProvider(props) {
217
385
  void prev?.flush();
218
386
  };
219
387
  }, [xapiEnabled, xapiClient, xapiTransport, courseId]);
220
- const [completedLessonIds, setCompletedLessonIds] = (0, import_react.useState)(() => /* @__PURE__ */ new Set());
221
- const completedLessonIdsRef = (0, import_react.useRef)(completedLessonIds);
222
- completedLessonIdsRef.current = completedLessonIds;
223
- const [activeLessonId, setActiveLessonId] = (0, import_react.useState)(void 0);
224
- const [courseCompleted, setCourseCompleted] = (0, import_react.useState)(false);
225
- const courseCompletedRef = (0, import_react.useRef)(false);
226
- courseCompletedRef.current = courseCompleted;
227
- const activeLessonIdRef = (0, import_react.useRef)(void 0);
228
- activeLessonIdRef.current = activeLessonId;
229
- const lessonStartTimesRef = (0, import_react.useRef)(/* @__PURE__ */ new Map());
388
+ const trackingRef = (0, import_react.useRef)((0, import_core3.createTrackingClient)());
389
+ const [tracking, setTracking] = (0, import_react.useState)(() => trackingRef.current);
390
+ const trackingEnabled = config.tracking?.enabled;
391
+ const trackingSink = config.tracking?.sink;
392
+ const trackingBatchSink = config.tracking?.batchSink;
393
+ const batchEnabled = config.tracking?.batch?.enabled;
394
+ const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
395
+ const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
396
+ useIsoLayoutEffect(() => {
397
+ const prev = trackingRef.current;
398
+ const next = createTrackingClientFromConfig(config);
399
+ trackingRef.current = next;
400
+ setTracking(next);
401
+ const sessionId = sessionIdRef.current;
402
+ const cid = courseIdRef.current;
403
+ if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
404
+ markCourseStarted(defaultStorage, sessionId, cid);
405
+ emitTelemetry(
406
+ next,
407
+ xapiRef.current,
408
+ buildTrackEvent({
409
+ name: "course_started",
410
+ courseId: cid,
411
+ sessionId,
412
+ attemptId: attemptIdRef.current,
413
+ user: userRef.current
414
+ }),
415
+ { lxpackBridge: lxpackBridgeModeRef.current }
416
+ );
417
+ }
418
+ return () => {
419
+ disposeTrackingClient(prev);
420
+ };
421
+ }, [
422
+ trackingEnabled,
423
+ trackingSink,
424
+ trackingBatchSink,
425
+ batchEnabled,
426
+ batchFlushIntervalMs,
427
+ batchMaxBatchSize
428
+ ]);
429
+ const emitWithBridge = (0, import_react.useCallback)(
430
+ (trackingClient, event) => {
431
+ emitTelemetry(trackingClient, xapiRef.current, event, {
432
+ lxpackBridge: lxpackBridgeModeRef.current
433
+ });
434
+ },
435
+ []
436
+ );
230
437
  const track = (0, import_react.useCallback)(
231
438
  (name, data, opts) => {
232
- trackingRef.current?.track({
439
+ const event = tryBuildTrackEvent({
233
440
  name,
234
- timestamp: (0, import_core2.nowIso)(),
235
441
  courseId: courseIdRef.current,
236
442
  lessonId: opts?.lessonId ?? activeLessonIdRef.current,
237
443
  sessionId: sessionIdRef.current,
@@ -239,54 +445,85 @@ function LessonkitProvider(props) {
239
445
  user: userRef.current,
240
446
  data
241
447
  });
448
+ if (!event) return;
449
+ emitWithBridge(trackingRef.current, event);
242
450
  },
243
- []
451
+ [emitWithBridge]
244
452
  );
453
+ const prevCourseIdRef = (0, import_react.useRef)(config.courseId);
454
+ (0, import_react.useEffect)(() => {
455
+ if (prevCourseIdRef.current === config.courseId) return;
456
+ prevCourseIdRef.current = config.courseId;
457
+ progressRef.current = createProgressController();
458
+ syncProgress();
459
+ const sessionId = sessionIdRef.current;
460
+ const cid = config.courseId;
461
+ if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
462
+ markCourseStarted(defaultStorage, sessionId, cid);
463
+ emitTelemetry(
464
+ trackingRef.current,
465
+ xapiRef.current,
466
+ buildTrackEvent({
467
+ name: "course_started",
468
+ courseId: cid,
469
+ sessionId,
470
+ attemptId: attemptIdRef.current,
471
+ user: userRef.current
472
+ }),
473
+ { lxpackBridge: lxpackBridgeModeRef.current }
474
+ );
475
+ }
476
+ }, [config.courseId, syncProgress]);
245
477
  (0, import_react.useEffect)(() => {
246
478
  return () => {
247
479
  trackingRef.current?.flush?.();
248
480
  void xapiRef.current?.flush();
249
481
  };
250
482
  }, []);
251
- const setActiveLesson = (0, import_react.useCallback)((lessonId) => {
252
- if (activeLessonIdRef.current === lessonId) return;
253
- activeLessonIdRef.current = lessonId;
254
- setActiveLessonId(lessonId);
255
- lessonStartTimesRef.current.set(lessonId, Date.now());
256
- track("lesson_started", { lessonId }, { lessonId });
257
- xapiRef.current?.startedLesson({ lessonId });
258
- }, [track]);
259
- const completeLesson = (0, import_react.useCallback)(
260
- (lessonId) => {
261
- if (completedLessonIdsRef.current.has(lessonId)) return;
262
- completedLessonIdsRef.current = new Set(completedLessonIdsRef.current).add(lessonId);
263
- setCompletedLessonIds(completedLessonIdsRef.current);
264
- const startedAt = lessonStartTimesRef.current.get(lessonId);
265
- lessonStartTimesRef.current.delete(lessonId);
266
- const durationMs = typeof startedAt === "number" ? Math.max(0, Date.now() - startedAt) : void 0;
483
+ const emitLessonCompleted = (0, import_react.useCallback)(
484
+ (lessonId, durationMs) => {
267
485
  track("lesson_completed", { lessonId, durationMs }, { lessonId });
268
486
  if (durationMs !== void 0) {
269
487
  track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
270
488
  }
271
- xapiRef.current?.completeLesson({ lessonId, durationMs });
272
489
  },
273
490
  [track]
274
491
  );
492
+ const completeLesson = (0, import_react.useCallback)(
493
+ (lessonId) => {
494
+ const result = progressRef.current.completeLesson(lessonId, Date.now());
495
+ if (!result.didComplete) return;
496
+ syncProgress();
497
+ emitLessonCompleted(lessonId, result.durationMs);
498
+ },
499
+ [syncProgress, emitLessonCompleted]
500
+ );
501
+ const setActiveLesson = (0, import_react.useCallback)(
502
+ (lessonId) => {
503
+ const current = progressRef.current.getState();
504
+ if (current.activeLessonId === lessonId) return;
505
+ const previous = current.activeLessonId;
506
+ if (previous && previous !== lessonId) {
507
+ const completed = progressRef.current.completeLesson(previous, Date.now());
508
+ if (completed.didComplete) {
509
+ emitLessonCompleted(previous, completed.durationMs);
510
+ }
511
+ }
512
+ progressRef.current.setActiveLesson(lessonId, Date.now());
513
+ syncProgress();
514
+ track("lesson_started", { lessonId }, { lessonId });
515
+ },
516
+ [track, syncProgress, emitLessonCompleted]
517
+ );
275
518
  const completeCourse = (0, import_react.useCallback)(() => {
276
- if (courseCompletedRef.current) return;
277
- courseCompletedRef.current = true;
278
- setCourseCompleted(true);
519
+ const result = progressRef.current.completeCourse();
520
+ if (!result.didComplete) return;
521
+ syncProgress();
279
522
  track("course_completed");
280
- xapiRef.current?.completeCourse();
281
- }, [track]);
282
- const progress = (0, import_react.useMemo)(
283
- () => ({
284
- activeLessonId,
285
- completedLessonIds: new Set(completedLessonIds),
286
- courseCompleted
287
- }),
288
- [activeLessonId, completedLessonIds, courseCompleted]
289
- );
523
+ }, [track, syncProgress]);
524
+ const sessionUser = config.session?.user;
525
+ const sessionAttemptId = config.session?.attemptId;
526
+ const sessionConfiguredId = config.session?.sessionId;
290
527
  const runtime = (0, import_react.useMemo)(
291
528
  () => ({
292
529
  config,
@@ -299,7 +536,19 @@ function LessonkitProvider(props) {
299
536
  completeCourse,
300
537
  track
301
538
  }),
302
- [config, tracking, xapi, progress, setActiveLesson, completeLesson, completeCourse, track]
539
+ [
540
+ config,
541
+ tracking,
542
+ xapi,
543
+ progress,
544
+ setActiveLesson,
545
+ completeLesson,
546
+ completeCourse,
547
+ track,
548
+ sessionUser,
549
+ sessionAttemptId,
550
+ sessionConfiguredId
551
+ ]
303
552
  );
304
553
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LessonkitContext.Provider, { value: runtime, children: props.children });
305
554
  }
@@ -338,9 +587,28 @@ function useQuizState() {
338
587
  );
339
588
  }
340
589
 
590
+ // src/runtime/validateComponentId.ts
591
+ var import_core4 = require("@lessonkit/core");
592
+ var warnedPaths = /* @__PURE__ */ new Set();
593
+ function isDevEnvironment2() {
594
+ const g = globalThis;
595
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
596
+ }
597
+ function warnInvalidComponentId(id, path) {
598
+ if (!isDevEnvironment2()) return;
599
+ const key = `${path}:${String(id)}`;
600
+ if (warnedPaths.has(key)) return;
601
+ const result = (0, import_core4.validateId)(id, path);
602
+ if (result.ok) return;
603
+ warnedPaths.add(key);
604
+ const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
605
+ console.warn(`[lessonkit] invalid ${path} \u2014 ${detail}`);
606
+ }
607
+
341
608
  // src/components.tsx
342
609
  var import_jsx_runtime2 = require("react/jsx-runtime");
343
610
  function Course(props) {
611
+ warnInvalidComponentId(props.courseId, "courseId");
344
612
  const providerConfig = (0, import_react3.useMemo)(
345
613
  () => ({ ...props.config, courseId: props.courseId }),
346
614
  [props.config, props.courseId]
@@ -351,15 +619,23 @@ function Course(props) {
351
619
  ] }) });
352
620
  }
353
621
  function Lesson(props) {
622
+ warnInvalidComponentId(props.lessonId, "lessonId");
354
623
  const { setActiveLesson } = useLessonkit();
355
624
  const { completeLesson } = useCompletion();
356
- const reactId = (0, import_react3.useId)();
357
- const generatedId = (0, import_react3.useMemo)(() => `lesson-${sanitizeLessonId(reactId)}`, [reactId]);
358
- const id = props.lessonId ?? generatedId;
625
+ const id = props.lessonId;
626
+ const pendingCompleteRef = (0, import_react3.useRef)(null);
359
627
  (0, import_react3.useEffect)(() => {
628
+ if (pendingCompleteRef.current !== null) {
629
+ clearTimeout(pendingCompleteRef.current);
630
+ pendingCompleteRef.current = null;
631
+ }
360
632
  setActiveLesson(id);
361
633
  return () => {
362
- completeLesson(id);
634
+ const lessonId = id;
635
+ pendingCompleteRef.current = setTimeout(() => {
636
+ pendingCompleteRef.current = null;
637
+ completeLesson(lessonId);
638
+ }, 0);
363
639
  };
364
640
  }, [id, setActiveLesson, completeLesson]);
365
641
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("article", { "aria-label": props.title, children: [
@@ -368,11 +644,13 @@ function Lesson(props) {
368
644
  ] });
369
645
  }
370
646
  function Scenario(props) {
371
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { "aria-label": "Scenario", children: props.children });
647
+ if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
648
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { "aria-label": "Scenario", "data-lk-block-id": props.blockId, children: props.children });
372
649
  }
373
650
  function Reflection(props) {
651
+ if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
374
652
  const promptId = (0, import_react3.useId)();
375
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Reflection", children: [
653
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Reflection", "data-lk-block-id": props.blockId, children: [
376
654
  props.prompt ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: promptId, children: props.prompt }) : null,
377
655
  props.children,
378
656
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
@@ -385,14 +663,23 @@ function Reflection(props) {
385
663
  ] });
386
664
  }
387
665
  function KnowledgeCheck(props) {
388
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Quiz, { question: props.question, choices: props.choices, answer: props.answer });
666
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
667
+ Quiz,
668
+ {
669
+ checkId: props.checkId,
670
+ question: props.question,
671
+ choices: props.choices,
672
+ answer: props.answer
673
+ }
674
+ );
389
675
  }
390
676
  function Quiz(props) {
677
+ warnInvalidComponentId(props.checkId, "checkId");
391
678
  const quiz = useQuizState();
392
679
  const [selected, setSelected] = (0, import_react3.useState)(null);
393
680
  const completedRef = (0, import_react3.useRef)(false);
394
681
  const questionId = (0, import_react3.useId)();
395
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", children: [
682
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
396
683
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: questionId, children: props.question }),
397
684
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("fieldset", { "aria-labelledby": questionId, children: [
398
685
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("legend", { style: import_accessibility.visuallyHiddenStyle, children: "Quiz choices" }),
@@ -407,10 +694,15 @@ function Quiz(props) {
407
694
  onChange: () => {
408
695
  setSelected(c);
409
696
  const correct = c === props.answer;
410
- quiz.answer({ question: props.question, choice: c, correct });
697
+ quiz.answer({
698
+ checkId: props.checkId,
699
+ question: props.question,
700
+ choice: c,
701
+ correct
702
+ });
411
703
  if (correct && !completedRef.current) {
412
704
  completedRef.current = true;
413
- quiz.complete({ score: 1, maxScore: 1 });
705
+ quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1, passingScore: 1 });
414
706
  }
415
707
  }
416
708
  }
@@ -429,10 +721,6 @@ function ProgressTracker() {
429
721
  completed
430
722
  ] }) });
431
723
  }
432
- function sanitizeLessonId(id) {
433
- const s = id.replace(/[^a-zA-Z0-9_-]/g, "");
434
- return s.length ? s : "id";
435
- }
436
724
 
437
725
  // src/theme/ThemeProvider.tsx
438
726
  var import_react4 = __toESM(require("react"), 1);