@lessonkit/core 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +786 -221
- package/dist/index.d.cts +215 -28
- package/dist/index.d.ts +215 -28
- package/dist/index.js +764 -221
- package/package.json +6 -2
- package/telemetry-catalog.v2.json +21 -0
- package/telemetry-catalog.v3.json +61 -0
package/dist/index.js
CHANGED
|
@@ -60,21 +60,40 @@ function assertValidId(input, path = "id") {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
// src/slugify.ts
|
|
63
|
+
function shortHash(input) {
|
|
64
|
+
let h = 0;
|
|
65
|
+
for (let i = 0; i < input.length; i++) {
|
|
66
|
+
h = Math.imul(31, h) + input.charCodeAt(i) >>> 0;
|
|
67
|
+
}
|
|
68
|
+
return h.toString(36);
|
|
69
|
+
}
|
|
70
|
+
function uniqueFallbackId(input, usedIds) {
|
|
71
|
+
const hash = shortHash(input);
|
|
72
|
+
for (let n = 0; n < 100; n++) {
|
|
73
|
+
const candidate = (n === 0 ? `id-${hash}` : `id-${hash}-${n}`).slice(0, 64);
|
|
74
|
+
const validated2 = validateId(candidate);
|
|
75
|
+
if (validated2.ok && !usedIds.has(validated2.id)) return validated2.id;
|
|
76
|
+
}
|
|
77
|
+
const timed = `id-${hash}-${Date.now().toString(36)}`.slice(0, 64);
|
|
78
|
+
const validated = validateId(timed);
|
|
79
|
+
return validated.ok ? validated.id : `id-${hash}`;
|
|
80
|
+
}
|
|
63
81
|
function slugifyId(input) {
|
|
64
82
|
const slug = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-").slice(0, 64);
|
|
65
|
-
if (!slug.length) return
|
|
66
|
-
const candidate = /^[a-z]/.test(slug) ? slug : `id-${slug}
|
|
83
|
+
if (!slug.length) return uniqueFallbackId(input, /* @__PURE__ */ new Set());
|
|
84
|
+
const candidate = /^[a-z]/.test(slug) ? slug : `id-${slug}`.slice(0, 64);
|
|
67
85
|
const validated = validateId(candidate);
|
|
68
|
-
return validated.ok ? validated.id :
|
|
86
|
+
return validated.ok ? validated.id : uniqueFallbackId(input, /* @__PURE__ */ new Set());
|
|
69
87
|
}
|
|
70
88
|
function deriveId(title, usedIds = /* @__PURE__ */ new Set()) {
|
|
71
89
|
const base = slugifyId(title);
|
|
72
|
-
if (!usedIds.has(base)) return base;
|
|
90
|
+
if (!usedIds.has(base) && validateId(base).ok) return base;
|
|
73
91
|
for (let n = 2; n < 1e3; n++) {
|
|
74
|
-
const candidate = `${base}-${n}
|
|
75
|
-
|
|
92
|
+
const candidate = `${base}-${n}`.slice(0, 64);
|
|
93
|
+
const validated = validateId(candidate);
|
|
94
|
+
if (validated.ok && !usedIds.has(validated.id)) return validated.id;
|
|
76
95
|
}
|
|
77
|
-
return `${
|
|
96
|
+
return uniqueFallbackId(`${title}-${Date.now()}`, usedIds);
|
|
78
97
|
}
|
|
79
98
|
|
|
80
99
|
// src/urn.ts
|
|
@@ -102,6 +121,187 @@ function buildLessonkitUrn(parts) {
|
|
|
102
121
|
return urn;
|
|
103
122
|
}
|
|
104
123
|
|
|
124
|
+
// src/compound.ts
|
|
125
|
+
var COMPOUND_RESUME_SCHEMA_VERSION = 1;
|
|
126
|
+
function createCompoundResumeState(input = {}) {
|
|
127
|
+
const childStates = {};
|
|
128
|
+
if (input.childStates) {
|
|
129
|
+
for (const [key, value] of Object.entries(input.childStates)) {
|
|
130
|
+
childStates[key] = value;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
|
|
135
|
+
activePageIndex: input.activePageIndex ?? 0,
|
|
136
|
+
...input.activeChapterIndex !== void 0 ? { activeChapterIndex: input.activeChapterIndex } : {},
|
|
137
|
+
childStates
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function clampCompoundPageIndex(index, pageCount) {
|
|
141
|
+
if (pageCount < 1) return 0;
|
|
142
|
+
return Math.min(Math.max(0, Math.floor(index)), pageCount - 1);
|
|
143
|
+
}
|
|
144
|
+
function isJsonPrimitive(value) {
|
|
145
|
+
return value === null || typeof value === "boolean" || typeof value === "string" || typeof value === "number" && Number.isFinite(value);
|
|
146
|
+
}
|
|
147
|
+
function isPlainStringKeyMap(value) {
|
|
148
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
149
|
+
return Object.entries(value).every(
|
|
150
|
+
([key, entry]) => typeof key === "string" && isJsonPrimitive(entry)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
function isValidChildResumeValue(value) {
|
|
154
|
+
if (isJsonPrimitive(value)) return true;
|
|
155
|
+
if (Array.isArray(value)) return value.every((item) => isJsonPrimitive(item));
|
|
156
|
+
if (isPlainStringKeyMap(value)) return true;
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
function isPlainSerializableChildState(value) {
|
|
160
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
161
|
+
return Object.values(value).every(
|
|
162
|
+
(entry) => isValidChildResumeValue(entry)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
function parseCompoundResumeState(raw) {
|
|
166
|
+
if (!raw || typeof raw !== "object") return null;
|
|
167
|
+
const obj = raw;
|
|
168
|
+
if (obj.schemaVersion !== COMPOUND_RESUME_SCHEMA_VERSION) return null;
|
|
169
|
+
if (typeof obj.activePageIndex !== "number" || !Number.isFinite(obj.activePageIndex)) return null;
|
|
170
|
+
const childStates = {};
|
|
171
|
+
if (obj.childStates && typeof obj.childStates === "object" && !Array.isArray(obj.childStates)) {
|
|
172
|
+
for (const [key, value] of Object.entries(obj.childStates)) {
|
|
173
|
+
if (isPlainSerializableChildState(value)) {
|
|
174
|
+
childStates[key] = value;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const activeChapterIndex = typeof obj.activeChapterIndex === "number" && Number.isFinite(obj.activeChapterIndex) ? obj.activeChapterIndex : void 0;
|
|
179
|
+
return {
|
|
180
|
+
schemaVersion: COMPOUND_RESUME_SCHEMA_VERSION,
|
|
181
|
+
activePageIndex: Math.max(0, Math.floor(obj.activePageIndex)),
|
|
182
|
+
...activeChapterIndex !== void 0 ? { activeChapterIndex: Math.max(0, Math.floor(activeChapterIndex)) } : {},
|
|
183
|
+
childStates
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
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
|
+
// src/compoundState.ts
|
|
198
|
+
var COMPOUND_STATE_PREFIX = "lessonkit:compound:";
|
|
199
|
+
function compoundStateStorageKey(courseId, compoundId) {
|
|
200
|
+
return `${COMPOUND_STATE_PREFIX}${courseId}:${compoundId}`;
|
|
201
|
+
}
|
|
202
|
+
function loadCompoundState(storage, courseId, compoundId) {
|
|
203
|
+
const key = compoundStateStorageKey(courseId, compoundId);
|
|
204
|
+
const raw = storage.getItem(key);
|
|
205
|
+
if (!raw) return null;
|
|
206
|
+
try {
|
|
207
|
+
const parsed = parseCompoundResumeState(JSON.parse(raw));
|
|
208
|
+
if (parsed === null && isDevEnvironment()) {
|
|
209
|
+
console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
|
|
210
|
+
}
|
|
211
|
+
return parsed;
|
|
212
|
+
} catch {
|
|
213
|
+
if (isDevEnvironment()) {
|
|
214
|
+
console.warn(`[lessonkit] Ignoring corrupt compound resume state at ${key}`);
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function saveCompoundState(storage, courseId, compoundId, state) {
|
|
220
|
+
return storage.setItem(compoundStateStorageKey(courseId, compoundId), JSON.stringify(state));
|
|
221
|
+
}
|
|
222
|
+
function clearCompoundState(storage, courseId, compoundId) {
|
|
223
|
+
storage.removeItem?.(compoundStateStorageKey(courseId, compoundId));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/compoundAllowlists.ts
|
|
227
|
+
var PAGE_ALLOWED_CHILD_TYPES = [
|
|
228
|
+
"Text",
|
|
229
|
+
"Heading",
|
|
230
|
+
"Image",
|
|
231
|
+
"Scenario",
|
|
232
|
+
"Reflection",
|
|
233
|
+
"Quiz",
|
|
234
|
+
"KnowledgeCheck",
|
|
235
|
+
"TrueFalse",
|
|
236
|
+
"FillInTheBlanks",
|
|
237
|
+
"DragAndDrop",
|
|
238
|
+
"DragTheWords",
|
|
239
|
+
"MarkTheWords",
|
|
240
|
+
"Accordion",
|
|
241
|
+
"DialogCards",
|
|
242
|
+
"Flashcards",
|
|
243
|
+
"ImageHotspots",
|
|
244
|
+
"FindHotspot",
|
|
245
|
+
"FindMultipleHotspots",
|
|
246
|
+
"ImageSlider",
|
|
247
|
+
"ProgressTracker"
|
|
248
|
+
];
|
|
249
|
+
var INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES = ["Page"];
|
|
250
|
+
var SLIDE_ALLOWED_CHILD_TYPES = [
|
|
251
|
+
"Text",
|
|
252
|
+
"Heading",
|
|
253
|
+
"Image",
|
|
254
|
+
"Scenario",
|
|
255
|
+
"Reflection",
|
|
256
|
+
"Quiz",
|
|
257
|
+
"KnowledgeCheck",
|
|
258
|
+
"TrueFalse",
|
|
259
|
+
"FillInTheBlanks",
|
|
260
|
+
"DragAndDrop",
|
|
261
|
+
"DragTheWords",
|
|
262
|
+
"MarkTheWords",
|
|
263
|
+
"Accordion",
|
|
264
|
+
"DialogCards",
|
|
265
|
+
"Flashcards",
|
|
266
|
+
"ImageHotspots",
|
|
267
|
+
"FindHotspot",
|
|
268
|
+
"FindMultipleHotspots",
|
|
269
|
+
"ImageSlider"
|
|
270
|
+
];
|
|
271
|
+
var SLIDE_DECK_ALLOWED_CHILD_TYPES = ["Slide"];
|
|
272
|
+
var ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES = [
|
|
273
|
+
"TrueFalse",
|
|
274
|
+
"FillInTheBlanks",
|
|
275
|
+
"DragAndDrop",
|
|
276
|
+
"DragTheWords",
|
|
277
|
+
"MarkTheWords",
|
|
278
|
+
"Quiz",
|
|
279
|
+
"KnowledgeCheck",
|
|
280
|
+
"FindHotspot",
|
|
281
|
+
"FindMultipleHotspots"
|
|
282
|
+
];
|
|
283
|
+
var ALLOWLISTS = {
|
|
284
|
+
Page: PAGE_ALLOWED_CHILD_TYPES,
|
|
285
|
+
InteractiveBook: INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
|
|
286
|
+
Slide: SLIDE_ALLOWED_CHILD_TYPES,
|
|
287
|
+
SlideDeck: SLIDE_DECK_ALLOWED_CHILD_TYPES,
|
|
288
|
+
AssessmentSequence: ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES
|
|
289
|
+
};
|
|
290
|
+
var COMPOUND_MAX_NESTING_DEPTH = {
|
|
291
|
+
Page: 1,
|
|
292
|
+
InteractiveBook: 2,
|
|
293
|
+
Slide: 1,
|
|
294
|
+
SlideDeck: 2,
|
|
295
|
+
AssessmentSequence: 1
|
|
296
|
+
};
|
|
297
|
+
function getAllowedChildTypes(parent) {
|
|
298
|
+
return ALLOWLISTS[parent];
|
|
299
|
+
}
|
|
300
|
+
function isChildTypeAllowed(parent, childType) {
|
|
301
|
+
return ALLOWLISTS[parent].includes(childType);
|
|
302
|
+
}
|
|
303
|
+
var ACCORDION_FORBIDDEN_CHILD_TYPES = ["Accordion"];
|
|
304
|
+
|
|
105
305
|
// src/telemetryCatalog.ts
|
|
106
306
|
var telemetryCatalogVersion = 1;
|
|
107
307
|
var TELEMETRY_EVENT_CATALOG = [
|
|
@@ -198,35 +398,99 @@ function buildTelemetryCatalogV2() {
|
|
|
198
398
|
return TELEMETRY_EVENT_CATALOG_V2.map((entry) => ({ ...entry }));
|
|
199
399
|
}
|
|
200
400
|
|
|
201
|
-
// src/
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
401
|
+
// src/telemetryCatalogV3.ts
|
|
402
|
+
var telemetryCatalogV3Version = 3;
|
|
403
|
+
var TELEMETRY_EVENT_CATALOG_V3 = [
|
|
404
|
+
{
|
|
405
|
+
name: "book_page_viewed",
|
|
406
|
+
description: "Learner viewed a page/chapter in an Interactive Book",
|
|
407
|
+
requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
|
|
408
|
+
dataFields: ["blockId", "pageIndex", "pageTitle"],
|
|
409
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
410
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
name: "slide_viewed",
|
|
414
|
+
description: "Learner viewed a slide in a SlideDeck (Course Presentation)",
|
|
415
|
+
requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
|
|
416
|
+
dataFields: ["blockId", "slideIndex", "slideTitle"],
|
|
417
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
418
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
name: "compound_page_viewed",
|
|
422
|
+
description: "Learner activated a page inside a compound container",
|
|
423
|
+
requiredFields: ["courseId", "lessonId", "sessionId", "timestamp"],
|
|
424
|
+
dataFields: ["blockId", "pageIndex", "parentType"],
|
|
425
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
426
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
name: "hotspot_opened",
|
|
430
|
+
description: "Learner opened an image hotspot popover",
|
|
431
|
+
requiredFields: ["courseId", "sessionId", "timestamp"],
|
|
432
|
+
dataFields: ["blockId", "hotspotId"],
|
|
433
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
434
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
name: "accordion_section_toggled",
|
|
438
|
+
description: "Learner expanded or collapsed an accordion section",
|
|
439
|
+
requiredFields: ["courseId", "sessionId", "timestamp"],
|
|
440
|
+
dataFields: ["blockId", "sectionId", "expanded"],
|
|
441
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
442
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
name: "flashcard_flipped",
|
|
446
|
+
description: "Learner flipped a flashcard",
|
|
447
|
+
requiredFields: ["courseId", "sessionId", "timestamp"],
|
|
448
|
+
dataFields: ["blockId", "cardIndex", "face"],
|
|
449
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
450
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
name: "image_slider_changed",
|
|
454
|
+
description: "Learner changed the active slide in an image slider",
|
|
455
|
+
requiredFields: ["courseId", "sessionId", "timestamp"],
|
|
456
|
+
dataFields: ["blockId", "slideIndex"],
|
|
457
|
+
xapiVerb: "http://adlnet.gov/expapi/verbs/experienced",
|
|
458
|
+
urnPattern: "urn:lessonkit:course:{courseId}:lesson:{lessonId}:block:{blockId}"
|
|
459
|
+
}
|
|
460
|
+
];
|
|
461
|
+
function buildTelemetryCatalogV3() {
|
|
462
|
+
return TELEMETRY_EVENT_CATALOG_V3.map((entry) => ({ ...entry }));
|
|
205
463
|
}
|
|
464
|
+
|
|
465
|
+
// src/internal/sinkInvoke.ts
|
|
206
466
|
function invokeTrackingSink(sink, event) {
|
|
207
467
|
let result;
|
|
208
468
|
try {
|
|
209
469
|
result = sink(event);
|
|
210
470
|
} catch (err) {
|
|
211
|
-
|
|
212
|
-
console.warn(
|
|
213
|
-
"[lessonkit] tracking sink failed:",
|
|
214
|
-
err instanceof Error ? err.message : err
|
|
215
|
-
);
|
|
216
|
-
}
|
|
471
|
+
warnDev("[lessonkit] tracking sink failed:", err);
|
|
217
472
|
throw err;
|
|
218
473
|
}
|
|
219
474
|
if (result != null && typeof result.catch === "function") {
|
|
220
|
-
void result.catch((err) =>
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
475
|
+
void result.catch((err) => warnDev("[lessonkit] tracking sink failed:", err));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function invokePipelineSink(sinkId, emit) {
|
|
479
|
+
let result;
|
|
480
|
+
try {
|
|
481
|
+
result = emit();
|
|
482
|
+
} catch (err) {
|
|
483
|
+
warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (result != null && typeof result.catch === "function") {
|
|
487
|
+
void result.catch(
|
|
488
|
+
(err) => warnDev(`[lessonkit] telemetry sink "${sinkId}" failed:`, err)
|
|
489
|
+
);
|
|
228
490
|
}
|
|
229
491
|
}
|
|
492
|
+
|
|
493
|
+
// src/trackingClient.ts
|
|
230
494
|
function createTrackingClient(opts) {
|
|
231
495
|
const sink = opts?.sink;
|
|
232
496
|
const batchSink = opts?.batchSink;
|
|
@@ -262,41 +526,58 @@ function createTrackingClient(opts) {
|
|
|
262
526
|
let disposing = false;
|
|
263
527
|
let intervalId;
|
|
264
528
|
const runFlush = () => {
|
|
265
|
-
if (!buffer.length) return Promise.resolve();
|
|
529
|
+
if (!buffer.length) return Promise.resolve(true);
|
|
266
530
|
const events = buffer.splice(0, buffer.length);
|
|
267
|
-
let sent = 0;
|
|
268
531
|
let succeeded = false;
|
|
269
532
|
return Promise.resolve().then(async () => {
|
|
270
533
|
if (batchSink) {
|
|
271
534
|
await batchSink(events);
|
|
272
535
|
} else {
|
|
273
|
-
for (
|
|
274
|
-
|
|
275
|
-
|
|
536
|
+
for (let i = 0; i < events.length; i++) {
|
|
537
|
+
try {
|
|
538
|
+
await sink?.(events[i]);
|
|
539
|
+
} catch {
|
|
540
|
+
buffer.unshift(...events.slice(i));
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
276
543
|
}
|
|
277
544
|
}
|
|
278
545
|
succeeded = true;
|
|
279
546
|
}).catch(() => {
|
|
280
|
-
|
|
281
|
-
|
|
547
|
+
if (batchSink) {
|
|
548
|
+
buffer.unshift(...events);
|
|
549
|
+
}
|
|
550
|
+
}).then(async () => {
|
|
282
551
|
if (succeeded && buffer.length > 0 && !disposed) {
|
|
283
552
|
return runFlush();
|
|
284
553
|
}
|
|
554
|
+
return succeeded;
|
|
285
555
|
});
|
|
286
556
|
};
|
|
287
557
|
const flush = () => {
|
|
288
|
-
if (disposed) return Promise.resolve();
|
|
558
|
+
if (disposed) return Promise.resolve(true);
|
|
289
559
|
if (flushInFlight) return flushInFlight;
|
|
290
|
-
if (!buffer.length) return Promise.resolve();
|
|
560
|
+
if (!buffer.length) return Promise.resolve(true);
|
|
291
561
|
flushInFlight = runFlush().finally(() => {
|
|
292
562
|
flushInFlight = null;
|
|
293
563
|
});
|
|
294
564
|
return flushInFlight;
|
|
295
565
|
};
|
|
566
|
+
const MAX_DISPOSE_FLUSH_ATTEMPTS = 10;
|
|
296
567
|
const drainAll = async () => {
|
|
297
|
-
|
|
298
|
-
while (buffer.length > 0) {
|
|
299
|
-
await flush();
|
|
568
|
+
let attempts = 0;
|
|
569
|
+
while (buffer.length > 0 && attempts < MAX_DISPOSE_FLUSH_ATTEMPTS) {
|
|
570
|
+
const delivered = await flush();
|
|
571
|
+
attempts += 1;
|
|
572
|
+
if (!delivered) break;
|
|
573
|
+
}
|
|
574
|
+
if (buffer.length > 0) {
|
|
575
|
+
if (isDevEnvironment()) {
|
|
576
|
+
console.warn(
|
|
577
|
+
`[lessonkit] dropped ${buffer.length} buffered telemetry event(s) after dispose flush cap`
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
buffer.length = 0;
|
|
300
581
|
}
|
|
301
582
|
};
|
|
302
583
|
intervalId = flushIntervalMs > 0 ? globalThis.setInterval(() => void flush(), flushIntervalMs) : void 0;
|
|
@@ -305,13 +586,14 @@ function createTrackingClient(opts) {
|
|
|
305
586
|
track: (event) => {
|
|
306
587
|
if (disposed || disposing) return;
|
|
307
588
|
if (buffer.length >= maxBufferSize) {
|
|
308
|
-
|
|
589
|
+
opts?.onBufferDrop?.();
|
|
309
590
|
if (!warnedBufferCap && isDevEnvironment()) {
|
|
310
591
|
warnedBufferCap = true;
|
|
311
592
|
console.warn(
|
|
312
|
-
`[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events;
|
|
593
|
+
`[lessonkit] telemetry batch buffer capped at ${maxBufferSize} events; new events are dropped until the buffer drains.`
|
|
313
594
|
);
|
|
314
595
|
}
|
|
596
|
+
return;
|
|
315
597
|
}
|
|
316
598
|
buffer.push(event);
|
|
317
599
|
if (buffer.length >= maxBatchSize) void flush();
|
|
@@ -344,96 +626,243 @@ function nowIso() {
|
|
|
344
626
|
return (/* @__PURE__ */ new Date()).toISOString();
|
|
345
627
|
}
|
|
346
628
|
|
|
347
|
-
// src/
|
|
348
|
-
var warnedMissingQuizLesson = false;
|
|
349
|
-
var warnedMissingAssessmentLesson = false;
|
|
350
|
-
function isDevEnvironment2() {
|
|
351
|
-
const g = globalThis;
|
|
352
|
-
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
353
|
-
}
|
|
354
|
-
function resetTelemetryBuilderWarningsForTests() {
|
|
355
|
-
warnedMissingQuizLesson = false;
|
|
356
|
-
warnedMissingAssessmentLesson = false;
|
|
357
|
-
}
|
|
629
|
+
// src/telemetry/eventRegistry.ts
|
|
358
630
|
function resolveLessonId(opts, eventName) {
|
|
359
631
|
const lessonId = opts.lessonId ?? opts.data?.lessonId;
|
|
360
632
|
if (!lessonId) throw new Error(`${eventName} requires lessonId`);
|
|
361
633
|
return lessonId;
|
|
362
634
|
}
|
|
363
|
-
function
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
635
|
+
function withLessonScopedData(name, base, lessonId, data) {
|
|
636
|
+
return { name, ...base, lessonId, data: { ...data, lessonId } };
|
|
637
|
+
}
|
|
638
|
+
var TELEMETRY_EVENT_REGISTRY = {
|
|
639
|
+
course_started: {
|
|
640
|
+
build: (_opts, base) => ({ name: "course_started", ...base })
|
|
641
|
+
},
|
|
642
|
+
course_completed: {
|
|
643
|
+
build: (_opts, base) => ({ name: "course_completed", ...base })
|
|
644
|
+
},
|
|
645
|
+
lesson_started: {
|
|
646
|
+
requiresLessonId: true,
|
|
647
|
+
build: (opts, base) => {
|
|
648
|
+
if (opts.name !== "lesson_started") throw new Error("unexpected event");
|
|
377
649
|
const lessonId = resolveLessonId(opts, "lesson_started");
|
|
650
|
+
return withLessonScopedData("lesson_started", base, lessonId, opts.data);
|
|
651
|
+
}
|
|
652
|
+
},
|
|
653
|
+
lesson_completed: {
|
|
654
|
+
requiresLessonId: true,
|
|
655
|
+
build: (opts, base) => {
|
|
656
|
+
if (opts.name !== "lesson_completed") throw new Error("unexpected event");
|
|
657
|
+
const lessonId = resolveLessonId(opts, opts.name);
|
|
658
|
+
return withLessonScopedData(opts.name, base, lessonId, opts.data);
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
lesson_time_on_task: {
|
|
662
|
+
requiresLessonId: true,
|
|
663
|
+
build: (opts, base) => {
|
|
664
|
+
if (opts.name !== "lesson_time_on_task") throw new Error("unexpected event");
|
|
665
|
+
const lessonId = resolveLessonId(opts, opts.name);
|
|
666
|
+
return withLessonScopedData(opts.name, base, lessonId, opts.data);
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
quiz_answered: {
|
|
670
|
+
requiresLessonId: true,
|
|
671
|
+
tryBuildMissingLessonWarning: "quiz",
|
|
672
|
+
build: (opts, base) => {
|
|
673
|
+
if (opts.name !== "quiz_answered") throw new Error("unexpected event");
|
|
674
|
+
const lessonId = opts.lessonId;
|
|
675
|
+
if (!lessonId) throw new Error("quiz_answered requires active lessonId");
|
|
378
676
|
return {
|
|
379
|
-
name: "
|
|
677
|
+
name: "quiz_answered",
|
|
380
678
|
...base,
|
|
381
679
|
lessonId,
|
|
382
|
-
data:
|
|
680
|
+
data: opts.data
|
|
383
681
|
};
|
|
384
682
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
683
|
+
},
|
|
684
|
+
quiz_completed: {
|
|
685
|
+
requiresLessonId: true,
|
|
686
|
+
tryBuildMissingLessonWarning: "quiz",
|
|
687
|
+
build: (opts, base) => {
|
|
688
|
+
if (opts.name !== "quiz_completed") throw new Error("unexpected event");
|
|
689
|
+
const lessonId = opts.lessonId;
|
|
690
|
+
if (!lessonId) throw new Error("quiz_completed requires active lessonId");
|
|
388
691
|
return {
|
|
389
|
-
name:
|
|
692
|
+
name: "quiz_completed",
|
|
390
693
|
...base,
|
|
391
694
|
lessonId,
|
|
392
|
-
data:
|
|
695
|
+
data: opts.data
|
|
393
696
|
};
|
|
394
697
|
}
|
|
395
|
-
|
|
698
|
+
},
|
|
699
|
+
assessment_answered: {
|
|
700
|
+
requiresLessonId: true,
|
|
701
|
+
tryBuildMissingLessonWarning: "assessment",
|
|
702
|
+
build: (opts, base) => {
|
|
703
|
+
if (opts.name !== "assessment_answered") throw new Error("unexpected event");
|
|
396
704
|
const lessonId = opts.lessonId;
|
|
397
|
-
if (!lessonId) throw new Error("
|
|
398
|
-
return {
|
|
705
|
+
if (!lessonId) throw new Error("assessment_answered requires active lessonId");
|
|
706
|
+
return {
|
|
707
|
+
name: "assessment_answered",
|
|
708
|
+
...base,
|
|
709
|
+
lessonId,
|
|
710
|
+
data: opts.data
|
|
711
|
+
};
|
|
399
712
|
}
|
|
400
|
-
|
|
713
|
+
},
|
|
714
|
+
assessment_completed: {
|
|
715
|
+
requiresLessonId: true,
|
|
716
|
+
tryBuildMissingLessonWarning: "assessment",
|
|
717
|
+
build: (opts, base) => {
|
|
718
|
+
if (opts.name !== "assessment_completed") throw new Error("unexpected event");
|
|
401
719
|
const lessonId = opts.lessonId;
|
|
402
|
-
if (!lessonId) throw new Error("
|
|
403
|
-
return {
|
|
720
|
+
if (!lessonId) throw new Error("assessment_completed requires active lessonId");
|
|
721
|
+
return {
|
|
722
|
+
name: "assessment_completed",
|
|
723
|
+
...base,
|
|
724
|
+
lessonId,
|
|
725
|
+
data: opts.data
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
interaction: {
|
|
730
|
+
build: (opts, base) => {
|
|
731
|
+
if (opts.name !== "interaction") throw new Error("unexpected event");
|
|
732
|
+
return {
|
|
733
|
+
name: "interaction",
|
|
734
|
+
...base,
|
|
735
|
+
lessonId: opts.lessonId,
|
|
736
|
+
data: opts.data
|
|
737
|
+
};
|
|
404
738
|
}
|
|
405
|
-
|
|
739
|
+
},
|
|
740
|
+
book_page_viewed: {
|
|
741
|
+
requiresLessonId: true,
|
|
742
|
+
build: (opts, base) => {
|
|
743
|
+
if (opts.name !== "book_page_viewed") throw new Error("unexpected event");
|
|
406
744
|
const lessonId = opts.lessonId;
|
|
407
|
-
if (!lessonId) throw new Error("
|
|
408
|
-
return {
|
|
745
|
+
if (!lessonId) throw new Error("book_page_viewed requires active lessonId");
|
|
746
|
+
return {
|
|
747
|
+
name: "book_page_viewed",
|
|
748
|
+
...base,
|
|
749
|
+
lessonId,
|
|
750
|
+
data: opts.data
|
|
751
|
+
};
|
|
409
752
|
}
|
|
410
|
-
|
|
753
|
+
},
|
|
754
|
+
slide_viewed: {
|
|
755
|
+
requiresLessonId: true,
|
|
756
|
+
build: (opts, base) => {
|
|
757
|
+
if (opts.name !== "slide_viewed") throw new Error("unexpected event");
|
|
411
758
|
const lessonId = opts.lessonId;
|
|
412
|
-
if (!lessonId) throw new Error("
|
|
413
|
-
return {
|
|
759
|
+
if (!lessonId) throw new Error("slide_viewed requires active lessonId");
|
|
760
|
+
return {
|
|
761
|
+
name: "slide_viewed",
|
|
762
|
+
...base,
|
|
763
|
+
lessonId,
|
|
764
|
+
data: opts.data
|
|
765
|
+
};
|
|
414
766
|
}
|
|
415
|
-
|
|
767
|
+
},
|
|
768
|
+
compound_page_viewed: {
|
|
769
|
+
requiresLessonId: true,
|
|
770
|
+
build: (opts, base) => {
|
|
771
|
+
if (opts.name !== "compound_page_viewed") throw new Error("unexpected event");
|
|
772
|
+
const lessonId = opts.lessonId;
|
|
773
|
+
if (!lessonId) throw new Error("compound_page_viewed requires active lessonId");
|
|
416
774
|
return {
|
|
417
|
-
name: "
|
|
775
|
+
name: "compound_page_viewed",
|
|
776
|
+
...base,
|
|
777
|
+
lessonId,
|
|
778
|
+
data: opts.data
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
hotspot_opened: {
|
|
783
|
+
build: (opts, base) => {
|
|
784
|
+
if (opts.name !== "hotspot_opened") throw new Error("unexpected event");
|
|
785
|
+
return {
|
|
786
|
+
name: "hotspot_opened",
|
|
787
|
+
...base,
|
|
788
|
+
lessonId: opts.lessonId,
|
|
789
|
+
data: opts.data
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
accordion_section_toggled: {
|
|
794
|
+
build: (opts, base) => {
|
|
795
|
+
if (opts.name !== "accordion_section_toggled") throw new Error("unexpected event");
|
|
796
|
+
return {
|
|
797
|
+
name: "accordion_section_toggled",
|
|
798
|
+
...base,
|
|
799
|
+
lessonId: opts.lessonId,
|
|
800
|
+
data: opts.data
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
},
|
|
804
|
+
flashcard_flipped: {
|
|
805
|
+
build: (opts, base) => {
|
|
806
|
+
if (opts.name !== "flashcard_flipped") throw new Error("unexpected event");
|
|
807
|
+
return {
|
|
808
|
+
name: "flashcard_flipped",
|
|
809
|
+
...base,
|
|
810
|
+
lessonId: opts.lessonId,
|
|
811
|
+
data: opts.data
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
image_slider_changed: {
|
|
816
|
+
build: (opts, base) => {
|
|
817
|
+
if (opts.name !== "image_slider_changed") throw new Error("unexpected event");
|
|
818
|
+
return {
|
|
819
|
+
name: "image_slider_changed",
|
|
418
820
|
...base,
|
|
419
821
|
lessonId: opts.lessonId,
|
|
420
822
|
data: opts.data
|
|
421
823
|
};
|
|
422
|
-
|
|
423
|
-
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
function buildTelemetryEventFromRegistry(opts) {
|
|
828
|
+
const entry = TELEMETRY_EVENT_REGISTRY[opts.name];
|
|
829
|
+
if (!entry) {
|
|
830
|
+
throw new Error("Unexpected value");
|
|
424
831
|
}
|
|
832
|
+
const base = {
|
|
833
|
+
timestamp: opts.timestamp ?? nowIso(),
|
|
834
|
+
courseId: opts.courseId,
|
|
835
|
+
sessionId: opts.sessionId,
|
|
836
|
+
attemptId: opts.attemptId,
|
|
837
|
+
user: opts.user
|
|
838
|
+
};
|
|
839
|
+
return entry.build(opts, base);
|
|
840
|
+
}
|
|
841
|
+
function getTelemetryEventRegistryEntry(name) {
|
|
842
|
+
return TELEMETRY_EVENT_REGISTRY[name];
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// src/telemetryBuilder.ts
|
|
846
|
+
var warnedMissingQuizLesson = false;
|
|
847
|
+
var warnedMissingAssessmentLesson = false;
|
|
848
|
+
function resetTelemetryBuilderWarningsForTests() {
|
|
849
|
+
warnedMissingQuizLesson = false;
|
|
850
|
+
warnedMissingAssessmentLesson = false;
|
|
851
|
+
}
|
|
852
|
+
function buildTelemetryEvent(opts) {
|
|
853
|
+
return buildTelemetryEventFromRegistry(opts);
|
|
425
854
|
}
|
|
426
855
|
function tryBuildTelemetryEvent(opts) {
|
|
427
|
-
const
|
|
428
|
-
if (
|
|
429
|
-
if (
|
|
430
|
-
if (
|
|
856
|
+
const entry = getTelemetryEventRegistryEntry(opts.name);
|
|
857
|
+
if (entry.requiresLessonId && !opts.lessonId && entry.tryBuildMissingLessonWarning) {
|
|
858
|
+
if (isDevEnvironment()) {
|
|
859
|
+
if (entry.tryBuildMissingLessonWarning === "quiz" && !warnedMissingQuizLesson) {
|
|
431
860
|
warnedMissingQuizLesson = true;
|
|
432
861
|
console.warn(
|
|
433
862
|
`[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
|
|
434
863
|
);
|
|
435
864
|
}
|
|
436
|
-
if (
|
|
865
|
+
if (entry.tryBuildMissingLessonWarning === "assessment" && !warnedMissingAssessmentLesson) {
|
|
437
866
|
warnedMissingAssessmentLesson = true;
|
|
438
867
|
console.warn(
|
|
439
868
|
`[lessonkit] ${opts.name} skipped: wrap assessment blocks in <Lesson> so an active lessonId is available`
|
|
@@ -446,29 +875,8 @@ function tryBuildTelemetryEvent(opts) {
|
|
|
446
875
|
}
|
|
447
876
|
|
|
448
877
|
// src/telemetryPipeline.ts
|
|
449
|
-
function isDevEnvironment3() {
|
|
450
|
-
const g = globalThis;
|
|
451
|
-
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
452
|
-
}
|
|
453
|
-
function warnSinkFailure(sinkId, err) {
|
|
454
|
-
if (isDevEnvironment3()) {
|
|
455
|
-
console.warn(
|
|
456
|
-
`[lessonkit] telemetry sink "${sinkId}" failed:`,
|
|
457
|
-
err instanceof Error ? err.message : err
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
878
|
function invokeSink(sink, event, emitCtx) {
|
|
462
|
-
|
|
463
|
-
try {
|
|
464
|
-
result = sink.emit(event, emitCtx);
|
|
465
|
-
} catch (err) {
|
|
466
|
-
warnSinkFailure(sink.id, err);
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
if (result != null && typeof result.catch === "function") {
|
|
470
|
-
void result.catch((err) => warnSinkFailure(sink.id, err));
|
|
471
|
-
}
|
|
879
|
+
invokePipelineSink(sink.id, () => sink.emit(event, emitCtx));
|
|
472
880
|
}
|
|
473
881
|
function createTelemetryPipeline(sinks) {
|
|
474
882
|
const list = [...sinks];
|
|
@@ -505,8 +913,7 @@ function createDefaultClock() {
|
|
|
505
913
|
function createNoopStorage() {
|
|
506
914
|
return {
|
|
507
915
|
getItem: () => null,
|
|
508
|
-
setItem: () =>
|
|
509
|
-
}
|
|
916
|
+
setItem: () => true
|
|
510
917
|
};
|
|
511
918
|
}
|
|
512
919
|
function createMemoryBackedSessionStorage(session) {
|
|
@@ -537,8 +944,10 @@ function createMemoryBackedSessionStorage(session) {
|
|
|
537
944
|
memory.set(key, value);
|
|
538
945
|
try {
|
|
539
946
|
session.setItem(key, value);
|
|
947
|
+
return true;
|
|
540
948
|
} catch {
|
|
541
949
|
warnPersistFailure();
|
|
950
|
+
return false;
|
|
542
951
|
}
|
|
543
952
|
},
|
|
544
953
|
removeItem: (key) => {
|
|
@@ -563,6 +972,7 @@ function createInMemorySessionStoragePort() {
|
|
|
563
972
|
getItem: (key) => memory.get(key) ?? null,
|
|
564
973
|
setItem: (key, value) => {
|
|
565
974
|
memory.set(key, value);
|
|
975
|
+
return true;
|
|
566
976
|
},
|
|
567
977
|
removeItem: (key) => {
|
|
568
978
|
memory.delete(key);
|
|
@@ -615,7 +1025,12 @@ function createProgressController() {
|
|
|
615
1025
|
return { previousLessonId };
|
|
616
1026
|
},
|
|
617
1027
|
completeLesson: (lessonId, completedAtMs) => {
|
|
618
|
-
if (completedLessonIds.has(lessonId))
|
|
1028
|
+
if (completedLessonIds.has(lessonId)) {
|
|
1029
|
+
if (activeLessonId === lessonId) {
|
|
1030
|
+
activeLessonId = void 0;
|
|
1031
|
+
}
|
|
1032
|
+
return { didComplete: false };
|
|
1033
|
+
}
|
|
619
1034
|
completedLessonIds = new Set(completedLessonIds).add(lessonId);
|
|
620
1035
|
if (activeLessonId === lessonId) {
|
|
621
1036
|
activeLessonId = void 0;
|
|
@@ -635,6 +1050,12 @@ function createProgressController() {
|
|
|
635
1050
|
|
|
636
1051
|
// src/session.ts
|
|
637
1052
|
var SESSION_STORAGE_KEY = "lessonkit:sessionId";
|
|
1053
|
+
var volatileSessionIds = /* @__PURE__ */ new WeakMap();
|
|
1054
|
+
var sharedVolatileSessionId = null;
|
|
1055
|
+
function isDevEnvironment2() {
|
|
1056
|
+
const g = globalThis;
|
|
1057
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
1058
|
+
}
|
|
638
1059
|
function getTabSessionId(storage) {
|
|
639
1060
|
return storage.getItem(SESSION_STORAGE_KEY);
|
|
640
1061
|
}
|
|
@@ -642,11 +1063,28 @@ var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
|
|
|
642
1063
|
var COURSE_STARTED_TRACKING_PREFIX = "lessonkit:course_started_tracking:";
|
|
643
1064
|
var COURSE_STARTED_PIPELINE_PREFIX = "lessonkit:course_started_pipeline:";
|
|
644
1065
|
function resolveSessionId(storage, provided) {
|
|
645
|
-
if (provided)
|
|
1066
|
+
if (provided !== void 0) {
|
|
1067
|
+
const trimmed = provided.trim();
|
|
1068
|
+
if (trimmed.length > 0) return trimmed;
|
|
1069
|
+
}
|
|
646
1070
|
const existing = storage.getItem(SESSION_STORAGE_KEY);
|
|
647
1071
|
if (existing) return existing;
|
|
1072
|
+
const volatile = volatileSessionIds.get(storage);
|
|
1073
|
+
if (volatile) return volatile;
|
|
648
1074
|
const id = createSessionId();
|
|
649
|
-
storage.setItem(SESSION_STORAGE_KEY, id);
|
|
1075
|
+
const persisted = storage.setItem(SESSION_STORAGE_KEY, id);
|
|
1076
|
+
if (!persisted) {
|
|
1077
|
+
if (!sharedVolatileSessionId) {
|
|
1078
|
+
sharedVolatileSessionId = id;
|
|
1079
|
+
}
|
|
1080
|
+
volatileSessionIds.set(storage, sharedVolatileSessionId);
|
|
1081
|
+
if (isDevEnvironment2()) {
|
|
1082
|
+
console.warn(
|
|
1083
|
+
"[lessonkit] session id could not be persisted; reusing in-memory id for this tab."
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
return sharedVolatileSessionId;
|
|
1087
|
+
}
|
|
650
1088
|
return id;
|
|
651
1089
|
}
|
|
652
1090
|
function courseStartedStorageKey(sessionId, courseId) {
|
|
@@ -663,8 +1101,8 @@ function hasCourseStarted(storage, sessionId, courseId) {
|
|
|
663
1101
|
return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
|
|
664
1102
|
}
|
|
665
1103
|
function markCourseStarted(storage, sessionId, courseId) {
|
|
666
|
-
if (!courseId) return;
|
|
667
|
-
storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
|
|
1104
|
+
if (!courseId) return false;
|
|
1105
|
+
return storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
|
|
668
1106
|
}
|
|
669
1107
|
function hasCourseStartedEmittedToTracking(storage, sessionId, courseId) {
|
|
670
1108
|
if (!courseId) return false;
|
|
@@ -682,6 +1120,9 @@ function markCourseStartedPipelineDelivered(storage, sessionId, courseId) {
|
|
|
682
1120
|
if (!courseId) return;
|
|
683
1121
|
storage.setItem(courseStartedPipelineStorageKey(sessionId, courseId), "1");
|
|
684
1122
|
}
|
|
1123
|
+
function resetSharedVolatileSessionIdForTests() {
|
|
1124
|
+
sharedVolatileSessionId = null;
|
|
1125
|
+
}
|
|
685
1126
|
function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
|
|
686
1127
|
if (!courseId || fromSessionId === toSessionId) return;
|
|
687
1128
|
if (hasCourseStarted(storage, fromSessionId, courseId)) {
|
|
@@ -704,14 +1145,14 @@ function tryEmitCourseStarted(ctx, deps, alreadyEmittedToSink) {
|
|
|
704
1145
|
if (alreadyEmittedToSink) {
|
|
705
1146
|
return { emitted: true, marked };
|
|
706
1147
|
}
|
|
707
|
-
if (marked) {
|
|
708
|
-
return { emitted: false, marked: true };
|
|
709
|
-
}
|
|
710
1148
|
const emitted = deps.emitCourseStartedEvent(ctx);
|
|
711
|
-
if (emitted) {
|
|
1149
|
+
if (emitted && !marked) {
|
|
712
1150
|
markCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId);
|
|
713
1151
|
}
|
|
714
|
-
return {
|
|
1152
|
+
return {
|
|
1153
|
+
emitted,
|
|
1154
|
+
marked: hasCourseStarted(ctx.storage, ctx.sessionId, ctx.courseId)
|
|
1155
|
+
};
|
|
715
1156
|
}
|
|
716
1157
|
function buildCourseStartedTelemetryEvent(ctx) {
|
|
717
1158
|
return buildTelemetryEvent({
|
|
@@ -744,101 +1185,13 @@ function completeCourseWithTelemetry(opts) {
|
|
|
744
1185
|
return true;
|
|
745
1186
|
}
|
|
746
1187
|
|
|
747
|
-
// src/
|
|
748
|
-
function
|
|
749
|
-
const storage = ports.storage ?? createSessionStoragePort();
|
|
750
|
-
const clock = ports.clock ?? createDefaultClock();
|
|
751
|
-
const configSnapshot = { ...config };
|
|
752
|
-
let sessionId = resolveSessionId(storage, configSnapshot.session?.sessionId);
|
|
753
|
-
let attemptId = configSnapshot.session?.attemptId;
|
|
754
|
-
let user = configSnapshot.session?.user;
|
|
755
|
-
let courseId = configSnapshot.courseId;
|
|
756
|
-
let progress = createProgressController();
|
|
757
|
-
const getSession = () => ({ sessionId, attemptId, user });
|
|
758
|
-
const syncSessionFromConfig = (next) => {
|
|
759
|
-
sessionId = resolveSessionId(storage, next.session?.sessionId);
|
|
760
|
-
attemptId = next.session?.attemptId;
|
|
761
|
-
user = next.session?.user;
|
|
762
|
-
courseId = next.courseId;
|
|
763
|
-
};
|
|
764
|
-
syncSessionFromConfig(configSnapshot);
|
|
765
|
-
const track = (name, data, emit, lessonId) => {
|
|
766
|
-
const event = tryBuildTelemetryEvent({
|
|
767
|
-
name,
|
|
768
|
-
courseId,
|
|
769
|
-
lessonId: lessonId ?? progress.getState().activeLessonId,
|
|
770
|
-
sessionId,
|
|
771
|
-
attemptId,
|
|
772
|
-
user,
|
|
773
|
-
data
|
|
774
|
-
});
|
|
775
|
-
if (!event) return;
|
|
776
|
-
emit(event);
|
|
777
|
-
};
|
|
778
|
-
const emitLessonCompleted = (lessonId, durationMs, emitFn) => {
|
|
779
|
-
emitFn("lesson_completed", { lessonId, durationMs }, lessonId);
|
|
780
|
-
if (durationMs !== void 0) {
|
|
781
|
-
emitFn("lesson_time_on_task", { lessonId, durationMs }, lessonId);
|
|
782
|
-
}
|
|
783
|
-
};
|
|
1188
|
+
// src/plugins/context.ts
|
|
1189
|
+
function buildPluginContext(opts) {
|
|
784
1190
|
return {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
return progress;
|
|
790
|
-
},
|
|
791
|
-
getProgressState: () => progress.getState(),
|
|
792
|
-
getSession,
|
|
793
|
-
updateConfig(next) {
|
|
794
|
-
if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
|
|
795
|
-
if (next.runtimeVersion !== void 0) configSnapshot.runtimeVersion = next.runtimeVersion;
|
|
796
|
-
if (next.plugins !== void 0) configSnapshot.plugins = next.plugins;
|
|
797
|
-
if (next.session !== void 0) {
|
|
798
|
-
configSnapshot.session = { ...configSnapshot.session, ...next.session };
|
|
799
|
-
}
|
|
800
|
-
syncSessionFromConfig(configSnapshot);
|
|
801
|
-
},
|
|
802
|
-
setActiveLesson(lessonId, emitFn) {
|
|
803
|
-
const current = progress.getState();
|
|
804
|
-
if (current.activeLessonId === lessonId) return;
|
|
805
|
-
if (current.completedLessonIds.has(lessonId)) {
|
|
806
|
-
progress.setActiveLesson(lessonId, clock.nowMs());
|
|
807
|
-
return;
|
|
808
|
-
}
|
|
809
|
-
const previous = current.activeLessonId;
|
|
810
|
-
if (previous && previous !== lessonId) {
|
|
811
|
-
const completed = progress.completeLesson(previous, clock.nowMs());
|
|
812
|
-
if (completed.didComplete) {
|
|
813
|
-
emitLessonCompleted(previous, completed.durationMs, emitFn);
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
progress.setActiveLesson(lessonId, clock.nowMs());
|
|
817
|
-
emitFn("lesson_started", { lessonId }, lessonId);
|
|
818
|
-
},
|
|
819
|
-
completeLesson(lessonId, emitFn) {
|
|
820
|
-
const result = progress.completeLesson(lessonId, clock.nowMs());
|
|
821
|
-
if (!result.didComplete) return;
|
|
822
|
-
emitLessonCompleted(lessonId, result.durationMs, emitFn);
|
|
823
|
-
},
|
|
824
|
-
completeCourse(emitFn) {
|
|
825
|
-
const current = progress.getState();
|
|
826
|
-
if (current.activeLessonId) {
|
|
827
|
-
const lessonResult = progress.completeLesson(current.activeLessonId, clock.nowMs());
|
|
828
|
-
if (lessonResult.didComplete) {
|
|
829
|
-
emitLessonCompleted(current.activeLessonId, lessonResult.durationMs, emitFn);
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
const result = progress.completeCourse();
|
|
833
|
-
if (!result.didComplete) return;
|
|
834
|
-
emitFn("course_completed");
|
|
835
|
-
},
|
|
836
|
-
track,
|
|
837
|
-
resetForCourseChange(nextCourseId) {
|
|
838
|
-
configSnapshot.courseId = nextCourseId;
|
|
839
|
-
courseId = nextCourseId;
|
|
840
|
-
progress = createProgressController();
|
|
841
|
-
}
|
|
1191
|
+
courseId: opts.courseId,
|
|
1192
|
+
sessionId: opts.sessionId,
|
|
1193
|
+
attemptId: opts.attemptId,
|
|
1194
|
+
user: opts.user
|
|
842
1195
|
};
|
|
843
1196
|
}
|
|
844
1197
|
|
|
@@ -889,7 +1242,7 @@ function createPluginRegistry(plugins = []) {
|
|
|
889
1242
|
const composeTrackingSink = (sink, ctxSource) => {
|
|
890
1243
|
if (!sink) return void 0;
|
|
891
1244
|
const resolveCtx = () => typeof ctxSource === "function" ? ctxSource() : ctxSource;
|
|
892
|
-
const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}`;
|
|
1245
|
+
const ctxKey = (ctx) => `${ctx.courseId}\0${ctx.sessionId ?? ""}\0${ctx.attemptId ?? ""}\0${ctx.user?.id ?? ""}`;
|
|
893
1246
|
const layers = [];
|
|
894
1247
|
let composed = sink;
|
|
895
1248
|
for (const plugin of list) {
|
|
@@ -929,6 +1282,174 @@ function createPluginRegistry(plugins = []) {
|
|
|
929
1282
|
};
|
|
930
1283
|
}
|
|
931
1284
|
|
|
1285
|
+
// src/runtime/createLessonkitRuntime.ts
|
|
1286
|
+
function resolvePluginHost(plugins) {
|
|
1287
|
+
if (!plugins) return null;
|
|
1288
|
+
if (typeof plugins === "object" && "runTelemetry" in plugins) return plugins;
|
|
1289
|
+
if (Array.isArray(plugins) && plugins.length > 0) return createPluginRegistry(plugins);
|
|
1290
|
+
return null;
|
|
1291
|
+
}
|
|
1292
|
+
function warnRuntimeV1Deprecated() {
|
|
1293
|
+
const g = globalThis;
|
|
1294
|
+
if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production") return;
|
|
1295
|
+
console.warn(
|
|
1296
|
+
'[lessonkit] runtimeVersion "v1" is deprecated; use "v2" (default). v1 will be removed in LessonKit 2.0.'
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
function createLessonkitRuntime(config, ports = {}) {
|
|
1300
|
+
if (config.runtimeVersion === "v1") warnRuntimeV1Deprecated();
|
|
1301
|
+
const storage = ports.storage ?? createSessionStoragePort();
|
|
1302
|
+
const clock = ports.clock ?? createDefaultClock();
|
|
1303
|
+
const configSnapshot = { ...config };
|
|
1304
|
+
let sessionId = resolveSessionId(storage, configSnapshot.session?.sessionId);
|
|
1305
|
+
let attemptId = configSnapshot.session?.attemptId;
|
|
1306
|
+
let user = configSnapshot.session?.user;
|
|
1307
|
+
let courseId = configSnapshot.courseId;
|
|
1308
|
+
let progress = createProgressController();
|
|
1309
|
+
let pluginHost = resolvePluginHost(configSnapshot.plugins);
|
|
1310
|
+
const getPluginCtx = () => buildPluginContext({
|
|
1311
|
+
courseId,
|
|
1312
|
+
sessionId,
|
|
1313
|
+
attemptId,
|
|
1314
|
+
user
|
|
1315
|
+
});
|
|
1316
|
+
if (!configSnapshot.deferPluginSetup) {
|
|
1317
|
+
pluginHost?.setupAll(getPluginCtx());
|
|
1318
|
+
}
|
|
1319
|
+
const getSession = () => ({ sessionId, attemptId, user });
|
|
1320
|
+
const syncSessionFromConfig = (next) => {
|
|
1321
|
+
sessionId = resolveSessionId(storage, next.session?.sessionId);
|
|
1322
|
+
attemptId = next.session?.attemptId;
|
|
1323
|
+
user = next.session?.user;
|
|
1324
|
+
courseId = next.courseId;
|
|
1325
|
+
};
|
|
1326
|
+
const applyPluginsToEvent = (event) => {
|
|
1327
|
+
if (!pluginHost) return event;
|
|
1328
|
+
return pluginHost.runTelemetry(event, getPluginCtx());
|
|
1329
|
+
};
|
|
1330
|
+
const buildAndApply = (name, data, lessonId) => {
|
|
1331
|
+
const event = tryBuildTelemetryEvent({
|
|
1332
|
+
name,
|
|
1333
|
+
courseId,
|
|
1334
|
+
lessonId: lessonId ?? progress.getState().activeLessonId,
|
|
1335
|
+
sessionId,
|
|
1336
|
+
attemptId,
|
|
1337
|
+
user,
|
|
1338
|
+
data
|
|
1339
|
+
});
|
|
1340
|
+
if (!event) return null;
|
|
1341
|
+
return applyPluginsToEvent(event);
|
|
1342
|
+
};
|
|
1343
|
+
const wrapEmitFn = (emitFn) => {
|
|
1344
|
+
return (name, data, lessonId) => {
|
|
1345
|
+
const event = buildAndApply(name, data, lessonId);
|
|
1346
|
+
if (event === null) return;
|
|
1347
|
+
const eventLessonId = "lessonId" in event ? event.lessonId : lessonId;
|
|
1348
|
+
const eventData = "data" in event ? event.data : data;
|
|
1349
|
+
emitFn(event.name, eventData, eventLessonId);
|
|
1350
|
+
};
|
|
1351
|
+
};
|
|
1352
|
+
syncSessionFromConfig(configSnapshot);
|
|
1353
|
+
const track = (name, data, emit, lessonId) => {
|
|
1354
|
+
const event = buildAndApply(name, data, lessonId);
|
|
1355
|
+
if (!event) return;
|
|
1356
|
+
emit(event);
|
|
1357
|
+
};
|
|
1358
|
+
const emitLessonCompletedEvents = (lessonId, durationMs, emitFn) => {
|
|
1359
|
+
const wrapped = wrapEmitFn(emitFn);
|
|
1360
|
+
wrapped("lesson_completed", { lessonId, durationMs }, lessonId);
|
|
1361
|
+
if (durationMs !== void 0) {
|
|
1362
|
+
wrapped("lesson_time_on_task", { lessonId, durationMs }, lessonId);
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
return {
|
|
1366
|
+
get config() {
|
|
1367
|
+
return configSnapshot;
|
|
1368
|
+
},
|
|
1369
|
+
get progress() {
|
|
1370
|
+
return progress;
|
|
1371
|
+
},
|
|
1372
|
+
get pluginHost() {
|
|
1373
|
+
return pluginHost;
|
|
1374
|
+
},
|
|
1375
|
+
getProgressState: () => progress.getState(),
|
|
1376
|
+
getSession,
|
|
1377
|
+
updateConfig(next) {
|
|
1378
|
+
const previousCourseId = courseId;
|
|
1379
|
+
const sessionKeyBefore = JSON.stringify({ sessionId, attemptId, user });
|
|
1380
|
+
if (next.courseId !== void 0) configSnapshot.courseId = next.courseId;
|
|
1381
|
+
if (next.runtimeVersion !== void 0) {
|
|
1382
|
+
if (next.runtimeVersion === "v1") warnRuntimeV1Deprecated();
|
|
1383
|
+
configSnapshot.runtimeVersion = next.runtimeVersion;
|
|
1384
|
+
}
|
|
1385
|
+
if (next.session !== void 0) {
|
|
1386
|
+
configSnapshot.session = { ...configSnapshot.session, ...next.session };
|
|
1387
|
+
}
|
|
1388
|
+
syncSessionFromConfig(configSnapshot);
|
|
1389
|
+
const sessionKeyAfter = JSON.stringify({ sessionId, attemptId, user });
|
|
1390
|
+
if (next.courseId !== void 0 && next.courseId !== previousCourseId) {
|
|
1391
|
+
progress = createProgressController();
|
|
1392
|
+
}
|
|
1393
|
+
if (next.plugins !== void 0 && next.plugins !== pluginHost) {
|
|
1394
|
+
pluginHost?.disposeAll();
|
|
1395
|
+
configSnapshot.plugins = next.plugins;
|
|
1396
|
+
pluginHost = resolvePluginHost(configSnapshot.plugins);
|
|
1397
|
+
pluginHost?.setupAll(getPluginCtx());
|
|
1398
|
+
} else if (next.session !== void 0 && sessionKeyBefore !== sessionKeyAfter && pluginHost && !configSnapshot.deferPluginSetup) {
|
|
1399
|
+
pluginHost.disposeAll();
|
|
1400
|
+
pluginHost.setupAll(getPluginCtx());
|
|
1401
|
+
}
|
|
1402
|
+
},
|
|
1403
|
+
setActiveLesson(lessonId, emitFn) {
|
|
1404
|
+
const wrapped = wrapEmitFn(emitFn);
|
|
1405
|
+
const current = progress.getState();
|
|
1406
|
+
if (current.activeLessonId === lessonId) return;
|
|
1407
|
+
if (current.completedLessonIds.has(lessonId)) {
|
|
1408
|
+
progress.setActiveLesson(lessonId, clock.nowMs());
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
const previous = current.activeLessonId;
|
|
1412
|
+
if (previous && previous !== lessonId) {
|
|
1413
|
+
const completed = progress.completeLesson(previous, clock.nowMs());
|
|
1414
|
+
if (completed.didComplete) {
|
|
1415
|
+
emitLessonCompletedEvents(previous, completed.durationMs, wrapped);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
progress.setActiveLesson(lessonId, clock.nowMs());
|
|
1419
|
+
wrapped("lesson_started", { lessonId }, lessonId);
|
|
1420
|
+
},
|
|
1421
|
+
completeLesson(lessonId, emitFn) {
|
|
1422
|
+
completeLessonWithTelemetry({
|
|
1423
|
+
progress,
|
|
1424
|
+
lessonId,
|
|
1425
|
+
nowMs: clock.nowMs(),
|
|
1426
|
+
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn))
|
|
1427
|
+
});
|
|
1428
|
+
},
|
|
1429
|
+
completeCourse(emitFn) {
|
|
1430
|
+
completeCourseWithTelemetry({
|
|
1431
|
+
progress,
|
|
1432
|
+
nowMs: clock.nowMs(),
|
|
1433
|
+
emitLessonCompleted: (id, durationMs) => emitLessonCompletedEvents(id, durationMs, wrapEmitFn(emitFn)),
|
|
1434
|
+
emitCourseCompleted: () => wrapEmitFn(emitFn)("course_completed")
|
|
1435
|
+
});
|
|
1436
|
+
},
|
|
1437
|
+
track,
|
|
1438
|
+
scoreAssessment(input, _lessonId) {
|
|
1439
|
+
if (!pluginHost) return null;
|
|
1440
|
+
return pluginHost.scoreAssessment(input, getPluginCtx());
|
|
1441
|
+
},
|
|
1442
|
+
resetForCourseChange(nextCourseId) {
|
|
1443
|
+
configSnapshot.courseId = nextCourseId;
|
|
1444
|
+
courseId = nextCourseId;
|
|
1445
|
+
progress = createProgressController();
|
|
1446
|
+
},
|
|
1447
|
+
dispose() {
|
|
1448
|
+
pluginHost?.disposeAll();
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
|
|
932
1453
|
// src/plugins/define.ts
|
|
933
1454
|
function defineTelemetryPlugin(plugin) {
|
|
934
1455
|
return plugin;
|
|
@@ -940,20 +1461,35 @@ function defineLifecyclePlugin(plugin) {
|
|
|
940
1461
|
return plugin;
|
|
941
1462
|
}
|
|
942
1463
|
export {
|
|
1464
|
+
ACCORDION_FORBIDDEN_CHILD_TYPES,
|
|
1465
|
+
ASSESSMENT_SEQUENCE_ALLOWED_CHILD_TYPES,
|
|
1466
|
+
COMPOUND_MAX_NESTING_DEPTH,
|
|
1467
|
+
COMPOUND_RESUME_SCHEMA_VERSION,
|
|
943
1468
|
ID_MAX_LENGTH,
|
|
944
1469
|
ID_PATTERN,
|
|
1470
|
+
INTERACTIVE_BOOK_ALLOWED_CHILD_TYPES,
|
|
1471
|
+
PAGE_ALLOWED_CHILD_TYPES,
|
|
945
1472
|
SESSION_STORAGE_KEY,
|
|
1473
|
+
SLIDE_ALLOWED_CHILD_TYPES,
|
|
1474
|
+
SLIDE_DECK_ALLOWED_CHILD_TYPES,
|
|
946
1475
|
TELEMETRY_EVENT_CATALOG,
|
|
947
1476
|
TELEMETRY_EVENT_CATALOG_V2,
|
|
1477
|
+
TELEMETRY_EVENT_CATALOG_V3,
|
|
948
1478
|
assertNever,
|
|
949
1479
|
assertValidId,
|
|
950
1480
|
buildCourseStartedTelemetryEvent,
|
|
951
1481
|
buildLessonkitUrn,
|
|
1482
|
+
buildPluginContext,
|
|
952
1483
|
buildTelemetryCatalog,
|
|
953
1484
|
buildTelemetryCatalogV2,
|
|
1485
|
+
buildTelemetryCatalogV3,
|
|
954
1486
|
buildTelemetryEvent,
|
|
1487
|
+
clampCompoundPageIndex,
|
|
1488
|
+
clearCompoundState,
|
|
955
1489
|
completeCourseWithTelemetry,
|
|
956
1490
|
completeLessonWithTelemetry,
|
|
1491
|
+
compoundStateStorageKey,
|
|
1492
|
+
createCompoundResumeState,
|
|
957
1493
|
createDefaultClock,
|
|
958
1494
|
createGlobalTimer,
|
|
959
1495
|
createLessonkitRuntime,
|
|
@@ -969,10 +1505,13 @@ export {
|
|
|
969
1505
|
defineLifecyclePlugin,
|
|
970
1506
|
defineTelemetryPlugin,
|
|
971
1507
|
deriveId,
|
|
1508
|
+
getAllowedChildTypes,
|
|
972
1509
|
getTabSessionId,
|
|
973
1510
|
hasCourseStarted,
|
|
974
1511
|
hasCourseStartedEmittedToTracking,
|
|
975
1512
|
hasCourseStartedPipelineDelivered,
|
|
1513
|
+
isChildTypeAllowed,
|
|
1514
|
+
loadCompoundState,
|
|
976
1515
|
markCourseStarted,
|
|
977
1516
|
markCourseStartedEmittedToTracking,
|
|
978
1517
|
markCourseStartedPipelineDelivered,
|
|
@@ -980,13 +1519,17 @@ export {
|
|
|
980
1519
|
nowIso,
|
|
981
1520
|
parseBlockId,
|
|
982
1521
|
parseCheckId,
|
|
1522
|
+
parseCompoundResumeState,
|
|
983
1523
|
parseCourseId,
|
|
984
1524
|
parseLessonId,
|
|
1525
|
+
resetSharedVolatileSessionIdForTests,
|
|
985
1526
|
resetStoragePortForTests,
|
|
986
1527
|
resetTelemetryBuilderWarningsForTests,
|
|
987
1528
|
resolveSessionId,
|
|
1529
|
+
saveCompoundState,
|
|
988
1530
|
slugifyId,
|
|
989
1531
|
telemetryCatalogV2Version,
|
|
1532
|
+
telemetryCatalogV3Version,
|
|
990
1533
|
telemetryCatalogVersion,
|
|
991
1534
|
tryBuildTelemetryEvent,
|
|
992
1535
|
tryEmitCourseStarted,
|