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
@@ -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">
@@ -10,37 +10,47 @@
10
10
  * Works for both SCORM 1.2 and 2004 - pipwerks auto-detects.
11
11
  */
12
12
 
13
+ import { escapeXmlAttribute, escapeXmlText, makeXmlId } from './xml-utils.js';
14
+
15
+ function proxyIdentifier(config) {
16
+ return makeXmlId(config.identifier || config.title, 'course');
17
+ }
18
+
13
19
  /**
14
20
  * Generate SCORM 1.2 proxy manifest
15
21
  * @param {Object} config - Course configuration
16
22
  * @returns {string} imsmanifest.xml content
17
23
  */
18
24
  export function generateScorm12ProxyManifest(config) {
19
- const identifier = config.identifier ||
20
- config.title.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
25
+ const identifier = proxyIdentifier(config);
26
+ const escapedIdentifier = escapeXmlAttribute(identifier);
21
27
 
22
28
  return `<?xml version="1.0" encoding="UTF-8"?>
23
29
  <!-- SCORM 1.2 Proxy Manifest - Generated by CourseCode -->
24
- <manifest identifier="${identifier}_proxy" version="1.0"
30
+ <manifest identifier="${escapedIdentifier}_proxy" version="1.0"
25
31
  xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
26
- xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2">
32
+ xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
33
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
34
+ xsi:schemaLocation="
35
+ http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd
36
+ http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd">
27
37
 
28
38
  <metadata>
29
39
  <schema>ADL SCORM</schema>
30
40
  <schemaversion>1.2</schemaversion>
31
41
  </metadata>
32
42
 
33
- <organizations default="org_${identifier}">
34
- <organization identifier="org_${identifier}">
35
- <title>${escapeXml(config.title)}</title>
36
- <item identifier="item_${identifier}" identifierref="res_${identifier}">
37
- <title>${escapeXml(config.title)}</title>
43
+ <organizations default="org_${escapedIdentifier}">
44
+ <organization identifier="org_${escapedIdentifier}">
45
+ <title>${escapeXmlText(config.title)}</title>
46
+ <item identifier="item_${escapedIdentifier}" identifierref="res_${escapedIdentifier}">
47
+ <title>${escapeXmlText(config.title)}</title>
38
48
  </item>
39
49
  </organization>
40
50
  </organizations>
41
51
 
42
52
  <resources>
43
- <resource identifier="res_${identifier}" type="webcontent" adlcp:scormtype="sco" href="proxy.html">
53
+ <resource identifier="res_${escapedIdentifier}" type="webcontent" adlcp:scormtype="sco" href="proxy.html">
44
54
  <file href="proxy.html"/>
45
55
  <file href="scorm-bridge.js"/>
46
56
  <file href="pipwerks.js"/>
@@ -55,34 +65,41 @@ export function generateScorm12ProxyManifest(config) {
55
65
  * @returns {string} imsmanifest.xml content
56
66
  */
57
67
  export function generateScorm2004ProxyManifest(config) {
58
- const identifier = config.identifier ||
59
- config.title.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
68
+ const identifier = proxyIdentifier(config);
69
+ const escapedIdentifier = escapeXmlAttribute(identifier);
60
70
 
61
71
  return `<?xml version="1.0" encoding="UTF-8"?>
62
72
  <!-- SCORM 2004 Proxy Manifest - Generated by CourseCode -->
63
- <manifest identifier="${identifier}_proxy" version="1.0"
73
+ <manifest identifier="${escapedIdentifier}_proxy" version="1.0"
64
74
  xmlns="http://www.imsglobal.org/xsd/imscp_v1p1"
65
75
  xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_v1p3"
66
76
  xmlns:adlseq="http://www.adlnet.org/xsd/adlseq_v1p3"
67
77
  xmlns:adlnav="http://www.adlnet.org/xsd/adlnav_v1p3"
68
- xmlns:imsss="http://www.imsglobal.org/xsd/imsss">
78
+ xmlns:imsss="http://www.imsglobal.org/xsd/imsss"
79
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
80
+ xsi:schemaLocation="
81
+ http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd
82
+ http://www.imsglobal.org/xsd/imsss imsss_v1p0.xsd
83
+ http://www.adlnet.org/xsd/adlcp_v1p3 adlcp_v1p3.xsd
84
+ http://www.adlnet.org/xsd/adlseq_v1p3 adlseq_v1p3.xsd
85
+ http://www.adlnet.org/xsd/adlnav_v1p3 adlnav_v1p3.xsd">
69
86
 
70
87
  <metadata>
71
88
  <schema>ADL SCORM</schema>
72
89
  <schemaversion>2004 4th Edition</schemaversion>
73
90
  </metadata>
74
91
 
75
- <organizations default="org_${identifier}">
76
- <organization identifier="org_${identifier}">
77
- <title>${escapeXml(config.title)}</title>
78
- <item identifier="item_${identifier}" identifierref="res_${identifier}">
79
- <title>${escapeXml(config.title)}</title>
92
+ <organizations default="org_${escapedIdentifier}">
93
+ <organization identifier="org_${escapedIdentifier}">
94
+ <title>${escapeXmlText(config.title)}</title>
95
+ <item identifier="item_${escapedIdentifier}" identifierref="res_${escapedIdentifier}">
96
+ <title>${escapeXmlText(config.title)}</title>
80
97
  </item>
81
98
  </organization>
82
99
  </organizations>
83
100
 
84
101
  <resources>
85
- <resource identifier="res_${identifier}" type="webcontent" adlcp:scormType="sco" href="proxy.html">
102
+ <resource identifier="res_${escapedIdentifier}" type="webcontent" adlcp:scormType="sco" href="proxy.html">
86
103
  <file href="proxy.html"/>
87
104
  <file href="scorm-bridge.js"/>
88
105
  <file href="pipwerks.js"/>
@@ -90,15 +107,3 @@ export function generateScorm2004ProxyManifest(config) {
90
107
  </resources>
91
108
  </manifest>`;
92
109
  }
93
-
94
- /**
95
- * Escape special XML characters
96
- */
97
- function escapeXml(str) {
98
- return String(str)
99
- .replace(/&/g, '&amp;')
100
- .replace(/</g, '&lt;')
101
- .replace(/>/g, '&gt;')
102
- .replace(/"/g, '&quot;')
103
- .replace(/'/g, '&apos;');
104
- }
@@ -12,3 +12,13 @@ export function escapeXmlAttribute(value) {
12
12
  .replace(/"/g, '&quot;')
13
13
  .replace(/'/g, '&apos;');
14
14
  }
15
+
16
+ /** Create a conservative ASCII XML ID (xsd:ID) from authored text. */
17
+ export function makeXmlId(value, fallback = 'course') {
18
+ const normalized = String(value ?? '')
19
+ .trim()
20
+ .replace(/[^A-Za-z0-9_.-]+/g, '-')
21
+ .replace(/^-+|-+$/g, '');
22
+ const candidate = normalized || fallback;
23
+ return /^[A-Za-z_]/.test(candidate) ? candidate : `${fallback}-${candidate}`;
24
+ }
package/lib/narration.js CHANGED
@@ -35,10 +35,10 @@ export async function narration(options = {}) {
35
35
  if (options.rebuildCache) args.push('--rebuild-cache');
36
36
 
37
37
  return new Promise((resolve, reject) => {
38
- const child = spawn('node', args, {
38
+ const child = spawn(process.execPath, args, {
39
39
  cwd: process.cwd(),
40
40
  stdio: 'inherit',
41
- shell: true
41
+ shell: false
42
42
  });
43
43
 
44
44
  child.on('close', (code) => {
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Portable HTML build support.
3
+ *
4
+ * The Vite plugin collapses generated JavaScript and CSS. The export command
5
+ * then embeds CourseCode's copied runtime assets and writes the final single
6
+ * HTML file outside the temporary build directory.
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { spawn } from 'child_process';
12
+ import { pathToFileURL } from 'url';
13
+ import { viteSingleFile } from 'vite-plugin-singlefile';
14
+ import { getMimeType, validateProject } from './project-utils.js';
15
+
16
+ const PORTABLE_BUILD_PREFIX = '.coursecode-portable-build-';
17
+
18
+ export function createPortableHtmlPlugin() {
19
+ return viteSingleFile({
20
+ removeViteModuleLoader: true,
21
+ useRecommendedBuildConfig: true
22
+ });
23
+ }
24
+
25
+ function runCommand(command, args, options = {}) {
26
+ return new Promise((resolve, reject) => {
27
+ const child = spawn(command, args, {
28
+ cwd: process.cwd(),
29
+ stdio: 'inherit',
30
+ shell: process.platform === 'win32',
31
+ ...options
32
+ });
33
+ child.on('error', reject);
34
+ child.on('close', code => {
35
+ if (code === 0) resolve();
36
+ else reject(new Error(`${command} ${args.join(' ')} failed with code ${code}`));
37
+ });
38
+ });
39
+ }
40
+
41
+ function walkFiles(rootDir) {
42
+ const files = [];
43
+ if (!fs.existsSync(rootDir)) return files;
44
+
45
+ for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
46
+ const absolute = path.join(rootDir, entry.name);
47
+ if (entry.isDirectory()) files.push(...walkFiles(absolute));
48
+ else if (entry.isFile()) files.push(absolute);
49
+ }
50
+ return files;
51
+ }
52
+
53
+ function toDataUrl(filePath) {
54
+ const mimeType = getMimeType(filePath);
55
+ const base64 = fs.readFileSync(filePath).toString('base64');
56
+ return `data:${mimeType};base64,${base64}`;
57
+ }
58
+
59
+ function addAssetAliases(map, canonicalPath, dataUrl) {
60
+ const canonical = canonicalPath.replace(/\\/g, '/').replace(/^\.\//, '');
61
+ map[canonical] = dataUrl;
62
+ }
63
+
64
+ export function collectPortableAssets(buildDir) {
65
+ const map = {};
66
+ const courseAssetsDir = path.join(buildDir, 'course', 'assets');
67
+ for (const filePath of walkFiles(courseAssetsDir)) {
68
+ const canonical = path.relative(buildDir, filePath).replace(/\\/g, '/');
69
+ addAssetAliases(map, canonical, toDataUrl(filePath));
70
+ }
71
+
72
+ for (const filename of ['_gallery-manifest.json']) {
73
+ const filePath = path.join(buildDir, filename);
74
+ if (fs.existsSync(filePath)) addAssetAliases(map, filename, toDataUrl(filePath));
75
+ }
76
+ return map;
77
+ }
78
+
79
+ function replaceDirectAssetReferences(html, assetMap) {
80
+ const aliases = [];
81
+ for (const canonical of Object.keys(assetMap)) {
82
+ aliases.push([`./${canonical}`, assetMap[canonical]], [canonical, assetMap[canonical]]);
83
+ if (canonical.startsWith('course/assets/')) {
84
+ const relative = canonical.slice('course/assets/'.length);
85
+ aliases.push([`./assets/${relative}`, assetMap[canonical]], [`assets/${relative}`, assetMap[canonical]]);
86
+ }
87
+ }
88
+ aliases.sort((a, b) => b[0].length - a[0].length);
89
+
90
+ let result = html;
91
+ for (const [alias, dataUrl] of aliases) {
92
+ result = result.split(alias).join(dataUrl);
93
+ }
94
+ return result;
95
+ }
96
+
97
+ export function assemblePortableHtml(indexHtml, assetMap) {
98
+ const serializedMap = JSON.stringify(assetMap).replace(/</g, '\\u003c');
99
+ const bootstrap = `<script>window.__COURSECODE_PORTABLE_ASSETS__=${serializedMap};</script>`;
100
+ let html = replaceDirectAssetReferences(indexHtml, assetMap);
101
+
102
+ const firstModule = html.search(/<script\b[^>]*type=["']module["']/i);
103
+ if (firstModule >= 0) {
104
+ html = `${html.slice(0, firstModule)}${bootstrap}\n${html.slice(firstModule)}`;
105
+ } else if (html.includes('</head>')) {
106
+ html = html.replace('</head>', `${bootstrap}\n</head>`);
107
+ } else {
108
+ throw new Error('Portable build is missing a document head or module entry point');
109
+ }
110
+
111
+ return html
112
+ .replace(/<title([^>]*)>(.*?)<\/title>/i, '<title$1>$2</title>')
113
+ .replace(/<html([^>]*)>/i, '<html$1 data-coursecode-portable="true">');
114
+ }
115
+
116
+ export function validatePortableHtml(html) {
117
+ const problems = [];
118
+ if (!html.includes('data-coursecode-portable="true"')) problems.push('portable marker missing');
119
+ if (!html.includes('__COURSECODE_PORTABLE_ASSETS__')) problems.push('embedded asset map missing');
120
+ if (/<script\b[^>]+src=["'](?!data:|https?:|\/\/)/i.test(html)) problems.push('external local script remains');
121
+ if (/<link\b[^>]+href=["'](?!data:|https?:|\/\/|#)/i.test(html)) problems.push('external local stylesheet remains');
122
+ if (/\b(?:src|href|poster)=["'](?:\.\/)?course\/assets\//i.test(html)) problems.push('unembedded course asset remains');
123
+ if (problems.length > 0) throw new Error(`Portable HTML validation failed: ${problems.join('; ')}`);
124
+ }
125
+
126
+ function safeFilename(value) {
127
+ return String(value || 'course')
128
+ .trim()
129
+ .replace(/[<>:"/\\|?*]/g, '-')
130
+ .replace(/\s+/g, '_') || 'course';
131
+ }
132
+
133
+ async function loadCourseConfig(coursePath) {
134
+ const url = `${pathToFileURL(path.join(coursePath, 'course-config.js')).href}?portable=${Date.now()}`;
135
+ const module = await import(url);
136
+ return module.courseConfig || module.default || {};
137
+ }
138
+
139
+ function assertPortableBuildSupport(rootDir) {
140
+ const configPath = path.join(rootDir, 'vite.config.js');
141
+ const configSource = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';
142
+ if (configSource.includes('COURSECODE_PORTABLE_HTML') && configSource.includes('COURSECODE_OUT_DIR')) return;
143
+
144
+ throw new Error(
145
+ 'This project\'s vite.config.js predates portable HTML export. ' +
146
+ 'Run "coursecode upgrade --configs", review the generated config backup, and try again.'
147
+ );
148
+ }
149
+
150
+ export async function exportPortableHtml(options = {}) {
151
+ const { coursePath } = validateProject();
152
+ const rootDir = process.cwd();
153
+ assertPortableBuildSupport(rootDir);
154
+ const config = await loadCourseConfig(coursePath);
155
+ const title = config.metadata?.title || 'Course';
156
+ const outputPath = path.resolve(options.output || `${safeFilename(title)}.html`);
157
+ const buildDirName = `${PORTABLE_BUILD_PREFIX}${process.pid}`;
158
+ const buildDir = path.join(rootDir, buildDirName);
159
+
160
+ if (fs.existsSync(buildDir)) fs.rmSync(buildDir, { recursive: true, force: true });
161
+
162
+ console.log('\nšŸ“„ Building portable HTML course...\n');
163
+ try {
164
+ const env = {
165
+ ...process.env,
166
+ COURSECODE_PORTABLE_HTML: 'true',
167
+ COURSECODE_OUT_DIR: buildDirName,
168
+ LMS_FORMAT: 'standalone'
169
+ };
170
+
171
+ if (options.lint === false) {
172
+ await runCommand('npx', ['vite', 'build'], { env });
173
+ } else {
174
+ await runCommand('npm', ['run', 'build'], { env });
175
+ }
176
+
177
+ const indexPath = path.join(buildDir, 'index.html');
178
+ if (!fs.existsSync(indexPath)) throw new Error('Portable build did not produce index.html');
179
+
180
+ const assetMap = collectPortableAssets(buildDir);
181
+ const html = assemblePortableHtml(fs.readFileSync(indexPath, 'utf8'), assetMap);
182
+ validatePortableHtml(html);
183
+
184
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
185
+ fs.writeFileSync(outputPath, html, 'utf8');
186
+
187
+ const sizeMB = fs.statSync(outputPath).size / 1024 / 1024;
188
+ const sizeWarning = sizeMB >= 50
189
+ ? '\n ⚠ Large portable file: test sharing limits and browser startup time.'
190
+ : '';
191
+ console.log(`\nāœ… Portable HTML exported\n\n Output: ${outputPath}\n Size: ${sizeMB.toFixed(2)} MB${sizeWarning}\n\n Open the file directly in a modern browser; no LMS or server is required.\n`);
192
+ return { outputPath, sizeBytes: fs.statSync(outputPath).size, assetCount: Object.keys(assetMap).length };
193
+ } finally {
194
+ if (fs.existsSync(buildDir)) fs.rmSync(buildDir, { recursive: true, force: true });
195
+ }
196
+ }
@@ -32,7 +32,7 @@ function runCommand(cmd, args, options = {}) {
32
32
  const child = spawn(cmd, args, {
33
33
  cwd: process.cwd(),
34
34
  stdio: 'inherit',
35
- shell: true,
35
+ shell: process.platform === 'win32',
36
36
  ...options
37
37
  });
38
38