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.
Files changed (95) hide show
  1. package/README.md +8 -1
  2. package/bin/cli.js +16 -0
  3. package/framework/css/components/accordions.css +117 -31
  4. package/framework/css/layouts/article.css +7 -0
  5. package/framework/docs/COURSE_AUTHORING_GUIDE.md +4 -2
  6. package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
  7. package/framework/docs/USER_GUIDE.md +14 -0
  8. package/framework/js/app/AppActions.js +17 -23
  9. package/framework/js/app/AppUI.js +7 -4
  10. package/framework/js/assessment/AssessmentActions.js +16 -15
  11. package/framework/js/assessment/AssessmentFactory.js +44 -15
  12. package/framework/js/automation/api-engagement.js +6 -3
  13. package/framework/js/components/interactions/drag-drop.js +4 -4
  14. package/framework/js/components/interactions/fill-in.js +7 -4
  15. package/framework/js/components/interactions/hotspot.js +2 -2
  16. package/framework/js/components/interactions/matching.js +4 -3
  17. package/framework/js/components/ui-components/audio-player.js +30 -8
  18. package/framework/js/components/ui-components/collapse.js +4 -2
  19. package/framework/js/components/ui-components/dropdown.js +7 -3
  20. package/framework/js/components/ui-components/embed-frame.js +5 -1
  21. package/framework/js/components/ui-components/flip-card.js +12 -6
  22. package/framework/js/components/ui-components/interactive-image.js +28 -52
  23. package/framework/js/components/ui-components/lightbox.js +19 -5
  24. package/framework/js/components/ui-components/modal.js +20 -7
  25. package/framework/js/components/ui-components/notifications.js +8 -3
  26. package/framework/js/components/ui-components/video-player.js +46 -20
  27. package/framework/js/core/event-bus.js +31 -28
  28. package/framework/js/dev/runtime-linter.js +6 -2
  29. package/framework/js/drivers/cmi5-driver.js +186 -83
  30. package/framework/js/drivers/driver-factory.js +7 -1
  31. package/framework/js/drivers/driver-interface.js +1 -1
  32. package/framework/js/drivers/http-driver-base.js +6 -0
  33. package/framework/js/drivers/lti-driver.js +112 -47
  34. package/framework/js/drivers/proxy-driver.js +347 -49
  35. package/framework/js/drivers/scorm-12-driver.js +213 -130
  36. package/framework/js/drivers/scorm-2004-driver.js +32 -19
  37. package/framework/js/drivers/scorm-driver-base.js +12 -0
  38. package/framework/js/drivers/standalone-driver.js +139 -0
  39. package/framework/js/engagement/engagement-trackers.js +38 -13
  40. package/framework/js/engagement/requirement-strategies.js +5 -1
  41. package/framework/js/main.js +7 -0
  42. package/framework/js/managers/assessment-manager.js +16 -1
  43. package/framework/js/managers/audio-manager.js +50 -16
  44. package/framework/js/managers/flag-manager.js +5 -1
  45. package/framework/js/managers/objective-manager.js +15 -7
  46. package/framework/js/managers/score-manager.js +27 -6
  47. package/framework/js/managers/video-manager.js +149 -70
  48. package/framework/js/navigation/NavigationActions.js +18 -4
  49. package/framework/js/navigation/document-gallery.js +7 -2
  50. package/framework/js/navigation/navigation-validators.js +3 -2
  51. package/framework/js/state/lms-connection.js +41 -16
  52. package/framework/js/state/state-commits.js +10 -5
  53. package/framework/js/state/state-manager.js +95 -9
  54. package/framework/js/state/state-validation.js +44 -11
  55. package/framework/js/utilities/course-helpers.js +3 -2
  56. package/framework/js/utilities/media-utils.js +28 -0
  57. package/framework/js/utilities/portable-assets.js +151 -0
  58. package/framework/js/utilities/ui-initializer.js +79 -5
  59. package/framework/js/utilities/utilities.js +32 -20
  60. package/framework/js/utilities/view-manager.js +16 -1
  61. package/framework/js/validation/scorm-validators.js +69 -0
  62. package/framework/version.json +2 -2
  63. package/lib/authoring-api.js +37 -18
  64. package/lib/build-linter.js +192 -2
  65. package/lib/build-packaging.js +47 -3
  66. package/lib/build.js +69 -8
  67. package/lib/cloud.js +40 -11
  68. package/lib/convert.js +30 -2
  69. package/lib/create.js +5 -3
  70. package/lib/dev.js +1 -1
  71. package/lib/headless-browser.js +66 -14
  72. package/lib/manifest/cmi5-manifest.js +26 -4
  73. package/lib/manifest/lti-tool-config.js +7 -2
  74. package/lib/manifest/manifest-factory.d.ts +1 -1
  75. package/lib/manifest/manifest-factory.js +3 -2
  76. package/lib/manifest/scorm-12-manifest.js +2 -2
  77. package/lib/manifest/scorm-2004-manifest.js +3 -28
  78. package/lib/manifest/scorm-proxy-manifest.js +37 -32
  79. package/lib/manifest/xml-utils.js +10 -0
  80. package/lib/narration.js +2 -2
  81. package/lib/portable-html.js +196 -0
  82. package/lib/preview-export.js +1 -1
  83. package/lib/preview-server.js +27 -6
  84. package/lib/project-utils.js +9 -1
  85. package/lib/proxy-templates/proxy.html +4 -1
  86. package/lib/proxy-templates/scorm-bridge.js +82 -10
  87. package/lib/scaffold.js +9 -4
  88. package/lib/stub-player/lms-api.js +63 -8
  89. package/package.json +5 -1
  90. package/schemas/adlcp_rootv1p2.xsd +110 -0
  91. package/schemas/coursecode_scorm12_package.xsd +5 -0
  92. package/schemas/coursecode_scorm2004_package.xsd +8 -0
  93. package/schemas/imscp_rootv1p1p2.xsd +304 -0
  94. package/template/course/slides/example-ui-showcase.js +1 -1
  95. 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 in obj) {
48
- if (obj.hasOwnProperty(key)) {
49
- clonedObj[key] = deepClone(obj[key]);
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 (!target || typeof target !== 'object' || Array.isArray(target)) {
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 (source && typeof source === 'object' && !Array.isArray(source)) {
255
- for (const key in source) {
256
- if (source.hasOwnProperty(key)) {
257
- const sourceValue = source[key];
258
- const targetValue = target[key];
259
-
260
- if (sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)) {
261
- // Recursive merge for plain objects
262
- if (!targetValue || typeof targetValue !== 'object' || Array.isArray(targetValue)) {
263
- target[key] = {};
264
- }
265
- deepMerge(target[key], sourceValue);
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[]}
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.1.57",
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-11",
5
+ "released": "2026-07-13",
6
6
  "formats": [
7
7
  "SCORM 2004 4th Edition",
8
8
  "SCORM 1.2",
@@ -965,7 +965,32 @@ export async function getWorkflowStatus(port = 4173) {
965
965
  // Infer stage
966
966
  let stage, stageNumber, nextAction, recommendedTool;
967
967
 
968
- if (checklist.hasSlides && checklist.hasCourseConfig) {
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: true,
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
-
@@ -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.
@@ -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 separator = externalUrl.includes('?') ? '&' : '?';
32
- return `${externalUrl}${separator}clientId=${encodeURIComponent(clientId)}&token=${encodeURIComponent(token)}`;
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
- console.warn(` AU URL points to: ${externalUrl.replace(/\/$/, '')}/index.html`);
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 });