coursecode 0.1.57 → 0.1.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +8 -1
  2. package/bin/cli.js +16 -0
  3. package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
  4. package/framework/docs/USER_GUIDE.md +14 -0
  5. package/framework/js/app/AppActions.js +17 -23
  6. package/framework/js/app/AppUI.js +7 -4
  7. package/framework/js/assessment/AssessmentActions.js +16 -15
  8. package/framework/js/assessment/AssessmentFactory.js +44 -15
  9. package/framework/js/automation/api-engagement.js +6 -3
  10. package/framework/js/components/ui-components/audio-player.js +30 -8
  11. package/framework/js/components/ui-components/collapse.js +4 -2
  12. package/framework/js/components/ui-components/dropdown.js +7 -3
  13. package/framework/js/components/ui-components/embed-frame.js +5 -1
  14. package/framework/js/components/ui-components/flip-card.js +12 -6
  15. package/framework/js/components/ui-components/interactive-image.js +28 -52
  16. package/framework/js/components/ui-components/lightbox.js +19 -5
  17. package/framework/js/components/ui-components/modal.js +20 -7
  18. package/framework/js/components/ui-components/notifications.js +8 -3
  19. package/framework/js/components/ui-components/video-player.js +46 -20
  20. package/framework/js/core/event-bus.js +31 -28
  21. package/framework/js/drivers/cmi5-driver.js +186 -83
  22. package/framework/js/drivers/driver-factory.js +7 -1
  23. package/framework/js/drivers/driver-interface.js +1 -1
  24. package/framework/js/drivers/http-driver-base.js +6 -0
  25. package/framework/js/drivers/lti-driver.js +112 -47
  26. package/framework/js/drivers/proxy-driver.js +347 -49
  27. package/framework/js/drivers/scorm-12-driver.js +213 -130
  28. package/framework/js/drivers/scorm-2004-driver.js +32 -19
  29. package/framework/js/drivers/scorm-driver-base.js +12 -0
  30. package/framework/js/drivers/standalone-driver.js +139 -0
  31. package/framework/js/engagement/engagement-trackers.js +38 -13
  32. package/framework/js/engagement/requirement-strategies.js +5 -1
  33. package/framework/js/main.js +7 -0
  34. package/framework/js/managers/assessment-manager.js +16 -1
  35. package/framework/js/managers/audio-manager.js +50 -16
  36. package/framework/js/managers/flag-manager.js +5 -1
  37. package/framework/js/managers/objective-manager.js +15 -7
  38. package/framework/js/managers/score-manager.js +27 -6
  39. package/framework/js/managers/video-manager.js +149 -70
  40. package/framework/js/navigation/NavigationActions.js +18 -4
  41. package/framework/js/navigation/document-gallery.js +7 -2
  42. package/framework/js/navigation/navigation-validators.js +3 -2
  43. package/framework/js/state/lms-connection.js +41 -16
  44. package/framework/js/state/state-commits.js +10 -5
  45. package/framework/js/state/state-manager.js +95 -9
  46. package/framework/js/state/state-validation.js +44 -11
  47. package/framework/js/utilities/course-helpers.js +3 -2
  48. package/framework/js/utilities/media-utils.js +28 -0
  49. package/framework/js/utilities/portable-assets.js +131 -0
  50. package/framework/js/utilities/ui-initializer.js +79 -5
  51. package/framework/js/utilities/utilities.js +32 -20
  52. package/framework/js/utilities/view-manager.js +16 -1
  53. package/framework/js/validation/scorm-validators.js +69 -0
  54. package/framework/version.json +2 -2
  55. package/lib/authoring-api.js +37 -18
  56. package/lib/build-packaging.js +47 -3
  57. package/lib/build.js +1 -1
  58. package/lib/cloud.js +40 -11
  59. package/lib/convert.js +30 -2
  60. package/lib/create.js +5 -3
  61. package/lib/dev.js +1 -1
  62. package/lib/headless-browser.js +66 -14
  63. package/lib/manifest/cmi5-manifest.js +26 -4
  64. package/lib/manifest/lti-tool-config.js +7 -2
  65. package/lib/manifest/manifest-factory.d.ts +1 -1
  66. package/lib/manifest/manifest-factory.js +3 -2
  67. package/lib/manifest/scorm-12-manifest.js +2 -2
  68. package/lib/manifest/scorm-2004-manifest.js +3 -28
  69. package/lib/manifest/scorm-proxy-manifest.js +37 -32
  70. package/lib/manifest/xml-utils.js +10 -0
  71. package/lib/narration.js +2 -2
  72. package/lib/portable-html.js +196 -0
  73. package/lib/preview-export.js +1 -1
  74. package/lib/preview-server.js +27 -6
  75. package/lib/project-utils.js +9 -1
  76. package/lib/proxy-templates/proxy.html +4 -1
  77. package/lib/proxy-templates/scorm-bridge.js +82 -10
  78. package/lib/scaffold.js +9 -4
  79. package/lib/stub-player/lms-api.js +63 -8
  80. package/package.json +5 -1
  81. package/schemas/adlcp_rootv1p2.xsd +110 -0
  82. package/schemas/coursecode_scorm12_package.xsd +5 -0
  83. package/schemas/coursecode_scorm2004_package.xsd +8 -0
  84. package/schemas/imscp_rootv1p1p2.xsd +304 -0
  85. package/template/vite.config.js +50 -22
