@missing-elements/h5p-embed 1.3.1 → 1.3.3
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 +50 -32
- package/lib/scorm.mjs +49 -275
- package/package.json +5 -4
- package/lib/crc32.mjs +0 -36
- package/types/lib/crc32.d.mts +0 -10
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Writes the H5P embed page as a static site for a player domain of your own. Any site then embeds
|
|
4
4
|
a package with an iframe and one script line. No H5P server, no backend, no third-party service.
|
|
5
|
-
With `--scorm`, it writes one package as a SCORM zip instead, for an LMS
|
|
5
|
+
With `--scorm`, it writes one package as a SCORM zip instead, for an LMS such as Moodle, ILIAS,
|
|
6
|
+
OpenOLAT or Chamilo to play and grade (see
|
|
6
7
|
[A SCORM package, for an LMS](#a-scorm-package-for-an-lms)).
|
|
7
8
|
|
|
8
9
|
Needs Node 22 or later. Write `@latest`: without it, npm on an older Node quietly installs the
|
|
@@ -133,8 +134,8 @@ npx @missing-elements/h5p-embed@latest --scorm course.h5p # writes ./course.
|
|
|
133
134
|
```
|
|
134
135
|
|
|
135
136
|
writes one package as a SCORM 1.2 zip: the package, the player and the H5P runtime, and a launch
|
|
136
|
-
page that reports to the LMS. Add it to a course as a SCORM activity, in
|
|
137
|
-
takes SCORM 1.2, and the LMS records:
|
|
137
|
+
page that reports to the LMS. Add it to a course as a SCORM activity, in one of the
|
|
138
|
+
[supported LMSs](#supported-lmss) or another that takes SCORM 1.2, and the LMS records:
|
|
138
139
|
|
|
139
140
|
- the score out of 100, and `passed` or `failed` when the content says whether it was passed,
|
|
140
141
|
`completed` when it does not. The content's last result counts, as on an H5P site;
|
|
@@ -143,9 +144,6 @@ takes SCORM 1.2, and the LMS records:
|
|
|
143
144
|
launch continues from it. SCORM 1.2 keeps 4,096 characters, enough for a quiz; a larger state is
|
|
144
145
|
not saved.
|
|
145
146
|
|
|
146
|
-
Moodle records the score as the grade, out of the activity's maximum grade, so leave that at 100,
|
|
147
|
-
its default.
|
|
148
|
-
|
|
149
147
|
An export from h5p.com or h5p.org, which leaves the libraries out, works as well: the zip carries
|
|
150
148
|
the ones it needs from the library pack, and only those. For a quiz with formulas, that is 11
|
|
151
149
|
libraries, 4 MB, most of it the formula renderer. A package that carries its libraries, as exports
|
|
@@ -154,38 +152,58 @@ a library neither it nor the pack has, or a newer version than the pack's, is re
|
|
|
154
152
|
missing named.
|
|
155
153
|
|
|
156
154
|
The package plays trusted: its own code runs on the LMS's origin, as every SCORM package's does.
|
|
157
|
-
The LMS has to be served over https, since the player needs its Service Worker
|
|
158
|
-
serve files by path, which it does by default (`slasharguments`).
|
|
155
|
+
The LMS has to be served over https, since the player needs its Service Worker.
|
|
159
156
|
|
|
160
157
|
Content that reports no result of its own, an Accordion or a Virtual Tour, stays `incomplete`:
|
|
161
158
|
complete such an activity on viewing it instead. Replacing the zip with one written from another
|
|
162
159
|
`.h5p`, or with other libraries from the pack, starts learners over, since their saved state
|
|
163
160
|
belongs to what it was saved in.
|
|
164
161
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
in
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
162
|
+
### Supported LMSs
|
|
163
|
+
|
|
164
|
+
Checked at these versions, as a learner in Chrome. Other LMSs have not been tried yet.
|
|
165
|
+
|
|
166
|
+
| LMS | Add the zip as | Change from the defaults | The package is read |
|
|
167
|
+
|---|---|---|---|
|
|
168
|
+
| Moodle 4.5 | a SCORM package activity | nothing; keep the maximum grade at 100 | in ranges |
|
|
169
|
+
| ILIAS 11 | a SCORM learning module | turn Learning Progress on | whole |
|
|
170
|
+
| OpenOLAT 21.1 | a "SCORM 1.2" course element | the pass mark; in a learning path, the completion criterion | in ranges |
|
|
171
|
+
| Chamilo 3.0 | an imported learning path | publish the learning path | whole |
|
|
172
|
+
| SCORM Cloud | a course | nothing | in ranges |
|
|
173
|
+
| Frappe LMS, `develop` | a SCORM chapter | nothing; the content needs a pass mark | whole |
|
|
174
|
+
|
|
175
|
+
All of them record the score, whether it passed, the time and the learner's place, except Frappe
|
|
176
|
+
LMS, which records only a pass and the learner's place. Where the package is read whole, it is
|
|
177
|
+
downloaded in full when it opens, and a large video in it starts once the download has reached it;
|
|
178
|
+
a quiz does not notice.
|
|
179
|
+
|
|
180
|
+
**Moodle.** The score goes to the gradebook as the grade, out of the activity's maximum grade, so
|
|
181
|
+
keep that at 100, its default. Moodle has to serve files by path, which it does by default
|
|
182
|
+
(`slasharguments`).
|
|
183
|
+
|
|
184
|
+
**ILIAS.** Learning Progress is off in a fresh ILIAS, and without it ILIAS's SCORM player keeps
|
|
185
|
+
the learner's place and drops every score and status the content sends. Turn it on in
|
|
186
|
+
Administration, then in the module's Learning Progress settings choose "Collection of SCORM Items"
|
|
187
|
+
and tick the item. ILIAS sent the larger packages whole; only one of a few kilobytes came in ranges.
|
|
188
|
+
|
|
189
|
+
**OpenOLAT.** Its grading takes the score with a pass mark of 0, so every result counts as passed, a
|
|
190
|
+
failed quiz too: set "Score needed to pass" to the quiz's own pass mark, or choose "Transfer passed
|
|
191
|
+
value", which takes pass or fail from the content and no score. In a learning-path course the
|
|
192
|
+
element counts as done only once the learner confirms it: choose "Passed" as its completion
|
|
193
|
+
criterion.
|
|
194
|
+
|
|
195
|
+
**Chamilo.** Chamilo imports a learning path unpublished, so publish it before learners can see it.
|
|
196
|
+
Chamilo 1.11 has not been tried.
|
|
197
|
+
|
|
198
|
+
**Frappe LMS.** Frappe marks the lesson done only when the content reports a pass, so a quiz needs a
|
|
199
|
+
pass mark; a failed attempt, or content that reports only `completed`, leaves the lesson open. It
|
|
200
|
+
keeps no score.
|
|
201
|
+
|
|
202
|
+
A zip written by embed 1.3.0 or earlier is worth writing again with the current version. SCORM
|
|
203
|
+
Cloud cannot import it ("IO Error processing zip"), nor, likely, can some other LMSs written in
|
|
204
|
+
Java, though OpenOLAT can; ILIAS keeps no time for a session played from it that the learner ends
|
|
205
|
+
by closing the tab, and Chamilo does not record that such a session was left unfinished, though it
|
|
206
|
+
resumes it.
|
|
189
207
|
|
|
190
208
|
## As a library
|
|
191
209
|
|
package/lib/scorm.mjs
CHANGED
|
@@ -3,10 +3,11 @@ import { createReadStream, createWriteStream, openAsBlob } from 'node:fs'
|
|
|
3
3
|
import { readdir, readFile, rename, rm, stat } from 'node:fs/promises'
|
|
4
4
|
import { basename, dirname, extname, join, resolve, sep } from 'node:path'
|
|
5
5
|
import { Writable } from 'node:stream'
|
|
6
|
-
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
7
7
|
import { BlobReader, TextReader, TextWriter, ZipReader, ZipWriter, configure } from '@zip.js/zip.js'
|
|
8
|
+
import { crc32 } from '@missing-elements/h5p-normalize/crc32'
|
|
9
|
+
import { dependenciesIn, indexLibraryFolders, librariesNamedIn, ownLibraries, planPack, reachableFolder, walkDependencies } from '@missing-elements/h5p-offline-player/plan'
|
|
8
10
|
import { EmbedError, PLAYER_FILES, installed } from './build.mjs'
|
|
9
|
-
import { crc32 } from './crc32.mjs'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Writes a SCORM 1.2 package that plays one `.h5p` in an LMS and reports its result there: the
|
|
@@ -34,266 +35,22 @@ const PACKAGE = 'package.h5p'
|
|
|
34
35
|
const LIBRARIES = 'libraries'
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* @returns {{ machineName: string, major: number, minor: number }[]}
|
|
41
|
-
*/
|
|
42
|
-
function dependenciesIn(json) {
|
|
43
|
-
const list = /** @type {{ preloadedDependencies?: unknown }} */ (json ?? {}).preloadedDependencies
|
|
44
|
-
if (!Array.isArray(list)) return []
|
|
45
|
-
return list.flatMap((dependency) => {
|
|
46
|
-
const major = Number(dependency?.majorVersion)
|
|
47
|
-
const minor = Number(dependency?.minorVersion)
|
|
48
|
-
return typeof dependency?.machineName === 'string' && Number.isInteger(major) && Number.isInteger(minor)
|
|
49
|
-
? [{ machineName: dependency.machineName, major, minor }]
|
|
50
|
-
: []
|
|
51
|
-
})
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* The folder that serves a dependency, by the player's own rule: its versioned or bare name when
|
|
56
|
-
* that folder has a `library.json`, else the lowest versioned folder of the same major with a
|
|
57
|
-
* minor at least as high (`reachableFolder` in the player's `package-reader.ts`).
|
|
58
|
-
* @internal
|
|
59
|
-
* @param {{ machineName: string, major: number, minor: number }} dependency
|
|
60
|
-
* @param {ReadonlySet<string>} names the archive's entry names
|
|
61
|
-
* @returns {string | undefined}
|
|
62
|
-
*/
|
|
63
|
-
export function servingFolder({ machineName, major, minor }, names) {
|
|
64
|
-
const own = [`${machineName}-${major}.${minor}`, machineName].find((folder) => names.has(`${folder}/library.json`))
|
|
65
|
-
if (own) return own
|
|
66
|
-
let best
|
|
67
|
-
for (const name of names) {
|
|
68
|
-
const match = /^(.+)-(\d+)\.(\d+)\/library\.json$/.exec(name)
|
|
69
|
-
if (!match || match[1] !== machineName || Number(match[2]) !== major || Number(match[3]) < minor) continue
|
|
70
|
-
if (!best || Number(match[3]) < best.minor) best = { folder: `${match[1]}-${match[2]}.${match[3]}`, minor: Number(match[3]) }
|
|
71
|
-
}
|
|
72
|
-
return best?.folder
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* What the content asks for, and what those libraries need in turn, that no folder in `names`
|
|
77
|
-
* serves: the player refuses such a package when it opens it, and the deep walk is the one it makes
|
|
78
|
-
* before the runtime boots (`missingDependencies`). The sub-content the parameters name counts
|
|
79
|
-
* too, which the player finds missing only when the runtime reaches it.
|
|
80
|
-
* @param {{ machineName: string, major: number, minor: number }[]} roots
|
|
81
|
-
* @param {ReadonlySet<string>} names
|
|
82
|
-
* @param {(name: string) => Promise<unknown>} read a `library.json` among `names`, parsed
|
|
83
|
-
* @returns {Promise<{ machineName: string, major: number, minor: number }[]>}
|
|
84
|
-
*/
|
|
85
|
-
async function missingLibraries(roots, names, read) {
|
|
86
|
-
/** @type {Map<string, { machineName: string, major: number, minor: number }>} */
|
|
87
|
-
const missing = new Map()
|
|
88
|
-
const seen = new Set()
|
|
89
|
-
let level = roots
|
|
90
|
-
while (level.length > 0) {
|
|
91
|
-
const next = []
|
|
92
|
-
for (const dependency of level) {
|
|
93
|
-
const key = `${dependency.machineName} ${dependency.major}.${dependency.minor}`
|
|
94
|
-
if (seen.has(key)) continue
|
|
95
|
-
seen.add(key)
|
|
96
|
-
const folder = servingFolder(dependency, names)
|
|
97
|
-
if (!folder) missing.set(key, dependency)
|
|
98
|
-
else next.push(...dependenciesIn(await read(`${folder}/library.json`)))
|
|
99
|
-
}
|
|
100
|
-
level = next
|
|
101
|
-
}
|
|
102
|
-
return [...missing].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, dependency]) => dependency)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/*
|
|
106
|
-
* The library pack's part of the work: which of its parts the package needs. These follow the
|
|
107
|
-
* player's own functions, named on each, so that the element, planning from the zip's index,
|
|
108
|
-
* asks for exactly the parts the zip carries; keep them in step.
|
|
109
|
-
*/
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* The sub-content libraries the parameters name, by each object's `library` string, as
|
|
113
|
-
* `"H5P.MultiChoice 1.16"`: what a full export's `h5p.json` lists and a stripped one's does not.
|
|
114
|
-
* The player's `librariesNamedIn`.
|
|
115
|
-
* @param {unknown} params
|
|
116
|
-
* @returns {{ machineName: string, major: number, minor: number }[]}
|
|
117
|
-
*/
|
|
118
|
-
function librariesNamedIn(params) {
|
|
119
|
-
/** @type {Map<string, { machineName: string, major: number, minor: number }>} */
|
|
120
|
-
const found = new Map()
|
|
121
|
-
/** @param {unknown} value @param {number} depth */
|
|
122
|
-
const walk = (value, depth) => {
|
|
123
|
-
if (depth > 64 || !value || typeof value !== 'object') return
|
|
124
|
-
if (Array.isArray(value)) {
|
|
125
|
-
for (const item of value) walk(item, depth + 1)
|
|
126
|
-
return
|
|
127
|
-
}
|
|
128
|
-
const library = /** @type {{ library?: unknown }} */ (value).library
|
|
129
|
-
if (typeof library === 'string') {
|
|
130
|
-
const match = /^([A-Za-z0-9_.-]+) (\d+)\.(\d+)$/.exec(library)
|
|
131
|
-
if (match && !found.has(match[1])) found.set(match[1], { machineName: match[1], major: Number(match[2]), minor: Number(match[3]) })
|
|
132
|
-
}
|
|
133
|
-
for (const child of Object.values(value)) walk(child, depth + 1)
|
|
134
|
-
}
|
|
135
|
-
walk(params, 0)
|
|
136
|
-
return [...found.values()]
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* An addon's patterns, under `addTo.content.types[].text.regex`, PHP's `/…/flags` made
|
|
141
|
-
* JavaScript's; one JavaScript cannot read, or with a flag it has no counterpart for, is left out.
|
|
142
|
-
* The player's `addonPatterns`.
|
|
143
|
-
* @param {unknown} addTo
|
|
144
|
-
* @returns {RegExp[]}
|
|
145
|
-
*/
|
|
146
|
-
function addonPatterns(addTo) {
|
|
147
|
-
const types = /** @type {{ content?: { types?: unknown } } | null | undefined} */ (addTo)?.content?.types
|
|
148
|
-
if (!Array.isArray(types)) return []
|
|
149
|
-
const patterns = []
|
|
150
|
-
for (const type of types) {
|
|
151
|
-
const source = type?.text?.regex
|
|
152
|
-
if (typeof source !== 'string' || source.length < 2) continue
|
|
153
|
-
const delimiter = source[0]
|
|
154
|
-
if (/[\p{L}\p{N}\\\s([{<]/u.test(delimiter)) continue
|
|
155
|
-
const end = source.lastIndexOf(delimiter)
|
|
156
|
-
const flags = source.slice(end + 1)
|
|
157
|
-
if (end < 1 || /[^imsu]/.test(flags)) continue
|
|
158
|
-
try {
|
|
159
|
-
patterns.push(new RegExp(source.slice(1, end), flags))
|
|
160
|
-
} catch {
|
|
161
|
-
// PCRE that JavaScript does not read.
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return patterns
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Whether a string anywhere in the parameters matches one of the patterns. The player's
|
|
169
|
-
* `textMatches`.
|
|
170
|
-
* @param {unknown} params
|
|
171
|
-
* @param {RegExp[]} patterns
|
|
172
|
-
*/
|
|
173
|
-
function textMatches(params, patterns) {
|
|
174
|
-
/** @param {unknown} value @param {number} depth @returns {boolean} */
|
|
175
|
-
const walk = (value, depth) => {
|
|
176
|
-
if (typeof value === 'string') return patterns.some((pattern) => pattern.test(value))
|
|
177
|
-
if (depth > 64 || !value || typeof value !== 'object') return false
|
|
178
|
-
return Object.values(value).some((child) => walk(child, depth + 1))
|
|
179
|
-
}
|
|
180
|
-
return patterns.length > 0 && walk(params, 0)
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* The library folders the package carries, each as its `library.json` says: the player's
|
|
185
|
-
* `ownLibraries` once every `library.json` is at hand. One that does not parse, or a folder named
|
|
186
|
-
* for another library, is left out.
|
|
187
|
-
* @param {Iterable<string>} names
|
|
188
|
-
* @param {(name: string) => Promise<unknown>} read
|
|
189
|
-
*/
|
|
190
|
-
async function ownLibraries(names, read) {
|
|
191
|
-
/** @param {unknown} part */
|
|
192
|
-
const version = (part) => (typeof part === 'number' && Number.isInteger(part) && part >= 0 ? part : typeof part === 'string' && /^\d+$/.test(part) ? Number(part) : null)
|
|
193
|
-
const libraries = []
|
|
194
|
-
for (const name of names) {
|
|
195
|
-
const slash = name.indexOf('/')
|
|
196
|
-
if (slash < 0 || name.slice(slash + 1) !== 'library.json') continue
|
|
197
|
-
const folder = name.slice(0, slash)
|
|
198
|
-
const library = /** @type {{ machineName?: unknown, majorVersion?: unknown, minorVersion?: unknown } | null} */ (await read(name).catch(() => null))
|
|
199
|
-
const major = version(library?.majorVersion)
|
|
200
|
-
const minor = version(library?.minorVersion)
|
|
201
|
-
const machineName = library?.machineName
|
|
202
|
-
if (typeof machineName !== 'string' || major === null || minor === null) continue
|
|
203
|
-
const versioned = /^(.+)-(\d+)\.(\d+)$/.exec(folder)
|
|
204
|
-
if (versioned ? versioned[1] !== machineName || Number(versioned[2]) !== major : folder !== machineName) continue
|
|
205
|
-
libraries.push({ folder, machineName, major, minor, dependencies: dependenciesIn(library) })
|
|
206
|
-
}
|
|
207
|
-
return libraries
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* The library pack's index, its `parts/index.json`, as the plan reads it. `entry` is the library's
|
|
212
|
-
* own record, which the zip's index repeats.
|
|
38
|
+
* The library pack's index, its `parts/index.json`, as the plan takes it: each library's record,
|
|
39
|
+
* which the zip's index repeats as `record`, with the part's file as `url`. A record the index got
|
|
40
|
+
* wrong is left out.
|
|
213
41
|
* @param {unknown} index
|
|
42
|
+
* @param {string} packDir the folder holding the index and the parts
|
|
214
43
|
*/
|
|
215
|
-
function packLibraries(index) {
|
|
44
|
+
function packLibraries(index, packDir) {
|
|
216
45
|
const list = /** @type {{ libraries?: unknown } | null} */ (index)?.libraries
|
|
217
|
-
return (Array.isArray(list) ? list : []).flatMap((
|
|
218
|
-
const { folder, machineName, majorVersion, minorVersion,
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
46
|
+
return (Array.isArray(list) ? list : []).flatMap((record) => {
|
|
47
|
+
const { folder, machineName, majorVersion, minorVersion, patchVersion, size, sha256 } = record ?? {}
|
|
48
|
+
if (typeof folder !== 'string' || typeof machineName !== 'string' || ![majorVersion, minorVersion, patchVersion, size].every(Number.isInteger) || typeof sha256 !== 'string') return []
|
|
49
|
+
const library = /** @type {import('@missing-elements/h5p-offline-player/plan').PackLibrary} */ (record)
|
|
50
|
+
return [{ ...library, url: pathToFileURL(join(packDir, `${folder}.h5p`)).href, record: library }]
|
|
222
51
|
})
|
|
223
52
|
}
|
|
224
53
|
|
|
225
|
-
/**
|
|
226
|
-
* The parts of the library pack the package needs, and the folders of its own they replace: the
|
|
227
|
-
* player's `planPack` (`sw/pack-plan.ts`) as the launch page has the element run it, filling gaps
|
|
228
|
-
* with nothing attached before (`refresh-libraries="off"`).
|
|
229
|
-
* - What the content asks for, and the package carries at that major and at no lower minor, plays
|
|
230
|
-
* from the package. Anything else comes from the pack, which has one version of each major, and
|
|
231
|
-
* so does whatever a part needs; an older minor the package carries gives way to the part.
|
|
232
|
-
* - Once anything comes from the pack, so do the addons whose pattern matches a string in the
|
|
233
|
-
* parameters: H5P.MathDisplay, for LaTeX.
|
|
234
|
-
* @param {object} input
|
|
235
|
-
* @param {Awaited<ReturnType<typeof ownLibraries>>} input.own
|
|
236
|
-
* @param {{ machineName: string, major: number, minor: number }[]} input.roots what `h5p.json`
|
|
237
|
-
* declares and the parameters name as sub-content
|
|
238
|
-
* @param {unknown} input.params
|
|
239
|
-
* @param {ReturnType<typeof packLibraries>} input.pack
|
|
240
|
-
*/
|
|
241
|
-
function planParts({ own, roots, params, pack }) {
|
|
242
|
-
const byLibrary = new Map(pack.map((library) => [`${library.machineName} ${library.major}`, library]))
|
|
243
|
-
/** @type {Map<string, ReturnType<typeof packLibraries>[number]>} */
|
|
244
|
-
const planned = new Map()
|
|
245
|
-
/** @type {string[]} */
|
|
246
|
-
const superseded = []
|
|
247
|
-
let carried = own
|
|
248
|
-
const queue = [...roots]
|
|
249
|
-
const seen = new Set()
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* @param {{ machineName: string, major: number, minor: number }} version
|
|
253
|
-
* @param {{ machineName: string, major: number, minor: number }} dependency
|
|
254
|
-
*/
|
|
255
|
-
const satisfies = (version, dependency) =>
|
|
256
|
-
version.machineName === dependency.machineName && version.major === dependency.major && version.minor >= dependency.minor
|
|
257
|
-
/** @param {ReturnType<typeof packLibraries>[number]} library */
|
|
258
|
-
const plan = (library) => {
|
|
259
|
-
if (planned.has(library.folder)) return
|
|
260
|
-
planned.set(library.folder, library)
|
|
261
|
-
queue.push(...library.dependencies)
|
|
262
|
-
}
|
|
263
|
-
const follow = () => {
|
|
264
|
-
for (let next = queue.shift(); next; next = queue.shift()) {
|
|
265
|
-
const dependency = next
|
|
266
|
-
const key = `${dependency.machineName} ${dependency.major}.${dependency.minor}`
|
|
267
|
-
if (seen.has(key)) continue
|
|
268
|
-
seen.add(key)
|
|
269
|
-
const ownVersion = carried.find((library) => satisfies(library, dependency))
|
|
270
|
-
if (ownVersion) {
|
|
271
|
-
queue.push(...ownVersion.dependencies)
|
|
272
|
-
continue
|
|
273
|
-
}
|
|
274
|
-
if ([...planned.values()].some((library) => satisfies(library, dependency))) continue
|
|
275
|
-
const candidate = byLibrary.get(`${dependency.machineName} ${dependency.major}`)
|
|
276
|
-
if (!candidate || candidate.minor < dependency.minor) continue
|
|
277
|
-
const older = carried.find((library) => library.machineName === dependency.machineName && library.major === dependency.major)
|
|
278
|
-
if (older) {
|
|
279
|
-
carried = carried.filter((library) => library !== older)
|
|
280
|
-
superseded.push(older.folder)
|
|
281
|
-
}
|
|
282
|
-
plan(candidate)
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
follow()
|
|
287
|
-
if (planned.size > 0) {
|
|
288
|
-
for (const addon of pack) {
|
|
289
|
-
if (addon.addTo === undefined || carried.some((library) => library.machineName === addon.machineName)) continue
|
|
290
|
-
if (textMatches(params, addonPatterns(addon.addTo))) plan(addon)
|
|
291
|
-
}
|
|
292
|
-
follow()
|
|
293
|
-
}
|
|
294
|
-
return { parts: [...planned.values()].sort((a, b) => (a.folder < b.folder ? -1 : a.folder > b.folder ? 1 : 0)), superseded }
|
|
295
|
-
}
|
|
296
|
-
|
|
297
54
|
/** @param {string} text */
|
|
298
55
|
const escapeXml = (text) =>
|
|
299
56
|
text.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char] ?? char)
|
|
@@ -443,13 +200,13 @@ export async function buildScorm({ h5p, out, force = false }) {
|
|
|
443
200
|
if (targetInfo && !force) throw new EmbedError(`${out} exists. Pass --force to replace it.`)
|
|
444
201
|
|
|
445
202
|
const packDir = dirname(installed('@missing-elements/h5p-libraries/parts/index.json', 'Reinstall this package.'))
|
|
446
|
-
const pack = packLibraries(JSON.parse(await readFile(join(packDir, 'index.json'), 'utf8')))
|
|
203
|
+
const pack = packLibraries(JSON.parse(await readFile(join(packDir, 'index.json'), 'utf8')), packDir)
|
|
447
204
|
|
|
448
205
|
// What the package declares and carries, and what the pack adds, read before anything is written.
|
|
449
206
|
/** @type {any} `h5p.json`, parsed */
|
|
450
207
|
let manifest
|
|
451
|
-
/** @type {
|
|
452
|
-
let
|
|
208
|
+
/** @type {typeof pack} the parts of the pack the zip carries, by folder */
|
|
209
|
+
let parts
|
|
453
210
|
let reader
|
|
454
211
|
try {
|
|
455
212
|
reader = new ZipReader(new BlobReader(await openAsBlob(source)))
|
|
@@ -473,22 +230,39 @@ export async function buildScorm({ h5p, out, force = false }) {
|
|
|
473
230
|
throw new EmbedError(`${basename(h5p)}: h5p.json names no libraries, so there is nothing to play.`)
|
|
474
231
|
}
|
|
475
232
|
const params = await readJson('content/content.json')
|
|
476
|
-
|
|
477
|
-
|
|
233
|
+
/** @param {{ machineName: string, majorVersion: string | number, minorVersion: string | number }} dependency */
|
|
234
|
+
const numbered = ({ machineName, majorVersion, minorVersion }) => ({ machineName, majorVersion: Number(majorVersion), minorVersion: Number(minorVersion) })
|
|
235
|
+
const roots = [...dependenciesIn(manifest), ...librariesNamedIn(params)].map(numbered)
|
|
236
|
+
const folders = [...byName.keys()].flatMap((name) => {
|
|
237
|
+
const match = /^([^/]+)\/library\.json$/.exec(name)
|
|
238
|
+
return match ? [match[1]] : []
|
|
239
|
+
})
|
|
240
|
+
// The player's own plan, as the launch page has the element run it: gaps filled from the pack,
|
|
241
|
+
// nothing provided before, no refresh (`refresh-libraries="off"`).
|
|
242
|
+
const plan = planPack({ own: await ownLibraries(folders, (folder) => readJson(`${folder}/library.json`)), provided: [], roots, params, pack, refresh: false, fill: true })
|
|
243
|
+
const byFolder = new Map(pack.map((library) => [library.folder, library]))
|
|
244
|
+
parts = (plan?.parts ?? []).map((part) => /** @type {typeof pack[number]} */ (byFolder.get(part.folder))).sort((a, b) => (a.folder < b.folder ? -1 : a.folder > b.folder ? 1 : 0))
|
|
478
245
|
|
|
479
246
|
// Held to its libraries as the player holds it once the parts are attached: the folders they
|
|
480
247
|
// replace withheld, and each part's `library.json` naming what its record in the index does.
|
|
481
|
-
const
|
|
482
|
-
const withheld = new Set(plan
|
|
248
|
+
const records = new Map(parts.map((part) => [`${part.folder}/library.json`, part.record]))
|
|
249
|
+
const withheld = new Set(plan?.superseded ?? [])
|
|
483
250
|
const names = new Set([...byName.keys()].filter((name) => !withheld.has(name.split('/')[0])))
|
|
484
|
-
for (const name of
|
|
485
|
-
const
|
|
251
|
+
for (const name of records.keys()) names.add(name)
|
|
252
|
+
const available = indexLibraryFolders(names)
|
|
253
|
+
const { missing } = await walkDependencies(
|
|
254
|
+
roots,
|
|
255
|
+
(dependency) => reachableFolder(dependency, names, available),
|
|
256
|
+
async (folder) => records.get(`${folder}/library.json`) ?? readJson(`${folder}/library.json`)
|
|
257
|
+
)
|
|
486
258
|
if (missing.length > 0) {
|
|
487
|
-
const packed = new Map(pack.map((library) => [`${library.machineName} ${library.
|
|
488
|
-
const listed = missing
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
259
|
+
const packed = new Map(pack.map((library) => [`${library.machineName} ${library.majorVersion}`, library]))
|
|
260
|
+
const listed = missing
|
|
261
|
+
.map(({ machineName, majorVersion, minorVersion }) => {
|
|
262
|
+
const older = packed.get(`${machineName} ${Number(majorVersion)}`)
|
|
263
|
+
return `${machineName} ${majorVersion}.${minorVersion}${older ? ` (the pack has ${older.majorVersion}.${older.minorVersion})` : ''}`
|
|
264
|
+
})
|
|
265
|
+
.sort()
|
|
492
266
|
throw new EmbedError(
|
|
493
267
|
`${basename(h5p)} needs libraries that neither it nor the library pack carries: ${listed.join(', ')}. ` +
|
|
494
268
|
'Export the package with its libraries, as Lumi and the H5P plugins of Moodle, WordPress and Drupal do.'
|
|
@@ -506,12 +280,12 @@ export async function buildScorm({ h5p, out, force = false }) {
|
|
|
506
280
|
const title = named || basename(h5p, extname(h5p))
|
|
507
281
|
// The learner's saved place is kept against the libraries too, as the player keeps it against
|
|
508
282
|
// the revision: a zip written with others starts learners over, as a refreshed package does.
|
|
509
|
-
const hashes = [await sha256(source), ...
|
|
283
|
+
const hashes = [await sha256(source), ...parts.map((part) => part.sha256)]
|
|
510
284
|
const build = (hashes.length === 1 ? hashes[0] : createHash('sha256').update(hashes.join('\n')).digest('hex')).slice(0, 16)
|
|
511
285
|
const player = dirname(installed('@missing-elements/h5p-offline-player/dist/h5p-player.js', 'Run `pnpm build` in the workspace, or reinstall this package.'))
|
|
512
286
|
const runtime = dirname(installed('@missing-elements/h5p-runtime/dist/h5p.css', 'Run `pnpm build` in the workspace, or reinstall this package.'))
|
|
513
287
|
const version = (await readFile(join(player, 'VERSION'), 'utf8').catch(() => 'unknown')).trim()
|
|
514
|
-
const packVersion =
|
|
288
|
+
const packVersion = parts.length > 0 ? String(JSON.parse(await readFile(join(dirname(packDir), 'package.json'), 'utf8')).version) : null
|
|
515
289
|
|
|
516
290
|
const page = (await readFile(join(SCORM, 'index.html'), 'utf8'))
|
|
517
291
|
.replace('%LANG%', languageTag(manifest.defaultLanguage))
|
|
@@ -531,8 +305,8 @@ export async function buildScorm({ h5p, out, force = false }) {
|
|
|
531
305
|
[PACKAGE, { file: source }]
|
|
532
306
|
]
|
|
533
307
|
if (packVersion) {
|
|
534
|
-
files.push([`${LIBRARIES}/index.json`, { text: `${JSON.stringify({ libraries:
|
|
535
|
-
for (const part of
|
|
308
|
+
files.push([`${LIBRARIES}/index.json`, { text: `${JSON.stringify({ libraries: parts.map((part) => part.record) }, null, 2)}\n` }])
|
|
309
|
+
for (const part of parts) files.push([`${LIBRARIES}/${part.folder}.h5p`, { file: join(packDir, `${part.folder}.h5p`) }])
|
|
536
310
|
files.push(['libraries.txt', { file: join(dirname(packDir), 'libraries.txt') }])
|
|
537
311
|
}
|
|
538
312
|
files.push(['NOTICE.txt', { text: notice({ version, source: basename(h5p), pack: packVersion }) }])
|
|
@@ -566,5 +340,5 @@ export async function buildScorm({ h5p, out, force = false }) {
|
|
|
566
340
|
throw error
|
|
567
341
|
}
|
|
568
342
|
|
|
569
|
-
return { out: target, title, version, libraries:
|
|
343
|
+
return { out: target, title, version, libraries: parts.map((part) => part.folder), build, size: (await stat(target)).size }
|
|
570
344
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@missing-elements/h5p-embed",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
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": {
|
|
@@ -59,8 +59,9 @@
|
|
|
59
59
|
],
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@zip.js/zip.js": "^2.24.0",
|
|
62
|
-
"@missing-elements/h5p-libraries": "^1.0.
|
|
63
|
-
"@missing-elements/h5p-offline-player": "
|
|
62
|
+
"@missing-elements/h5p-libraries": "^1.0.1",
|
|
63
|
+
"@missing-elements/h5p-offline-player": "1.4.0",
|
|
64
|
+
"@missing-elements/h5p-normalize": "^1.2.0",
|
|
64
65
|
"@missing-elements/h5p-runtime": "^1.0.0"
|
|
65
66
|
},
|
|
66
67
|
"devDependencies": {
|
|
@@ -75,6 +76,6 @@
|
|
|
75
76
|
"scripts": {
|
|
76
77
|
"test": "pnpm --filter @missing-elements/h5p-offline-player build && vitest run",
|
|
77
78
|
"typecheck": "pnpm types && tsc -p tsconfig.json",
|
|
78
|
-
"types": "node -e \"require('node:fs').rmSync('types', { recursive: true, force: true })\" && tsc -p tsconfig.types.json"
|
|
79
|
+
"types": "pnpm --filter @missing-elements/h5p-offline-player exec tsc -b && node -e \"require('node:fs').rmSync('types', { recursive: true, force: true })\" && tsc -p tsconfig.types.json"
|
|
79
80
|
}
|
|
80
81
|
}
|
package/lib/crc32.mjs
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
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/types/lib/crc32.d.mts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
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;
|