coursecode 0.1.57 → 0.1.58

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.
Files changed (85) hide show
  1. package/README.md +8 -1
  2. package/bin/cli.js +16 -0
  3. package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
  4. package/framework/docs/USER_GUIDE.md +14 -0
  5. package/framework/js/app/AppActions.js +17 -23
  6. package/framework/js/app/AppUI.js +7 -4
  7. package/framework/js/assessment/AssessmentActions.js +16 -15
  8. package/framework/js/assessment/AssessmentFactory.js +44 -15
  9. package/framework/js/automation/api-engagement.js +6 -3
  10. package/framework/js/components/ui-components/audio-player.js +30 -8
  11. package/framework/js/components/ui-components/collapse.js +4 -2
  12. package/framework/js/components/ui-components/dropdown.js +7 -3
  13. package/framework/js/components/ui-components/embed-frame.js +5 -1
  14. package/framework/js/components/ui-components/flip-card.js +12 -6
  15. package/framework/js/components/ui-components/interactive-image.js +28 -52
  16. package/framework/js/components/ui-components/lightbox.js +19 -5
  17. package/framework/js/components/ui-components/modal.js +20 -7
  18. package/framework/js/components/ui-components/notifications.js +8 -3
  19. package/framework/js/components/ui-components/video-player.js +46 -20
  20. package/framework/js/core/event-bus.js +31 -28
  21. package/framework/js/drivers/cmi5-driver.js +186 -83
  22. package/framework/js/drivers/driver-factory.js +7 -1
  23. package/framework/js/drivers/driver-interface.js +1 -1
  24. package/framework/js/drivers/http-driver-base.js +6 -0
  25. package/framework/js/drivers/lti-driver.js +112 -47
  26. package/framework/js/drivers/proxy-driver.js +347 -49
  27. package/framework/js/drivers/scorm-12-driver.js +213 -130
  28. package/framework/js/drivers/scorm-2004-driver.js +32 -19
  29. package/framework/js/drivers/scorm-driver-base.js +12 -0
  30. package/framework/js/drivers/standalone-driver.js +139 -0
  31. package/framework/js/engagement/engagement-trackers.js +38 -13
  32. package/framework/js/engagement/requirement-strategies.js +5 -1
  33. package/framework/js/main.js +7 -0
  34. package/framework/js/managers/assessment-manager.js +16 -1
  35. package/framework/js/managers/audio-manager.js +50 -16
  36. package/framework/js/managers/flag-manager.js +5 -1
  37. package/framework/js/managers/objective-manager.js +15 -7
  38. package/framework/js/managers/score-manager.js +27 -6
  39. package/framework/js/managers/video-manager.js +149 -70
  40. package/framework/js/navigation/NavigationActions.js +18 -4
  41. package/framework/js/navigation/document-gallery.js +7 -2
  42. package/framework/js/navigation/navigation-validators.js +3 -2
  43. package/framework/js/state/lms-connection.js +41 -16
  44. package/framework/js/state/state-commits.js +10 -5
  45. package/framework/js/state/state-manager.js +95 -9
  46. package/framework/js/state/state-validation.js +44 -11
  47. package/framework/js/utilities/course-helpers.js +3 -2
  48. package/framework/js/utilities/media-utils.js +28 -0
  49. package/framework/js/utilities/portable-assets.js +131 -0
  50. package/framework/js/utilities/ui-initializer.js +79 -5
  51. package/framework/js/utilities/utilities.js +32 -20
  52. package/framework/js/utilities/view-manager.js +16 -1
  53. package/framework/js/validation/scorm-validators.js +69 -0
  54. package/framework/version.json +2 -2
  55. package/lib/authoring-api.js +37 -18
  56. package/lib/build-packaging.js +47 -3
  57. package/lib/build.js +1 -1
  58. package/lib/cloud.js +40 -11
  59. package/lib/convert.js +30 -2
  60. package/lib/create.js +5 -3
  61. package/lib/dev.js +1 -1
  62. package/lib/headless-browser.js +66 -14
  63. package/lib/manifest/cmi5-manifest.js +26 -4
  64. package/lib/manifest/lti-tool-config.js +7 -2
  65. package/lib/manifest/manifest-factory.d.ts +1 -1
  66. package/lib/manifest/manifest-factory.js +3 -2
  67. package/lib/manifest/scorm-12-manifest.js +2 -2
  68. package/lib/manifest/scorm-2004-manifest.js +3 -28
  69. package/lib/manifest/scorm-proxy-manifest.js +37 -32
  70. package/lib/manifest/xml-utils.js +10 -0
  71. package/lib/narration.js +2 -2
  72. package/lib/portable-html.js +196 -0
  73. package/lib/preview-export.js +1 -1
  74. package/lib/preview-server.js +27 -6
  75. package/lib/project-utils.js +9 -1
  76. package/lib/proxy-templates/proxy.html +4 -1
  77. package/lib/proxy-templates/scorm-bridge.js +82 -10
  78. package/lib/scaffold.js +9 -4
  79. package/lib/stub-player/lms-api.js +63 -8
  80. package/package.json +5 -1
  81. package/schemas/adlcp_rootv1p2.xsd +110 -0
  82. package/schemas/coursecode_scorm12_package.xsd +5 -0
  83. package/schemas/coursecode_scorm2004_package.xsd +8 -0
  84. package/schemas/imscp_rootv1p1p2.xsd +304 -0
  85. package/template/vite.config.js +50 -22
