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
@@ -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
 
@@ -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 });