scorm-review 1.1.0 → 1.3.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.
Files changed (3) hide show
  1. package/README.md +361 -0
  2. package/dist/cli.js +140 -355
  3. package/package.json +1 -1
package/README.md ADDED
@@ -0,0 +1,361 @@
1
+ # scorm-review
2
+
3
+ > Inject the **coreview** annotation tool into any SCORM package — no server, no browser extension, no setup needed on the reviewer's end.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [What it does](#what-it-does)
10
+ - [How it works](#how-it-works)
11
+ - [Requirements](#requirements)
12
+ - [Installation](#installation)
13
+ - [Option A — Use with npx (no install)](#option-a--use-with-npx-no-install)
14
+ - [Option B — Install globally](#option-b--install-globally)
15
+ - [Usage](#usage)
16
+ - [Convert a SCORM zip](#convert-a-scorm-zip)
17
+ - [Convert a SCORM folder](#convert-a-scorm-folder)
18
+ - [Convert a plain HTML file](#convert-a-plain-html-file)
19
+ - [Specify a custom output path](#specify-a-custom-output-path)
20
+ - [Show help](#show-help)
21
+ - [Output explained](#output-explained)
22
+ - [What gets changed inside your package](#what-gets-changed-inside-your-package)
23
+ - [How to send to a reviewer](#how-to-send-to-a-reviewer)
24
+ - [CLI reference](#cli-reference)
25
+ - [How it works internally](#how-it-works-internally)
26
+ - [Entry point detection](#entry-point-detection)
27
+ - [Script injection](#script-injection)
28
+ - [coreview.js bundling](#coreviewjs-bundling)
29
+ - [Project structure](#project-structure)
30
+ - [Building from source](#building-from-source)
31
+ - [Troubleshooting](#troubleshooting)
32
+
33
+ ---
34
+
35
+ ## What it does
36
+
37
+ `scorm-review` takes a SCORM package (zip file, unzipped folder, or a standalone HTML file) and injects the **coreview** annotation tool into it. The output is a new file your reviewers can open in any browser and leave comments directly on the slides — no install needed on their end.
38
+
39
+ ```
40
+ Before: my-course.zip ← plain SCORM package
41
+ After: my-course_review.zip ← same package + annotation panel built in
42
+ ```
43
+
44
+ Reviewers open `index.html` from the output zip and the annotation sidebar appears automatically.
45
+
46
+ ---
47
+
48
+ ## How it works
49
+
50
+ 1. Opens the input (zip, folder, or HTML file)
51
+ 2. Finds the HTML entry point (`index.html`)
52
+ 3. Injects a `<script>` tag referencing `coreview.js` into that HTML file
53
+ 4. Adds `coreview.js` (the annotation tool) next to the entry HTML
54
+ 5. Saves the modified package as a new zip (or HTML file for single-file input)
55
+
56
+ The original file is never modified. A new `_review` file is always created.
57
+
58
+ ---
59
+
60
+ ## Requirements
61
+
62
+ - [Node.js](https://nodejs.org/) **v18 or higher**
63
+
64
+ That's all. No other tools, no browser extensions, no server.
65
+
66
+ To check your Node.js version:
67
+
68
+ ```bash
69
+ node --version
70
+ # Should print v18.x.x or higher
71
+ ```
72
+
73
+ If you don't have Node.js, download the LTS version from [nodejs.org](https://nodejs.org).
74
+
75
+ ---
76
+
77
+ ## Installation
78
+
79
+ ### Option A — Use with npx (no install)
80
+
81
+ Run it directly without installing anything:
82
+
83
+ ```bash
84
+ npx scorm-review ./my-course.zip
85
+ ```
86
+
87
+ `npx` is included with Node.js. On first run it fetches the package automatically.
88
+
89
+ ### Option B — Install globally
90
+
91
+ Install once, use from anywhere without the `npx` prefix:
92
+
93
+ ```bash
94
+ npm install -g scorm-review
95
+ ```
96
+
97
+ Then:
98
+
99
+ ```bash
100
+ scorm-review ./my-course.zip
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Usage
106
+
107
+ ### Convert a SCORM zip
108
+
109
+ ```bash
110
+ npx scorm-review ./my-course.zip
111
+ ```
112
+
113
+ Output in the same folder:
114
+
115
+ ```
116
+ Input: my-course.zip
117
+ Output: C:\path\to\my-course_review.zip
118
+
119
+ Processing SCORM zip... done.
120
+
121
+ ✓ Created: my-course_review.zip (15.2 MB)
122
+ ```
123
+
124
+ ### Convert a SCORM folder
125
+
126
+ If your SCORM package is already unzipped:
127
+
128
+ ```bash
129
+ npx scorm-review ./my-course/
130
+ ```
131
+
132
+ The tool walks the folder, finds `index.html`, injects the annotation tool, and packages everything back into a zip.
133
+
134
+ ### Convert a plain HTML file
135
+
136
+ For a standalone HTML file (not a SCORM package):
137
+
138
+ ```bash
139
+ npx scorm-review ./page.html
140
+ ```
141
+
142
+ Output: `page_review.html` — the script is embedded inline so no separate `coreview.js` file is needed.
143
+
144
+ ### Specify a custom output path
145
+
146
+ Use `-o` or `--output` to choose where the output file is saved:
147
+
148
+ ```bash
149
+ npx scorm-review ./my-course.zip -o ./output/my-course_review.zip
150
+ ```
151
+
152
+ The output directory is created automatically if it doesn't exist.
153
+
154
+ ### Show help
155
+
156
+ ```bash
157
+ npx scorm-review --help
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Output explained
163
+
164
+ | Input | Output file | Notes |
165
+ |---|---|---|
166
+ | `my-course.zip` | `my-course_review.zip` | New zip with `coreview.js` added and `index.html` injected |
167
+ | `my-course/` (folder) | `my-course_review.zip` | Folder re-zipped with `coreview.js` added and `index.html` injected |
168
+ | `page.html` | `page_review.html` | Single file — script embedded inline |
169
+
170
+ The output is always saved **next to the input file** unless you specify `-o`.
171
+
172
+ ---
173
+
174
+ ## What gets changed inside your package
175
+
176
+ Only two things change inside the output zip compared to the original:
177
+
178
+ 1. **`coreview.js` is added** — placed in the same folder as `index.html`
179
+ 2. **One `<script>` tag is added to `index.html`** — injected just before `</body>`
180
+
181
+ ```html
182
+ <!-- Added by scorm-review -->
183
+ <script
184
+ src="./coreview.js"
185
+ data-project="scorm-review"
186
+ data-note="Please review all slides. Check layout, wording, and content."
187
+ data-start-open="false">
188
+ </script>
189
+ ```
190
+
191
+ Everything else — folder structure, file names, SCORM manifest, assets — is **untouched**.
192
+
193
+ If a `coreview.js` script tag already exists in the HTML, it is replaced rather than duplicated.
194
+
195
+ ---
196
+
197
+ ## How to send to a reviewer
198
+
199
+ 1. Run `scorm-review` on your SCORM package
200
+ 2. Send the `_review.zip` to your reviewer (email, shared drive, etc.)
201
+ 3. Reviewer unzips it and opens `index.html` in any modern browser (Chrome, Edge, Firefox, Safari)
202
+ 4. The annotation panel appears automatically — they can leave comments on any slide
203
+ 5. They share their annotated session back to you (via the coreview export feature)
204
+
205
+ No installs, no accounts, no browser extensions required on the reviewer's side.
206
+
207
+ ---
208
+
209
+ ## CLI reference
210
+
211
+ ```
212
+ scorm-review <input> [options]
213
+
214
+ Arguments:
215
+ <input> Path to a .zip file, a folder, or a .html file
216
+
217
+ Options:
218
+ -o, --output <path> Custom output file path (default: <name>_review.zip next to input)
219
+ -h, --help Show help message
220
+
221
+ Supported input types:
222
+ .zip SCORM package zip file
223
+ folder/ Unzipped SCORM package directory
224
+ .html / .htm Plain HTML file
225
+
226
+ Exit codes:
227
+ 0 Success
228
+ 1 Error (path not found, unsupported file type, no HTML entry point found)
229
+ ```
230
+
231
+ ---
232
+
233
+ ## How it works internally
234
+
235
+ ### Entry point detection
236
+
237
+ The tool looks for the HTML entry point in this priority order:
238
+
239
+ 1. `index.html` at the root of the zip/folder
240
+ 2. `Index.html` at the root (case-insensitive fallback)
241
+ 3. `<subfolder>/index.html` — one level deep (common in SCORM packages where content is inside `scorm_package/`)
242
+ 4. Any `.html` file anywhere in the package
243
+
244
+ If no HTML file is found, the tool exits with an error.
245
+
246
+ ### Script injection
247
+
248
+ The `<script>` tag is injected just before `</body>`. If the HTML has no `</body>` tag, the script is appended to the end of the file.
249
+
250
+ Any existing `coreview.js` script tags are removed before injecting the new one — so re-running the tool on an already-injected package is safe and idempotent.
251
+
252
+ ### coreview.js bundling
253
+
254
+ `coreview.js` is **baked into the CLI binary at build time** using esbuild. This means:
255
+
256
+ - The CLI is a single file with no runtime file dependencies
257
+ - The same version of the annotation tool ships with every release
258
+ - No network access is needed at runtime — everything is self-contained
259
+
260
+ ---
261
+
262
+ ## Project structure
263
+
264
+ ```
265
+ scorm-review-cli/
266
+ ├── src/
267
+ │ ├── cli.ts # Entry point — argument parsing, file I/O, orchestration
268
+ │ ├── bundler.ts # Core logic — zip handling, HTML injection, folder packing
269
+ │ └── bundler-template.ts # Template used during the build to embed coreview.js
270
+ ├── dist/ # Compiled output (generated by build)
271
+ │ └── cli.js # Single-file compiled CLI (published to npm)
272
+ ├── build.mjs # esbuild build script — compiles + inlines coreview.js
273
+ ├── tsconfig.json # TypeScript configuration
274
+ └── package.json
275
+ ```
276
+
277
+ ### Key files
278
+
279
+ **`src/cli.ts`** — Handles all CLI concerns: argument parsing, input validation, calling the right bundler function, and writing the output file. Contains no bundling logic itself.
280
+
281
+ **`src/bundler.ts`** — Contains three exported functions:
282
+ - `bundleScormZip(zipBuffer, coreviewScript)` — processes a zip Buffer
283
+ - `bundleScormFolder(folderPath, coreviewScript)` — processes a folder on disk
284
+ - `bundlePlainHtml(htmlBuffer, coreviewScript)` — processes a standalone HTML file
285
+
286
+ **`build.mjs`** — esbuild script that reads `coreview.js` from disk, base64-encodes it (or inlines it as a string), and substitutes the `__COREVIEW_SCRIPT__` constant at bundle time. The result is `dist/cli.js`, a single portable file with no external dependencies.
287
+
288
+ ---
289
+
290
+ ## Building from source
291
+
292
+ ```bash
293
+ # Install dev dependencies
294
+ npm install
295
+
296
+ # Build dist/cli.js
297
+ npm run build
298
+ ```
299
+
300
+ The build script (`build.mjs`) uses esbuild to:
301
+ 1. Compile TypeScript to JavaScript
302
+ 2. Bundle all imports into a single file
303
+ 3. Inline `coreview.js` as a string constant at build time
304
+ 4. Output `dist/cli.js` with the Node.js shebang line
305
+
306
+ To test the built binary locally:
307
+
308
+ ```bash
309
+ node dist/cli.js ./samplefolder/sample3.zip
310
+ ```
311
+
312
+ ---
313
+
314
+ ## Troubleshooting
315
+
316
+ **`npx: command not found`**
317
+
318
+ Node.js is not installed or not on your PATH. Download from [nodejs.org](https://nodejs.org) and reinstall.
319
+
320
+ ---
321
+
322
+ **`Error: Path not found: ...`**
323
+
324
+ The file or folder you specified doesn't exist at that path. Check for typos, or use the full absolute path:
325
+
326
+ ```bash
327
+ npx scorm-review "C:\Users\yourname\Desktop\my-course.zip"
328
+ ```
329
+
330
+ ---
331
+
332
+ **`Error: No HTML entry point found`**
333
+
334
+ The zip doesn't contain an `index.html`. Possible causes:
335
+ - You pointed at a zip that isn't a SCORM package
336
+ - The SCORM package has a non-standard entry point (e.g. `story.html` instead of `index.html`)
337
+ - The `index.html` is nested more than one level deep
338
+
339
+ Try unzipping manually and pointing the tool at the folder that contains `index.html`:
340
+
341
+ ```bash
342
+ npx scorm-review ./unzipped-course/
343
+ ```
344
+
345
+ ---
346
+
347
+ **`Error: Unsupported file type ".pptx"`**
348
+
349
+ Only `.zip`, `.html`, `.htm`, and folders are accepted. Other file types are not supported.
350
+
351
+ ---
352
+
353
+ **Output zip is much larger than the original**
354
+
355
+ This is expected when the original zip used maximum compression. The tool re-compresses with level 6 (balanced), which may produce a slightly larger file compared to an original that was compressed at level 9. The content is identical.
356
+
357
+ ---
358
+
359
+ **Running the tool again on a `_review.zip` adds a second injection**
360
+
361
+ Re-running on an already-processed file is safe — the tool removes any existing `coreview.js` script tag before injecting a new one, so no duplication occurs.
package/dist/cli.js CHANGED
@@ -9702,9 +9702,9 @@ var require_load = __commonJS({
9702
9702
  var require_lib3 = __commonJS({
9703
9703
  "node_modules/jszip/lib/index.js"(exports2, module2) {
9704
9704
  "use strict";
9705
- function JSZip3() {
9706
- if (!(this instanceof JSZip3)) {
9707
- return new JSZip3();
9705
+ function JSZip2() {
9706
+ if (!(this instanceof JSZip2)) {
9707
+ return new JSZip2();
9708
9708
  }
9709
9709
  if (arguments.length) {
9710
9710
  throw new Error("The constructor with parameters has been removed in JSZip 3.0, please check the upgrade guide.");
@@ -9713,7 +9713,7 @@ var require_lib3 = __commonJS({
9713
9713
  this.comment = null;
9714
9714
  this.root = "";
9715
9715
  this.clone = function() {
9716
- var newObj = new JSZip3();
9716
+ var newObj = new JSZip2();
9717
9717
  for (var i in this) {
9718
9718
  if (typeof this[i] !== "function") {
9719
9719
  newObj[i] = this[i];
@@ -9722,354 +9722,150 @@ var require_lib3 = __commonJS({
9722
9722
  return newObj;
9723
9723
  };
9724
9724
  }
9725
- JSZip3.prototype = require_object();
9726
- JSZip3.prototype.loadAsync = require_load();
9727
- JSZip3.support = require_support();
9728
- JSZip3.defaults = require_defaults();
9729
- JSZip3.version = "3.10.1";
9730
- JSZip3.loadAsync = function(content, options) {
9731
- return new JSZip3().loadAsync(content, options);
9725
+ JSZip2.prototype = require_object();
9726
+ JSZip2.prototype.loadAsync = require_load();
9727
+ JSZip2.support = require_support();
9728
+ JSZip2.defaults = require_defaults();
9729
+ JSZip2.version = "3.10.1";
9730
+ JSZip2.loadAsync = function(content, options) {
9731
+ return new JSZip2().loadAsync(content, options);
9732
9732
  };
9733
- JSZip3.external = require_external();
9734
- module2.exports = JSZip3;
9733
+ JSZip2.external = require_external();
9734
+ module2.exports = JSZip2;
9735
9735
  }
9736
9736
  });
9737
9737
 
9738
9738
  // src/cli.ts
9739
9739
  var import_fs2 = __toESM(require("fs"));
9740
9740
  var import_path2 = __toESM(require("path"));
9741
- var import_jszip2 = __toESM(require_lib3());
9742
9741
 
9743
9742
  // src/bundler.ts
9744
9743
  var import_jszip = __toESM(require_lib3());
9745
9744
  var import_fs = __toESM(require("fs"));
9746
9745
  var import_path = __toESM(require("path"));
9747
-
9748
- // src/bundler-template.ts
9749
- var UNPACKER_SCRIPT = `
9750
- document.addEventListener('DOMContentLoaded', async function() {
9751
- var loading = document.getElementById('__bundler_loading');
9752
- function setStatus(msg) { if (loading) loading.textContent = msg; }
9753
-
9754
- window.addEventListener('error', function(e) {
9755
- var p = document.body || document.documentElement;
9756
- var d = document.getElementById('__bundler_err') || p.appendChild(document.createElement('div'));
9757
- d.id = '__bundler_err';
9758
- d.style.cssText = 'position:fixed;bottom:12px;left:12px;right:12px;font:12px/1.4 ui-monospace,monospace;background:#2a1215;color:#ff8a80;padding:10px 14px;border-radius:8px;border:1px solid #5c2b2e;z-index:99999;white-space:pre-wrap;max-height:40vh;overflow:auto';
9759
- d.textContent = (d.textContent ? d.textContent + '\\n' : '') +
9760
- '[bundle] ' + (e.message || e.type) +
9761
- (e.filename ? ' (' + e.filename.slice(0,60) + ':' + e.lineno + ')' : '');
9762
- }, true);
9763
-
9764
- try {
9765
- var manifestEl = document.querySelector('script[type="__bundler/manifest"]');
9766
- var templateEl = document.querySelector('script[type="__bundler/template"]');
9767
- if (!manifestEl || !templateEl) {
9768
- setStatus('Error: missing bundle data');
9769
- return;
9770
- }
9771
-
9772
- var manifest = JSON.parse(manifestEl.textContent);
9773
- var template = JSON.parse(templateEl.textContent);
9774
-
9775
- var uuids = Object.keys(manifest);
9776
- setStatus('Unpacking ' + uuids.length + ' assets...');
9777
-
9778
- var blobUrls = {};
9779
- await Promise.all(uuids.map(async function(uuid) {
9780
- var entry = manifest[uuid];
9781
- try {
9782
- var binaryStr = atob(entry.data);
9783
- var bytes = new Uint8Array(binaryStr.length);
9784
- for (var i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
9785
- var finalBytes = bytes;
9786
- if (entry.compressed && typeof DecompressionStream !== 'undefined') {
9787
- var ds = new DecompressionStream('gzip');
9788
- var writer = ds.writable.getWriter();
9789
- var reader = ds.readable.getReader();
9790
- writer.write(bytes); writer.close();
9791
- var chunks = [], totalLen = 0;
9792
- while (true) {
9793
- var res = await reader.read();
9794
- if (res.done) break;
9795
- chunks.push(res.value); totalLen += res.value.length;
9796
- }
9797
- finalBytes = new Uint8Array(totalLen);
9798
- var offset = 0;
9799
- for (var ci = 0; ci < chunks.length; ci++) { finalBytes.set(chunks[ci], offset); offset += chunks[ci].length; }
9800
- }
9801
- blobUrls[uuid] = URL.createObjectURL(new Blob([finalBytes], { type: entry.mime }));
9802
- } catch(err) {
9803
- console.error('Failed to decode asset ' + uuid + ':', err);
9804
- blobUrls[uuid] = URL.createObjectURL(new Blob([], { type: entry.mime }));
9805
- }
9806
- }));
9807
-
9808
- setStatus('Rendering...');
9809
- for (var uuid of uuids) template = template.split(uuid).join(blobUrls[uuid]);
9810
-
9811
- template = template.replace(/\\s+integrity="[^"]*"/gi, '').replace(/\\s+crossorigin="[^"]*"/gi, '');
9812
-
9813
- var resourceScript = '<script>window.__resources = ' +
9814
- JSON.stringify({}).split('</' + 'script>').join('<\\\\/' + 'script>') +
9815
- ';</' + 'script>';
9816
-
9817
- var headOpen = template.match(/<head[^>]*>/i);
9818
- if (headOpen) {
9819
- var hi = headOpen.index + headOpen[0].length;
9820
- template = template.slice(0, hi) + resourceScript + template.slice(hi);
9821
- }
9822
-
9823
- var doc = new DOMParser().parseFromString(template, 'text/html');
9824
- document.documentElement.replaceWith(doc.documentElement);
9825
- var dead = Array.from(document.scripts);
9826
- for (var old of dead) {
9827
- var s = document.createElement('script');
9828
- for (var a of old.attributes) s.setAttribute(a.name, a.value);
9829
- s.textContent = old.textContent;
9830
- if ((s.type === 'text/babel' || s.type === 'text/jsx') && s.src) {
9831
- var r = await fetch(s.src);
9832
- s.textContent = await r.text();
9833
- s.removeAttribute('src');
9834
- }
9835
- var p = s.src ? new Promise(function(resolve) { s.onload = s.onerror = resolve; }) : null;
9836
- old.replaceWith(s);
9837
- if (p) await p;
9838
- }
9839
- if (window.Babel && typeof window.Babel.transformScriptTags === 'function') {
9840
- window.Babel.transformScriptTags();
9841
- }
9842
-
9843
- // Inject annotate review tool
9844
- window.AnnotateConfig = {
9845
- project: document.title || 'scorm-review',
9846
- note: 'Please review all slides. Check layout, wording, and content.',
9847
- startOpen: false,
9848
- theme: 'auto'
9849
- };
9850
- await new Promise(function(resolve) {
9851
- var ann = document.createElement('script');
9852
- ann.id = '__annotate_inline';
9853
- ann.textContent = window.__ANNOTATE_SCRIPT__;
9854
- document.head.appendChild(ann);
9855
- resolve();
9856
- });
9857
-
9858
- } catch(err) {
9859
- setStatus('Error unpacking: ' + err.message);
9860
- console.error('Bundle unpack error:', err);
9746
+ function findEntryHtml(files) {
9747
+ const norm = files.map((f) => f.replace(/\\/g, "/"));
9748
+ const rootCandidates = [
9749
+ "index.html",
9750
+ "Index.html",
9751
+ "story.html",
9752
+ "Story.html",
9753
+ "index_lms.html",
9754
+ "launch.html",
9755
+ "default.html"
9756
+ ];
9757
+ for (const candidate of rootCandidates) {
9758
+ const match = norm.find((f) => f.toLowerCase() === candidate.toLowerCase());
9759
+ if (match) return match;
9861
9760
  }
9862
- });
9863
- `;
9864
- var THUMBNAIL_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#6366f1"></rect><text x="50" y="58" font-size="32" font-family="Arial" font-weight="bold" text-anchor="middle" fill="#fff">\u25B6</text></svg>`;
9865
-
9866
- // src/bundler.ts
9867
- var MIME_MAP = {
9868
- // Images
9869
- png: "image/png",
9870
- jpg: "image/jpeg",
9871
- jpeg: "image/jpeg",
9872
- gif: "image/gif",
9873
- webp: "image/webp",
9874
- svg: "image/svg+xml",
9875
- ico: "image/x-icon",
9876
- avif: "image/avif",
9877
- // Audio / Video
9878
- mp3: "audio/mpeg",
9879
- mp4: "video/mp4",
9880
- ogg: "audio/ogg",
9881
- ogv: "video/ogg",
9882
- wav: "audio/wav",
9883
- webm: "video/webm",
9884
- m4a: "audio/m4a",
9885
- // Fonts
9886
- woff: "font/woff",
9887
- woff2: "font/woff2",
9888
- ttf: "font/ttf",
9889
- otf: "font/otf",
9890
- eot: "application/vnd.ms-fontobject",
9891
- // Scripts / Styles
9892
- js: "application/javascript",
9893
- mjs: "application/javascript",
9894
- css: "text/css",
9895
- json: "application/json",
9896
- xml: "application/xml",
9897
- // Docs
9898
- pdf: "application/pdf"
9899
- };
9900
- function getMime(filename) {
9901
- const ext = filename.split(".").pop()?.toLowerCase() ?? "";
9902
- return MIME_MAP[ext] ?? "application/octet-stream";
9903
- }
9904
- function makeUUID() {
9905
- const hex = () => Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
9906
- return `${hex()}-${hex().slice(0, 4)}-4${hex().slice(0, 3)}-${hex().slice(0, 4)}-${hex()}${hex().slice(0, 4)}`;
9761
+ const nestedCandidates = ["index.html", "story.html", "index_lms.html", "launch.html", "default.html"];
9762
+ for (const candidate of nestedCandidates) {
9763
+ const match = norm.find((f) => new RegExp(`^[^/]+/${candidate}$`, "i").test(f));
9764
+ if (match) return match;
9765
+ }
9766
+ const rootHtml = norm.find((f) => /^[^/]+\.html?$/i.test(f));
9767
+ if (rootHtml) return rootHtml;
9768
+ const anyHtml = norm.find((f) => /\.html?$/i.test(f));
9769
+ return anyHtml ?? null;
9907
9770
  }
9908
- function escapeForJsonString(s) {
9909
- return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
9771
+ function injectCoreviewTag(html) {
9772
+ const scriptTag = `<script src="./coreview.js" data-project="scorm-review" data-note="Please review all slides. Check layout, wording, and content." data-start-open="false"></script>`;
9773
+ html = html.replace(/<script[^>]*src="[^"]*coreview\.js"[^>]*><\/script>\s*/gi, "");
9774
+ const hasBody = /<\/body>/i.test(html);
9775
+ const hasHtml = /<\/html>/i.test(html);
9776
+ if (hasBody && hasHtml) {
9777
+ const bodyIdx = html.search(/<\/body>/i);
9778
+ const htmlIdx = html.search(/<\/html>/i);
9779
+ const betweenBodyAndHtml = html.slice(bodyIdx + "</body>".length, htmlIdx).trim();
9780
+ if (betweenBodyAndHtml.length > 0) {
9781
+ return html.replace(/<\/html>/i, `${scriptTag}
9782
+ </html>`);
9783
+ }
9784
+ return html.replace(/<\/body>/i, `${scriptTag}
9785
+ </body>`);
9786
+ }
9787
+ if (hasBody) {
9788
+ return html.replace(/<\/body>/i, `${scriptTag}
9789
+ </body>`);
9790
+ }
9791
+ if (hasHtml) {
9792
+ return html.replace(/<\/html>/i, `${scriptTag}
9793
+ </html>`);
9794
+ }
9795
+ return html + "\n" + scriptTag;
9910
9796
  }
9911
- async function bundleScormZip(zipBuffer, annotateScript) {
9797
+ async function bundleScormZip(zipBuffer, coreviewScript) {
9912
9798
  const zip = await import_jszip.default.loadAsync(zipBuffer);
9913
- const entryPath = findEntryHtml(zip);
9799
+ const fileNames = Object.keys(zip.files).filter((f) => !zip.files[f].dir);
9800
+ const entryPath = findEntryHtml(fileNames);
9914
9801
  if (!entryPath) throw new Error("No HTML entry point found in the zip file.");
9915
- const entryFile = zip.file(entryPath);
9916
- if (!entryFile) throw new Error(`Entry file "${entryPath}" could not be read.`);
9917
- const templateHtml = await entryFile.async("string");
9918
9802
  const entryDir = entryPath.includes("/") ? entryPath.slice(0, entryPath.lastIndexOf("/") + 1) : "";
9919
- const manifest = {};
9920
- const uuidMap = {};
9921
- const files = Object.values(zip.files).filter((f) => !f.dir);
9922
- for (const file of files) {
9923
- if (file.name === entryPath) continue;
9924
- if (file.name.endsWith("imsmanifest.xml")) continue;
9925
- if (file.name.endsWith(".bat")) continue;
9926
- const mime = getMime(file.name);
9927
- const data = await file.async("base64");
9928
- const uuid = makeUUID();
9929
- manifest[uuid] = { mime, data, compressed: false };
9930
- const relPath = entryDir && file.name.startsWith(entryDir) ? file.name.slice(entryDir.length) : file.name;
9931
- uuidMap[relPath] = uuid;
9932
- if (relPath !== file.name) uuidMap[file.name] = uuid;
9933
- }
9934
- let processedHtml = replaceAssetRefs(templateHtml, uuidMap);
9935
- processedHtml = stripExistingBundle(processedHtml);
9936
- return buildOutputHtml(processedHtml, manifest, annotateScript);
9803
+ const entryFile = zip.file(entryPath);
9804
+ if (!entryFile) throw new Error(`Could not read entry file: ${entryPath}`);
9805
+ const originalHtml = await entryFile.async("string");
9806
+ const modifiedHtml = injectCoreviewTag(originalHtml);
9807
+ zip.file(entryPath, modifiedHtml);
9808
+ zip.file(`${entryDir}coreview.js`, coreviewScript);
9809
+ return zip.generateAsync({
9810
+ type: "nodebuffer",
9811
+ compression: "DEFLATE",
9812
+ compressionOptions: { level: 6 }
9813
+ });
9937
9814
  }
9938
- async function bundleScormFolder(folderPath, annotateScript) {
9939
- function collectFiles(dir, baseDir) {
9815
+ async function bundleScormFolder(folderPath, coreviewScript) {
9816
+ function collectFiles(dir) {
9940
9817
  const results = [];
9941
9818
  for (const entry of import_fs.default.readdirSync(dir)) {
9942
9819
  const fullPath = import_path.default.join(dir, entry);
9943
9820
  const stat = import_fs.default.statSync(fullPath);
9944
9821
  if (stat.isDirectory()) {
9945
- results.push(...collectFiles(fullPath, baseDir));
9822
+ results.push(...collectFiles(fullPath));
9946
9823
  } else {
9947
9824
  results.push(fullPath);
9948
9825
  }
9949
9826
  }
9950
9827
  return results;
9951
9828
  }
9952
- const allFiles = collectFiles(folderPath, folderPath);
9829
+ const allFiles = collectFiles(folderPath);
9953
9830
  const relFiles = allFiles.map((f) => import_path.default.relative(folderPath, f).replace(/\\/g, "/"));
9954
- function findEntryHtmlInFiles(files) {
9955
- if (files.includes("index.html")) return "index.html";
9956
- if (files.includes("Index.html")) return "Index.html";
9957
- const nested = files.find((f) => /^[^/]+\/index\.html$/i.test(f));
9958
- if (nested) return nested;
9959
- const anyHtml = files.find((f) => /\.html?$/i.test(f));
9960
- return anyHtml ?? null;
9961
- }
9962
- const entryRelPath = findEntryHtmlInFiles(relFiles);
9831
+ const entryRelPath = findEntryHtml(relFiles);
9963
9832
  if (!entryRelPath) throw new Error("No HTML entry point found in the folder.");
9964
- const entryAbsPath = import_path.default.join(folderPath, entryRelPath);
9965
- const templateHtml = import_fs.default.readFileSync(entryAbsPath, "utf-8");
9966
- const entryDir = entryRelPath.includes("/") ? entryRelPath.slice(0, entryRelPath.lastIndexOf("/") + 1) : "";
9967
- const manifest = {};
9968
- const uuidMap = {};
9833
+ const zip = new import_jszip.default();
9969
9834
  for (const relFile of relFiles) {
9970
- if (relFile === entryRelPath) continue;
9971
- if (relFile.endsWith("imsmanifest.xml")) continue;
9972
- if (relFile.endsWith(".bat")) continue;
9973
9835
  if (import_path.default.basename(relFile) === ".DS_Store") continue;
9974
9836
  const absPath = import_path.default.join(folderPath, relFile);
9975
- const mime = getMime(relFile);
9976
- const data = import_fs.default.readFileSync(absPath).toString("base64");
9977
- const uuid = makeUUID();
9978
- manifest[uuid] = { mime, data, compressed: false };
9979
- const normalizedRel = relFile.replace(/\\/g, "/");
9980
- const relPath = entryDir && normalizedRel.startsWith(entryDir) ? normalizedRel.slice(entryDir.length) : normalizedRel;
9981
- uuidMap[relPath] = uuid;
9982
- if (relPath !== normalizedRel) uuidMap[normalizedRel] = uuid;
9983
- }
9984
- let processedHtml = replaceAssetRefs(templateHtml, uuidMap);
9985
- processedHtml = stripExistingBundle(processedHtml);
9986
- return buildOutputHtml(processedHtml, manifest, annotateScript);
9987
- }
9988
- function bundlePlainHtml(htmlBuffer, annotateScript) {
9989
- let html = htmlBuffer.toString("utf-8");
9990
- html = stripExistingBundle(html);
9991
- return injectAnnotate(html, annotateScript);
9992
- }
9993
- function findEntryHtml(zip) {
9994
- const files = Object.keys(zip.files);
9995
- if (zip.files["index.html"]) return "index.html";
9996
- if (zip.files["Index.html"]) return "Index.html";
9997
- const nested = files.find((f) => /^[^/]+\/index\.html$/i.test(f));
9998
- if (nested) return nested;
9999
- const anyHtml = files.find((f) => /\.html?$/i.test(f) && !zip.files[f].dir);
10000
- return anyHtml ?? null;
10001
- }
10002
- function replaceAssetRefs(html, uuidMap) {
10003
- const paths = Object.keys(uuidMap).sort((a, b) => b.length - a.length);
10004
- for (const refPath of paths) {
10005
- const uuid = uuidMap[refPath];
10006
- const escaped = refPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10007
- html = html.replace(new RegExp(escaped, "g"), uuid);
9837
+ if (relFile === entryRelPath) {
9838
+ const originalHtml = import_fs.default.readFileSync(absPath, "utf-8");
9839
+ const modifiedHtml = injectCoreviewTag(originalHtml);
9840
+ zip.file(relFile, modifiedHtml);
9841
+ } else {
9842
+ zip.file(relFile, import_fs.default.readFileSync(absPath));
9843
+ }
10008
9844
  }
10009
- return html;
10010
- }
10011
- function stripExistingBundle(html) {
10012
- html = html.replace(/<script[^>]*type="__bundler\/manifest"[^>]*>[\s\S]*?<\/script>/gi, "");
10013
- html = html.replace(/<script[^>]*type="__bundler\/template"[^>]*>[\s\S]*?<\/script>/gi, "");
10014
- html = html.replace(/<script[^>]*type="__bundler\/ext_resources"[^>]*>[\s\S]*?<\/script>/gi, "");
10015
- html = html.replace(/<div[^>]*id="__bundler_thumbnail"[^>]*>[\s\S]*?<\/div>/gi, "");
10016
- html = html.replace(/<div[^>]*id="__bundler_loading"[^>]*>[^<]*<\/div>/gi, "");
10017
- html = html.replace(/<script[^>]*id="__annotate_inline"[^>]*>[\s\S]*?<\/script>/gi, "");
10018
- return html;
9845
+ const entryDir = entryRelPath.includes("/") ? entryRelPath.slice(0, entryRelPath.lastIndexOf("/") + 1) : "";
9846
+ zip.file(`${entryDir}coreview.js`, coreviewScript);
9847
+ return zip.generateAsync({
9848
+ type: "nodebuffer",
9849
+ compression: "DEFLATE",
9850
+ compressionOptions: { level: 6 }
9851
+ });
10019
9852
  }
10020
- function injectAnnotate(html, annotateScript) {
10021
- const annotateTag = `
10022
- <script id="__annotate_inline">
10023
- window.AnnotateConfig = {
10024
- project: document.title || 'review',
10025
- note: 'Please review this document. Check layout, wording, and content.',
10026
- startOpen: false,
10027
- theme: 'auto'
10028
- };
10029
- ${annotateScript}
9853
+ function bundlePlainHtml(htmlBuffer, coreviewScript) {
9854
+ const html = htmlBuffer.toString("utf-8");
9855
+ const scriptTag = `<script id="__coreview_inline">
9856
+ window.AnnotateConfig = { project: document.title || 'review', note: 'Please review this document.', startOpen: false, theme: 'auto' };
9857
+ ${coreviewScript}
10030
9858
  </script>`;
10031
- if (/<\/body>/i.test(html)) {
10032
- return html.replace(/<\/body>/i, `${annotateTag}
9859
+ const cleaned = html.replace(/<script[^>]*id="__coreview_inline"[^>]*>[\s\S]*?<\/script>\s*/gi, "");
9860
+ if (/<\/body>/i.test(cleaned)) {
9861
+ return cleaned.replace(/<\/body>/i, `${scriptTag}
10033
9862
  </body>`);
10034
9863
  }
10035
- return html + annotateTag;
10036
- }
10037
- function buildOutputHtml(templateHtml, manifest, annotateScript) {
10038
- const titleMatch = templateHtml.match(/<title[^>]*>([^<]*)<\/title>/i);
10039
- const title = titleMatch ? titleMatch[1].trim() : "SCORM Review";
10040
- const manifestJson = JSON.stringify(manifest);
10041
- const templateJson = JSON.stringify(templateHtml);
10042
- const annotateEscaped = escapeForJsonString(annotateScript);
10043
- return `<!DOCTYPE html>
10044
- <html>
10045
- <head>
10046
- <meta charset="utf-8">
10047
- <title>${title}</title>
10048
- <style>
10049
- * { margin: 0; padding: 0; box-sizing: border-box; }
10050
- body { background: #faf9f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
10051
- #__bundler_loading { position: fixed; bottom: 20px; right: 20px; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; color: #666; background: #fff; padding: 8px 14px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.12); z-index: 10000; }
10052
- #__bundler_thumbnail { position: fixed; inset: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #faf9f5; z-index: 9999; }
10053
- #__bundler_thumbnail svg { width: 120px; height: 120px; }
10054
- </style>
10055
- </head>
10056
- <body>
10057
- <div id="__bundler_thumbnail">
10058
- ${THUMBNAIL_SVG}
10059
- </div>
10060
- <div id="__bundler_loading">Unpacking...</div>
10061
-
10062
- <script>window.__ANNOTATE_SCRIPT__ = "${annotateEscaped}";</script>
10063
- <script>${UNPACKER_SCRIPT}</script>
10064
-
10065
- <script type="__bundler/manifest">${manifestJson}</script>
10066
- <script type="__bundler/template">${templateJson}</script>
10067
- </body>
10068
- </html>`;
9864
+ return cleaned + "\n" + scriptTag;
10069
9865
  }
10070
9866
 
10071
9867
  // src/cli.ts
10072
- var ANNOTATE_SCRIPT = `\uFEFF/* =============================================================================\r
9868
+ var COREVIEW_SCRIPT = `\uFEFF/* =============================================================================\r
10073
9869
  * coreview.js \u2014 a drop-in visual review & annotation layer for any website.\r
10074
9870
  * Open-source edition \xB7 local-only \xB7 zero backend.\r
10075
9871
  *\r
@@ -12888,39 +12684,33 @@ var ANNOTATE_SCRIPT = `\uFEFF/* ================================================
12888
12684
  `;
12889
12685
  function printHelp() {
12890
12686
  console.log(`
12891
- scorm-review \u2014 Convert SCORM packages to self-contained review HTML files
12687
+ scorm-review \u2014 Inject the coreview review tool into SCORM packages
12892
12688
 
12893
12689
  Usage:
12894
12690
  scorm-review <input> Convert a .zip, folder, or .html file
12895
- scorm-review <input> -o <output> Specify output base name (no extension)
12691
+ scorm-review <input> -o <output> Specify output file path
12896
12692
  scorm-review --help Show this help message
12897
12693
 
12898
12694
  Examples:
12899
12695
  scorm-review ./my-course.zip
12900
12696
  scorm-review ./my-course/
12901
12697
  scorm-review ./page.html
12902
- scorm-review ./my-course.zip -o ./output/my-course
12698
+ scorm-review ./my-course.zip -o ./output/my-course_review.zip
12903
12699
 
12904
- Output (zip or folder input):
12905
- <name>_review_file.html Self-contained review HTML
12906
- <name>_review.zip Zip file containing the HTML (for easy sharing)
12700
+ Output:
12701
+ .zip or folder input \u2192 <name>_review.zip (SCORM package with coreview.js added)
12702
+ .html input \u2192 <name>_review.html (HTML with coreview injected inline)
12907
12703
  `);
12908
12704
  }
12909
- function resolveOutputBase(inputPath, outputFlag) {
12705
+ function resolveOutputPath(inputPath, isFolder, ext, outputFlag) {
12910
12706
  if (outputFlag) return import_path2.default.resolve(outputFlag);
12911
- const resolved = import_path2.default.resolve(inputPath);
12707
+ const resolved = import_path2.default.resolve(inputPath.replace(/[\\/]+$/, ""));
12912
12708
  const dir = import_path2.default.dirname(resolved);
12913
- const base = import_path2.default.basename(resolved.replace(/[\\/]+$/, ""), import_path2.default.extname(resolved));
12914
- return import_path2.default.join(dir, base);
12915
- }
12916
- async function createReviewZip(htmlContent, htmlFilename) {
12917
- const zip = new import_jszip2.default();
12918
- zip.file(htmlFilename, htmlContent);
12919
- return zip.generateAsync({
12920
- type: "nodebuffer",
12921
- compression: "DEFLATE",
12922
- compressionOptions: { level: 6 }
12923
- });
12709
+ const base = import_path2.default.basename(resolved, isFolder ? "" : ext);
12710
+ if (ext === ".html" || ext === ".htm") {
12711
+ return import_path2.default.join(dir, base + "_review.html");
12712
+ }
12713
+ return import_path2.default.join(dir, base + "_review.zip");
12924
12714
  }
12925
12715
  async function main() {
12926
12716
  const args = process.argv.slice(2);
@@ -12955,43 +12745,38 @@ async function main() {
12955
12745
  console.error(`Error: Unsupported file type "${ext}". Only .zip, .html, .htm, or a folder are supported.`);
12956
12746
  process.exit(1);
12957
12747
  }
12958
- const outputBase = resolveOutputBase(resolvedInput, outputFlag);
12959
- const htmlFilename = import_path2.default.basename(outputBase) + "_review_file.html";
12960
- const htmlOutputPath = outputBase + "_review_file.html";
12961
- const zipOutputPath = outputBase + "_review.zip";
12962
- console.log(`Converting: ${import_path2.default.basename(resolvedInput)}${isFolder ? "/" : ""}`);
12963
- console.log(`HTML: ${htmlOutputPath}`);
12964
- if (!ext || ext === ".zip") {
12965
- console.log(`ZIP: ${zipOutputPath}`);
12966
- }
12748
+ const outputPath = resolveOutputPath(inputPath, isFolder, ext, outputFlag);
12749
+ console.log(`Input: ${import_path2.default.basename(resolvedInput)}${isFolder ? "/" : ""}`);
12750
+ console.log(`Output: ${outputPath}`);
12967
12751
  console.log("");
12968
- import_fs2.default.mkdirSync(import_path2.default.dirname(outputBase), { recursive: true });
12752
+ import_fs2.default.mkdirSync(import_path2.default.dirname(outputPath), { recursive: true });
12969
12753
  try {
12970
- let outputHtml;
12971
12754
  if (isFolder) {
12972
12755
  process.stdout.write("Processing SCORM folder...");
12973
- outputHtml = await bundleScormFolder(resolvedInput, ANNOTATE_SCRIPT);
12756
+ const zipBuffer = await bundleScormFolder(resolvedInput, COREVIEW_SCRIPT);
12757
+ console.log(" done.");
12758
+ import_fs2.default.writeFileSync(outputPath, zipBuffer);
12759
+ const sizeMb = (zipBuffer.length / 1024 / 1024).toFixed(1);
12760
+ console.log(`
12761
+ \u2713 Created: ${outputPath} (${sizeMb} MB)`);
12974
12762
  } else if (ext === ".zip") {
12975
12763
  process.stdout.write("Processing SCORM zip...");
12976
12764
  const fileBuffer = import_fs2.default.readFileSync(resolvedInput);
12977
- outputHtml = await bundleScormZip(fileBuffer, ANNOTATE_SCRIPT);
12765
+ const zipBuffer = await bundleScormZip(fileBuffer, COREVIEW_SCRIPT);
12766
+ console.log(" done.");
12767
+ import_fs2.default.writeFileSync(outputPath, zipBuffer);
12768
+ const sizeMb = (zipBuffer.length / 1024 / 1024).toFixed(1);
12769
+ console.log(`
12770
+ \u2713 Created: ${outputPath} (${sizeMb} MB)`);
12978
12771
  } else {
12979
12772
  process.stdout.write("Processing HTML file...");
12980
12773
  const fileBuffer = import_fs2.default.readFileSync(resolvedInput);
12981
- outputHtml = bundlePlainHtml(fileBuffer, ANNOTATE_SCRIPT);
12982
- }
12983
- console.log(" done.");
12984
- import_fs2.default.writeFileSync(htmlOutputPath, outputHtml, "utf-8");
12985
- const htmlSizeMb = (Buffer.byteLength(outputHtml, "utf-8") / 1024 / 1024).toFixed(1);
12986
- console.log(`
12987
- \u2713 HTML: ${htmlOutputPath} (${htmlSizeMb} MB)`);
12988
- if (isFolder || ext === ".zip") {
12989
- process.stdout.write("Creating review zip...");
12990
- const zipBuffer = await createReviewZip(outputHtml, htmlFilename);
12991
- import_fs2.default.writeFileSync(zipOutputPath, zipBuffer);
12992
- const zipSizeMb = (zipBuffer.length / 1024 / 1024).toFixed(1);
12993
- console.log(` done.`);
12994
- console.log(`\u2713 ZIP: ${zipOutputPath} (${zipSizeMb} MB)`);
12774
+ const outputHtml = bundlePlainHtml(fileBuffer, COREVIEW_SCRIPT);
12775
+ console.log(" done.");
12776
+ import_fs2.default.writeFileSync(outputPath, outputHtml, "utf-8");
12777
+ const sizeMb = (Buffer.byteLength(outputHtml, "utf-8") / 1024 / 1024).toFixed(1);
12778
+ console.log(`
12779
+ \u2713 Created: ${outputPath} (${sizeMb} MB)`);
12995
12780
  }
12996
12781
  } catch (err) {
12997
12782
  const msg = err instanceof Error ? err.message : String(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scorm-review",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Convert SCORM zip packages into self-contained review HTML files",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {