scorm-kit 0.2.0
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/LICENSE +21 -0
- package/README.md +109 -0
- package/bin/scorm-kit.js +62 -0
- package/package.json +32 -0
- package/src/a11y/a11y.js +401 -0
- package/src/cmi5/cmi5.js +561 -0
- package/src/confine.js +20 -0
- package/src/diff/diff.js +277 -0
- package/src/i18n/i18n-cli.js +222 -0
- package/src/i18n/runtime/i18n.js +168 -0
- package/src/lint/lint.js +323 -0
- package/src/mock/mock.js +197 -0
- package/src/mock/web/mock-lms.css +57 -0
- package/src/mock/web/mock-lms.html +53 -0
- package/src/mock/web/mock-lms.js +328 -0
- package/src/privacy/privacy.js +535 -0
- package/src/report/report.js +148 -0
- package/src/rum/rum-cli.js +144 -0
- package/src/rum/runtime/rum.js +156 -0
package/src/cmi5/cmi5.js
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* scorm-kit cmi5 — cmi5 package validator, linter, and SCORM-to-cmi5 wrapper.
|
|
4
|
+
*
|
|
5
|
+
* cmi5 (ADL, 2016/2023) is the spec that replaces SCORM 1.2 for new builds:
|
|
6
|
+
* SCORM-style "launch and handshake" + xAPI-based tracking. Most LMS RFPs in
|
|
7
|
+
* 2026 require cmi5 support. This subcommand:
|
|
8
|
+
*
|
|
9
|
+
* scorm-kit cmi5 validate <package.zip|dir>
|
|
10
|
+
* Validates a cmi5 package against the AICC cmi5 v1 spec:
|
|
11
|
+
* - cmi5.xml present at archive root
|
|
12
|
+
* - <courseStructure> root, valid xmlns
|
|
13
|
+
* - <course id="..."> with IRI-shaped id
|
|
14
|
+
* - At least one <au> (Assignable Unit)
|
|
15
|
+
* - Each AU has id (IRI), launchMethod, moveOn, url, title (langstring)
|
|
16
|
+
* - launchMethod ∈ { OwnWindow, AnyWindow }
|
|
17
|
+
* - moveOn ∈ { Passed, Completed, CompletedAndPassed, CompletedOrPassed, NotApplicable }
|
|
18
|
+
* - masteryScore (if present) is 0.0–1.0
|
|
19
|
+
* - activityType (if present) is a IRI
|
|
20
|
+
* - All AU launch URLs resolve within the package
|
|
21
|
+
*
|
|
22
|
+
* scorm-kit cmi5 lint <package.zip|dir>
|
|
23
|
+
* Same as validate, plus stylistic / interop checks:
|
|
24
|
+
* - course id and AU ids unique
|
|
25
|
+
* - title and description present in at least 'en'
|
|
26
|
+
* - no AU duplicated url
|
|
27
|
+
* - duration follows ISO-8601
|
|
28
|
+
* - extensions namespaced (https://...)
|
|
29
|
+
* - waivedMoveOnConditions consistent with moveOn
|
|
30
|
+
*
|
|
31
|
+
* scorm-kit cmi5 convert <scorm-package.zip> --out <cmi5-package.zip>
|
|
32
|
+
* Wraps a SCORM 1.2 package as cmi5: emits cmi5.xml referencing the
|
|
33
|
+
* SCORM SCO's launch HTML as the cmi5 AU. The SCORM API is left in
|
|
34
|
+
* place so the package degrades gracefully if launched from a SCORM-
|
|
35
|
+
* only LMS. This is the "dual-stream" pattern most teams now use:
|
|
36
|
+
* SCORM for HR completion records, cmi5/xAPI for behavioural data.
|
|
37
|
+
*
|
|
38
|
+
* Exit codes: 0 = clean, 1 = warnings only, 2 = errors.
|
|
39
|
+
*/
|
|
40
|
+
"use strict";
|
|
41
|
+
|
|
42
|
+
var fs = require("fs");
|
|
43
|
+
var path = require("path");
|
|
44
|
+
var os = require("os");
|
|
45
|
+
var { spawnSync } = require("child_process");
|
|
46
|
+
var verifyConfinement = require("../confine");
|
|
47
|
+
|
|
48
|
+
// ---------- rules table ----------------------------------------------------
|
|
49
|
+
|
|
50
|
+
var RULES = {
|
|
51
|
+
// structural (errors)
|
|
52
|
+
"cmi5-missing": { sev: "error", msg: "cmi5.xml not found at package root" },
|
|
53
|
+
"cmi5-bad-xml": { sev: "error", msg: "cmi5.xml is not well-formed XML" },
|
|
54
|
+
"cmi5-bad-root": { sev: "error", msg: "root element must be <courseStructure>" },
|
|
55
|
+
"cmi5-bad-namespace": { sev: "error", msg: "courseStructure xmlns must be https://w3id.org/xapi/profiles/cmi5/v1" },
|
|
56
|
+
"course-missing": { sev: "error", msg: "<course> element missing" },
|
|
57
|
+
"course-no-id": { sev: "error", msg: "<course id=...> missing or empty" },
|
|
58
|
+
"course-id-not-iri": { sev: "error", msg: "<course id=...> must be an IRI (https://... or urn:...)" },
|
|
59
|
+
"course-no-title": { sev: "error", msg: "<course> must contain at least one <title><langstring/></title>" },
|
|
60
|
+
"au-none": { sev: "error", msg: "no <au> (Assignable Unit) found" },
|
|
61
|
+
"au-no-id": { sev: "error", msg: "<au id=...> missing or empty" },
|
|
62
|
+
"au-id-not-iri": { sev: "error", msg: "<au id=...> must be an IRI" },
|
|
63
|
+
"au-no-launchmethod": { sev: "error", msg: "<au launchMethod=...> required" },
|
|
64
|
+
"au-bad-launchmethod": { sev: "error", msg: "launchMethod must be OwnWindow or AnyWindow" },
|
|
65
|
+
"au-no-moveon": { sev: "error", msg: "<au moveOn=...> required" },
|
|
66
|
+
"au-bad-moveon": { sev: "error", msg: "moveOn must be Passed | Completed | CompletedAndPassed | CompletedOrPassed | NotApplicable" },
|
|
67
|
+
"au-no-url": { sev: "error", msg: "<au> must contain <url> element" },
|
|
68
|
+
"au-url-not-found": { sev: "error", msg: "<au> launch URL does not resolve inside the package" },
|
|
69
|
+
"au-no-title": { sev: "error", msg: "<au> must contain at least one <title><langstring/></title>" },
|
|
70
|
+
"au-mastery-out-of-range": { sev: "error", msg: "masteryScore must be between 0.0 and 1.0" },
|
|
71
|
+
"au-activity-type-not-iri": { sev: "error", msg: "activityType must be an IRI" },
|
|
72
|
+
|
|
73
|
+
// interop / style (warnings — lint only)
|
|
74
|
+
"lint-id-duplicate": { sev: "error", msg: "duplicate id within course structure (must be unique)" },
|
|
75
|
+
"lint-url-duplicate": { sev: "warn", msg: "two AUs share the same launch URL" },
|
|
76
|
+
"lint-no-en-title": { sev: "warn", msg: "no 'en' langstring on title — many LMSs default to en and will show blank" },
|
|
77
|
+
"lint-no-description": { sev: "info", msg: "no <description> on this element (recommended for LMS catalogues)" },
|
|
78
|
+
"lint-duration-not-iso": { sev: "warn", msg: "duration is not ISO-8601 (e.g. PT15M)" },
|
|
79
|
+
"lint-extension-bad-iri": { sev: "warn", msg: "extension key should be a resolvable https:// IRI" },
|
|
80
|
+
"lint-waived-without-mco": { sev: "warn", msg: "waivedMoveOnConditions ignored unless moveOn is CompletedAndPassed or CompletedOrPassed" },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
var LAUNCH_METHODS = new Set(["OwnWindow", "AnyWindow"]);
|
|
84
|
+
var MOVE_ON_VALUES = new Set(["Passed", "Completed", "CompletedAndPassed", "CompletedOrPassed", "NotApplicable"]);
|
|
85
|
+
var CMI5_NS = "https://w3id.org/xapi/profiles/cmi5/v1";
|
|
86
|
+
|
|
87
|
+
// ---------- arg parsing ----------------------------------------------------
|
|
88
|
+
|
|
89
|
+
var USAGE = [
|
|
90
|
+
"Usage: scorm-kit cmi5 <validate|lint|convert> <package> [options]",
|
|
91
|
+
"",
|
|
92
|
+
"Commands:",
|
|
93
|
+
" validate <package> structural validation (errors only)",
|
|
94
|
+
" lint <package> validate + interop / style warnings",
|
|
95
|
+
" convert <scorm.zip> wrap a SCORM 1.2 package as cmi5",
|
|
96
|
+
"",
|
|
97
|
+
"Options:",
|
|
98
|
+
" --out <path> output path (convert only)",
|
|
99
|
+
" --json emit findings as JSON",
|
|
100
|
+
" --no-info suppress info findings",
|
|
101
|
+
" --no-color plain output",
|
|
102
|
+
"",
|
|
103
|
+
"Exit codes: 0 = clean, 1 = warnings, 2 = errors.",
|
|
104
|
+
].join("\n");
|
|
105
|
+
|
|
106
|
+
function usage(code) {
|
|
107
|
+
if (code === 0) { console.log(USAGE); process.exit(0); }
|
|
108
|
+
console.error(USAGE);
|
|
109
|
+
process.exit(2);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseArgs(argv) {
|
|
113
|
+
var args = argv.slice(2);
|
|
114
|
+
if (args.length === 0 || args[0] === "-h" || args[0] === "--help") usage(0);
|
|
115
|
+
var mode = args.shift();
|
|
116
|
+
if (mode !== "validate" && mode !== "lint" && mode !== "convert") {
|
|
117
|
+
console.error("cmi5: unknown mode '" + mode + "'");
|
|
118
|
+
usage(2);
|
|
119
|
+
}
|
|
120
|
+
var pkg = null, out = null, json = false, infoOff = false, color = true;
|
|
121
|
+
for (var i = 0; i < args.length; i++) {
|
|
122
|
+
var a = args[i];
|
|
123
|
+
if (a === "--out") out = args[++i];
|
|
124
|
+
else if (a === "--json") json = true;
|
|
125
|
+
else if (a === "--no-info") infoOff = true;
|
|
126
|
+
else if (a === "--no-color") color = false;
|
|
127
|
+
else if (a === "-h" || a === "--help") usage(0);
|
|
128
|
+
else if (a[0] === "-") { console.error("unknown flag: " + a); usage(2); }
|
|
129
|
+
else if (pkg === null) pkg = a;
|
|
130
|
+
else { console.error("unexpected arg: " + a); usage(2); }
|
|
131
|
+
}
|
|
132
|
+
if (!pkg) { console.error("cmi5: package required"); usage(2); }
|
|
133
|
+
if (mode === "convert" && !out) out = pkg.replace(/\.zip$/i, "") + "-cmi5.zip";
|
|
134
|
+
return { mode: mode, pkg: pkg, out: out, json: json, infoOff: infoOff, color: color };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------- io helpers -----------------------------------------------------
|
|
138
|
+
|
|
139
|
+
function unzipToTemp(zipPath, tag) {
|
|
140
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), "scorm-cmi5-" + (tag || "") + "-"));
|
|
141
|
+
var r = spawnSync("unzip", ["-q", "-o", zipPath, "-d", tmp]);
|
|
142
|
+
if (r.status !== 0) throw new Error("unzip: " + r.stderr.toString());
|
|
143
|
+
verifyConfinement(tmp);
|
|
144
|
+
return tmp;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function walk(dir, acc, base) {
|
|
148
|
+
acc = acc || [];
|
|
149
|
+
base = base || dir;
|
|
150
|
+
for (var name of fs.readdirSync(dir)) {
|
|
151
|
+
var p = path.join(dir, name);
|
|
152
|
+
var st = fs.statSync(p);
|
|
153
|
+
if (st.isDirectory()) walk(p, acc, base);
|
|
154
|
+
else acc.push(path.relative(base, p));
|
|
155
|
+
}
|
|
156
|
+
return acc;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function find(rule, line, detail) {
|
|
160
|
+
return { rule: rule, sev: RULES[rule].sev, msg: RULES[rule].msg, line: line || 0, detail: detail || "" };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------- tiny XML reader ------------------------------------------------
|
|
164
|
+
//
|
|
165
|
+
// cmi5.xml is small (<10KB typical) and the schema is shallow. A regex-based
|
|
166
|
+
// reader is enough — and avoids the dep tree of a full XML parser. We
|
|
167
|
+
// extract: root element name+attrs, every <course>, every <au>, every
|
|
168
|
+
// <title><langstring lang="..">..</langstring></title>, every <url>, and
|
|
169
|
+
// every attribute on each <au>.
|
|
170
|
+
|
|
171
|
+
function readXml(text) {
|
|
172
|
+
// Reject if not well-formed (very loose check — unbalanced tags only)
|
|
173
|
+
// We don't try to be a real validator; the well-formedness check below
|
|
174
|
+
// catches the common copy/paste failures.
|
|
175
|
+
var openCount = (text.match(/<[a-zA-Z][^!?>]*[^/]>/g) || []).length;
|
|
176
|
+
var closeCount = (text.match(/<\/[a-zA-Z][^>]*>/g) || []).length;
|
|
177
|
+
var selfCount = (text.match(/<[a-zA-Z][^>]*\/>/g) || []).length;
|
|
178
|
+
if (openCount !== closeCount) {
|
|
179
|
+
return { ok: false, reason: "unbalanced tags (open=" + openCount + " close=" + closeCount + ")" };
|
|
180
|
+
}
|
|
181
|
+
return { ok: true, text: text, selfCount: selfCount };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function rootEl(text) {
|
|
185
|
+
var m = text.match(/<([a-zA-Z][\w:\-]*)\b([^>]*)>/);
|
|
186
|
+
if (!m) return null;
|
|
187
|
+
return { name: m[1], attrs: parseAttrs(m[2]), index: m.index };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseAttrs(s) {
|
|
191
|
+
var out = {};
|
|
192
|
+
var re = /\b([\w:\-]+)\s*=\s*"([^"]*)"/g;
|
|
193
|
+
var m;
|
|
194
|
+
while ((m = re.exec(s)) !== null) out[m[1]] = m[2];
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Extract repeated nested elements by tag name, returning text slice + attrs.
|
|
199
|
+
function elements(text, tag) {
|
|
200
|
+
var out = [];
|
|
201
|
+
var re = new RegExp("<" + tag + "\\b([^>]*)>([\\s\\S]*?)<\\/" + tag + ">", "g");
|
|
202
|
+
var m;
|
|
203
|
+
while ((m = re.exec(text)) !== null) {
|
|
204
|
+
out.push({ attrs: parseAttrs(m[1]), inner: m[2], outer: m[0], index: m.index });
|
|
205
|
+
}
|
|
206
|
+
// also pick up self-closing (e.g. <au id="x" launchMethod="..."/>) — rare for AUs but legal
|
|
207
|
+
re = new RegExp("<" + tag + "\\b([^>]*)\\/>", "g");
|
|
208
|
+
while ((m = re.exec(text)) !== null) {
|
|
209
|
+
out.push({ attrs: parseAttrs(m[1]), inner: "", outer: m[0], index: m.index });
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function firstText(text, tag) {
|
|
215
|
+
var m = text.match(new RegExp("<" + tag + "\\b[^>]*>([\\s\\S]*?)<\\/" + tag + ">"));
|
|
216
|
+
return m ? m[1].trim() : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function langstrings(inner) {
|
|
220
|
+
// <title><langstring lang="en">..</langstring><langstring lang="fr">..</langstring></title>
|
|
221
|
+
// or <description>...</description>
|
|
222
|
+
var out = [];
|
|
223
|
+
var re = /<langstring\b([^>]*)>([\s\S]*?)<\/langstring>/g;
|
|
224
|
+
var m;
|
|
225
|
+
while ((m = re.exec(inner)) !== null) {
|
|
226
|
+
var attrs = parseAttrs(m[1]);
|
|
227
|
+
out.push({ lang: attrs.lang || "", text: m[2].trim() });
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function lineOf(text, idx) {
|
|
233
|
+
var n = 1;
|
|
234
|
+
for (var i = 0; i < idx && i < text.length; i++) if (text[i] === "\n") n++;
|
|
235
|
+
return n;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------- IRI / format checks -------------------------------------------
|
|
239
|
+
|
|
240
|
+
function isIri(s) {
|
|
241
|
+
if (!s) return false;
|
|
242
|
+
return /^(https?:\/\/|urn:)\S+$/i.test(s);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isIsoDuration(s) {
|
|
246
|
+
// ISO-8601 P[n]Y[n]M[n]DT[n]H[n]M[n]S
|
|
247
|
+
return /^P(?:\d+Y)?(?:\d+M)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/.test(s);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------- validate / lint ------------------------------------------------
|
|
251
|
+
|
|
252
|
+
function auditCmi5(rootDir, opts) {
|
|
253
|
+
opts = opts || {};
|
|
254
|
+
var findings = [];
|
|
255
|
+
var cmi5Path = path.join(rootDir, "cmi5.xml");
|
|
256
|
+
if (!fs.existsSync(cmi5Path)) {
|
|
257
|
+
findings.push(find("cmi5-missing"));
|
|
258
|
+
return findings;
|
|
259
|
+
}
|
|
260
|
+
var text = fs.readFileSync(cmi5Path, "utf8");
|
|
261
|
+
var parsed = readXml(text);
|
|
262
|
+
if (!parsed.ok) {
|
|
263
|
+
findings.push(find("cmi5-bad-xml", 0, parsed.reason));
|
|
264
|
+
return findings;
|
|
265
|
+
}
|
|
266
|
+
var root = rootEl(text);
|
|
267
|
+
if (!root || root.name !== "courseStructure") {
|
|
268
|
+
findings.push(find("cmi5-bad-root", root ? lineOf(text, root.index) : 0,
|
|
269
|
+
root ? root.name : "<no root>"));
|
|
270
|
+
return findings;
|
|
271
|
+
}
|
|
272
|
+
if (root.attrs.xmlns && root.attrs.xmlns !== CMI5_NS) {
|
|
273
|
+
findings.push(find("cmi5-bad-namespace", lineOf(text, root.index), root.attrs.xmlns));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// course
|
|
277
|
+
var courses = elements(text, "course");
|
|
278
|
+
if (courses.length === 0) {
|
|
279
|
+
findings.push(find("course-missing"));
|
|
280
|
+
} else {
|
|
281
|
+
var c = courses[0];
|
|
282
|
+
if (!c.attrs.id) {
|
|
283
|
+
findings.push(find("course-no-id", lineOf(text, c.index)));
|
|
284
|
+
} else if (!isIri(c.attrs.id)) {
|
|
285
|
+
findings.push(find("course-id-not-iri", lineOf(text, c.index), c.attrs.id));
|
|
286
|
+
}
|
|
287
|
+
var cTitle = firstText(c.inner, "title");
|
|
288
|
+
if (!cTitle || langstrings(cTitle).length === 0) {
|
|
289
|
+
findings.push(find("course-no-title", lineOf(text, c.index)));
|
|
290
|
+
} else if (opts.lint) {
|
|
291
|
+
var hasEn = langstrings(cTitle).some(function (l) { return l.lang === "en" || /^en[-_]/.test(l.lang); });
|
|
292
|
+
if (!hasEn) findings.push(find("lint-no-en-title", lineOf(text, c.index), "course"));
|
|
293
|
+
}
|
|
294
|
+
if (opts.lint) {
|
|
295
|
+
var cDesc = firstText(c.inner, "description");
|
|
296
|
+
if (!cDesc) findings.push(find("lint-no-description", lineOf(text, c.index), "course"));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// AUs
|
|
301
|
+
var aus = elements(text, "au");
|
|
302
|
+
if (aus.length === 0) {
|
|
303
|
+
findings.push(find("au-none"));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// package files for URL resolution
|
|
307
|
+
var allFiles = walk(rootDir).map(function (p) { return p.replace(/\\/g, "/"); });
|
|
308
|
+
var fileSet = new Set(allFiles);
|
|
309
|
+
|
|
310
|
+
var seenIds = new Set();
|
|
311
|
+
var seenUrls = new Set();
|
|
312
|
+
|
|
313
|
+
aus.forEach(function (au) {
|
|
314
|
+
var line = lineOf(text, au.index);
|
|
315
|
+
|
|
316
|
+
// id
|
|
317
|
+
if (!au.attrs.id) findings.push(find("au-no-id", line));
|
|
318
|
+
else {
|
|
319
|
+
if (!isIri(au.attrs.id)) findings.push(find("au-id-not-iri", line, au.attrs.id));
|
|
320
|
+
if (opts.lint) {
|
|
321
|
+
if (seenIds.has(au.attrs.id)) findings.push(find("lint-id-duplicate", line, au.attrs.id));
|
|
322
|
+
seenIds.add(au.attrs.id);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// launchMethod
|
|
327
|
+
if (!au.attrs.launchMethod) findings.push(find("au-no-launchmethod", line));
|
|
328
|
+
else if (!LAUNCH_METHODS.has(au.attrs.launchMethod)) {
|
|
329
|
+
findings.push(find("au-bad-launchmethod", line, au.attrs.launchMethod));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// moveOn
|
|
333
|
+
if (!au.attrs.moveOn) findings.push(find("au-no-moveon", line));
|
|
334
|
+
else if (!MOVE_ON_VALUES.has(au.attrs.moveOn)) {
|
|
335
|
+
findings.push(find("au-bad-moveon", line, au.attrs.moveOn));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// masteryScore
|
|
339
|
+
if (au.attrs.masteryScore != null && au.attrs.masteryScore !== "") {
|
|
340
|
+
var s = parseFloat(au.attrs.masteryScore);
|
|
341
|
+
if (isNaN(s) || s < 0 || s > 1) {
|
|
342
|
+
findings.push(find("au-mastery-out-of-range", line, au.attrs.masteryScore));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// activityType
|
|
347
|
+
if (au.attrs.activityType && !isIri(au.attrs.activityType)) {
|
|
348
|
+
findings.push(find("au-activity-type-not-iri", line, au.attrs.activityType));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// url
|
|
352
|
+
var url = firstText(au.inner, "url");
|
|
353
|
+
if (!url) {
|
|
354
|
+
findings.push(find("au-no-url", line));
|
|
355
|
+
} else {
|
|
356
|
+
// strip query/fragment, normalise
|
|
357
|
+
var cleanUrl = url.split("?")[0].split("#")[0].replace(/^\.\//, "");
|
|
358
|
+
if (!/^https?:\/\//i.test(cleanUrl) && !fileSet.has(cleanUrl)) {
|
|
359
|
+
findings.push(find("au-url-not-found", line, url));
|
|
360
|
+
}
|
|
361
|
+
if (opts.lint) {
|
|
362
|
+
if (seenUrls.has(cleanUrl)) findings.push(find("lint-url-duplicate", line, cleanUrl));
|
|
363
|
+
seenUrls.add(cleanUrl);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// title langstring(s)
|
|
368
|
+
var auTitle = firstText(au.inner, "title");
|
|
369
|
+
if (!auTitle || langstrings(auTitle).length === 0) {
|
|
370
|
+
findings.push(find("au-no-title", line));
|
|
371
|
+
} else if (opts.lint) {
|
|
372
|
+
var hasEnAu = langstrings(auTitle).some(function (l) {
|
|
373
|
+
return l.lang === "en" || /^en[-_]/.test(l.lang);
|
|
374
|
+
});
|
|
375
|
+
if (!hasEnAu) findings.push(find("lint-no-en-title", line, "au"));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (opts.lint) {
|
|
379
|
+
var auDesc = firstText(au.inner, "description");
|
|
380
|
+
if (!auDesc) findings.push(find("lint-no-description", line, "au"));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// duration (optional, but if present must be ISO-8601)
|
|
384
|
+
if (opts.lint && au.attrs.duration && !isIsoDuration(au.attrs.duration)) {
|
|
385
|
+
findings.push(find("lint-duration-not-iso", line, au.attrs.duration));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// waivedMoveOnConditions only meaningful with CompletedAndPassed / CompletedOrPassed
|
|
389
|
+
if (opts.lint && au.attrs.waivedMoveOnConditions
|
|
390
|
+
&& au.attrs.moveOn && au.attrs.moveOn !== "CompletedAndPassed"
|
|
391
|
+
&& au.attrs.moveOn !== "CompletedOrPassed") {
|
|
392
|
+
findings.push(find("lint-waived-without-mco", line, au.attrs.moveOn));
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// extensions (lint only)
|
|
397
|
+
if (opts.lint) {
|
|
398
|
+
var extRe = /<extension\b([^>]*)>/g;
|
|
399
|
+
var m;
|
|
400
|
+
while ((m = extRe.exec(text)) !== null) {
|
|
401
|
+
var k = parseAttrs(m[1]).key || "";
|
|
402
|
+
if (k && !isIri(k)) {
|
|
403
|
+
findings.push(find("lint-extension-bad-iri", lineOf(text, m.index), k));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return findings;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------- convert: SCORM 1.2 → cmi5 wrapper ------------------------------
|
|
412
|
+
|
|
413
|
+
function convertScormToCmi5(srcZip, dstZip) {
|
|
414
|
+
// 1. unzip the SCORM package
|
|
415
|
+
var src = unzipToTemp(srcZip, "src");
|
|
416
|
+
// 2. find the launch HTML (imsmanifest.xml → resource[adlcp:scormtype="sco"]/@href)
|
|
417
|
+
var manifestPath = path.join(src, "imsmanifest.xml");
|
|
418
|
+
if (!fs.existsSync(manifestPath)) {
|
|
419
|
+
throw new Error("source has no imsmanifest.xml — not a SCORM 1.2 package");
|
|
420
|
+
}
|
|
421
|
+
var manifest = fs.readFileSync(manifestPath, "utf8");
|
|
422
|
+
var hrefMatch = manifest.match(/<resource\b[^>]*\bscormtype\s*=\s*["']sco["'][^>]*\bhref\s*=\s*["']([^"']+)["']/i)
|
|
423
|
+
|| manifest.match(/<resource\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*\bscormtype\s*=\s*["']sco["']/i);
|
|
424
|
+
if (!hrefMatch) throw new Error("could not locate SCO launch HTML in imsmanifest.xml");
|
|
425
|
+
var launchHref = hrefMatch[1];
|
|
426
|
+
|
|
427
|
+
// title from manifest
|
|
428
|
+
var titleMatch = manifest.match(/<title>\s*([\s\S]*?)\s*<\/title>/i);
|
|
429
|
+
var title = titleMatch ? titleMatch[1].trim() : path.basename(srcZip, path.extname(srcZip));
|
|
430
|
+
|
|
431
|
+
// organisation identifier becomes the courseId (best-effort)
|
|
432
|
+
var orgMatch = manifest.match(/<organization\b[^>]*identifier\s*=\s*"([^"]+)"/i);
|
|
433
|
+
var orgId = orgMatch ? orgMatch[1] : ("scorm-" + Date.now());
|
|
434
|
+
var courseIri = "urn:scorm:" + orgId.replace(/[^a-zA-Z0-9\-._]/g, "-");
|
|
435
|
+
var auIri = courseIri + ":au:1";
|
|
436
|
+
|
|
437
|
+
// 3. write cmi5.xml
|
|
438
|
+
var cmi5Xml = [
|
|
439
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
440
|
+
'<courseStructure xmlns="https://w3id.org/xapi/profiles/cmi5/v1">',
|
|
441
|
+
' <course id="' + xmlEscape(courseIri) + '">',
|
|
442
|
+
' <title><langstring lang="en">' + xmlEscape(title) + '</langstring></title>',
|
|
443
|
+
' <description><langstring lang="en">SCORM 1.2 package wrapped as cmi5 (dual-stream).</langstring></description>',
|
|
444
|
+
' </course>',
|
|
445
|
+
' <au id="' + xmlEscape(auIri) + '" moveOn="CompletedOrPassed" launchMethod="AnyWindow">',
|
|
446
|
+
' <title><langstring lang="en">' + xmlEscape(title) + '</langstring></title>',
|
|
447
|
+
' <url>' + xmlEscape(launchHref) + '</url>',
|
|
448
|
+
' </au>',
|
|
449
|
+
'</courseStructure>',
|
|
450
|
+
'',
|
|
451
|
+
].join("\n");
|
|
452
|
+
fs.writeFileSync(path.join(src, "cmi5.xml"), cmi5Xml);
|
|
453
|
+
|
|
454
|
+
// 4. re-zip into destination
|
|
455
|
+
var absDst = path.resolve(dstZip);
|
|
456
|
+
try { fs.unlinkSync(absDst); } catch (e) {}
|
|
457
|
+
var r = spawnSync("zip", ["-qr", absDst, "."], { cwd: src });
|
|
458
|
+
if (r.status !== 0) throw new Error("zip: " + r.stderr.toString());
|
|
459
|
+
|
|
460
|
+
// 5. clean up
|
|
461
|
+
try { fs.rmSync(src, { recursive: true, force: true }); } catch (e) {}
|
|
462
|
+
|
|
463
|
+
return { courseId: courseIri, auId: auIri, launchHref: launchHref, title: title };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function xmlEscape(s) {
|
|
467
|
+
return String(s)
|
|
468
|
+
.replace(/&/g, "&")
|
|
469
|
+
.replace(/</g, "<")
|
|
470
|
+
.replace(/>/g, ">")
|
|
471
|
+
.replace(/"/g, """);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ---------- report ---------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
function color(s, code, on) { return on ? "\x1b[" + code + "m" + s + "\x1b[0m" : s; }
|
|
477
|
+
|
|
478
|
+
function report(findings, opts) {
|
|
479
|
+
if (opts.json) {
|
|
480
|
+
var counts = { error: 0, warn: 0, info: 0 };
|
|
481
|
+
findings.forEach(function (f) { counts[f.sev]++; });
|
|
482
|
+
console.log(JSON.stringify({
|
|
483
|
+
ok: counts.error === 0,
|
|
484
|
+
counts: counts,
|
|
485
|
+
findings: findings.map(function (f) {
|
|
486
|
+
return { rule: f.rule, sev: f.sev, msg: f.msg, line: f.line, detail: f.detail };
|
|
487
|
+
}),
|
|
488
|
+
}, null, 2));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (findings.length === 0) {
|
|
492
|
+
console.log(color("✓ cmi5.xml is valid (no findings)", "32", opts.color));
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
var groups = { error: [], warn: [], info: [] };
|
|
496
|
+
findings.forEach(function (f) { groups[f.sev].push(f); });
|
|
497
|
+
["error", "warn", "info"].forEach(function (sev) {
|
|
498
|
+
if (groups[sev].length === 0) return;
|
|
499
|
+
var head = sev.toUpperCase() + " (" + groups[sev].length + ")";
|
|
500
|
+
console.log("\n" + color(head, sev === "error" ? "31" : sev === "warn" ? "33" : "36", opts.color));
|
|
501
|
+
groups[sev].forEach(function (f) {
|
|
502
|
+
var loc = f.line ? "cmi5.xml:" + f.line : "cmi5.xml";
|
|
503
|
+
var det = f.detail ? " [" + f.detail + "]" : "";
|
|
504
|
+
console.log(" " + f.rule.padEnd(28) + " " + loc.padEnd(16) + " " + f.msg + det);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
var errs = groups.error.length, warns = groups.warn.length;
|
|
508
|
+
console.log("\n" + errs + " errors, " + warns + " warnings, " + groups.info.length + " info");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ---------- main -----------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
function main() {
|
|
514
|
+
var opts = parseArgs(process.argv);
|
|
515
|
+
var root, cleanup = function () {};
|
|
516
|
+
|
|
517
|
+
if (opts.mode === "convert") {
|
|
518
|
+
try {
|
|
519
|
+
var info = convertScormToCmi5(opts.pkg, opts.out);
|
|
520
|
+
if (opts.json) {
|
|
521
|
+
console.log(JSON.stringify({ ok: true, out: opts.out, info: info }, null, 2));
|
|
522
|
+
} else {
|
|
523
|
+
console.log(color("✓ wrote cmi5 package", "32", opts.color) + " " + opts.out);
|
|
524
|
+
console.log(" course id: " + info.courseId);
|
|
525
|
+
console.log(" au id: " + info.auId);
|
|
526
|
+
console.log(" launch: " + info.launchHref);
|
|
527
|
+
}
|
|
528
|
+
process.exit(0);
|
|
529
|
+
} catch (e) {
|
|
530
|
+
console.error("convert failed: " + e.message);
|
|
531
|
+
process.exit(2);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
try {
|
|
536
|
+
if (fs.statSync(opts.pkg).isDirectory()) {
|
|
537
|
+
root = opts.pkg;
|
|
538
|
+
} else {
|
|
539
|
+
root = unzipToTemp(opts.pkg);
|
|
540
|
+
cleanup = function () { try { fs.rmSync(root, { recursive: true, force: true }); } catch (e) {} };
|
|
541
|
+
}
|
|
542
|
+
} catch (e) {
|
|
543
|
+
console.error("cannot read package: " + e.message);
|
|
544
|
+
process.exit(2);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
try {
|
|
548
|
+
var findings = auditCmi5(root, { lint: opts.mode === "lint" });
|
|
549
|
+
if (opts.infoOff) findings = findings.filter(function (f) { return f.sev !== "info"; });
|
|
550
|
+
report(findings, opts);
|
|
551
|
+
var anyErr = findings.some(function (f) { return f.sev === "error"; });
|
|
552
|
+
var anyWarn = findings.some(function (f) { return f.sev === "warn"; });
|
|
553
|
+
process.exit(anyErr ? 2 : anyWarn ? 1 : 0);
|
|
554
|
+
} finally {
|
|
555
|
+
cleanup();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (require.main === module) main();
|
|
560
|
+
|
|
561
|
+
module.exports = { auditCmi5: auditCmi5, convertScormToCmi5: convertScormToCmi5, RULES: RULES };
|
package/src/confine.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var fs = require("fs");
|
|
3
|
+
var path = require("path");
|
|
4
|
+
|
|
5
|
+
function verifyConfinement(dir) {
|
|
6
|
+
var real = fs.realpathSync(dir);
|
|
7
|
+
(function walk(d) {
|
|
8
|
+
fs.readdirSync(d).forEach(function (f) {
|
|
9
|
+
var p = path.join(d, f);
|
|
10
|
+
var rp = fs.realpathSync(p);
|
|
11
|
+
if (rp !== real && rp.indexOf(real + path.sep) !== 0) {
|
|
12
|
+
fs.rmSync(real, { recursive: true, force: true });
|
|
13
|
+
throw new Error("Path traversal detected: " + rp + " escapes " + real);
|
|
14
|
+
}
|
|
15
|
+
if (fs.statSync(p).isDirectory()) walk(p);
|
|
16
|
+
});
|
|
17
|
+
})(dir);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = verifyConfinement;
|