playable-lessons 0.6.4 → 0.8.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.
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ /**
3
+ * SCORM 1.2 export: wrap the standalone story player in a single-SCO package
4
+ * that reports completion to an LMS (Moodle, Canvas, Blackboard, …).
5
+ *
6
+ * Unlike H5P/Twee/EPUB this works for *stateful* stories too — the SCO is the
7
+ * full HTML player, and completion is reported when the story reaches an
8
+ * ending. The package is self-contained (one index.html), so no assets folder
9
+ * is needed.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.exportScorm = exportScorm;
13
+ const storyExport_js_1 = require("./storyExport.js");
14
+ const zip_js_1 = require("./zip.js");
15
+ /** Report completion/exit to a SCORM 1.2 LMS found on the parent chain. */
16
+ const SCORM_WRAPPER = `
17
+ <script>
18
+ (function () {
19
+ function findAPI(win) {
20
+ var tries = 0;
21
+ while (win && !win.API && win.parent && win.parent !== win && tries < 10) {
22
+ win = win.parent;
23
+ tries++;
24
+ }
25
+ return (win && win.API) || null;
26
+ }
27
+ var api = null;
28
+ try { api = findAPI(window); } catch (e) { api = null; }
29
+ if (!api) { try { api = window.opener && window.opener.API; } catch (e) { api = null; } }
30
+ if (!api) return;
31
+
32
+ var done = false;
33
+ function init() {
34
+ try {
35
+ api.LMSInitialize('');
36
+ api.LMSSetValue('cmi.core.lesson_status', 'incomplete');
37
+ api.LMSCommit('');
38
+ } catch (e) {}
39
+ }
40
+ function complete() {
41
+ try {
42
+ api.LMSSetValue('cmi.core.lesson_status', 'completed');
43
+ api.LMSSetValue('cmi.core.exit', '');
44
+ api.LMSCommit('');
45
+ } catch (e) {}
46
+ }
47
+ function check() {
48
+ if (done) return;
49
+ if (document.querySelector('.ending')) { done = true; complete(); }
50
+ }
51
+
52
+ init();
53
+ var target = document.getElementById('content') || document.body;
54
+ if (window.MutationObserver) new MutationObserver(check).observe(target, { childList: true, subtree: true });
55
+ setInterval(check, 1000);
56
+
57
+ window.addEventListener('beforeunload', function () {
58
+ try {
59
+ if (!done) api.LMSSetValue('cmi.core.exit', 'suspend');
60
+ api.LMSCommit('');
61
+ api.LMSFinish('');
62
+ } catch (e) {}
63
+ });
64
+ })();
65
+ </script>
66
+ `;
67
+ /** SCORM 1.2 imsmanifest.xml for a single web-content SCO. */
68
+ function manifest(title) {
69
+ const safeTitle = (0, storyExport_js_1.escapeHtml)(title);
70
+ const id = `com.playablelessons.${Math.random().toString(36).slice(2, 10)}`;
71
+ return `<?xml version="1.0" encoding="UTF-8"?>
72
+ <manifest identifier="${id}" version="1.2"
73
+ xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
74
+ xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
75
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
76
+ xsi:schemaLocation="http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd
77
+ http://www.imsglobal.org/xsd/imsmd_rootv1p2p1 imsmd_rootv1p2p1.xsd
78
+ http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd">
79
+ <metadata>
80
+ <schema>ADL SCORM</schema>
81
+ <schemaversion>1.2</schemaversion>
82
+ </metadata>
83
+ <organizations default="ORG-1">
84
+ <organization identifier="ORG-1">
85
+ <title>${safeTitle}</title>
86
+ <item identifier="ITEM-1" identifierref="RES-1">
87
+ <title>${safeTitle}</title>
88
+ </item>
89
+ </organization>
90
+ </organizations>
91
+ <resources>
92
+ <resource identifier="RES-1" type="webcontent" adlcp:scormtype="sco" href="index.html">
93
+ <file href="index.html"/>
94
+ </resource>
95
+ </resources>
96
+ </manifest>
97
+ `;
98
+ }
99
+ function injectWrapper(html) {
100
+ return html.includes('</body>')
101
+ ? html.replace('</body>', `${SCORM_WRAPPER}</body>`)
102
+ : html + SCORM_WRAPPER;
103
+ }
104
+ /**
105
+ * Build a SCORM 1.2 package (zip bytes) from a story. Never gated on state:
106
+ * the SCO is the same standalone HTML player, so variables/conditionals work.
107
+ */
108
+ async function exportScorm(inkSource, title, sceneImages) {
109
+ const html = injectWrapper(await (0, storyExport_js_1.exportStandaloneHTML)(inkSource, title, sceneImages));
110
+ return (0, zip_js_1.zipStore)([
111
+ { name: 'imsmanifest.xml', data: manifest(title) },
112
+ { name: 'index.html', data: html }
113
+ ]);
114
+ }
@@ -163,7 +163,7 @@ ${favicon_js_1.FAVICON_LINK}
163
163
  <body>
