@missing-elements/h5p-embed 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -1
- package/lib/crc32.mjs +36 -0
- package/lib/scorm.mjs +25 -3
- package/package.json +3 -3
- package/scorm/scorm.js +26 -3
- package/site/embed.js +20 -11
- package/types/lib/crc32.d.mts +10 -0
- package/types/site/embed.d.ts +1 -1
package/README.md
CHANGED
|
@@ -83,6 +83,7 @@ To update, run the command again with `--force` and redeploy.
|
|
|
83
83
|
| `src=<url>` | The package, required: an `http` or `https` URL, or a path on the player domain. Encode `&`, `#`, `+`, `%` and spaces |
|
|
84
84
|
| `libraries=pack`, `<url>` or `none` | Where libraries come from; several are tried in order. `pack` is this domain's copy, or jsDelivr's without one: a package gets the libraries it lacks from it, and those it carries at the pack's newer versions. `none` turns off `--default-libraries` |
|
|
85
85
|
| `refresh-libraries=off` | Play the libraries a package carries as exported; `pack` then only fills gaps |
|
|
86
|
+
| `downgrade-libraries=off` | Refuse content made for a newer version of a library than any source has, instead of playing it at the newest older one |
|
|
86
87
|
| `frame`, `copyright`, `export`, `icon`, `reporting` | H5P's action bar and its buttons |
|
|
87
88
|
| `fullscreen=off` | No fullscreen button |
|
|
88
89
|
| `preload=auto` | Start fetching media at once |
|
|
@@ -162,7 +163,29 @@ complete such an activity on viewing it instead. Replacing the zip with one writ
|
|
|
162
163
|
belongs to what it was saved in.
|
|
163
164
|
|
|
164
165
|
Checked in Moodle 4.5: the score reaches the gradebook, and a learner who leaves halfway continues
|
|
165
|
-
there on the next launch.
|
|
166
|
+
there on the next launch.
|
|
167
|
+
|
|
168
|
+
Checked in Frappe LMS too, on its `develop` branch, uploaded as a SCORM chapter. A learner who
|
|
169
|
+
leaves halfway continues there, but Frappe records less than Moodle. It marks the lesson done only
|
|
170
|
+
when the content reports a pass, so a quiz needs a pass mark; a failed attempt, or content that
|
|
171
|
+
reports only `completed`, leaves the lesson open. It keeps no score. Frappe sends a file whole
|
|
172
|
+
rather than in ranges, so the package is downloaded in full when the lesson opens, and a large video
|
|
173
|
+
in it starts once the download has reached it.
|
|
174
|
+
|
|
175
|
+
Checked in SCORM Cloud too: the score, whether it passed, the time and the learner's place are
|
|
176
|
+
recorded, and an export without its libraries plays from the parts in the zip. A zip written by
|
|
177
|
+
embed 1.3.0 or earlier cannot be imported there ("IO Error processing zip"), nor, likely, into
|
|
178
|
+
other LMSs that read uploads with Java; write it again with a later version.
|
|
179
|
+
|
|
180
|
+
Checked in ILIAS 11 too, uploaded as a SCORM learning module. The score, whether it passed, the
|
|
181
|
+
time and the learner's place are recorded, but only once Learning Progress is on, which a fresh
|
|
182
|
+
ILIAS has off: without it ILIAS's SCORM player keeps the learner's place and drops every score and
|
|
183
|
+
status the content sends. Turn it on in Administration, then in the module's Learning Progress
|
|
184
|
+
settings choose "Collection of SCORM Items" and tick the item. ILIAS sent the larger packages whole
|
|
185
|
+
rather than in ranges, as Frappe does. With a zip written by embed 1.3.0 or earlier, ILIAS records
|
|
186
|
+
no time for a session the learner ends by closing the tab; write it again with a later version.
|
|
187
|
+
|
|
188
|
+
Other LMSs have not been tried yet.
|
|
166
189
|
|
|
167
190
|
## As a library
|
|
168
191
|
|
package/lib/crc32.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import zlib from 'node:zlib'
|
|
2
|
+
|
|
3
|
+
/** @type {Uint32Array | undefined} */
|
|
4
|
+
let table
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {Uint8Array} bytes
|
|
8
|
+
* @param {number} previous
|
|
9
|
+
*/
|
|
10
|
+
function tableCrc32(bytes, previous) {
|
|
11
|
+
if (!table) {
|
|
12
|
+
table = new Uint32Array(256)
|
|
13
|
+
for (let n = 0; n < 256; n++) {
|
|
14
|
+
let c = n
|
|
15
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
|
16
|
+
table[n] = c >>> 0
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
let crc = ~previous >>> 0
|
|
20
|
+
for (let i = 0; i < bytes.length; i++) crc = table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8)
|
|
21
|
+
return ~crc >>> 0
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* CRC-32 of `bytes`, continuing from `previous`. Native from Node 22.2 on; a table before that.
|
|
26
|
+
* A namespace import rather than a named one, because a named import of a builtin's missing
|
|
27
|
+
* export fails at link time and would take the whole script down on an older Node.
|
|
28
|
+
*
|
|
29
|
+
* A copy of the normalizer's `lib/crc32.mjs`; keep the two in step.
|
|
30
|
+
*
|
|
31
|
+
* @type {(bytes: Uint8Array, previous?: number) => number}
|
|
32
|
+
*/
|
|
33
|
+
export const crc32 =
|
|
34
|
+
typeof zlib.crc32 === 'function'
|
|
35
|
+
? (bytes, previous = 0) => zlib.crc32(bytes, previous)
|
|
36
|
+
: (bytes, previous = 0) => tableCrc32(bytes, previous)
|
package/lib/scorm.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { Writable } from 'node:stream'
|
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import { BlobReader, TextReader, TextWriter, ZipReader, ZipWriter, configure } from '@zip.js/zip.js'
|
|
8
8
|
import { EmbedError, PLAYER_FILES, installed } from './build.mjs'
|
|
9
|
+
import { crc32 } from './crc32.mjs'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Writes a SCORM 1.2 package that plays one `.h5p` in an LMS and reports its result there: the
|
|
@@ -382,6 +383,16 @@ function sha256(file) {
|
|
|
382
383
|
})
|
|
383
384
|
}
|
|
384
385
|
|
|
386
|
+
/**
|
|
387
|
+
* A file's CRC-32, read as a stream.
|
|
388
|
+
* @param {string} file
|
|
389
|
+
*/
|
|
390
|
+
async function fileCrc32(file) {
|
|
391
|
+
let crc = 0
|
|
392
|
+
for await (const chunk of createReadStream(file)) crc = crc32(chunk, crc)
|
|
393
|
+
return crc
|
|
394
|
+
}
|
|
395
|
+
|
|
385
396
|
/**
|
|
386
397
|
* Every file under a folder, as zip paths relative to it, sorted.
|
|
387
398
|
* @param {string} dir
|
|
@@ -533,9 +544,20 @@ export async function buildScorm({ h5p, out, force = false }) {
|
|
|
533
544
|
const zip = new ZipWriter(Writable.toWeb(createWriteStream(partial)))
|
|
534
545
|
await zip.add('imsmanifest.xml', new TextReader(xml))
|
|
535
546
|
for (const [name, content] of files) {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
547
|
+
if ('text' in content) {
|
|
548
|
+
await zip.add(name, new TextReader(content.text), { level: 6 })
|
|
549
|
+
continue
|
|
550
|
+
}
|
|
551
|
+
const blob = await openAsBlob(content.file)
|
|
552
|
+
// The package, the pack's parts and the fonts are compressed already, so they are stored.
|
|
553
|
+
// Written into a stream, an entry gets a data descriptor, and a Java ZipInputStream refuses
|
|
554
|
+
// a stored entry that has one: SCORM Cloud refused exactly those (#92). Passed through with
|
|
555
|
+
// its size and CRC, it gets a complete local header instead, and is still streamed, not held.
|
|
556
|
+
if (name.endsWith('.h5p') || name.endsWith('.woff2')) {
|
|
557
|
+
await zip.add(name, new BlobReader(blob), { passThrough: true, compressionMethod: 0, uncompressedSize: blob.size, crc32: await fileCrc32(content.file) })
|
|
558
|
+
} else {
|
|
559
|
+
await zip.add(name, new BlobReader(blob), { level: 6 })
|
|
560
|
+
}
|
|
539
561
|
}
|
|
540
562
|
await zip.close()
|
|
541
563
|
await rename(partial, target)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@missing-elements/h5p-embed",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Self-hosted H5P embeds: writes the H5P embed page as a static site for a player domain of your own, then any site frames it with an iframe and one script line; or wraps one .h5p as a SCORM 1.2 package an LMS plays and grades. No H5P server, no third-party service; optional allowlists for package hosts and embedding sites.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@zip.js/zip.js": "^2.24.0",
|
|
62
62
|
"@missing-elements/h5p-libraries": "^1.0.0",
|
|
63
|
-
"@missing-elements/h5p-
|
|
64
|
-
"@missing-elements/h5p-
|
|
63
|
+
"@missing-elements/h5p-offline-player": "^1.2.0",
|
|
64
|
+
"@missing-elements/h5p-runtime": "^1.0.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@types/node": "^26.6.5",
|
package/scorm/scorm.js
CHANGED
|
@@ -151,13 +151,36 @@ player.addEventListener('finished', (event) => {
|
|
|
151
151
|
commit()
|
|
152
152
|
})
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
/*
|
|
155
|
+
* The session ends with the page. Unfinished, it is suspended, so the next launch resumes it.
|
|
156
|
+
*
|
|
157
|
+
* Its time and how it ended are committed on `beforeunload` too, while the page is still there,
|
|
158
|
+
* and the session is finished on `pagehide`, once the page is going for good. ILIAS sends what a
|
|
159
|
+
* closing page writes with a beacon only during `beforeunload`, `unload` or a click; on `pagehide`
|
|
160
|
+
* it tries a synchronous request, which Chrome refuses while a page goes, so the finish alone lost
|
|
161
|
+
* the session's time there (#94). The commit is all `beforeunload` does: the content saves its
|
|
162
|
+
* state for the last time on its own frame's `beforeunload`, which comes after this page's, and a
|
|
163
|
+
* learner asked whether to leave may stay.
|
|
164
|
+
*/
|
|
155
165
|
let ended = false
|
|
166
|
+
|
|
167
|
+
function writeEnd() {
|
|
168
|
+
set('cmi.core.session_time', timespan(Date.now() - started))
|
|
169
|
+
set('cmi.core.exit', finished ? '' : 'suspend')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (initialized) {
|
|
173
|
+
addEventListener('beforeunload', () => {
|
|
174
|
+
if (ended) return
|
|
175
|
+
writeEnd()
|
|
176
|
+
commit()
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
156
180
|
addEventListener('pagehide', () => {
|
|
157
181
|
if (!initialized || ended || !api) return
|
|
158
182
|
ended = true
|
|
159
|
-
|
|
160
|
-
set('cmi.core.exit', finished ? '' : 'suspend')
|
|
183
|
+
writeEnd()
|
|
161
184
|
api.LMSFinish('')
|
|
162
185
|
})
|
|
163
186
|
|
package/site/embed.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* `h5p-embed` was told when it wrote the site, and the demo's `/embed` with the demo's copy of the
|
|
6
6
|
* library pack.
|
|
7
7
|
*
|
|
8
|
-
* ?src=<package url>[&libraries=pack|<url> …|none][&refresh-libraries=off][&preload=auto]
|
|
8
|
+
* ?src=<package url>[&libraries=pack|<url> …|none][&refresh-libraries=off][&downgrade-libraries=off][&preload=auto]
|
|
9
9
|
* [&xapi=<parent origin>][&frame][©right][&export][&icon][&reporting][&fullscreen=off]
|
|
10
10
|
* [&activity-id=<IRI>][&custom-css=<stylesheet url>][&lang=<language tag>]
|
|
11
11
|
*
|
|
@@ -141,20 +141,23 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
|
|
|
141
141
|
* What the player learnt about the package, for the embedding page to show: whether the host
|
|
142
142
|
* streamed it or made the browser download it whole (`source.type`), how big it is, what it
|
|
143
143
|
* says it is (`metadata`), where libraries it did not carry came from (`libraryBundle`, `null`
|
|
144
|
-
* when it carried its own),
|
|
144
|
+
* when it carried its own), which played at an older version than the content names
|
|
145
|
+
* (`downgradedLibraries`, empty when none did), and how long it took here. Posted once, when the
|
|
146
|
+
* content is up:
|
|
145
147
|
*
|
|
146
|
-
* { context: 'h5p-offline-player', action: 'report', source, metadata, libraryBundle,
|
|
148
|
+
* { context: 'h5p-offline-player', action: 'report', source, metadata, libraryBundle,
|
|
149
|
+
* downgradedLibraries, elapsedMs }
|
|
147
150
|
*
|
|
148
151
|
* and to any parent, like the heights: the parent named the package, and the manifest's strings
|
|
149
152
|
* are the package's own to tell. A parent treats them as text. A load that fails before the
|
|
150
153
|
* content is up posts `{ context: 'h5p-offline-player', action: 'error', code, message }`
|
|
151
154
|
* instead, a refusal by this page included (`code: 'refused'`). The shapes are an interface:
|
|
152
|
-
* a site that checks packages builds its preview from them, so
|
|
155
|
+
* a site that checks packages builds its preview from them, so a field is only ever added.
|
|
153
156
|
*/
|
|
154
157
|
let startedAt = 0
|
|
155
158
|
|
|
156
159
|
player.addEventListener('ready', (event) => {
|
|
157
|
-
const { source, metadata, libraryBundle } = /** @type {CustomEvent<ReadyDetail>} */ (event).detail
|
|
160
|
+
const { source, metadata, libraryBundle, downgradedLibraries } = /** @type {CustomEvent<ReadyDetail>} */ (event).detail
|
|
158
161
|
post({
|
|
159
162
|
context: 'h5p-offline-player',
|
|
160
163
|
action: 'report',
|
|
@@ -167,6 +170,8 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
|
|
|
167
170
|
mainLibrary: metadata.mainLibrary
|
|
168
171
|
},
|
|
169
172
|
libraryBundle,
|
|
173
|
+
// Absent from an element older than the page's own; this page and the element ship together.
|
|
174
|
+
downgradedLibraries: downgradedLibraries ?? [],
|
|
170
175
|
elapsedMs: Math.round(performance.now() - startedAt)
|
|
171
176
|
})
|
|
172
177
|
})
|
|
@@ -311,12 +316,13 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
|
|
|
311
316
|
* The element's display options, by their attribute names, for the embedding page to ask for:
|
|
312
317
|
* `&frame©right&export` shows H5P's action bar with those buttons, `&fullscreen=off` takes
|
|
313
318
|
* that one away, `&refresh-libraries=off` plays a package's own libraries even where the pack
|
|
314
|
-
* has newer ones, `&
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
319
|
+
* has newer ones, `&downgrade-libraries=off` refuses content made for a newer version of a
|
|
320
|
+
* library than any source has, `&activity-id=` names the statements' object and `&custom-css=`
|
|
321
|
+
* restyles the content to the embedding site's taste. `&lang=` is the embedding page's
|
|
322
|
+
* language, which this page cannot read across origins: H5P's own buttons and dialogs follow it
|
|
323
|
+
* for a package that does not name its own language, and follow the content's where it does.
|
|
324
|
+
* Not `custom-js`, `embed-code` or `user`: a script is a capability on this origin that a link
|
|
325
|
+
* should not hand out, the embed is the embed, and a learner's name has no place in a URL.
|
|
320
326
|
*/
|
|
321
327
|
const applyOptions = () => {
|
|
322
328
|
for (const name of ['frame', 'copyright', 'export', 'icon', 'reporting']) {
|
|
@@ -326,6 +332,9 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
|
|
|
326
332
|
// The pack's newer versions of the libraries a package carries, unless the embedding page
|
|
327
333
|
// needs the package exactly as exported.
|
|
328
334
|
if (params.get('refresh-libraries') === 'off') player.setAttribute('refresh-libraries', 'off')
|
|
335
|
+
// Content made for a newer version than any source has plays at the older one, unless the
|
|
336
|
+
// embedding page would rather have it refused.
|
|
337
|
+
if (params.get('downgrade-libraries') === 'off') player.setAttribute('downgrade-libraries', 'off')
|
|
329
338
|
for (const name of ['activity-id', 'custom-css']) {
|
|
330
339
|
const value = params.get(name)?.trim()
|
|
331
340
|
if (value) player.setAttribute(name, value)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRC-32 of `bytes`, continuing from `previous`. Native from Node 22.2 on; a table before that.
|
|
3
|
+
* A namespace import rather than a named one, because a named import of a builtin's missing
|
|
4
|
+
* export fails at link time and would take the whole script down on an older Node.
|
|
5
|
+
*
|
|
6
|
+
* A copy of the normalizer's `lib/crc32.mjs`; keep the two in step.
|
|
7
|
+
*
|
|
8
|
+
* @type {(bytes: Uint8Array, previous?: number) => number}
|
|
9
|
+
*/
|
|
10
|
+
export declare const crc32: (bytes: Uint8Array, previous?: number) => number;
|
package/types/site/embed.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* `h5p-embed` was told when it wrote the site, and the demo's `/embed` with the demo's copy of the
|
|
6
6
|
* library pack.
|
|
7
7
|
*
|
|
8
|
-
* ?src=<package url>[&libraries=pack|<url> …|none][&refresh-libraries=off][&preload=auto]
|
|
8
|
+
* ?src=<package url>[&libraries=pack|<url> …|none][&refresh-libraries=off][&downgrade-libraries=off][&preload=auto]
|
|
9
9
|
* [&xapi=<parent origin>][&frame][©right][&export][&icon][&reporting][&fullscreen=off]
|
|
10
10
|
* [&activity-id=<IRI>][&custom-css=<stylesheet url>][&lang=<language tag>]
|
|
11
11
|
*
|