@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 +51 -4
- package/bin/h5p-embed.mjs +51 -5
- package/lib/build.mjs +9 -5
- package/lib/scorm.mjs +548 -0
- package/package.json +13 -5
- package/scorm/index.html +21 -0
- package/scorm/scorm.js +176 -0
- package/site/embed.js +24 -7
- package/types/lib/scorm.d.mts +58 -0
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
|
-
|
|
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`.
|
|
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
|
|
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
|
-
/**
|
|
24
|
-
|
|
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
|
|
308
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char] ?? char)
|
|
299
|
+
|
|
300
|
+
/** @param {string} text */
|
|
301
|
+
const escapeHtml = (text) => text.replace(/[&<>"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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.
|
|
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-
|
|
55
|
-
"@missing-elements/h5p-
|
|
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"
|
package/scorm/index.html
ADDED
|
@@ -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
|
|
359
|
-
if (!
|
|
360
|
-
refuse('The package address is not
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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>;
|