coursecode 0.1.57 → 0.1.59
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/css/components/accordions.css +117 -31
- package/framework/css/layouts/article.css +7 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +4 -2
- 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/interactions/drag-drop.js +4 -4
- package/framework/js/components/interactions/fill-in.js +7 -4
- package/framework/js/components/interactions/hotspot.js +2 -2
- package/framework/js/components/interactions/matching.js +4 -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/dev/runtime-linter.js +6 -2
- 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 +151 -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-linter.js +192 -2
- package/lib/build-packaging.js +47 -3
- package/lib/build.js +69 -8
- 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/course/slides/example-ui-showcase.js +1 -1
- package/template/vite.config.js +50 -22
|
@@ -44,10 +44,15 @@ export function deepClone(obj) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const clonedObj = {};
|
|
47
|
-
for (const key
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
for (const key of Object.keys(obj)) {
|
|
48
|
+
// defineProperty avoids invoking Object.prototype.__proto__ when state
|
|
49
|
+
// originated from JSON containing that literal key.
|
|
50
|
+
Object.defineProperty(clonedObj, key, {
|
|
51
|
+
value: deepClone(obj[key]),
|
|
52
|
+
enumerable: true,
|
|
53
|
+
configurable: true,
|
|
54
|
+
writable: true
|
|
55
|
+
});
|
|
51
56
|
}
|
|
52
57
|
return clonedObj;
|
|
53
58
|
}
|
|
@@ -241,7 +246,7 @@ export function waitFor(condition, timeout = 5000, interval = 100) {
|
|
|
241
246
|
* @returns {Object} The merged target object
|
|
242
247
|
*/
|
|
243
248
|
export function deepMerge(target, ...sources) {
|
|
244
|
-
if (!
|
|
249
|
+
if (!isPlainObject(target)) {
|
|
245
250
|
throw new Error('deepMerge: target must be a plain object');
|
|
246
251
|
}
|
|
247
252
|
|
|
@@ -251,22 +256,23 @@ export function deepMerge(target, ...sources) {
|
|
|
251
256
|
|
|
252
257
|
const source = sources.shift();
|
|
253
258
|
|
|
254
|
-
if (
|
|
255
|
-
for (const key
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
} else {
|
|
267
|
-
// Direct assignment for primitives, arrays, and other types
|
|
268
|
-
target[key] = sourceValue;
|
|
259
|
+
if (isPlainObject(source)) {
|
|
260
|
+
for (const key of Object.keys(source)) {
|
|
261
|
+
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
|
|
262
|
+
throw new Error(`deepMerge: unsafe key "${key}" is not allowed`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const sourceValue = source[key];
|
|
266
|
+
const targetValue = target[key];
|
|
267
|
+
|
|
268
|
+
if (isPlainObject(sourceValue)) {
|
|
269
|
+
if (!isPlainObject(targetValue)) {
|
|
270
|
+
target[key] = {};
|
|
269
271
|
}
|
|
272
|
+
deepMerge(target[key], sourceValue);
|
|
273
|
+
} else {
|
|
274
|
+
// Direct assignment for primitives, arrays, and non-plain objects
|
|
275
|
+
target[key] = sourceValue;
|
|
270
276
|
}
|
|
271
277
|
}
|
|
272
278
|
}
|
|
@@ -274,6 +280,12 @@ export function deepMerge(target, ...sources) {
|
|
|
274
280
|
return deepMerge(target, ...sources);
|
|
275
281
|
}
|
|
276
282
|
|
|
283
|
+
function isPlainObject(value) {
|
|
284
|
+
if (value === null || typeof value !== 'object') return false;
|
|
285
|
+
const prototype = Object.getPrototypeOf(value);
|
|
286
|
+
return prototype === Object.prototype || prototype === null;
|
|
287
|
+
}
|
|
288
|
+
|
|
277
289
|
/**
|
|
278
290
|
* Shuffle an array using Fisher-Yates algorithm (creates new array).
|
|
279
291
|
* @param {Array} array - Array to shuffle
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { eventBus } from '../core/event-bus.js';
|
|
2
2
|
import { logger } from './logger.js';
|
|
3
3
|
import { validateRenderedHTML } from '../validation/html-validators.js';
|
|
4
|
-
import { initializeDeclarativeComponents } from './ui-initializer.js';
|
|
4
|
+
import { cleanupDeclarativeComponents, initializeDeclarativeComponents } from './ui-initializer.js';
|
|
5
5
|
import { courseConfig } from '../../../course/course-config.js';
|
|
6
|
+
import { rewritePortableAssetAttributes } from './portable-assets.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Creates a view manager for a given container element.
|
|
@@ -78,9 +79,20 @@ export function createViewManager(container, scope = 'local') {
|
|
|
78
79
|
// Auto-wrap content with content-width class if configured and not already wrapped
|
|
79
80
|
newElement = autoWrapContentIfNeeded(newElement, name);
|
|
80
81
|
|
|
82
|
+
// Convert copied course assets to embedded data URLs before components
|
|
83
|
+
// initialize and begin loading media in a portable HTML export.
|
|
84
|
+
rewritePortableAssetAttributes(newElement);
|
|
85
|
+
|
|
81
86
|
// Validate rendered HTML for common issues BEFORE adding to DOM
|
|
82
87
|
validateRenderedContent(newElement, name);
|
|
83
88
|
|
|
89
|
+
// Release component resources before removing the old view. Some
|
|
90
|
+
// components subscribe to document-level events or own media managers,
|
|
91
|
+
// so removing their DOM alone is not sufficient cleanup.
|
|
92
|
+
if (oldElement) {
|
|
93
|
+
cleanupDeclarativeComponents(oldElement);
|
|
94
|
+
}
|
|
95
|
+
|
|
84
96
|
// Clear container and add new view
|
|
85
97
|
container.innerHTML = '';
|
|
86
98
|
container.appendChild(newElement);
|
|
@@ -243,6 +255,9 @@ export function createViewManager(container, scope = 'local') {
|
|
|
243
255
|
if (currentElement && typeof currentView.onHide === 'function') {
|
|
244
256
|
currentView.onHide(currentElement);
|
|
245
257
|
}
|
|
258
|
+
if (currentElement) {
|
|
259
|
+
cleanupDeclarativeComponents(currentElement);
|
|
260
|
+
}
|
|
246
261
|
}
|
|
247
262
|
container.innerHTML = '';
|
|
248
263
|
currentViewName = null;
|
|
@@ -229,6 +229,75 @@ export function formatLearnerResponseForScorm(interactionType, response) {
|
|
|
229
229
|
}
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
/** SCORM 1.2 interaction vocabulary (RTE 1.2.7). */
|
|
233
|
+
export const SCORM_12_INTERACTION_TYPES = [
|
|
234
|
+
'true-false',
|
|
235
|
+
'choice',
|
|
236
|
+
'fill-in',
|
|
237
|
+
'matching',
|
|
238
|
+
'performance',
|
|
239
|
+
'likert',
|
|
240
|
+
'sequencing',
|
|
241
|
+
'numeric'
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Converts framework/SCORM 2004 interaction data to the materially different
|
|
246
|
+
* SCORM 1.2 vocabulary and response syntax.
|
|
247
|
+
*
|
|
248
|
+
* The framework keeps one lossless internal representation. Unsupported 2004
|
|
249
|
+
* types are represented as fill-in rows in the optional native 1.2 interaction
|
|
250
|
+
* array; the original record remains intact in suspend_data.
|
|
251
|
+
*/
|
|
252
|
+
export function serializeInteractionForScorm12(interaction) {
|
|
253
|
+
const type = SCORM_12_INTERACTION_TYPES.includes(interaction.type)
|
|
254
|
+
? interaction.type
|
|
255
|
+
: 'fill-in';
|
|
256
|
+
|
|
257
|
+
const convertResponse = value => {
|
|
258
|
+
if (value === null || value === undefined) return '';
|
|
259
|
+
let serialized = String(value);
|
|
260
|
+
|
|
261
|
+
if (interaction.type === 'true-false') {
|
|
262
|
+
const normalized = serialized.toLowerCase().trim();
|
|
263
|
+
if (normalized === 'true' || normalized === 't' || normalized === '1') return 't';
|
|
264
|
+
if (normalized === 'false' || normalized === 'f' || normalized === '0') return 'f';
|
|
265
|
+
return '';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (interaction.type === 'choice' || interaction.type === 'sequencing') {
|
|
269
|
+
serialized = serialized.replaceAll('[,]', ',');
|
|
270
|
+
} else if (interaction.type === 'matching') {
|
|
271
|
+
serialized = serialized.replaceAll('[.]', '.').replaceAll('[,]', ',');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// CMIString values in SCORM 1.2 are ASCII. Escape non-ASCII code units
|
|
275
|
+
// instead of submitting an invalid value that strict LMSs reject.
|
|
276
|
+
return serialized.replace(/[^\x00-\x7F]/g, char =>
|
|
277
|
+
`\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`
|
|
278
|
+
);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const result = interaction.result === 'incorrect'
|
|
282
|
+
? 'wrong'
|
|
283
|
+
: interaction.result;
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
...interaction,
|
|
287
|
+
id: convertResponse(interaction.id),
|
|
288
|
+
type,
|
|
289
|
+
learner_response: convertResponse(interaction.learner_response),
|
|
290
|
+
result,
|
|
291
|
+
correct_responses: interaction.correct_responses?.map(item => {
|
|
292
|
+
if (typeof item === 'object' && item !== null && 'pattern' in item) {
|
|
293
|
+
return { ...item, pattern: convertResponse(item.pattern) };
|
|
294
|
+
}
|
|
295
|
+
return convertResponse(item);
|
|
296
|
+
}),
|
|
297
|
+
objectives: interaction.objectives?.map(convertResponse)
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
232
301
|
/**
|
|
233
302
|
* SCORM 2004 4th Edition valid completion status values.
|
|
234
303
|
* @constant {string[]}
|
package/framework/version.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.59",
|
|
3
3
|
"name": "CourseCode Framework",
|
|
4
4
|
"description": "Multi-format course authoring and learner runtime framework",
|
|
5
|
-
"released": "2026-07-
|
|
5
|
+
"released": "2026-07-13",
|
|
6
6
|
"formats": [
|
|
7
7
|
"SCORM 2004 4th Edition",
|
|
8
8
|
"SCORM 1.2",
|
package/lib/authoring-api.js
CHANGED
|
@@ -965,7 +965,32 @@ export async function getWorkflowStatus(port = 4173) {
|
|
|
965
965
|
// Infer stage
|
|
966
966
|
let stage, stageNumber, nextAction, recommendedTool;
|
|
967
967
|
|
|
968
|
-
|
|
968
|
+
const isPowerPointImport = checklist.source === 'powerpoint-import';
|
|
969
|
+
const needsReferenceConversion = checklist.hasRawRefs && !checklist.hasConvertedRefs && !isPowerPointImport;
|
|
970
|
+
const needsOutline = checklist.hasConvertedRefs && !checklist.hasOutline && !isPowerPointImport;
|
|
971
|
+
const hasNoAuthoringInputs = !checklist.hasRawRefs && !checklist.hasConvertedRefs && !checklist.hasOutline && !isPowerPointImport;
|
|
972
|
+
|
|
973
|
+
if (needsReferenceConversion) {
|
|
974
|
+
stage = 'source-ingestion';
|
|
975
|
+
stageNumber = 1;
|
|
976
|
+
nextAction = 'Convert reference files to markdown: coursecode convert';
|
|
977
|
+
recommendedTool = 'coursecode_workflow_status';
|
|
978
|
+
} else if (needsOutline) {
|
|
979
|
+
stage = 'outline-creation';
|
|
980
|
+
stageNumber = 2;
|
|
981
|
+
nextAction = 'Create course outline from reference materials. Stage instructions have all file paths.';
|
|
982
|
+
recommendedTool = 'coursecode_workflow_status';
|
|
983
|
+
} else if (hasNoAuthoringInputs) {
|
|
984
|
+
stage = 'source-ingestion';
|
|
985
|
+
stageNumber = 1;
|
|
986
|
+
nextAction = 'Add reference files (PDF, DOCX, PPTX, MD) to course/references/ and run coursecode convert';
|
|
987
|
+
recommendedTool = 'coursecode_workflow_status';
|
|
988
|
+
} else if (checklist.hasOutline && (!checklist.hasSlides || !checklist.hasCourseConfig)) {
|
|
989
|
+
stage = 'course-building';
|
|
990
|
+
stageNumber = 3;
|
|
991
|
+
nextAction = 'Build slide files and course-config.js based on the outline. Stage instructions have all file paths.';
|
|
992
|
+
recommendedTool = 'coursecode_workflow_status';
|
|
993
|
+
} else if (checklist.hasSlides && checklist.hasCourseConfig) {
|
|
969
994
|
// Course is built — run lint to decide polish vs export
|
|
970
995
|
let lintPassed = false;
|
|
971
996
|
try {
|
|
@@ -993,21 +1018,6 @@ export async function getWorkflowStatus(port = 4173) {
|
|
|
993
1018
|
}
|
|
994
1019
|
recommendedTool = 'coursecode_lint';
|
|
995
1020
|
}
|
|
996
|
-
} else if (!checklist.hasRawRefs && !checklist.hasConvertedRefs) {
|
|
997
|
-
stage = 'source-ingestion';
|
|
998
|
-
stageNumber = 1;
|
|
999
|
-
nextAction = 'Add reference files (PDF, DOCX, PPTX, MD) to course/references/ and run coursecode convert';
|
|
1000
|
-
recommendedTool = 'coursecode_workflow_status';
|
|
1001
|
-
} else if (checklist.hasRawRefs && !checklist.hasConvertedRefs) {
|
|
1002
|
-
stage = 'source-ingestion';
|
|
1003
|
-
stageNumber = 1;
|
|
1004
|
-
nextAction = 'Convert reference files to markdown: coursecode convert';
|
|
1005
|
-
recommendedTool = 'coursecode_workflow_status';
|
|
1006
|
-
} else if (!checklist.hasOutline) {
|
|
1007
|
-
stage = 'outline-creation';
|
|
1008
|
-
stageNumber = 2;
|
|
1009
|
-
nextAction = 'Create course outline from reference materials. Stage instructions have all file paths.';
|
|
1010
|
-
recommendedTool = 'coursecode_workflow_status';
|
|
1011
1021
|
} else {
|
|
1012
1022
|
stage = 'course-building';
|
|
1013
1023
|
stageNumber = 3;
|
|
@@ -1050,7 +1060,7 @@ export async function buildCourse(options = {}) {
|
|
|
1050
1060
|
const child = spawn('npx', ['vite', 'build'], {
|
|
1051
1061
|
cwd: courseRoot,
|
|
1052
1062
|
env,
|
|
1053
|
-
shell:
|
|
1063
|
+
shell: process.platform === 'win32',
|
|
1054
1064
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
1055
1065
|
});
|
|
1056
1066
|
|
|
@@ -1059,6 +1069,16 @@ export async function buildCourse(options = {}) {
|
|
|
1059
1069
|
|
|
1060
1070
|
child.stdout.on('data', (data) => { _stdout += data.toString(); });
|
|
1061
1071
|
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
1072
|
+
child.on('error', (error) => {
|
|
1073
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
|
|
1074
|
+
resolve({
|
|
1075
|
+
success: false,
|
|
1076
|
+
error: error.message,
|
|
1077
|
+
errors: [error.message],
|
|
1078
|
+
warnings: [],
|
|
1079
|
+
duration
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1062
1082
|
|
|
1063
1083
|
child.on('close', (code) => {
|
|
1064
1084
|
const duration = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
|
|
@@ -1235,4 +1255,3 @@ export async function generateNarration(options = {}) {
|
|
|
1235
1255
|
});
|
|
1236
1256
|
});
|
|
1237
1257
|
}
|
|
1238
|
-
|
package/lib/build-linter.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import fs from 'fs';
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import { parseSlideSource, extractAssessment } from './course-parser.js';
|
|
15
|
-
import { getEngagementTrackingMap, getRegisteredComponentTypes } from './schema-extractor.js';
|
|
15
|
+
import { getAllIcons, getEngagementTrackingMap, getRegisteredComponentTypes } from './schema-extractor.js';
|
|
16
16
|
import { getValidCssClasses, lintCssSelectors } from './css-index.js';
|
|
17
17
|
import {
|
|
18
18
|
parseSlideNarration,
|
|
@@ -51,7 +51,7 @@ const DYNAMIC_CLASSES = new Set([
|
|
|
51
51
|
// Component-internal classes — styled via [data-component] selectors in individual component CSS files
|
|
52
52
|
'intro-card', 'card-icon',
|
|
53
53
|
// Interaction-internal classes — used by interaction JS for DOM structure
|
|
54
|
-
'drag-drop', 'matching-items', 'matching-targets',
|
|
54
|
+
'drag-drop', 'matching-items', 'matching-targets', 'image-container', 'hotspot-feedback',
|
|
55
55
|
// Slide-specific JS selectors — queried by slide scripts for event binding
|
|
56
56
|
'resources', 'complete-remedial-btn',
|
|
57
57
|
]);
|
|
@@ -93,6 +93,7 @@ export async function lintCourse(courseConfig, coursePath) {
|
|
|
93
93
|
slideFilesOnDisk
|
|
94
94
|
);
|
|
95
95
|
warnings.push(...globalWarnings);
|
|
96
|
+
validateMenuIcons(courseConfig, coursePath, warnings);
|
|
96
97
|
|
|
97
98
|
// Build valid CSS class index once for all slides
|
|
98
99
|
const validCssIndex = getValidCssClasses();
|
|
@@ -219,8 +220,12 @@ async function validateSlide(slide, coursePath, objectiveIds, errors, warnings,
|
|
|
219
220
|
if (interaction.id) {
|
|
220
221
|
registerInteractionId(interaction.id, slide.id, 'DOM Interaction', interactionIdRegistry, errors);
|
|
221
222
|
}
|
|
223
|
+
validateInteractionSchema(slide.id, interaction, errors);
|
|
224
|
+
validateInteractionAssetReferences(slide.id, interaction, coursePath, errors);
|
|
222
225
|
}
|
|
223
226
|
|
|
227
|
+
validateDirectAssetReferences(slide.id, slideData.elements || [], coursePath, errors);
|
|
228
|
+
|
|
224
229
|
// Static CSS class validation — checks class attributes in source template
|
|
225
230
|
validateCssClassesStatic(slide.id, source, validCssIndex, warnings);
|
|
226
231
|
|
|
@@ -244,6 +249,191 @@ function validateRequirementContent(slideId, requirement, slideData, engagementT
|
|
|
244
249
|
}
|
|
245
250
|
}
|
|
246
251
|
|
|
252
|
+
function schemaTypeMatches(value, expectedType) {
|
|
253
|
+
if (Array.isArray(expectedType)) {
|
|
254
|
+
return expectedType.some(type => schemaTypeMatches(value, type));
|
|
255
|
+
}
|
|
256
|
+
if (expectedType === 'array') return Array.isArray(value);
|
|
257
|
+
if (expectedType === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
258
|
+
if (expectedType === 'number') return typeof value === 'number' && Number.isFinite(value);
|
|
259
|
+
return typeof value === expectedType;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function validateSchemaValue(reference, value, definition, errors, enforceRequiredChildren = true) {
|
|
263
|
+
if (!definition || value === undefined) return;
|
|
264
|
+
|
|
265
|
+
if (definition.type && !schemaTypeMatches(value, definition.type)) {
|
|
266
|
+
const actualType = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
|
|
267
|
+
const expectedType = Array.isArray(definition.type) ? definition.type.join(' or ') : definition.type;
|
|
268
|
+
errors.push(`${reference} must be ${expectedType}, got ${actualType}.`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (definition.enum && !definition.enum.includes(value)) {
|
|
273
|
+
errors.push(`${reference} must be one of: ${definition.enum.join(', ')}.`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (Array.isArray(value)) {
|
|
277
|
+
if (definition.minItems != null && value.length < definition.minItems) {
|
|
278
|
+
errors.push(`${reference} must contain at least ${definition.minItems} item(s).`);
|
|
279
|
+
}
|
|
280
|
+
if (definition.itemSchema) {
|
|
281
|
+
value.forEach((item, index) => {
|
|
282
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return;
|
|
283
|
+
for (const [key, childDefinition] of Object.entries(definition.itemSchema)) {
|
|
284
|
+
if (childDefinition.required && !Object.hasOwn(item, key)) {
|
|
285
|
+
errors.push(`${reference}[${index}] is missing required property "${key}".`);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
validateSchemaValue(`${reference}[${index}].${key}`, item[key], childDefinition, errors);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (definition.type === 'object' && definition.valueSchema) {
|
|
295
|
+
for (const [key, childDefinition] of Object.entries(definition.valueSchema)) {
|
|
296
|
+
if (enforceRequiredChildren && childDefinition.required && !Object.hasOwn(value, key)) {
|
|
297
|
+
errors.push(`${reference} is missing required property "${key}".`);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
validateSchemaValue(`${reference}.${key}`, value[key], childDefinition, errors);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (definition.type === 'object' && definition.mapValueSchema) {
|
|
305
|
+
for (const [key, childValue] of Object.entries(value)) {
|
|
306
|
+
validateSchemaValue(`${reference}.${key}`, childValue, definition.mapValueSchema, errors);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Validate statically parsed interaction values against the registered schema.
|
|
313
|
+
* Top-level missing values are skipped because they may be supplied by a runtime
|
|
314
|
+
* variable that the lightweight source parser cannot resolve. Known literal
|
|
315
|
+
* values, nested objects, and array items are validated fully.
|
|
316
|
+
*/
|
|
317
|
+
export function validateInteractionSchema(slideId, interaction, errors) {
|
|
318
|
+
const properties = interaction?.schema?.properties;
|
|
319
|
+
if (!properties) return;
|
|
320
|
+
|
|
321
|
+
for (const [key, definition] of Object.entries(properties)) {
|
|
322
|
+
if (!Object.hasOwn(interaction, key)) continue;
|
|
323
|
+
validateSchemaValue(`Slide "${slideId}" interaction "${interaction.id}" property "${key}"`, interaction[key], definition, errors);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const DIRECT_ASSET_ATTRIBUTES = new Set([
|
|
328
|
+
'src', 'href', 'poster', 'data-src', 'data-audio-src', 'data-video-src',
|
|
329
|
+
'data-video-poster', 'data-video-captions', 'data-lightbox-src',
|
|
330
|
+
'data-lightbox-thumbnail', 'data-md-src'
|
|
331
|
+
]);
|
|
332
|
+
const NATIVE_ASSET_ATTRIBUTES = new Set(['src', 'href', 'poster']);
|
|
333
|
+
|
|
334
|
+
function isExternalAssetReference(reference) {
|
|
335
|
+
return /^(?:data:|blob:|https?:|mailto:|tel:|#|\/\/|@)/i.test(reference);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function assetPathForReference(reference, coursePath, directHtml) {
|
|
339
|
+
if (typeof reference !== 'string' || !reference || reference.includes('${')) return null;
|
|
340
|
+
if (isExternalAssetReference(reference)) return null;
|
|
341
|
+
|
|
342
|
+
const clean = reference.split('#', 1)[0].split('?', 1)[0].replace(/\\/g, '/');
|
|
343
|
+
const withoutDot = clean.replace(/^\.\//, '');
|
|
344
|
+
|
|
345
|
+
if (directHtml && withoutDot.startsWith('assets/')) {
|
|
346
|
+
return {
|
|
347
|
+
error: `Direct HTML asset path "${reference}" will not exist in LMS packages. Use "course/${withoutDot}".`
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let relativePath = null;
|
|
352
|
+
if (withoutDot.startsWith('course/assets/')) {
|
|
353
|
+
relativePath = withoutDot.slice('course/assets/'.length);
|
|
354
|
+
} else if (!directHtml && withoutDot.startsWith('assets/')) {
|
|
355
|
+
relativePath = withoutDot.slice('assets/'.length);
|
|
356
|
+
} else if (!directHtml && !withoutDot.startsWith('/') && !withoutDot.startsWith('../')) {
|
|
357
|
+
relativePath = withoutDot;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!relativePath) return null;
|
|
361
|
+
return { filePath: path.join(coursePath, 'assets', relativePath), relativePath };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function validateAssetReference(slideId, reference, coursePath, directHtml, errors) {
|
|
365
|
+
const resolved = assetPathForReference(reference, coursePath, directHtml);
|
|
366
|
+
if (!resolved) return;
|
|
367
|
+
if (resolved.error) {
|
|
368
|
+
errors.push(`Slide "${slideId}": ${resolved.error}`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (!fs.existsSync(resolved.filePath)) {
|
|
372
|
+
errors.push(`Slide "${slideId}": local course asset not found: course/assets/${resolved.relativePath}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function validateDirectAssetReferences(slideId, elements, coursePath, errors) {
|
|
377
|
+
for (const element of elements || []) {
|
|
378
|
+
for (const [attribute, value] of Object.entries(element.attributes || {})) {
|
|
379
|
+
if (!DIRECT_ASSET_ATTRIBUTES.has(attribute)) continue;
|
|
380
|
+
const componentOwnsHref = attribute === 'href' && element.attributes?.['data-component'] === 'lightbox';
|
|
381
|
+
validateAssetReference(
|
|
382
|
+
slideId,
|
|
383
|
+
value,
|
|
384
|
+
coursePath,
|
|
385
|
+
NATIVE_ASSET_ATTRIBUTES.has(attribute) && !componentOwnsHref,
|
|
386
|
+
errors
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function validateInteractionAssetReferences(slideId, interaction, coursePath, errors) {
|
|
393
|
+
const visit = (value, key = '') => {
|
|
394
|
+
if (!value || typeof value !== 'object') return;
|
|
395
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
396
|
+
if (childKey === 'schema') continue;
|
|
397
|
+
if (childKey === 'src' && typeof childValue === 'string') {
|
|
398
|
+
validateAssetReference(slideId, childValue, coursePath, false, errors);
|
|
399
|
+
} else if (childValue && typeof childValue === 'object') {
|
|
400
|
+
visit(childValue, childKey || key);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
visit(interaction);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function customIconNames(coursePath) {
|
|
408
|
+
const iconFile = path.join(coursePath, 'icons.js');
|
|
409
|
+
if (!fs.existsSync(iconFile)) return [];
|
|
410
|
+
const source = fs.readFileSync(iconFile, 'utf-8');
|
|
411
|
+
const declaration = source.match(/export\s+const\s+customIcons\s*=\s*\{([\s\S]*?)\n\};/);
|
|
412
|
+
if (!declaration) return [];
|
|
413
|
+
return [...declaration[1].matchAll(/^\s*['"]([^'"]+)['"]\s*:/gm)].map(match => match[1]);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function isEmojiIcon(value) {
|
|
417
|
+
return /^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1FA00}-\u{1FAFF}]/u.test(value);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function validateMenuIcons(courseConfig, coursePath, warnings) {
|
|
421
|
+
const validIcons = new Set([...Object.keys(getAllIcons()), ...customIconNames(coursePath)]);
|
|
422
|
+
const inspectIcon = (icon, owner) => {
|
|
423
|
+
if (typeof icon !== 'string' || !icon || validIcons.has(icon) || isEmojiIcon(icon)) return;
|
|
424
|
+
warnings.push(`Unknown menu icon "${icon}" on ${owner}. Add it to course/icons.js or use a registered icon.`);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const walk = items => {
|
|
428
|
+
for (const item of items || []) {
|
|
429
|
+
inspectIcon(item.menu?.icon, `${item.type || 'item'} "${item.id || item.title || 'unknown'}"`);
|
|
430
|
+
if (item.children) walk(item.children);
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
walk(courseConfig.structure);
|
|
434
|
+
inspectIcon(courseConfig.navigation?.documentGallery?.icon, 'navigation.documentGallery');
|
|
435
|
+
}
|
|
436
|
+
|
|
247
437
|
/**
|
|
248
438
|
* Static CSS class validation — extracts class="..." values from slide source
|
|
249
439
|
* and checks them against the valid CSS class index built from PostCSS.
|
package/lib/build-packaging.js
CHANGED
|
@@ -28,8 +28,30 @@ function sanitizeTitle(title) {
|
|
|
28
28
|
|
|
29
29
|
function withClientCredentials(externalUrl, clientId, token) {
|
|
30
30
|
if (!clientId || !token) return externalUrl;
|
|
31
|
-
const
|
|
32
|
-
|
|
31
|
+
const url = new URL(externalUrl);
|
|
32
|
+
url.searchParams.set('clientId', clientId);
|
|
33
|
+
url.searchParams.set('token', token);
|
|
34
|
+
return url.toString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validateExternalUrl(value) {
|
|
38
|
+
let url;
|
|
39
|
+
try {
|
|
40
|
+
url = new URL(value);
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(`externalUrl must be an absolute URL, received: ${value}`);
|
|
43
|
+
}
|
|
44
|
+
const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1', '::1'].includes(url.hostname);
|
|
45
|
+
if (url.protocol !== 'https:' && !localHttp) {
|
|
46
|
+
throw new Error('externalUrl must use HTTPS (HTTP is allowed only for local development)');
|
|
47
|
+
}
|
|
48
|
+
if (url.hash) {
|
|
49
|
+
throw new Error('externalUrl must not contain a URL fragment because launch credentials would not reach the server');
|
|
50
|
+
}
|
|
51
|
+
if (url.username || url.password) {
|
|
52
|
+
throw new Error('externalUrl must not embed HTTP credentials');
|
|
53
|
+
}
|
|
54
|
+
return url;
|
|
33
55
|
}
|
|
34
56
|
|
|
35
57
|
function zipDirectory(sourceDir, zipFilePath) {
|
|
@@ -55,6 +77,7 @@ export function validateExternalHostingConfig(config) {
|
|
|
55
77
|
if (!config.externalUrl) {
|
|
56
78
|
throw new Error(`${config.lmsFormat} format requires 'externalUrl' in course-config.js`);
|
|
57
79
|
}
|
|
80
|
+
validateExternalUrl(config.externalUrl);
|
|
58
81
|
|
|
59
82
|
if (config.accessControl?.enforcement !== 'server') {
|
|
60
83
|
throw new Error(`${config.lmsFormat} requires accessControl.enforcement = 'server'. Browser-only token checks are not secure.`);
|
|
@@ -164,6 +187,10 @@ export async function createProxyPackage({ rootDir, config, clientId = null, tok
|
|
|
164
187
|
|
|
165
188
|
let proxyHtml = fs.readFileSync(path.join(templatesDir, 'proxy.html'), 'utf-8');
|
|
166
189
|
proxyHtml = proxyHtml.replace('{{EXTERNAL_URL_JSON}}', JSON.stringify(externalUrl));
|
|
190
|
+
proxyHtml = proxyHtml.replace(
|
|
191
|
+
'{{BASE_FORMAT_JSON}}',
|
|
192
|
+
JSON.stringify(config.lmsFormat === 'scorm1.2-proxy' ? 'scorm1.2' : 'scorm2004')
|
|
193
|
+
);
|
|
167
194
|
fs.writeFileSync(path.join(proxyDir, 'proxy.html'), proxyHtml);
|
|
168
195
|
|
|
169
196
|
fs.copyFileSync(path.join(templatesDir, 'scorm-bridge.js'), path.join(proxyDir, 'scorm-bridge.js'));
|
|
@@ -172,6 +199,22 @@ export async function createProxyPackage({ rootDir, config, clientId = null, tok
|
|
|
172
199
|
const { filename, content } = generateManifest(config.lmsFormat, config, [], { externalUrl: config.externalUrl });
|
|
173
200
|
fs.writeFileSync(path.join(proxyDir, filename), content);
|
|
174
201
|
|
|
202
|
+
const schemasDir = path.join(PACKAGE_ROOT, 'schemas');
|
|
203
|
+
for (const entry of fs.readdirSync(schemasDir, { withFileTypes: true })) {
|
|
204
|
+
if (entry.isFile() && /\.(?:xsd|dtd|xml)$/i.test(entry.name)) {
|
|
205
|
+
fs.copyFileSync(path.join(schemasDir, entry.name), path.join(proxyDir, entry.name));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
fs.cpSync(path.join(schemasDir, 'common'), path.join(proxyDir, 'common'), { recursive: true });
|
|
209
|
+
|
|
210
|
+
const required = config.lmsFormat === 'scorm1.2-proxy'
|
|
211
|
+
? ['imsmanifest.xml', 'proxy.html', 'scorm-bridge.js', 'pipwerks.js', 'imscp_rootv1p1p2.xsd', 'adlcp_rootv1p2.xsd', 'ims_xml.xsd']
|
|
212
|
+
: ['imsmanifest.xml', 'proxy.html', 'scorm-bridge.js', 'pipwerks.js', 'imscp_v1p1.xsd', 'adlcp_v1p3.xsd', 'imsss_v1p0.xsd'];
|
|
213
|
+
const missing = required.filter(file => !fs.existsSync(path.join(proxyDir, file)));
|
|
214
|
+
if (missing.length > 0 || proxyHtml.includes('{{')) {
|
|
215
|
+
throw new Error(`Proxy package validation failed: ${missing.length ? `missing ${missing.join(', ')}` : 'unresolved template placeholder'}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
175
218
|
if (fs.existsSync(zipFilePath)) fs.unlinkSync(zipFilePath);
|
|
176
219
|
const bytes = await zipDirectory(proxyDir, zipFilePath);
|
|
177
220
|
const sizeKB = (bytes / 1024).toFixed(1);
|
|
@@ -206,7 +249,8 @@ export async function createRemotePackage({ rootDir, config, clientId = null, to
|
|
|
206
249
|
const bytes = await zipDirectory(remoteDir, zipFilePath);
|
|
207
250
|
const sizeKB = (bytes / 1024).toFixed(1);
|
|
208
251
|
console.warn(`📦 Created ${zipFileName} (${sizeKB} KB) - Upload to LMS`);
|
|
209
|
-
|
|
252
|
+
const auUrl = content.match(/<url>([^<]+)<\/url>/)?.[1] || externalUrl;
|
|
253
|
+
console.warn(` AU URL points to: ${auUrl}`);
|
|
210
254
|
return zipFilePath;
|
|
211
255
|
} finally {
|
|
212
256
|
if (fs.existsSync(remoteDir)) fs.rmSync(remoteDir, { recursive: true });
|