@@ -206,6 +206,14 @@ export function hasValidPreviewMutationToken(headers, expectedToken) {
206
206
  return Boolean(expectedToken) && headers?.['x-coursecode-preview-token'] === expectedToken;
207
207
  }
208
208
 
209
+ export function parsePreviewPort(value = 4173) {
210
+ const port = Number(value);
211
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
212
+ throw new Error(`Preview port must be an integer between 1 and 65535, got "${value}"`);
213
+ }
214
+ return port;
215
+ }
216
+
209
217
 
210
218
 
211
219
  // ============================================================================
@@ -216,7 +224,7 @@ export async function previewServer(options = {}) {
216
224
  const frameworkDev = options.frameworkDev || false;
217
225
  const paths = validateProject({ frameworkDev });
218
226
  const title = options.title || getCourseTitle(paths.coursePath);
219
- const previewPort = parseInt(options.port || '4173', 10);
227
+ const previewPort = parsePreviewPort(options.port ?? 4173);
220
228
  const previewHost = options.host || '127.0.0.1';
221
229
  const previewToken = crypto.randomBytes(32).toString('base64url');
222
230
  const distDir = path.join(process.cwd(), 'dist');
@@ -277,6 +285,17 @@ export async function previewServer(options = {}) {
277
285
  });
278
286
 
279
287
  let initialBuildDone = false;
288
+ let viteSpawnError = null;
289
+
290
+ viteProcess.on('error', (error) => {
291
+ viteSpawnError = error;
292
+ buildState.lastBuildSuccess = false;
293
+ buildState.errors.push({
294
+ type: 'build',
295
+ message: `Failed to start Vite: ${error.message}`,
296
+ time: new Date().toISOString()
297
+ });
298
+ });
280
299
 
