@lessonkit/core 1.4.0 → 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-PEWFPVQ6.js → chunk-KFXFQ6B2.js} +230 -45
- package/dist/index.cjs +481 -110
- package/dist/index.d.cts +56 -12
- package/dist/index.d.ts +56 -12
- package/dist/index.js +322 -122
- package/dist/{testing-BhVGckZ5.d.cts → testing-BFr8oEfw.d.cts} +46 -7
- package/dist/{testing-BhVGckZ5.d.ts → testing-BFr8oEfw.d.ts} +46 -7
- package/dist/testing.cjs +1 -3
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +1 -1
- package/package.json +3 -3
- package/telemetry-catalog.v3.json +37 -0
package/dist/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ID_MAX_LENGTH,
|
|
3
|
+
ID_PATTERN,
|
|
2
4
|
SESSION_STORAGE_KEY,
|
|
5
|
+
assertValidId,
|
|
3
6
|
buildCourseStartedTelemetryEvent,
|
|
4
7
|
buildTelemetryEvent,
|
|
5
8
|
completeCourseWithTelemetry,
|
|
@@ -13,82 +16,33 @@ import {
|
|
|
13
16
|
hasCourseStarted,
|
|
14
17
|
hasCourseStartedEmittedToTracking,
|
|
15
18
|
hasCourseStartedPipelineDelivered,
|
|
19
|
+
hasCourseStartedXapiSent,
|
|
16
20
|
isDevEnvironment,
|
|
17
21
|
markCourseStarted,
|
|
18
22
|
markCourseStartedEmittedToTracking,
|
|
19
23
|
markCourseStartedPipelineDelivered,
|
|
24
|
+
markCourseStartedXapiSent,
|
|
20
25
|
migrateCourseStartedMark,
|
|
21
26
|
nowIso,
|
|
27
|
+
parseBlockId,
|
|
28
|
+
parseCheckId,
|
|
29
|
+
parseCourseId,
|
|
30
|
+
parseLessonId,
|
|
22
31
|
resetSharedVolatileSessionIdForTests,
|
|
23
32
|
resetStoragePortForTests,
|
|
24
33
|
resetTelemetryBuilderWarningsForTests,
|
|
25
34
|
resolveSessionId,
|
|
26
35
|
tryBuildTelemetryEvent,
|
|
27
36
|
tryEmitCourseStarted,
|
|
37
|
+
validateId,
|
|
28
38
|
warnDev
|
|
29
|
-
} from "./chunk-
|
|
30
|
-
|
|
31
|
-
// src/identityTypes.ts
|
|
32
|
-
var ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
33
|
-
var ID_MAX_LENGTH = 64;
|
|
39
|
+
} from "./chunk-KFXFQ6B2.js";
|
|
34
40
|
|
|
35
41
|
// src/assertNever.ts
|
|
36
42
|
function assertNever(value, message = "Unexpected value") {
|
|
37
43
|
throw new Error(`${message}: ${String(value)}`);
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
// src/validateId.ts
|
|
41
|
-
function validateId(input, path = "id") {
|
|
42
|
-
if (typeof input !== "string") {
|
|
43
|
-
return { ok: false, issues: [{ path, message: "id must be a string" }] };
|
|
44
|
-
}
|
|
45
|
-
const id = input.trim();
|
|
46
|
-
if (!id.length) {
|
|
47
|
-
return { ok: false, issues: [{ path, message: "id must not be empty" }] };
|
|
48
|
-
}
|
|
49
|
-
if (id.length > ID_MAX_LENGTH) {
|
|
50
|
-
return {
|
|
51
|
-
ok: false,
|
|
52
|
-
issues: [{ path, message: `id must be at most ${ID_MAX_LENGTH} characters` }]
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
if (!ID_PATTERN.test(id)) {
|
|
56
|
-
return {
|
|
57
|
-
ok: false,
|
|
58
|
-
issues: [
|
|
59
|
-
{
|
|
60
|
-
path,
|
|
61
|
-
message: "id must start with a letter and contain only letters, digits, underscores, and hyphens"
|
|
62
|
-
}
|
|
63
|
-
]
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
return { ok: true, id };
|
|
67
|
-
}
|
|
68
|
-
function parseCourseId(input) {
|
|
69
|
-
const result = validateId(input, "courseId");
|
|
70
|
-
return result.ok ? result.id : null;
|
|
71
|
-
}
|
|
72
|
-
function parseLessonId(input) {
|
|
73
|
-
const result = validateId(input, "lessonId");
|
|
74
|
-
return result.ok ? result.id : null;
|
|
75
|
-
}
|
|
76
|
-
function parseCheckId(input) {
|
|
77
|
-
const result = validateId(input, "checkId");
|
|
78
|
-
return result.ok ? result.id : null;
|
|
79
|
-
}
|
|
80
|
-
function parseBlockId(input) {
|
|
81
|
-
const result = validateId(input, "blockId");
|
|
82
|
-
return result.ok ? result.id : null;
|
|
83
|
-
}
|
|
84
|
-
function assertValidId(input, path = "id") {
|
|
85
|
-
const result = validateId(input, path);
|
|
86
|
-
if (!result.ok) {
|
|
87
|
-
throw new Error(result.issues.map((i) => `${i.path}: ${i.message}`).join("; "));
|
|
88
|
-
}
|
|
89
|
-
return result.id;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
46
|
// src/slugify.ts
|
|
93
47
|
function shortHash(input) {
|
|
94
48
|
let h = 0;
|
|
@@ -101,12 +55,26 @@ function uniqueFallbackId(input, usedIds) {
|
|
|
101
55
|
const hash = shortHash(input);
|
|
102
56
|
for (let n = 0; n < 100; n++) {
|
|
103
57
|
const candidate = (n === 0 ? `id-${hash}` : `id-${hash}-${n}`).slice(0, 64);
|
|
104
|
-
const
|
|
105
|
-
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;
|
|
106
66
|
}
|
|
107
67
|
const timed = `id-${hash}-${Date.now().toString(36)}`.slice(0, 64);
|
|
108
|
-
const
|
|
109
|
-
|
|
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)}`);
|
|
110
78
|
}
|
|
111
79
|
function slugifyId(input) {
|
|
112
80
|
const slug = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-").slice(0, 64);
|
|
@@ -148,6 +116,13 @@ function buildLessonkitUrn(parts) {
|
|
|
148
116
|
}
|
|
149
117
|
urn += `:block:${blockId}`;
|
|
150
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
|
+
}
|
|
151
126
|
return urn;
|
|
152
127
|
}
|
|
153
128
|
|
|
@@ -192,19 +167,25 @@ function isPlainSerializableChildState(value) {
|
|
|
192
167
|
(entry) => isValidChildResumeValue(entry)
|
|
193
168
|
);
|
|
194
169
|
}
|
|
195
|
-
function parseCompoundResumeState(raw) {
|
|
170
|
+
function parseCompoundResumeState(raw, opts) {
|
|
196
171
|
if (!raw || typeof raw !== "object") return null;
|
|
197
172
|
const obj = raw;
|
|
198
173
|
if (obj.schemaVersion !== COMPOUND_RESUME_SCHEMA_VERSION) return null;
|
|
199
174
|
if (typeof obj.activePageIndex !== "number" || !Number.isFinite(obj.activePageIndex)) return null;
|
|
200
175
|
const childStates = {};
|
|
176
|
+
const droppedChildKeys = [];
|
|
201
177
|
if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
|
|
202
178
|
for (const [key, value] of Object.entries(obj.childStates)) {
|
|
203
179
|
if (isPlainSerializableChildState(value)) {
|
|
204
180
|
childStates[key] = value;
|
|
181
|
+
} else {
|
|
182
|
+
droppedChildKeys.push(key);
|
|
205
183
|
}
|
|
206
184
|
}
|
|
207
185
|
}
|
|
186
|
+
if (droppedChildKeys.length > 0) {
|
|
187
|
+
opts?.onDroppedChildKeys?.(droppedChildKeys);
|
|
188
|
+
}
|
|
208
189
|
const activeChapterIndex = typeof obj.activeChapterIndex === "number" && Number.isFinite(obj.activeChapterIndex) ? obj.activeChapterIndex : void 0;
|
|
209
190
|
return {
|
|
210
191
|
schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
|
|
@@ -219,17 +200,21 @@ var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
|
|
|
219
200
|
function compoundStateStorageKey(courseId, compoundId) {
|
|
220
201
|
return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
|
|
221
202
|
}
|
|
222
|
-
function loadCompoundState(storage, courseId, compoundId) {
|
|
203
|
+
function loadCompoundState(storage, courseId, compoundId, opts) {
|
|
223
204
|
const key = compoundStateStorageKey(courseId, compoundId);
|
|
224
205
|
const raw = storage.getItem(key);
|
|
225
206
|
if (!raw) return null;
|
|
226
207
|
try {
|
|
227
|
-
const parsed = parseCompoundResumeState(JSON.parse(raw));
|
|
228
|
-
if (parsed === null
|
|
229
|
-
|
|
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
|
+
}
|
|
230
214
|
}
|
|
231
215
|
return parsed;
|
|
232
216
|
} catch {
|
|
217
|
+
opts?.onCorrupt?.();
|
|
233
218
|
if (isDevEnvironment()) {
|
|
234
219
|
console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
|
|
235
220
|
}
|
|
@@ -286,8 +271,45 @@ var PAGE_ALLOWED_CHILD_TYPES = [
|
|
|
286
271
|
"FindHotspot",
|
|
287
272
|
"FindMultipleHotspots",
|
|
288
273
|
"ImageSlider",
|
|
274
|
+
"Embed",
|
|
275
|
+
"Chart",
|
|
289
276
|
"ProgressTracker"
|
|
290
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"];
|
|
291
313
|
var INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES = ["Page"];
|
|
292
314
|
var SLIDE_ALLOWED_CHILD_TYPES = [
|
|
293
315
|
"Text",
|
|
@@ -318,7 +340,9 @@ var SLIDE_ALLOWED_CHILD_TYPES = [
|
|
|
318
340
|
"ImageHotspots",
|
|
319
341
|
"FindHotspot",
|
|
320
342
|
"FindMultipleHotspots",
|
|
321
|
-
"ImageSlider"
|
|
343
|
+
"ImageSlider",
|
|
344
|
+
"Embed",
|
|
345
|
+
"Chart"
|
|
322
346
|
];
|
|
323
347
|
var SLIDE_DECK_ALLOWED_CHILD_TYPES = ["Slide"];
|
|
324
348
|
var TIMED_CUE_ALLOWED_CHILD_TYPES = [
|
|
@@ -360,7 +384,9 @@ var ALLOWLISTS = {
|
|
|
360
384
|
SlideDeck: SLIDE_DECK_ALLOWED_CHILD_TYPES,
|
|
361
385
|
TimedCue: TIMED_CUE_ALLOWED_CHILD_TYPES,
|
|
362
386
|
InteractiveVideo: INTERACTIVE_VIDEO_ALLOWED_CHILD_TYPES,
|
|
363
|
-
AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES
|
|
387
|
+
AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
|
|
388
|
+
BranchingScenario: BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
|
|
389
|
+
BranchNode: BRANCH_NODE_ALLOWED_CHILD_TYPES
|
|
364
390
|
};
|
|
365
391
|
var COMPOUND_MAX_NESTING_DEPTH = {
|
|
366
392
|
Page: 1,
|
|
@@ -369,7 +395,9 @@ var COMPOUND_MAX_NESTING_DEPTH = {
|
|
|
369
395
|
SlideDeck: 2,
|
|
370
396
|
TimedCue: 1,
|
|
371
397
|
InteractiveVideo: 2,
|
|
372
|
-
AssessmentSequence: 1
|
|
398
|
+
AssessmentSequence: 1,
|
|
399
|
+
BranchingScenario: 2,
|
|
400
|
+
BranchNode: 1
|
|
373
401
|
};
|
|
374
402
|
function getAllowedChildTypes(parent) {
|
|
375
403
|
return ALLOWLISTS[parent];
|
|
@@ -380,6 +408,82 @@ function isChildTypeAllowed(parent, childType) {
|
|
|
380
408
|
var ACCORDION_FORBIDDEN_CHILD_TYPES = ["Accordion"];
|
|
381
409
|
var BLOCKS_14_PAGE_SLIDE = PAGE_AND_SLIDE_14_BLOCKS;
|
|
382
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
|
+
}
|
|
486
|
+
|
|
383
487
|
// src/telemetryCatalog.ts
|
|
384
488
|
var telemetryCatalogVersion = 1;
|
|
385
489
|
var TELEMETRY_EVENT_CATALOG = [
|
|
@@ -582,6 +686,22 @@ var TELEMETRY_EVENT_CATALOG_V3 = [
|
|
|
582
686
|
dataFields: ["blockId", "fieldCount"],
|
|
583
687
|
xapiVerb: "http://adlnet.gov/expapi/verbs/completed",
|
|
584
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}"
|
|
585
705
|
}
|
|
586
706
|
];
|
|
587
707
|
function buildTelemetryCatalogV3() {
|
|
@@ -613,22 +733,12 @@ function invokeTrackingSink(sink, event) {
|
|
|
613
733
|
void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
|
|
614
734
|
}
|
|
615
735
|
}
|
|
616
|
-
function invokePipelineSink(sinkId, emit) {
|
|
617
|
-
let result;
|
|
618
|
-
try {
|
|
619
|
-
result = emit();
|
|
620
|
-
} catch (err) {
|
|
621
|
-
warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err);
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
if (result != null && typeof result.catch === "function") {
|
|
625
|
-
void result.catch(
|
|
626
|
-
(err) => warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err)
|
|
627
|
-
);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
736
|
|
|
631
737
|
// src/trackingClient.ts
|
|
738
|
+
function eventDedupKey(event) {
|
|
739
|
+
const id = event.id?.trim();
|
|
740
|
+
return id || void 0;
|
|
741
|
+
}
|
|
632
742
|
function createTrackingClient(opts) {
|
|
633
743
|
const sink = opts?.sink;
|
|
634
744
|
const batchSink = opts?.batchSink;
|
|
@@ -646,13 +756,14 @@ function createTrackingClient(opts) {
|
|
|
646
756
|
let disposed2 = false;
|
|
647
757
|
return {
|
|
648
758
|
track: (event) => {
|
|
649
|
-
if (disposed2) return;
|
|
759
|
+
if (disposed2) return false;
|
|
650
760
|
if (sink) {
|
|
651
761
|
try {
|
|
652
762
|
invokeTrackingSink(sink, event);
|
|
653
763
|
} catch {
|
|
654
764
|
}
|
|
655
765
|
}
|
|
766
|
+
return true;
|
|
656
767
|
},
|
|
657
768
|
deliver: async (event) => {
|
|
658
769
|
if (disposed2) return false;
|
|
@@ -665,19 +776,28 @@ function createTrackingClient(opts) {
|
|
|
665
776
|
};
|
|
666
777
|
}
|
|
667
778
|
if (!sink && !batchSink) {
|
|
668
|
-
return { track: () =>
|
|
669
|
-
} };
|
|
779
|
+
return { track: () => true };
|
|
670
780
|
}
|
|
671
781
|
const buffer = [];
|
|
782
|
+
const pendingDeliverIds = /* @__PURE__ */ new Set();
|
|
672
783
|
let flushInFlight = null;
|
|
673
|
-
let inflightExitBatch = null;
|
|
674
784
|
let disposed = false;
|
|
675
785
|
let disposing = false;
|
|
676
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
|
+
};
|
|
677
798
|
const runFlush = () => {
|
|
678
799
|
if (!buffer.length) return Promise.resolve(true);
|
|
679
800
|
const events = buffer.splice(0, buffer.length);
|
|
680
|
-
inflightExitBatch = events;
|
|
681
801
|
let succeeded = false;
|
|
682
802
|
return Promise.resolve().then(async () => {
|
|
683
803
|
if (batchSink) {
|
|
@@ -698,12 +818,13 @@ function createTrackingClient(opts) {
|
|
|
698
818
|
buffer.unshift(...events);
|
|
699
819
|
}
|
|
700
820
|
}).then(async () => {
|
|
821
|
+
if (succeeded) {
|
|
822
|
+
clearPendingDeliverIds(events);
|
|
823
|
+
}
|
|
701
824
|
if (succeeded && buffer.length > 0 && !disposed) {
|
|
702
825
|
return runFlush();
|
|
703
826
|
}
|
|
704
827
|
return succeeded;
|
|
705
|
-
}).finally(() => {
|
|
706
|
-
inflightExitBatch = null;
|
|
707
828
|
});
|
|
708
829
|
};
|
|
709
830
|
const flush = () => {
|
|
@@ -724,18 +845,27 @@ function createTrackingClient(opts) {
|
|
|
724
845
|
if (!delivered) break;
|
|
725
846
|
}
|
|
726
847
|
if (buffer.length > 0) {
|
|
848
|
+
const droppedCount = buffer.length;
|
|
727
849
|
if (isDevEnvironment()) {
|
|
728
850
|
console.warn(
|
|
729
|
-
`[lessonkit] dropped ${
|
|
851
|
+
`[lessonkit] dropped ${droppedCount} buffered telemetry event(s) after dispose flush cap`
|
|
730
852
|
);
|
|
731
853
|
}
|
|
854
|
+
for (let i = 0; i < droppedCount; i++) {
|
|
855
|
+
opts?.onBufferDrop?.();
|
|
856
|
+
}
|
|
732
857
|
buffer.length = 0;
|
|
858
|
+
pendingDeliverIds.clear();
|
|
733
859
|
}
|
|
734
860
|
};
|
|
735
861
|
intervalId = flushIntervalMs > 0 ? globalThis.setInterval(() => void flush(), flushIntervalMs) : void 0;
|
|
736
862
|
intervalId?.unref?.();
|
|
737
863
|
const track = (event) => {
|
|
738
|
-
if (disposed || disposing) return;
|
|
864
|
+
if (disposed || disposing) return false;
|
|
865
|
+
const key = eventDedupKey(event);
|
|
866
|
+
if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
|
|
867
|
+
return true;
|
|
868
|
+
}
|
|
739
869
|
if (buffer.length >= maxBufferSize) {
|
|
740
870
|
opts?.onBufferDrop?.();
|
|
741
871
|
if (!warnedBufferCap && isDevEnvironment()) {
|
|
@@ -744,29 +874,37 @@ function createTrackingClient(opts) {
|
|
|
744
874
|
`[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
|
|
745
875
|
);
|
|
746
876
|
}
|
|
747
|
-
return;
|
|
877
|
+
return false;
|
|
748
878
|
}
|
|
749
879
|
buffer.push(event);
|
|
750
880
|
if (buffer.length >= maxBatchSize) void flush();
|
|
881
|
+
return true;
|
|
751
882
|
};
|
|
752
883
|
return {
|
|
753
884
|
track,
|
|
754
885
|
deliver: async (event) => {
|
|
755
|
-
|
|
886
|
+
const key = eventDedupKey(event);
|
|
887
|
+
if (key && (pendingDeliverIds.has(key) || isEventBuffered(event))) {
|
|
888
|
+
return flush();
|
|
889
|
+
}
|
|
890
|
+
if (!track(event)) return false;
|
|
891
|
+
if (key) pendingDeliverIds.add(key);
|
|
756
892
|
return flush();
|
|
757
893
|
},
|
|
758
894
|
flush,
|
|
759
895
|
flushOnExit: opts?.exitBatchSink ? () => {
|
|
760
|
-
const
|
|
761
|
-
const fromInflight = inflightExitBatch ? [...inflightExitBatch] : [];
|
|
762
|
-
const events = [...fromInflight, ...fromBuffer];
|
|
896
|
+
const events = buffer.splice(0, buffer.length);
|
|
763
897
|
if (!events.length) return;
|
|
764
898
|
try {
|
|
765
899
|
const result = opts.exitBatchSink(events);
|
|
766
900
|
if (result != null && typeof result.catch === "function") {
|
|
767
|
-
void result.
|
|
901
|
+
void result.then(() => {
|
|
902
|
+
clearPendingDeliverIds(events);
|
|
903
|
+
}).catch(() => {
|
|
768
904
|
buffer.unshift(...events);
|
|
769
905
|
});
|
|
906
|
+
} else {
|
|
907
|
+
clearPendingDeliverIds(events);
|
|
770
908
|
}
|
|
771
909
|
} catch {
|
|
772
910
|
buffer.unshift(...events);
|
|
@@ -788,21 +926,44 @@ function createTrackingClient(opts) {
|
|
|
788
926
|
}
|
|
789
927
|
|
|
790
928
|
// src/telemetryPipeline.ts
|
|
791
|
-
|
|
792
|
-
|
|
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;
|
|
946
|
+
}
|
|
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);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
793
954
|
}
|
|
794
955
|
function createTelemetryPipeline(sinks) {
|
|
795
956
|
const list = [...sinks];
|
|
796
957
|
return {
|
|
797
958
|
sinks: list,
|
|
798
|
-
emit(event, ctx) {
|
|
959
|
+
async emit(event, ctx) {
|
|
799
960
|
const emitCtx = ctx ?? {
|
|
800
961
|
courseId: event.courseId,
|
|
801
962
|
sessionId: event.sessionId,
|
|
802
963
|
attemptId: event.attemptId
|
|
803
964
|
};
|
|
804
965
|
for (const sink of list) {
|
|
805
|
-
invokeSink(sink, event, emitCtx);
|
|
966
|
+
await invokeSink(sink, event, emitCtx);
|
|
806
967
|
}
|
|
807
968
|
}
|
|
808
969
|
};
|
|
@@ -841,6 +1002,11 @@ function createProgressController() {
|
|
|
841
1002
|
}
|
|
842
1003
|
return { didComplete: false };
|
|
843
1004
|
}
|
|
1005
|
+
if (!lessonStartTimes.has(lessonId) && isDevEnvironment()) {
|
|
1006
|
+
console.warn(
|
|
1007
|
+
`[lessonkit] completeLesson("${lessonId}") called without activating the lesson first`
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
844
1010
|
completedLessonIds = new Set(completedLessonIds).add(lessonId);
|
|
845
1011
|
if (activeLessonId === lessonId) {
|
|
846
1012
|
activeLessonId = void 0;
|
|
@@ -874,6 +1040,20 @@ function warnDuplicatePlugin(id) {
|
|
|
874
1040
|
if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
|
|
875
1041
|
console.warn(`[lessonkit] plugin id "${id}" was registered more than once; using the latest definition`);
|
|
876
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
|
+
}
|
|
877
1057
|
function createPluginRegistry(plugins = []) {
|
|
878
1058
|
const registry = /* @__PURE__ */ new Map();
|
|
879
1059
|
for (const plugin of plugins) {
|
|
@@ -915,7 +1095,7 @@ function createPluginRegistry(plugins = []) {
|
|
|
915
1095
|
const composeTrackingSink = (sink, ctxSource) => {
|
|
916
1096
|
if (!sink) return void 0;
|
|
917
1097
|
const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
|
|
918
|
-
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)}`;
|
|
919
1099
|
const layers = [];
|
|
920
1100
|
let composed = sink;
|
|
921
1101
|
for (const plugin of list) {
|
|
@@ -980,6 +1160,7 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
980
1160
|
let courseId = configSnapshot.courseId;
|
|
981
1161
|
let progress = createProgressController();
|
|
982
1162
|
let pluginHost = resolvePluginHost(configSnapshot.plugins);
|
|
1163
|
+
let disposed = false;
|
|
983
1164
|
const getPluginCtx = () => buildPluginContext({
|
|
984
1165
|
courseId,
|
|
985
1166
|
sessionId,
|
|
@@ -1013,28 +1194,24 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1013
1194
|
if (!event) return null;
|
|
1014
1195
|
return applyPluginsToEvent(event);
|
|
1015
1196
|
};
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
if (event === null) return;
|
|
1020
|
-
const eventLessonId = "lessonId" in event ? event.lessonId : lessonId;
|
|
1021
|
-
const eventData = "data" in event ? event.data : data;
|
|
1022
|
-
emitFn(event.name, eventData, eventLessonId);
|
|
1023
|
-
};
|
|
1197
|
+
const emitLifecycleEvent = (emitFn, name, data, lessonId) => {
|
|
1198
|
+
const event = buildAndApply(name, data, lessonId);
|
|
1199
|
+
if (event) emitFn(event);
|
|
1024
1200
|
};
|
|
1025
1201
|
syncSessionFromConfig(configSnapshot);
|
|
1026
1202
|
const track = (name, data, emit, lessonId) => {
|
|
1203
|
+
if (disposed) return;
|
|
1027
1204
|
const event = buildAndApply(name, data, lessonId);
|
|
1028
1205
|
if (!event) return;
|
|
1029
1206
|
emit(event);
|
|
1030
1207
|
};
|
|
1031
1208
|
const emitLessonCompletedEvents = (lessonId, durationMs, emitFn) => {
|
|
1032
|
-
|
|
1033
|
-
wrapped("lesson_completed", { lessonId, durationMs }, lessonId);
|
|
1209
|
+
emitLifecycleEvent(emitFn, "lesson_completed", { lessonId, durationMs }, lessonId);
|
|
1034
1210
|
if (durationMs !== void 0) {
|
|
1035
|
-
|
|
1211
|
+
emitLifecycleEvent(emitFn, "lesson_time_on_task", { lessonId, durationMs }, lessonId);
|
|
1036
1212
|
}
|
|
1037
1213
|
};
|
|
1214
|
+
const autoCompleteOnLessonSwitch = () => configSnapshot.autoCompleteOnLessonSwitch ?? true;
|
|
1038
1215
|
return {
|
|
1039
1216
|
get config() {
|
|
1040
1217
|
return configSnapshot;
|
|
@@ -1047,7 +1224,12 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1047
1224
|
},
|
|
1048
1225
|
getProgressState: () => progress.getState(),
|
|
1049
1226
|
getSession,
|
|
1227
|
+
migrateSessionMarks(fromSessionId, toSessionId) {
|
|
1228
|
+
if (disposed) return;
|
|
1229
|
+
migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId);
|
|
1230
|
+
},
|
|
1050
1231
|
updateConfig(next) {
|
|
1232
|
+
if (disposed) return;
|
|
1051
1233
|
const previousCourseId = courseId;
|
|
1052
1234
|
const sessionKeyBefore = JSON.stringify({ sessionId, attemptId, user });
|
|
1053
1235
|
if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
|
|
@@ -1055,6 +1237,9 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1055
1237
|
if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
|
|
1056
1238
|
configSnapshot.runtimeVersion = next.runtimeVersion;
|
|
1057
1239
|
}
|
|
1240
|
+
if (next.autoCompleteOnLessonSwitch !== void 0) {
|
|
1241
|
+
configSnapshot.autoCompleteOnLessonSwitch = next.autoCompleteOnLessonSwitch;
|
|
1242
|
+
}
|
|
1058
1243
|
if (next.session !== void 0) {
|
|
1059
1244
|
configSnapshot.session = { ...configSnapshot.session, ...next.session };
|
|
1060
1245
|
}
|
|
@@ -1076,14 +1261,14 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1076
1261
|
}
|
|
1077
1262
|
},
|
|
1078
1263
|
setActiveLesson(lessonId, emitFn) {
|
|
1079
|
-
|
|
1264
|
+
if (disposed) return;
|
|
1080
1265
|
const current = progress.getState();
|
|
1081
1266
|
if (current.activeLessonId === lessonId) return;
|
|
1082
1267
|
const previous = current.activeLessonId;
|
|
1083
|
-
if (previous && previous !== lessonId && !current.completedLessonIds.has(previous)) {
|
|
1268
|
+
if (autoCompleteOnLessonSwitch() && previous && previous !== lessonId && !current.completedLessonIds.has(previous)) {
|
|
1084
1269
|
const completed = progress.completeLesson(previous, clock.nowMs());
|
|
1085
1270
|
if (completed.didComplete) {
|
|
1086
|
-
emitLessonCompletedEvents(previous, completed.durationMs,
|
|
1271
|
+
emitLessonCompletedEvents(previous, completed.durationMs, emitFn);
|
|
1087
1272
|
}
|
|
1088
1273
|
}
|
|
1089
1274
|
if (current.completedLessonIds.has(lessonId)) {
|
|
@@ -1091,38 +1276,47 @@ function createLessonkitRuntime(config, ports = {}) {
|
|
|
1091
1276
|
return;
|
|
1092
1277
|
}
|
|
1093
1278
|
progress.setActiveLesson(lessonId, clock.nowMs());
|
|
1094
|
-
|
|
1279
|
+
emitLifecycleEvent(emitFn, "lesson_started", { lessonId }, lessonId);
|
|
1095
1280
|
},
|
|
1096
1281
|
completeLesson(lessonId, emitFn) {
|
|
1282
|
+
if (disposed) return;
|
|
1097
1283
|
completeLessonWithTelemetry({
|
|
1098
1284
|
progress,
|
|
1099
1285
|
lessonId,
|
|
1100
1286
|
nowMs: clock.nowMs(),
|
|
1101
|
-
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs,
|
|
1287
|
+
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn)
|
|
1102
1288
|
});
|
|
1103
1289
|
},
|
|
1104
1290
|
completeCourse(emitFn) {
|
|
1291
|
+
if (disposed) return;
|
|
1105
1292
|
completeCourseWithTelemetry({
|
|
1106
1293
|
progress,
|
|
1107
1294
|
nowMs: clock.nowMs(),
|
|
1108
|
-
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs,
|
|
1109
|
-
emitCourseCompleted: () =>
|
|
1295
|
+
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, emitFn),
|
|
1296
|
+
emitCourseCompleted: () => emitLifecycleEvent(emitFn, "course_completed")
|
|
1110
1297
|
});
|
|
1111
1298
|
},
|
|
1112
1299
|
track,
|
|
1113
1300
|
scoreAssessment(input, lessonId) {
|
|
1114
|
-
if (!pluginHost) return null;
|
|
1301
|
+
if (disposed || !pluginHost) return null;
|
|
1115
1302
|
return pluginHost.scoreAssessment(
|
|
1116
1303
|
{ ...input, lessonId: input.lessonId ?? lessonId },
|
|
1117
1304
|
getPluginCtx()
|
|
1118
1305
|
);
|
|
1119
1306
|
},
|
|
1120
1307
|
resetForCourseChange(nextCourseId) {
|
|
1308
|
+
if (disposed) return;
|
|
1121
1309
|
configSnapshot.courseId = nextCourseId;
|
|
1122
1310
|
courseId = nextCourseId;
|
|
1123
1311
|
progress = createProgressController();
|
|
1312
|
+
pluginHost?.disposeAll();
|
|
1313
|
+
if (!configSnapshot.deferPluginSetup) {
|
|
1314
|
+
pluginHost?.setupAll(getPluginCtx());
|
|
1315
|
+
}
|
|
1124
1316
|
},
|
|
1125
1317
|
dispose() {
|
|
1318
|
+
if (disposed) return;
|
|
1319
|
+
disposed = true;
|
|
1126
1320
|
pluginHost?.disposeAll();
|
|
1127
1321
|
}
|
|
1128
1322
|
};
|
|
@@ -1142,6 +1336,8 @@ export {
|
|
|
1142
1336
|
ACCORDION_FORBIDDEN_CHILD_TYPES,
|
|
1143
1337
|
ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
|
|
1144
1338
|
BLOCKS_14_PAGE_SLIDE,
|
|
1339
|
+
BRANCHING_SCENARIO_ALLOWED_CHILD_TYPES,
|
|
1340
|
+
BRANCH_NODE_ALLOWED_CHILD_TYPES,
|
|
1145
1341
|
COMPOUND_MAX_NESTING_DEPTH,
|
|
1146
1342
|
COMPOUND_RESUME_SCHEMA_VERSION,
|
|
1147
1343
|
ID_MAX_LENGTH,
|
|
@@ -1191,11 +1387,14 @@ export {
|
|
|
1191
1387
|
hasCourseStarted,
|
|
1192
1388
|
hasCourseStartedEmittedToTracking,
|
|
1193
1389
|
hasCourseStartedPipelineDelivered,
|
|
1390
|
+
hasCourseStartedXapiSent,
|
|
1194
1391
|
isChildTypeAllowed,
|
|
1392
|
+
isLifecycleTelemetryEvent,
|
|
1195
1393
|
loadCompoundState,
|
|
1196
1394
|
markCourseStarted,
|
|
1197
1395
|
markCourseStartedEmittedToTracking,
|
|
1198
1396
|
markCourseStartedPipelineDelivered,
|
|
1397
|
+
markCourseStartedXapiSent,
|
|
1199
1398
|
migrateCourseStartedMark,
|
|
1200
1399
|
nowIso,
|
|
1201
1400
|
parseBlockId,
|
|
@@ -1214,5 +1413,6 @@ export {
|
|
|
1214
1413
|
telemetryCatalogVersion,
|
|
1215
1414
|
tryBuildTelemetryEvent,
|
|
1216
1415
|
tryEmitCourseStarted,
|
|
1416
|
+
validateBranchGraph,
|
|
1217
1417
|
validateId
|
|
1218
1418
|
};
|