@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 +14 -9
- package/dist/index.cjs +243 -84
- package/dist/index.d.cts +23 -15
- package/dist/index.d.ts +23 -15
- package/dist/index.js +235 -76
- package/package.json +5 -5
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.
|
|
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
|
-
-
|
|
91
|
-
|
|
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
|
|
58
|
-
var
|
|
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
|
|
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
|
-
|
|
93
|
-
return (0,
|
|
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
|
|
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,
|
|
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,
|
|
255
|
+
return (0, import_core3.createTrackingClient)();
|
|
132
256
|
}
|
|
133
|
-
return (0,
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
252
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
405
|
+
const result = progressRef.current.completeCourse();
|
|
406
|
+
if (!result.didComplete) return;
|
|
407
|
+
syncProgress();
|
|
279
408
|
track("course_completed");
|
|
280
|
-
|
|
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
|
|
357
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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)(
|
|
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({
|
|
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,
|
|
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
|
|
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:
|
|
19
|
-
batchSink?: (events:
|
|
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
|
-
|
|
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:
|
|
52
|
+
track: (name: TelemetryEventName, data?: unknown, opts?: {
|
|
51
53
|
lessonId?: LessonId;
|
|
52
54
|
}) => void;
|
|
53
55
|
};
|
|
54
56
|
declare function LessonkitProvider(props: {
|
|
55
|
-
config
|
|
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
|
|
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
|
|
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.
|
|
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,
|
|
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
|
|
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:
|
|
19
|
-
batchSink?: (events:
|
|
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
|
-
|
|
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:
|
|
52
|
+
track: (name: TelemetryEventName, data?: unknown, opts?: {
|
|
51
53
|
lessonId?: LessonId;
|
|
52
54
|
}) => void;
|
|
53
55
|
};
|
|
54
56
|
declare function LessonkitProvider(props: {
|
|
55
|
-
config
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
51
|
-
return createXAPIClient({
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
210
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
363
|
+
const result = progressRef.current.completeCourse();
|
|
364
|
+
if (!result.didComplete) return;
|
|
365
|
+
syncProgress();
|
|
237
366
|
track("course_completed");
|
|
238
|
-
|
|
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
|
|
315
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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({
|
|
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.
|
|
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.
|
|
54
|
-
"@lessonkit/core": "0.
|
|
55
|
-
"@lessonkit/themes": "0.
|
|
56
|
-
"@lessonkit/xapi": "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",
|