@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 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 to play and grade (see
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 Moodle or another LMS that
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, and Moodle has to
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
- Checked in Moodle 4.5: the score reaches the gradebook, and a learner who leaves halfway continues
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.
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
- * A dependency as `h5p.json` and every `library.json` list them, or nothing for an entry that is
38
- * not one.
39
- * @param {unknown} json
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((entry) => {
218
- const { folder, machineName, majorVersion, minorVersion, sha256, addTo } = entry ?? {}
219
- return typeof folder === 'string' && typeof machineName === 'string' && Number.isInteger(majorVersion) && Number.isInteger(minorVersion) && typeof sha256 === 'string'
220
- ? [{ folder, machineName, major: Number(majorVersion), minor: Number(minorVersion), sha256, dependencies: dependenciesIn(entry), addTo, entry: /** @type {object} */ (entry) }]
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[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 {ReturnType<typeof planParts>} */
452
- let plan
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
- const roots = [...dependenciesIn(manifest), ...librariesNamedIn(params)]
477
- plan = planParts({ own: await ownLibraries(byName.keys(), readJson), roots, params, pack })
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 parts = new Map(plan.parts.map((part) => [`${part.folder}/library.json`, part.entry]))
482
- const withheld = new Set(plan.superseded)
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 parts.keys()) names.add(name)
485
- const missing = await missingLibraries(roots, names, async (name) => parts.get(name) ?? readJson(name))
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.major}`, library]))
488
- const listed = missing.map(({ machineName, major, minor }) => {
489
- const older = packed.get(`${machineName} ${major}`)
490
- return `${machineName} ${major}.${minor}${older ? ` (the pack has ${older.major}.${older.minor})` : ''}`
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), ...plan.parts.map((part) => part.sha256)]
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 = plan.parts.length > 0 ? String(JSON.parse(await readFile(join(dirname(packDir), 'package.json'), 'utf8')).version) : null
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: plan.parts.map((part) => part.entry) }, null, 2)}\n` }])
535
- for (const part of plan.parts) files.push([`${LIBRARIES}/${part.folder}.h5p`, { file: join(packDir, `${part.folder}.h5p`) }])
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: plan.parts.map((part) => part.folder), build, size: (await stat(target)).size }
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.1",
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.0",
63
- "@missing-elements/h5p-offline-player": "^1.2.0",
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)
@@ -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;