@@ -50,6 +50,7 @@ class StateManager {
50
50
  constructor() {
51
51
  this.isInitialized = false;
52
52
  this.isTerminated = false;
53
+ this._terminationPromise = null;
53
54
 
54
55
  // Compose internal modules
55
56
  this._txLog = new TransactionLog();
@@ -148,6 +149,12 @@ class StateManager {
148
149
  }
149
150
  this._assertInitialized();
150
151
 
152
+ // Validate semantic domains before mutating suspend-data state. This
153
+ // prevents a failed LMS write from first persisting invalid CMI data.
154
+ if (domain === 'objectives' && value) {
155
+ this._assertValidObjectives(value);
156
+ }
157
+
151
158
  const result = this._domains.setDomainState(domain, value, meta);
152
159
 
153
160
  // Report to driver for format-specific handling
@@ -171,7 +178,28 @@ class StateManager {
171
178
  this._assertInitialized();
172
179
  this._assertNotTerminated('Cannot clear data after termination.');
173
180
 
174
- logger.debug('[StateManager] Clearing all suspend data for course restart');
181
+ logger.debug('[StateManager] Resetting learner progress for course restart');
182
+
183
+ // Native CMI arrays are append-only and cannot be deleted, but their
184
+ // objective status can be returned to a neutral state. Historical
185
+ // interaction rows remain as an LMS audit trail and no longer affect
186
+ // the freshly cleared CourseCode state.
187
+ const objectives = this._domains.getDomainState('objectives') || {};
188
+ for (const id of Object.keys(objectives)) {
189
+ lmsConnection.reportObjective({
190
+ id,
191
+ completion_status: 'incomplete',
192
+ success_status: 'unknown',
193
+ score: 0,
194
+ progress_measure: 0
195
+ });
196
+ }
197
+
198
+ lmsConnection.setBookmark('');
199
+ lmsConnection.reportCompletion('incomplete');
200
+ lmsConnection.reportSuccess('unknown');
201
+ lmsConnection.reportScore({ raw: 0, scaled: 0, min: 0, max: 100 });
202
+ lmsConnection.reportProgress(0);
175
203
  this._domains.clearState();
176
204
  await this._commits.commitToLMS();
177
205
  eventBus.emit('state:cleared', { reason: 'course restart' });
@@ -212,24 +240,28 @@ class StateManager {
212
240
  throw new Error('StateManager: bookmark exceeds maximum length (1024)');
213
241
  }
214
242
  lmsConnection.setBookmark(slideId);
243
+ this._commits.scheduleCommit(false);
215
244
  }
216
245
 
217
246
  /** @param {{raw: number, scaled: number, min: number, max: number}} score */
218
247
  reportScore(score) {
219
248
  this._assertValidScore(score);
220
249
  lmsConnection.reportScore(score);
250
+ this._commits.scheduleCommit(false);
221
251
  }
222
252
 
223
253
  /** @param {string} status - 'completed' | 'incomplete' */
224
254
  reportCompletion(status) {
225
255
  this._assertValidCompletionStatus(status);
226
256
  lmsConnection.reportCompletion(status);
257
+ this._commits.scheduleCommit(false);
227
258
  }
228
259
 
229
260
  /** @param {string} status - 'passed' | 'failed' | 'unknown' */
230
261
  reportSuccess(status) {
231
262
  this._assertValidSuccessStatus(status);
232
263
  lmsConnection.reportSuccess(status);
264
+ this._commits.scheduleCommit(false);
233
265
  }
234
266
 
235
267
  /** @returns {string} Current completion status */
@@ -302,22 +334,52 @@ class StateManager {
302
334
  return await this.terminate();
303
335
  }
304
336
 
337
+ /**
338
+ * Synchronously stages the newest in-memory state for an unload handler.
339
+ * No Promise is used because browsers do not wait for asynchronous work
340
+ * during pagehide.
341
+ */
342
+ prepareEmergencySave() {
343
+ if (!this.isInitialized || this.isTerminated) return;
344
+ this._reportSessionTime();
345
+ lmsConnection.setExitMode('suspend');
346
+ lmsConnection.setSuspendData(this._domains.state);
347
+ }
348
+
305
349
  /**
306
350
  * Terminates the LMS connection with a final commit.
307
351
  */
308
352
  async terminate() {
309
353
  this._assertInitialized();
354
+
355
+ // Coalesce concurrent exit requests so the final commit and LMS
356
+ // termination happen exactly once.
357
+ if (this._terminationPromise) {
358
+ return await this._terminationPromise;
359
+ }
360
+
310
361
  this._assertNotTerminated('Cannot terminate again.');
311
362
 
312
363
  logger.debug('[StateManager] Terminating...');
313
364
 
314
- // Emit BEFORE termination so services can send final xAPI statements
315
- await eventBus.emitAsync('session:beforeTerminate');
365
+ this._terminationPromise = (async () => {
366
+ // Emit BEFORE termination so services can send final xAPI statements
367
+ await eventBus.emitAsync('session:beforeTerminate');
316
368
 
317
- await this._commits.commitToLMS();
318
- this.isTerminated = true;
369
+ await this._commits.commitToLMS();
370
+ const result = await lmsConnection.terminate();
319
371
 
320
- return await lmsConnection.terminate();
372
+ // Do not strand the manager in a terminated state when the LMS
373
+ // operation rejects; callers must be able to retry.
374
+ this.isTerminated = true;
375
+ return result;
376
+ })();
377
+
378
+ try {
379
+ return await this._terminationPromise;
380
+ } finally {
381
+ this._terminationPromise = null;
382
+ }
321
383
  }
322
384
 
323
385
  // =================================================================
@@ -327,8 +389,9 @@ class StateManager {
327
389
  /**
328
390
  * Updates progress_measure based on visited slides.
329
391
  * @param {number} totalSlides - Total sequential slides
392
+ * @param {string[]|null} eligibleSlideIds - Active-sequence slide IDs
330
393
  */
331
- updateProgressMeasure(totalSlides) {
394
+ updateProgressMeasure(totalSlides, eligibleSlideIds = null) {
332
395
  this._assertInitialized();
333
396
  this._assertNotTerminated('Cannot update progress after termination.');
334
397
  if (!totalSlides || totalSlides <= 0) {
@@ -338,13 +401,18 @@ class StateManager {
338
401
  try {
339
402
  const navigationState = this.getDomainState('navigation') || {};
340
403
  const visitedSlides = navigationState.visitedSlides || [];
341
- const visitedCount = visitedSlides.length;
404
+ const eligible = Array.isArray(eligibleSlideIds) ? new Set(eligibleSlideIds) : null;
405
+ const eligibleVisitedSlides = eligible
406
+ ? visitedSlides.filter(slideId => eligible.has(slideId))
407
+ : visitedSlides;
408
+ const visitedCount = eligibleVisitedSlides.length;
342
409
 
343
410
  let progressMeasure = Math.min(visitedCount / totalSlides, 1.0);
344
411
  progressMeasure = Math.max(0, Math.min(1, progressMeasure));
345
412
  progressMeasure = Math.round(progressMeasure * 100) / 100;
346
413
 
347
414
  lmsConnection.reportProgress(progressMeasure);
415
+ this._commits.scheduleCommit(false);
348
416
 
349
417
  logger.debug(`[StateManager] Progress measure updated: ${progressMeasure} (${(progressMeasure * 100).toFixed(0)}%)`);
350
418
  logger.debug(` - Slides visited: ${visitedCount}/${totalSlides}`);
@@ -420,11 +488,19 @@ class StateManager {
420
488
  _reportObjectivesToDriver(objectives) {
421
489
  if (!objectives || typeof objectives !== 'object') return;
422
490
  for (const [id, objective] of Object.entries(objectives)) {
423
- this._assertValidObjective({ id, ...objective });
424
491
  lmsConnection.reportObjective({ id, ...objective });
425
492
  }
426
493
  }
427
494
 
495
+ _assertValidObjectives(objectives) {
496
+ if (!objectives || typeof objectives !== 'object' || Array.isArray(objectives)) {
497
+ throw new Error('StateManager: objectives must be an object keyed by objective ID');
498
+ }
499
+ for (const [id, objective] of Object.entries(objectives)) {
500
+ this._assertValidObjective({ id, ...objective });
501
+ }
502
+ }
503
+
428
504
  _reportSessionTime() {
429
505
  try {
430
506
  const sessionStartTime = lmsConnection.sessionStart;
@@ -492,6 +568,16 @@ class StateManager {
492
568
  throw new Error(`StateManager: objective.progress_measure must be between 0 and 1, got ${objective.progress_measure}`);
493
569
  }
494
570
  }
571
+
572
+
573
+ if (objective.completion_status !== undefined &&
574
+ !VALID_COMPLETION_STATUSES.has(objective.completion_status)) {
575
+ throw new Error(`StateManager: invalid objective completion status "${objective.completion_status}"`);
576
+ }
577
+ if (objective.success_status !== undefined &&
578
+ !VALID_SUCCESS_STATUSES.has(objective.success_status)) {
579
+ throw new Error(`StateManager: invalid objective success status "${objective.success_status}"`);
580
+ }
495
581
  }
496
582
  }
497
583
 
@@ -107,6 +107,7 @@ export class StateValidator {
107
107
  return {
108
108
  _meta: {
109
109
  schemaVersion: STATE_SCHEMA_VERSION,
110
+ courseVersion: this._validationConfig?.courseVersion || null,
110
111
  createdAt: new Date().toISOString()
111
112
  }
112
113
  };
@@ -162,8 +163,9 @@ export class StateValidator {
162
163
  return loadedState;
163
164
  }
164
165
 
165
- const { slideIds, objectiveIds: _objectiveIds } = this._validationConfig;
166
+ const { slideIds, objectiveIds: _objectiveIds, courseVersion } = this._validationConfig;
166
167
  const storedSchemaVersion = loadedState._meta?.schemaVersion || 0;
168
+ const storedCourseVersion = loadedState._meta?.courseVersion || null;
167
169
 
168
170
  logger.debug(`[StateManager] Validating state: stored schema v${storedSchemaVersion}, current v${STATE_SCHEMA_VERSION}`);
169
171
 
@@ -182,34 +184,65 @@ export class StateValidator {
182
184
  loadedState = this._runMigrations(loadedState, storedSchemaVersion, STATE_SCHEMA_VERSION);
183
185
  }
184
186
 
185
- const validatedState = { ...loadedState };
187
+ let validatedState = { ...loadedState };
188
+
189
+ if (storedCourseVersion && courseVersion && storedCourseVersion !== courseVersion) {
190
+ validatedState = this._invalidateVersionSensitiveState(validatedState);
191
+ logger.warn(
192
+ `[StateManager] Course version changed from ${storedCourseVersion} to ${courseVersion}; ` +
193
+ 'cleared in-progress assessment responses and authored interaction responses while preserving earned summaries.'
194
+ );
195
+ eventBus.emit('state:recovered', {
196
+ domain: '_meta',
197
+ message: `Course version changed from ${storedCourseVersion} to ${courseVersion}`,
198
+ context: { storedCourseVersion, courseVersion },
199
+ action: 'invalidated_version_sensitive_state'
200
+ });
201
+ }
186
202
 
187
203
  validatedState._meta = {
188
- ...loadedState._meta,
204
+ ...validatedState._meta,
189
205
  schemaVersion: STATE_SCHEMA_VERSION,
206
+ courseVersion,
190
207
  lastValidatedAt: new Date().toISOString()
191
208
  };
192
209
 
193
- if (loadedState.navigation) {
194
- validatedState.navigation = this._validateNavigationState(loadedState.navigation, slideIds);
210
+ if (validatedState.navigation) {
211
+ validatedState.navigation = this._validateNavigationState(validatedState.navigation, slideIds);
195
212
  }
196
- if (loadedState.engagement) {
197
- validatedState.engagement = this._validateEngagementState(loadedState.engagement, slideIds);
213
+ if (validatedState.engagement) {
214
+ validatedState.engagement = this._validateEngagementState(validatedState.engagement, slideIds);
198
215
  }
199
- if (loadedState.interactionResponses) {
200
- validatedState.interactionResponses = this._validateInteractionResponsesState(loadedState.interactionResponses);
216
+ if (validatedState.interactionResponses) {
217
+ validatedState.interactionResponses = this._validateInteractionResponsesState(validatedState.interactionResponses);
201
218
  }
202
219
 
203
- for (const key of Object.keys(loadedState)) {
220
+ for (const key of Object.keys(validatedState)) {
204
221
  if (key.startsWith('assessment_')) {
205
222
  const assessmentId = key.replace('assessment_', '');
206
- validatedState[key] = this._validateAssessmentState(loadedState[key], assessmentId);
223
+ validatedState[key] = this._validateAssessmentState(validatedState[key], assessmentId);
207
224
  }
208
225
  }
209
226
 
210
227
  return validatedState;
211
228
  }
212
229
 
230
+ _invalidateVersionSensitiveState(state) {
231
+ const recovered = { ...state };
232
+ delete recovered.interactionResponses;
233
+
234
+ for (const key of Object.keys(recovered)) {
235
+ if (!key.startsWith('assessment_')) continue;
236
+ const assessment = recovered[key];
237
+ if (!assessment || typeof assessment !== 'object' || !assessment.session) continue;
238
+
239
+ recovered[key] = { ...assessment };
240
+ delete recovered[key].session;
241
+ }
242
+
243
+ return recovered;
244
+ }
245
+
213
246
  _runMigrations(state, fromVersion, toVersion) {
214
247
  let migratedState = { ...state };
215
248
 
@@ -204,6 +204,9 @@ async function computeDerived() {
204
204
  if (!slideId) {
205
205
  throw new Error('Slide is missing an id');
206
206
  }
207
+ if (Object.prototype.hasOwnProperty.call(indexById, slideId)) {
208
+ throw new Error(`[CourseHelpers] Duplicate slide ID '${slideId}' found. Each slide must have a unique ID.`);
209
+ }
207
210
 
208
211
  let component;
209
212
  let assessmentId = null;
@@ -416,5 +419,3 @@ function normalizeComponentPath(path) {
416
419
 
417
420
  return normalized;
418
421
  }
419
-
420
-
@@ -0,0 +1,28 @@
1
+ /** Default fraction of media that must be consumed for completion. */
2
+ export const DEFAULT_MEDIA_COMPLETION_THRESHOLD = 0.95;
3
+
4
+ /**
5
+ * Normalizes authored media completion thresholds from JS config or data
6
+ * attributes. Values outside the documented 0-1 range fall back to the
7
+ * framework default instead of creating media that can never complete.
8
+ * @param {number|string|null|undefined} value
9
+ * @returns {number}
10
+ */
11
+ export function normalizeCompletionThreshold(value) {
12
+ if (value === null || value === undefined) {
13
+ return DEFAULT_MEDIA_COMPLETION_THRESHOLD;
14
+ }
15
+
16
+ if (typeof value !== 'number' && typeof value !== 'string') {
17
+ return DEFAULT_MEDIA_COMPLETION_THRESHOLD;
18
+ }
19
+
20
+ if (typeof value === 'string' && value.trim() === '') {
21
+ return DEFAULT_MEDIA_COMPLETION_THRESHOLD;
22
+ }
23
+
24
+ const parsed = typeof value === 'number' ? value : Number(value.trim());
25
+ return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1
26
+ ? parsed
27
+ : DEFAULT_MEDIA_COMPLETION_THRESHOLD;
28
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Runtime helpers for CourseCode portable HTML exports.
3
+ *
4
+ * Normal LMS and hosted builds do not define the global asset map, so every
5
+ * helper is a cheap no-op outside a portable build.
6
+ */
7
+
8
+ const URL_ATTRIBUTES = [
9
+ 'src',
10
+ 'href',
11
+ 'poster',
12
+ 'data-src',
13
+ 'data-audio-src',
14
+ 'data-video-src',
15
+ 'data-video-poster',
16
+ 'data-video-captions',
17
+ 'data-lightbox-src',
18
+ 'data-lightbox-thumbnail',
19
+ 'data-md-src'
20
+ ];
21
+
22
+ function getAssetMap() {
23
+ if (typeof window === 'undefined') return null;
24
+ const map = window.__COURSECODE_PORTABLE_ASSETS__;
25
+ return map && typeof map === 'object' ? map : null;
26
+ }
27
+
28
+ function stripQueryAndHash(value) {
29
+ return value.split('#', 1)[0].split('?', 1)[0];
30
+ }
31
+
32
+ function candidateKeys(value) {
33
+ const clean = stripQueryAndHash(value)
34
+ .replace(/\\/g, '/')
35
+ .replace(/^(?:\.\/)+/, '')
36
+ .replace(/^\//, '');
37
+ const keys = new Set([clean]);
38
+
39
+ if (clean.startsWith('course/')) keys.add(clean.slice('course/'.length));
40
+ if (clean.startsWith('course/assets/')) {
41
+ const relative = clean.slice('course/assets/'.length);
42
+ keys.add(`assets/${relative}`);
43
+ keys.add(relative);
44
+ } else if (clean.startsWith('assets/')) {
45
+ const relative = clean.slice('assets/'.length);
46
+ keys.add(`course/assets/${relative}`);
47
+ keys.add(relative);
48
+ } else if (clean && !clean.startsWith('_')) {
49
+ keys.add(`course/assets/${clean}`);
50
+ keys.add(`assets/${clean}`);
51
+ }
52
+
53
+ return keys;
54
+ }
55
+
56
+ /**
57
+ * Resolve a course-relative URL to its embedded data URL when running from a
58
+ * portable HTML export.
59
+ * @param {string} value
60
+ * @returns {string}
61
+ */
62
+ export function resolvePortableAssetUrl(value) {
63
+ if (typeof value !== 'string' || value.length === 0) return value;
64
+ if (/^(?:data:|blob:|https?:|mailto:|tel:|#|\/\/)/i.test(value)) return value;
65
+
66
+ const map = getAssetMap();
67
+ if (!map) return value;
68
+
69
+ for (const key of candidateKeys(value)) {
70
+ if (typeof map[key] === 'string') return map[key];
71
+ }
72
+ return value;
73
+ }
74
+
75
+ /**
76
+ * Rewrite URL-bearing attributes in freshly rendered slide DOM before
77
+ * declarative components initialize and begin loading media.
78
+ * @param {HTMLElement} root
79
+ */
80
+ export function rewritePortableAssetAttributes(root) {
81
+ if (!root || !getAssetMap()) return;
82
+
83
+ const elements = [root, ...root.querySelectorAll('*')];
84
+ for (const element of elements) {
85
+ for (const attribute of URL_ATTRIBUTES) {
86
+ if (!element.hasAttribute?.(attribute)) continue;
87
+ const current = element.getAttribute(attribute);
88
+ const resolved = resolvePortableAssetUrl(current);
89
+ if (resolved !== current) element.setAttribute(attribute, resolved);
90
+ }
91
+
92
+ const style = element.getAttribute?.('style');
93
+ if (!style || !style.includes('url(')) continue;
94
+ const rewritten = style.replace(/url\((['"]?)([^)'"\s]+)\1\)/g, (match, quote, url) => {
95
+ const resolved = resolvePortableAssetUrl(url);
96
+ return resolved === url ? match : `url(${quote}${resolved}${quote})`;
97
+ });
98
+ if (rewritten !== style) element.setAttribute('style', rewritten);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Replace asset-looking strings inside course configuration objects. This
104
+ * covers branding, narration, and programmatic component configuration that
105
+ * never appears as DOM until after managers initialize.
106
+ * @param {unknown} value
107
+ * @param {WeakSet<object>} [seen]
108
+ * @returns {unknown}
109
+ */
110
+ export function hydratePortableAssetObject(value, seen = new WeakSet()) {
111
+ if (!getAssetMap()) return value;
112
+ if (typeof value === 'string') return resolvePortableAssetUrl(value);
113
+ if (!value || typeof value !== 'object' || seen.has(value)) return value;
114
+
115
+ seen.add(value);
116
+ if (Array.isArray(value)) {
117
+ for (let index = 0; index < value.length; index++) {
118
+ value[index] = hydratePortableAssetObject(value[index], seen);
119
+ }
120
+ return value;
121
+ }
122
+
123
+ for (const [key, child] of Object.entries(value)) {
124
+ value[key] = hydratePortableAssetObject(child, seen);
125
+ }
126
+ return value;
127
+ }
128
+
129
+ export function isPortableHtml() {
130
+ return Boolean(getAssetMap());
131
+ }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { logger } from './logger.js';
8
8
  import { getComponentInit, getComponentStyles, isComponentRegistered, getRegisteredComponentTypes } from '../core/component-catalog.js';
9
- import { init as initNotificationTriggers } from '../components/ui-components/notifications.js';
9
+ import { initNotificationTriggers } from '../components/ui-components/notifications.js';
10
10
 
11
11
 
12
12
  import engagementManager from '../engagement/engagement-manager.js';
@@ -15,6 +15,11 @@ import * as NavigationState from '../navigation/NavigationState.js';
15
15
  // Track which custom component styles have been injected
16
16
  const injectedStyles = new Set();
17
17
 
18
+ // Component initializers may return an object with destroy(), or a cleanup
19
+ // function directly. Keep those handles with the rendered view so ViewManager
20
+ // can release document-level listeners and other resources before removing it.
21
+ const componentCleanups = new WeakMap();
22
+
18
23
  /**
19
24
  * Inject custom component styles into the document head
20
25
  * @param {string} type - Component type
@@ -39,7 +44,14 @@ export function initializeDeclarativeComponents(container) {
39
44
  return;
40
45
  }
41
46
 
42
- const components = container.querySelectorAll('[data-component]');
47
+ // Re-initializing the same rendered view should not stack event listeners.
48
+ cleanupDeclarativeComponents(container);
49
+
50
+ const components = [
51
+ ...(container.matches?.('[data-component]') ? [container] : []),
52
+ ...container.querySelectorAll('[data-component]')
53
+ ];
54
+ const cleanups = [];
43
55
 
44
56
  components.forEach(element => {
45
57
  const componentName = element.dataset.component;
@@ -59,7 +71,12 @@ export function initializeDeclarativeComponents(container) {
59
71
  const initializer = getComponentInit(componentName);
60
72
  if (initializer && typeof initializer === 'function') {
61
73
  try {
62
- initializer(element);
74
+ const instance = initializer(element);
75
+ if (typeof instance === 'function') {
76
+ cleanups.push(instance);
77
+ } else if (instance && typeof instance.destroy === 'function') {
78
+ cleanups.push(() => instance.destroy());
79
+ }
63
80
  } catch (error) {
64
81
  logger.error(`[UI-Initializer] Failed to initialize '${componentName}' component: ${error.message}`, { domain: 'ui', operation: 'initializeComponent', stack: error.stack, component: componentName });
65
82
  }
@@ -77,13 +94,48 @@ export function initializeDeclarativeComponents(container) {
77
94
  // This must happen AFTER all modal triggers are initialized
78
95
  registerModalsForEngagement(container);
79
96
 
97
+ // Register hotspots across every interactive image in the rendered view as
98
+ // one batch so revisiting a slide cannot inflate engagement totals.
99
+ registerInteractiveImagesForEngagement(container);
100
+
80
101
  // Initialize declarative notification triggers (event delegation pattern)
81
- initNotificationTriggers(container);
102
+ const notificationInstance = initNotificationTriggers(container);
103
+ if (notificationInstance && typeof notificationInstance.destroy === 'function') {
104
+ cleanups.push(() => notificationInstance.destroy());
105
+ }
82
106
 
83
107
 
84
108
  // Register lightbox triggers with engagement manager (batch registration)
85
109
  // This must happen AFTER all lightbox triggers are initialized by the catalog
86
110
  registerLightboxesForEngagement(container);
111
+
112
+ if (cleanups.length > 0) {
113
+ componentCleanups.set(container, cleanups);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Destroys declarative component instances associated with a rendered view.
119
+ * Cleanup is best-effort so one faulty component cannot prevent the rest from
120
+ * releasing their resources.
121
+ * @param {HTMLElement} container - The rendered view that was initialized.
122
+ */
123
+ export function cleanupDeclarativeComponents(container) {
124
+ const cleanups = componentCleanups.get(container);
125
+ if (!cleanups) return;
126
+
127
+ componentCleanups.delete(container);
128
+ for (const cleanup of [...cleanups].reverse()) {
129
+ try {
130
+ cleanup();
131
+ } catch (error) {
132
+ logger.error(`[UI-Initializer] Component cleanup failed: ${error.message}`, {
133
+ domain: 'ui',
134
+ operation: 'cleanupComponent',
135
+ stack: error.stack
136
+ });
137
+ }
138
+ }
87
139
  }
88
140
 
89
141
  /**
@@ -124,6 +176,28 @@ function registerModalsForEngagement(container) {
124
176
  }
125
177
  }
126
178
 
179
+ /**
180
+ * Registers every interactive-image hotspot in the rendered view as one batch.
181
+ * @param {HTMLElement} container - The container to scan for hotspots
182
+ */
183
+ function registerInteractiveImagesForEngagement(container) {
184
+ const hotspots = container.querySelectorAll(
185
+ '[data-component="interactive-image"] [data-hotspot-id]'
186
+ );
187
+ if (!hotspots.length) return;
188
+
189
+ const currentSlideId = NavigationState.getCurrentSlideId();
190
+ if (!currentSlideId) return;
191
+
192
+ const hotspotIds = Array.from(hotspots)
193
+ .map(hotspot => hotspot.dataset.hotspotId)
194
+ .filter(Boolean);
195
+ if (hotspotIds.length > 0) {
196
+ engagementManager.registerInteractiveImages(currentSlideId, hotspotIds);
197
+ logger.debug(`[UI-Initializer] Registered ${hotspotIds.length} interactive-image hotspots for engagement tracking`);
198
+ }
199
+ }
200
+
127
201
  /**
128
202
  * Registers all lightbox triggers in the container with the engagement manager.
129
203
  * This batch registration ensures lightboxesTotal is set correctly after all triggers are found.
@@ -140,7 +214,7 @@ function registerLightboxesForEngagement(container) {
140
214
  .map(trigger => trigger.id || trigger.dataset.lightboxId)
141
215
  .filter(Boolean);
142
216
  if (lightboxIds.length > 0) {
143
- engagementManager.registerLightbox(currentSlideId, lightboxIds);
217
+ engagementManager.registerLightboxes(currentSlideId, lightboxIds);
144
218
  logger.debug(`[UI-Initializer] Registered ${lightboxIds.length} lightboxes for engagement tracking`);
145
219
  }
146
220
  }