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.
- package/README.md +8 -1
- package/bin/cli.js +16 -0
- package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
- package/framework/docs/USER_GUIDE.md +14 -0
- package/framework/js/app/AppActions.js +17 -23
- package/framework/js/app/AppUI.js +7 -4
- package/framework/js/assessment/AssessmentActions.js +16 -15
- package/framework/js/assessment/AssessmentFactory.js +44 -15
- package/framework/js/automation/api-engagement.js +6 -3
- package/framework/js/components/ui-components/audio-player.js +30 -8
- package/framework/js/components/ui-components/collapse.js +4 -2
- package/framework/js/components/ui-components/dropdown.js +7 -3
- package/framework/js/components/ui-components/embed-frame.js +5 -1
- package/framework/js/components/ui-components/flip-card.js +12 -6
- package/framework/js/components/ui-components/interactive-image.js +28 -52
- package/framework/js/components/ui-components/lightbox.js +19 -5
- package/framework/js/components/ui-components/modal.js +20 -7
- package/framework/js/components/ui-components/notifications.js +8 -3
- package/framework/js/components/ui-components/video-player.js +46 -20
- package/framework/js/core/event-bus.js +31 -28
- package/framework/js/drivers/cmi5-driver.js +186 -83
- package/framework/js/drivers/driver-factory.js +7 -1
- package/framework/js/drivers/driver-interface.js +1 -1
- package/framework/js/drivers/http-driver-base.js +6 -0
- package/framework/js/drivers/lti-driver.js +112 -47
- package/framework/js/drivers/proxy-driver.js +347 -49
- package/framework/js/drivers/scorm-12-driver.js +213 -130
- package/framework/js/drivers/scorm-2004-driver.js +32 -19
- package/framework/js/drivers/scorm-driver-base.js +12 -0
- package/framework/js/drivers/standalone-driver.js +139 -0
- package/framework/js/engagement/engagement-trackers.js +38 -13
- package/framework/js/engagement/requirement-strategies.js +5 -1
- package/framework/js/main.js +7 -0
- package/framework/js/managers/assessment-manager.js +16 -1
- package/framework/js/managers/audio-manager.js +50 -16
- package/framework/js/managers/flag-manager.js +5 -1
- package/framework/js/managers/objective-manager.js +15 -7
- package/framework/js/managers/score-manager.js +27 -6
- package/framework/js/managers/video-manager.js +149 -70
- package/framework/js/navigation/NavigationActions.js +18 -4
- package/framework/js/navigation/document-gallery.js +7 -2
- package/framework/js/navigation/navigation-validators.js +3 -2
- package/framework/js/state/lms-connection.js +41 -16
- package/framework/js/state/state-commits.js +10 -5
- package/framework/js/state/state-manager.js +95 -9
- package/framework/js/state/state-validation.js +44 -11
- package/framework/js/utilities/course-helpers.js +3 -2
- package/framework/js/utilities/media-utils.js +28 -0
- package/framework/js/utilities/portable-assets.js +131 -0
- package/framework/js/utilities/ui-initializer.js +79 -5
- package/framework/js/utilities/utilities.js +32 -20
- package/framework/js/utilities/view-manager.js +16 -1
- package/framework/js/validation/scorm-validators.js +69 -0
- package/framework/version.json +2 -2
- package/lib/authoring-api.js +37 -18
- package/lib/build-packaging.js +47 -3
- package/lib/build.js +1 -1
- package/lib/cloud.js +40 -11
- package/lib/convert.js +30 -2
- package/lib/create.js +5 -3
- package/lib/dev.js +1 -1
- package/lib/headless-browser.js +66 -14
- package/lib/manifest/cmi5-manifest.js +26 -4
- package/lib/manifest/lti-tool-config.js +7 -2
- package/lib/manifest/manifest-factory.d.ts +1 -1
- package/lib/manifest/manifest-factory.js +3 -2
- package/lib/manifest/scorm-12-manifest.js +2 -2
- package/lib/manifest/scorm-2004-manifest.js +3 -28
- package/lib/manifest/scorm-proxy-manifest.js +37 -32
- package/lib/manifest/xml-utils.js +10 -0
- package/lib/narration.js +2 -2
- package/lib/portable-html.js +196 -0
- package/lib/preview-export.js +1 -1
- package/lib/preview-server.js +27 -6
- package/lib/project-utils.js +9 -1
- package/lib/proxy-templates/proxy.html +4 -1
- package/lib/proxy-templates/scorm-bridge.js +82 -10
- package/lib/scaffold.js +9 -4
- package/lib/stub-player/lms-api.js +63 -8
- package/package.json +5 -1
- package/schemas/adlcp_rootv1p2.xsd +110 -0
- package/schemas/coursecode_scorm12_package.xsd +5 -0
- package/schemas/coursecode_scorm2004_package.xsd +8 -0
- package/schemas/imscp_rootv1p1p2.xsd +304 -0
- 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]
|
|
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
|
-
|
|
315
|
-
|
|
365
|
+
this._terminationPromise = (async () => {
|
|
366
|
+
// Emit BEFORE termination so services can send final xAPI statements
|
|
367
|
+
await eventBus.emitAsync('session:beforeTerminate');
|
|
316
368
|
|
|
317
|
-
|
|
318
|
-
|
|
369
|
+
await this._commits.commitToLMS();
|
|
370
|
+
const result = await lmsConnection.terminate();
|
|
319
371
|
|
|
320
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
...
|
|
204
|
+
...validatedState._meta,
|
|
189
205
|
schemaVersion: STATE_SCHEMA_VERSION,
|
|
206
|
+
courseVersion,
|
|
190
207
|
lastValidatedAt: new Date().toISOString()
|
|
191
208
|
};
|
|
192
209
|
|
|
193
|
-
if (
|
|
194
|
-
validatedState.navigation = this._validateNavigationState(
|
|
210
|
+
if (validatedState.navigation) {
|
|
211
|
+
validatedState.navigation = this._validateNavigationState(validatedState.navigation, slideIds);
|
|
195
212
|
}
|
|
196
|
-
if (
|
|
197
|
-
validatedState.engagement = this._validateEngagementState(
|
|
213
|
+
if (validatedState.engagement) {
|
|
214
|
+
validatedState.engagement = this._validateEngagementState(validatedState.engagement, slideIds);
|
|
198
215
|
}
|
|
199
|
-
if (
|
|
200
|
-
validatedState.interactionResponses = this._validateInteractionResponsesState(
|
|
216
|
+
if (validatedState.interactionResponses) {
|
|
217
|
+
validatedState.interactionResponses = this._validateInteractionResponsesState(validatedState.interactionResponses);
|
|
201
218
|
}
|
|
202
219
|
|
|
203
|
-
for (const key of Object.keys(
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
217
|
+
engagementManager.registerLightboxes(currentSlideId, lightboxIds);
|
|
144
218
|
logger.debug(`[UI-Initializer] Registered ${lightboxIds.length} lightboxes for engagement tracking`);
|
|
145
219
|
}
|
|
146
220
|
}
|