@missing-elements/h5p-embed 1.1.0 → 1.2.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.
package/README.md CHANGED
@@ -2,6 +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
6
+ [A SCORM package, for an LMS](#a-scorm-package-for-an-lms)).
5
7
 
6
8
  Needs Node 22 or later. Write `@latest`: without it, npm on an older Node quietly installs the
7
9
  last version that supported it, which writes a player from before 1.0.
@@ -35,6 +37,7 @@ a subdomain shares your site's cookies.
35
37
  | `--default-libraries <sources>` | The `libraries` value for addresses that name none: `pack` or URLs. Checked against `--packages`. Default: none, so exports without libraries are refused unless the address asks |
36
38
  | `--no-libraries` | Leave out the library pack, 13.8 MB in `libraries/`, one archive per library, of which a learner's browser fetches only those a package needs; `libraries=pack` then fetches them from jsDelivr, where allowed |
37
39
  | `--force` | Write into a non-empty folder, replacing only this tool's files |
40
+ | `--scorm <package.h5p> [zip]` | Write a SCORM 1.2 package of one `.h5p` instead of the site; see [A SCORM package, for an LMS](#a-scorm-package-for-an-lms) |
38
41
  | `-h`, `--help` / `-v`, `--version` | |
39
42
 
40
43
  Origins are `https://host.example`, comma or space separated, no path or trailing slash;
@@ -77,7 +80,7 @@ To update, run the command again with `--force` and redeploy.
77
80
 
78
81
  | Parameter | Effect |
79
82
  |---|---|
80
- | `src=<url>` | The package, required. Encode `&`, `#`, `+`, `%` and spaces |
83
+ | `src=<url>` | The package, required: an `http` or `https` URL, or a path on the player domain. Encode `&`, `#`, `+`, `%` and spaces |
81
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` |
82
85
  | `refresh-libraries=off` | Play the libraries a package carries as exported; `pack` then only fills gaps |
83
86
  | `frame`, `copyright`, `export`, `icon`, `reporting` | H5P's action bar and its buttons |
@@ -122,6 +125,45 @@ Whatever `xapi=` says, the frame also posts to its parent:
122
125
  { context: 'h5p-offline-player', action: 'error', code, message } // code 'refused', 'no-src' or the load's
123
126
  ```
124
127
 
128
+ ## A SCORM package, for an LMS
129
+
130
+ ```bash
131
+ npx @missing-elements/h5p-embed@latest --scorm course.h5p # writes ./course.scorm.zip
132
+ ```
133
+
134
+ writes one package as a SCORM 1.2 zip: the package, the player and the H5P runtime, and a launch
135
+ page that reports to the LMS. Add it to a course as a SCORM activity, in Moodle or another LMS that
136
+ takes SCORM 1.2, and the LMS records:
137
+
138
+ - the score out of 100, and `passed` or `failed` when the content says whether it was passed,
139
+ `completed` when it does not. The content's last result counts, as on an H5P site;
140
+ - the time spent in each session;
141
+ - the learner's place: the content's saved state goes into `cmi.suspend_data`, and the next
142
+ launch continues from it. SCORM 1.2 keeps 4,096 characters, enough for a quiz; a larger state is
143
+ not saved.
144
+
145
+ Moodle records the score as the grade, out of the activity's maximum grade, so leave that at 100,
146
+ its default.
147
+
148
+ An export from h5p.com or h5p.org, which leaves the libraries out, works as well: the zip carries
149
+ the ones it needs from the library pack, and only those. For a quiz with formulas, that is 11
150
+ libraries, 4 MB, most of it the formula renderer. A package that carries its libraries, as exports
151
+ from Lumi and the H5P plugins of Moodle, WordPress and Drupal do, plays with its own. One that needs
152
+ a library neither it nor the pack has, or a newer version than the pack's, is refused, with what is
153
+ missing named.
154
+
155
+ The package plays trusted: its own code runs on the LMS's origin, as every SCORM package's does.
156
+ The LMS has to be served over https, since the player needs its Service Worker, and Moodle has to
157
+ serve files by path, which it does by default (`slasharguments`).
158
+
159
+ Content that reports no result of its own, an Accordion or a Virtual Tour, stays `incomplete`:
160
+ complete such an activity on viewing it instead. Replacing the zip with one written from another
161
+ `.h5p`, or with other libraries from the pack, starts learners over, since their saved state
162
+ belongs to what it was saved in.
163
+
164
+ Checked in Moodle 4.5: the score reaches the gradebook, and a learner who leaves halfway continues
165
+ there on the next launch. Other LMSs have not been tried yet.
166
+
125
167
  ## As a library
126
168
 
127
169
  ```js
@@ -138,7 +180,9 @@ export, which `libraries=pack` then plays from) or `packBase` (the folder of a c
138
180
  `@missing-elements/h5p-runtime` export, for a bundled element) and `askInOwnFrame` (default
139
181
  `true`; `false` plays a package from another origin as it is when a same-origin page frames it).
140
182
  `librariesPack`, the URL of a whole `libraries.h5p`, still works, as a bundle that only fills gaps.
141
- Both entries ship with TypeScript declarations.
183
+ `buildScorm` from `@missing-elements/h5p-embed/scorm` is `--scorm`: it takes `h5p`, `out` and
184
+ `force`, and returns the zip's path, its title, size, the `libraries` it carries from the pack,
185
+ and `build`, the stamp saved state is kept against. Every entry ships with TypeScript declarations.
142
186
 
143
187
  ```js
144
188
  import '@missing-elements/h5p-offline-player'
@@ -154,7 +198,8 @@ startEmbed({ runtime, pack, defaultLibraries: 'pack' })
154
198
 
155
199
  The site sets no cookies and sends nothing anywhere of its own. Learners' browsers contact your
156
200
  player domain, the package hosts and, only for `pack` on a site built with `--no-libraries`,
157
- `cdn.jsdelivr.net`. What the player stores and
201
+ `cdn.jsdelivr.net`. A SCORM package contacts nothing but the LMS it was uploaded to, which gets the
202
+ score, the result, the time spent and the learner's saved state. What the player stores and
158
203
  sends, for a privacy notice or a GDPR review:
159
204
  [Privacy and data protection](https://github.com/missing-elements/h5p-offline-player/blob/main/docs/privacy.md).
160
205
 
@@ -164,4 +209,6 @@ MIT. The written folder also carries
164
209
  [`@missing-elements/h5p-runtime`](https://www.npmjs.com/package/@missing-elements/h5p-runtime)
165
210
  under the GPL-3.0, as `frame-assets/` with its `LICENSE.txt` and `NOTICE.txt`, and, unless
166
211
  `--no-libraries`, the hub's libraries under their own licences, listed in `libraries.txt`. The
167
- folder's `NOTICE.txt` says which file is what.
212
+ folder's `NOTICE.txt` says which file is what. A SCORM zip carries the runtime the same way, the
213
+ package under its own licences, and any libraries from the pack as the folder does; its
214
+ `NOTICE.txt` says the same.
package/bin/h5p-embed.mjs CHANGED
@@ -1,15 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'node:fs'
3
- import { relative } from 'node:path'
3
+ import { basename, extname, relative } from 'node:path'
4
4
  import { parseArgs } from 'node:util'
5
5
  import { EmbedError, buildSite } from '../lib/build.mjs'
6
+ import { buildScorm } from '../lib/scorm.mjs'
6
7
 
7
8
  const USAGE = `Usage: h5p-embed [folder] [options]
9
+ h5p-embed --scorm <package.h5p> [zip] [--force]
8
10
 
9
11
  Writes the H5P embed page as a static site, ready to deploy to a player domain of your own.
10
12
  The folder defaults to ./h5p-player.
11
13
 
14
+ With --scorm, writes a SCORM 1.2 package of one .h5p instead, for an LMS to play and grade: the
15
+ player, the package and the libraries it does not carry, from the library pack, in one zip. The
16
+ zip defaults to ./<package>.scorm.zip.
17
+
12
18
  Options:
19
+ --scorm <package.h5p> write a SCORM package of this .h5p rather than the site
13
20
  --packages <origins> play packages, and fetch library bundles, only from these origins
14
21
  (comma or space separated; this site's own is always allowed). Add
15
22
  any host a package URL redirects to, and https://cdn.jsdelivr.net for
@@ -21,7 +28,8 @@ Options:
21
28
  it still plays an export with no libraries: pack or URLs, as the
22
29
  parameter. Default: none, such exports are refused unless the address asks
23
30
  --no-libraries leave out the 13.7 MB library pack; libraries=pack then fetches it from jsDelivr
24
- --force write into a folder that is not empty, replacing only this tool's files
31
+ --force write into a folder that is not empty, replacing only this tool's files;
32
+ with --scorm, replace the zip
25
33
  -h, --help show this
26
34
  -v, --version print the version`
27
35
 
@@ -30,6 +38,7 @@ try {
30
38
  args = parseArgs({
31
39
  allowPositionals: true,
32
40
  options: {
41
+ scorm: { type: 'string' },
33
42
  packages: { type: 'string', multiple: true },
34
43
  ancestors: { type: 'string', multiple: true },
35
44
  'default-libraries': { type: 'string' },
@@ -54,12 +63,50 @@ if (values.version) {
54
63
  process.exit(0)
55
64
  }
56
65
  if (positionals.length > 1) {
57
- console.error(`h5p-embed: one folder at most, got ${positionals.length}\n\n${USAGE}`)
66
+ console.error(`h5p-embed: one ${values.scorm !== undefined ? 'zip' : 'folder'} at most, got ${positionals.length}\n\n${USAGE}`)
58
67
  process.exit(2)
59
68
  }
60
69
 
61
70
  const megabytes = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`
62
71
 
72
+ /** Where a written file or folder is, said from here when it is below here. */
73
+ const shown = (path) => {
74
+ const below = relative(process.cwd(), path)
75
+ return below === '' ? '.' : below.startsWith('..') ? path : below
76
+ }
77
+
78
+ // `--scorm=` with no package still means a SCORM package, refused for naming none.
79
+ if (values.scorm !== undefined) {
80
+ // The site's options shape a site; a SCORM package has no hosts, framers or library sources.
81
+ const siteOnly = ['packages', 'ancestors', 'default-libraries', 'no-libraries'].filter((name) => values[name] !== undefined)
82
+ if (siteOnly.length > 0) {
83
+ console.error(`h5p-embed: ${siteOnly.map((name) => `--${name}`).join(', ')} not with --scorm, which writes a package, not a site\n\n${USAGE}`)
84
+ process.exit(2)
85
+ }
86
+ try {
87
+ const zip = await buildScorm({
88
+ h5p: values.scorm,
89
+ out: positionals[0] ?? `${basename(values.scorm, extname(values.scorm))}.scorm.zip`,
90
+ force: values.force ?? false
91
+ })
92
+ const count = zip.libraries.length
93
+ const supplied = count > 0 ? `, the H5P runtime and ${count} ${count === 1 ? 'library' : 'libraries'} from the library pack, which it does not carry` : ' and the H5P runtime'
94
+ console.log(`Wrote ${shown(zip.out)} (${megabytes(zip.size)}): "${zip.title}" as a SCORM 1.2 package,
95
+ with the player ${zip.version}${supplied}.
96
+
97
+ Add it to a course as a SCORM package. The LMS gets the content's score and whether it passed,
98
+ when the content reports them, and the time spent; the learner's place is kept between sessions.
99
+ The LMS must be served over https, as the player's Service Worker needs.`)
100
+ } catch (error) {
101
+ if (error instanceof EmbedError) {
102
+ console.error(`h5p-embed: ${error.message}`)
103
+ process.exit(1)
104
+ }
105
+ throw error
106
+ }
107
+ process.exit(0)
108
+ }
109
+
63
110
  try {
64
111
  const site = await buildSite({
65
112
  out: positionals[0] ?? 'h5p-player',
@@ -69,8 +116,7 @@ try {
69
116
  defaultLibraries: values['default-libraries'] ?? null,
70
117
  force: values.force ?? false
71
118
  })
72
- const below = relative(process.cwd(), site.out)
73
- const folder = below === '' ? '.' : below.startsWith('..') ? site.out : below
119
+ const folder = shown(site.out)
74
120
  const parts = [`the player ${site.version}`, 'the H5P runtime', site.libraries ? 'the library pack' : null].filter(Boolean)
75
121
  console.log(`Wrote ${folder}/ (${megabytes(site.size)}): the embed page, ${parts.join(', ')}.`)
76
122
  console.log(`Packages from: ${site.packages ? `this site, ${site.packages.join(', ')}` : 'any https host'}.`)
package/lib/build.mjs CHANGED
@@ -20,15 +20,19 @@ const SITE = fileURLToPath(new URL('../site', import.meta.url))
20
20
 
21
21
  /** The page's own files, copied as they are; `index.html` is written, not copied. */
22
22
  const PAGE_FILES = ['embed.js', 'embed.css', 'main.js', 'resizer.js']
23
- /** The player's files, by the names the element looks for beside itself. */
24
- const PLAYER_FILES = ['h5p-player.js', 'h5p-sw.js', 'h5p-jobs.js']
23
+ /**
24
+ * The player's files, by the names the element looks for beside itself.
25
+ * @internal
26
+ */
27
+ export const PLAYER_FILES = ['h5p-player.js', 'h5p-sw.js', 'h5p-jobs.js']
25
28
 
26
29
  /**
27
30
  * Where an installed package's file is, or a message that says which one is missing.
31
+ * @internal
28
32
  * @param {string} specifier
29
33
  * @param {string} hint
30
34
  */
31
- function installed(specifier, hint) {
35
+ export function installed(specifier, hint) {
32
36
  try {
33
37
  return require.resolve(specifier)
34
38
  } catch {
@@ -304,8 +308,8 @@ function notice({ version, libraries }) {
304
308
  Caddyfile, nginx.conf, .htaccess, _headers, vercel.json
305
309
  the same headers for five kinds of host; use the one yours reads
306
310
  h5p-player.js, h5p-sw.js, h5p-jobs.js
307
- @missing-elements/h5p-offline-player ${version}: MIT. The two workers also
308
- carry zip.js, BSD-3-Clause, its licence in each file's header.
311
+ @missing-elements/h5p-offline-player ${version}: MIT. The Service Worker,
312
+ h5p-sw.js, also carries zip.js, BSD-3-Clause, its licence in its header.
309
313
  frame-assets/ @missing-elements/h5p-runtime, the H5P core runtime: GPL-3.0-only. Its
310
314
  LICENSE.txt and NOTICE.txt say what it is and where its source is; keep them
311
315
  with it.
package/lib/scorm.mjs ADDED
@@ -0,0 +1,548 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { createReadStream, createWriteStream, openAsBlob } from 'node:fs'
3
+ import { readdir, readFile, rename, rm, stat } from 'node:fs/promises'
4
+ import { basename, dirname, extname, join, resolve, sep } from 'node:path'
5
+ import { Writable } from 'node:stream'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { BlobReader, TextReader, TextWriter, ZipReader, ZipWriter, configure } from '@zip.js/zip.js'
8
+ import { EmbedError, PLAYER_FILES, installed } from './build.mjs'
9
+
10
+ /**
11
+ * Writes a SCORM 1.2 package that plays one `.h5p` in an LMS and reports its result there: the
12
+ * launch page and its bridge to the LMS's API, the player, the H5P runtime and the package, in
13
+ * one zip a teacher uploads as a SCORM activity. The LMS serves the files from its own origin,
14
+ * under a path of the package's own, so the player's Service Worker registers there, scoped to
15
+ * that path, and nothing is fetched from anywhere else.
16
+ *
17
+ * The package plays trusted, as every SCORM package runs its own code on the LMS's origin, with the
18
+ * libraries it carries. What it does not carry, as an export from h5p.com or h5p.org carries none,
19
+ * comes from the library pack, written into the zip: only the parts it needs. One that needs a
20
+ * library neither has is refused here rather than in front of a learner. Checked against Moodle 4.5
21
+ * on 2026-10-28: the worker registered under `pluginfile.php`, the package was read by ranges, and
22
+ * the score reached the gradebook.
23
+ */
24
+
25
+ configure({ useWebWorkers: false })
26
+
27
+ const SCORM = fileURLToPath(new URL('../scorm', import.meta.url))
28
+ const SITE = fileURLToPath(new URL('../site', import.meta.url))
29
+
30
+ /** What the zip calls the package; the launch page plays it by this name. */
31
+ const PACKAGE = 'package.h5p'
32
+ /** Where the zip keeps the library pack's parts it carries, as the embed site does. */
33
+ const LIBRARIES = 'libraries'
34
+
35
+ /**
36
+ * A dependency as `h5p.json` and every `library.json` list them, or nothing for an entry that is
37
+ * not one.
38
+ * @param {unknown} json
39
+ * @returns {{ machineName: string, major: number, minor: number }[]}
40
+ */
41
+ function dependenciesIn(json) {
42
+ const list = /** @type {{ preloadedDependencies?: unknown }} */ (json ?? {}).preloadedDependencies
43
+ if (!Array.isArray(list)) return []
44
+ return list.flatMap((dependency) => {
45
+ const major = Number(dependency?.majorVersion)
46
+ const minor = Number(dependency?.minorVersion)
47
+ return typeof dependency?.machineName === 'string' && Number.isInteger(major) && Number.isInteger(minor)
48
+ ? [{ machineName: dependency.machineName, major, minor }]
49
+ : []
50
+ })
51
+ }
52
+
53
+ /**
54
+ * The folder that serves a dependency, by the player's own rule: its versioned or bare name when
55
+ * that folder has a `library.json`, else the lowest versioned folder of the same major with a
56
+ * minor at least as high (`reachableFolder` in the player's `package-reader.ts`).
57
+ * @internal
58
+ * @param {{ machineName: string, major: number, minor: number }} dependency
59
+ * @param {ReadonlySet<string>} names the archive's entry names
60
+ * @returns {string | undefined}
61
+ */
62
+ export function servingFolder({ machineName, major, minor }, names) {
63
+ const own = [`${machineName}-${major}.${minor}`, machineName].find((folder) => names.has(`${folder}/library.json`))
64
+ if (own) return own
65
+ let best
66
+ for (const name of names) {
67
+ const match = /^(.+)-(\d+)\.(\d+)\/library\.json$/.exec(name)
68
+ if (!match || match[1] !== machineName || Number(match[2]) !== major || Number(match[3]) < minor) continue
69
+ if (!best || Number(match[3]) < best.minor) best = { folder: `${match[1]}-${match[2]}.${match[3]}`, minor: Number(match[3]) }
70
+ }
71
+ return best?.folder
72
+ }
73
+
74
+ /**
75
+ * What the content asks for, and what those libraries need in turn, that no folder in `names`
76
+ * serves: the player refuses such a package when it opens it, and the deep walk is the one it makes
77
+ * before the runtime boots (`missingDependencies`). The sub-content the parameters name counts
78
+ * too, which the player finds missing only when the runtime reaches it.
79
+ * @param {{ machineName: string, major: number, minor: number }[]} roots
80
+ * @param {ReadonlySet<string>} names
81
+ * @param {(name: string) => Promise<unknown>} read a `library.json` among `names`, parsed
82
+ * @returns {Promise<{ machineName: string, major: number, minor: number }[]>}
83
+ */
84
+ async function missingLibraries(roots, names, read) {
85
+ /** @type {Map<string, { machineName: string, major: number, minor: number }>} */
86
+ const missing = new Map()
87
+ const seen = new Set()
88
+ let level = roots
89
+ while (level.length > 0) {
90
+ const next = []
91
+ for (const dependency of level) {
92
+ const key = `${dependency.machineName} ${dependency.major}.${dependency.minor}`
93
+ if (seen.has(key)) continue
94
+ seen.add(key)
95
+ const folder = servingFolder(dependency, names)
96
+ if (!folder) missing.set(key, dependency)
97
+ else next.push(...dependenciesIn(await read(`${folder}/library.json`)))
98
+ }
99
+ level = next
100
+ }
101
+ return [...missing].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, dependency]) => dependency)
102
+ }
103
+
104
+ /*
105
+ * The library pack's part of the work: which of its parts the package needs. These follow the
106
+ * player's own functions, named on each, so that the element, planning from the zip's index,
107
+ * asks for exactly the parts the zip carries; keep them in step.
108
+ */
109
+
110
+ /**
111
+ * The sub-content libraries the parameters name, by each object's `library` string, as
112
+ * `"H5P.MultiChoice 1.16"`: what a full export's `h5p.json` lists and a stripped one's does not.
113
+ * The player's `librariesNamedIn`.
114
+ * @param {unknown} params
115
+ * @returns {{ machineName: string, major: number, minor: number }[]}
116
+ */
117
+ function librariesNamedIn(params) {
118
+ /** @type {Map<string, { machineName: string, major: number, minor: number }>} */
119
+ const found = new Map()
120
+ /** @param {unknown} value @param {number} depth */
121
+ const walk = (value, depth) => {
122
+ if (depth > 64 || !value || typeof value !== 'object') return
123
+ if (Array.isArray(value)) {
124
+ for (const item of value) walk(item, depth + 1)
125
+ return
126
+ }
127
+ const library = /** @type {{ library?: unknown }} */ (value).library
128
+ if (typeof library === 'string') {
129
+ const match = /^([A-Za-z0-9_.-]+) (\d+)\.(\d+)$/.exec(library)
130
+ if (match && !found.has(match[1])) found.set(match[1], { machineName: match[1], major: Number(match[2]), minor: Number(match[3]) })
131
+ }
132
+ for (const child of Object.values(value)) walk(child, depth + 1)
133
+ }
134
+ walk(params, 0)
135
+ return [...found.values()]
136
+ }
137
+
138
+ /**
139
+ * An addon's patterns, under `addTo.content.types[].text.regex`, PHP's `/…/flags` made
140
+ * JavaScript's; one JavaScript cannot read, or with a flag it has no counterpart for, is left out.
141
+ * The player's `addonPatterns`.
142
+ * @param {unknown} addTo
143
+ * @returns {RegExp[]}
144
+ */
145
+ function addonPatterns(addTo) {
146
+ const types = /** @type {{ content?: { types?: unknown } } | null | undefined} */ (addTo)?.content?.types
147
+ if (!Array.isArray(types)) return []
148
+ const patterns = []
149
+ for (const type of types) {
150
+ const source = type?.text?.regex
151
+ if (typeof source !== 'string' || source.length < 2) continue
152
+ const delimiter = source[0]
153
+ if (/[\p{L}\p{N}\\\s([{<]/u.test(delimiter)) continue
154
+ const end = source.lastIndexOf(delimiter)
155
+ const flags = source.slice(end + 1)
156
+ if (end < 1 || /[^imsu]/.test(flags)) continue
157
+ try {
158
+ patterns.push(new RegExp(source.slice(1, end), flags))
159
+ } catch {
160
+ // PCRE that JavaScript does not read.
161
+ }
162
+ }
163
+ return patterns
164
+ }
165
+
166
+ /**
167
+ * Whether a string anywhere in the parameters matches one of the patterns. The player's
168
+ * `textMatches`.
169
+ * @param {unknown} params
170
+ * @param {RegExp[]} patterns
171
+ */
172
+ function textMatches(params, patterns) {
173
+ /** @param {unknown} value @param {number} depth @returns {boolean} */
174
+ const walk = (value, depth) => {
175
+ if (typeof value === 'string') return patterns.some((pattern) => pattern.test(value))
176
+ if (depth > 64 || !value || typeof value !== 'object') return false
177
+ return Object.values(value).some((child) => walk(child, depth + 1))
178
+ }
179
+ return patterns.length > 0 && walk(params, 0)
180
+ }
181
+
182
+ /**
183
+ * The library folders the package carries, each as its `library.json` says: the player's
184
+ * `ownLibraries` once every `library.json` is at hand. One that does not parse, or a folder named
185
+ * for another library, is left out.
186
+ * @param {Iterable<string>} names
187
+ * @param {(name: string) => Promise<unknown>} read
188
+ */
189
+ async function ownLibraries(names, read) {
190
+ /** @param {unknown} part */
191
+ const version = (part) => (typeof part === 'number' && Number.isInteger(part) && part >= 0 ? part : typeof part === 'string' && /^\d+$/.test(part) ? Number(part) : null)
192
+ const libraries = []
193
+ for (const name of names) {
194
+ const slash = name.indexOf('/')
195
+ if (slash < 0 || name.slice(slash + 1) !== 'library.json') continue
196
+ const folder = name.slice(0, slash)
197
+ const library = /** @type {{ machineName?: unknown, majorVersion?: unknown, minorVersion?: unknown } | null} */ (await read(name).catch(() => null))
198
+ const major = version(library?.majorVersion)
199
+ const minor = version(library?.minorVersion)
200
+ const machineName = library?.machineName
201
+ if (typeof machineName !== 'string' || major === null || minor === null) continue
202
+ const versioned = /^(.+)-(\d+)\.(\d+)$/.exec(folder)
203
+ if (versioned ? versioned[1] !== machineName || Number(versioned[2]) !== major : folder !== machineName) continue
204
+ libraries.push({ folder, machineName, major, minor, dependencies: dependenciesIn(library) })
205
+ }
206
+ return libraries
207
+ }
208
+
209
+ /**
210
+ * The library pack's index, its `parts/index.json`, as the plan reads it. `entry` is the library's
211
+ * own record, which the zip's index repeats.
212
+ * @param {unknown} index
213
+ */
214
+ function packLibraries(index) {
215
+ const list = /** @type {{ libraries?: unknown } | null} */ (index)?.libraries
216
+ return (Array.isArray(list) ? list : []).flatMap((entry) => {
217
+ const { folder, machineName, majorVersion, minorVersion, sha256, addTo } = entry ?? {}
218
+ return typeof folder === 'string' && typeof machineName === 'string' && Number.isInteger(majorVersion) && Number.isInteger(minorVersion) && typeof sha256 === 'string'
219
+ ? [{ folder, machineName, major: Number(majorVersion), minor: Number(minorVersion), sha256, dependencies: dependenciesIn(entry), addTo, entry: /** @type {object} */ (entry) }]
220
+ : []
221
+ })
222
+ }
223
+
224
+ /**
225
+ * The parts of the library pack the package needs, and the folders of its own they replace: the
226
+ * player's `planPack` (`sw/pack-plan.ts`) as the launch page has the element run it, filling gaps
227
+ * with nothing attached before (`refresh-libraries="off"`).
228
+ * - What the content asks for, and the package carries at that major and at no lower minor, plays
229
+ * from the package. Anything else comes from the pack, which has one version of each major, and
230
+ * so does whatever a part needs; an older minor the package carries gives way to the part.
231
+ * - Once anything comes from the pack, so do the addons whose pattern matches a string in the
232
+ * parameters: H5P.MathDisplay, for LaTeX.
233
+ * @param {object} input
234
+ * @param {Awaited<ReturnType<typeof ownLibraries>>} input.own
235
+ * @param {{ machineName: string, major: number, minor: number }[]} input.roots what `h5p.json`
236
+ * declares and the parameters name as sub-content
237
+ * @param {unknown} input.params
238
+ * @param {ReturnType<typeof packLibraries>} input.pack
239
+ */
240
+ function planParts({ own, roots, params, pack }) {
241
+ const byLibrary = new Map(pack.map((library) => [`${library.machineName} ${library.major}`, library]))
242
+ /** @type {Map<string, ReturnType<typeof packLibraries>[number]>} */
243
+ const planned = new Map()
244
+ /** @type {string[]} */
245
+ const superseded = []
246
+ let carried = own
247
+ const queue = [...roots]
248
+ const seen = new Set()
249
+
250
+ /**
251
+ * @param {{ machineName: string, major: number, minor: number }} version
252
+ * @param {{ machineName: string, major: number, minor: number }} dependency
253
+ */
254
+ const satisfies = (version, dependency) =>
255
+ version.machineName === dependency.machineName && version.major === dependency.major && version.minor >= dependency.minor
256
+ /** @param {ReturnType<typeof packLibraries>[number]} library */
257
+ const plan = (library) => {
258
+ if (planned.has(library.folder)) return
259
+ planned.set(library.folder, library)
260
+ queue.push(...library.dependencies)
261
+ }
262
+ const follow = () => {
263
+ for (let next = queue.shift(); next; next = queue.shift()) {
264
+ const dependency = next
265
+ const key = `${dependency.machineName} ${dependency.major}.${dependency.minor}`
266
+ if (seen.has(key)) continue
267
+ seen.add(key)
268
+ const ownVersion = carried.find((library) => satisfies(library, dependency))
269
+ if (ownVersion) {
270
+ queue.push(...ownVersion.dependencies)
271
+ continue
272
+ }
273
+ if ([...planned.values()].some((library) => satisfies(library, dependency))) continue
274
+ const candidate = byLibrary.get(`${dependency.machineName} ${dependency.major}`)
275
+ if (!candidate || candidate.minor < dependency.minor) continue
276
+ const older = carried.find((library) => library.machineName === dependency.machineName && library.major === dependency.major)
277
+ if (older) {
278
+ carried = carried.filter((library) => library !== older)
279
+ superseded.push(older.folder)
280
+ }
281
+ plan(candidate)
282
+ }
283
+ }
284
+
285
+ follow()
286
+ if (planned.size > 0) {
287
+ for (const addon of pack) {
288
+ if (addon.addTo === undefined || carried.some((library) => library.machineName === addon.machineName)) continue
289
+ if (textMatches(params, addonPatterns(addon.addTo))) plan(addon)
290
+ }
291
+ follow()
292
+ }
293
+ return { parts: [...planned.values()].sort((a, b) => (a.folder < b.folder ? -1 : a.folder > b.folder ? 1 : 0)), superseded }
294
+ }
295
+
296
+ /** @param {string} text */
297
+ const escapeXml = (text) =>
298
+ text.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[char] ?? char)
299
+
300
+ /** @param {string} text */
301
+ const escapeHtml = (text) => text.replace(/[&<>"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[char] ?? char)
302
+
303
+ /**
304
+ * The page's language: `h5p.json`'s `defaultLanguage` when it is a language tag, else English.
305
+ * @param {unknown} value
306
+ */
307
+ function languageTag(value) {
308
+ return typeof value === 'string' && /^[a-z]{2,3}(?:-[a-z0-9]{1,8})*$/i.test(value) ? value : 'en'
309
+ }
310
+
311
+ /**
312
+ * The manifest, SCORM 1.2's: one organisation with one item, played by one SCO, the launch page.
313
+ * Every file is listed, which Moodle does not need and some LMSs do.
314
+ * @param {{ identifier: string, title: string, files: string[] }} manifest
315
+ */
316
+ function manifestXml({ identifier, title, files }) {
317
+ const name = escapeXml(title)
318
+ return `<?xml version="1.0" encoding="UTF-8"?>
319
+ <manifest identifier="${identifier}" version="1.0"
320
+ xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
321
+ xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2">
322
+ <metadata>
323
+ <schema>ADL SCORM</schema>
324
+ <schemaversion>1.2</schemaversion>
325
+ </metadata>
326
+ <organizations default="organization">
327
+ <organization identifier="organization">
328
+ <title>${name}</title>
329
+ <item identifier="item" identifierref="sco" isvisible="true">
330
+ <title>${name}</title>
331
+ </item>
332
+ </organization>
333
+ </organizations>
334
+ <resources>
335
+ <resource identifier="sco" type="webcontent" adlcp:scormtype="sco" href="index.html">
336
+ ${files.map((file) => ` <file href="${escapeXml(file)}"/>`).join('\n')}
337
+ </resource>
338
+ </resources>
339
+ </manifest>
340
+ `
341
+ }
342
+
343
+ /** @param {{ version: string, source: string, pack: string | null }} zip `pack`, the library pack's version when the zip carries parts of it */
344
+ function notice({ version, source, pack }) {
345
+ return `This is a SCORM 1.2 package written by @missing-elements/h5p-embed. It plays ${source} in an
346
+ LMS and reports its score, its result and the time spent, and keeps the learner's place.
347
+
348
+ imsmanifest.xml the package's manifest
349
+ index.html, scorm.js, embed.css
350
+ the launch page and its bridge to the LMS: MIT
351
+ h5p-player.js, h5p-sw.js, h5p-jobs.js
352
+ @missing-elements/h5p-offline-player ${version}: MIT. The Service Worker,
353
+ h5p-sw.js, also carries zip.js, BSD-3-Clause, its licence in its header.
354
+ frame-assets/ @missing-elements/h5p-runtime, the H5P core runtime: GPL-3.0-only. Its
355
+ LICENSE.txt and NOTICE.txt say what it is and where its source is; keep them
356
+ with it.
357
+ ${PACKAGE} the H5P package, its content and libraries under their own licences, which its
358
+ h5p.json and each library's library.json name.
359
+ ${
360
+ pack
361
+ ? ` ${LIBRARIES}/ @missing-elements/h5p-libraries ${pack}, the H5P hub's libraries: those the package
362
+ needs and does not carry, one archive per library and index.json, each under
363
+ its own licence, listed in libraries.txt.
364
+ `
365
+ : ''
366
+ }
367
+ https://github.com/missing-elements/h5p-offline-player/tree/main/packages/embed
368
+ `
369
+ }
370
+
371
+ /**
372
+ * A file's SHA-256, read as a stream.
373
+ * @param {string} file
374
+ */
375
+ function sha256(file) {
376
+ return new Promise((done, fail) => {
377
+ const hash = createHash('sha256')
378
+ createReadStream(file)
379
+ .on('data', (chunk) => hash.update(chunk))
380
+ .on('error', fail)
381
+ .on('end', () => done(hash.digest('hex')))
382
+ })
383
+ }
384
+
385
+ /**
386
+ * Every file under a folder, as zip paths relative to it, sorted.
387
+ * @param {string} dir
388
+ */
389
+ async function filesUnder(dir) {
390
+ const files = []
391
+ for (const name of await readdir(dir, { recursive: true })) {
392
+ if ((await stat(join(dir, name))).isFile()) files.push(name.split(sep).join('/'))
393
+ }
394
+ return files.sort()
395
+ }
396
+
397
+ /**
398
+ * What `buildScorm` wrote, for a summary.
399
+ * @typedef {object} BuiltScorm
400
+ * @property {string} out the zip, as an absolute path
401
+ * @property {string} title the package's title, as the LMS lists it: `h5p.json`'s, or the file's name
402
+ * @property {string} version the player's version, as its `dist/VERSION` says
403
+ * @property {string[]} libraries the folders of the libraries the zip carries from the library
404
+ * pack, for a package that does not carry them itself; none for one that does
405
+ * @property {string} build the first 16 hex digits of the package's SHA-256, or, with libraries
406
+ * from the pack, of one over its and theirs: a learner's saved place is kept against it, and
407
+ * handed back only to the same package playing with the same libraries
408
+ * @property {number} size the zip's size in bytes
409
+ */
410
+
411
+ /**
412
+ * Writes a SCORM 1.2 zip that plays `h5p` in an LMS, with the parts of the library pack it needs
413
+ * and does not carry. Refuses a package that needs a library neither it nor the pack has, and a
414
+ * file already at `out` unless `force`.
415
+ *
416
+ * @param {object} options
417
+ * @param {string} options.h5p the package to wrap
418
+ * @param {string} options.out the zip to write
419
+ * @param {boolean} [options.force] replace a file already at `out`
420
+ * @returns {Promise<BuiltScorm>}
421
+ */
422
+ export async function buildScorm({ h5p, out, force = false }) {
423
+ if (!h5p) throw new EmbedError('No package given.')
424
+ if (!out) throw new EmbedError('No output file given.')
425
+ const source = resolve(h5p)
426
+ const target = resolve(out)
427
+ if (source === target) throw new EmbedError(`${out} is the package itself. Name another file to write.`)
428
+ const sourceInfo = await stat(source).catch(() => null)
429
+ if (!sourceInfo?.isFile()) throw new EmbedError(`No such file: ${h5p}`)
430
+ const targetInfo = await stat(target).catch(() => null)
431
+ if (targetInfo?.isDirectory()) throw new EmbedError(`${out} is a folder. Name the zip to write.`)
432
+ if (targetInfo && !force) throw new EmbedError(`${out} exists. Pass --force to replace it.`)
433
+
434
+ const packDir = dirname(installed('@missing-elements/h5p-libraries/parts/index.json', 'Reinstall this package.'))
435
+ const pack = packLibraries(JSON.parse(await readFile(join(packDir, 'index.json'), 'utf8')))
436
+
437
+ // What the package declares and carries, and what the pack adds, read before anything is written.
438
+ /** @type {any} `h5p.json`, parsed */
439
+ let manifest
440
+ /** @type {ReturnType<typeof planParts>} */
441
+ let plan
442
+ let reader
443
+ try {
444
+ reader = new ZipReader(new BlobReader(await openAsBlob(source)))
445
+ // One unsafe name does not refuse the package, as it does not in the player, which leaves that
446
+ // entry out.
447
+ const entries = await reader.getEntries({ filenameValidation: 'tolerant' })
448
+ const byName = new Map(entries.filter((entry) => !entry.directory).map((entry) => [entry.filename, entry]))
449
+ /** @param {string} name */
450
+ const readJson = async (name) => {
451
+ const entry = byName.get(name)
452
+ if (!entry?.getData) throw new EmbedError(`${basename(h5p)} has no ${name}: it is not an H5P package.`)
453
+ const text = await entry.getData(new TextWriter())
454
+ try {
455
+ return JSON.parse(text)
456
+ } catch {
457
+ throw new EmbedError(`${basename(h5p)}: ${name} is not JSON.`)
458
+ }
459
+ }
460
+ manifest = await readJson('h5p.json')
461
+ if (dependenciesIn(manifest).length === 0) {
462
+ throw new EmbedError(`${basename(h5p)}: h5p.json names no libraries, so there is nothing to play.`)
463
+ }
464
+ const params = await readJson('content/content.json')
465
+ const roots = [...dependenciesIn(manifest), ...librariesNamedIn(params)]
466
+ plan = planParts({ own: await ownLibraries(byName.keys(), readJson), roots, params, pack })
467
+
468
+ // Held to its libraries as the player holds it once the parts are attached: the folders they
469
+ // replace withheld, and each part's `library.json` naming what its record in the index does.
470
+ const parts = new Map(plan.parts.map((part) => [`${part.folder}/library.json`, part.entry]))
471
+ const withheld = new Set(plan.superseded)
472
+ const names = new Set([...byName.keys()].filter((name) => !withheld.has(name.split('/')[0])))
473
+ for (const name of parts.keys()) names.add(name)
474
+ const missing = await missingLibraries(roots, names, async (name) => parts.get(name) ?? readJson(name))
475
+ if (missing.length > 0) {
476
+ const packed = new Map(pack.map((library) => [`${library.machineName} ${library.major}`, library]))
477
+ const listed = missing.map(({ machineName, major, minor }) => {
478
+ const older = packed.get(`${machineName} ${major}`)
479
+ return `${machineName} ${major}.${minor}${older ? ` (the pack has ${older.major}.${older.minor})` : ''}`
480
+ })
481
+ throw new EmbedError(
482
+ `${basename(h5p)} needs libraries that neither it nor the library pack carries: ${listed.join(', ')}. ` +
483
+ 'Export the package with its libraries, as Lumi and the H5P plugins of Moodle, WordPress and Drupal do.'
484
+ )
485
+ }
486
+ } catch (error) {
487
+ if (error instanceof EmbedError) throw error
488
+ throw new EmbedError(`${basename(h5p)} could not be read as a zip archive: ${error instanceof Error ? error.message : error}`)
489
+ } finally {
490
+ await reader?.close()
491
+ }
492
+
493
+ // Control characters, which XML refuses, read as spaces: a title pasted in can carry one.
494
+ const named = typeof manifest.title === 'string' ? manifest.title.replace(/[\x00-\x1f\x7f]+/g, ' ').trim() : ''
495
+ const title = named || basename(h5p, extname(h5p))
496
+ // The learner's saved place is kept against the libraries too, as the player keeps it against
497
+ // the revision: a zip written with others starts learners over, as a refreshed package does.
498
+ const hashes = [await sha256(source), ...plan.parts.map((part) => part.sha256)]
499
+ const build = (hashes.length === 1 ? hashes[0] : createHash('sha256').update(hashes.join('\n')).digest('hex')).slice(0, 16)
500
+ const player = dirname(installed('@missing-elements/h5p-offline-player/dist/h5p-player.js', 'Run `pnpm build` in the workspace, or reinstall this package.'))
501
+ const runtime = dirname(installed('@missing-elements/h5p-runtime/dist/h5p.css', 'Run `pnpm build` in the workspace, or reinstall this package.'))
502
+ const version = (await readFile(join(player, 'VERSION'), 'utf8').catch(() => 'unknown')).trim()
503
+ const packVersion = plan.parts.length > 0 ? String(JSON.parse(await readFile(join(dirname(packDir), 'package.json'), 'utf8')).version) : null
504
+
505
+ const page = (await readFile(join(SCORM, 'index.html'), 'utf8'))
506
+ .replace('%LANG%', languageTag(manifest.defaultLanguage))
507
+ // A function, so that a title's `$&` or `$'` is written as it is rather than read as a pattern.
508
+ .replace('%TITLE%', () => escapeHtml(title))
509
+ .replace('%BUILD%', build)
510
+ // The element plans from the zip's index what the writer planned, and plays the package's own
511
+ // libraries as they are.
512
+ .replace('%LIBRARIES%', packVersion ? ` libraries="pack" pack-base="./${LIBRARIES}/" refresh-libraries="off"` : '')
513
+ /** @type {[string, { text: string } | { file: string }][]} zip path, and what it holds */
514
+ const files = [
515
+ ['index.html', { text: page }],
516
+ ['scorm.js', { file: join(SCORM, 'scorm.js') }],
517
+ ['embed.css', { file: join(SITE, 'embed.css') }],
518
+ ...PLAYER_FILES.map((name) => /** @type {[string, { file: string }]} */ ([name, { file: join(player, name) }])),
519
+ ...(await filesUnder(runtime)).map((name) => /** @type {[string, { file: string }]} */ ([`frame-assets/${name}`, { file: join(runtime, name) }])),
520
+ [PACKAGE, { file: source }]
521
+ ]
522
+ if (packVersion) {
523
+ files.push([`${LIBRARIES}/index.json`, { text: `${JSON.stringify({ libraries: plan.parts.map((part) => part.entry) }, null, 2)}\n` }])
524
+ for (const part of plan.parts) files.push([`${LIBRARIES}/${part.folder}.h5p`, { file: join(packDir, `${part.folder}.h5p`) }])
525
+ files.push(['libraries.txt', { file: join(dirname(packDir), 'libraries.txt') }])
526
+ }
527
+ files.push(['NOTICE.txt', { text: notice({ version, source: basename(h5p), pack: packVersion }) }])
528
+ const xml = manifestXml({ identifier: `h5p-${build}`, title, files: ['imsmanifest.xml', ...files.map(([name]) => name)] })
529
+
530
+ // Written beside the target and renamed into place, so a failure leaves no half-written zip.
531
+ const partial = `${target}.${process.pid}.partial`
532
+ try {
533
+ const zip = new ZipWriter(Writable.toWeb(createWriteStream(partial)))
534
+ await zip.add('imsmanifest.xml', new TextReader(xml))
535
+ for (const [name, content] of files) {
536
+ // The package, the pack's parts and the fonts are compressed already.
537
+ const level = name.endsWith('.h5p') || name.endsWith('.woff2') ? 0 : 6
538
+ await zip.add(name, 'text' in content ? new TextReader(content.text) : new BlobReader(await openAsBlob(content.file)), { level })
539
+ }
540
+ await zip.close()
541
+ await rename(partial, target)
542
+ } catch (error) {
543
+ await rm(partial, { force: true })
544
+ throw error
545
+ }
546
+
547
+ return { out: target, title, version, libraries: plan.parts.map((part) => part.folder), build, size: (await stat(target)).size }
548
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@missing-elements/h5p-embed",
3
- "version": "1.1.0",
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. No H5P server, no third-party service; optional allowlists for package hosts and embedding sites.",
3
+ "version": "1.2.0",
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": {
7
7
  "type": "git",
@@ -24,6 +24,10 @@
24
24
  "types": "./types/lib/build.d.mts",
25
25
  "default": "./lib/build.mjs"
26
26
  },
27
+ "./scorm": {
28
+ "types": "./types/lib/scorm.d.mts",
29
+ "default": "./lib/scorm.mjs"
30
+ },
27
31
  "./embed.js": {
28
32
  "types": "./types/site/embed.d.ts",
29
33
  "default": "./site/embed.js"
@@ -35,6 +39,7 @@
35
39
  "files": [
36
40
  "bin",
37
41
  "lib",
42
+ "scorm",
38
43
  "site",
39
44
  "types"
40
45
  ],
@@ -47,16 +52,19 @@
47
52
  "embed",
48
53
  "static-site",
49
54
  "elearning",
55
+ "scorm",
56
+ "lms",
57
+ "moodle",
50
58
  "cli"
51
59
  ],
52
60
  "dependencies": {
61
+ "@zip.js/zip.js": "^2.24.0",
53
62
  "@missing-elements/h5p-libraries": "^1.0.0",
54
- "@missing-elements/h5p-runtime": "^1.0.0",
55
- "@missing-elements/h5p-offline-player": "^1.1.1"
63
+ "@missing-elements/h5p-offline-player": "^1.1.1",
64
+ "@missing-elements/h5p-runtime": "^1.0.0"
56
65
  },
57
66
  "devDependencies": {
58
67
  "@types/node": "^26.6.5",
59
- "@zip.js/zip.js": "^2.24.0",
60
68
  "playwright": "^1.64.1",
61
69
  "typescript": "^7.0.2",
62
70
  "vitest": "^5.0.5"
@@ -0,0 +1,21 @@
1
+ <!doctype html>
2
+ <html lang="%LANG%">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>%TITLE%</title>
7
+ <meta name="h5p-package-build" content="%BUILD%" />
8
+ <link rel="stylesheet" href="./embed.css" />
9
+ <script type="module" src="./h5p-player.js"></script>
10
+ <script type="module" src="./scorm.js"></script>
11
+ </head>
12
+
13
+ <body>
14
+ <div class="loader" id="loader" role="status">
15
+ <span class="spinner" aria-hidden="true"></span>
16
+ <span class="loader-text">Loading…</span>
17
+ </div>
18
+ <h5p-player%LIBRARIES%></h5p-player>
19
+ <p class="notice error" id="notice" role="alert" hidden></p>
20
+ </body>
21
+ </html>
package/scorm/scorm.js ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The launch page's script in a SCORM 1.2 package `h5p-embed --scorm` writes: it finds the LMS's
3
+ * API, opens the session, and plays the package beside this page. The LMS hears what the content
4
+ * reports: its score and whether it passed when it finishes, the time spent, and the state it
5
+ * saves, which comes back on the next launch so the learner continues where they left off.
6
+ *
7
+ * Outside an LMS, with no API to find, it plays the package and reports nothing.
8
+ *
9
+ * @import { H5PPlayerElement, PlayerErrorDetail, PlayerStateChangeDetail, UserDataDetail } from '@missing-elements/h5p-offline-player'
10
+ */
11
+
12
+ /**
13
+ * The SCORM 1.2 API, as far as this page calls it: every call takes and returns strings.
14
+ * @typedef {object} ScormApi
15
+ * @property {(empty: string) => string} LMSInitialize
16
+ * @property {(empty: string) => string} LMSFinish
17
+ * @property {(element: string) => string} LMSGetValue
18
+ * @property {(element: string, value: string) => string} LMSSetValue
19
+ * @property {(empty: string) => string} LMSCommit
20
+ */
21
+
22
+ /** What SCORM 1.2 keeps of `cmi.suspend_data`: a `CMIString4096`. */
23
+ const SUSPEND_DATA_LIMIT = 4096
24
+
25
+ /**
26
+ * The `API` object an LMS puts in a window above the content, searched from `start` up to its top
27
+ * window (the run-time environment's discovery, 3.3.6.1).
28
+ * @param {Window | null} start
29
+ * @returns {ScormApi | null}
30
+ */
31
+ function findApi(start) {
32
+ let win = start
33
+ for (let hops = 0; win && hops < 10; hops++) {
34
+ try {
35
+ const api = /** @type {{ API?: ScormApi }} */ (/** @type {unknown} */ (win)).API
36
+ if (api) return api
37
+ } catch {
38
+ // A window on another origin holds nothing this page can call.
39
+ }
40
+ if (win.parent === win) break
41
+ win = win.parent
42
+ }
43
+ return null
44
+ }
45
+
46
+ /**
47
+ * `cmi.core.session_time`'s format, a `CMITimespan`: hours, minutes and seconds to the hundredth.
48
+ * @param {number} ms
49
+ */
50
+ function timespan(ms) {
51
+ const hundredths = Math.max(0, Math.round(ms / 10))
52
+ const hours = Math.floor(hundredths / 360000)
53
+ const minutes = Math.floor(hundredths / 6000) % 60
54
+ const seconds = (hundredths % 6000) / 100
55
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${seconds.toFixed(2).padStart(5, '0')}`
56
+ }
57
+
58
+ /**
59
+ * A `finished` statement's result as SCORM 1.2 records it: the score out of 100, and the status,
60
+ * `passed` or `failed` when the content says whether it was passed, `completed` when it does not.
61
+ * @param {{ score?: { scaled?: unknown, raw?: unknown, max?: unknown }, success?: unknown } | undefined} result
62
+ */
63
+ function scormResult(result) {
64
+ const score = result?.score ?? {}
65
+ const scaled =
66
+ typeof score.scaled === 'number'
67
+ ? score.scaled
68
+ : typeof score.raw === 'number' && typeof score.max === 'number' && score.max > 0
69
+ ? score.raw / score.max
70
+ : null
71
+ return {
72
+ raw: scaled === null ? null : String(Math.round(Math.min(Math.max(scaled, 0), 1) * 10000) / 100),
73
+ status: result?.success === true ? 'passed' : result?.success === false ? 'failed' : 'completed'
74
+ }
75
+ }
76
+
77
+ /** The package's build, as the writer stamped it: what saved state is kept against. */
78
+ const build = document.querySelector('meta[name="h5p-package-build"]')?.getAttribute('content') ?? ''
79
+ const player = /** @type {H5PPlayerElement} */ (document.querySelector('h5p-player'))
80
+ const loader = /** @type {HTMLElement} */ (document.getElementById('loader'))
81
+ const notice = /** @type {HTMLElement} */ (document.getElementById('notice'))
82
+
83
+ const api = findApi(window) ?? (window.opener ? findApi(window.opener) : null)
84
+ const started = Date.now()
85
+ /** Whether the LMS accepted the session; nothing is reported to one that did not. */
86
+ const initialized = api ? String(api.LMSInitialize('')) === 'true' : false
87
+
88
+ /** @param {string} element */
89
+ const get = (element) => (initialized && api ? String(api.LMSGetValue(element)) : '')
90
+ /** @param {string} element @param {string} value */
91
+ const set = (element, value) => initialized && api !== null && String(api.LMSSetValue(element, value)) === 'true'
92
+ const commit = () => initialized && api !== null && String(api.LMSCommit('')) === 'true'
93
+
94
+ if (initialized) {
95
+ const status = get('cmi.core.lesson_status')
96
+ if (status === 'not attempted' || status === '') set('cmi.core.lesson_status', 'incomplete')
97
+ }
98
+
99
+ /*
100
+ * The learner's place. The content saves its state through the element (`resume="host"`), and
101
+ * the whole of it goes into `cmi.suspend_data`, stamped with the package's build so that a teacher
102
+ * who replaces the package does not hand its learners state saved against another one.
103
+ */
104
+
105
+ /** @type {Map<string, { dataType: string, subContentId: string, data: string }>} */
106
+ const saved = new Map()
107
+
108
+ if (initialized) {
109
+ try {
110
+ const kept = JSON.parse(get('cmi.suspend_data') || 'null')
111
+ if (build && kept?.build === build && Array.isArray(kept.entries)) {
112
+ for (const [dataType, subContentId, data] of kept.entries) {
113
+ if (typeof data === 'string') saved.set(`${dataType}\n${subContentId}`, { dataType: String(dataType), subContentId: String(subContentId), data })
114
+ }
115
+ }
116
+ } catch {
117
+ // Not ours, or cut short by an LMS: the content starts over.
118
+ }
119
+ player.setAttribute('resume', 'host')
120
+ player.userData = [...saved.values()]
121
+ }
122
+
123
+ player.addEventListener('userdata', (event) => {
124
+ const { dataType, subContentId, data } = /** @type {CustomEvent<UserDataDetail>} */ (event).detail
125
+ const key = `${dataType}\n${subContentId}`
126
+ if (data === null) saved.delete(key)
127
+ else saved.set(key, { dataType, subContentId, data })
128
+ const value = JSON.stringify({ build, entries: [...saved.values()].map((entry) => [entry.dataType, entry.subContentId, entry.data]) })
129
+ // A state larger than SCORM 1.2 keeps is not saved, rather than saved cut short; the last one
130
+ // that fitted stays.
131
+ if (value.length <= SUSPEND_DATA_LIMIT && set('cmi.suspend_data', value)) commit()
132
+ })
133
+
134
+ /*
135
+ * The result. The content's last `finished` is its result, as on an H5P site: a learner who
136
+ * retries replaces it.
137
+ */
138
+ let finished = false
139
+
140
+ player.addEventListener('finished', (event) => {
141
+ if (!initialized) return
142
+ const { raw, status } = scormResult(/** @type {CustomEvent<{ statement?: { result?: any } }>} */ (event).detail?.statement?.result)
143
+ if (raw !== null) {
144
+ set('cmi.core.score.min', '0')
145
+ set('cmi.core.score.max', '100')
146
+ set('cmi.core.score.raw', raw)
147
+ }
148
+ set('cmi.core.lesson_status', status)
149
+ set('cmi.core.session_time', timespan(Date.now() - started))
150
+ finished = true
151
+ commit()
152
+ })
153
+
154
+ // The session ends with the page. Unfinished, it is suspended, so the next launch resumes it.
155
+ let ended = false
156
+ addEventListener('pagehide', () => {
157
+ if (!initialized || ended || !api) return
158
+ ended = true
159
+ set('cmi.core.session_time', timespan(Date.now() - started))
160
+ set('cmi.core.exit', finished ? '' : 'suspend')
161
+ api.LMSFinish('')
162
+ })
163
+
164
+ player.addEventListener('statechange', (event) => {
165
+ const { state } = /** @type {CustomEvent<PlayerStateChangeDetail>} */ (event).detail
166
+ if (state === 'ready' || state === 'error') loader.hidden = true
167
+ })
168
+
169
+ player.addEventListener('error', (event) => {
170
+ // After `ready` the content is up and an error leaves it running.
171
+ if (player.state === 'ready') return
172
+ notice.textContent = /** @type {CustomEvent<PlayerErrorDetail>} */ (/** @type {unknown} */ (event)).detail.message
173
+ notice.hidden = false
174
+ })
175
+
176
+ player.setAttribute('src', new URL('./package.h5p', location.href).href)
package/site/embed.js CHANGED
@@ -256,6 +256,22 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
256
256
  }
257
257
  }
258
258
 
259
+ /**
260
+ * A package's address as the element is given it: resolved against this page, and only when it
261
+ * is `http` or `https`. `null` for anything else, a `javascript:` or `data:` URL or text that is
262
+ * not a URL. The element gets the parsed URL's `href`, never the text, so `src` holds what was
263
+ * checked.
264
+ * @param {string} value
265
+ */
266
+ const packageUrl = (value) => {
267
+ try {
268
+ const url = new URL(value, location.href)
269
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url : null
270
+ } catch {
271
+ return null
272
+ }
273
+ }
274
+
259
275
  const allowed = packages ? new Set(packages) : null
260
276
  /**
261
277
  * Whether this player was told it may fetch from `origin`. Always true without a list.
@@ -355,11 +371,12 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
355
371
  refuse('No package given. Add ?src=<url of a .h5p file> to the address.', 'no-src')
356
372
  return
357
373
  }
358
- const origin = urlOrigin(src)
359
- if (!origin) {
360
- refuse('The package address is not a URL.')
374
+ const address = packageUrl(src)
375
+ if (!address) {
376
+ refuse('The package address is not an http or https URL.')
361
377
  return
362
378
  }
379
+ const { origin } = address
363
380
  if (!permitted(origin)) {
364
381
  refuse(`This player does not play packages from ${origin}.`)
365
382
  return
@@ -375,14 +392,14 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
375
392
  // A host on the list was vouched for when the site was set up, and in another site's frame the
376
393
  // storage is that site's own: those play as they are.
377
394
  if (origin === location.origin || allowed || !sharesOriginStorage()) {
378
- start(src, sources.value)
395
+ start(address.href, sources.value)
379
396
  return
380
397
  }
381
398
 
382
399
  // Anything else from another origin would share this origin's storage. It plays untrusted, as
383
400
  // data with the library pack's code and none of its own, and waits for a click that names the
384
401
  // host only when it cannot play without its own, or there is no pack to play it with.
385
- const host = new URL(src, location.href).host || origin
402
+ const { host } = address
386
403
  askToTrust = () => {
387
404
  loader.hidden = true
388
405
  const button = document.createElement('button')
@@ -393,7 +410,7 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
393
410
  loader.hidden = false
394
411
  player.untrusted = false
395
412
  player.removeAttribute('src')
396
- start(src, sources.value)
413
+ start(address.href, sources.value)
397
414
  })
398
415
  say(
399
416
  `This package from ${host} plays only with its own scripts. They would run on this site and ` +
@@ -411,5 +428,5 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
411
428
  return
412
429
  }
413
430
  player.untrusted = true
414
- start(src, sitesSources)
431
+ start(address.href, sitesSources)
415
432
  }
@@ -0,0 +1,58 @@
1
+ export type BuiltScorm = {
2
+ /**
3
+ * the zip, as an absolute path
4
+ */
5
+ out: string;
6
+ /**
7
+ * the package's title, as the LMS lists it: `h5p.json`'s, or the file's name
8
+ */
9
+ title: string;
10
+ /**
11
+ * the player's version, as its `dist/VERSION` says
12
+ */
13
+ version: string;
14
+ /**
15
+ * the folders of the libraries the zip carries from the library
16
+ * pack, for a package that does not carry them itself; none for one that does
17
+ */
18
+ libraries: string[];
19
+ /**
20
+ * the first 16 hex digits of the package's SHA-256, or, with libraries
21
+ * from the pack, of one over its and theirs: a learner's saved place is kept against it, and
22
+ * handed back only to the same package playing with the same libraries
23
+ */
24
+ build: string;
25
+ /**
26
+ * the zip's size in bytes
27
+ */
28
+ size: number;
29
+ };
30
+ /**
31
+ * What `buildScorm` wrote, for a summary.
32
+ * @typedef {object} BuiltScorm
33
+ * @property {string} out the zip, as an absolute path
34
+ * @property {string} title the package's title, as the LMS lists it: `h5p.json`'s, or the file's name
35
+ * @property {string} version the player's version, as its `dist/VERSION` says
36
+ * @property {string[]} libraries the folders of the libraries the zip carries from the library
37
+ * pack, for a package that does not carry them itself; none for one that does
38
+ * @property {string} build the first 16 hex digits of the package's SHA-256, or, with libraries
39
+ * from the pack, of one over its and theirs: a learner's saved place is kept against it, and
40
+ * handed back only to the same package playing with the same libraries
41
+ * @property {number} size the zip's size in bytes
42
+ */
43
+ /**
44
+ * Writes a SCORM 1.2 zip that plays `h5p` in an LMS, with the parts of the library pack it needs
45
+ * and does not carry. Refuses a package that needs a library neither it nor the pack has, and a
46
+ * file already at `out` unless `force`.
47
+ *
48
+ * @param {object} options
49
+ * @param {string} options.h5p the package to wrap
50
+ * @param {string} options.out the zip to write
51
+ * @param {boolean} [options.force] replace a file already at `out`
52
+ * @returns {Promise<BuiltScorm>}
53
+ */
54
+ export declare function buildScorm({ h5p, out, force }: {
55
+ h5p: string;
56
+ out: string;
57
+ force?: boolean;
58
+ }): Promise<BuiltScorm>;