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.
- package/README.md +8 -1
- package/bin/cli.js +16 -0
- package/framework/css/components/accordions.css +117 -31
- package/framework/css/layouts/article.css +7 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +4 -2
- 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/interactions/drag-drop.js +4 -4
- package/framework/js/components/interactions/fill-in.js +7 -4
- package/framework/js/components/interactions/hotspot.js +2 -2
- package/framework/js/components/interactions/matching.js +4 -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/dev/runtime-linter.js +6 -2
- 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 +151 -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-linter.js +192 -2
- package/lib/build-packaging.js +47 -3
- package/lib/build.js +69 -8
- package/lib/cloud.js +40 -11
- package/lib/convert.js +30 -2
- package/lib/create.js +5 -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 +5 -1
- 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/course/slides/example-ui-showcase.js +1 -1
- 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
|
|
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
|
+
}
|
package/lib/preview-export.js
CHANGED
package/lib/preview-server.js
CHANGED
|
@@ -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 =
|
|
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 (
|
|
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
|
-
|
|
337
|
-
|
|
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
|
});
|
package/lib/project-utils.js
CHANGED
|
@@ -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 = {
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|