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
package/lib/build.js
CHANGED
|
@@ -9,6 +9,69 @@ import { fileURLToPath } from 'url';
|
|
|
9
9
|
import { validateProject } from './project-utils.js';
|
|
10
10
|
|
|
11
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
export function snapshotZipFiles(directory) {
|
|
14
|
+
const snapshot = new Map();
|
|
15
|
+
if (!fs.existsSync(directory)) return snapshot;
|
|
16
|
+
|
|
17
|
+
for (const filename of fs.readdirSync(directory)) {
|
|
18
|
+
if (!filename.endsWith('.zip')) continue;
|
|
19
|
+
const stats = fs.statSync(path.join(directory, filename));
|
|
20
|
+
snapshot.set(filename, { mtimeMs: stats.mtimeMs, size: stats.size });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return snapshot;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function findProducedZipFiles(directory, before = new Map()) {
|
|
27
|
+
if (!fs.existsSync(directory)) return [];
|
|
28
|
+
|
|
29
|
+
return fs.readdirSync(directory)
|
|
30
|
+
.filter(filename => filename.endsWith('.zip'))
|
|
31
|
+
.map(filename => {
|
|
32
|
+
const stats = fs.statSync(path.join(directory, filename));
|
|
33
|
+
return { filename, mtimeMs: stats.mtimeMs, size: stats.size };
|
|
34
|
+
})
|
|
35
|
+
.filter(file => {
|
|
36
|
+
const previous = before.get(file.filename);
|
|
37
|
+
return !previous || previous.mtimeMs !== file.mtimeMs || previous.size !== file.size;
|
|
38
|
+
})
|
|
39
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs || a.filename.localeCompare(b.filename))
|
|
40
|
+
.map(file => file.filename);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function detectBuiltFormat(distDirectory, requestedFormat) {
|
|
44
|
+
const indexPath = path.join(distDirectory, 'index.html');
|
|
45
|
+
|
|
46
|
+
if (fs.existsSync(indexPath)) {
|
|
47
|
+
const html = fs.readFileSync(indexPath, 'utf8');
|
|
48
|
+
const match = html.match(/<meta\s+name=["']lms-format["']\s+content=["']([^"']+)["']/i);
|
|
49
|
+
if (match?.[1]) return match[1];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (fs.existsSync(path.join(distDirectory, 'cmi5.xml'))) return 'cmi5';
|
|
53
|
+
if (fs.existsSync(path.join(distDirectory, 'lti-tool-config.json'))) return 'lti';
|
|
54
|
+
|
|
55
|
+
return requestedFormat || 'scorm2004';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatBuildOutput(format, zipFiles = []) {
|
|
59
|
+
const normalizedFormat = format || 'scorm2004';
|
|
60
|
+
const displayNames = {
|
|
61
|
+
scorm2004: 'SCORM 2004',
|
|
62
|
+
'scorm1.2': 'SCORM 1.2',
|
|
63
|
+
cmi5: 'cmi5',
|
|
64
|
+
lti: 'LTI 1.3',
|
|
65
|
+
'scorm2004-proxy': 'SCORM 2004 proxy',
|
|
66
|
+
'scorm1.2-proxy': 'SCORM 1.2 proxy',
|
|
67
|
+
'cmi5-remote': 'cmi5 remote'
|
|
68
|
+
};
|
|
69
|
+
const displayName = displayNames[normalizedFormat] || normalizedFormat;
|
|
70
|
+
const archives = zipFiles.map(filename => ` - ${filename} Ready for LMS upload`).join('\n');
|
|
71
|
+
|
|
72
|
+
return ` Output:\n - dist/ ${displayName} package files${archives ? `\n${archives}` : ''}\n\n To test locally, launch dist/ or the archive in a ${displayName}-compatible LMS or conformance tool.`;
|
|
73
|
+
}
|
|
74
|
+
|
|
12
75
|
/**
|
|
13
76
|
* Run a command and return a promise
|
|
14
77
|
*/
|
|
@@ -17,7 +80,7 @@ function runCommand(cmd, args, options = {}) {
|
|
|
17
80
|
const child = spawn(cmd, args, {
|
|
18
81
|
cwd: process.cwd(),
|
|
19
82
|
stdio: 'inherit',
|
|
20
|
-
shell:
|
|
83
|
+
shell: process.platform === 'win32',
|
|
21
84
|
env: options.env || process.env,
|
|
22
85
|
...options
|
|
23
86
|
});
|
|
@@ -42,6 +105,7 @@ export async function build(options = {}) {
|
|
|
42
105
|
`);
|
|
43
106
|
|
|
44
107
|
const startTime = Date.now();
|
|
108
|
+
const zipSnapshot = snapshotZipFiles(process.cwd());
|
|
45
109
|
|
|
46
110
|
try {
|
|
47
111
|
// Build environment — pass lib dir so vite.config.js can resolve coursecode utilities
|
|
@@ -64,17 +128,14 @@ export async function build(options = {}) {
|
|
|
64
128
|
|
|
65
129
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
66
130
|
|
|
67
|
-
|
|
68
|
-
const
|
|
131
|
+
const zipFiles = findProducedZipFiles(process.cwd(), zipSnapshot);
|
|
132
|
+
const builtFormat = detectBuiltFormat(path.join(process.cwd(), 'dist'), options.format);
|
|
133
|
+
const output = formatBuildOutput(builtFormat, zipFiles);
|
|
69
134
|
|
|
70
135
|
console.log(`
|
|
71
136
|
✅ Build completed in ${elapsed}s
|
|
72
137
|
|
|
73
|
-
|
|
74
|
-
- dist/ SCORM package files
|
|
75
|
-
${zipFiles.length > 0 ? `- ${zipFiles[zipFiles.length - 1]} Ready for LMS upload` : ''}
|
|
76
|
-
|
|
77
|
-
To test locally, load dist/ directory in a SCORM testing tool.
|
|
138
|
+
${output}
|
|
78
139
|
`);
|
|
79
140
|
|
|
80
141
|
} catch (error) {
|
package/lib/cloud.js
CHANGED
|
@@ -11,7 +11,7 @@ import crypto from 'crypto';
|
|
|
11
11
|
import fs from 'fs';
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import os from 'os';
|
|
14
|
-
import {
|
|
14
|
+
import { execFile } from 'child_process';
|
|
15
15
|
import readline from 'readline';
|
|
16
16
|
import { fileURLToPath } from 'url';
|
|
17
17
|
|
|
@@ -427,10 +427,23 @@ function handleResponseError(status, body) {
|
|
|
427
427
|
*/
|
|
428
428
|
function openBrowser(url) {
|
|
429
429
|
const platform = process.platform;
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
430
|
+
let command;
|
|
431
|
+
let args;
|
|
432
|
+
if (platform === 'darwin') {
|
|
433
|
+
command = 'open';
|
|
434
|
+
args = [url];
|
|
435
|
+
} else if (platform === 'win32') {
|
|
436
|
+
command = 'rundll32';
|
|
437
|
+
args = ['url.dll,FileProtocolHandler', url];
|
|
438
|
+
} else {
|
|
439
|
+
command = 'xdg-open';
|
|
440
|
+
args = [url];
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const child = execFile(command, args);
|
|
444
|
+
child.on('error', (error) => {
|
|
445
|
+
console.warn(` ⚠ Could not open the browser automatically: ${error.message}`);
|
|
446
|
+
});
|
|
434
447
|
}
|
|
435
448
|
|
|
436
449
|
/**
|
|
@@ -987,9 +1000,17 @@ export async function deploy(options = {}) {
|
|
|
987
1000
|
// Reconcile local sourceType with cloud truth (handles unlink-via-dashboard)
|
|
988
1001
|
try {
|
|
989
1002
|
const statusData = JSON.parse(await statusRes.text());
|
|
990
|
-
const
|
|
1003
|
+
const serverGithubRepo = statusData.source?.githubRepo || statusData.github_repo;
|
|
991
1004
|
const localRc = readRcConfig();
|
|
992
|
-
if (
|
|
1005
|
+
if (serverGithubRepo && (
|
|
1006
|
+
localRc?.sourceType !== 'github' || localRc?.githubRepo !== serverGithubRepo
|
|
1007
|
+
)) {
|
|
1008
|
+
updateRcConfig((rc) => {
|
|
1009
|
+
rc.sourceType = 'github';
|
|
1010
|
+
rc.githubRepo = serverGithubRepo;
|
|
1011
|
+
return rc;
|
|
1012
|
+
});
|
|
1013
|
+
} else if (localRc?.sourceType === 'github' && !serverGithubRepo) {
|
|
993
1014
|
updateRcConfig((rc) => {
|
|
994
1015
|
delete rc.sourceType;
|
|
995
1016
|
delete rc.githubRepo;
|
|
@@ -1508,8 +1529,16 @@ export async function status(options = {}) {
|
|
|
1508
1529
|
|
|
1509
1530
|
// Reconcile local sourceType with cloud truth (handles unlink-via-dashboard)
|
|
1510
1531
|
const localRc = readRcConfig();
|
|
1511
|
-
const
|
|
1512
|
-
if (
|
|
1532
|
+
const serverGithubRepo = data.source?.githubRepo || data.github_repo;
|
|
1533
|
+
if (serverGithubRepo && (
|
|
1534
|
+
localRc?.sourceType !== 'github' || localRc?.githubRepo !== serverGithubRepo
|
|
1535
|
+
)) {
|
|
1536
|
+
updateRcConfig((rc) => {
|
|
1537
|
+
rc.sourceType = 'github';
|
|
1538
|
+
rc.githubRepo = serverGithubRepo;
|
|
1539
|
+
return rc;
|
|
1540
|
+
});
|
|
1541
|
+
} else if (localRc?.sourceType === 'github' && !serverGithubRepo) {
|
|
1513
1542
|
updateRcConfig((rc) => {
|
|
1514
1543
|
delete rc.sourceType;
|
|
1515
1544
|
delete rc.githubRepo;
|
|
@@ -1527,9 +1556,9 @@ export async function status(options = {}) {
|
|
|
1527
1556
|
|
|
1528
1557
|
console.log(`\n${data.slug} — ${data.name} (${data.orgName})\n`);
|
|
1529
1558
|
|
|
1530
|
-
const sourceType = data.source?.type || data.source_type;
|
|
1531
1559
|
const githubRepo = data.source?.githubRepo || data.github_repo;
|
|
1532
|
-
|
|
1560
|
+
const sourceType = data.source?.type || data.source_type;
|
|
1561
|
+
if (githubRepo) {
|
|
1533
1562
|
console.log(`Source: GitHub — ${githubRepo}`);
|
|
1534
1563
|
console.log(' production=github | preview=cli+github');
|
|
1535
1564
|
} else if (sourceType) {
|
package/lib/convert.js
CHANGED
|
@@ -238,13 +238,41 @@ async function convertFile(filePath) {
|
|
|
238
238
|
async function convertDocx(filePath) {
|
|
239
239
|
const mammoth = await import('mammoth');
|
|
240
240
|
const result = await mammoth.convertToMarkdown({ path: filePath });
|
|
241
|
+
const sanitized = replaceEmbeddedDataImages(result.value);
|
|
242
|
+
const warnings = result.messages.filter(m => m.type === 'warning');
|
|
243
|
+
|
|
244
|
+
if (sanitized.omittedImageCount > 0) {
|
|
245
|
+
warnings.push({
|
|
246
|
+
type: 'warning',
|
|
247
|
+
message: `Omitted ${sanitized.omittedImageCount} embedded image payload(s). Review the source document for visual content.`
|
|
248
|
+
});
|
|
249
|
+
}
|
|
241
250
|
|
|
242
251
|
return {
|
|
243
|
-
markdown:
|
|
244
|
-
warnings
|
|
252
|
+
markdown: sanitized.markdown,
|
|
253
|
+
warnings
|
|
245
254
|
};
|
|
246
255
|
}
|
|
247
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Replace mammoth's inline base64 image output with compact review markers.
|
|
259
|
+
* Reference Markdown is consumed by AI authoring tools, where multi-megabyte
|
|
260
|
+
* data URIs waste context and do not preserve useful visual meaning.
|
|
261
|
+
*/
|
|
262
|
+
export function replaceEmbeddedDataImages(markdown) {
|
|
263
|
+
let omittedImageCount = 0;
|
|
264
|
+
const sanitized = markdown.replace(
|
|
265
|
+
/!\[([^\]]*)\]\(data:image\/[^;]+;base64,[^)]+\)/gi,
|
|
266
|
+
(_match, altText) => {
|
|
267
|
+
omittedImageCount++;
|
|
268
|
+
const label = altText?.trim() ? `: ${altText.trim()}` : '';
|
|
269
|
+
return `*[Embedded image omitted${label}. Review the source document for visual content.]*`;
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
return { markdown: sanitized, omittedImageCount };
|
|
274
|
+
}
|
|
275
|
+
|
|
248
276
|
/**
|
|
249
277
|
* Convert PPTX to markdown using node-pptx-parser
|
|
250
278
|
*/
|
package/lib/create.js
CHANGED
|
@@ -172,7 +172,7 @@ function npmInstall(cwd) {
|
|
|
172
172
|
const child = spawn(command, args, {
|
|
173
173
|
cwd,
|
|
174
174
|
stdio: 'inherit',
|
|
175
|
-
shell: !npmCli
|
|
175
|
+
shell: !npmCli && process.platform === 'win32'
|
|
176
176
|
});
|
|
177
177
|
|
|
178
178
|
child.on('close', (code) => {
|
|
@@ -182,6 +182,7 @@ function npmInstall(cwd) {
|
|
|
182
182
|
reject(new Error(`npm install failed with code ${code}`));
|
|
183
183
|
}
|
|
184
184
|
});
|
|
185
|
+
child.on('error', reject);
|
|
185
186
|
});
|
|
186
187
|
}
|
|
187
188
|
|
|
@@ -193,7 +194,7 @@ function gitInit(cwd) {
|
|
|
193
194
|
const child = spawn('git', ['init'], {
|
|
194
195
|
cwd,
|
|
195
196
|
stdio: 'pipe',
|
|
196
|
-
shell:
|
|
197
|
+
shell: false
|
|
197
198
|
});
|
|
198
199
|
|
|
199
200
|
child.on('close', (code) => {
|
|
@@ -203,6 +204,7 @@ function gitInit(cwd) {
|
|
|
203
204
|
reject(new Error(`git init failed with code ${code}`));
|
|
204
205
|
}
|
|
205
206
|
});
|
|
207
|
+
child.on('error', reject);
|
|
206
208
|
});
|
|
207
209
|
}
|
|
208
210
|
|
|
@@ -384,7 +386,7 @@ export async function create(name, options = {}) {
|
|
|
384
386
|
const child = spawn(command, args, {
|
|
385
387
|
cwd: targetDir,
|
|
386
388
|
stdio: 'inherit',
|
|
387
|
-
shell: !npxCli
|
|
389
|
+
shell: !npxCli && process.platform === 'win32'
|
|
388
390
|
});
|
|
389
391
|
|
|
390
392
|
child.on('error', () => {
|
package/lib/dev.js
CHANGED
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">
|