@lessonkit/react 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,6 +44,7 @@ export default function App() {
44
44
  </Scenario>
45
45
 
46
46
  <Quiz
47
+ checkId="first-step"
47
48
  question="What should you do first?"
48
49
  choices={["Open attachment", "Verify sender"]}
49
50
  answer="Verify sender"
@@ -55,16 +56,15 @@ export default function App() {
55
56
  }
56
57
  ```
57
58
 
58
- ## API (0.4.0)
59
+ ## API (0.5.0)
59
60
 
60
61
  ### Components
61
62
 
62
- - `Course`
63
- - `Lesson`
64
- - `Scenario`
65
- - `Quiz`
66
- - `Reflection`
67
- - `KnowledgeCheck`
63
+ - `Course` — requires `courseId`
64
+ - `Lesson` — requires `lessonId`
65
+ - `Scenario` — optional `blockId`
66
+ - `Quiz` / `KnowledgeCheck` — require `checkId`
67
+ - `Reflection` — optional `blockId`
68
68
  - `ProgressTracker`
69
69
 
70
70
  ### Hooks
@@ -87,10 +87,15 @@ export default function App() {
87
87
  - `Course` accepts a `config` prop that is passed through to `LessonkitProvider` (tracking sink,
88
88
  optional `xapi.transport` or custom `xapi.client`, session metadata). Hoist `config` with `useMemo`
89
89
  so tracking/xAPI clients are not recreated every render.
90
- - When a `<Lesson>` unmounts (for example, wizard navigation), it automatically calls `completeLesson`
91
- for that lesson. Use stable `lessonId` values so completion and time-on-task telemetry stay consistent.
90
+ - A lesson is marked complete when its `<Lesson>` unmounts (for example, wizard navigation) or when
91
+ another lesson becomes active via `setActiveLesson`. Use stable `lessonId` values so completion and
92
+ time-on-task telemetry stay consistent.
93
+ - `<Lesson>` defers completion on unmount so React Strict Mode remounts in development do not emit
94
+ spurious `lesson_completed` events; completion runs after the component leaves the tree.
92
95
  - If you omit `session.sessionId`, the provider reuses a tab-scoped id via `sessionStorage` so React
93
96
  Strict Mode remounts do not split analytics sessions in development.
97
+ - In development, invalid `courseId` / `lessonId` / `checkId` values log a one-time `console.warn`.
94
98
  - Accessibility guidance lives in [`docs/ACCESSIBILITY.md`](../../docs/ACCESSIBILITY.md).
95
99
  - Theming and token catalog: [`docs/THEMING.md`](../../docs/THEMING.md).
100
+ - Identity and telemetry: [`docs/IDENTITY.md`](../../docs/IDENTITY.md), [`docs/TELEMETRY.md`](../../docs/TELEMETRY.md).
96
101
 
package/dist/index.cjs CHANGED
@@ -54,8 +54,94 @@ 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
+
60
+ // src/runtime/emitTelemetry.ts
61
+ var import_core = require("@lessonkit/core");
62
+ var import_xapi = require("@lessonkit/xapi");
63
+ var warnedMissingCourseId = false;
64
+ function isDevEnvironment() {
65
+ const g = globalThis;
66
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
67
+ }
68
+ function emitTelemetry(tracking, xapi, event) {
69
+ if (!event.courseId) {
70
+ if (isDevEnvironment() && !warnedMissingCourseId) {
71
+ warnedMissingCourseId = true;
72
+ console.warn("[lessonkit] telemetry event missing courseId");
73
+ }
74
+ return;
75
+ }
76
+ tracking.track(event);
77
+ try {
78
+ const statement = (0, import_xapi.telemetryEventToXAPIStatement)(event);
79
+ if (statement) xapi?.send(statement);
80
+ } catch (err) {
81
+ if (isDevEnvironment()) {
82
+ console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
83
+ }
84
+ }
85
+ }
86
+ function buildTrackEvent(opts) {
87
+ const base = {
88
+ timestamp: (0, import_core.nowIso)(),
89
+ courseId: opts.courseId,
90
+ sessionId: opts.sessionId,
91
+ attemptId: opts.attemptId,
92
+ user: opts.user
93
+ };
94
+ switch (opts.name) {
95
+ case "course_started":
96
+ return { name: "course_started", ...base };
97
+ case "course_completed":
98
+ return { name: "course_completed", ...base };
99
+ case "lesson_started": {
100
+ const data = opts.data;
101
+ const lessonId = opts.lessonId ?? data?.lessonId;
102
+ if (!lessonId) throw new Error("lesson_started requires lessonId");
103
+ return {
104
+ name: "lesson_started",
105
+ ...base,
106
+ lessonId,
107
+ data: { lessonId, ...data }
108
+ };
109
+ }
110
+ case "lesson_completed":
111
+ case "lesson_time_on_task": {
112
+ const data = opts.data;
113
+ const lessonId = opts.lessonId ?? data?.lessonId;
114
+ if (!lessonId) throw new Error(`${opts.name} requires lessonId`);
115
+ return {
116
+ name: opts.name,
117
+ ...base,
118
+ lessonId,
119
+ data: { lessonId, ...data }
120
+ };
121
+ }
122
+ case "quiz_answered": {
123
+ const data = opts.data;
124
+ const lessonId = opts.lessonId;
125
+ if (!lessonId) throw new Error("quiz_answered requires active lessonId");
126
+ return { name: "quiz_answered", ...base, lessonId, data };
127
+ }
128
+ case "quiz_completed": {
129
+ const data = opts.data;
130
+ const lessonId = opts.lessonId;
131
+ if (!lessonId) throw new Error("quiz_completed requires active lessonId");
132
+ return { name: "quiz_completed", ...base, lessonId, data };
133
+ }
134
+ case "interaction":
135
+ return {
136
+ name: "interaction",
137
+ ...base,
138
+ lessonId: opts.lessonId,
139
+ data: opts.data
140
+ };
141
+ default:
142
+ return { name: opts.name, ...base };
143
+ }
144
+ }
59
145
 
60
146
  // src/runtime/ports.ts
61
147
  function createNoopStorage() {
@@ -84,24 +170,62 @@ function createSessionStoragePort() {
84
170
  };
85
171
  }
86
172
 
173
+ // src/runtime/progress.ts
174
+ function createProgressController() {
175
+ let activeLessonId;
176
+ let completedLessonIds = /* @__PURE__ */ new Set();
177
+ let courseCompleted = false;
178
+ const lessonStartTimes = /* @__PURE__ */ new Map();
179
+ return {
180
+ getState: () => ({
181
+ activeLessonId,
182
+ completedLessonIds: new Set(completedLessonIds),
183
+ courseCompleted
184
+ }),
185
+ setActiveLesson: (lessonId, startedAtMs) => {
186
+ const previousLessonId = activeLessonId;
187
+ activeLessonId = lessonId;
188
+ lessonStartTimes.set(lessonId, startedAtMs);
189
+ return { previousLessonId };
190
+ },
191
+ completeLesson: (lessonId, completedAtMs) => {
192
+ if (completedLessonIds.has(lessonId)) return { didComplete: false };
193
+ completedLessonIds = new Set(completedLessonIds).add(lessonId);
194
+ const startedAt = lessonStartTimes.get(lessonId);
195
+ lessonStartTimes.delete(lessonId);
196
+ const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
197
+ return { durationMs, didComplete: true };
198
+ },
199
+ completeCourse: () => {
200
+ if (courseCompleted) return { didComplete: false };
201
+ courseCompleted = true;
202
+ return { didComplete: true };
203
+ }
204
+ };
205
+ }
206
+
87
207
  // src/runtime/xapi.ts
88
- var import_xapi = require("@lessonkit/xapi");
208
+ var import_xapi2 = require("@lessonkit/xapi");
89
209
  function createXapiClientFromConfig(config, queue) {
90
210
  if (config.xapi?.enabled === false) return null;
91
211
  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 });
212
+ if (!config.courseId) return null;
213
+ return (0, import_xapi2.createXAPIClient)({
214
+ courseId: config.courseId,
215
+ transport: config.xapi?.transport,
216
+ queue
217
+ });
94
218
  }
95
219
 
96
220
  // src/runtime/session.ts
97
- var import_core = require("@lessonkit/core");
221
+ var import_core2 = require("@lessonkit/core");
98
222
  var SESSION_STORAGE_KEY = "lessonkit:sessionId";
99
223
  var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
100
224
  function resolveSessionId(storage, provided) {
101
225
  if (provided) return provided;
102
226
  const existing = storage.getItem(SESSION_STORAGE_KEY);
103
227
  if (existing) return existing;
104
- const id = (0, import_core.createSessionId)();
228
+ const id = (0, import_core2.createSessionId)();
105
229
  storage.setItem(SESSION_STORAGE_KEY, id);
106
230
  return id;
107
231
  }
@@ -128,16 +252,16 @@ function disposeTrackingClient(client) {
128
252
  var defaultStorage = createSessionStoragePort();
129
253
  function createTrackingClientFromConfig(config) {
130
254
  if (config.tracking?.enabled === false) {
131
- return (0, import_core2.createTrackingClient)();
255
+ return (0, import_core3.createTrackingClient)();
132
256
  }
133
- return (0, import_core2.createTrackingClient)({
257
+ return (0, import_core3.createTrackingClient)({
134
258
  sink: config.tracking?.sink,
135
259
  batchSink: config.tracking?.batchSink,
136
260
  batch: config.tracking?.batch
137
261
  });
138
262
  }
139
263
  function LessonkitProvider(props) {
140
- const config = props.config ?? {};
264
+ const config = props.config;
141
265
  const sessionIdRef = (0, import_react.useRef)(resolveSessionId(defaultStorage, config.session?.sessionId));
142
266
  if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
143
267
  const attemptIdRef = (0, import_react.useRef)(config.session?.attemptId);
@@ -146,9 +270,15 @@ function LessonkitProvider(props) {
146
270
  userRef.current = config.session?.user;
147
271
  const courseIdRef = (0, import_react.useRef)(config.courseId);
148
272
  courseIdRef.current = config.courseId;
149
- const trackingRef = (0, import_react.useRef)((0, import_core2.createTrackingClient)());
273
+ const progressRef = (0, import_react.useRef)(createProgressController());
274
+ const [progress, setProgress] = (0, import_react.useState)(() => progressRef.current.getState());
275
+ const syncProgress = (0, import_react.useCallback)(() => {
276
+ setProgress(progressRef.current.getState());
277
+ }, []);
278
+ const activeLessonIdRef = (0, import_react.useRef)(progress.activeLessonId);
279
+ activeLessonIdRef.current = progress.activeLessonId;
280
+ const trackingRef = (0, import_react.useRef)((0, import_core3.createTrackingClient)());
150
281
  const [tracking, setTracking] = (0, import_react.useState)(() => trackingRef.current);
151
- const courseStartedInProviderRef = (0, import_react.useRef)(false);
152
282
  const trackingEnabled = config.tracking?.enabled;
153
283
  const trackingSink = config.tracking?.sink;
154
284
  const trackingBatchSink = config.tracking?.batchSink;
@@ -162,21 +292,19 @@ function LessonkitProvider(props) {
162
292
  setTracking(next);
163
293
  const sessionId = sessionIdRef.current;
164
294
  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
- });
295
+ if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
296
+ markCourseStarted(defaultStorage, sessionId, cid);
297
+ emitTelemetry(
298
+ next,
299
+ xapiRef.current,
300
+ buildTrackEvent({
301
+ name: "course_started",
302
+ courseId: cid,
303
+ sessionId,
304
+ attemptId: attemptIdRef.current,
305
+ user: userRef.current
306
+ })
307
+ );
180
308
  }
181
309
  return () => {
182
310
  disposeTrackingClient(prev);
@@ -189,7 +317,7 @@ function LessonkitProvider(props) {
189
317
  batchFlushIntervalMs,
190
318
  batchMaxBatchSize
191
319
  ]);
192
- const xapiQueueRef = (0, import_react.useRef)((0, import_xapi2.createInMemoryXAPIQueue)());
320
+ const xapiQueueRef = (0, import_react.useRef)((0, import_xapi3.createInMemoryXAPIQueue)());
193
321
  const xapiRef = (0, import_react.useRef)(null);
194
322
  const [xapi, setXapi] = (0, import_react.useState)(null);
195
323
  const xapiEnabled = config.xapi?.enabled;
@@ -217,21 +345,10 @@ function LessonkitProvider(props) {
217
345
  void prev?.flush();
218
346
  };
219
347
  }, [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());
230
348
  const track = (0, import_react.useCallback)(
231
349
  (name, data, opts) => {
232
- trackingRef.current?.track({
350
+ const event = buildTrackEvent({
233
351
  name,
234
- timestamp: (0, import_core2.nowIso)(),
235
352
  courseId: courseIdRef.current,
236
353
  lessonId: opts?.lessonId ?? activeLessonIdRef.current,
237
354
  sessionId: sessionIdRef.current,
@@ -239,6 +356,7 @@ function LessonkitProvider(props) {
239
356
  user: userRef.current,
240
357
  data
241
358
  });
359
+ emitTelemetry(trackingRef.current, xapiRef.current, event);
242
360
  },
243
361
  []
244
362
  );
@@ -248,45 +366,47 @@ function LessonkitProvider(props) {
248
366
  void xapiRef.current?.flush();
249
367
  };
250
368
  }, []);
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;
369
+ const emitLessonCompleted = (0, import_react.useCallback)(
370
+ (lessonId, durationMs) => {
267
371
  track("lesson_completed", { lessonId, durationMs }, { lessonId });
268
372
  if (durationMs !== void 0) {
269
373
  track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
270
374
  }
271
- xapiRef.current?.completeLesson({ lessonId, durationMs });
272
375
  },
273
376
  [track]
274
377
  );
378
+ const completeLesson = (0, import_react.useCallback)(
379
+ (lessonId) => {
380
+ const result = progressRef.current.completeLesson(lessonId, Date.now());
381
+ if (!result.didComplete) return;
382
+ syncProgress();
383
+ emitLessonCompleted(lessonId, result.durationMs);
384
+ },
385
+ [syncProgress, emitLessonCompleted]
386
+ );
387
+ const setActiveLesson = (0, import_react.useCallback)(
388
+ (lessonId) => {
389
+ const current = progressRef.current.getState();
390
+ if (current.activeLessonId === lessonId) return;
391
+ const previous = current.activeLessonId;
392
+ if (previous && previous !== lessonId) {
393
+ const completed = progressRef.current.completeLesson(previous, Date.now());
394
+ if (completed.didComplete) {
395
+ emitLessonCompleted(previous, completed.durationMs);
396
+ }
397
+ }
398
+ progressRef.current.setActiveLesson(lessonId, Date.now());
399
+ syncProgress();
400
+ track("lesson_started", { lessonId }, { lessonId });
401
+ },
402
+ [track, syncProgress, emitLessonCompleted]
403
+ );
275
404
  const completeCourse = (0, import_react.useCallback)(() => {
276
- if (courseCompletedRef.current) return;
277
- courseCompletedRef.current = true;
278
- setCourseCompleted(true);
405
+ const result = progressRef.current.completeCourse();
406
+ if (!result.didComplete) return;
407
+ syncProgress();
279
408
  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
- );
409
+ }, [track, syncProgress]);
290
410
  const runtime = (0, import_react.useMemo)(
291
411
  () => ({
292
412
  config,
@@ -338,9 +458,28 @@ function useQuizState() {
338
458
  );
339
459
  }
340
460
 
461
+ // src/runtime/validateComponentId.ts
462
+ var import_core4 = require("@lessonkit/core");
463
+ var warnedPaths = /* @__PURE__ */ new Set();
464
+ function isDevEnvironment2() {
465
+ const g = globalThis;
466
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
467
+ }
468
+ function warnInvalidComponentId(id, path) {
469
+ if (!isDevEnvironment2()) return;
470
+ const key = `${path}:${String(id)}`;
471
+ if (warnedPaths.has(key)) return;
472
+ const result = (0, import_core4.validateId)(id, path);
473
+ if (result.ok) return;
474
+ warnedPaths.add(key);
475
+ const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
476
+ console.warn(`[lessonkit] invalid ${path} \u2014 ${detail}`);
477
+ }
478
+
341
479
  // src/components.tsx
342
480
  var import_jsx_runtime2 = require("react/jsx-runtime");
343
481
  function Course(props) {
482
+ warnInvalidComponentId(props.courseId, "courseId");
344
483
  const providerConfig = (0, import_react3.useMemo)(
345
484
  () => ({ ...props.config, courseId: props.courseId }),
346
485
  [props.config, props.courseId]
@@ -351,15 +490,23 @@ function Course(props) {
351
490
  ] }) });
352
491
  }
353
492
  function Lesson(props) {
493
+ warnInvalidComponentId(props.lessonId, "lessonId");
354
494
  const { setActiveLesson } = useLessonkit();
355
495
  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;
496
+ const id = props.lessonId;
497
+ const pendingCompleteRef = (0, import_react3.useRef)(null);
359
498
  (0, import_react3.useEffect)(() => {
499
+ if (pendingCompleteRef.current !== null) {
500
+ clearTimeout(pendingCompleteRef.current);
501
+ pendingCompleteRef.current = null;
502
+ }
360
503
  setActiveLesson(id);
361
504
  return () => {
362
- completeLesson(id);
505
+ const lessonId = id;
506
+ pendingCompleteRef.current = setTimeout(() => {
507
+ pendingCompleteRef.current = null;
508
+ completeLesson(lessonId);
509
+ }, 0);
363
510
  };
364
511
  }, [id, setActiveLesson, completeLesson]);
365
512
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("article", { "aria-label": props.title, children: [
@@ -368,11 +515,13 @@ function Lesson(props) {
368
515
  ] });
369
516
  }
370
517
  function Scenario(props) {
371
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { "aria-label": "Scenario", children: props.children });
518
+ if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
519
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { "aria-label": "Scenario", "data-lk-block-id": props.blockId, children: props.children });
372
520
  }
373
521
  function Reflection(props) {
522
+ if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
374
523
  const promptId = (0, import_react3.useId)();
375
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Reflection", children: [
524
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Reflection", "data-lk-block-id": props.blockId, children: [
376
525
  props.prompt ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: promptId, children: props.prompt }) : null,
377
526
  props.children,
378
527
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
@@ -385,14 +534,23 @@ function Reflection(props) {
385
534
  ] });
386
535
  }
387
536
  function KnowledgeCheck(props) {
388
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Quiz, { question: props.question, choices: props.choices, answer: props.answer });
537
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
538
+ Quiz,
539
+ {
540
+ checkId: props.checkId,
541
+ question: props.question,
542
+ choices: props.choices,
543
+ answer: props.answer
544
+ }
545
+ );
389
546
  }
390
547
  function Quiz(props) {
548
+ warnInvalidComponentId(props.checkId, "checkId");
391
549
  const quiz = useQuizState();
392
550
  const [selected, setSelected] = (0, import_react3.useState)(null);
393
551
  const completedRef = (0, import_react3.useRef)(false);
394
552
  const questionId = (0, import_react3.useId)();
395
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", children: [
553
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
396
554
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: questionId, children: props.question }),
397
555
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("fieldset", { "aria-labelledby": questionId, children: [
398
556
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("legend", { style: import_accessibility.visuallyHiddenStyle, children: "Quiz choices" }),
@@ -407,10 +565,15 @@ function Quiz(props) {
407
565
  onChange: () => {
408
566
  setSelected(c);
409
567
  const correct = c === props.answer;
410
- quiz.answer({ question: props.question, choice: c, correct });
568
+ quiz.answer({
569
+ checkId: props.checkId,
570
+ question: props.question,
571
+ choice: c,
572
+ correct
573
+ });
411
574
  if (correct && !completedRef.current) {
412
575
  completedRef.current = true;
413
- quiz.complete({ score: 1, maxScore: 1 });
576
+ quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1 });
414
577
  }
415
578
  }
416
579
  }
@@ -429,10 +592,6 @@ function ProgressTracker() {
429
592
  completed
430
593
  ] }) });
431
594
  }
432
- function sanitizeLessonId(id) {
433
- const s = id.replace(/[^a-zA-Z0-9_-]/g, "");
434
- return s.length ? s : "id";
435
- }
436
595
 
437
596
  // src/theme/ThemeProvider.tsx
438
597
  var import_react4 = __toESM(require("react"), 1);
package/dist/index.d.cts CHANGED
@@ -1,13 +1,19 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import React from 'react';
3
3
  import * as _lessonkit_core from '@lessonkit/core';
4
- import { CourseId, TelemetryUser, TelemetryEvent, TrackingClient, LessonId } from '@lessonkit/core';
4
+ import { LessonId, CourseId, TelemetryUser, TrackingClient, TelemetryEventName, CheckId, BlockId } from '@lessonkit/core';
5
5
  import { XAPITransport, XAPIClient } from '@lessonkit/xapi';
6
6
  import { LessonkitThemeV1, ThemePresetName, PartialLessonkitThemeV1 } from '@lessonkit/themes';
7
7
  export { ThemePresetName } from '@lessonkit/themes';
8
8
 
9
+ type ProgressState = {
10
+ activeLessonId?: LessonId;
11
+ completedLessonIds: ReadonlySet<LessonId>;
12
+ courseCompleted: boolean;
13
+ };
14
+
9
15
  type LessonkitConfig = {
10
- courseId?: CourseId;
16
+ courseId: CourseId;
11
17
  session?: {
12
18
  sessionId?: string;
13
19
  attemptId?: string;
@@ -15,8 +21,8 @@ type LessonkitConfig = {
15
21
  };
16
22
  tracking?: {
17
23
  enabled?: boolean;
18
- sink?: (event: TelemetryEvent) => void | Promise<void>;
19
- batchSink?: (events: TelemetryEvent[]) => void | Promise<void>;
24
+ sink?: (event: Parameters<TrackingClient["track"]>[0]) => void | Promise<void>;
25
+ batchSink?: (events: Parameters<TrackingClient["track"]>[0][]) => void | Promise<void>;
20
26
  batch?: {
21
27
  enabled?: boolean;
22
28
  flushIntervalMs?: number;
@@ -29,11 +35,7 @@ type LessonkitConfig = {
29
35
  client?: XAPIClient;
30
36
  };
31
37
  };
32
- type ProgressState = {
33
- activeLessonId?: LessonId;
34
- completedLessonIds: ReadonlySet<LessonId>;
35
- courseCompleted: boolean;
36
- };
38
+
37
39
  type LessonkitRuntime = {
38
40
  config: LessonkitConfig;
39
41
  tracking: TrackingClient;
@@ -47,39 +49,43 @@ type LessonkitRuntime = {
47
49
  setActiveLesson: (lessonId: LessonId) => void;
48
50
  completeLesson: (lessonId: LessonId) => void;
49
51
  completeCourse: () => void;
50
- track: (name: TelemetryEvent["name"], data?: TelemetryEvent["data"], opts?: {
52
+ track: (name: TelemetryEventName, data?: unknown, opts?: {
51
53
  lessonId?: LessonId;
52
54
  }) => void;
53
55
  };
54
56
  declare function LessonkitProvider(props: {
55
- config?: LessonkitConfig;
57
+ config: LessonkitConfig;
56
58
  children: React.ReactNode;
57
59
  }): react_jsx_runtime.JSX.Element;
58
60
 
59
61
  declare function Course(props: {
60
62
  title: string;
61
- courseId?: CourseId;
63
+ courseId: CourseId;
62
64
  config?: Omit<React.ComponentProps<typeof LessonkitProvider>["config"], "courseId">;
63
65
  children: React.ReactNode;
64
66
  }): react_jsx_runtime.JSX.Element;
65
67
  declare function Lesson(props: {
66
68
  title: string;
67
- lessonId?: LessonId;
69
+ lessonId: LessonId;
68
70
  children: React.ReactNode;
69
71
  }): react_jsx_runtime.JSX.Element;
70
72
  declare function Scenario(props: {
73
+ blockId?: BlockId;
71
74
  children: React.ReactNode;
72
75
  }): react_jsx_runtime.JSX.Element;
73
76
  declare function Reflection(props: {
77
+ blockId?: BlockId;
74
78
  prompt?: string;
75
79
  children?: React.ReactNode;
76
80
  }): react_jsx_runtime.JSX.Element;
77
81
  declare function KnowledgeCheck(props: {
82
+ checkId: CheckId;
78
83
  question: string;
79
84
  choices: string[];
80
85
  answer: string;
81
86
  }): react_jsx_runtime.JSX.Element;
82
87
  declare function Quiz(props: {
88
+ checkId: CheckId;
83
89
  question: string;
84
90
  choices: string[];
85
91
  answer: string;
@@ -89,7 +95,7 @@ declare function ProgressTracker(): react_jsx_runtime.JSX.Element;
89
95
  declare function useLessonkit(): LessonkitRuntime;
90
96
  declare function useProgress(): ProgressState;
91
97
  declare function useTracking(): {
92
- track: (name: _lessonkit_core.TelemetryEvent["name"], data?: _lessonkit_core.TelemetryEvent["data"], opts?: {
98
+ track: (name: _lessonkit_core.TelemetryEventName, data?: unknown, opts?: {
93
99
  lessonId?: _lessonkit_core.LessonId;
94
100
  }) => void;
95
101
  };
@@ -99,11 +105,13 @@ declare function useCompletion(): {
99
105
  };
100
106
  declare function useQuizState(): {
101
107
  answer: (opts: {
108
+ checkId: CheckId;
102
109
  question: string;
103
110
  choice: string;
104
111
  correct: boolean;
105
112
  }) => void;
106
- complete: (opts?: {
113
+ complete: (opts: {
114
+ checkId: CheckId;
107
115
  score?: number;
108
116
  maxScore?: number;
109
117
  }) => void;
package/dist/index.d.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import React from 'react';
3
3
  import * as _lessonkit_core from '@lessonkit/core';
4
- import { CourseId, TelemetryUser, TelemetryEvent, TrackingClient, LessonId } from '@lessonkit/core';
4
+ import { LessonId, CourseId, TelemetryUser, TrackingClient, TelemetryEventName, CheckId, BlockId } from '@lessonkit/core';
5
5
  import { XAPITransport, XAPIClient } from '@lessonkit/xapi';
6
6
  import { LessonkitThemeV1, ThemePresetName, PartialLessonkitThemeV1 } from '@lessonkit/themes';
7
7
  export { ThemePresetName } from '@lessonkit/themes';
8
8
 
9
+ type ProgressState = {
10
+ activeLessonId?: LessonId;
11
+ completedLessonIds: ReadonlySet<LessonId>;
12
+ courseCompleted: boolean;
13
+ };
14
+
9
15
  type LessonkitConfig = {
10
- courseId?: CourseId;
16
+ courseId: CourseId;
11
17
  session?: {
12
18
  sessionId?: string;
13
19
  attemptId?: string;
@@ -15,8 +21,8 @@ type LessonkitConfig = {
15
21
  };
16
22
  tracking?: {
17
23
  enabled?: boolean;
18
- sink?: (event: TelemetryEvent) => void | Promise<void>;
19
- batchSink?: (events: TelemetryEvent[]) => void | Promise<void>;
24
+ sink?: (event: Parameters<TrackingClient["track"]>[0]) => void | Promise<void>;
25
+ batchSink?: (events: Parameters<TrackingClient["track"]>[0][]) => void | Promise<void>;
20
26
  batch?: {
21
27
  enabled?: boolean;
22
28
  flushIntervalMs?: number;
@@ -29,11 +35,7 @@ type LessonkitConfig = {
29
35
  client?: XAPIClient;
30
36
  };
31
37
  };
32
- type ProgressState = {
33
- activeLessonId?: LessonId;
34
- completedLessonIds: ReadonlySet<LessonId>;
35
- courseCompleted: boolean;
36
- };
38
+
37
39
  type LessonkitRuntime = {
38
40
  config: LessonkitConfig;
39
41
  tracking: TrackingClient;
@@ -47,39 +49,43 @@ type LessonkitRuntime = {
47
49
  setActiveLesson: (lessonId: LessonId) => void;
48
50
  completeLesson: (lessonId: LessonId) => void;
49
51
  completeCourse: () => void;
50
- track: (name: TelemetryEvent["name"], data?: TelemetryEvent["data"], opts?: {
52
+ track: (name: TelemetryEventName, data?: unknown, opts?: {
51
53
  lessonId?: LessonId;
52
54
  }) => void;
53
55
  };
54
56
  declare function LessonkitProvider(props: {
55
- config?: LessonkitConfig;
57
+ config: LessonkitConfig;
56
58
  children: React.ReactNode;
57
59
  }): react_jsx_runtime.JSX.Element;
58
60
 
59
61
  declare function Course(props: {
60
62
  title: string;
61
- courseId?: CourseId;
63
+ courseId: CourseId;
62
64
  config?: Omit<React.ComponentProps<typeof LessonkitProvider>["config"], "courseId">;
63
65
  children: React.ReactNode;
64
66
  }): react_jsx_runtime.JSX.Element;
65
67
  declare function Lesson(props: {
66
68
  title: string;
67
- lessonId?: LessonId;
69
+ lessonId: LessonId;
68
70
  children: React.ReactNode;
69
71
  }): react_jsx_runtime.JSX.Element;
70
72
  declare function Scenario(props: {
73
+ blockId?: BlockId;
71
74
  children: React.ReactNode;
72
75
  }): react_jsx_runtime.JSX.Element;
73
76
  declare function Reflection(props: {
77
+ blockId?: BlockId;
74
78
  prompt?: string;
75
79
  children?: React.ReactNode;
76
80
  }): react_jsx_runtime.JSX.Element;
77
81
  declare function KnowledgeCheck(props: {
82
+ checkId: CheckId;
78
83
  question: string;
79
84
  choices: string[];
80
85
  answer: string;
81
86
  }): react_jsx_runtime.JSX.Element;
82
87
  declare function Quiz(props: {
88
+ checkId: CheckId;
83
89
  question: string;
84
90
  choices: string[];
85
91
  answer: string;
@@ -89,7 +95,7 @@ declare function ProgressTracker(): react_jsx_runtime.JSX.Element;
89
95
  declare function useLessonkit(): LessonkitRuntime;
90
96
  declare function useProgress(): ProgressState;
91
97
  declare function useTracking(): {
92
- track: (name: _lessonkit_core.TelemetryEvent["name"], data?: _lessonkit_core.TelemetryEvent["data"], opts?: {
98
+ track: (name: _lessonkit_core.TelemetryEventName, data?: unknown, opts?: {
93
99
  lessonId?: _lessonkit_core.LessonId;
94
100
  }) => void;
95
101
  };
@@ -99,11 +105,13 @@ declare function useCompletion(): {
99
105
  };
100
106
  declare function useQuizState(): {
101
107
  answer: (opts: {
108
+ checkId: CheckId;
102
109
  question: string;
103
110
  choice: string;
104
111
  correct: boolean;
105
112
  }) => void;
106
- complete: (opts?: {
113
+ complete: (opts: {
114
+ checkId: CheckId;
107
115
  score?: number;
108
116
  maxScore?: number;
109
117
  }) => void;
package/dist/index.js CHANGED
@@ -12,9 +12,95 @@ import {
12
12
  useRef,
13
13
  useState
14
14
  } from "react";
15
- import { createTrackingClient, nowIso } from "@lessonkit/core";
15
+ import { createTrackingClient } from "@lessonkit/core";
16
16
  import { createInMemoryXAPIQueue } from "@lessonkit/xapi";
17
17
 
18
+ // src/runtime/emitTelemetry.ts
19
+ import { nowIso } from "@lessonkit/core";
20
+ import { telemetryEventToXAPIStatement } from "@lessonkit/xapi";
21
+ var warnedMissingCourseId = false;
22
+ function isDevEnvironment() {
23
+ const g = globalThis;
24
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
25
+ }
26
+ function emitTelemetry(tracking, xapi, event) {
27
+ if (!event.courseId) {
28
+ if (isDevEnvironment() && !warnedMissingCourseId) {
29
+ warnedMissingCourseId = true;
30
+ console.warn("[lessonkit] telemetry event missing courseId");
31
+ }
32
+ return;
33
+ }
34
+ tracking.track(event);
35
+ try {
36
+ const statement = telemetryEventToXAPIStatement(event);
37
+ if (statement) xapi?.send(statement);
38
+ } catch (err) {
39
+ if (isDevEnvironment()) {
40
+ console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
41
+ }
42
+ }
43
+ }
44
+ function buildTrackEvent(opts) {
45
+ const base = {
46
+ timestamp: nowIso(),
47
+ courseId: opts.courseId,
48
+ sessionId: opts.sessionId,
49
+ attemptId: opts.attemptId,
50
+ user: opts.user
51
+ };
52
+ switch (opts.name) {
53
+ case "course_started":
54
+ return { name: "course_started", ...base };
55
+ case "course_completed":
56
+ return { name: "course_completed", ...base };
57
+ case "lesson_started": {
58
+ const data = opts.data;
59
+ const lessonId = opts.lessonId ?? data?.lessonId;
60
+ if (!lessonId) throw new Error("lesson_started requires lessonId");
61
+ return {
62
+ name: "lesson_started",
63
+ ...base,
64
+ lessonId,
65
+ data: { lessonId, ...data }
66
+ };
67
+ }
68
+ case "lesson_completed":
69
+ case "lesson_time_on_task": {
70
+ const data = opts.data;
71
+ const lessonId = opts.lessonId ?? data?.lessonId;
72
+ if (!lessonId) throw new Error(`${opts.name} requires lessonId`);
73
+ return {
74
+ name: opts.name,
75
+ ...base,
76
+ lessonId,
77
+ data: { lessonId, ...data }
78
+ };
79
+ }
80
+ case "quiz_answered": {
81
+ const data = opts.data;
82
+ const lessonId = opts.lessonId;
83
+ if (!lessonId) throw new Error("quiz_answered requires active lessonId");
84
+ return { name: "quiz_answered", ...base, lessonId, data };
85
+ }
86
+ case "quiz_completed": {
87
+ const data = opts.data;
88
+ const lessonId = opts.lessonId;
89
+ if (!lessonId) throw new Error("quiz_completed requires active lessonId");
90
+ return { name: "quiz_completed", ...base, lessonId, data };
91
+ }
92
+ case "interaction":
93
+ return {
94
+ name: "interaction",
95
+ ...base,
96
+ lessonId: opts.lessonId,
97
+ data: opts.data
98
+ };
99
+ default:
100
+ return { name: opts.name, ...base };
101
+ }
102
+ }
103
+
18
104
  // src/runtime/ports.ts
19
105
  function createNoopStorage() {
20
106
  return {
@@ -42,13 +128,51 @@ function createSessionStoragePort() {
42
128
  };
43
129
  }
44
130
 
131
+ // src/runtime/progress.ts
132
+ function createProgressController() {
133
+ let activeLessonId;
134
+ let completedLessonIds = /* @__PURE__ */ new Set();
135
+ let courseCompleted = false;
136
+ const lessonStartTimes = /* @__PURE__ */ new Map();
137
+ return {
138
+ getState: () => ({
139
+ activeLessonId,
140
+ completedLessonIds: new Set(completedLessonIds),
141
+ courseCompleted
142
+ }),
143
+ setActiveLesson: (lessonId, startedAtMs) => {
144
+ const previousLessonId = activeLessonId;
145
+ activeLessonId = lessonId;
146
+ lessonStartTimes.set(lessonId, startedAtMs);
147
+ return { previousLessonId };
148
+ },
149
+ completeLesson: (lessonId, completedAtMs) => {
150
+ if (completedLessonIds.has(lessonId)) return { didComplete: false };
151
+ completedLessonIds = new Set(completedLessonIds).add(lessonId);
152
+ const startedAt = lessonStartTimes.get(lessonId);
153
+ lessonStartTimes.delete(lessonId);
154
+ const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
155
+ return { durationMs, didComplete: true };
156
+ },
157
+ completeCourse: () => {
158
+ if (courseCompleted) return { didComplete: false };
159
+ courseCompleted = true;
160
+ return { didComplete: true };
161
+ }
162
+ };
163
+ }
164
+
45
165
  // src/runtime/xapi.ts
46
166
  import { createXAPIClient } from "@lessonkit/xapi";
47
167
  function createXapiClientFromConfig(config, queue) {
48
168
  if (config.xapi?.enabled === false) return null;
49
169
  if (config.xapi?.client) return config.xapi.client;
50
- const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
51
- return createXAPIClient({ baseId, transport: config.xapi?.transport, queue });
170
+ if (!config.courseId) return null;
171
+ return createXAPIClient({
172
+ courseId: config.courseId,
173
+ transport: config.xapi?.transport,
174
+ queue
175
+ });
52
176
  }
53
177
 
54
178
  // src/runtime/session.ts
@@ -95,7 +219,7 @@ function createTrackingClientFromConfig(config) {
95
219
  });
96
220
  }
97
221
  function LessonkitProvider(props) {
98
- const config = props.config ?? {};
222
+ const config = props.config;
99
223
  const sessionIdRef = useRef(resolveSessionId(defaultStorage, config.session?.sessionId));
100
224
  if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
101
225
  const attemptIdRef = useRef(config.session?.attemptId);
@@ -104,9 +228,15 @@ function LessonkitProvider(props) {
104
228
  userRef.current = config.session?.user;
105
229
  const courseIdRef = useRef(config.courseId);
106
230
  courseIdRef.current = config.courseId;
231
+ const progressRef = useRef(createProgressController());
232
+ const [progress, setProgress] = useState(() => progressRef.current.getState());
233
+ const syncProgress = useCallback(() => {
234
+ setProgress(progressRef.current.getState());
235
+ }, []);
236
+ const activeLessonIdRef = useRef(progress.activeLessonId);
237
+ activeLessonIdRef.current = progress.activeLessonId;
107
238
  const trackingRef = useRef(createTrackingClient());
108
239
  const [tracking, setTracking] = useState(() => trackingRef.current);
109
- const courseStartedInProviderRef = useRef(false);
110
240
  const trackingEnabled = config.tracking?.enabled;
111
241
  const trackingSink = config.tracking?.sink;
112
242
  const trackingBatchSink = config.tracking?.batchSink;
@@ -120,21 +250,19 @@ function LessonkitProvider(props) {
120
250
  setTracking(next);
121
251
  const sessionId = sessionIdRef.current;
122
252
  const cid = courseIdRef.current;
123
- const shouldEmitCourseStarted = cid ? !hasCourseStarted(defaultStorage, sessionId, cid) : !courseStartedInProviderRef.current;
124
- if (shouldEmitCourseStarted) {
125
- if (cid) {
126
- markCourseStarted(defaultStorage, sessionId, cid);
127
- } else {
128
- courseStartedInProviderRef.current = true;
129
- }
130
- next.track({
131
- name: "course_started",
132
- timestamp: nowIso(),
133
- courseId: cid,
134
- sessionId,
135
- attemptId: attemptIdRef.current,
136
- user: userRef.current
137
- });
253
+ if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
254
+ markCourseStarted(defaultStorage, sessionId, cid);
255
+ emitTelemetry(
256
+ next,
257
+ xapiRef.current,
258
+ buildTrackEvent({
259
+ name: "course_started",
260
+ courseId: cid,
261
+ sessionId,
262
+ attemptId: attemptIdRef.current,
263
+ user: userRef.current
264
+ })
265
+ );
138
266
  }
139
267
  return () => {
140
268
  disposeTrackingClient(prev);
@@ -175,21 +303,10 @@ function LessonkitProvider(props) {
175
303
  void prev?.flush();
176
304
  };
177
305
  }, [xapiEnabled, xapiClient, xapiTransport, courseId]);
178
- const [completedLessonIds, setCompletedLessonIds] = useState(() => /* @__PURE__ */ new Set());
179
- const completedLessonIdsRef = useRef(completedLessonIds);
180
- completedLessonIdsRef.current = completedLessonIds;
181
- const [activeLessonId, setActiveLessonId] = useState(void 0);
182
- const [courseCompleted, setCourseCompleted] = useState(false);
183
- const courseCompletedRef = useRef(false);
184
- courseCompletedRef.current = courseCompleted;
185
- const activeLessonIdRef = useRef(void 0);
186
- activeLessonIdRef.current = activeLessonId;
187
- const lessonStartTimesRef = useRef(/* @__PURE__ */ new Map());
188
306
  const track = useCallback(
189
307
  (name, data, opts) => {
190
- trackingRef.current?.track({
308
+ const event = buildTrackEvent({
191
309
  name,
192
- timestamp: nowIso(),
193
310
  courseId: courseIdRef.current,
194
311
  lessonId: opts?.lessonId ?? activeLessonIdRef.current,
195
312
  sessionId: sessionIdRef.current,
@@ -197,6 +314,7 @@ function LessonkitProvider(props) {
197
314
  user: userRef.current,
198
315
  data
199
316
  });
317
+ emitTelemetry(trackingRef.current, xapiRef.current, event);
200
318
  },
201
319
  []
202
320
  );
@@ -206,45 +324,47 @@ function LessonkitProvider(props) {
206
324
  void xapiRef.current?.flush();
207
325
  };
208
326
  }, []);
209
- const setActiveLesson = useCallback((lessonId) => {
210
- if (activeLessonIdRef.current === lessonId) return;
211
- activeLessonIdRef.current = lessonId;
212
- setActiveLessonId(lessonId);
213
- lessonStartTimesRef.current.set(lessonId, Date.now());
214
- track("lesson_started", { lessonId }, { lessonId });
215
- xapiRef.current?.startedLesson({ lessonId });
216
- }, [track]);
217
- const completeLesson = useCallback(
218
- (lessonId) => {
219
- if (completedLessonIdsRef.current.has(lessonId)) return;
220
- completedLessonIdsRef.current = new Set(completedLessonIdsRef.current).add(lessonId);
221
- setCompletedLessonIds(completedLessonIdsRef.current);
222
- const startedAt = lessonStartTimesRef.current.get(lessonId);
223
- lessonStartTimesRef.current.delete(lessonId);
224
- const durationMs = typeof startedAt === "number" ? Math.max(0, Date.now() - startedAt) : void 0;
327
+ const emitLessonCompleted = useCallback(
328
+ (lessonId, durationMs) => {
225
329
  track("lesson_completed", { lessonId, durationMs }, { lessonId });
226
330
  if (durationMs !== void 0) {
227
331
  track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
228
332
  }
229
- xapiRef.current?.completeLesson({ lessonId, durationMs });
230
333
  },
231
334
  [track]
232
335
  );
336
+ const completeLesson = useCallback(
337
+ (lessonId) => {
338
+ const result = progressRef.current.completeLesson(lessonId, Date.now());
339
+ if (!result.didComplete) return;
340
+ syncProgress();
341
+ emitLessonCompleted(lessonId, result.durationMs);
342
+ },
343
+ [syncProgress, emitLessonCompleted]
344
+ );
345
+ const setActiveLesson = useCallback(
346
+ (lessonId) => {
347
+ const current = progressRef.current.getState();
348
+ if (current.activeLessonId === lessonId) return;
349
+ const previous = current.activeLessonId;
350
+ if (previous && previous !== lessonId) {
351
+ const completed = progressRef.current.completeLesson(previous, Date.now());
352
+ if (completed.didComplete) {
353
+ emitLessonCompleted(previous, completed.durationMs);
354
+ }
355
+ }
356
+ progressRef.current.setActiveLesson(lessonId, Date.now());
357
+ syncProgress();
358
+ track("lesson_started", { lessonId }, { lessonId });
359
+ },
360
+ [track, syncProgress, emitLessonCompleted]
361
+ );
233
362
  const completeCourse = useCallback(() => {
234
- if (courseCompletedRef.current) return;
235
- courseCompletedRef.current = true;
236
- setCourseCompleted(true);
363
+ const result = progressRef.current.completeCourse();
364
+ if (!result.didComplete) return;
365
+ syncProgress();
237
366
  track("course_completed");
238
- xapiRef.current?.completeCourse();
239
- }, [track]);
240
- const progress = useMemo(
241
- () => ({
242
- activeLessonId,
243
- completedLessonIds: new Set(completedLessonIds),
244
- courseCompleted
245
- }),
246
- [activeLessonId, completedLessonIds, courseCompleted]
247
- );
367
+ }, [track, syncProgress]);
248
368
  const runtime = useMemo(
249
369
  () => ({
250
370
  config,
@@ -296,9 +416,28 @@ function useQuizState() {
296
416
  );
297
417
  }
298
418
 
419
+ // src/runtime/validateComponentId.ts
420
+ import { validateId } from "@lessonkit/core";
421
+ var warnedPaths = /* @__PURE__ */ new Set();
422
+ function isDevEnvironment2() {
423
+ const g = globalThis;
424
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
425
+ }
426
+ function warnInvalidComponentId(id, path) {
427
+ if (!isDevEnvironment2()) return;
428
+ const key = `${path}:${String(id)}`;
429
+ if (warnedPaths.has(key)) return;
430
+ const result = validateId(id, path);
431
+ if (result.ok) return;
432
+ warnedPaths.add(key);
433
+ const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
434
+ console.warn(`[lessonkit] invalid ${path} \u2014 ${detail}`);
435
+ }
436
+
299
437
  // src/components.tsx
300
438
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
301
439
  function Course(props) {
440
+ warnInvalidComponentId(props.courseId, "courseId");
302
441
  const providerConfig = useMemo3(
303
442
  () => ({ ...props.config, courseId: props.courseId }),
304
443
  [props.config, props.courseId]
@@ -309,15 +448,23 @@ function Course(props) {
309
448
  ] }) });
310
449
  }
311
450
  function Lesson(props) {
451
+ warnInvalidComponentId(props.lessonId, "lessonId");
312
452
  const { setActiveLesson } = useLessonkit();
313
453
  const { completeLesson } = useCompletion();
314
- const reactId = useId();
315
- const generatedId = useMemo3(() => `lesson-${sanitizeLessonId(reactId)}`, [reactId]);
316
- const id = props.lessonId ?? generatedId;
454
+ const id = props.lessonId;
455
+ const pendingCompleteRef = useRef2(null);
317
456
  useEffect2(() => {
457
+ if (pendingCompleteRef.current !== null) {
458
+ clearTimeout(pendingCompleteRef.current);
459
+ pendingCompleteRef.current = null;
460
+ }
318
461
  setActiveLesson(id);
319
462
  return () => {
320
- completeLesson(id);
463
+ const lessonId = id;
464
+ pendingCompleteRef.current = setTimeout(() => {
465
+ pendingCompleteRef.current = null;
466
+ completeLesson(lessonId);
467
+ }, 0);
321
468
  };
322
469
  }, [id, setActiveLesson, completeLesson]);
323
470
  return /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
@@ -326,11 +473,13 @@ function Lesson(props) {
326
473
  ] });
327
474
  }
328
475
  function Scenario(props) {
329
- return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", children: props.children });
476
+ if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
477
+ return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", "data-lk-block-id": props.blockId, children: props.children });
330
478
  }
331
479
  function Reflection(props) {
480
+ if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
332
481
  const promptId = useId();
333
- return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", children: [
482
+ return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", "data-lk-block-id": props.blockId, children: [
334
483
  props.prompt ? /* @__PURE__ */ jsx2("p", { id: promptId, children: props.prompt }) : null,
335
484
  props.children,
336
485
  /* @__PURE__ */ jsx2(
@@ -343,14 +492,23 @@ function Reflection(props) {
343
492
  ] });
344
493
  }
345
494
  function KnowledgeCheck(props) {
346
- return /* @__PURE__ */ jsx2(Quiz, { question: props.question, choices: props.choices, answer: props.answer });
495
+ return /* @__PURE__ */ jsx2(
496
+ Quiz,
497
+ {
498
+ checkId: props.checkId,
499
+ question: props.question,
500
+ choices: props.choices,
501
+ answer: props.answer
502
+ }
503
+ );
347
504
  }
348
505
  function Quiz(props) {
506
+ warnInvalidComponentId(props.checkId, "checkId");
349
507
  const quiz = useQuizState();
350
508
  const [selected, setSelected] = useState2(null);
351
509
  const completedRef = useRef2(false);
352
510
  const questionId = useId();
353
- return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", children: [
511
+ return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
354
512
  /* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
355
513
  /* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
356
514
  /* @__PURE__ */ jsx2("legend", { style: visuallyHiddenStyle, children: "Quiz choices" }),
@@ -365,10 +523,15 @@ function Quiz(props) {
365
523
  onChange: () => {
366
524
  setSelected(c);
367
525
  const correct = c === props.answer;
368
- quiz.answer({ question: props.question, choice: c, correct });
526
+ quiz.answer({
527
+ checkId: props.checkId,
528
+ question: props.question,
529
+ choice: c,
530
+ correct
531
+ });
369
532
  if (correct && !completedRef.current) {
370
533
  completedRef.current = true;
371
- quiz.complete({ score: 1, maxScore: 1 });
534
+ quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1 });
372
535
  }
373
536
  }
374
537
  }
@@ -387,10 +550,6 @@ function ProgressTracker() {
387
550
  completed
388
551
  ] }) });
389
552
  }
390
- function sanitizeLessonId(id) {
391
- const s = id.replace(/[^a-zA-Z0-9_-]/g, "");
392
- return s.length ? s : "id";
393
- }
394
553
 
395
554
  // src/theme/ThemeProvider.tsx
396
555
  import React3, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/react",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "React components and hooks for building learning experiences with LessonKit.",
6
6
  "license": "Apache-2.0",
@@ -50,10 +50,10 @@
50
50
  "react-dom": ">=18"
51
51
  },
52
52
  "dependencies": {
53
- "@lessonkit/accessibility": "0.4.0",
54
- "@lessonkit/core": "0.4.0",
55
- "@lessonkit/themes": "0.4.0",
56
- "@lessonkit/xapi": "0.4.0"
53
+ "@lessonkit/accessibility": "0.5.0",
54
+ "@lessonkit/core": "0.5.0",
55
+ "@lessonkit/themes": "0.5.0",
56
+ "@lessonkit/xapi": "0.5.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@testing-library/react": "^16.3.0",