164
164
  <div id="story">
165
165
  <h1>${safeTitle}</h1>
166
- <div id="content"></div>
166
+ <div id="content" role="region" aria-live="polite" aria-label="Story"></div>
167
167
  </div>
168
168
 
169
169
  <!-- inkjs ${inkRuntime_generated_js_1.INKJS_VERSION} runtime embedded inline so this file plays fully offline -->
@@ -190,24 +190,26 @@ ${favicon_js_1.FAVICON_LINK}
190
190
  }
191
191
 
192
192
  // Scene illustrations: resolve IMAGE_PROMPT tags via the embedded map;
193
- // IMAGE tags render only when they carry a usable URL/data URL.
194
- var imgSrcs = [];
193
+ // IMAGE tags render only when they carry a usable URL/data URL. The prompt
194
+ // doubles as alt text so screen readers describe the illustration.
195
+ var imgs = [];
195
196
  tags.forEach(function(tag) {
196
197
  if (tag.indexOf('IMAGE_PROMPT:') === 0) {
197
- var src = sceneImages[tag.substring('IMAGE_PROMPT:'.length).trim()];
198
- if (src) imgSrcs.push(src);
198
+ var prompt = tag.substring('IMAGE_PROMPT:'.length).trim();
199
+ var src = sceneImages[prompt];
200
+ if (src) imgs.push({ src: src, alt: prompt });
199
201
  } else if (tag.indexOf('IMAGE:') === 0) {
200
202
  var val = tag.substring('IMAGE:'.length).trim();
201
203
  // NB: no '//' inside this regex — in this template literal \/ collapses
202
204
  // to / and would turn into a line comment in the generated page.
203
- if (/^(data:|https?:)/.test(val)) imgSrcs.push(val);
205
+ if (/^(data:|https?:)/.test(val)) imgs.push({ src: val, alt: '' });
204
206
  }
205
207
  });
