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/diff/diff.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* scorm-diff — structured diff between two SCORM 1.2 packages.
|
|
4
|
+
*
|
|
5
|
+
* scorm-diff before.zip after.zip
|
|
6
|
+
* scorm-diff before.zip after.zip --json
|
|
7
|
+
*
|
|
8
|
+
* Output sections:
|
|
9
|
+
* - Manifest changes (parsed: identifier, title, masteryscore, schema, …)
|
|
10
|
+
* - Asset list (added, removed, modified)
|
|
11
|
+
* - Per-file detail:
|
|
12
|
+
* text files (HTML/CSS/JS/JSON/XML/VTT) → unified line diff
|
|
13
|
+
* binary files → size + sha256 change
|
|
14
|
+
*
|
|
15
|
+
* Use it for content PR review (treat SCORM as a reviewable diff, not a
|
|
16
|
+
* binary blob) or in CI to gate large unintended changes.
|
|
17
|
+
*
|
|
18
|
+
* Exit codes: 0 = no changes, 1 = changes present, 2 = error.
|
|
19
|
+
*/
|
|
20
|
+
"use strict";
|
|
21
|
+
|
|
22
|
+
var fs = require("fs");
|
|
23
|
+
var path = require("path");
|
|
24
|
+
var os = require("os");
|
|
25
|
+
var crypto = require("crypto");
|
|
26
|
+
var { spawnSync } = require("child_process");
|
|
27
|
+
var verifyConfinement = require("../confine");
|
|
28
|
+
|
|
29
|
+
// ---------- args -----------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
var a = {
|
|
33
|
+
before: "", after: "", json: false, noColor: false,
|
|
34
|
+
maxTextDiffKB: 256, maxDiffLines: 200,
|
|
35
|
+
};
|
|
36
|
+
for (var i = 0; i < argv.length; i++) {
|
|
37
|
+
var k = argv[i];
|
|
38
|
+
if (k === "--json") a.json = true;
|
|
39
|
+
else if (k === "--no-color") a.noColor = true;
|
|
40
|
+
else if (k === "--max-text-kb") a.maxTextDiffKB = +argv[++i];
|
|
41
|
+
else if (k === "--max-diff-lines") a.maxDiffLines = +argv[++i];
|
|
42
|
+
else if (k === "-h" || k === "--help") { usage(); process.exit(0); }
|
|
43
|
+
else if (k[0] === "-") { console.error("Unknown flag: " + k); process.exit(2); }
|
|
44
|
+
else if (!a.before) a.before = k;
|
|
45
|
+
else if (!a.after) a.after = k;
|
|
46
|
+
else { console.error("Unexpected arg: " + k); process.exit(2); }
|
|
47
|
+
}
|
|
48
|
+
if (!a.before || !a.after) { usage(); process.exit(2); }
|
|
49
|
+
return a;
|
|
50
|
+
}
|
|
51
|
+
function usage() {
|
|
52
|
+
console.error("Usage: scorm-diff <before.zip|dir> <after.zip|dir> [options]");
|
|
53
|
+
console.error(" --json emit JSON");
|
|
54
|
+
console.error(" --no-color disable ANSI colors");
|
|
55
|
+
console.error(" --max-text-kb N skip line-diff on text files > N KB (default 256)");
|
|
56
|
+
console.error(" --max-diff-lines N truncate per-file diff to N lines (default 200)");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------- unpacking ------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
function unpack(input) {
|
|
62
|
+
var st = fs.statSync(input);
|
|
63
|
+
if (st.isDirectory()) return { root: path.resolve(input), cleanup: function () {} };
|
|
64
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), "scorm-diff-"));
|
|
65
|
+
var r = spawnSync("unzip", ["-q", "-o", input, "-d", tmp]);
|
|
66
|
+
if (r.status !== 0) throw new Error("unzip " + input + ": " + r.stderr.toString());
|
|
67
|
+
verifyConfinement(tmp);
|
|
68
|
+
return { root: tmp, cleanup: function () { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (e) {} } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function walk(dir, acc, prefix) {
|
|
72
|
+
acc = acc || []; prefix = prefix || "";
|
|
73
|
+
for (var name of fs.readdirSync(dir)) {
|
|
74
|
+
var p = path.join(dir, name);
|
|
75
|
+
var rel = prefix ? prefix + "/" + name : name;
|
|
76
|
+
var st = fs.statSync(p);
|
|
77
|
+
if (st.isDirectory()) walk(p, acc, rel);
|
|
78
|
+
else acc.push({ rel: rel, abs: p, size: st.size });
|
|
79
|
+
}
|
|
80
|
+
return acc;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------- manifest parsing -----------------------------------------------
|
|
84
|
+
|
|
85
|
+
function parseManifest(root) {
|
|
86
|
+
var p = path.join(root, "imsmanifest.xml");
|
|
87
|
+
if (!fs.existsSync(p)) return null;
|
|
88
|
+
var xml = fs.readFileSync(p, "utf8");
|
|
89
|
+
function pluck(re) { var m = re.exec(xml); return m ? m[1].trim() : null; }
|
|
90
|
+
return {
|
|
91
|
+
identifier: pluck(/<manifest\b[^>]*\bidentifier\s*=\s*["']([^"']+)["']/i),
|
|
92
|
+
version: pluck(/<manifest\b[^>]*\bversion\s*=\s*["']([^"']+)["']/i),
|
|
93
|
+
schema: pluck(/<schema>\s*([^<]+?)\s*<\/schema>/i),
|
|
94
|
+
schemaversion: pluck(/<schemaversion>\s*([^<]+?)\s*<\/schemaversion>/i),
|
|
95
|
+
title: pluck(/<title>\s*([^<]+?)\s*<\/title>/i),
|
|
96
|
+
masteryscore: pluck(/<adlcp:masteryscore>\s*([^<]+?)\s*<\/adlcp:masteryscore>/i),
|
|
97
|
+
launchHref: pluck(/<resource\b[^>]*\bscormtype\s*=\s*["']sco["'][^>]*\bhref\s*=\s*["']([^"']+)["']/i)
|
|
98
|
+
|| pluck(/<resource\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*\bscormtype\s*=\s*["']sco["']/i)
|
|
99
|
+
|| pluck(/<resource\b[^>]*\bhref\s*=\s*["']([^"']+)["']/i),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function diffManifest(a, b) {
|
|
104
|
+
var out = [];
|
|
105
|
+
if (!a && !b) return out;
|
|
106
|
+
if (!a) return [{ kind: "added", key: "manifest", value: "(no manifest before)" }];
|
|
107
|
+
if (!b) return [{ kind: "removed", key: "manifest", value: "(no manifest after)" }];
|
|
108
|
+
var keys = Object.keys(a).concat(Object.keys(b))
|
|
109
|
+
.filter(function (k, i, arr) { return arr.indexOf(k) === i; });
|
|
110
|
+
keys.sort();
|
|
111
|
+
keys.forEach(function (k) {
|
|
112
|
+
var x = a[k], y = b[k];
|
|
113
|
+
if (x === y) return;
|
|
114
|
+
if (x == null) out.push({ kind: "added", key: k, value: y });
|
|
115
|
+
else if (y == null) out.push({ kind: "removed", key: k, value: x });
|
|
116
|
+
else out.push({ kind: "changed", key: k, before: x, after: y });
|
|
117
|
+
});
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------- file hashing + classification ---------------------------------
|
|
122
|
+
|
|
123
|
+
var TEXT_EXT = new Set([".html", ".htm", ".css", ".js", ".json", ".xml", ".vtt", ".txt", ".md", ".srt"]);
|
|
124
|
+
function isText(rel) { return TEXT_EXT.has(path.extname(rel).toLowerCase()); }
|
|
125
|
+
|
|
126
|
+
function sha256(absPath) {
|
|
127
|
+
var h = crypto.createHash("sha256");
|
|
128
|
+
h.update(fs.readFileSync(absPath));
|
|
129
|
+
return h.digest("hex");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function indexFiles(root) {
|
|
133
|
+
var files = walk(root);
|
|
134
|
+
// Manifest is reported in its own section.
|
|
135
|
+
files = files.filter(function (f) { return f.rel !== "imsmanifest.xml"; });
|
|
136
|
+
var byRel = new Map();
|
|
137
|
+
files.forEach(function (f) {
|
|
138
|
+
f.hash = sha256(f.abs);
|
|
139
|
+
byRel.set(f.rel, f);
|
|
140
|
+
});
|
|
141
|
+
return byRel;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------- text diff (shell out to `diff -u`) ----------------------------
|
|
145
|
+
|
|
146
|
+
function unifiedDiff(beforePath, afterPath, maxLines) {
|
|
147
|
+
var r = spawnSync("diff", ["-u", "--label", "before", "--label", "after", beforePath, afterPath]);
|
|
148
|
+
// diff exit code is 1 when files differ (not an error), 2 on real errors.
|
|
149
|
+
if (r.status === 2) return ["(diff failed: " + r.stderr.toString().trim() + ")"];
|
|
150
|
+
var text = r.stdout.toString();
|
|
151
|
+
if (!text) {
|
|
152
|
+
// Content hash differed but unified diff is empty — usually BOM, line
|
|
153
|
+
// endings, or trailing-newline changes. Surface this instead of silently
|
|
154
|
+
// showing nothing under the "modified" header.
|
|
155
|
+
return ["(no line-level changes — likely encoding / line-ending / trailing-newline only)"];
|
|
156
|
+
}
|
|
157
|
+
var lines = text.split("\n");
|
|
158
|
+
if (lines.length > maxLines) {
|
|
159
|
+
lines = lines.slice(0, maxLines).concat(["… (" + (text.split("\n").length - maxLines) + " more lines truncated)"]);
|
|
160
|
+
}
|
|
161
|
+
return lines;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------- core diff ----------------------------------------------------
|
|
165
|
+
|
|
166
|
+
function compareAssets(aMap, bMap, args) {
|
|
167
|
+
var changes = { added: [], removed: [], modified: [] };
|
|
168
|
+
bMap.forEach(function (bf, rel) {
|
|
169
|
+
var af = aMap.get(rel);
|
|
170
|
+
if (!af) { changes.added.push({ rel: rel, size: bf.size }); return; }
|
|
171
|
+
if (af.hash === bf.hash) return; // unchanged
|
|
172
|
+
var mod = {
|
|
173
|
+
rel: rel,
|
|
174
|
+
beforeSize: af.size,
|
|
175
|
+
afterSize: bf.size,
|
|
176
|
+
beforeHash: af.hash.slice(0, 12),
|
|
177
|
+
afterHash: bf.hash.slice(0, 12),
|
|
178
|
+
text: isText(rel),
|
|
179
|
+
diff: null,
|
|
180
|
+
};
|
|
181
|
+
if (mod.text && af.size <= args.maxTextDiffKB * 1024 && bf.size <= args.maxTextDiffKB * 1024) {
|
|
182
|
+
mod.diff = unifiedDiff(af.abs, bf.abs, args.maxDiffLines);
|
|
183
|
+
}
|
|
184
|
+
changes.modified.push(mod);
|
|
185
|
+
});
|
|
186
|
+
aMap.forEach(function (af, rel) {
|
|
187
|
+
if (!bMap.has(rel)) changes.removed.push({ rel: rel, size: af.size });
|
|
188
|
+
});
|
|
189
|
+
return changes;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------- rendering ----------------------------------------------------
|
|
193
|
+
|
|
194
|
+
function color(s, code, on) { return on ? "\x1b[" + code + "m" + s + "\x1b[0m" : s; }
|
|
195
|
+
function fmtSize(n) {
|
|
196
|
+
if (n < 1024) return n + " B";
|
|
197
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
|
198
|
+
return (n / (1024 * 1024)).toFixed(1) + " MB";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function renderText(report, useColor) {
|
|
202
|
+
var out = [];
|
|
203
|
+
// Manifest
|
|
204
|
+
if (report.manifest.length) {
|
|
205
|
+
out.push(color("=== Manifest ===", 1, useColor));
|
|
206
|
+
report.manifest.forEach(function (m) {
|
|
207
|
+
if (m.kind === "added")
|
|
208
|
+
out.push(color("+ " + m.key + ": " + JSON.stringify(m.value), 32, useColor));
|
|
209
|
+
else if (m.kind === "removed")
|
|
210
|
+
out.push(color("- " + m.key + ": " + JSON.stringify(m.value), 31, useColor));
|
|
211
|
+
else
|
|
212
|
+
out.push(color("~ " + m.key + ": ", 33, useColor) +
|
|
213
|
+
JSON.stringify(m.before) + " → " + JSON.stringify(m.after));
|
|
214
|
+
});
|
|
215
|
+
out.push("");
|
|
216
|
+
}
|
|
217
|
+
// Assets
|
|
218
|
+
var a = report.assets;
|
|
219
|
+
if (a.added.length + a.removed.length + a.modified.length > 0) {
|
|
220
|
+
out.push(color("=== Assets ===", 1, useColor));
|
|
221
|
+
a.added.forEach(function (f) {
|
|
222
|
+
out.push(color("+ " + f.rel.padEnd(40), 32, useColor) + " " + fmtSize(f.size));
|
|
223
|
+
});
|
|
224
|
+
a.removed.forEach(function (f) {
|
|
225
|
+
out.push(color("- " + f.rel.padEnd(40), 31, useColor) + " " + fmtSize(f.size));
|
|
226
|
+
});
|
|
227
|
+
a.modified.forEach(function (f) {
|
|
228
|
+
var sizeNote = (f.beforeSize === f.afterSize)
|
|
229
|
+
? fmtSize(f.afterSize)
|
|
230
|
+
: fmtSize(f.beforeSize) + " → " + fmtSize(f.afterSize);
|
|
231
|
+
var hashNote = " (" + f.beforeHash + " → " + f.afterHash + ")";
|
|
232
|
+
out.push(color("~ " + f.rel.padEnd(40), 33, useColor) + " " + sizeNote + hashNote);
|
|
233
|
+
if (f.diff && f.diff.length > 0) {
|
|
234
|
+
f.diff.forEach(function (line) {
|
|
235
|
+
var c = line.startsWith("+") && !line.startsWith("+++") ? 32 :
|
|
236
|
+
line.startsWith("-") && !line.startsWith("---") ? 31 :
|
|
237
|
+
line.startsWith("@@") ? 36 : 0;
|
|
238
|
+
out.push(" " + color(line, c, useColor));
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
out.push("");
|
|
243
|
+
}
|
|
244
|
+
// Summary
|
|
245
|
+
out.push(color("=== Summary ===", 1, useColor));
|
|
246
|
+
out.push("Manifest: " + report.manifest.length + " change(s)");
|
|
247
|
+
out.push("Assets: " + a.added.length + " added, " + a.removed.length + " removed, " + a.modified.length + " modified");
|
|
248
|
+
return out.join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------- main ---------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
function main() {
|
|
254
|
+
var args = parseArgs(process.argv.slice(2));
|
|
255
|
+
if (!fs.existsSync(args.before)) { console.error("Not found: " + args.before); process.exit(2); }
|
|
256
|
+
if (!fs.existsSync(args.after)) { console.error("Not found: " + args.after); process.exit(2); }
|
|
257
|
+
|
|
258
|
+
var aBox = unpack(args.before), bBox = unpack(args.after);
|
|
259
|
+
try {
|
|
260
|
+
var manifestDiff = diffManifest(parseManifest(aBox.root), parseManifest(bBox.root));
|
|
261
|
+
var assetDiff = compareAssets(indexFiles(aBox.root), indexFiles(bBox.root), args);
|
|
262
|
+
var report = { manifest: manifestDiff, assets: assetDiff };
|
|
263
|
+
|
|
264
|
+
if (args.json) {
|
|
265
|
+
console.log(JSON.stringify(report, null, 2));
|
|
266
|
+
} else {
|
|
267
|
+
var useColor = !args.noColor && process.stdout.isTTY;
|
|
268
|
+
console.log(renderText(report, useColor));
|
|
269
|
+
}
|
|
270
|
+
var changed = manifestDiff.length + assetDiff.added.length + assetDiff.removed.length + assetDiff.modified.length;
|
|
271
|
+
process.exit(changed > 0 ? 1 : 0);
|
|
272
|
+
} finally {
|
|
273
|
+
aBox.cleanup(); bBox.cleanup();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
main();
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* scorm-i18n — bundle a translation pack into a SCORM 1.2 package.
|
|
4
|
+
*
|
|
5
|
+
* scorm-i18n <package.zip | dir> --strings strings.json [opts]
|
|
6
|
+
*
|
|
7
|
+
* What this tool does NOT do: auto-translate. It bundles strings the author
|
|
8
|
+
* already provides, and ships a runtime that swaps text + media at page load.
|
|
9
|
+
*
|
|
10
|
+
* What the author must do upfront in their course HTML:
|
|
11
|
+
*
|
|
12
|
+
* <h1 data-i18n="title">Welcome</h1> ← default (en) text;
|
|
13
|
+
* runtime replaces it.
|
|
14
|
+
* <button data-i18n="btn.submit">Submit</button>
|
|
15
|
+
* <input data-i18n-attr="placeholder:input.name.placeholder">
|
|
16
|
+
* <video>
|
|
17
|
+
* <source data-i18n-source src="lesson.en.mp4">
|
|
18
|
+
* <track data-i18n-track src="lesson.en.vtt" kind="captions">
|
|
19
|
+
* </video>
|
|
20
|
+
*
|
|
21
|
+
* The CLI bundles the strings.json + the runtime, injects two <script>
|
|
22
|
+
* tags into the launch HTML, and re-zips.
|
|
23
|
+
*
|
|
24
|
+
* strings.json shape:
|
|
25
|
+
*
|
|
26
|
+
* {
|
|
27
|
+
* "defaultLang": "en",
|
|
28
|
+
* "names": { "en": "English", "hi": "हिन्दी" },
|
|
29
|
+
* "rtl": ["ar","he"],
|
|
30
|
+
* "strings": {
|
|
31
|
+
* "en": { "title": "Welcome", "btn.submit": "Submit" },
|
|
32
|
+
* "hi": { "title": "स्वागत", "btn.submit": "जमा करें" }
|
|
33
|
+
* }
|
|
34
|
+
* }
|
|
35
|
+
*
|
|
36
|
+
* The CLI validates the file and warns about missing keys per language.
|
|
37
|
+
*/
|
|
38
|
+
"use strict";
|
|
39
|
+
|
|
40
|
+
var fs = require("fs");
|
|
41
|
+
var path = require("path");
|
|
42
|
+
var os = require("os");
|
|
43
|
+
var { spawnSync } = require("child_process");
|
|
44
|
+
var verifyConfinement = require("../confine");
|
|
45
|
+
|
|
46
|
+
// ---------- args -----------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function parseArgs(argv) {
|
|
49
|
+
var a = { input: "", strings: "", out: "", hideSwitcher: false, dryRun: false };
|
|
50
|
+
for (var i = 0; i < argv.length; i++) {
|
|
51
|
+
var k = argv[i];
|
|
52
|
+
if (k === "--strings") a.strings = argv[++i];
|
|
53
|
+
else if (k === "--out") a.out = argv[++i];
|
|
54
|
+
else if (k === "--hide-switcher") a.hideSwitcher = true;
|
|
55
|
+
else if (k === "--dry-run") a.dryRun = true;
|
|
56
|
+
else if (k === "-h" || k === "--help") { usage(); process.exit(0); }
|
|
57
|
+
else if (k[0] === "-") { console.error("Unknown flag: " + k); process.exit(2); }
|
|
58
|
+
else if (!a.input) a.input = k;
|
|
59
|
+
else { console.error("Unexpected arg: " + k); process.exit(2); }
|
|
60
|
+
}
|
|
61
|
+
if (!a.input || !a.strings) { usage(); process.exit(2); }
|
|
62
|
+
return a;
|
|
63
|
+
}
|
|
64
|
+
function usage() {
|
|
65
|
+
console.error("Usage: scorm-i18n <package.zip | dir> --strings file.json [options]");
|
|
66
|
+
console.error(" --out <path> output zip path");
|
|
67
|
+
console.error(" --hide-switcher bundle without the floating lang selector UI");
|
|
68
|
+
console.error(" --dry-run print plan, change nothing");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------- zip ------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
function unzipToTemp(zipPath) {
|
|
74
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), "scorm-i18n-"));
|
|
75
|
+
var r = spawnSync("unzip", ["-q", "-o", zipPath, "-d", tmp]);
|
|
76
|
+
if (r.status !== 0) throw new Error("unzip: " + r.stderr.toString());
|
|
77
|
+
verifyConfinement(tmp);
|
|
78
|
+
return tmp;
|
|
79
|
+
}
|
|
80
|
+
function zipFromDir(dir, outZip) {
|
|
81
|
+
if (fs.existsSync(outZip)) fs.unlinkSync(outZip);
|
|
82
|
+
var r = spawnSync("zip", ["-qrX", outZip, "."], { cwd: dir });
|
|
83
|
+
if (r.status !== 0) throw new Error("zip: " + r.stderr.toString());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------- manifest + injection ------------------------------------------
|
|
87
|
+
|
|
88
|
+
function findLaunchHref(root) {
|
|
89
|
+
var mPath = path.join(root, "imsmanifest.xml");
|
|
90
|
+
if (!fs.existsSync(mPath)) return null;
|
|
91
|
+
var xml = fs.readFileSync(mPath, "utf8");
|
|
92
|
+
var m = /<resource\b[^>]*\bscormtype\s*=\s*["']sco["'][^>]*\bhref\s*=\s*["']([^"']+)["']/i.exec(xml);
|
|
93
|
+
if (!m) m = /<resource\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*\bscormtype\s*=\s*["']sco["']/i.exec(xml);
|
|
94
|
+
if (!m) m = /<resource\b[^>]*\bhref\s*=\s*["']([^"']+)["']/i.exec(xml);
|
|
95
|
+
return m ? m[1] : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function injectScripts(html, stringsName, runtimeName) {
|
|
99
|
+
if (/<script\s+[^>]*src=["']i18n-strings\.js["']/i.test(html) &&
|
|
100
|
+
/<script\s+[^>]*src=["']i18n\.js["']/i.test(html)) return { html: html, injected: false };
|
|
101
|
+
var tags =
|
|
102
|
+
'<script src="' + stringsName + '"></script>\n' +
|
|
103
|
+
'<script src="' + runtimeName + '"></script>\n';
|
|
104
|
+
if (/<\/head>/i.test(html)) {
|
|
105
|
+
return { html: html.replace(/<\/head>/i, tags + "</head>"), injected: true };
|
|
106
|
+
}
|
|
107
|
+
if (/<head\b[^>]*>/i.test(html)) {
|
|
108
|
+
return { html: html.replace(/<head\b[^>]*>/i, function (m) { return m + "\n" + tags; }), injected: true };
|
|
109
|
+
}
|
|
110
|
+
if (/<html\b[^>]*>/i.test(html)) {
|
|
111
|
+
return { html: html.replace(/<html\b[^>]*>/i, function (m) { return m + "\n<head>\n" + tags + "</head>"; }), injected: true };
|
|
112
|
+
}
|
|
113
|
+
return { html: tags + html, injected: true };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------- strings validation --------------------------------------------
|
|
117
|
+
|
|
118
|
+
function validateStrings(strings) {
|
|
119
|
+
var problems = [];
|
|
120
|
+
if (!strings || typeof strings !== "object") {
|
|
121
|
+
problems.push("strings.json is empty or not an object");
|
|
122
|
+
return problems;
|
|
123
|
+
}
|
|
124
|
+
if (!strings.strings || typeof strings.strings !== "object") {
|
|
125
|
+
problems.push("missing top-level `strings` map");
|
|
126
|
+
return problems;
|
|
127
|
+
}
|
|
128
|
+
var langs = Object.keys(strings.strings);
|
|
129
|
+
if (langs.length < 2) problems.push("only " + langs.length + " language(s) — i18n needs ≥ 2");
|
|
130
|
+
if (strings.defaultLang && !strings.strings[strings.defaultLang]) {
|
|
131
|
+
problems.push("defaultLang \"" + strings.defaultLang + "\" not in strings map");
|
|
132
|
+
}
|
|
133
|
+
// Key parity
|
|
134
|
+
if (langs.length >= 2) {
|
|
135
|
+
var base = strings.defaultLang || langs[0];
|
|
136
|
+
var baseKeys = new Set(Object.keys(strings.strings[base]));
|
|
137
|
+
langs.forEach(function (l) {
|
|
138
|
+
if (l === base) return;
|
|
139
|
+
var ks = new Set(Object.keys(strings.strings[l]));
|
|
140
|
+
var missing = [], extra = [];
|
|
141
|
+
baseKeys.forEach(function (k) { if (!ks.has(k)) missing.push(k); });
|
|
142
|
+
ks.forEach(function (k) { if (!baseKeys.has(k)) extra.push(k); });
|
|
143
|
+
if (missing.length) problems.push("[" + l + "] missing " + missing.length + " key(s): " + missing.slice(0, 5).join(", ") + (missing.length > 5 ? "…" : ""));
|
|
144
|
+
if (extra.length) problems.push("[" + l + "] " + extra.length + " key(s) not in " + base + ": " + extra.slice(0, 5).join(", ") + (extra.length > 5 ? "…" : ""));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return problems;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------- main -----------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
function main() {
|
|
153
|
+
var args = parseArgs(process.argv.slice(2));
|
|
154
|
+
if (!fs.existsSync(args.input)) { console.error("Not found: " + args.input); process.exit(2); }
|
|
155
|
+
if (!fs.existsSync(args.strings)) { console.error("Strings file not found: " + args.strings); process.exit(2); }
|
|
156
|
+
|
|
157
|
+
var strings;
|
|
158
|
+
try { strings = JSON.parse(fs.readFileSync(args.strings, "utf8")); }
|
|
159
|
+
catch (e) { console.error("strings.json is not valid JSON: " + e.message); process.exit(2); }
|
|
160
|
+
if (args.hideSwitcher) strings.hideSwitcher = true;
|
|
161
|
+
|
|
162
|
+
var problems = validateStrings(strings);
|
|
163
|
+
problems.forEach(function (p) { console.warn("warn " + p); });
|
|
164
|
+
|
|
165
|
+
var runtimeSrc = path.join(__dirname, "runtime", "i18n.js");
|
|
166
|
+
if (!fs.existsSync(runtimeSrc)) { console.error("Runtime missing at " + runtimeSrc); process.exit(2); }
|
|
167
|
+
|
|
168
|
+
var inputIsZip = fs.statSync(args.input).isFile();
|
|
169
|
+
var root, cleanup = function () {};
|
|
170
|
+
if (inputIsZip) {
|
|
171
|
+
root = unzipToTemp(args.input);
|
|
172
|
+
cleanup = function () { try { fs.rmSync(root, { recursive: true, force: true }); } catch (e) {} };
|
|
173
|
+
} else {
|
|
174
|
+
root = path.resolve(args.input);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
var launchHref = findLaunchHref(root);
|
|
179
|
+
if (!launchHref) { console.error("No launch HTML in manifest."); process.exit(2); }
|
|
180
|
+
var launchPath = path.resolve(root, launchHref.split("?")[0]);
|
|
181
|
+
if (!fs.existsSync(launchPath)) { console.error("Launch HTML missing: " + launchHref); process.exit(2); }
|
|
182
|
+
|
|
183
|
+
var langs = Object.keys(strings.strings || {});
|
|
184
|
+
console.log("languages: " + langs.join(", "));
|
|
185
|
+
console.log("default: " + (strings.defaultLang || langs[0]));
|
|
186
|
+
console.log("rtl: " + ((strings.rtl || []).join(", ") || "none"));
|
|
187
|
+
console.log("strings: " + (strings.strings[langs[0]] ? Object.keys(strings.strings[langs[0]]).length : 0) + " keys per language");
|
|
188
|
+
|
|
189
|
+
if (args.dryRun) {
|
|
190
|
+
console.log("\n(dry-run) would write i18n-strings.js + i18n.js next to launch HTML, inject 2 <script> tags");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
var launchDir = path.dirname(launchPath);
|
|
195
|
+
fs.copyFileSync(runtimeSrc, path.join(launchDir, "i18n.js"));
|
|
196
|
+
fs.writeFileSync(
|
|
197
|
+
path.join(launchDir, "i18n-strings.js"),
|
|
198
|
+
"window.I18N_STRINGS = " + JSON.stringify(strings, null, 2) + ";\n"
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
var html = fs.readFileSync(launchPath, "utf8");
|
|
202
|
+
var inj = injectScripts(html, "i18n-strings.js", "i18n.js");
|
|
203
|
+
if (inj.injected) {
|
|
204
|
+
fs.writeFileSync(launchPath, inj.html);
|
|
205
|
+
console.log("injected " + path.relative(root, launchPath));
|
|
206
|
+
} else {
|
|
207
|
+
console.log("inject skipped (already wrapped); strings refreshed");
|
|
208
|
+
}
|
|
209
|
+
console.log("copied i18n.js");
|
|
210
|
+
console.log("wrote i18n-strings.js");
|
|
211
|
+
|
|
212
|
+
if (inputIsZip) {
|
|
213
|
+
var out = args.out || args.input.replace(/\.zip$/i, "") + "-i18n.zip";
|
|
214
|
+
zipFromDir(root, path.resolve(out));
|
|
215
|
+
console.log("\nwrote " + out);
|
|
216
|
+
}
|
|
217
|
+
} finally {
|
|
218
|
+
cleanup();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
main();
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* scorm-i18n runtime — runs in the course window at page load.
|
|
3
|
+
*
|
|
4
|
+
* Reads window.I18N_STRINGS (injected by the CLI), determines the active
|
|
5
|
+
* language from (in order):
|
|
6
|
+
* 1. ?lang=xx query param
|
|
7
|
+
* 2. cmi.student_preference.language (if SCORM API is available)
|
|
8
|
+
* 3. <html lang="..."> attribute
|
|
9
|
+
* 4. config.defaultLang
|
|
10
|
+
*
|
|
11
|
+
* Then walks the DOM and:
|
|
12
|
+
* - replaces textContent on [data-i18n="key"] elements with strings[key]
|
|
13
|
+
* - rewrites src on <track data-i18n-track="suffix"> from
|
|
14
|
+
* "video.vtt" → "video.{lang}.vtt"
|
|
15
|
+
* - rewrites src on <source data-i18n-source="suffix"> the same way
|
|
16
|
+
* - sets <html lang> to the active language
|
|
17
|
+
* - injects a small language switcher in the top-right
|
|
18
|
+
*/
|
|
19
|
+
(function () {
|
|
20
|
+
"use strict";
|
|
21
|
+
var data = window.I18N_STRINGS;
|
|
22
|
+
if (!data || !data.strings) { console.warn("[scorm-i18n] no strings"); return; }
|
|
23
|
+
|
|
24
|
+
var langs = Object.keys(data.strings);
|
|
25
|
+
var defaultLang = data.defaultLang || langs[0];
|
|
26
|
+
|
|
27
|
+
function findApi() {
|
|
28
|
+
var win = window, d = 500;
|
|
29
|
+
while (win && d-- > 0) {
|
|
30
|
+
if (win.API) return win.API;
|
|
31
|
+
if (win.parent && win.parent !== win) { win = win.parent; continue; }
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
function getScormLang() {
|
|
37
|
+
// Read without calling LMSInitialize — initializing here would emit a
|
|
38
|
+
// premature `launched` xAPI statement when paired with scorm-xapi, and
|
|
39
|
+
// could also conflict with the course's own init sequence. If the LMS
|
|
40
|
+
// hasn't been initialized yet, LMSGetValue returns "" and we move on.
|
|
41
|
+
var api = findApi();
|
|
42
|
+
if (!api) return null;
|
|
43
|
+
try {
|
|
44
|
+
var v = api.LMSGetValue("cmi.student_preference.language");
|
|
45
|
+
return v || null;
|
|
46
|
+
} catch (e) { return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pick() {
|
|
50
|
+
var q = (location.search.match(/[?&]lang=([^&]+)/) || [])[1];
|
|
51
|
+
if (q && data.strings[q]) return q;
|
|
52
|
+
var scorm = getScormLang();
|
|
53
|
+
if (scorm && data.strings[scorm]) return scorm;
|
|
54
|
+
try {
|
|
55
|
+
var ls = localStorage.getItem("scormI18nLang");
|
|
56
|
+
if (ls && data.strings[ls]) return ls;
|
|
57
|
+
} catch (e) {}
|
|
58
|
+
var htmlLang = document.documentElement.getAttribute("lang");
|
|
59
|
+
if (htmlLang && data.strings[htmlLang]) return htmlLang;
|
|
60
|
+
return defaultLang;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function applyText(lang) {
|
|
64
|
+
var strings = data.strings[lang] || {};
|
|
65
|
+
document.querySelectorAll("[data-i18n]").forEach(function (el) {
|
|
66
|
+
var key = el.getAttribute("data-i18n");
|
|
67
|
+
if (strings[key] != null) el.textContent = strings[key];
|
|
68
|
+
});
|
|
69
|
+
document.querySelectorAll("[data-i18n-attr]").forEach(function (el) {
|
|
70
|
+
// data-i18n-attr="title:tooltipKey,aria-label:labelKey"
|
|
71
|
+
el.getAttribute("data-i18n-attr").split(",").forEach(function (pair) {
|
|
72
|
+
var p = pair.split(":");
|
|
73
|
+
if (p.length === 2 && strings[p[1].trim()] != null) {
|
|
74
|
+
el.setAttribute(p[0].trim(), strings[p[1].trim()]);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function applyMedia(lang) {
|
|
81
|
+
function rewrite(src, lang) {
|
|
82
|
+
// Split off query / hash so a "foo.en.mp4?v=2" round-trips correctly.
|
|
83
|
+
var qi = src.search(/[?#]/);
|
|
84
|
+
var base = qi >= 0 ? src.slice(0, qi) : src;
|
|
85
|
+
var tail = qi >= 0 ? src.slice(qi) : "";
|
|
86
|
+
// foo.vtt → foo.<lang>.vtt ; foo.en.vtt → foo.<lang>.vtt
|
|
87
|
+
var m = base.match(/^(.+?)(?:\.[a-z]{2,3}(?:-[A-Z]{2})?)?(\.[a-z0-9]+)$/i);
|
|
88
|
+
if (!m) return src;
|
|
89
|
+
return m[1] + "." + lang + m[2] + tail;
|
|
90
|
+
}
|
|
91
|
+
document.querySelectorAll("track[data-i18n-track]").forEach(function (el) {
|
|
92
|
+
var orig = el.getAttribute("data-i18n-track-orig") || el.getAttribute("src");
|
|
93
|
+
if (!el.getAttribute("data-i18n-track-orig")) el.setAttribute("data-i18n-track-orig", orig);
|
|
94
|
+
el.setAttribute("src", rewrite(orig, lang));
|
|
95
|
+
el.setAttribute("srclang", lang);
|
|
96
|
+
});
|
|
97
|
+
document.querySelectorAll("source[data-i18n-source]").forEach(function (el) {
|
|
98
|
+
var orig = el.getAttribute("data-i18n-source-orig") || el.getAttribute("src");
|
|
99
|
+
if (!el.getAttribute("data-i18n-source-orig")) el.setAttribute("data-i18n-source-orig", orig);
|
|
100
|
+
el.setAttribute("src", rewrite(orig, lang));
|
|
101
|
+
});
|
|
102
|
+
// Force <video> elements to reload their selected source after we changed it.
|
|
103
|
+
document.querySelectorAll("video").forEach(function (v) {
|
|
104
|
+
if (v.querySelector("source[data-i18n-source]")) {
|
|
105
|
+
var t = v.currentTime;
|
|
106
|
+
v.load();
|
|
107
|
+
v.currentTime = t;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function persistChoice(lang) {
|
|
113
|
+
try {
|
|
114
|
+
var api = (function f(w, d) {
|
|
115
|
+
while (w && d-- > 0) { if (w.API) return w.API; if (w.parent && w.parent !== w) w = w.parent; else break; }
|
|
116
|
+
return null;
|
|
117
|
+
})(window, 500);
|
|
118
|
+
if (api) api.LMSSetValue("cmi.student_preference.language", lang);
|
|
119
|
+
} catch (e) {}
|
|
120
|
+
try { localStorage.setItem("scormI18nLang", lang); } catch (e) {}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function injectSwitcher(current) {
|
|
124
|
+
if (langs.length < 2 || data.hideSwitcher) return;
|
|
125
|
+
var wrap = document.createElement("div");
|
|
126
|
+
wrap.id = "i18n-switcher";
|
|
127
|
+
wrap.setAttribute("role", "region");
|
|
128
|
+
wrap.setAttribute("aria-label", "Language selector");
|
|
129
|
+
wrap.style.cssText = "position:fixed;top:8px;right:8px;z-index:99999;background:#fff;" +
|
|
130
|
+
"border:1px solid #ccc;border-radius:6px;padding:4px 8px;font:13px system-ui,sans-serif;" +
|
|
131
|
+
"box-shadow:0 1px 3px rgba(0,0,0,0.1);";
|
|
132
|
+
var sel = document.createElement("select");
|
|
133
|
+
sel.setAttribute("aria-label", "Language");
|
|
134
|
+
sel.style.cssText = "border:0;background:transparent;font:inherit;cursor:pointer;";
|
|
135
|
+
langs.forEach(function (l) {
|
|
136
|
+
var o = document.createElement("option");
|
|
137
|
+
o.value = l;
|
|
138
|
+
o.textContent = (data.names && data.names[l]) || l.toUpperCase();
|
|
139
|
+
if (l === current) o.selected = true;
|
|
140
|
+
sel.appendChild(o);
|
|
141
|
+
});
|
|
142
|
+
sel.addEventListener("change", function () {
|
|
143
|
+
var newLang = sel.value;
|
|
144
|
+
persistChoice(newLang);
|
|
145
|
+
apply(newLang);
|
|
146
|
+
});
|
|
147
|
+
wrap.appendChild(sel);
|
|
148
|
+
document.body.appendChild(wrap);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function apply(lang) {
|
|
152
|
+
document.documentElement.setAttribute("lang", lang);
|
|
153
|
+
document.documentElement.setAttribute("dir",
|
|
154
|
+
data.rtl && data.rtl.indexOf(lang) >= 0 ? "rtl" : "ltr");
|
|
155
|
+
applyText(lang);
|
|
156
|
+
applyMedia(lang);
|
|
157
|
+
var sel = document.querySelector("#i18n-switcher select");
|
|
158
|
+
if (sel && sel.value !== lang) sel.value = lang;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function boot() {
|
|
162
|
+
var lang = pick();
|
|
163
|
+
apply(lang);
|
|
164
|
+
injectSwitcher(lang);
|
|
165
|
+
}
|
|
166
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
|
|
167
|
+
else boot();
|
|
168
|
+
})();
|