coursecode 0.1.56 ā 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.
- package/README.md +8 -1
- package/bin/cli.js +16 -0
- package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
- package/framework/docs/USER_GUIDE.md +14 -0
- package/framework/js/app/AppActions.js +17 -23
- package/framework/js/app/AppUI.js +7 -4
- package/framework/js/assessment/AssessmentActions.js +16 -15
- package/framework/js/assessment/AssessmentFactory.js +44 -15
- package/framework/js/automation/api-engagement.js +6 -3
- package/framework/js/components/ui-components/audio-player.js +30 -8
- package/framework/js/components/ui-components/collapse.js +4 -2
- package/framework/js/components/ui-components/dropdown.js +7 -3
- package/framework/js/components/ui-components/embed-frame.js +5 -1
- package/framework/js/components/ui-components/flip-card.js +12 -6
- package/framework/js/components/ui-components/interactive-image.js +28 -52
- package/framework/js/components/ui-components/lightbox.js +19 -5
- package/framework/js/components/ui-components/modal.js +20 -7
- package/framework/js/components/ui-components/notifications.js +8 -3
- package/framework/js/components/ui-components/video-player.js +46 -20
- package/framework/js/core/event-bus.js +31 -28
- package/framework/js/drivers/cmi5-driver.js +186 -83
- package/framework/js/drivers/driver-factory.js +7 -1
- package/framework/js/drivers/driver-interface.js +1 -1
- package/framework/js/drivers/http-driver-base.js +6 -0
- package/framework/js/drivers/lti-driver.js +112 -47
- package/framework/js/drivers/proxy-driver.js +347 -49
- package/framework/js/drivers/scorm-12-driver.js +213 -130
- package/framework/js/drivers/scorm-2004-driver.js +32 -19
- package/framework/js/drivers/scorm-driver-base.js +12 -0
- package/framework/js/drivers/standalone-driver.js +139 -0
- package/framework/js/engagement/engagement-trackers.js +38 -13
- package/framework/js/engagement/requirement-strategies.js +5 -1
- package/framework/js/main.js +7 -0
- package/framework/js/managers/assessment-manager.js +16 -1
- package/framework/js/managers/audio-manager.js +50 -16
- package/framework/js/managers/flag-manager.js +5 -1
- package/framework/js/managers/objective-manager.js +15 -7
- package/framework/js/managers/score-manager.js +27 -6
- package/framework/js/managers/video-manager.js +149 -70
- package/framework/js/navigation/NavigationActions.js +18 -4
- package/framework/js/navigation/document-gallery.js +7 -2
- package/framework/js/navigation/navigation-validators.js +3 -2
- package/framework/js/state/lms-connection.js +41 -16
- package/framework/js/state/state-commits.js +10 -5
- package/framework/js/state/state-manager.js +95 -9
- package/framework/js/state/state-validation.js +44 -11
- package/framework/js/utilities/course-helpers.js +3 -2
- package/framework/js/utilities/media-utils.js +28 -0
- package/framework/js/utilities/portable-assets.js +131 -0
- package/framework/js/utilities/ui-initializer.js +79 -5
- package/framework/js/utilities/utilities.js +32 -20
- package/framework/js/utilities/view-manager.js +16 -1
- package/framework/js/validation/scorm-validators.js +69 -0
- package/framework/version.json +2 -2
- package/lib/authoring-api.js +37 -18
- package/lib/build-packaging.js +49 -5
- package/lib/build.js +1 -1
- package/lib/cloud.js +40 -11
- package/lib/convert.js +30 -2
- package/lib/create.js +28 -3
- package/lib/dev.js +1 -1
- package/lib/headless-browser.js +66 -14
- package/lib/manifest/cmi5-manifest.js +26 -4
- package/lib/manifest/lti-tool-config.js +7 -2
- package/lib/manifest/manifest-factory.d.ts +1 -1
- package/lib/manifest/manifest-factory.js +3 -2
- package/lib/manifest/scorm-12-manifest.js +2 -2
- package/lib/manifest/scorm-2004-manifest.js +3 -28
- package/lib/manifest/scorm-proxy-manifest.js +37 -32
- package/lib/manifest/xml-utils.js +10 -0
- package/lib/narration.js +2 -2
- package/lib/portable-html.js +196 -0
- package/lib/preview-export.js +1 -1
- package/lib/preview-server.js +27 -6
- package/lib/project-utils.js +9 -1
- package/lib/proxy-templates/proxy.html +4 -1
- package/lib/proxy-templates/scorm-bridge.js +82 -10
- package/lib/scaffold.js +9 -4
- package/lib/stub-player/lms-api.js +63 -8
- package/package.json +6 -2
- package/schemas/adlcp_rootv1p2.xsd +110 -0
- package/schemas/coursecode_scorm12_package.xsd +5 -0
- package/schemas/coursecode_scorm2004_package.xsd +8 -0
- package/schemas/imscp_rootv1p1p2.xsd +304 -0
- package/template/package.json +1 -1
- package/template/vite.config.js +50 -22
package/lib/headless-browser.js
CHANGED
|
@@ -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
|
|
26
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
27
|
-
|
|
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';
|
|
@@ -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
|
|
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
|
|
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
|
|
20
|
-
|
|
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="${
|
|
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_${
|
|
34
|
-
<organization identifier="org_${
|
|
35
|
-
<title>${
|
|
36
|
-
<item identifier="item_${
|
|
37
|
-
<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_${
|
|
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
|
|
59
|
-
|
|
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="${
|
|
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_${
|
|
76
|
-
<organization identifier="org_${
|
|
77
|
-
<title>${
|
|
78
|
-
<item identifier="item_${
|
|
79
|
-
<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_${
|
|
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, '&')
|
|
100
|
-
.replace(/</g, '<')
|
|
101
|
-
.replace(/>/g, '>')
|
|
102
|
-
.replace(/"/g, '"')
|
|
103
|
-
.replace(/'/g, ''');
|
|
104
|
-
}
|
|
@@ -12,3 +12,13 @@ export function escapeXmlAttribute(value) {
|
|
|
12
12
|
.replace(/"/g, '"')
|
|
13
13
|
.replace(/'/g, ''');
|
|
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(
|
|
38
|
+
const child = spawn(process.execPath, args, {
|
|
39
39
|
cwd: process.cwd(),
|
|
40
40
|
stdio: 'inherit',
|
|
41
|
-
shell:
|
|
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
|
+
}
|