@lessonkit/core 1.3.1 → 1.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 +28 -2
- package/dist/chunk-KFXFQ6B2.js +813 -0
- package/dist/index.cjs +720 -130
- package/dist/index.d.cts +63 -488
- package/dist/index.d.ts +63 -488
- package/dist/index.js +509 -658
- package/dist/testing-BFr8oEfw.d.cts +608 -0
- package/dist/testing-BFr8oEfw.d.ts +608 -0
- package/dist/testing.cjs +58 -0
- package/dist/testing.d.cts +1 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +12 -0
- package/package.json +11 -6
- package/telemetry-catalog.v3.json +207 -14
package/dist/index.js
CHANGED
|
@@ -1,64 +1,48 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
ID_MAX_LENGTH,
|
|
3
|
+
ID_PATTERN,
|
|
4
|
+
SESSION_STORAGE_KEY,
|
|
5
|
+
assertValidId,
|
|
6
|
+
buildCourseStartedTelemetryEvent,
|
|
7
|
+
buildTelemetryEvent,
|
|
8
|
+
completeCourseWithTelemetry,
|
|
9
|
+
completeLessonWithTelemetry,
|
|
10
|
+
createDefaultClock,
|
|
11
|
+
createGlobalTimer,
|
|
12
|
+
createNoopStorage,
|
|
13
|
+
createSessionId,
|
|
14
|
+
createSessionStoragePort,
|
|
15
|
+
getTabSessionId,
|
|
16
|
+
hasCourseStarted,
|
|
17
|
+
hasCourseStartedEmittedToTracking,
|
|
18
|
+
hasCourseStartedPipelineDelivered,
|
|
19
|
+
hasCourseStartedXapiSent,
|
|
20
|
+
isDevEnvironment,
|
|
21
|
+
markCourseStarted,
|
|
22
|
+
markCourseStartedEmittedToTracking,
|
|
23
|
+
markCourseStartedPipelineDelivered,
|
|
24
|
+
markCourseStartedXapiSent,
|
|
25
|
+
migrateCourseStartedMark,
|
|
26
|
+
nowIso,
|
|
27
|
+
parseBlockId,
|
|
28
|
+
parseCheckId,
|
|
29
|
+
parseCourseId,
|
|
30
|
+
parseLessonId,
|
|
31
|
+
resetSharedVolatileSessionIdForTests,
|
|
32
|
+
resetStoragePortForTests,
|
|
33
|
+
resetTelemetryBuilderWarningsForTests,
|
|
34
|
+
resolveSessionId,
|
|
35
|
+
tryBuildTelemetryEvent,
|
|
36
|
+
tryEmitCourseStarted,
|
|
37
|
+
validateId,
|
|
38
|
+
warnDev
|
|
39
|
+
} from "./chunk-KFXFQ6B2.js";
|
|
4
40
|
|
|
5
41
|
// src/assertNever.ts
|
|
6
42
|
function assertNever(value, message = "Unexpected value") {
|
|
7
43
|
throw new Error(`${message}: ${String(value)}`);
|
|
8
44
|
}
|
|
9
45
|
|
|
10
|
-
// src/validateId.ts
|
|
11
|
-
function validateId(input, path = "id") {
|
|
12
|
-
if (typeof input !== "string") {
|
|
13
|
-
return { ok: false, issues: [{ path, message: "id must be a string" }] };
|
|
14
|
-
}
|
|
15
|
-
const id = input.trim();
|
|
16
|
-
if (!id.length) {
|
|
17
|
-
return { ok: false, issues: [{ path, message: "id must not be empty" }] };
|
|
18
|
-
}
|
|
19
|
-
if (id.length > ID_MAX_LENGTH) {
|
|
20
|
-
return {
|
|
21
|
-
ok: false,
|
|
22
|
-
issues: [{ path, message: `id must be at most ${ID_MAX_LENGTH} characters` }]
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
if (!ID_PATTERN.test(id)) {
|
|
26
|
-
return {
|
|
27
|
-
ok: false,
|
|
28
|
-
issues: [
|
|
29
|
-
{
|
|
30
|
-
path,
|
|
31
|
-
message: "id must start with a letter and contain only letters, digits, underscores, and hyphens"
|
|
32
|
-
}
|
|
33
|
-
]
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
return { ok: true, id };
|
|
37
|
-
}
|
|
38
|
-
function parseCourseId(input) {
|
|
39
|
-
const result = validateId(input, "courseId");
|
|
40
|
-
return result.ok ? result.id : null;
|
|
41
|
-
}
|
|
42
|
-
function parseLessonId(input) {
|
|
43
|
-
const result = validateId(input, "lessonId");
|
|
44
|
-
return result.ok ? result.id : null;
|
|
45
|
-
}
|
|
46
|
-
function parseCheckId(input) {
|
|
47
|
-
const result = validateId(input, "checkId");
|
|
48
|
-
return result.ok ? result.id : null;
|
|
49
|
-
}
|
|
50
|
-
function parseBlockId(input) {
|
|
51
|
-
const result = validateId(input, "blockId");
|
|
52
|
-
return result.ok ? result.id : null;
|
|
53
|
-
}
|
|
54
|
-
function assertValidId(input, path = "id") {
|
|
55
|
-
const result = validateId(input, path);
|
|
56
|
-
if (!result.ok) {
|
|
57
|
-
throw new Error(result.issues.map((i) => `${i.path}: ${i.message}`).join("; "));
|
|
58
|
-
}
|
|
59
|
-
return result.id;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
46
|
// src/slugify.ts
|
|
63
47
|
function shortHash(input) {
|
|
64
48
|
let h = 0;
|
|
@@ -71,12 +55,26 @@ function uniqueFallbackId(input, usedIds) {
|
|
|
71
55
|
const hash = shortHash(input);
|
|
72
56
|
for (let n = 0; n < 100; n++) {
|
|
73
57
|
const candidate = (n === 0 ? `id-${hash}` : `id-${hash}-${n}`).slice(0, 64);
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
58
|
+
const validated = validateId(candidate);
|
|
59
|
+
if (validated.ok && !usedIds.has(validated.id)) return validated.id;
|
|
60
|
+
}
|
|
61
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
62
|
+
const randomSuffix = Math.random().toString(36).slice(2, 8);
|
|
63
|
+
const candidate = `id-${hash}-${randomSuffix}`.slice(0, 64);
|
|
64
|
+
const validated = validateId(candidate);
|
|
65
|
+
if (validated.ok && !usedIds.has(validated.id)) return validated.id;
|
|
76
66
|
}
|
|
77
67
|
const timed = `id-${hash}-${Date.now().toString(36)}`.slice(0, 64);
|
|
78
|
-
const
|
|
79
|
-
|
|
68
|
+
const timedValidated = validateId(timed);
|
|
69
|
+
if (timedValidated.ok && !usedIds.has(timedValidated.id)) return timedValidated.id;
|
|
70
|
+
const cryptoApi = globalThis.crypto;
|
|
71
|
+
for (let attempt = 0; attempt < 1e3; attempt++) {
|
|
72
|
+
const suffix = typeof cryptoApi?.randomUUID === "function" ? cryptoApi.randomUUID().replace(/-/g, "").slice(0, 12) : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
73
|
+
const candidate = `id-${hash}-${suffix}`.slice(0, 64);
|
|
74
|
+
const validated = validateId(candidate);
|
|
75
|
+
if (validated.ok && !usedIds.has(validated.id)) return validated.id;
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`[lessonkit] unable to derive unique id for input: ${input.slice(0, 32)}`);
|
|
80
78
|
}
|
|
81
79
|
function slugifyId(input) {
|
|
82
80
|
const slug = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-").slice(0, 64);
|
|
@@ -118,6 +116,13 @@ function buildLessonkitUrn(parts) {
|
|
|
118
116
|
}
|
|
119
117
|
urn += `:block:${blockId}`;
|
|
120
118
|
}
|
|
119
|
+
if (parts.nodeId !== void 0) {
|
|
120
|
+
const nodeId = assertValidId(parts.nodeId, "blockId");
|
|
121
|
+
if (parts.blockId === void 0) {
|
|
122
|
+
throw new Error("buildLessonkitUrn: nodeId requires blockId");
|
|
123
|
+
}
|
|
124
|
+
urn += `:node:${nodeId}`;
|
|
125
|
+
}
|
|
121
126
|
return urn;
|
|
122
127
|
}
|
|
123
128
|
|
|
@@ -162,19 +167,25 @@ function isPlainSerializableChildState(value) {
|
|
|
162
167
|
(entry) => isValidChildResumeValue(entry)
|
|
163
168
|
);
|
|
164
169
|
}
|
|
165
|
-
function parseCompoundResumeState(raw) {
|
|
170
|
+
function parseCompoundResumeState(raw, opts) {
|
|
166
171
|
if (!raw || typeof raw !== "object") return null;
|
|
167
172
|
const obj = raw;
|
|
168
173
|
if (obj.schemaVersion !== COMPOUND_RESUME_SCHEMA_VERSION) return null;
|
|
169
174
|
if (typeof obj.activePageIndex !== "number" || !Number.isFinite(obj.activePageIndex)) return null;
|
|
170
175
|
const childStates = {};
|
|
176
|
+
const droppedChildKeys = [];
|
|
171
177
|
if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
|
|
172
178
|
for (const [key, value] of Object.entries(obj.childStates)) {
|
|
173
179
|
if (isPlainSerializableChildState(value)) {
|
|
174
180
|
childStates[key] = value;
|
|
181
|
+
} else {
|
|
182
|
+
droppedChildKeys.push(key);
|
|
175
183
|
}
|
|
176
184
|
}
|
|
177
185
|
}
|
|
186
|
+
if (droppedChildKeys.length > 0) {
|
|
187
|
+
opts?.onDroppedChildKeys?.(droppedChildKeys);
|
|
188
|
+
}
|
|
178
189
|
const activeChapterIndex = typeof obj.activeChapterIndex === "number" && Number.isFinite(obj.activeChapterIndex) ? obj.activeChapterIndex : void 0;
|
|
179
190
|
return {
|
|
180
191
|
schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
|
|
@@ -184,32 +195,26 @@ function parseCompoundResumeState(raw) {
|
|
|
184
195
|
};
|
|
185
196
|
}
|
|
186
197
|
|
|
187
|
-
// src/internal/env.ts
|
|
188
|
-
function isDevEnvironment() {
|
|
189
|
-
const g = globalThis;
|
|
190
|
-
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
191
|
-
}
|
|
192
|
-
function warnDev(message, err) {
|
|
193
|
-
if (!isDevEnvironment()) return;
|
|
194
|
-
console.warn(message, err instanceof Error ? err.message : err);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
198
|
// src/compoundState.ts
|
|
198
199
|
var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
|
|
199
200
|
function compoundStateStorageKey(courseId, compoundId) {
|
|
200
201
|
return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
|
|
201
202
|
}
|
|
202
|
-
function loadCompoundState(storage, courseId, compoundId) {
|
|
203
|
+
function loadCompoundState(storage, courseId, compoundId, opts) {
|
|
203
204
|
const key = compoundStateStorageKey(courseId, compoundId);
|
|
204
205
|
const raw = storage.getItem(key);
|
|
205
206
|
if (!raw) return null;
|
|
206
207
|
try {
|
|
207
|
-
const parsed = parseCompoundResumeState(JSON.parse(raw));
|
|
208
|
-
if (parsed === null
|
|
209
|
-
|
|
208
|
+
const parsed = parseCompoundResumeState(JSON.parse(raw), opts);
|
|
209
|
+
if (parsed === null) {
|
|
210
|
+
opts?.onCorrupt?.();
|
|
211
|
+
if (isDevEnvironment()) {
|
|
212
|
+
console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
|
|
213
|
+
}
|
|
210
214
|
}
|
|
211
215
|
return parsed;
|
|
212
216
|
} catch {
|
|
217
|
+
opts?.onCorrupt?.();
|
|
213
218
|
if (isDevEnvironment()) {
|
|
214
219
|
console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
|
|
215
220
|
}
|
|
@@ -224,10 +229,23 @@ function clearCompoundState(storage, courseId, compoundId) {
|
|
|
224
229
|
}
|
|
225
230
|
|
|
226
231
|
// src/compoundAllowlists.ts
|
|
232
|
+
var PAGE_AND_SLIDE_14_BLOCKS = [
|
|
233
|
+
"Video",
|
|
234
|
+
"Summary",
|
|
235
|
+
"ImagePairing",
|
|
236
|
+
"ImageSequencing",
|
|
237
|
+
"MemoryGame",
|
|
238
|
+
"InformationWall",
|
|
239
|
+
"ParallaxSlideshow",
|
|
240
|
+
"Questionnaire",
|
|
241
|
+
"Essay",
|
|
242
|
+
"ArithmeticQuiz"
|
|
243
|
+
];
|
|
227
244
|
var PAGE_ALLOWED_CHILD_TYPES = [
|
|
228
245
|
"Text",
|
|
229
246
|
"Heading",
|
|
230
247
|
"Image",
|
|
248
|
+
"Video",
|
|
231
249
|
"Scenario",
|
|
232
250
|
"Reflection",
|
|
233
251
|
"Quiz",
|
|
@@ -237,6 +255,15 @@ var PAGE_ALLOWED_CHILD_TYPES = [
|
|
|
237
255
|
"DragAndDrop",
|
|
238
256
|
"DragTheWords",
|
|
239
257
|
"MarkTheWords",
|
|
258
|
+
"Summary",
|
|
259
|
+
"ImagePairing",
|
|
260
|
+
"ImageSequencing",
|
|
261
|
+
"MemoryGame",
|
|
262
|
+
"InformationWall",
|
|
263
|
+
"ParallaxSlideshow",
|
|
264
|
+
"Questionnaire",
|
|
265
|
+
"Essay",
|
|
266
|
+
"ArithmeticQuiz",
|
|
240
267
|
"Accordion",
|
|
241
268
|
"DialogCards",
|
|
242
269
|
"Flashcards",
|
|
@@ -244,13 +271,51 @@ var PAGE_ALLOWED_CHILD_TYPES = [
|
|
|
244
271
|
"FindHotspot",
|
|
245
272
|
"FindMultipleHotspots",
|
|
246
273
|
"ImageSlider",
|
|
274
|
+
"Embed",
|
|
275
|
+
"Chart",
|
|
247
276
|
"ProgressTracker"
|
|
248
277
|
];
|
|
278
|
+
var BRANCH_NODE_ALLOWED_CHILD_TYPES = [
|
|
279
|
+
"Text",
|
|
280
|
+
"Heading",
|
|
281
|
+
"Image",
|
|
282
|
+
"Video",
|
|
283
|
+
"Scenario",
|
|
284
|
+
"Reflection",
|
|
285
|
+
"Quiz",
|
|
286
|
+
"KnowledgeCheck",
|
|
287
|
+
"TrueFalse",
|
|
288
|
+
"FillInTheBlanks",
|
|
289
|
+
"DragAndDrop",
|
|
290
|
+
"DragTheWords",
|
|
291
|
+
"MarkTheWords",
|
|
292
|
+
"Summary",
|
|
293
|
+
"ImagePairing",
|
|
294
|
+
"ImageSequencing",
|
|
295
|
+
"MemoryGame",
|
|
296
|
+
"InformationWall",
|
|
297
|
+
"ParallaxSlideshow",
|
|
298
|
+
"Questionnaire",
|
|
299
|
+
"Essay",
|
|
300
|
+
"ArithmeticQuiz",
|
|
301
|
+
"Accordion",
|
|
302
|
+
"DialogCards",
|
|
303
|
+
"Flashcards",
|
|
304
|
+
"ImageHotspots",
|
|
305
|
+
"FindHotspot",
|
|
306
|
+
"FindMultipleHotspots",
|
|
307
|
+
"ImageSlider",
|
|
308
|
+
"Embed",
|
|
309
|
+
"Chart",
|
|
310
|
+
"BranchChoice"
|
|
311
|
+
];
|
|
312
|
+
var BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES = ["BranchNode"];
|
|
249
313
|
var INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES = ["Page"];
|
|
250
314
|
var SLIDE_ALLOWED_CHILD_TYPES = [
|
|
251
315
|
"Text",
|
|
252
316
|
"Heading",
|
|
253
317
|
"Image",
|
|
318
|
+
"Video",
|
|
254
319
|
"Scenario",
|
|
255
320
|
"Reflection",
|
|
256
321
|
"Quiz",
|
|
@@ -260,15 +325,42 @@ var SLIDE_ALLOWED_CHILD_TYPES = [
|
|
|
260
325
|
"DragAndDrop",
|
|
261
326
|
"DragTheWords",
|
|
262
327
|
"MarkTheWords",
|
|
328
|
+
"Summary",
|
|
329
|
+
"ImagePairing",
|
|
330
|
+
"ImageSequencing",
|
|
331
|
+
"MemoryGame",
|
|
332
|
+
"InformationWall",
|
|
333
|
+
"ParallaxSlideshow",
|
|
334
|
+
"Questionnaire",
|
|
335
|
+
"Essay",
|
|
336
|
+
"ArithmeticQuiz",
|
|
263
337
|
"Accordion",
|
|
264
338
|
"DialogCards",
|
|
265
339
|
"Flashcards",
|
|
266
340
|
"ImageHotspots",
|
|
267
341
|
"FindHotspot",
|
|
268
342
|
"FindMultipleHotspots",
|
|
269
|
-
"ImageSlider"
|
|
343
|
+
"ImageSlider",
|
|
344
|
+
"Embed",
|
|
345
|
+
"Chart"
|
|
270
346
|
];
|
|
271
347
|
var SLIDE_DECK_ALLOWED_CHILD_TYPES = ["Slide"];
|
|
348
|
+
var TIMED_CUE_ALLOWED_CHILD_TYPES = [
|
|
349
|
+
"Text",
|
|
350
|
+
"Heading",
|
|
351
|
+
"Image",
|
|
352
|
+
"Quiz",
|
|
353
|
+
"TrueFalse",
|
|
354
|
+
"FillInTheBlanks",
|
|
355
|
+
"Summary",
|
|
356
|
+
"ImagePairing",
|
|
357
|
+
"ImageSequencing",
|
|
358
|
+
"MemoryGame",
|
|
359
|
+
"Questionnaire",
|
|
360
|
+
"Essay",
|
|
361
|
+
"ArithmeticQuiz"
|
|
362
|
+
];
|
|
363
|
+
var INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES = ["TimedCue"];
|
|
272
364
|
var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
|
|
273
365
|
"TrueFalse",
|
|
274
366
|
"FillInTheBlanks",
|
|
@@ -278,21 +370,34 @@ var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
|
|
|
278
370
|
"Quiz",
|
|
279
371
|
"KnowledgeCheck",
|
|
280
372
|
"FindHotspot",
|
|
281
|
-
"FindMultipleHotspots"
|
|
373
|
+
"FindMultipleHotspots",
|
|
374
|
+
"Summary",
|
|
375
|
+
"ImagePairing",
|
|
376
|
+
"ImageSequencing",
|
|
377
|
+
"ArithmeticQuiz",
|
|
378
|
+
"Essay"
|
|
282
379
|
];
|
|
283
380
|
var ALLOWLISTS = {
|
|
284
381
|
Page: PAGE_ALLOWED_CHILD_TYPES,
|
|
285
382
|
InteractiveBook: INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
|
|
286
383
|
Slide: SLIDE_ALLOWED_CHILD_TYPES,
|
|
287
384
|
SlideDeck: SLIDE_DECK_ALLOWED_CHILD_TYPES,
|
|
288
|
-
|
|
385
|
+
TimedCue: TIMED_CUE_ALLOWED_CHILD_TYPES,
|
|
386
|
+
InteractiveVideo: INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
|
|
387
|
+
AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
|
|
388
|
+
BranchingScenario: BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
|
|
389
|
+
BranchNode: BRANCH_NODE_ALLOWED_CHILD_TYPES
|
|
289
390
|
};
|
|
290
391
|
var COMPOUND_MAX_NESTING_DEPTH = {
|
|
291
392
|
Page: 1,
|
|
292
393
|
InteractiveBook: 2,
|
|
293
394
|
Slide: 1,
|
|
294
395
|
SlideDeck: 2,
|
|
295
|
-
|
|
396
|
+
TimedCue: 1,
|
|
397
|
+
InteractiveVideo: 2,
|
|
398
|
+
AssessmentSequence: 1,
|
|
399
|
+
BranchingScenario: 2,
|
|
400
|
+
BranchNode: 1
|
|
296
401
|
};
|
|
297
402
|
function getAllowedChildTypes(parent) {
|
|
298
403
|
return ALLOWLISTS[parent];
|
|
@@ -301,6 +406,83 @@ function isChildTypeAllowed(parent, childType) {
|
|
|
301
406
|
return ALLOWLISTS[parent].includes(childType);
|
|
302
407
|
}
|
|
303
408
|
var ACCORDION_FORBIDDEN_CHILD_TYPES = ["Accordion"];
|
|
409
|
+
var BLOCKS_14_PAGE_SLIDE = PAGE_AND_SLIDE_14_BLOCKS;
|
|
410
|
+
|
|
411
|
+
// src/branchGraph.ts
|
|
412
|
+
function validateBranchGraph(startNodeId, nodes) {
|
|
413
|
+
const issues = [];
|
|
414
|
+
if (nodes.length === 0) {
|
|
415
|
+
issues.push({ code: "empty_graph", message: "Branch graph has no nodes" });
|
|
416
|
+
return { ok: false, issues, reachableNodeIds: [] };
|
|
417
|
+
}
|
|
418
|
+
const nodeIds = /* @__PURE__ */ new Set();
|
|
419
|
+
for (const node of nodes) {
|
|
420
|
+
if (nodeIds.has(node.nodeId)) {
|
|
421
|
+
issues.push({
|
|
422
|
+
code: "duplicate_node_id",
|
|
423
|
+
message: `Duplicate nodeId "${node.nodeId}"`,
|
|
424
|
+
nodeId: node.nodeId
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
nodeIds.add(node.nodeId);
|
|
428
|
+
}
|
|
429
|
+
if (!nodeIds.has(startNodeId)) {
|
|
430
|
+
issues.push({
|
|
431
|
+
code: "start_not_found",
|
|
432
|
+
message: `startNodeId "${startNodeId}" does not match any BranchNode`,
|
|
433
|
+
nodeId: startNodeId
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
if (nodes.length > 1 && nodeIds.has(startNodeId)) {
|
|
437
|
+
const startNode = nodes.find((n) => n.nodeId === startNodeId);
|
|
438
|
+
if (startNode && startNode.choices.length === 0) {
|
|
439
|
+
issues.push({
|
|
440
|
+
code: "start_no_choices",
|
|
441
|
+
message: `startNodeId "${startNodeId}" has no BranchChoice children in a multi-node scenario`,
|
|
442
|
+
nodeId: startNodeId
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
for (const node of nodes) {
|
|
447
|
+
for (const choice of node.choices) {
|
|
448
|
+
if (!nodeIds.has(choice.targetNodeId)) {
|
|
449
|
+
issues.push({
|
|
450
|
+
code: "unknown_target",
|
|
451
|
+
message: `Choice from "${node.nodeId}" references unknown target "${choice.targetNodeId}"`,
|
|
452
|
+
nodeId: node.nodeId
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
458
|
+
if (nodeIds.has(startNodeId)) {
|
|
459
|
+
const queue = [startNodeId];
|
|
460
|
+
while (queue.length > 0) {
|
|
461
|
+
const current = queue.shift();
|
|
462
|
+
if (reachable.has(current)) continue;
|
|
463
|
+
reachable.add(current);
|
|
464
|
+
const node = nodes.find((n) => n.nodeId === current);
|
|
465
|
+
if (!node) continue;
|
|
466
|
+
for (const choice of node.choices) {
|
|
467
|
+
if (!reachable.has(choice.targetNodeId)) queue.push(choice.targetNodeId);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
for (const nodeId of nodeIds) {
|
|
472
|
+
if (!reachable.has(nodeId)) {
|
|
473
|
+
issues.push({
|
|
474
|
+
code: "unreachable_node",
|
|
475
|
+
message: `Node "${nodeId}" is not reachable from startNodeId "${startNodeId}"`,
|
|
476
|
+
nodeId
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
ok: issues.length === 0,
|
|
482
|
+
issues,
|
|
483
|
+
reachableNodeIds: [...reachable]
|
|
484
|
+
};
|
|
485
|
+
}
|
|
304
486
|
|
|
305
487
|
// src/telemetryCatalog.ts
|
|
306
488
|
var telemetryCatalogVersion = 1;
|
|
@@ -456,6 +638,70 @@ var TELEMETRY_EVENT_CATALOG_V3 = [
|
|
|
456
638
|
dataFields: ["blockId", "slideIndex"],
|
|
457
639
|
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
458
640
|
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
name: "video_cue_reached",
|
|
644
|
+
description: "Learner reached a timed cue in an Interactive Video",
|
|
645
|
+
requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
|
|
646
|
+
dataFields: ["blockId", "cueIndex", "atSeconds", "cueLabel"],
|
|
647
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
648
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
name: "video_segment_completed",
|
|
652
|
+
description: "Learner completed a timed segment in an Interactive Video",
|
|
653
|
+
requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
|
|
654
|
+
dataFields: ["blockId", "segmentIndex", "atSeconds", "segmentLabel"],
|
|
655
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/completed",
|
|
656
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
name: "memory_card_flipped",
|
|
660
|
+
description: "Learner flipped a memory game card",
|
|
661
|
+
requiredFields: ["courseId", "sessionId", "timestamp"],
|
|
662
|
+
dataFields: ["blockId", "cardIndex", "face"],
|
|
663
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
664
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
name: "information_wall_search",
|
|
668
|
+
description: "Learner searched an information wall",
|
|
669
|
+
requiredFields: ["courseId", "sessionId", "timestamp"],
|
|
670
|
+
dataFields: ["blockId", "query", "resultCount"],
|
|
671
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
672
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
name: "parallax_slide_viewed",
|
|
676
|
+
description: "Learner viewed a slide in a parallax slideshow",
|
|
677
|
+
requiredFields: ["courseId", "sessionId", "timestamp"],
|
|
678
|
+
dataFields: ["blockId", "slideIndex"],
|
|
679
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
680
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
name: "questionnaire_submitted",
|
|
684
|
+
description: "Learner submitted an unscored questionnaire",
|
|
685
|
+
requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
|
|
686
|
+
dataFields: ["blockId", "fieldCount"],
|
|
687
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/completed",
|
|
688
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
name: "branch_node_viewed",
|
|
692
|
+
description: "Learner viewed a node in a BranchingScenario",
|
|
693
|
+
requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
|
|
694
|
+
dataFields: ["blockId", "nodeId", "nodeIndex", "nodeTitle"],
|
|
695
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
696
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:node:{nodeId}"
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
name: "branch_selected",
|
|
700
|
+
description: "Learner selected a branch choice in a BranchingScenario",
|
|
701
|
+
requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
|
|
702
|
+
dataFields: ["blockId", "fromNodeId", "toNodeId", "label", "scoreWeight"],
|
|
703
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/answered",
|
|
704
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}:node:{toNodeId}"
|
|
459
705
|
}
|
|
460
706
|
];
|
|
461
707
|
function buildTelemetryCatalogV3() {
|
|
@@ -463,34 +709,36 @@ function buildTelemetryCatalogV3() {
|
|
|
463
709
|
}
|
|
464
710
|
|
|
465
711
|
// src/internal/sinkInvoke.ts
|
|
466
|
-
function
|
|
467
|
-
let result;
|
|
712
|
+
async function invokeTrackingSinkWithResult(sink, event) {
|
|
468
713
|
try {
|
|
469
|
-
result = sink(event);
|
|
714
|
+
const result = sink(event);
|
|
715
|
+
if (result != null && typeof result.then === "function") {
|
|
716
|
+
await result;
|
|
717
|
+
}
|
|
718
|
+
return true;
|
|
470
719
|
} catch (err) {
|
|
471
720
|
warnDev("[lessonkit] tracking sink failed:", err);
|
|
472
|
-
|
|
473
|
-
}
|
|
474
|
-
if (result != null && typeof result.catch === "function") {
|
|
475
|
-
void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
|
|
721
|
+
return false;
|
|
476
722
|
}
|
|
477
723
|
}
|
|
478
|
-
function
|
|
724
|
+
function invokeTrackingSink(sink, event) {
|
|
479
725
|
let result;
|
|
480
726
|
try {
|
|
481
|
-
result =
|
|
727
|
+
result = sink(event);
|
|
482
728
|
} catch (err) {
|
|
483
|
-
warnDev(
|
|
484
|
-
|
|
729
|
+
warnDev("[lessonkit] tracking sink failed:", err);
|
|
730
|
+
throw err;
|
|
485
731
|
}
|
|
486
732
|
if (result != null && typeof result.catch === "function") {
|
|
487
|
-
void result.catch(
|
|
488
|
-
(err) => warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err)
|
|
489
|
-
);
|
|
733
|
+
void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
|
|
490
734
|
}
|
|
491
735
|
}
|
|
492
736
|
|
|
493
737
|
// src/trackingClient.ts
|
|
738
|
+
function eventDedupKey(event) {
|
|
739
|
+
const id = event.id?.trim();
|
|
740
|
+
return id || void 0;
|
|
741
|
+
}
|
|
494
742
|
function createTrackingClient(opts) {
|
|
495
743
|
const sink = opts?.sink;
|
|
496
744
|
const batchSink = opts?.batchSink;
|
|
@@ -508,8 +756,19 @@ function createTrackingClient(opts) {
|
|
|
508
756
|
let disposed2 = false;
|
|
509
757
|
return {
|
|
510
758
|
track: (event) => {
|
|
511
|
-
if (disposed2) return;
|
|
512
|
-
if (sink)
|
|
759
|
+
if (disposed2) return false;
|
|
760
|
+
if (sink) {
|
|
761
|
+
try {
|
|
762
|
+
invokeTrackingSink(sink, event);
|
|
763
|
+
} catch {
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return true;
|
|
767
|
+
},
|
|
768
|
+
deliver: async (event) => {
|
|
769
|
+
if (disposed2) return false;
|
|
770
|
+
if (!sink) return true;
|
|
771
|
+
return invokeTrackingSinkWithResult(sink, event);
|
|
513
772
|
},
|
|
514
773
|
dispose: () => {
|
|
515
774
|
disposed2 = true;
|
|
@@ -517,19 +776,28 @@ function createTrackingClient(opts) {
|
|
|
517
776
|
};
|
|
518
777
|
}
|
|
519
778
|
if (!sink && !batchSink) {
|
|
520
|
-
return { track: () =>
|
|
521
|
-
} };
|
|
779
|
+
return { track: () => true };
|
|
522
780
|
}
|
|
523
781
|
const buffer = [];
|
|
782
|
+
const pendingDeliverIds = /* @__PURE__ */ new Set();
|
|
524
783
|
let flushInFlight = null;
|
|
525
|
-
let inflightExitBatch = null;
|
|
526
784
|
let disposed = false;
|
|
527
785
|
let disposing = false;
|
|
528
786
|
let intervalId;
|
|
787
|
+
const clearPendingDeliverIds = (events) => {
|
|
788
|
+
for (const event of events) {
|
|
789
|
+
const key = eventDedupKey(event);
|
|
790
|
+
if (key) pendingDeliverIds.delete(key);
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
const isEventBuffered = (event) => {
|
|
794
|
+
const key = eventDedupKey(event);
|
|
795
|
+
if (!key) return false;
|
|
796
|
+
return buffer.some((buffered) => eventDedupKey(buffered) === key);
|
|
797
|
+
};
|
|
529
798
|
const runFlush = () => {
|
|
530
799
|
if (!buffer.length) return Promise.resolve(true);
|
|
531
800
|
const events = buffer.splice(0, buffer.length);
|
|
532
|
-
inflightExitBatch = events;
|
|
533
801
|
let succeeded = false;
|
|
534
802
|
return Promise.resolve().then(async () => {
|
|
535
803
|
if (batchSink) {
|
|
@@ -550,12 +818,13 @@ function createTrackingClient(opts) {
|
|
|
550
818
|
buffer.unshift(...events);
|
|
551
819
|
}
|
|
552
820
|
}).then(async () => {
|
|
821
|
+
if (succeeded) {
|
|
822
|
+
clearPendingDeliverIds(events);
|
|
823
|
+
}
|
|
553
824
|
if (succeeded && buffer.length > 0 && !disposed) {
|
|
554
825
|
return runFlush();
|
|
555
826
|
}
|
|
556
827
|
return succeeded;
|
|
557
|
-
}).finally(() => {
|
|
558
|
-
inflightExitBatch = null;
|
|
559
828
|
});
|
|
560
829
|
};
|
|
561
830
|
const flush = () => {
|
|
@@ -576,44 +845,66 @@ function createTrackingClient(opts) {
|
|
|
576
845
|
if (!delivered) break;
|
|
577
846
|
}
|
|
578
847
|
if (buffer.length > 0) {
|
|
848
|
+
const droppedCount = buffer.length;
|
|
579
849
|
if (isDevEnvironment()) {
|
|
580
850
|
console.warn(
|
|
581
|
-
`[lessonkit] dropped ${
|
|
851
|
+
`[lessonkit] dropped ${droppedCount} buffered telemetry event(s) after dispose flush cap`
|
|
582
852
|
);
|
|
583
853
|
}
|
|
854
|
+
for (let i = 0; i < droppedCount; i++) {
|
|
855
|
+
opts?.onBufferDrop?.();
|
|
856
|
+
}
|
|
584
857
|
buffer.length = 0;
|
|
858
|
+
pendingDeliverIds.clear();
|
|
585
859
|
}
|
|
586
860
|
};
|
|
587
861
|
intervalId = flushIntervalMs > 0 ? globalThis.setInterval(() => void flush(), flushIntervalMs) : void 0;
|
|
588
862
|
intervalId?.unref?.();
|
|
863
|
+
const track = (event) => {
|
|
864
|
+
if (disposed || disposing) return false;
|
|
865
|
+
const key = eventDedupKey(event);
|
|
866
|
+
if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
|
|
867
|
+
return true;
|
|
868
|
+
}
|
|
869
|
+
if (buffer.length >= maxBufferSize) {
|
|
870
|
+
opts?.onBufferDrop?.();
|
|
871
|
+
if (!warnedBufferCap && isDevEnvironment()) {
|
|
872
|
+
warnedBufferCap = true;
|
|
873
|
+
console.warn(
|
|
874
|
+
`[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
buffer.push(event);
|
|
880
|
+
if (buffer.length >= maxBatchSize) void flush();
|
|
881
|
+
return true;
|
|
882
|
+
};
|
|
589
883
|
return {
|
|
590
|
-
track
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
warnedBufferCap = true;
|
|
596
|
-
console.warn(
|
|
597
|
-
`[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
|
|
598
|
-
);
|
|
599
|
-
}
|
|
600
|
-
return;
|
|
884
|
+
track,
|
|
885
|
+
deliver: async (event) => {
|
|
886
|
+
const key = eventDedupKey(event);
|
|
887
|
+
if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
|
|
888
|
+
return flush();
|
|
601
889
|
}
|
|
602
|
-
|
|
603
|
-
if (
|
|
890
|
+
if (!track(event)) return false;
|
|
891
|
+
if (key) pendingDeliverIds.add(key);
|
|
892
|
+
return flush();
|
|
604
893
|
},
|
|
605
894
|
flush,
|
|
606
895
|
flushOnExit: opts?.exitBatchSink ? () => {
|
|
607
|
-
const
|
|
608
|
-
const fromInflight = inflightExitBatch ? [...inflightExitBatch] : [];
|
|
609
|
-
const events = [...fromInflight, ...fromBuffer];
|
|
896
|
+
const events = buffer.splice(0, buffer.length);
|
|
610
897
|
if (!events.length) return;
|
|
611
898
|
try {
|
|
612
899
|
const result = opts.exitBatchSink(events);
|
|
613
900
|
if (result != null && typeof result.catch === "function") {
|
|
614
|
-
void result.
|
|
901
|
+
void result.then(() => {
|
|
902
|
+
clearPendingDeliverIds(events);
|
|
903
|
+
}).catch(() => {
|
|
615
904
|
buffer.unshift(...events);
|
|
616
905
|
});
|
|
906
|
+
} else {
|
|
907
|
+
clearPendingDeliverIds(events);
|
|
617
908
|
}
|
|
618
909
|
} catch {
|
|
619
910
|
buffer.unshift(...events);
|
|
@@ -634,282 +925,45 @@ function createTrackingClient(opts) {
|
|
|
634
925
|
};
|
|
635
926
|
}
|
|
636
927
|
|
|
637
|
-
// src/
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
function
|
|
646
|
-
return
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
function withLessonScopedData(name, base, lessonId, data) {
|
|
656
|
-
return { name, ...base, lessonId, data: { ...data, lessonId } };
|
|
657
|
-
}
|
|
658
|
-
var TELEMETRY_EVENT_REGISTRY = {
|
|
659
|
-
course_started: {
|
|
660
|
-
build: (_opts, base) => ({ name: "course_started", ...base })
|
|
661
|
-
},
|
|
662
|
-
course_completed: {
|
|
663
|
-
build: (_opts, base) => ({ name: "course_completed", ...base })
|
|
664
|
-
},
|
|
665
|
-
lesson_started: {
|
|
666
|
-
requiresLessonId: true,
|
|
667
|
-
build: (opts, base) => {
|
|
668
|
-
if (opts.name !== "lesson_started") throw new Error("unexpected event");
|
|
669
|
-
const lessonId = resolveLessonId(opts, "lesson_started");
|
|
670
|
-
return withLessonScopedData("lesson_started", base, lessonId, opts.data);
|
|
671
|
-
}
|
|
672
|
-
},
|
|
673
|
-
lesson_completed: {
|
|
674
|
-
requiresLessonId: true,
|
|
675
|
-
build: (opts, base) => {
|
|
676
|
-
if (opts.name !== "lesson_completed") throw new Error("unexpected event");
|
|
677
|
-
const lessonId = resolveLessonId(opts, opts.name);
|
|
678
|
-
return withLessonScopedData(opts.name, base, lessonId, opts.data);
|
|
679
|
-
}
|
|
680
|
-
},
|
|
681
|
-
lesson_time_on_task: {
|
|
682
|
-
requiresLessonId: true,
|
|
683
|
-
build: (opts, base) => {
|
|
684
|
-
if (opts.name !== "lesson_time_on_task") throw new Error("unexpected event");
|
|
685
|
-
const lessonId = resolveLessonId(opts, opts.name);
|
|
686
|
-
return withLessonScopedData(opts.name, base, lessonId, opts.data);
|
|
687
|
-
}
|
|
688
|
-
},
|
|
689
|
-
quiz_answered: {
|
|
690
|
-
requiresLessonId: true,
|
|
691
|
-
tryBuildMissingLessonWarning: "quiz",
|
|
692
|
-
build: (opts, base) => {
|
|
693
|
-
if (opts.name !== "quiz_answered") throw new Error("unexpected event");
|
|
694
|
-
const lessonId = opts.lessonId;
|
|
695
|
-
if (!lessonId) throw new Error("quiz_answered requires active lessonId");
|
|
696
|
-
return {
|
|
697
|
-
name: "quiz_answered",
|
|
698
|
-
...base,
|
|
699
|
-
lessonId,
|
|
700
|
-
data: opts.data
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
},
|
|
704
|
-
quiz_completed: {
|
|
705
|
-
requiresLessonId: true,
|
|
706
|
-
tryBuildMissingLessonWarning: "quiz",
|
|
707
|
-
build: (opts, base) => {
|
|
708
|
-
if (opts.name !== "quiz_completed") throw new Error("unexpected event");
|
|
709
|
-
const lessonId = opts.lessonId;
|
|
710
|
-
if (!lessonId) throw new Error("quiz_completed requires active lessonId");
|
|
711
|
-
return {
|
|
712
|
-
name: "quiz_completed",
|
|
713
|
-
...base,
|
|
714
|
-
lessonId,
|
|
715
|
-
data: opts.data
|
|
716
|
-
};
|
|
717
|
-
}
|
|
718
|
-
},
|
|
719
|
-
assessment_answered: {
|
|
720
|
-
requiresLessonId: true,
|
|
721
|
-
tryBuildMissingLessonWarning: "assessment",
|
|
722
|
-
build: (opts, base) => {
|
|
723
|
-
if (opts.name !== "assessment_answered") throw new Error("unexpected event");
|
|
724
|
-
const lessonId = opts.lessonId;
|
|
725
|
-
if (!lessonId) throw new Error("assessment_answered requires active lessonId");
|
|
726
|
-
return {
|
|
727
|
-
name: "assessment_answered",
|
|
728
|
-
...base,
|
|
729
|
-
lessonId,
|
|
730
|
-
data: opts.data
|
|
731
|
-
};
|
|
732
|
-
}
|
|
733
|
-
},
|
|
734
|
-
assessment_completed: {
|
|
735
|
-
requiresLessonId: true,
|
|
736
|
-
tryBuildMissingLessonWarning: "assessment",
|
|
737
|
-
build: (opts, base) => {
|
|
738
|
-
if (opts.name !== "assessment_completed") throw new Error("unexpected event");
|
|
739
|
-
const lessonId = opts.lessonId;
|
|
740
|
-
if (!lessonId) throw new Error("assessment_completed requires active lessonId");
|
|
741
|
-
return {
|
|
742
|
-
name: "assessment_completed",
|
|
743
|
-
...base,
|
|
744
|
-
lessonId,
|
|
745
|
-
data: opts.data
|
|
746
|
-
};
|
|
747
|
-
}
|
|
748
|
-
},
|
|
749
|
-
interaction: {
|
|
750
|
-
build: (opts, base) => {
|
|
751
|
-
if (opts.name !== "interaction") throw new Error("unexpected event");
|
|
752
|
-
return {
|
|
753
|
-
name: "interaction",
|
|
754
|
-
...base,
|
|
755
|
-
lessonId: opts.lessonId,
|
|
756
|
-
data: opts.data
|
|
757
|
-
};
|
|
758
|
-
}
|
|
759
|
-
},
|
|
760
|
-
book_page_viewed: {
|
|
761
|
-
requiresLessonId: true,
|
|
762
|
-
build: (opts, base) => {
|
|
763
|
-
if (opts.name !== "book_page_viewed") throw new Error("unexpected event");
|
|
764
|
-
const lessonId = opts.lessonId;
|
|
765
|
-
if (!lessonId) throw new Error("book_page_viewed requires active lessonId");
|
|
766
|
-
return {
|
|
767
|
-
name: "book_page_viewed",
|
|
768
|
-
...base,
|
|
769
|
-
lessonId,
|
|
770
|
-
data: opts.data
|
|
771
|
-
};
|
|
772
|
-
}
|
|
773
|
-
},
|
|
774
|
-
slide_viewed: {
|
|
775
|
-
requiresLessonId: true,
|
|
776
|
-
build: (opts, base) => {
|
|
777
|
-
if (opts.name !== "slide_viewed") throw new Error("unexpected event");
|
|
778
|
-
const lessonId = opts.lessonId;
|
|
779
|
-
if (!lessonId) throw new Error("slide_viewed requires active lessonId");
|
|
780
|
-
return {
|
|
781
|
-
name: "slide_viewed",
|
|
782
|
-
...base,
|
|
783
|
-
lessonId,
|
|
784
|
-
data: opts.data
|
|
785
|
-
};
|
|
786
|
-
}
|
|
787
|
-
},
|
|
788
|
-
compound_page_viewed: {
|
|
789
|
-
requiresLessonId: true,
|
|
790
|
-
build: (opts, base) => {
|
|
791
|
-
if (opts.name !== "compound_page_viewed") throw new Error("unexpected event");
|
|
792
|
-
const lessonId = opts.lessonId;
|
|
793
|
-
if (!lessonId) throw new Error("compound_page_viewed requires active lessonId");
|
|
794
|
-
return {
|
|
795
|
-
name: "compound_page_viewed",
|
|
796
|
-
...base,
|
|
797
|
-
lessonId,
|
|
798
|
-
data: opts.data
|
|
799
|
-
};
|
|
800
|
-
}
|
|
801
|
-
},
|
|
802
|
-
hotspot_opened: {
|
|
803
|
-
build: (opts, base) => {
|
|
804
|
-
if (opts.name !== "hotspot_opened") throw new Error("unexpected event");
|
|
805
|
-
return {
|
|
806
|
-
name: "hotspot_opened",
|
|
807
|
-
...base,
|
|
808
|
-
lessonId: opts.lessonId,
|
|
809
|
-
data: opts.data
|
|
810
|
-
};
|
|
811
|
-
}
|
|
812
|
-
},
|
|
813
|
-
accordion_section_toggled: {
|
|
814
|
-
build: (opts, base) => {
|
|
815
|
-
if (opts.name !== "accordion_section_toggled") throw new Error("unexpected event");
|
|
816
|
-
return {
|
|
817
|
-
name: "accordion_section_toggled",
|
|
818
|
-
...base,
|
|
819
|
-
lessonId: opts.lessonId,
|
|
820
|
-
data: opts.data
|
|
821
|
-
};
|
|
822
|
-
}
|
|
823
|
-
},
|
|
824
|
-
flashcard_flipped: {
|
|
825
|
-
build: (opts, base) => {
|
|
826
|
-
if (opts.name !== "flashcard_flipped") throw new Error("unexpected event");
|
|
827
|
-
return {
|
|
828
|
-
name: "flashcard_flipped",
|
|
829
|
-
...base,
|
|
830
|
-
lessonId: opts.lessonId,
|
|
831
|
-
data: opts.data
|
|
832
|
-
};
|
|
833
|
-
}
|
|
834
|
-
},
|
|
835
|
-
image_slider_changed: {
|
|
836
|
-
build: (opts, base) => {
|
|
837
|
-
if (opts.name !== "image_slider_changed") throw new Error("unexpected event");
|
|
838
|
-
return {
|
|
839
|
-
name: "image_slider_changed",
|
|
840
|
-
...base,
|
|
841
|
-
lessonId: opts.lessonId,
|
|
842
|
-
data: opts.data
|
|
843
|
-
};
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
};
|
|
847
|
-
function buildTelemetryEventFromRegistry(opts) {
|
|
848
|
-
const entry = TELEMETRY_EVENT_REGISTRY[opts.name];
|
|
849
|
-
if (!entry) {
|
|
850
|
-
throw new Error("Unexpected value");
|
|
928
|
+
// src/telemetryPipeline.ts
|
|
929
|
+
var LIFECYCLE_TELEMETRY_EVENTS = /* @__PURE__ */ new Set([
|
|
930
|
+
"course_started",
|
|
931
|
+
"course_completed",
|
|
932
|
+
"lesson_started",
|
|
933
|
+
"lesson_completed",
|
|
934
|
+
"lesson_time_on_task"
|
|
935
|
+
]);
|
|
936
|
+
function isLifecycleTelemetryEvent(name) {
|
|
937
|
+
return LIFECYCLE_TELEMETRY_EVENTS.has(name);
|
|
938
|
+
}
|
|
939
|
+
async function invokeSink(sink, event, emitCtx) {
|
|
940
|
+
let result;
|
|
941
|
+
try {
|
|
942
|
+
result = sink.emit(event, emitCtx);
|
|
943
|
+
} catch (err) {
|
|
944
|
+
warnDev(`[lessonkit] telemetry sink "${sink.id}" failed:`, err);
|
|
945
|
+
return;
|
|
851
946
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
user: opts.user
|
|
858
|
-
};
|
|
859
|
-
return entry.build(opts, base);
|
|
860
|
-
}
|
|
861
|
-
function getTelemetryEventRegistryEntry(name) {
|
|
862
|
-
return TELEMETRY_EVENT_REGISTRY[name];
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
// src/telemetryBuilder.ts
|
|
866
|
-
var warnedMissingQuizLesson = false;
|
|
867
|
-
var warnedMissingAssessmentLesson = false;
|
|
868
|
-
function resetTelemetryBuilderWarningsForTests() {
|
|
869
|
-
warnedMissingQuizLesson = false;
|
|
870
|
-
warnedMissingAssessmentLesson = false;
|
|
871
|
-
}
|
|
872
|
-
function buildTelemetryEvent(opts) {
|
|
873
|
-
return buildTelemetryEventFromRegistry(opts);
|
|
874
|
-
}
|
|
875
|
-
function tryBuildTelemetryEvent(opts) {
|
|
876
|
-
const entry = getTelemetryEventRegistryEntry(opts.name);
|
|
877
|
-
if (entry.requiresLessonId && !opts.lessonId && entry.tryBuildMissingLessonWarning) {
|
|
878
|
-
if (isDevEnvironment()) {
|
|
879
|
-
if (entry.tryBuildMissingLessonWarning === "quiz" && !warnedMissingQuizLesson) {
|
|
880
|
-
warnedMissingQuizLesson = true;
|
|
881
|
-
console.warn(
|
|
882
|
-
`[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
|
|
883
|
-
);
|
|
884
|
-
}
|
|
885
|
-
if (entry.tryBuildMissingLessonWarning === "assessment" && !warnedMissingAssessmentLesson) {
|
|
886
|
-
warnedMissingAssessmentLesson = true;
|
|
887
|
-
console.warn(
|
|
888
|
-
`[lessonkit] ${opts.name} skipped: wrap assessment blocks in <Lesson> so an active lessonId is available`
|
|
889
|
-
);
|
|
890
|
-
}
|
|
947
|
+
if (result != null && typeof result.then === "function") {
|
|
948
|
+
try {
|
|
949
|
+
await result;
|
|
950
|
+
} catch (err) {
|
|
951
|
+
warnDev(`[lessonkit] telemetry sink "${sink.id}" failed:`, err);
|
|
891
952
|
}
|
|
892
|
-
return null;
|
|
893
953
|
}
|
|
894
|
-
return buildTelemetryEvent(opts);
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
// src/telemetryPipeline.ts
|
|
898
|
-
function invokeSink(sink, event, emitCtx) {
|
|
899
|
-
invokePipelineSink(sink.id, () => sink.emit(event, emitCtx));
|
|
900
954
|
}
|
|
901
955
|
function createTelemetryPipeline(sinks) {
|
|
902
956
|
const list = [...sinks];
|
|
903
957
|
return {
|
|
904
958
|
sinks: list,
|
|
905
|
-
emit(event, ctx) {
|
|
959
|
+
async emit(event, ctx) {
|
|
906
960
|
const emitCtx = ctx ?? {
|
|
907
961
|
courseId: event.courseId,
|
|
908
962
|
sessionId: event.sessionId,
|
|
909
963
|
attemptId: event.attemptId
|
|
910
964
|
};
|
|
911
965
|
for (const sink of list) {
|
|
912
|
-
invokeSink(sink, event, emitCtx);
|
|
966
|
+
await invokeSink(sink, event, emitCtx);
|
|
913
967
|
}
|
|
914
968
|
}
|
|
915
969
|
};
|
|
@@ -923,109 +977,6 @@ function createTrackingPipelineSink(id, track) {
|
|
|
923
977
|
};
|
|
924
978
|
}
|
|
925
979
|
|
|
926
|
-
// src/ports.ts
|
|
927
|
-
function createDefaultClock() {
|
|
928
|
-
return {
|
|
929
|
-
nowMs: () => Date.now(),
|
|
930
|
-
nowIso: () => (/* @__PURE__ */ new Date()).toISOString()
|
|
931
|
-
};
|
|
932
|
-
}
|
|
933
|
-
function createNoopStorage() {
|
|
934
|
-
return {
|
|
935
|
-
getItem: () => null,
|
|
936
|
-
setItem: () => true
|
|
937
|
-
};
|
|
938
|
-
}
|
|
939
|
-
function createMemoryBackedSessionStorage(session) {
|
|
940
|
-
const memory = /* @__PURE__ */ new Map();
|
|
941
|
-
let warnedPersistFailure = false;
|
|
942
|
-
const warnPersistFailure = () => {
|
|
943
|
-
if (warnedPersistFailure) return;
|
|
944
|
-
warnedPersistFailure = true;
|
|
945
|
-
const g = globalThis;
|
|
946
|
-
if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "development") {
|
|
947
|
-
console.warn(
|
|
948
|
-
"[lessonkit] sessionStorage is unavailable or failed; using in-memory session dedupe for this tab (may reset on full reload)."
|
|
949
|
-
);
|
|
950
|
-
}
|
|
951
|
-
};
|
|
952
|
-
return {
|
|
953
|
-
getItem: (key) => {
|
|
954
|
-
if (memory.has(key)) return memory.get(key);
|
|
955
|
-
try {
|
|
956
|
-
const value = session.getItem(key);
|
|
957
|
-
if (value !== null) memory.set(key, value);
|
|
958
|
-
return value;
|
|
959
|
-
} catch {
|
|
960
|
-
return memory.get(key) ?? null;
|
|
961
|
-
}
|
|
962
|
-
},
|
|
963
|
-
setItem: (key, value) => {
|
|
964
|
-
memory.set(key, value);
|
|
965
|
-
try {
|
|
966
|
-
session.setItem(key, value);
|
|
967
|
-
return true;
|
|
968
|
-
} catch {
|
|
969
|
-
warnPersistFailure();
|
|
970
|
-
return false;
|
|
971
|
-
}
|
|
972
|
-
},
|
|
973
|
-
removeItem: (key) => {
|
|
974
|
-
memory.delete(key);
|
|
975
|
-
try {
|
|
976
|
-
session.removeItem(key);
|
|
977
|
-
} catch {
|
|
978
|
-
warnPersistFailure();
|
|
979
|
-
}
|
|
980
|
-
},
|
|
981
|
-
resetForTests: () => {
|
|
982
|
-
memory.clear();
|
|
983
|
-
}
|
|
984
|
-
};
|
|
985
|
-
}
|
|
986
|
-
function resetStoragePortForTests(storage) {
|
|
987
|
-
storage.resetForTests?.();
|
|
988
|
-
}
|
|
989
|
-
function createInMemorySessionStoragePort() {
|
|
990
|
-
const memory = /* @__PURE__ */ new Map();
|
|
991
|
-
return {
|
|
992
|
-
getItem: (key) => memory.get(key) ?? null,
|
|
993
|
-
setItem: (key, value) => {
|
|
994
|
-
memory.set(key, value);
|
|
995
|
-
return true;
|
|
996
|
-
},
|
|
997
|
-
removeItem: (key) => {
|
|
998
|
-
memory.delete(key);
|
|
999
|
-
},
|
|
1000
|
-
resetForTests: () => {
|
|
1001
|
-
memory.clear();
|
|
1002
|
-
}
|
|
1003
|
-
};
|
|
1004
|
-
}
|
|
1005
|
-
function resolveBrowserSessionStorage() {
|
|
1006
|
-
try {
|
|
1007
|
-
if (typeof sessionStorage === "undefined" || sessionStorage == null) {
|
|
1008
|
-
return null;
|
|
1009
|
-
}
|
|
1010
|
-
return sessionStorage;
|
|
1011
|
-
} catch {
|
|
1012
|
-
return null;
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
function createSessionStoragePort() {
|
|
1016
|
-
const session = resolveBrowserSessionStorage();
|
|
1017
|
-
if (!session) {
|
|
1018
|
-
return createInMemorySessionStoragePort();
|
|
1019
|
-
}
|
|
1020
|
-
return createMemoryBackedSessionStorage(session);
|
|
1021
|
-
}
|
|
1022
|
-
function createGlobalTimer() {
|
|
1023
|
-
return {
|
|
1024
|
-
setInterval: (fn, ms) => globalThis.setInterval(fn, ms),
|
|
1025
|
-
clearInterval: (id) => globalThis.clearInterval(id)
|
|
1026
|
-
};
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
980
|
// src/progress.ts
|
|
1030
981
|
function createProgressController() {
|
|
1031
982
|
let activeLessonId;
|
|
@@ -1051,6 +1002,11 @@ function createProgressController() {
|
|
|
1051
1002
|
}
|
|
1052
1003
|
return { didComplete: false };
|
|
1053
1004
|
}
|
|
1005
|
+
if (!lessonStartTimes.has(lessonId) && isDevEnvironment()) {
|
|
1006
|
+
console.warn(
|
|
1007
|
+
`[lessonkit] completeLesson("${lessonId}") called without activating the lesson first`
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1054
1010
|
completedLessonIds = new Set(completedLessonIds).add(lessonId);
|
|
1055
1011
|
if (activeLessonId === lessonId) {
|
|
1056
1012
|
activeLessonId = void 0;
|
|
@@ -1068,153 +1024,6 @@ function createProgressController() {
|
|
|
1068
1024
|
};
|
|
1069
1025
|
}
|
|
1070
1026
|
|
|
1071
|
-
// src/session.ts
|
|
1072
|
-
var SESSION_STORAGE_KEY = "lessonkit:sessionId";
|
|
1073
|
-
var volatileSessionIds = /* @__PURE__ */ new WeakMap();
|
|
1074
|
-
var sharedVolatileSessionId = null;
|
|
1075
|
-
function isDevEnvironment2() {
|
|
1076
|
-
const g = globalThis;
|
|
1077
|
-
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
1078
|
-
}
|
|
1079
|
-
function getTabSessionId(storage) {
|
|
1080
|
-
return storage.getItem(SESSION_STORAGE_KEY);
|
|
1081
|
-
}
|
|
1082
|
-
var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
|
|
1083
|
-
var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
|
|
1084
|
-
var COURSE_STARTED_PIPELINE_PREFIX = "lessonkit:course_started_pipeline:";
|
|
1085
|
-
function resolveSessionId(storage, provided) {
|
|
1086
|
-
if (provided !== void 0) {
|
|
1087
|
-
const trimmed = provided.trim();
|
|
1088
|
-
if (trimmed.length > 0) return trimmed;
|
|
1089
|
-
}
|
|
1090
|
-
const existing = storage.getItem(SESSION_STORAGE_KEY);
|
|
1091
|
-
if (existing) return existing;
|
|
1092
|
-
const volatile = volatileSessionIds.get(storage);
|
|
1093
|
-
if (volatile) return volatile;
|
|
1094
|
-
const id = createSessionId();
|
|
1095
|
-
const persisted = storage.setItem(SESSION_STORAGE_KEY, id);
|
|
1096
|
-
if (!persisted) {
|
|
1097
|
-
if (!sharedVolatileSessionId) {
|
|
1098
|
-
sharedVolatileSessionId = id;
|
|
1099
|
-
}
|
|
1100
|
-
volatileSessionIds.set(storage, sharedVolatileSessionId);
|
|
1101
|
-
if (isDevEnvironment2()) {
|
|
1102
|
-
console.warn(
|
|
1103
|
-
"[lessonkit] session id could not be persisted; reusing in-memory id for this tab."
|
|
1104
|
-
);
|
|
1105
|
-
}
|
|
1106
|
-
return sharedVolatileSessionId;
|
|
1107
|
-
}
|
|
1108
|
-
return id;
|
|
1109
|
-
}
|
|
1110
|
-
function courseStartedStorageKey(sessionId, courseId) {
|
|
1111
|
-
return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
|
|
1112
|
-
}
|
|
1113
|
-
function courseStartedTrackingStorageKey(sessionId, courseId) {
|
|
1114
|
-
return `${COURSE_STARTED_TRACKING_PREFIX}${sessionId}:${courseId ?? ""}`;
|
|
1115
|
-
}
|
|
1116
|
-
function courseStartedPipelineStorageKey(sessionId, courseId) {
|
|
1117
|
-
return `${COURSE_STARTED_PIPELINE_PREFIX}${sessionId}:${courseId ?? ""}`;
|
|
1118
|
-
}
|
|
1119
|
-
function hasCourseStarted(storage, sessionId, courseId) {
|
|
1120
|
-
if (!courseId) return false;
|
|
1121
|
-
return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
|
|
1122
|
-
}
|
|
1123
|
-
function markCourseStarted(storage, sessionId, courseId) {
|
|
1124
|
-
if (!courseId) return false;
|
|
1125
|
-
return storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
|
|
1126
|
-
}
|
|
1127
|
-
function hasCourseStartedEmittedToTracking(storage, sessionId, courseId) {
|
|
1128
|
-
if (!courseId) return false;
|
|
1129
|
-
return storage.getItem(courseStartedTrackingStorageKey(sessionId, courseId)) === "1";
|
|
1130
|
-
}
|
|
1131
|
-
function markCourseStartedEmittedToTracking(storage, sessionId, courseId) {
|
|
1132
|
-
if (!courseId) return false;
|
|
1133
|
-
return storage.setItem(courseStartedTrackingStorageKey(sessionId, courseId), "1");
|
|
1134
|
-
}
|
|
1135
|
-
function hasCourseStartedPipelineDelivered(storage, sessionId, courseId) {
|
|
1136
|
-
if (!courseId) return false;
|
|
1137
|
-
return storage.getItem(courseStartedPipelineStorageKey(sessionId, courseId)) === "1";
|
|
1138
|
-
}
|
|
1139
|
-
function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
|
|
1140
|
-
if (!courseId) return false;
|
|
1141
|
-
return storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
|
|
1142
|
-
}
|
|
1143
|
-
function resetSharedVolatileSessionIdForTests() {
|
|
1144
|
-
sharedVolatileSessionId = null;
|
|
1145
|
-
}
|
|
1146
|
-
function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
|
|
1147
|
-
if (!courseId || fromSessionId === toSessionId) return;
|
|
1148
|
-
if (hasCourseStarted(storage, fromSessionId, courseId)) {
|
|
1149
|
-
markCourseStarted(storage, toSessionId, courseId);
|
|
1150
|
-
storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
|
|
1151
|
-
}
|
|
1152
|
-
if (hasCourseStartedEmittedToTracking(storage, fromSessionId, courseId)) {
|
|
1153
|
-
markCourseStartedEmittedToTracking(storage, toSessionId, courseId);
|
|
1154
|
-
storage.removeItem?.(courseStartedTrackingStorageKey(fromSessionId, courseId));
|
|
1155
|
-
}
|
|
1156
|
-
if (hasCourseStartedPipelineDelivered(storage, fromSessionId, courseId)) {
|
|
1157
|
-
markCourseStartedPipelineDelivered(storage, toSessionId, courseId);
|
|
1158
|
-
storage.removeItem?.(courseStartedPipelineStorageKey(fromSessionId, courseId));
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
// src/runtime/courseLifecycle.ts
|
|
1163
|
-
var courseStartedEmitFlights = /* @__PURE__ */ new Set();
|
|
1164
|
-
function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
|
|
1165
|
-
const flightKey = `${ctx.sessionId}:${ctx.courseId}`;
|
|
1166
|
-
const marked = hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
|
|
1167
|
-
if (alreadyEmittedToSink) {
|
|
1168
|
-
return { emitted: true, marked };
|
|
1169
|
-
}
|
|
1170
|
-
if (courseStartedEmitFlights.has(flightKey)) {
|
|
1171
|
-
return { emitted: false, marked };
|
|
1172
|
-
}
|
|
1173
|
-
courseStartedEmitFlights.add(flightKey);
|
|
1174
|
-
try {
|
|
1175
|
-
const emitted = deps.emitCourseStartedEvent(ctx);
|
|
1176
|
-
if (emitted && !marked) {
|
|
1177
|
-
markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
|
|
1178
|
-
}
|
|
1179
|
-
return {
|
|
1180
|
-
emitted,
|
|
1181
|
-
marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
|
|
1182
|
-
};
|
|
1183
|
-
} finally {
|
|
1184
|
-
courseStartedEmitFlights.delete(flightKey);
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
function buildCourseStartedTelemetryEvent(ctx) {
|
|
1188
|
-
return buildTelemetryEvent({
|
|
1189
|
-
name: "course_started",
|
|
1190
|
-
courseId: ctx.courseId,
|
|
1191
|
-
sessionId: ctx.sessionId,
|
|
1192
|
-
attemptId: ctx.attemptId,
|
|
1193
|
-
user: ctx.user
|
|
1194
|
-
});
|
|
1195
|
-
}
|
|
1196
|
-
function completeLessonWithTelemetry(opts) {
|
|
1197
|
-
const result = opts.progress.completeLesson(opts.lessonId, opts.nowMs);
|
|
1198
|
-
if (!result.didComplete) return false;
|
|
1199
|
-
opts.emitLessonCompleted(opts.lessonId, result.durationMs);
|
|
1200
|
-
return true;
|
|
1201
|
-
}
|
|
1202
|
-
function completeCourseWithTelemetry(opts) {
|
|
1203
|
-
const current = opts.progress.getState();
|
|
1204
|
-
if (current.activeLessonId) {
|
|
1205
|
-
completeLessonWithTelemetry({
|
|
1206
|
-
progress: opts.progress,
|
|
1207
|
-
lessonId: current.activeLessonId,
|
|
1208
|
-
nowMs: opts.nowMs,
|
|
1209
|
-
emitLessonCompleted: opts.emitLessonCompleted
|
|
1210
|
-
});
|
|
1211
|
-
}
|
|
1212
|
-
const result = opts.progress.completeCourse();
|
|
1213
|
-
if (!result.didComplete) return false;
|
|
1214
|
-
opts.emitCourseCompleted();
|
|
1215
|
-
return true;
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
1027
|
// src/plugins/context.ts
|
|
1219
1028
|
function buildPluginContext(opts) {
|
|
1220
1029
|
return {
|
|
@@ -1231,6 +1040,20 @@ function warnDuplicatePlugin(id) {
|
|
|
1231
1040
|
if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
|
|
1232
1041
|
console.warn(`[lessonkit] plugin id "${id}" was registered more than once; using the latest definition`);
|
|
1233
1042
|
}
|
|
1043
|
+
function stableUserHash(user) {
|
|
1044
|
+
if (!user) return "";
|
|
1045
|
+
const keys = Object.keys(user).sort();
|
|
1046
|
+
const normalized = {};
|
|
1047
|
+
for (const key of keys) {
|
|
1048
|
+
normalized[key] = user[key];
|
|
1049
|
+
}
|
|
1050
|
+
let h = 0;
|
|
1051
|
+
const serialized = JSON.stringify(normalized);
|
|
1052
|
+
for (let i = 0; i < serialized.length; i++) {
|
|
1053
|
+
h = Math.imul(31, h) + serialized.charCodeAt(i) >>> 0;
|
|
1054
|
+
}
|
|
1055
|
+
return h.toString(36);
|
|
1056
|
+
}
|
|
1234
1057
|
function createPluginRegistry(plugins = []) {
|
|
1235
1058
|
const registry = /* @__PURE__ */ new Map();
|
|
1236
1059
|
for (const plugin of plugins) {
|
|
@@ -1272,7 +1095,7 @@ function createPluginRegistry(plugins = []) {
|
|
|
1272
1095
|
const composeTrackingSink = (sink, ctxSource) => {
|
|
1273
1096
|
if (!sink) return void 0;
|
|
1274
1097
|
const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
|
|
1275
|
-
const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${ctx.user
|
|
1098
|
+
const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${stableUserHash(ctx.user)}`;
|
|
1276
1099
|
const layers = [];
|
|
1277
1100
|
let composed = sink;
|
|
1278
1101
|
for (const plugin of list) {
|
|
@@ -1337,6 +1160,7 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1337
1160
|
let courseId = configSnapshot.courseId;
|
|
1338
1161
|
let progress = createProgressController();
|
|
1339
1162
|
let pluginHost = resolvePluginHost(configSnapshot.plugins);
|
|
1163
|
+
let disposed = false;
|
|
1340
1164
|
const getPluginCtx = () => buildPluginContext({
|
|
1341
1165
|
courseId,
|
|
1342
1166
|
sessionId,
|
|
@@ -1370,28 +1194,24 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1370
1194
|
if (!event) return null;
|
|
1371
1195
|
return applyPluginsToEvent(event);
|
|
1372
1196
|
};
|
|
1373
|
-
const
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
if (event === null) return;
|
|
1377
|
-
const eventLessonId = "lessonId" in event ? event.lessonId : lessonId;
|
|
1378
|
-
const eventData = "data" in event ? event.data : data;
|
|
1379
|
-
emitFn(event.name, eventData, eventLessonId);
|
|
1380
|
-
};
|
|
1197
|
+
const emitLifecycleEvent = (emitFn, name, data, lessonId) => {
|
|
1198
|
+
const event = buildAndApply(name, data, lessonId);
|
|
1199
|
+
if (event) emitFn(event);
|
|
1381
1200
|
};
|
|
1382
1201
|
syncSessionFromConfig(configSnapshot);
|
|
1383
1202
|
const track = (name, data, emit, lessonId) => {
|
|
1203
|
+
if (disposed) return;
|
|
1384
1204
|
const event = buildAndApply(name, data, lessonId);
|
|
1385
1205
|
if (!event) return;
|
|
1386
1206
|
emit(event);
|
|
1387
1207
|
};
|
|
1388
1208
|
const emitLessonCompletedEvents = (lessonId, durationMs, emitFn) => {
|
|
1389
|
-
|
|
1390
|
-
wrapped("lesson_completed", { lessonId, durationMs }, lessonId);
|
|
1209
|
+
emitLifecycleEvent(emitFn, "lesson_completed", { lessonId, durationMs }, lessonId);
|
|
1391
1210
|
if (durationMs !== void 0) {
|
|
1392
|
-
|
|
1211
|
+
emitLifecycleEvent(emitFn, "lesson_time_on_task", { lessonId, durationMs }, lessonId);
|
|
1393
1212
|
}
|
|
1394
1213
|
};
|
|
1214
|
+
const autoCompleteOnLessonSwitch = () => configSnapshot.autoCompleteOnLessonSwitch ?? true;
|
|
1395
1215
|
return {
|
|
1396
1216
|
get config() {
|
|
1397
1217
|
return configSnapshot;
|
|
@@ -1404,7 +1224,12 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1404
1224
|
},
|
|
1405
1225
|
getProgressState: () => progress.getState(),
|
|
1406
1226
|
getSession,
|
|
1227
|
+
migrateSessionMarks(fromSessionId, toSessionId) {
|
|
1228
|
+
if (disposed) return;
|
|
1229
|
+
migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId);
|
|
1230
|
+
},
|
|
1407
1231
|
updateConfig(next) {
|
|
1232
|
+
if (disposed) return;
|
|
1408
1233
|
const previousCourseId = courseId;
|
|
1409
1234
|
const sessionKeyBefore = JSON.stringify({ sessionId, attemptId, user });
|
|
1410
1235
|
if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
|
|
@@ -1412,6 +1237,9 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1412
1237
|
if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
|
|
1413
1238
|
configSnapshot.runtimeVersion = next.runtimeVersion;
|
|
1414
1239
|
}
|
|
1240
|
+
if (next.autoCompleteOnLessonSwitch !== void 0) {
|
|
1241
|
+
configSnapshot.autoCompleteOnLessonSwitch = next.autoCompleteOnLessonSwitch;
|
|
1242
|
+
}
|
|
1415
1243
|
if (next.session !== void 0) {
|
|
1416
1244
|
configSnapshot.session = { ...configSnapshot.session, ...next.session };
|
|
1417
1245
|
}
|
|
@@ -1420,61 +1248,75 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1420
1248
|
if (next.courseId !== void 0 && next.courseId !== previousCourseId) {
|
|
1421
1249
|
progress = createProgressController();
|
|
1422
1250
|
}
|
|
1423
|
-
if (next.plugins !== void 0 && next.plugins !==
|
|
1251
|
+
if (next.plugins !== void 0 && next.plugins !== configSnapshot.plugins) {
|
|
1424
1252
|
pluginHost?.disposeAll();
|
|
1425
1253
|
configSnapshot.plugins = next.plugins;
|
|
1426
1254
|
pluginHost = resolvePluginHost(configSnapshot.plugins);
|
|
1427
|
-
|
|
1255
|
+
if (!configSnapshot.deferPluginSetup) {
|
|
1256
|
+
pluginHost?.setupAll(getPluginCtx());
|
|
1257
|
+
}
|
|
1428
1258
|
} else if (next.session !== void 0 && sessionKeyBefore !== sessionKeyAfter && pluginHost && !configSnapshot.deferPluginSetup) {
|
|
1429
1259
|
pluginHost.disposeAll();
|
|
1430
1260
|
pluginHost.setupAll(getPluginCtx());
|
|
1431
1261
|
}
|
|
1432
1262
|
},
|
|
1433
1263
|
setActiveLesson(lessonId, emitFn) {
|
|
1434
|
-
|
|
1264
|
+
if (disposed) return;
|
|
1435
1265
|
const current = progress.getState();
|
|
1436
1266
|
if (current.activeLessonId === lessonId) return;
|
|
1437
|
-
if (current.completedLessonIds.has(lessonId)) {
|
|
1438
|
-
progress.setActiveLesson(lessonId, clock.nowMs());
|
|
1439
|
-
return;
|
|
1440
|
-
}
|
|
1441
1267
|
const previous = current.activeLessonId;
|
|
1442
|
-
if (previous && previous !== lessonId) {
|
|
1268
|
+
if (autoCompleteOnLessonSwitch() && previous && previous !== lessonId && !current.completedLessonIds.has(previous)) {
|
|
1443
1269
|
const completed = progress.completeLesson(previous, clock.nowMs());
|
|
1444
1270
|
if (completed.didComplete) {
|
|
1445
|
-
emitLessonCompletedEvents(previous, completed.durationMs,
|
|
1271
|
+
emitLessonCompletedEvents(previous, completed.durationMs, emitFn);
|
|
1446
1272
|
}
|
|
1447
1273
|
}
|
|
1274
|
+
if (current.completedLessonIds.has(lessonId)) {
|
|
1275
|
+
progress.setActiveLesson(lessonId, clock.nowMs());
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1448
1278
|
progress.setActiveLesson(lessonId, clock.nowMs());
|
|
1449
|
-
|
|
1279
|
+
emitLifecycleEvent(emitFn, "lesson_started", { lessonId }, lessonId);
|
|
1450
1280
|
},
|
|
1451
1281
|
completeLesson(lessonId, emitFn) {
|
|
1282
|
+
if (disposed) return;
|
|
1452
1283
|
completeLessonWithTelemetry({
|
|
1453
1284
|
progress,
|
|
1454
1285
|
lessonId,
|
|
1455
1286
|
nowMs: clock.nowMs(),
|
|
1456
|
-
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs,
|
|
1287
|
+
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn)
|
|
1457
1288
|
});
|
|
1458
1289
|
},
|
|
1459
1290
|
completeCourse(emitFn) {
|
|
1291
|
+
if (disposed) return;
|
|
1460
1292
|
completeCourseWithTelemetry({
|
|
1461
1293
|
progress,
|
|
1462
1294
|
nowMs: clock.nowMs(),
|
|
1463
|
-
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs,
|
|
1464
|
-
emitCourseCompleted: () =>
|
|
1295
|
+
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn),
|
|
1296
|
+
emitCourseCompleted: () => emitLifecycleEvent(emitFn, "course_completed")
|
|
1465
1297
|
});
|
|
1466
1298
|
},
|
|
1467
1299
|
track,
|
|
1468
|
-
scoreAssessment(input,
|
|
1469
|
-
if (!pluginHost) return null;
|
|
1470
|
-
return pluginHost.scoreAssessment(
|
|
1300
|
+
scoreAssessment(input, lessonId) {
|
|
1301
|
+
if (disposed || !pluginHost) return null;
|
|
1302
|
+
return pluginHost.scoreAssessment(
|
|
1303
|
+
{ ...input, lessonId: input.lessonId ?? lessonId },
|
|
1304
|
+
getPluginCtx()
|
|
1305
|
+
);
|
|
1471
1306
|
},
|
|
1472
1307
|
resetForCourseChange(nextCourseId) {
|
|
1308
|
+
if (disposed) return;
|
|
1473
1309
|
configSnapshot.courseId = nextCourseId;
|
|
1474
1310
|
courseId = nextCourseId;
|
|
1475
1311
|
progress = createProgressController();
|
|
1312
|
+
pluginHost?.disposeAll();
|
|
1313
|
+
if (!configSnapshot.deferPluginSetup) {
|
|
1314
|
+
pluginHost?.setupAll(getPluginCtx());
|
|
1315
|
+
}
|
|
1476
1316
|
},
|
|
1477
1317
|
dispose() {
|
|
1318
|
+
if (disposed) return;
|
|
1319
|
+
disposed = true;
|
|
1478
1320
|
pluginHost?.disposeAll();
|
|
1479
1321
|
}
|
|
1480
1322
|
};
|
|
@@ -1493,11 +1335,15 @@ function defineLifecyclePlugin(plugin) {
|
|
|
1493
1335
|
export {
|
|
1494
1336
|
ACCORDION_FORBIDDEN_CHILD_TYPES,
|
|
1495
1337
|
ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
|
|
1338
|
+
BLOCKS_14_PAGE_SLIDE,
|
|
1339
|
+
BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
|
|
1340
|
+
BRANCH_NODE_ALLOWED_CHILD_TYPES,
|
|
1496
1341
|
COMPOUND_MAX_NESTING_DEPTH,
|
|
1497
1342
|
COMPOUND_RESUME_SCHEMA_VERSION,
|
|
1498
1343
|
ID_MAX_LENGTH,
|
|
1499
1344
|
ID_PATTERN,
|
|
1500
1345
|
INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
|
|
1346
|
+
INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
|
|
1501
1347
|
PAGE_ALLOWED_CHILD_TYPES,
|
|
1502
1348
|
SESSION_STORAGE_KEY,
|
|
1503
1349
|
SLIDE_ALLOWED_CHILD_TYPES,
|
|
@@ -1505,6 +1351,7 @@ export {
|
|
|
1505
1351
|
TELEMETRY_EVENT_CATALOG,
|
|
1506
1352
|
TELEMETRY_EVENT_CATALOG_V2,
|
|
1507
1353
|
TELEMETRY_EVENT_CATALOG_V3,
|
|
1354
|
+
TIMED_CUE_ALLOWED_CHILD_TYPES,
|
|
1508
1355
|
assertNever,
|
|
1509
1356
|
assertValidId,
|
|
1510
1357
|
buildCourseStartedTelemetryEvent,
|
|
@@ -1540,11 +1387,14 @@ export {
|
|
|
1540
1387
|
hasCourseStarted,
|
|
1541
1388
|
hasCourseStartedEmittedToTracking,
|
|
1542
1389
|
hasCourseStartedPipelineDelivered,
|
|
1390
|
+
hasCourseStartedXapiSent,
|
|
1543
1391
|
isChildTypeAllowed,
|
|
1392
|
+
isLifecycleTelemetryEvent,
|
|
1544
1393
|
loadCompoundState,
|
|
1545
1394
|
markCourseStarted,
|
|
1546
1395
|
markCourseStartedEmittedToTracking,
|
|
1547
1396
|
markCourseStartedPipelineDelivered,
|
|
1397
|
+
markCourseStartedXapiSent,
|
|
1548
1398
|
migrateCourseStartedMark,
|
|
1549
1399
|
nowIso,
|
|
1550
1400
|
parseBlockId,
|
|
@@ -1563,5 +1413,6 @@ export {
|
|
|
1563
1413
|
telemetryCatalogVersion,
|
|
1564
1414
|
tryBuildTelemetryEvent,
|
|
1565
1415
|
tryEmitCourseStarted,
|
|
1416
|
+
validateBranchGraph,
|
|
1566
1417
|
validateId
|
|
1567
1418
|
};
|