281
300
  viteProcess.stdout.on('data', (data) => {
282
301
  const output = data.toString();
@@ -320,22 +339,24 @@ export async function previewServer(options = {}) {
320
339
  });
321
340
 
322
341
  // Wait for initial build
323
- await new Promise((resolve) => {
342
+ await new Promise((resolve, reject) => {
324
343
  const indexPath = path.join(distDir, 'index.html');
325
344
  let attempts = 0;
326
345
  const maxAttempts = 120;
327
346
 
328
347
  const checkReady = setInterval(() => {
329
348
  attempts++;
330
- if (fs.existsSync(indexPath)) {
349
+ if (viteSpawnError) {
350
+ clearInterval(checkReady);
351
+ reject(new Error(`Preview build could not start: ${viteSpawnError.message}`));
352
+ } else if (buildState.lastBuildSuccess && fs.existsSync(indexPath)) {
331
353
  clearInterval(checkReady);
332
354
  initialBuildDone = true;
333
355
  resolve();
334
356
  } else if (attempts >= maxAttempts) {
335
357
  clearInterval(checkReady);
336
- console.error('⚠️ Build timeout - index.html not found');
337
- initialBuildDone = true;
338
- resolve();
358
+ viteProcess.kill();
359
+ reject(new Error('Preview build timed out before a fresh index.html was produced'));
339
360
  }
340
361
  }, 500);
341
362
  });
@@ -159,14 +159,22 @@ const mimeTypes = {
159
159
  '.ttf': 'font/ttf',
160
160
  '.eot': 'application/vnd.ms-fontobject',
161
161
  '.mp3': 'audio/mpeg',
162
+ '.m4a': 'audio/mp4',
162
163
  '.mp4': 'video/mp4',
163
164
  '.webm': 'video/webm',
164
165
  '.ogg': 'audio/ogg',
165
166
  '.wav': 'audio/wav',
167
+ '.vtt': 'text/vtt',
168
+ '.md': 'text/markdown',
169
+ '.txt': 'text/plain',
170
+ '.csv': 'text/csv',
166
171
  '.xml': 'application/xml',
167
172
  '.xsd': 'application/xml',
168
173
  '.pdf': 'application/pdf',
169
- '.zip': 'application/zip'
174
+ '.zip': 'application/zip',
175
+ '.wasm': 'application/wasm',
176
+ '.gltf': 'model/gltf+json',
177
+ '.glb': 'model/gltf-binary'
170
178
  };
171
179
 
172
180
  /**
@@ -48,7 +48,10 @@
48
48
  <script src="pipwerks.js"></script>
49
49
  <script>
50
50
  // Injected as a JSON string by the build system before the bridge loads.
51
- window.COURSECODE_PROXY_CONFIG = { courseUrl: {{EXTERNAL_URL_JSON}} };
51
+ window.COURSECODE_PROXY_CONFIG = {
52
+ courseUrl: {{EXTERNAL_URL_JSON}},
53
+ baseFormat: {{BASE_FORMAT_JSON}}
54
+ };
52
55
  </script>
53
56
  <script src="scorm-bridge.js"></script>
54
57
  <script>
@@ -2,7 +2,7 @@
2
2
  * SCORM Bridge - Connects postMessage from iframe to LMS API
3
3
  * Generated by CourseCode - Do not edit manually
4
4
  *
5
- * Uses pipwerks wrapper which auto-detects SCORM 1.2 or 2004 API.
5
+ * Uses pipwerks with the package-declared SCORM version.
6
6
  */
7
7
 
8
8
  // Import pipwerks (bundled with proxy package)
@@ -21,15 +21,22 @@
21
21
  const scorm = pipwerks.SCORM;
22
22
  const iframe = document.getElementById('course');
23
23
  let initialized = false;
24
+ let sessionStartedAt = 0;
25
+ let courseOrigin = null;
24
26
 
25
- // Derive the exact expected course origin from build-time configuration.
26
- // Fail closed: wildcard origins would allow unrelated frames to operate the LMS API.
27
- let courseOrigin;
27
+ const baseFormat = window.COURSECODE_PROXY_CONFIG?.baseFormat;
28
+ if (baseFormat !== 'scorm1.2' && baseFormat !== 'scorm2004') {
29
+ console.error('SCORM Bridge: Invalid or missing base format; bridge disabled');
30
+ return;
31
+ }
32
+ scorm.version = baseFormat === 'scorm1.2' ? '1.2' : '2004';
33
+
34
+ // Validate the configured launch URL itself. The actual post-redirect course
35
+ // origin is securely bound by a nonce handshake with iframe.contentWindow.
28
36
  try {
29
37
  const courseUrl = window.COURSECODE_PROXY_CONFIG?.courseUrl;
30
38
  if (!courseUrl) throw new Error('Missing proxy course URL');
31
- courseOrigin = new URL(courseUrl).origin;
32
- console.log('SCORM Bridge: Expected course origin:', courseOrigin);
39
+ console.log('SCORM Bridge: Configured course origin:', new URL(courseUrl).origin);
33
40
  } catch (e) {
34
41
  console.error('SCORM Bridge: Invalid course URL; bridge disabled:', e.message);
35
42
  return;
@@ -39,12 +46,47 @@
39
46
  scorm.handleCompletionStatus = false;
40
47
  scorm.handleExitMode = false;
41
48
 
49
+ function formatSessionTime() {
50
+ const totalSeconds = Math.max(0, Math.floor((Date.now() - sessionStartedAt) / 1000));
51
+ const hours = Math.floor(totalSeconds / 3600);
52
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
53
+ const seconds = totalSeconds % 60;
54
+ if (baseFormat === 'scorm1.2') {
55
+ return `${String(hours).padStart(4, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
56
+ }
57
+ return `PT${hours ? `${hours}H` : ''}${minutes ? `${minutes}M` : ''}${seconds || (!hours && !minutes) ? `${seconds}S` : ''}`;
58
+ }
59
+
60
+ function stageEmergencySessionData() {
61
+ if (!initialized) return;
62
+ const exitKey = baseFormat === 'scorm1.2' ? 'cmi.core.exit' : 'cmi.exit';
63
+ const sessionTimeKey = baseFormat === 'scorm1.2' ? 'cmi.core.session_time' : 'cmi.session_time';
64
+ scorm.set(exitKey, 'suspend');
65
+ scorm.set(sessionTimeKey, formatSessionTime());
66
+ }
67
+
42
68
  /**
43
69
  * Handle messages from course iframe
44
70
  */
45
71
  window.addEventListener('message', function(event) {
46
72
  const { data } = event;
47
73
 
74
+ if (data?.type === 'scorm-proxy-handshake') {
75
+ if (!iframe || event.source !== iframe.contentWindow || !data.nonce) return;
76
+ if (data.baseFormat !== baseFormat) {
77
+ console.warn('SCORM Bridge: Rejected course with mismatched SCORM format');
78
+ return;
79
+ }
80
+
81
+ courseOrigin = event.origin;
82
+ event.source.postMessage({
83
+ type: 'scorm-proxy-handshake-response',
84
+ nonce: data.nonce,
85
+ baseFormat
86
+ }, courseOrigin);
87
+ return;
88
+ }
89
+
48
90
  // Ignore non-proxy messages
49
91
  if (!data || data.type !== 'scorm-proxy-request') {
50
92
  return;
@@ -55,8 +97,9 @@
55
97
  return;
56
98
  }
57
99
 
58
- // Validate origin when we have a known course origin
59
- if (event.origin !== courseOrigin) {
100
+ // Requests are accepted only after the handshake and only from the
101
+ // exact origin that owns the configured iframe window.
102
+ if (!courseOrigin || event.origin !== courseOrigin) {
60
103
  console.warn('SCORM Bridge: Rejected message from unexpected origin:', event.origin);
61
104
  return;
62
105
  }
@@ -71,6 +114,7 @@
71
114
  if (!initialized) {
72
115
  result = scorm.init();
73
116
  initialized = result;
117
+ if (initialized) sessionStartedAt = Date.now();
74
118
  } else {
75
119
  result = true;
76
120
  }
@@ -93,6 +137,11 @@
93
137
  initialized = false;
94
138
  break;
95
139
 
140
+ case 'EmergencySave':
141
+ if (initialized) stageEmergencySessionData();
142
+ result = initialized ? scorm.save() : false;
143
+ break;
144
+
96
145
  default:
97
146
  error = `Unknown method: ${method}`;
98
147
  }
@@ -101,8 +150,16 @@
101
150
  console.error('SCORM Bridge error:', method, e);
102
151
  }
103
152
 
153
+ const shouldInspectLmsError = method === 'GetValue' || result === false || result === 'false';
154
+ if (!error && shouldInspectLmsError) {
155
+ const code = Number(scorm.debug?.getCode?.() || 0);
156
+ if (code !== 0) {
157
+ error = `SCORM ${method} failed (${code}): ${scorm.debug?.getInfo?.(code) || 'Unknown LMS error'}`;
158
+ }
159
+ }
160
+
104
161
  // Send response back to iframe (targeted to known origin)
105
- if (iframe && iframe.contentWindow) {
162
+ if (id !== null && id !== undefined && iframe && iframe.contentWindow) {
106
163
  iframe.contentWindow.postMessage({
107
164
  type: 'scorm-proxy-response',
108
165
  id,
@@ -112,7 +169,22 @@
112
169
  }
113
170
  });
114
171
 
172
+ // The LMS-side page is the only context guaranteed to still have direct,
173
+ // synchronous API access when a browser tears down the external iframe.
174
+ window.addEventListener('pagehide', function() {
175
+ if (!initialized) return;
176
+ try {
177
+ stageEmergencySessionData();
178
+ scorm.save();
179
+ scorm.quit();
180
+ } catch (error) {
181
+ console.error('SCORM Bridge: Emergency pagehide finalization failed:', error);
182
+ } finally {
183
+ initialized = false;
184
+ }
185
+ });
186
+
115
187
  // Log bridge ready
116
- console.log('SCORM Bridge: Ready (pipwerks v' + (pipwerks.version || '1.x') + ')');
188
+ console.log('SCORM Bridge: Ready for ' + baseFormat + ' (pipwerks v' + (pipwerks.version || '1.x') + ')');
117
189
 
118
190
  })();
package/lib/scaffold.js CHANGED
@@ -67,7 +67,7 @@ function slideTemplate(id) {
67
67
  * Minimal assessment template
68
68
  */
69
69
  function assessmentTemplate(id) {
70
- return `const { AssessmentManager, createMultipleChoiceQuestion } = CourseCode;
70
+ return `const { AssessmentManager } = CourseCode;
71
71
 
72
72
  export const config = {
73
73
  id: '${id}',
@@ -80,8 +80,9 @@ export const config = {
80
80
  };
81
81
 
82
82
  const questions = [
83
- createMultipleChoiceQuestion({
83
+ {
84
84
  id: '${id}-q1',
85
+ type: 'multiple-choice',
85
86
  prompt: 'Question text?',
86
87
  choices: [
87
88
  { value: 'a', text: 'Option A' },
@@ -89,11 +90,15 @@ const questions = [
89
90
  { value: 'c', text: 'Option C' }
90
91
  ],
91
92
  correctAnswer: 'a',
92
- controlled: true
93
- })
93
+ feedback: {
94
+ correct: 'Correct. Explain why this answer is right.',
95
+ incorrect: 'Not quite. Explain the governing concept and point to the correct answer.'
96
+ }
97
+ }
94
98
  ];
95
99
 
96
100
  export const slide = {
101
+ assessmentId: config.id,
97
102
  render(_root, context = {}) {
98
103
  const container = document.createElement('div');
99
104
  const assessment = AssessmentManager.createAssessment({ ...config, questions });
@@ -82,7 +82,8 @@ const READ_ONLY_2004 = new Set([
82
82
  const READ_ONLY_12 = new Set([
83
83
  'cmi.core.student_id', 'cmi.core.student_name', 'cmi.core.credit',
84
84
  'cmi.core.entry', 'cmi.core.total_time', 'cmi.core.lesson_mode',
85
- 'cmi.launch_data', 'cmi.core.score._children',
85
+ 'cmi.launch_data', 'cmi._children', 'cmi.core.score._children',
86
+ 'cmi.objectives._count', 'cmi.interactions._count',
86
87
  'cmi.comments_from_lms._count'
87
88
  ]);
88
89
 
@@ -90,6 +91,10 @@ const READ_ONLY_12 = new Set([
90
91
  const SCORM12_LESSON_STATUS = new Set([
91
92
  'passed', 'completed', 'failed', 'incomplete', 'browsed', 'not attempted'
92
93
  ]);
94
+ const SCORM12_INTERACTION_TYPES = new Set([
95
+ 'true-false', 'choice', 'fill-in', 'matching', 'performance', 'likert', 'sequencing', 'numeric'
96
+ ]);
97
+ const SCORM12_INTERACTION_RESULTS = new Set(['correct', 'wrong', 'unanticipated', 'neutral']);
93
98
 
94
99
  // Valid cmi5 verbs (AU-allowed)
95
100
  const CMI5_ALLOWED_VERBS = new Set([
@@ -157,7 +162,9 @@ export function getDefaultCMI() {
157
162
  'cmi.mode': 'normal',
158
163
  'cmi.credit': 'credit',
159
164
  '_objectives': {},
160
- '_interactions': []
165
+ '_interactions': [],
166
+ 'cmi._children': 'core,suspend_data,launch_data,objectives,interactions,student_data,student_preference',
167
+ 'cmi.core.score._children': 'raw,min,max'
161
168
  };
162
169
  }
163
170
 
@@ -430,6 +437,32 @@ function validateSetValue(element, value) {
430
437
  return true;
431
438
  }
432
439
 
440
+ function validateScorm12InteractionValue(element, value) {
441
+ const match = element.match(/^cmi\.interactions\.(\d+)\.(.+)$/);
442
+ if (!match) return true;
443
+ const [, index, property] = match;
444
+ const row = cmiData._interactions?.[Number(index)] || {};
445
+ let valid = true;
446
+
447
+ if (property === 'type') valid = SCORM12_INTERACTION_TYPES.has(value);
448
+ else if (property === 'result') valid = SCORM12_INTERACTION_RESULTS.has(value) || Number.isFinite(Number(value));
449
+ else if (property === 'time') valid = /^\d{2}:\d{2}:\d{2}$/.test(value);
450
+ else if (property === 'latency') valid = /^\d{2,4}:\d{2}:\d{2}(?:\.\d+)?$/.test(value);
451
+ else if (property === 'student_response' || /^correct_responses\.\d+\.pattern$/.test(property)) {
452
+ if (/[^\x00-\x7F]/.test(value)) valid = false;
453
+ if (row.type === 'true-false') valid = /^[01tf]$/.test(value);
454
+ if (row.type === 'choice' || row.type === 'sequencing') valid = !value.includes('[,]');
455
+ if (row.type === 'matching') valid = !value.includes('[.]') && !value.includes('[,]');
456
+ }
457
+
458
+ if (!valid) {
459
+ logError('SCORM 1.2 Interaction Format', `${element} = "${value}"`, `Invalid value for interaction type ${row.type || 'unknown'}`);
460
+ if (strictMode) setError(405);
461
+ return !strictMode;
462
+ }
463
+ return true;
464
+ }
465
+
433
466
  function checkSuspendDataSize(value) {
434
467
  const size = value ? value.length : 0;
435
468
  const limit = SUSPEND_DATA_LIMITS[activeFormat] || 64000;
@@ -746,15 +779,22 @@ const API = {
746
779
  }
747
780
 
748
781
  // Strict: reject SCORM 2004 elements in SCORM 1.2 context
749
- if (strictMode && !element.startsWith('cmi.core.') && !element.startsWith('cmi.suspend_data') && !element.startsWith('cmi.launch_data') && !element.startsWith('cmi.objectives.') && !element.startsWith('cmi.interactions.') && !element.startsWith('cmi.student_data.') && !element.startsWith('cmi.comments')) {
782
+ if (strictMode && element !== 'cmi._children' && !element.startsWith('cmi.core.') && !element.startsWith('cmi.suspend_data') && !element.startsWith('cmi.launch_data') && !element.startsWith('cmi.objectives.') && !element.startsWith('cmi.interactions.') && !element.startsWith('cmi.student_data.') && !element.startsWith('cmi.comments')) {
750
783
  logError('Wrong Format', 'SCORM 1.2 does not support element: ' + element, 'Use SCORM 1.2 element names (cmi.core.*).');
751
784
  setError(201);
752
785
  logApiCall('LMSGetValue', element, '', true);
753
786
  return '';
754
787
  }
755
788
 
756
- const mapped = mapScorm12Element(element);
757
- let value = cmiData[mapped] !== undefined ? cmiData[mapped] : '';
789
+ let value = '';
790
+ if (element === 'cmi.objectives._count') value = String(Object.keys(cmiData._objectives || {}).length);
791
+ else if (element === 'cmi.interactions._count') value = String((cmiData._interactions || []).length);
792
+ else if (element.startsWith('cmi.objectives.')) value = handleObjectiveGet(element);
793
+ else if (element.startsWith('cmi.interactions.')) value = handleInteractionGet(element);
794
+ else {
795
+ const mapped = mapScorm12Element(element);
796
+ value = cmiData[mapped] !== undefined ? cmiData[mapped] : '';
797
+ }
758
798
  logApiCall('LMSGetValue', element, value);
759
799
  return value;
760
800
  },
@@ -780,6 +820,12 @@ const API = {
780
820
  logApiCall('LMSSetValue', element + ' = [' + value.length + ' chars]', 'false (too large)', true);
781
821
  return 'false';
782
822
  }
823
+ if (strictMode && /[^\x00-\x7F]/.test(value)) {
824
+ logError('SCORM 1.2 String Format', 'cmi.suspend_data contains non-ASCII characters', 'CMIString4096 is restricted to ASCII.');
825
+ setError(405);
826
+ logApiCall('LMSSetValue', element, 'false (non-ASCII)', true);
827
+ return 'false';
828
+ }
783
829
  }
784
830
 
785
831
  if (element === 'cmi.core.lesson_status') {
@@ -812,15 +858,24 @@ const API = {
812
858
  }
813
859
 
814
860
  // Strict: reject SCORM 2004-only elements in SCORM 1.2 context
815
- if (strictMode && !element.startsWith('cmi.core.') && !element.startsWith('cmi.suspend_data') && !element.startsWith('cmi.launch_data') && !element.startsWith('cmi.objectives.') && !element.startsWith('cmi.interactions.') && !element.startsWith('cmi.student_data.') && !element.startsWith('cmi.comments')) {
861
+ if (strictMode && element !== 'cmi._children' && !element.startsWith('cmi.core.') && !element.startsWith('cmi.suspend_data') && !element.startsWith('cmi.launch_data') && !element.startsWith('cmi.objectives.') && !element.startsWith('cmi.interactions.') && !element.startsWith('cmi.student_data.') && !element.startsWith('cmi.comments')) {
816
862
  logError('Wrong Format', 'SCORM 1.2 does not support element: ' + element, 'Use SCORM 1.2 element names (cmi.core.*).');
817
863
  setError(201);
818
864
  logApiCall('LMSSetValue', element + ' = ' + value, 'false', true);
819
865
  return 'false';
820
866
  }
821
867
 
822
- const mapped = mapScorm12Element(element);
823
- cmiData[mapped] = value;
868
+ if (!validateScorm12InteractionValue(element, value)) {
869
+ logApiCall('LMSSetValue', element + ' = ' + value, 'false (invalid interaction format)', true);
870
+ return 'false';
871
+ }
872
+
873
+ if (element.startsWith('cmi.objectives.')) handleObjectiveSet(element, value);
874
+ else if (element.startsWith('cmi.interactions.')) handleInteractionSet(element, value);
875
+ else {
876
+ const mapped = mapScorm12Element(element);
877
+ cmiData[mapped] = value;
878
+ }
824
879
  if (element === 'cmi.core.lesson_location') notifySlideChange(value);
825
880
  logApiCall('LMSSetValue', element + ' = ' + value, 'true');
826
881
  if (uiCallbacks.onStateChange) uiCallbacks.onStateChange('data');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.57",
3
+ "version": "0.1.58",
4
4
  "description": "Multi-format course authoring framework with CLI tools (SCORM 2004, SCORM 1.2, cmi5, LTI 1.3)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,9 @@
37
37
  },
38
38
  "./vite-plugin-content-discovery": {
39
39
  "default": "./lib/vite-plugin-content-discovery.js"
40
+ },
41
+ "./portable-html": {
42
+ "default": "./lib/portable-html.js"
40
43
  }
41
44
  },
42
45
  "scripts": {
@@ -114,6 +117,7 @@
114
117
  "pdf2json": "^4.0.3",
115
118
  "pdf2md": "^1.0.2",
116
119
  "puppeteer-core": "^24.43.1",
120
+ "vite-plugin-singlefile": "^2.3.3",
117
121
  "win-ca": "^3.5.1",
118
122
  "ws": "^8.21.0"
119
123
  },
@@ -0,0 +1,110 @@
1
+ <?xml version="1.0"?>
2
+ <!-- filename=adlcp_rootv1p2.xsd -->
3
+ <!-- Conforms to w3c http://www.w3.org/TR/xmlschema-1/ 2000-10-24-->
4
+
5
+ <xsd:schema xmlns="http://www.adlnet.org/xsd/adlcp_rootv1p2"
6
+ targetNamespace="http://www.adlnet.org/xsd/adlcp_rootv1p2"
7
+ xmlns:xml="http://www.w3.org/XML/1998/namespace"
8
+ xmlns:imscp="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
9
+ xmlns:xsd="http://www.w3.org/2001/XMLSchema"
10
+ elementFormDefault="unqualified"
11
+ version="ADL Version 1.2">
12
+
13
+ <xsd:import namespace="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
14
+ schemaLocation="imscp_rootv1p1p2.xsd"/>
15
+
16
+ <xsd:element name="location" type="locationType"/>
17
+ <xsd:element name="prerequisites" type="prerequisitesType"/>
18
+ <xsd:element name="maxtimeallowed" type="maxtimeallowedType"/>
19
+ <xsd:element name="timelimitaction" type="timelimitactionType"/>
20
+ <xsd:element name="datafromlms" type="datafromlmsType"/>
21
+ <xsd:element name="masteryscore" type="masteryscoreType"/>
22
+
23
+
24
+ <xsd:element name="schema" type="newSchemaType"/>
25
+ <xsd:simpleType name="newSchemaType">
26
+ <xsd:restriction base="imscp:schemaType">
27
+ <xsd:enumeration value="ADL SCORM"/>
28
+ </xsd:restriction>
29
+ </xsd:simpleType>
30
+
31
+ <xsd:element name="schemaversion" type="newSchemaversionType"/>
32
+ <xsd:simpleType name="newSchemaversionType">
33
+ <xsd:restriction base="imscp:schemaversionType">
34
+ <xsd:enumeration value="1.2"/>
35
+ </xsd:restriction>
36
+ </xsd:simpleType>
37
+
38
+
39
+ <xsd:attribute name="scormtype">
40
+ <xsd:simpleType>
41
+ <xsd:restriction base="xsd:string">
42
+ <xsd:enumeration value="asset"/>
43
+ <xsd:enumeration value="sco"/>
44
+ </xsd:restriction>
45
+ </xsd:simpleType>
46
+ </xsd:attribute>
47
+
48
+ <xsd:simpleType name="locationType">
49
+ <xsd:restriction base="xsd:string">
50
+ <xsd:maxLength value="2000"/>
51
+ </xsd:restriction>
52
+ </xsd:simpleType>
53
+
54
+
55
+ <xsd:complexType name="prerequisitesType">
56
+ <xsd:simpleContent>
57
+ <xsd:extension base="prerequisiteStringType">
58
+ <xsd:attributeGroup ref="attr.prerequisitetype"/>
59
+ </xsd:extension>
60
+ </xsd:simpleContent>
61
+ </xsd:complexType>
62
+
63
+ <xsd:attributeGroup name="attr.prerequisitetype">
64
+ <xsd:attribute name="type" use="required">
65
+ <xsd:simpleType>
66
+ <xsd:restriction base="xsd:string">
67
+ <xsd:enumeration value="aicc_script"/>
68
+ </xsd:restriction>
69
+ </xsd:simpleType>
70
+ </xsd:attribute>
71
+ </xsd:attributeGroup>
72
+
73
+ <xsd:simpleType name="maxtimeallowedType">
74
+ <xsd:restriction base="xsd:string">
75
+ <xsd:maxLength value="13"/>
76
+ </xsd:restriction>
77
+ </xsd:simpleType>
78
+
79
+ <xsd:simpleType name="timelimitactionType">
80
+ <xsd:restriction base="stringType">
81
+ <xsd:enumeration value="exit,no message"/>
82
+ <xsd:enumeration value="exit,message"/>
83
+ <xsd:enumeration value="continue,no message"/>
84
+ <xsd:enumeration value="continue,message"/>
85
+ </xsd:restriction>
86
+ </xsd:simpleType>
87
+
88
+ <xsd:simpleType name="datafromlmsType">
89
+ <xsd:restriction base="xsd:string">
90
+ <xsd:maxLength value="255"/>
91
+ </xsd:restriction>
92
+ </xsd:simpleType>
93
+
94
+ <xsd:simpleType name="masteryscoreType">
95
+ <xsd:restriction base="xsd:string">
96
+ <xsd:maxLength value="200"/>
97
+ </xsd:restriction>
98
+ </xsd:simpleType>
99
+
100
+ <xsd:simpleType name="stringType">
101
+ <xsd:restriction base="xsd:string"/>
102
+ </xsd:simpleType>
103
+
104
+ <xsd:simpleType name="prerequisiteStringType">
105
+ <xsd:restriction base="xsd:string">
106
+ <xsd:maxLength value="200"/>
107
+ </xsd:restriction>
108
+ </xsd:simpleType>
109
+
110
+ </xsd:schema>
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
3
+ <xsd:import namespace="http://www.imsproject.org/xsd/imscp_rootv1p1p2" schemaLocation="imscp_rootv1p1p2.xsd"/>
4
+ <xsd:import namespace="http://www.adlnet.org/xsd/adlcp_rootv1p2" schemaLocation="adlcp_rootv1p2.xsd"/>
5
+ </xsd:schema>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
3
+ <xsd:import namespace="http://www.imsglobal.org/xsd/imscp_v1p1" schemaLocation="imscp_v1p1.xsd"/>
4
+ <xsd:import namespace="http://www.imsglobal.org/xsd/imsss" schemaLocation="imsss_v1p0.xsd"/>
5
+ <xsd:import namespace="http://www.adlnet.org/xsd/adlcp_v1p3" schemaLocation="adlcp_v1p3.xsd"/>
6
+ <xsd:import namespace="http://www.adlnet.org/xsd/adlseq_v1p3" schemaLocation="adlseq_v1p3.xsd"/>
7
+ <xsd:import namespace="http://www.adlnet.org/xsd/adlnav_v1p3" schemaLocation="adlnav_v1p3.xsd"/>
8
+ </xsd:schema>