206
- imgSrcs.forEach(function(src) {
208
+ imgs.forEach(function(item) {
207
209
  var img = document.createElement('img');
208
210
  img.className = 'scene-img';
209
- img.src = src;
210
- img.alt = '';
211
+ img.src = item.src;
212
+ img.alt = item.alt;
211
213
  textDiv.insertBefore(img, textDiv.firstChild);
212
214
  });
213
215
 
@@ -3,5 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.APP_VERSION = void 0;
4
4
  // AUTO-GENERATED by scripts/embed-version.mjs — do not edit by hand.
5
5
  // Source: package.json (version). Regenerate: npm run embed:version
6
- /* eslint-disable */
7
- exports.APP_VERSION = "0.6.4";
6
+ exports.APP_VERSION = "0.8.0";
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ /**
3
+ * Minimal ZIP writer shared by the H5P, SCORM, and EPUB exporters.
4
+ *
5
+ * Uses the "store" method (no compression) so the output is byte-identical
6
+ * across runs and the EPUB `mimetype` entry can be first and uncompressed,
7
+ * as the OCF spec requires.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.zipStore = zipStore;
11
+ exports.bytesToBase64 = bytesToBase64;
12
+ exports.base64ToBytes = base64ToBytes;
13
+ const CRC_TABLE = (() => {
14
+ const table = new Uint32Array(256);
15
+ for (let n = 0; n < 256; n++) {
16
+ let c = n;
17
+ for (let k = 0; k < 8; k++)
18
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
19
+ table[n] = c >>> 0;
20
+ }
21
+ return table;
22
+ })();
23
+ function crc32(data) {
24
+ let crc = 0xffffffff;
25
+ for (let i = 0; i < data.length; i++) {
26
+ crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
27
+ }
28
+ return (crc ^ 0xffffffff) >>> 0;
29
+ }
30
+ // Fixed DOS timestamp (2026-01-01 00:00) keeps output byte-identical across runs.
31
+ const DOS_DATE = ((2026 - 1980) << 9) | (1 << 5) | 1;
32
+ const DOS_TIME = 0;
33
+ /** Assemble a store-only (uncompressed) zip. Entry order is preserved. */
34
+ function zipStore(entries) {
35
+ const encoder = new TextEncoder();
36
+ const chunks = [];
37
+ const central = [];
38
+ let offset = 0;
39
+ const u16 = (v) => new Uint8Array([v & 0xff, (v >> 8) & 0xff]);
40
+ const u32 = (v) => new Uint8Array([v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff]);
41
+ const cat = (...parts) => {
42
+ const total = parts.reduce((n, p) => n + p.length, 0);
43
+ const out = new Uint8Array(total);
44
+ let pos = 0;
45
+ for (const p of parts) {
46
+ out.set(p, pos);
47
+ pos += p.length;
48
+ }
49
+ return out;
50
+ };
51
+ for (const entry of entries) {
52
+ const name = encoder.encode(entry.name);
53
+ const data = typeof entry.data === 'string' ? encoder.encode(entry.data) : entry.data;
54
+ const crc = crc32(data);
55
+ // Local file header: flag bit 11 set = UTF-8 names; method 0 = store.
56
+ const local = cat(u32(0x04034b50), u16(20), u16(0x0800), u16(0), u16(DOS_TIME), u16(DOS_DATE), u32(crc), u32(data.length), u32(data.length), u16(name.length), u16(0), name, data);
57
+ central.push(cat(u32(0x02014b50), u16(20), u16(20), u16(0x0800), u16(0), u16(DOS_TIME), u16(DOS_DATE), u32(crc), u32(data.length), u32(data.length), u16(name.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(offset), name));
58
+ chunks.push(local);
59
+ offset += local.length;
60
+ }
61
+ const centralBlob = cat(...central);
62
+ const eocd = cat(u32(0x06054b50), u16(0), u16(0), u16(entries.length), u16(entries.length), u32(centralBlob.length), u32(offset), u16(0));
63
+ return cat(...chunks, centralBlob, eocd);
64
+ }
65
+ /** Base64-encode package bytes (works in both Node and the browser). */
66
+ function bytesToBase64(bytes) {
67
+ const B = globalThis.Buffer;
68
+ if (B)
69
+ return B.from(bytes).toString('base64');
70
+ let binary = '';
71
+ for (let i = 0; i < bytes.length; i += 0x8000) {
72
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
73
+ }
74
+ return btoa(binary);
75
+ }
76
+ /** Decode base64 to bytes (works in both Node and the browser). */
77
+ function base64ToBytes(base64) {
78
+ const B = globalThis.Buffer;
79
+ if (B)
80
+ return B.from(base64, 'base64');
81
+ const binary = atob(base64);
82
+ const out = new Uint8Array(binary.length);
83
+ for (let i = 0; i < binary.length; i++)
84
+ out[i] = binary.charCodeAt(i);
85
+ return out;
86
+ }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "playable-lessons",
3
- "version": "0.6.4",
3
+ "version": "0.8.0",
4
+ "description": "Turn educational source material into playable interactive fiction (Ink, HTML, H5P, EPUB, SCORM).",
4
5
  "main": "./out/main/index.mjs",
5
6
  "scripts": {
6
7
  "dev": "electron-vite dev",
@@ -50,8 +51,11 @@
50
51
  "url": "git+https://github.com/michael-borck/playable-lessons.git"
51
52
  },
52
53
  "homepage": "https://github.com/michael-borck/playable-lessons",
53
- "comment:dependencies": "Runtime deps for the published CLI only. The GUI libraries (react, zustand, @xyflow/react, …) are devDependencies because Vite bundles them into the Electron renderer at build time, so they don't need to be installed by CLI consumers.",
54
+ "comment:dependencies": "Runtime deps for the published CLI and the self-hosted web server. The GUI libraries (react, zustand, @xyflow/react, …) are devDependencies because Vite bundles them into the Electron renderer at build time, so they don't need to be installed by CLI consumers.",
54
55
  "dependencies": {
56
+ "electron-updater": "^6.8.11",
57
+ "express": "^5.3.0",
58
+ "helmet": "^8.3.0",
55
59
  "inkjs": "^2.4.0",
56
60
  "yargs": "^18.0.0"
57
61
  },
@@ -61,16 +65,15 @@
61
65
  "@types/react": "^19.2.14",
62
66
  "@types/react-dom": "^19.2.3",
63
67
  "@types/yargs": "^17.0.35",
64
- "@typescript-eslint/eslint-plugin": "^8.57.2",
65
- "@typescript-eslint/parser": "^8.57.2",
68
+ "@typescript-eslint/eslint-plugin": "^8.72.1",
69
+ "@typescript-eslint/parser": "^8.72.1",
66
70
  "@vitejs/plugin-react": "^6.0.1",
67
71
  "@xyflow/react": "^12.10.2",
68
- "electron": "^41.1.0",
69
- "electron-builder": "^26.8.1",
72
+ "electron": "^45.2.1",
73
+ "electron-builder": "^26.18.0",
70
74
  "electron-vite": "^5.0.0",
71
- "eslint": "^10.1.0",
75
+ "eslint": "^10.14.0",
72
76
  "eslint-plugin-react-hooks": "^5.2.0",
73
- "express": "^5.2.1",
74
77
  "react": "^19.2.4",
75
78
  "react-dom": "^19.2.4",
76
79
  "tsx": "^4.21.0",