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
package/lib/build.js CHANGED
@@ -9,6 +9,69 @@ import { fileURLToPath } from 'url';
9
9
  import { validateProject } from './project-utils.js';
10
10
 
11
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ export function snapshotZipFiles(directory) {
14
+ const snapshot = new Map();
15
+ if (!fs.existsSync(directory)) return snapshot;
16
+
17
+ for (const filename of fs.readdirSync(directory)) {
18
+ if (!filename.endsWith('.zip')) continue;
19
+ const stats = fs.statSync(path.join(directory, filename));
20
+ snapshot.set(filename, { mtimeMs: stats.mtimeMs, size: stats.size });
21
+ }
22
+
23
+ return snapshot;
24
+ }
25
+
26
+ export function findProducedZipFiles(directory, before = new Map()) {
27
+ if (!fs.existsSync(directory)) return [];
28
+
29
+ return fs.readdirSync(directory)
30
+ .filter(filename => filename.endsWith('.zip'))
31
+ .map(filename => {
32
+ const stats = fs.statSync(path.join(directory, filename));
33
+ return { filename, mtimeMs: stats.mtimeMs, size: stats.size };
34
+ })
35
+ .filter(file => {
36
+ const previous = before.get(file.filename);
37
+ return !previous || previous.mtimeMs !== file.mtimeMs || previous.size !== file.size;
38
+ })
39
+ .sort((a, b) => a.mtimeMs - b.mtimeMs || a.filename.localeCompare(b.filename))
40
+ .map(file => file.filename);
41
+ }
42
+
43
+ export function detectBuiltFormat(distDirectory, requestedFormat) {
44
+ const indexPath = path.join(distDirectory, 'index.html');
45
+
46
+ if (fs.existsSync(indexPath)) {
47
+ const html = fs.readFileSync(indexPath, 'utf8');
48
+ const match = html.match(/<meta\s+name=["']lms-format["']\s+content=["']([^"']+)["']/i);
49
+ if (match?.[1]) return match[1];
50
+ }
51
+
52
+ if (fs.existsSync(path.join(distDirectory, 'cmi5.xml'))) return 'cmi5';
53
+ if (fs.existsSync(path.join(distDirectory, 'lti-tool-config.json'))) return 'lti';
54
+
55
+ return requestedFormat || 'scorm2004';
56
+ }
57
+
58
+ export function formatBuildOutput(format, zipFiles = []) {
59
+ const normalizedFormat = format || 'scorm2004';
60
+ const displayNames = {
61
+ scorm2004: 'SCORM 2004',
62
+ 'scorm1.2': 'SCORM 1.2',
63
+ cmi5: 'cmi5',
64
+ lti: 'LTI 1.3',
65
+ 'scorm2004-proxy': 'SCORM 2004 proxy',
66
+ 'scorm1.2-proxy': 'SCORM 1.2 proxy',
67
+ 'cmi5-remote': 'cmi5 remote'
68
+ };
69
+ const displayName = displayNames[normalizedFormat] || normalizedFormat;
70
+ const archives = zipFiles.map(filename => ` - ${filename} Ready for LMS upload`).join('\n');
71
+
72
+ return ` Output:\n - dist/ ${displayName} package files${archives ? `\n${archives}` : ''}\n\n To test locally, launch dist/ or the archive in a ${displayName}-compatible LMS or conformance tool.`;
73
+ }
74
+
12
75
  /**
13
76
  * Run a command and return a promise
14
77
  */
@@ -17,7 +80,7 @@ function runCommand(cmd, args, options = {}) {
17
80
  const child = spawn(cmd, args, {
18
81
  cwd: process.cwd(),
19
82
  stdio: 'inherit',
20
- shell: true,
83
+ shell: process.platform === 'win32',
21
84
  env: options.env || process.env,
22
85
  ...options
23
86
  });
@@ -42,6 +105,7 @@ export async function build(options = {}) {
42
105
  `);
43
106
 
44
107
  const startTime = Date.now();
108
+ const zipSnapshot = snapshotZipFiles(process.cwd());
45
109
 
46
110
  try {
47
111
  // Build environment — pass lib dir so vite.config.js can resolve coursecode utilities
@@ -64,17 +128,14 @@ export async function build(options = {}) {
64
128
 
65
129
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
66
130
 
67
- // Check for output
68
- const zipFiles = fs.readdirSync(process.cwd()).filter(f => f.endsWith('.zip'));
131
+ const zipFiles = findProducedZipFiles(process.cwd(), zipSnapshot);
132
+ const builtFormat = detectBuiltFormat(path.join(process.cwd(), 'dist'), options.format);
133
+ const output = formatBuildOutput(builtFormat, zipFiles);
69
134
 
70
135
  console.log(`
71
136
  ✅ Build completed in ${elapsed}s
72
137
 
73
- Output:
74
- - dist/ SCORM package files
75
- ${zipFiles.length > 0 ? `- ${zipFiles[zipFiles.length - 1]} Ready for LMS upload` : ''}
76
-
77
- To test locally, load dist/ directory in a SCORM testing tool.
138
+ ${output}
78
139
  `);
79
140
 
80
141
  } catch (error) {
package/lib/cloud.js CHANGED
@@ -11,7 +11,7 @@ import crypto from 'crypto';
11
11
  import fs from 'fs';
12
12
  import path from 'path';
13
13
  import os from 'os';
14
- import { exec } from 'child_process';
14
+ import { execFile } from 'child_process';
15
15
  import readline from 'readline';
16
16
  import { fileURLToPath } from 'url';
17
17
 
@@ -427,10 +427,23 @@ function handleResponseError(status, body) {
427
427
  */
428
428
  function openBrowser(url) {
429
429
  const platform = process.platform;
430
- const cmd = platform === 'darwin' ? 'open'
431
- : platform === 'win32' ? 'start'
432
- : 'xdg-open';
433
- exec(`${cmd} "${url}"`);
430
+ let command;
431
+ let args;
432
+ if (platform === 'darwin') {
433
+ command = 'open';
434
+ args = [url];
435
+ } else if (platform === 'win32') {
436
+ command = 'rundll32';
437
+ args = ['url.dll,FileProtocolHandler', url];
438
+ } else {
439
+ command = 'xdg-open';
440
+ args = [url];
441
+ }
442
+
443
+ const child = execFile(command, args);
444
+ child.on('error', (error) => {
445
+ console.warn(` ⚠ Could not open the browser automatically: ${error.message}`);
446
+ });
434
447
  }
435
448
 
436
449
  /**
@@ -987,9 +1000,17 @@ export async function deploy(options = {}) {
987
1000
  // Reconcile local sourceType with cloud truth (handles unlink-via-dashboard)
988
1001
  try {
989
1002
  const statusData = JSON.parse(await statusRes.text());
990
- const serverSourceType = statusData.source?.type || statusData.source_type;
1003
+ const serverGithubRepo = statusData.source?.githubRepo || statusData.github_repo;
991
1004
  const localRc = readRcConfig();
992
- if (localRc?.sourceType === 'github' && serverSourceType !== 'github') {
1005
+ if (serverGithubRepo && (
1006
+ localRc?.sourceType !== 'github' || localRc?.githubRepo !== serverGithubRepo
1007
+ )) {
1008
+ updateRcConfig((rc) => {
1009
+ rc.sourceType = 'github';
1010
+ rc.githubRepo = serverGithubRepo;
1011
+ return rc;
1012
+ });
1013
+ } else if (localRc?.sourceType === 'github' && !serverGithubRepo) {
993
1014
  updateRcConfig((rc) => {
994
1015
  delete rc.sourceType;
995
1016
  delete rc.githubRepo;
@@ -1508,8 +1529,16 @@ export async function status(options = {}) {
1508
1529
 
1509
1530
  // Reconcile local sourceType with cloud truth (handles unlink-via-dashboard)
1510
1531
  const localRc = readRcConfig();
1511
- const serverSourceType = data.source?.type || data.source_type;
1512
- if (localRc?.sourceType === 'github' && serverSourceType !== 'github') {
1532
+ const serverGithubRepo = data.source?.githubRepo || data.github_repo;
1533
+ if (serverGithubRepo && (
1534
+ localRc?.sourceType !== 'github' || localRc?.githubRepo !== serverGithubRepo
1535
+ )) {
1536
+ updateRcConfig((rc) => {
1537
+ rc.sourceType = 'github';
1538
+ rc.githubRepo = serverGithubRepo;
1539
+ return rc;
1540
+ });
1541
+ } else if (localRc?.sourceType === 'github' && !serverGithubRepo) {
1513
1542
  updateRcConfig((rc) => {
1514
1543
  delete rc.sourceType;
1515
1544
  delete rc.githubRepo;
@@ -1527,9 +1556,9 @@ export async function status(options = {}) {
1527
1556
 
1528
1557
  console.log(`\n${data.slug} — ${data.name} (${data.orgName})\n`);
1529
1558
 
1530
- const sourceType = data.source?.type || data.source_type;
1531
1559
  const githubRepo = data.source?.githubRepo || data.github_repo;
1532
- if (sourceType === 'github' && githubRepo) {
1560
+ const sourceType = data.source?.type || data.source_type;
1561
+ if (githubRepo) {
1533
1562
  console.log(`Source: GitHub — ${githubRepo}`);
1534
1563
  console.log(' production=github | preview=cli+github');
1535
1564
  } else if (sourceType) {
package/lib/convert.js CHANGED
@@ -238,13 +238,41 @@ async function convertFile(filePath) {
238
238
  async function convertDocx(filePath) {
239
239
  const mammoth = await import('mammoth');
240
240
  const result = await mammoth.convertToMarkdown({ path: filePath });
241
+ const sanitized = replaceEmbeddedDataImages(result.value);
242
+ const warnings = result.messages.filter(m => m.type === 'warning');
243
+
244
+ if (sanitized.omittedImageCount > 0) {
245
+ warnings.push({
246
+ type: 'warning',
247
+ message: `Omitted ${sanitized.omittedImageCount} embedded image payload(s). Review the source document for visual content.`
248
+ });
249
+ }
241
250
 
242
251
  return {
243
- markdown: result.value,
244
- warnings: result.messages.filter(m => m.type === 'warning')
252
+ markdown: sanitized.markdown,
253
+ warnings
245
254
  };
246
255
  }
247
256
 
257
+ /**
258
+ * Replace mammoth's inline base64 image output with compact review markers.
259
+ * Reference Markdown is consumed by AI authoring tools, where multi-megabyte
260
+ * data URIs waste context and do not preserve useful visual meaning.
261
+ */
262
+ export function replaceEmbeddedDataImages(markdown) {
263
+ let omittedImageCount = 0;
264
+ const sanitized = markdown.replace(
265
+ /!\[([^\]]*)\]\(data:image\/[^;]+;base64,[^)]+\)/gi,
266
+ (_match, altText) => {
267
+ omittedImageCount++;
268
+ const label = altText?.trim() ? `: ${altText.trim()}` : '';
269
+ return `*[Embedded image omitted${label}. Review the source document for visual content.]*`;
270
+ }
271
+ );
272
+
273
+ return { markdown: sanitized, omittedImageCount };
274
+ }
275
+
248
276
  /**
249
277
  * Convert PPTX to markdown using node-pptx-parser
250
278
  */
package/lib/create.js CHANGED
@@ -172,7 +172,7 @@ function npmInstall(cwd) {
172
172
  const child = spawn(command, args, {
173
173
  cwd,
174
174
  stdio: 'inherit',
175
- shell: !npmCli
175
+ shell: !npmCli && process.platform === 'win32'
176
176
  });
177
177
 
178
178
  child.on('close', (code) => {
@@ -182,6 +182,7 @@ function npmInstall(cwd) {
182
182
  reject(new Error(`npm install failed with code ${code}`));
183
183
  }
184
184
  });
185
+ child.on('error', reject);
185
186
  });
186
187
  }
187
188
 
@@ -193,7 +194,7 @@ function gitInit(cwd) {
193
194
  const child = spawn('git', ['init'], {
194
195
  cwd,
195
196
  stdio: 'pipe',
196
- shell: true
197
+ shell: false
197
198
  });
198
199
 
199
200
  child.on('close', (code) => {
@@ -203,6 +204,7 @@ function gitInit(cwd) {
203
204
  reject(new Error(`git init failed with code ${code}`));
204
205
  }
205
206
  });
207
+ child.on('error', reject);
206
208
  });
207
209
  }
208
210
 
@@ -384,7 +386,7 @@ export async function create(name, options = {}) {
384
386
  const child = spawn(command, args, {
385
387
  cwd: targetDir,
386
388
  stdio: 'inherit',
387
- shell: !npxCli
389
+ shell: !npxCli && process.platform === 'win32'
388
390
  });
389
391
 
390
392
  child.on('error', () => {
package/lib/dev.js CHANGED
@@ -33,7 +33,7 @@ export async function dev(options = {}) {
33
33
  const viteBuild = spawn('npx', ['vite', 'build', '--mode', 'development', '--watch'], {
34
34
  cwd: process.cwd(),
35
35
  stdio: 'inherit',
36
- shell: true,
36
+ shell: process.platform === 'win32',
37
37
  env
38
38
  });
39
39
 
@@ -69,6 +69,10 @@ class HeadlessBrowser {
69
69
  this.chromePath = null;
70
70
  this._sseReloadListener = null;
71
71
  this._reconnectTimer = null;
72
+ this._reloadDebounceTimer = null;
73
+ this._reloadInProgress = false;
74
+ this._reloadQueued = false;
75
+ this._resolveReload = null;
72
76
  this._stopped = false;
73
77
  this._consoleLogs = [];
74
78
  this._viewport = { width: 1280, height: 720 };
@@ -195,19 +199,7 @@ class HeadlessBrowser {
195
199
  res.on('data', async (chunk) => {
196
200
  if (this._stopped) return;
197
201
  const data = chunk.toString();
198
- if (data.includes('data: reload')) {
199
- // Track the reload so concurrent tool calls wait for it
200
- let resolveReload;
201
- this._reloadPromise = new Promise(r => { resolveReload = r; });
202
- try {
203
- await this._navigateToPreview();
204
- } catch (_e) {
205
- // Preview may be mid-rebuild, retry will happen on next SSE
206
- } finally {
207
- this._reloadPromise = null;
208
- resolveReload();
209
- }
210
- }
202
+ if (data.includes('data: reload')) this._scheduleReload();
211
203
  });
212
204
 
213
205
  res.on('end', () => {
@@ -227,6 +219,55 @@ class HeadlessBrowser {
227
219
  connect();
228
220
  }
229
221
 
222
+ /**
223
+ * Coalesce rapid preview rebuild notifications into one browser reload.
224
+ * If another event arrives while navigation is running, perform one more
225
+ * debounced reload before releasing tool calls waiting on _reloadPromise.
226
+ * @private
227
+ */
228
+ _scheduleReload() {
229
+ if (this._stopped) return;
230
+
231
+ if (!this._reloadPromise) {
232
+ this._reloadPromise = new Promise(resolve => {
233
+ this._resolveReload = resolve;
234
+ });
235
+ }
236
+
237
+ if (this._reloadInProgress) {
238
+ this._reloadQueued = true;
239
+ return;
240
+ }
241
+
242
+ if (this._reloadDebounceTimer) clearTimeout(this._reloadDebounceTimer);
243
+ this._reloadDebounceTimer = setTimeout(() => this._performScheduledReload(), 300);
244
+ }
245
+
246
+ /** @private */
247
+ async _performScheduledReload() {
248
+ this._reloadDebounceTimer = null;
249
+ this._reloadInProgress = true;
250
+ this._reloadQueued = false;
251
+
252
+ try {
253
+ await this._navigateToPreview();
254
+ } catch (_e) {
255
+ // A later SSE event will retry after the preview finishes rebuilding.
256
+ } finally {
257
+ this._reloadInProgress = false;
258
+ }
259
+
260
+ if (this._reloadQueued) {
261
+ this._scheduleReload();
262
+ return;
263
+ }
264
+
265
+ const resolveReload = this._resolveReload;
266
+ this._reloadPromise = null;
267
+ this._resolveReload = null;
268
+ resolveReload?.();
269
+ }
270
+
230
271
  /**
231
272
  * Execute a function in the course iframe's context.
232
273
  * This is how runtime tools call CourseCodeAutomation methods.
@@ -481,6 +522,17 @@ class HeadlessBrowser {
481
522
  this._reconnectTimer = null;
482
523
  }
483
524
 
525
+ if (this._reloadDebounceTimer) {
526
+ clearTimeout(this._reloadDebounceTimer);
527
+ this._reloadDebounceTimer = null;
528
+ }
529
+
530
+ this._reloadQueued = false;
531
+ this._reloadInProgress = false;
532
+ this._resolveReload?.();
533
+ this._resolveReload = null;
534
+ this._reloadPromise = null;
535
+
484
536
  if (this._sseReloadListener) {
485
537
  this._sseReloadListener.destroy();
486
538
  this._sseReloadListener = null;
@@ -498,4 +550,4 @@ class HeadlessBrowser {
498
550
  // Singleton instance
499
551
  const headless = new HeadlessBrowser();
500
552
  export default headless;
501
- export { findChrome };
553
+ export { HeadlessBrowser, findChrome };
@@ -22,20 +22,42 @@ import { escapeXmlAttribute, escapeXmlText } from './xml-utils.js';
22
22
  */
23
23
  export function generateCmi5Manifest(config, _files, options = {}) {
24
24
  // cmi5 course identifier - use configured identifier or generate from title
25
- const courseId = config.identifier ||
26
- `urn:coursecode:${config.title.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}`;
25
+ const titleSlug = String(config.title || 'course')
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9]+/g, '-')
28
+ .replace(/^-+|-+$/g, '') || 'course';
29
+ const courseId = config.identifier || `urn:coursecode:${titleSlug}`;
30
+ try {
31
+ new URL(courseId);
32
+ } catch {
33
+ throw new Error(`cmi5 course identifier must be an absolute IRI: ${courseId}`);
34
+ }
27
35
 
28
36
  // Package-level mastery is optional. Do not invent a value from one
29
37
  // assessment because a course can contain multiple thresholds.
30
38
  const masteryAttribute = Number.isFinite(config.masteryScore)
31
39
  ? ` masteryScore="${(config.masteryScore / 100).toFixed(2)}"`
32
40
  : '';
41
+ const allowedMoveOn = new Set([
42
+ 'Passed', 'Completed', 'CompletedAndPassed', 'CompletedOrPassed', 'NotApplicable'
43
+ ]);
44
+ const moveOn = config.moveOn || (Number.isFinite(config.masteryScore) ? 'CompletedAndPassed' : 'Completed');
45
+ if (!allowedMoveOn.has(moveOn)) {
46
+ throw new Error(`Invalid cmi5 moveOn value: ${moveOn}`);
47
+ }
33
48
 
34
49
  // AU identifier - derive from course ID
35
50
  const auId = `${courseId}/au/1`;
36
51
 
37
52
  // URL: absolute for cmi5-remote (use as-is), relative for standard cmi5
38
- const auUrl = options.externalUrl || 'index.html';
53
+ let auUrl = options.externalUrl || 'index.html';
54
+ if (options.externalUrl) {
55
+ const parsedUrl = new URL(options.externalUrl);
56
+ if (!/\.[A-Za-z0-9]+$/.test(parsedUrl.pathname)) {
57
+ parsedUrl.pathname = `${parsedUrl.pathname.replace(/\/$/, '')}/index.html`;
58
+ }
59
+ auUrl = parsedUrl.toString();
60
+ }
39
61
  const escapedCourseId = escapeXmlAttribute(courseId);
40
62
  const escapedAuId = escapeXmlAttribute(auId);
41
63
  const escapedLanguage = escapeXmlAttribute(config.language);
@@ -55,7 +77,7 @@ export function generateCmi5Manifest(config, _files, options = {}) {
55
77
  </description>
56
78
  </course>
57
79
 
58
- <au id="${escapedAuId}" moveOn="Completed"${masteryAttribute} launchMethod="OwnWindow">
80
+ <au id="${escapedAuId}" moveOn="${escapeXmlAttribute(moveOn)}"${masteryAttribute} launchMethod="OwnWindow">
59
81
  <title>
60
82
  <langstring lang="${escapedLanguage}">${title}</langstring>
61
83
  </title>
@@ -23,8 +23,13 @@ export function generateLtiToolConfig(config, options = {}) {
23
23
  } catch {
24
24
  throw new Error(`Invalid LTI externalUrl: ${configuredUrl}`);
25
25
  }
26
- if (!['https:', 'http:'].includes(parsedBaseUrl.protocol)) {
27
- throw new Error('LTI externalUrl must use https (or http for local development)');
26
+ const localHttp = parsedBaseUrl.protocol === 'http:' &&
27
+ ['localhost', '127.0.0.1', '::1'].includes(parsedBaseUrl.hostname);
28
+ if (parsedBaseUrl.protocol !== 'https:' && !localHttp) {
29
+ throw new Error('LTI externalUrl must use HTTPS (HTTP is allowed only for local development)');
30
+ }
31
+ if (parsedBaseUrl.search || parsedBaseUrl.hash || parsedBaseUrl.username || parsedBaseUrl.password) {
32
+ throw new Error('LTI externalUrl must not contain a query, fragment, or embedded HTTP credentials');
28
33
  }
29
34
  const baseUrl = parsedBaseUrl.toString().replace(/\/$/, '');
30
35
  const title = config.title || 'CourseCode Course';
@@ -6,5 +6,5 @@ export function generateManifest(
6
6
  ): { filename: string; content: string }
7
7
 
8
8
  export function getSchemaFiles(
9
- format: 'scorm2004' | 'scorm1.2' | 'cmi5' | 'lti'
9
+ format: 'scorm2004' | 'scorm1.2' | 'scorm1.2-proxy' | 'scorm2004-proxy' | 'cmi5' | 'lti'
10
10
  ): string[]
@@ -77,6 +77,7 @@ export function getSchemaFiles(format) {
77
77
  return [];
78
78
 
79
79
  case 'scorm1.2':
80
+ case 'scorm1.2-proxy':
80
81
  // SCORM 1.2 schema files
81
82
  return [
82
83
  'imscp_rootv1p1p2.xsd',
@@ -84,6 +85,7 @@ export function getSchemaFiles(format) {
84
85
  'ims_xml.xsd'
85
86
  ];
86
87
 
88
+ case 'scorm2004-proxy':
87
89
  case 'scorm2004':
88
90
  default:
89
91
  // SCORM 2004 4th Edition schema files
@@ -92,8 +94,7 @@ export function getSchemaFiles(format) {
92
94
  'adlcp_v1p3.xsd',
93
95
  'imsss_v1p0.xsd',
94
96
  'adlseq_v1p3.xsd',
95
- 'adlnav_v1p3.xsd',
96
- 'lom.xsd'
97
+ 'adlnav_v1p3.xsd'
97
98
  ];
98
99
  }
99
100
  }
@@ -8,7 +8,7 @@
8
8
  * - 4th Edition schema references
9
9
  */
10
10
 
11
- import { escapeXmlAttribute, escapeXmlText } from './xml-utils.js';
11
+ import { escapeXmlAttribute, escapeXmlText, makeXmlId } from './xml-utils.js';
12
12
 
13
13
  /**
14
14
  * Generates the SCORM 1.2 manifest.
@@ -34,7 +34,7 @@ export function generateScorm12Manifest(config, files) {
34
34
  // SCORM 1.2 uses ADLCP 1.2 schema and doesn't include sequencing
35
35
  return `<?xml version="1.0" encoding="UTF-8"?>
36
36
  <!-- SCORM 1.2 manifest - GENERATED FILE - DO NOT EDIT MANUALLY -->
37
- <manifest identifier="${config.title.replace(/[^a-zA-Z0-9]/g, '-')}"
37
+ <manifest identifier="${makeXmlId(config.identifier || config.title)}"
38
38
  version="${version}"
39
39
  xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
40
40
  xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
@@ -3,7 +3,7 @@
3
3
  * @description Generates imsmanifest.xml for SCORM 2004 4th Edition packages.
4
4
  */
5
5
 
6
- import { escapeXmlAttribute, escapeXmlText } from './xml-utils.js';
6
+ import { escapeXmlAttribute, escapeXmlText, makeXmlId } from './xml-utils.js';
7
7
 
8
8
  /**
9
9
  * Generates the SCORM 2004 4th Edition manifest.
@@ -21,14 +21,11 @@ export function generateScorm2004Manifest(config, files) {
21
21
 
22
22
  const fileEntries = resourceFiles.map(f => ` <file href="${escapeXmlAttribute(f)}"/>`).join('\n');
23
23
  const version = escapeXmlAttribute(config.version);
24
- const language = escapeXmlAttribute(config.language);
25
24
  const title = escapeXmlText(config.title);
26
- const description = escapeXmlText(config.description);
27
- const author = escapeXmlText(config.author);
28
25
 
29
26
  return `<?xml version="1.0" encoding="UTF-8"?>
30
27
  <!-- SCORM 2004 4th Edition manifest - GENERATED FILE - DO NOT EDIT MANUALLY -->
31
- <manifest identifier="${config.title.replace(/[^a-zA-Z0-9]/g, '-')}"
28
+ <manifest identifier="${makeXmlId(config.identifier || config.title)}"
32
29
  version="${version}"
33
30
  xmlns="http://www.imsglobal.org/xsd/imscp_v1p1"
34
31
  xmlns:imscp="http://www.imsglobal.org/xsd/imscp_v1p1"
@@ -36,40 +33,18 @@ export function generateScorm2004Manifest(config, files) {
36
33
  xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_v1p3"
37
34
  xmlns:adlseq="http://www.adlnet.org/xsd/adlseq_v1p3"
38
35
  xmlns:adlnav="http://www.adlnet.org/xsd/adlnav_v1p3"
39
- xmlns:lom="http://ltsc.ieee.org/xsd/LOM"
40
36
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
41
37
  xsi:schemaLocation="
42
38
  http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd
43
39
  http://www.imsglobal.org/xsd/imsss imsss_v1p0.xsd
44
40
  http://www.adlnet.org/xsd/adlcp_v1p3 adlcp_v1p3.xsd
45
41
  http://www.adlnet.org/xsd/adlseq_v1p3 adlseq_v1p3.xsd
46
- http://www.adlnet.org/xsd/adlnav_v1p3 adlnav_v1p3.xsd
47
- http://ltsc.ieee.org/xsd/LOM lom.xsd"
42
+ http://www.adlnet.org/xsd/adlnav_v1p3 adlnav_v1p3.xsd"
48
43
  xml:base="./">
49
44
 
50
45
  <metadata>
51
46
  <schema>ADL SCORM</schema>
52
47
  <schemaversion>2004 4th Edition</schemaversion>
53
- <lom:lom>
54
- <lom:general>
55
- <lom:title><lom:string language="${language}">${title}</lom:string></lom:title>
56
- <lom:description><lom:string language="${language}">${description}</lom:string></lom:description>
57
- <lom:language>${escapeXmlText(config.language)}</lom:language>
58
- </lom:general>
59
- <lom:lifecycle>
60
- <lom:version><lom:string>${escapeXmlText(config.version)}</lom:string></lom:version>
61
- <lom:contribute>
62
- <lom:role>
63
- <lom:source><lom:string>LOMv1.0</lom:string></lom:source>
64
- <lom:value><lom:string>author</lom:string></lom:value>
65
- </lom:role>
66
- <lom:entity>${author}</lom:entity>
67
- </lom:contribute>
68
- </lom:lifecycle>
69
- <lom:technical>
70
- <lom:format>text/html</lom:format>
71
- </lom:technical>
72
- </lom:lom>
73
48
  </metadata>
74
49
 
75
50
  <organizations default="org-1">