@missing-elements/h5p-embed 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +168 -2
- package/bin/h5p-embed.mjs +95 -0
- package/lib/build.mjs +283 -0
- package/package.json +61 -3
- package/site/embed.css +84 -0
- package/site/embed.js +335 -0
- package/site/index.html +22 -0
- package/site/main.js +11 -0
- package/site/resizer.js +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 missing-elements
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,169 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @missing-elements/h5p-embed
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Self-hosted H5P embeds. One command writes the H5P embed page as a static site; you deploy it to
|
|
4
|
+
a player domain of your own, and any site — a page builder, a hosted CMS, an LMS page, a portal
|
|
5
|
+
with signed-in users — embeds a package with an iframe and one script line. No H5P server, no
|
|
6
|
+
backend, and no third-party service between your learners and your content.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx @missing-elements/h5p-embed h5p-player
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
Wrote h5p-player/ (11.1 MB): the embed page, the player 0.5.1, the H5P runtime, the library pack.
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Deploy the folder to any static host, then embed:
|
|
17
|
+
|
|
18
|
+
```html
|
|
19
|
+
<iframe src="https://h5p-player.example.net/?src=https://cdn.example.org/course.h5p"
|
|
20
|
+
allow="fullscreen" style="width: 100%; border: 0"></iframe>
|
|
21
|
+
<script src="https://h5p-player.example.net/resizer.js"></script>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The script line sizes the iframe to the content. A page that already has h5p.org's
|
|
25
|
+
`h5p-resizer.js` needs no second one; it speaks the same protocol.
|
|
26
|
+
|
|
27
|
+
## Why a domain of its own
|
|
28
|
+
|
|
29
|
+
An H5P package is JavaScript, and the player runs it on the origin that serves the page. On a
|
|
30
|
+
domain that holds nothing else, a package cannot reach your site's page, cookies, storage or APIs.
|
|
31
|
+
Use a separate registrable domain — `h5p-player.example.net`, not `h5p.example.com` — because a
|
|
32
|
+
subdomain is the same site and receives cookies set for `.example.com`. Put no accounts, no
|
|
33
|
+
cookies and nothing else on it.
|
|
34
|
+
|
|
35
|
+
## Options
|
|
36
|
+
|
|
37
|
+
| Option | Effect |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `--packages <origins>` | Play packages, and fetch library bundles, only from these origins (this domain's own is always allowed). The page refuses anything else by name, and the policy's `connect-src` blocks it in the browser. Add `https://api.h5p.org` to allow `libraries=hub` |
|
|
40
|
+
| `--ancestors <origins>` | Only these sites may frame the page: `frame-ancestors`, which only a header can carry |
|
|
41
|
+
| `--default-libraries <sources>` | The `libraries` value for addresses that name none, so a snippet without `&libraries=` still plays an export that carries no libraries (h5p.com and h5p.org exports usually do not): `pack`, `hub`, URLs, as the parameter. Checked against `--packages` when the site is written. Default: none, and such exports are refused unless the address asks |
|
|
42
|
+
| `--no-libraries` | Leave out the 9.5 MB library pack; `libraries=pack` then means the hub, where allowed |
|
|
43
|
+
| `--force` | Write into a folder that is not empty, replacing only this tool's files |
|
|
44
|
+
|
|
45
|
+
Origins are written `https://host.example`, comma or space separated, with no path or trailing
|
|
46
|
+
slash. `http://localhost` is accepted for trying it locally.
|
|
47
|
+
|
|
48
|
+
List every host a package URL passes through. The page checks the address it is given, but the
|
|
49
|
+
browser's policy also applies to each redirect, so a listed host that redirects to a CDN off the
|
|
50
|
+
list fails as an ordinary network error, with nothing naming the list as the cause.
|
|
51
|
+
|
|
52
|
+
A player domain for one organisation is best locked to its own hosts:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx @missing-elements/h5p-embed h5p-player \
|
|
56
|
+
--packages https://cdn.example.org \
|
|
57
|
+
--ancestors "https://www.example.org https://lms.example.org"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Hosting
|
|
61
|
+
|
|
62
|
+
| Host | Policy and caching |
|
|
63
|
+
|---|---|
|
|
64
|
+
| Netlify, Cloudflare Pages | `_headers`, written beside the page |
|
|
65
|
+
| Vercel | `vercel.json`, written beside the page; deploy the folder as a project |
|
|
66
|
+
| GitHub Pages, S3, nginx, anything else | The page carries the policy in a `<meta>` tag. `--ancestors` needs a header: configure `Content-Security-Policy: frame-ancestors …` on the server, or leave it out |
|
|
67
|
+
|
|
68
|
+
The site works at the domain's root or under a path (`https://example.github.io/player/`): every
|
|
69
|
+
URL in it is relative, and the player's Service Worker takes the scope `<folder>/h5p/`. Serve it
|
|
70
|
+
over https; a frame in a page on plain http has no Service Worker.
|
|
71
|
+
|
|
72
|
+
`h5p-sw.js` should be served with `Cache-Control: no-cache`, as the header files say, so an update
|
|
73
|
+
reaches learners on their next visit. To update, run the command again with `--force` and
|
|
74
|
+
redeploy.
|
|
75
|
+
|
|
76
|
+
## The address
|
|
77
|
+
|
|
78
|
+
| Parameter | Effect |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `src=<url>` | The package, required. Encode `&`, `#`, `+`, `%` and spaces in it |
|
|
81
|
+
| `libraries=pack`, `hub`, `<url>` or `none` | Libraries for an export that has none (h5p.com and h5p.org exports usually do not); `none` turns off the site's `--default-libraries` for this address. `pack` is the copy on this domain, with the hub behind it where allowed, or the hub alone on a site written with `--no-libraries`; several sources may be given, tried in order |
|
|
82
|
+
| `frame`, `copyright`, `export`, `icon`, `reporting` | H5P's action bar under the content and its buttons |
|
|
83
|
+
| `fullscreen=off` | No fullscreen button |
|
|
84
|
+
| `preload=auto` | Start fetching media at once |
|
|
85
|
+
| `activity-id=<IRI>` | The object id every xAPI statement names, instead of the package URL |
|
|
86
|
+
| `custom-css=<url>` | A stylesheet of yours, loaded into the content |
|
|
87
|
+
| `xapi=<origin>` | Relay statements to the embedding page, see below |
|
|
88
|
+
|
|
89
|
+
Not available on the address: a custom script, and a learner's name. A script is a capability on
|
|
90
|
+
the player's origin that a link should not hand out, and a name has no place in a URL.
|
|
91
|
+
|
|
92
|
+
Opened on its own (not framed), the page asks before playing a package from another origin
|
|
93
|
+
unless `--packages` names it: there, every package shares the domain's storage.
|
|
94
|
+
|
|
95
|
+
## Results
|
|
96
|
+
|
|
97
|
+
Add `&xapi=<the embedding page's origin>` and the frame posts every statement to that origin
|
|
98
|
+
and no other:
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
const frame = document.querySelector('iframe')
|
|
102
|
+
addEventListener('message', ({ source, origin, data }) => {
|
|
103
|
+
if (source !== frame.contentWindow || origin !== 'https://h5p-player.example.net') return
|
|
104
|
+
if (data?.context !== 'h5p-offline-player') return
|
|
105
|
+
if (data.action === 'xapi') send(data.statement) // every statement
|
|
106
|
+
if (data.action === 'finished') send(data.statement) // the final one, with result.score
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The checks prove where a message came from, not what it says: a package can post any statement,
|
|
111
|
+
so treat relayed results as the learner's report, not as proof for a grade. Each statement
|
|
112
|
+
carries `context.revision`, a fingerprint of the package build.
|
|
113
|
+
|
|
114
|
+
## Messages to the embedding page
|
|
115
|
+
|
|
116
|
+
Besides the heights and the relayed statements, the frame posts two messages to its parent,
|
|
117
|
+
whatever `xapi=` says. Neither carries anything the embedding page did not hand over itself.
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
// Once, when the content is up: what the player learnt about the package.
|
|
121
|
+
{ context: 'h5p-offline-player', action: 'report',
|
|
122
|
+
source, // { type: 'range-http' | 'chunked' | 'file', size }: streamed, or downloaded whole
|
|
123
|
+
metadata, // { title, license, licenseVersion, authors: [names], mainLibrary }, from h5p.json
|
|
124
|
+
libraryBundle, // { url, origin, fromCache } when libraries came from a bundle, else null
|
|
125
|
+
elapsedMs } // from setting the package to the content being up
|
|
126
|
+
|
|
127
|
+
// Instead, when the load fails, or the page refuses the address (code: 'refused', or 'no-src').
|
|
128
|
+
{ context: 'h5p-offline-player', action: 'error', code, message }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The strings in `metadata` are the package's own; show them as text.
|
|
132
|
+
|
|
133
|
+
## As a library
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
import { buildSite } from '@missing-elements/h5p-embed'
|
|
137
|
+
|
|
138
|
+
await buildSite({ out: 'public/player', packages: ['https://cdn.example.org'] })
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`@missing-elements/h5p-embed/embed.js` exports `startEmbed()`, the page's script, for a site
|
|
142
|
+
that builds the page into its own pipeline. It reads the page's `<h5p-player>`, `#loader` and
|
|
143
|
+
`#notice` (see `site/index.html`, and `site/embed.css` for their styles) and takes:
|
|
144
|
+
|
|
145
|
+
| Option | |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `librariesPack` | The URL of a copy of `@missing-elements/h5p-libraries`, which `libraries=pack` names |
|
|
148
|
+
| `packages` | The origins packages may come from, besides the page's own; `null` for any |
|
|
149
|
+
| `defaultLibraries` | The `libraries` value for addresses that name none |
|
|
150
|
+
| `runtime` | The `runtime` export of `@missing-elements/h5p-runtime`, for a bundled element |
|
|
151
|
+
| `askInOwnFrame` | `false` to skip the click a package from elsewhere otherwise waits for when a page of the same origin frames this one — for a site whose own preview frames it for a package the visitor just chose. Default `true` |
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
import '@missing-elements/h5p-offline-player'
|
|
155
|
+
import { runtime } from '@missing-elements/h5p-runtime'
|
|
156
|
+
import librariesPack from '@missing-elements/h5p-libraries/libraries.h5p?url'
|
|
157
|
+
import '@missing-elements/h5p-embed/embed.css'
|
|
158
|
+
import { startEmbed } from '@missing-elements/h5p-embed/embed.js'
|
|
159
|
+
|
|
160
|
+
startEmbed({ runtime, librariesPack, defaultLibraries: 'pack' })
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Licences
|
|
164
|
+
|
|
165
|
+
This package is MIT. The folder it writes also carries
|
|
166
|
+
[`@missing-elements/h5p-runtime`](https://www.npmjs.com/package/@missing-elements/h5p-runtime), the
|
|
167
|
+
H5P core runtime, under the GPL-3.0, as `frame-assets/` with its `LICENSE.txt` and `NOTICE.txt`;
|
|
168
|
+
and, unless `--no-libraries`, the H5P hub's libraries under their own licences, listed in
|
|
169
|
+
`libraries.txt`. `NOTICE.txt` in the folder says which file is what.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { relative } from 'node:path'
|
|
4
|
+
import { parseArgs } from 'node:util'
|
|
5
|
+
import { EmbedError, buildSite } from '../lib/build.mjs'
|
|
6
|
+
|
|
7
|
+
const USAGE = `Usage: h5p-embed [folder] [options]
|
|
8
|
+
|
|
9
|
+
Writes the H5P embed page as a static site, ready to deploy to a player domain of your own.
|
|
10
|
+
The folder defaults to ./h5p-player.
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--packages <origins> play packages, and fetch library bundles, only from these origins
|
|
14
|
+
(comma or space separated; this site's own is always allowed). Add
|
|
15
|
+
https://api.h5p.org to allow libraries=hub, and any host a package
|
|
16
|
+
URL redirects to. Default: any https host.
|
|
17
|
+
--ancestors <origins> only these sites may frame the page (frame-ancestors, sent as a header).
|
|
18
|
+
Default: any site.
|
|
19
|
+
--default-libraries <sources>
|
|
20
|
+
the libraries= value for addresses that name none, so a snippet without
|
|
21
|
+
it still plays an export with no libraries: pack, hub, URLs, as the
|
|
22
|
+
parameter. Default: none, such exports are refused unless the address asks
|
|
23
|
+
--no-libraries leave out the 9.5 MB library pack that libraries=pack names
|
|
24
|
+
--force write into a folder that is not empty, replacing only this tool's files
|
|
25
|
+
-h, --help show this
|
|
26
|
+
-v, --version print the version`
|
|
27
|
+
|
|
28
|
+
let args
|
|
29
|
+
try {
|
|
30
|
+
args = parseArgs({
|
|
31
|
+
allowPositionals: true,
|
|
32
|
+
options: {
|
|
33
|
+
packages: { type: 'string', multiple: true },
|
|
34
|
+
ancestors: { type: 'string', multiple: true },
|
|
35
|
+
'default-libraries': { type: 'string' },
|
|
36
|
+
'no-libraries': { type: 'boolean' },
|
|
37
|
+
force: { type: 'boolean' },
|
|
38
|
+
help: { type: 'boolean', short: 'h' },
|
|
39
|
+
version: { type: 'boolean', short: 'v' }
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error(`h5p-embed: ${error instanceof Error ? error.message : error}\n\n${USAGE}`)
|
|
44
|
+
process.exit(2)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { values, positionals } = args
|
|
48
|
+
if (values.help) {
|
|
49
|
+
console.log(USAGE)
|
|
50
|
+
process.exit(0)
|
|
51
|
+
}
|
|
52
|
+
if (values.version) {
|
|
53
|
+
console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version)
|
|
54
|
+
process.exit(0)
|
|
55
|
+
}
|
|
56
|
+
if (positionals.length > 1) {
|
|
57
|
+
console.error(`h5p-embed: one folder at most, got ${positionals.length}\n\n${USAGE}`)
|
|
58
|
+
process.exit(2)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const megabytes = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const site = await buildSite({
|
|
65
|
+
out: positionals[0] ?? 'h5p-player',
|
|
66
|
+
libraries: !values['no-libraries'],
|
|
67
|
+
packages: values.packages ?? null,
|
|
68
|
+
ancestors: values.ancestors ?? null,
|
|
69
|
+
defaultLibraries: values['default-libraries'] ?? null,
|
|
70
|
+
force: values.force ?? false
|
|
71
|
+
})
|
|
72
|
+
const below = relative(process.cwd(), site.out)
|
|
73
|
+
const folder = below === '' ? '.' : below.startsWith('..') ? site.out : below
|
|
74
|
+
const parts = [`the player ${site.version}`, 'the H5P runtime', site.libraries ? 'the library pack' : null].filter(Boolean)
|
|
75
|
+
console.log(`Wrote ${folder}/ (${megabytes(site.size)}): the embed page, ${parts.join(', ')}.`)
|
|
76
|
+
console.log(`Packages from: ${site.packages ? `this site, ${site.packages.join(', ')}` : 'any https host'}.`)
|
|
77
|
+
console.log(`Framed by: ${site.ancestors ? site.ancestors.join(', ') : 'any site'}.`)
|
|
78
|
+
console.log(`Libraries for exports without them: ${site.defaultLibraries ?? 'only when the address asks'}.`)
|
|
79
|
+
console.log(`
|
|
80
|
+
Deploy the folder to a domain that holds nothing else — a separate registrable domain, not a
|
|
81
|
+
subdomain of your site — then embed a package:
|
|
82
|
+
|
|
83
|
+
<iframe src="https://<player domain>/?src=<package url>" allow="fullscreen"
|
|
84
|
+
style="width: 100%; border: 0"></iframe>
|
|
85
|
+
<script src="https://<player domain>/resizer.js"></script>
|
|
86
|
+
|
|
87
|
+
_headers (Netlify, Cloudflare Pages) and vercel.json (Vercel) carry the policy and the caching.
|
|
88
|
+
Other hosts, GitHub Pages among them, get the policy from the page itself${site.ancestors ? ', without --ancestors,\nwhich only a header can carry' : ''}.`)
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof EmbedError) {
|
|
91
|
+
console.error(`h5p-embed: ${error.message}`)
|
|
92
|
+
process.exit(1)
|
|
93
|
+
}
|
|
94
|
+
throw error
|
|
95
|
+
}
|
package/lib/build.mjs
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, join, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Writes the embed page as a static site: one folder, deployable to any static host, that a site
|
|
8
|
+
* frames from a player domain of its own. Everything comes from installed packages — the element
|
|
9
|
+
* and its workers from the player's `dist/`, the H5P runtime from its own package as
|
|
10
|
+
* `frame-assets/`, the library pack from `@missing-elements/h5p-libraries` — so the versions are
|
|
11
|
+
* the ones this package was published with, and nothing is fetched.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export class EmbedError extends Error {}
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url)
|
|
17
|
+
// `fileURLToPath` rather than `import.meta.dirname`, which Node 20 has only from 20.11.
|
|
18
|
+
const SITE = fileURLToPath(new URL('../site', import.meta.url))
|
|
19
|
+
|
|
20
|
+
/** The page's own files, copied as they are; `index.html` is written, not copied. */
|
|
21
|
+
const PAGE_FILES = ['embed.js', 'embed.css', 'main.js', 'resizer.js']
|
|
22
|
+
/** The player's files, by the names the element looks for beside itself. */
|
|
23
|
+
const PLAYER_FILES = ['h5p-player.js', 'h5p-sw.js', 'h5p-jobs.js']
|
|
24
|
+
|
|
25
|
+
/** Where an installed package's file is, or a message that says which one is missing. */
|
|
26
|
+
function installed(specifier, hint) {
|
|
27
|
+
try {
|
|
28
|
+
return require.resolve(specifier)
|
|
29
|
+
} catch {
|
|
30
|
+
throw new EmbedError(`${specifier} is not installed or not built. ${hint}`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An origin as the CSP and the page compare it: `https://host[:port]`, or plain http on a loopback
|
|
36
|
+
* address for trying it locally. Anything with a path, a query or a trailing slash is refused
|
|
37
|
+
* rather than trimmed, so a typo is not silently a different policy.
|
|
38
|
+
*/
|
|
39
|
+
export function parseOrigin(value) {
|
|
40
|
+
let url
|
|
41
|
+
try {
|
|
42
|
+
url = new URL(value)
|
|
43
|
+
} catch {
|
|
44
|
+
throw new EmbedError(`Not an origin: ${value}. Write it as https://host.example`)
|
|
45
|
+
}
|
|
46
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
|
|
47
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
|
48
|
+
throw new EmbedError(`Not an https origin: ${value}`)
|
|
49
|
+
}
|
|
50
|
+
if (url.origin !== value) throw new EmbedError(`Not an origin: ${value}. Write it as ${url.origin}, with no path or trailing slash`)
|
|
51
|
+
return url.origin
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A `libraries` value for addresses that name none, checked against the site it goes into: the
|
|
56
|
+
* tokens the page understands (`pack`, `hub`, `none`, https URLs), and with a host list, nothing
|
|
57
|
+
* the page would then refuse on every load. Returns the value normalised to single spaces.
|
|
58
|
+
*
|
|
59
|
+
* @param {string | null | undefined} value
|
|
60
|
+
* @param {object} [site]
|
|
61
|
+
* @param {string[] | null} [site.packages] the site's host list, as `parseOrigins` returns it
|
|
62
|
+
* @param {boolean} [site.libraries] whether the site carries the pack
|
|
63
|
+
*/
|
|
64
|
+
export function parseDefaultLibraries(value, { packages = null, libraries = true } = {}) {
|
|
65
|
+
if (value == null) return null
|
|
66
|
+
const tokens = String(value).split(/\s+/).filter(Boolean)
|
|
67
|
+
if (tokens.length === 0) return null
|
|
68
|
+
if (tokens.includes('none')) {
|
|
69
|
+
if (tokens.length > 1) throw new EmbedError('--default-libraries none stands alone.')
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
// An exact match on a parsed origin, not a substring of a URL.
|
|
73
|
+
const hubAllowed = !packages || packages.some((origin) => origin === 'https://api.h5p.org')
|
|
74
|
+
for (const token of tokens) {
|
|
75
|
+
if (token === 'hub') {
|
|
76
|
+
if (!hubAllowed) throw new EmbedError('--default-libraries names hub, but --packages does not list https://api.h5p.org.')
|
|
77
|
+
} else if (token === 'pack') {
|
|
78
|
+
if (!libraries && !hubAllowed) throw new EmbedError('--default-libraries names pack, but the site has no pack (--no-libraries) and no hub to fall back to.')
|
|
79
|
+
} else {
|
|
80
|
+
let url
|
|
81
|
+
try {
|
|
82
|
+
url = new URL(token)
|
|
83
|
+
} catch {
|
|
84
|
+
throw new EmbedError(`--default-libraries: not pack, hub, none or a URL: ${token}`)
|
|
85
|
+
}
|
|
86
|
+
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
|
|
87
|
+
throw new EmbedError(`--default-libraries: not an https URL: ${token}`)
|
|
88
|
+
}
|
|
89
|
+
if (packages && !packages.includes(url.origin)) throw new EmbedError(`--default-libraries names ${url.origin}, which --packages does not list.`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return tokens.join(' ')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Origins given as a list, or as one string separated by commas or whitespace. */
|
|
96
|
+
export function parseOrigins(values) {
|
|
97
|
+
if (values == null) return null
|
|
98
|
+
const list = (Array.isArray(values) ? values : [values]).flatMap((value) => String(value).split(/[\s,]+/)).filter(Boolean)
|
|
99
|
+
return [...new Set(list.map(parseOrigin))]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The page's policy. `connect-src` is where the element and its workers may fetch packages and
|
|
104
|
+
* library bundles from: any https host by default, which is what a player for links needs, or
|
|
105
|
+
* exactly the hosts given. `frame-ancestors` goes only into a header — a `<meta>` policy ignores
|
|
106
|
+
* it — and names who may frame the page; without it any site may.
|
|
107
|
+
*/
|
|
108
|
+
/**
|
|
109
|
+
* @param {object} [options]
|
|
110
|
+
* @param {string[] | null} [options.packages]
|
|
111
|
+
* @param {string[] | null} [options.ancestors]
|
|
112
|
+
* @param {boolean} [options.meta] for the page's `<meta>`, which cannot carry `frame-ancestors`
|
|
113
|
+
*/
|
|
114
|
+
export function contentSecurityPolicy({ packages = null, ancestors = null, meta = false } = {}) {
|
|
115
|
+
const directives = [
|
|
116
|
+
"default-src 'self'",
|
|
117
|
+
"script-src 'self'",
|
|
118
|
+
"style-src 'self'",
|
|
119
|
+
"img-src 'self' data: blob:",
|
|
120
|
+
"font-src 'self'",
|
|
121
|
+
`connect-src ${["'self'", ...(packages ?? ['https:'])].join(' ')}`,
|
|
122
|
+
"worker-src 'self' blob:",
|
|
123
|
+
"frame-src 'self'",
|
|
124
|
+
"object-src 'none'",
|
|
125
|
+
"base-uri 'self'",
|
|
126
|
+
"form-action 'self'"
|
|
127
|
+
]
|
|
128
|
+
if (ancestors && !meta) directives.push(`frame-ancestors ${ancestors.join(' ')}`)
|
|
129
|
+
return directives.join('; ')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Header rules, one set for every host format: the policy everywhere, and the caching that matters. */
|
|
133
|
+
function headerRules(csp) {
|
|
134
|
+
const revalidate = 'public, max-age=3600, stale-while-revalidate=86400'
|
|
135
|
+
return [
|
|
136
|
+
{
|
|
137
|
+
path: '/*',
|
|
138
|
+
headers: {
|
|
139
|
+
'Content-Security-Policy': csp,
|
|
140
|
+
'X-Content-Type-Options': 'nosniff',
|
|
141
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin'
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
// The worker is checked on every load, so an update reaches learners on their next visit.
|
|
145
|
+
{ path: '/h5p-sw.js', headers: { 'Cache-Control': 'no-cache' } },
|
|
146
|
+
{ path: '/frame-assets/*', headers: { 'Cache-Control': revalidate } },
|
|
147
|
+
{ path: '/resizer.js', headers: { 'Cache-Control': revalidate } },
|
|
148
|
+
{ path: '/libraries.h5p', headers: { 'Cache-Control': revalidate } }
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Netlify's and Cloudflare Pages' `_headers`. */
|
|
153
|
+
function netlifyHeaders(rules) {
|
|
154
|
+
return rules.map(({ path, headers }) => `${path}\n${Object.entries(headers).map(([key, value]) => ` ${key}: ${value}`).join('\n')}`).join('\n') + '\n'
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Vercel's `vercel.json`, for deploying the folder as a project of its own. */
|
|
158
|
+
function vercelConfig(rules) {
|
|
159
|
+
const source = (path) => (path === '/*' ? '/(.*)' : path.replace(/\*$/, '(.*)'))
|
|
160
|
+
return JSON.stringify(
|
|
161
|
+
{ headers: rules.map(({ path, headers }) => ({ source: source(path), headers: Object.entries(headers).map(([key, value]) => ({ key, value })) })) },
|
|
162
|
+
null,
|
|
163
|
+
2
|
|
164
|
+
) + '\n'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function notice({ version, libraries }) {
|
|
168
|
+
return `This folder is the H5P embed page, written by @missing-elements/h5p-embed.
|
|
169
|
+
|
|
170
|
+
index.html, main.js, embed.js, embed.css, resizer.js, config.js
|
|
171
|
+
the embed page and the sizing script: MIT
|
|
172
|
+
h5p-player.js, h5p-sw.js, h5p-jobs.js
|
|
173
|
+
@missing-elements/h5p-offline-player ${version}: MIT. The two workers also
|
|
174
|
+
carry zip.js, BSD-3-Clause, its licence in each file's header.
|
|
175
|
+
frame-assets/ @missing-elements/h5p-runtime, the H5P core runtime: GPL-3.0-only. Its
|
|
176
|
+
LICENSE.txt and NOTICE.txt say what it is and where its source is; keep them
|
|
177
|
+
with it.
|
|
178
|
+
${libraries ? ` libraries.h5p @missing-elements/h5p-libraries, the H5P hub's libraries, each under its
|
|
179
|
+
own licence, listed in libraries.txt.
|
|
180
|
+
` : ''}
|
|
181
|
+
Serve it from a domain that holds nothing else, and frame it:
|
|
182
|
+
|
|
183
|
+
<iframe src="https://<this domain>/?src=<package url>" allow="fullscreen"
|
|
184
|
+
style="width: 100%; border: 0"></iframe>
|
|
185
|
+
<script src="https://<this domain>/resizer.js"></script>
|
|
186
|
+
|
|
187
|
+
https://github.com/missing-elements/h5p-offline-player/tree/main/packages/embed
|
|
188
|
+
`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Whether a directory exists and has anything in it. A path that is a file is an error. */
|
|
192
|
+
async function occupied(dir) {
|
|
193
|
+
let info
|
|
194
|
+
try {
|
|
195
|
+
info = await stat(dir)
|
|
196
|
+
} catch {
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
if (!info.isDirectory()) throw new EmbedError(`${dir} is a file, not a folder.`)
|
|
200
|
+
return (await readdir(dir)).length > 0
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Writes the site into `out`. Refuses a folder that already has files in it unless `force`, and
|
|
205
|
+
* even then only replaces the files it writes, so pointing it at the wrong folder costs nothing
|
|
206
|
+
* that was not its own.
|
|
207
|
+
*
|
|
208
|
+
* @param {object} options
|
|
209
|
+
* @param {string} options.out
|
|
210
|
+
* @param {boolean} [options.libraries] include the library pack, for `libraries=pack` (default true)
|
|
211
|
+
* @param {string[] | string | null} [options.packages] the only origins packages may come from
|
|
212
|
+
* @param {string[] | string | null} [options.ancestors] the only origins that may frame the page
|
|
213
|
+
* @param {string | null} [options.defaultLibraries] the `libraries` value for addresses that name none
|
|
214
|
+
* @param {boolean} [options.force]
|
|
215
|
+
*/
|
|
216
|
+
export async function buildSite({ out, libraries = true, packages = null, ancestors = null, defaultLibraries = null, force = false }) {
|
|
217
|
+
if (!out) throw new EmbedError('No output folder given.')
|
|
218
|
+
const target = resolve(out)
|
|
219
|
+
const allowedPackages = parseOrigins(packages)
|
|
220
|
+
const allowedAncestors = parseOrigins(ancestors)
|
|
221
|
+
if (allowedPackages?.length === 0) throw new EmbedError('--packages names no origin.')
|
|
222
|
+
if (allowedAncestors?.length === 0) throw new EmbedError('--ancestors names no origin.')
|
|
223
|
+
const fallbackLibraries = parseDefaultLibraries(defaultLibraries, { packages: allowedPackages, libraries })
|
|
224
|
+
// Checked with --force too: a path that is a file is refused either way.
|
|
225
|
+
if ((await occupied(target)) && !force) {
|
|
226
|
+
throw new EmbedError(`${out} is not empty. Choose an empty folder, or pass --force to replace the files this writes.`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const player = dirname(installed('@missing-elements/h5p-offline-player/dist/h5p-player.js', 'Run `pnpm build` in the workspace, or reinstall this package.'))
|
|
230
|
+
const runtime = dirname(installed('@missing-elements/h5p-runtime/dist/h5p.css', 'Run `pnpm build` in the workspace, or reinstall this package.'))
|
|
231
|
+
const pack = libraries ? installed('@missing-elements/h5p-libraries/libraries.h5p', 'Reinstall this package, or pass --no-libraries.') : null
|
|
232
|
+
const version = (await readFile(join(player, 'VERSION'), 'utf8').catch(() => 'unknown')).trim()
|
|
233
|
+
|
|
234
|
+
await mkdir(target, { recursive: true })
|
|
235
|
+
for (const name of PAGE_FILES) await cp(join(SITE, name), join(target, name))
|
|
236
|
+
for (const name of PLAYER_FILES) await cp(join(player, name), join(target, name))
|
|
237
|
+
await rm(join(target, 'frame-assets'), { recursive: true, force: true })
|
|
238
|
+
await cp(runtime, join(target, 'frame-assets'), { recursive: true })
|
|
239
|
+
if (pack) {
|
|
240
|
+
await cp(pack, join(target, 'libraries.h5p'))
|
|
241
|
+
await cp(join(dirname(pack), 'libraries.txt'), join(target, 'libraries.txt'))
|
|
242
|
+
} else {
|
|
243
|
+
await rm(join(target, 'libraries.h5p'), { force: true })
|
|
244
|
+
await rm(join(target, 'libraries.txt'), { force: true })
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const html = await readFile(join(SITE, 'index.html'), 'utf8')
|
|
248
|
+
await writeFile(join(target, 'index.html'), html.replace('%CSP%', contentSecurityPolicy({ packages: allowedPackages, meta: true })))
|
|
249
|
+
await writeFile(
|
|
250
|
+
join(target, 'config.js'),
|
|
251
|
+
`// Written by h5p-embed: what main.js hands the page.\nexport default ${JSON.stringify({ libraries: Boolean(pack), packages: allowedPackages, defaultLibraries: fallbackLibraries })}\n`
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
const csp = contentSecurityPolicy({ packages: allowedPackages, ancestors: allowedAncestors })
|
|
255
|
+
const rules = headerRules(csp)
|
|
256
|
+
await writeFile(join(target, '_headers'), netlifyHeaders(rules))
|
|
257
|
+
await writeFile(join(target, 'vercel.json'), vercelConfig(rules))
|
|
258
|
+
await writeFile(join(target, 'NOTICE.txt'), notice({ version, libraries: Boolean(pack) }))
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
out: target,
|
|
262
|
+
version,
|
|
263
|
+
csp,
|
|
264
|
+
libraries: Boolean(pack),
|
|
265
|
+
packages: allowedPackages,
|
|
266
|
+
ancestors: allowedAncestors,
|
|
267
|
+
defaultLibraries: fallbackLibraries,
|
|
268
|
+
size: await sizeOf(target)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The folder's size in bytes, for the summary. Paths from a recursive `readdir` rather than
|
|
274
|
+
* `Dirent.parentPath`, which Node 20 has only from 20.12.
|
|
275
|
+
*/
|
|
276
|
+
async function sizeOf(dir) {
|
|
277
|
+
let total = 0
|
|
278
|
+
for (const name of await readdir(dir, { recursive: true })) {
|
|
279
|
+
const info = await stat(join(dir, name))
|
|
280
|
+
if (info.isFile()) total += info.size
|
|
281
|
+
}
|
|
282
|
+
return total
|
|
283
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@missing-elements/h5p-embed",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.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. No H5P server, no third-party service; optional allowlists for package hosts and embedding sites.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/missing-elements/h5p-offline-player.git",
|
|
9
|
+
"directory": "packages/embed"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/missing-elements/h5p-offline-player/tree/main/packages/embed#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/missing-elements/h5p-offline-player/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"bin": {
|
|
20
|
+
"h5p-embed": "./bin/h5p-embed.mjs"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./lib/build.mjs",
|
|
24
|
+
"./embed.js": "./site/embed.js",
|
|
25
|
+
"./embed.css": "./site/embed.css",
|
|
26
|
+
"./resizer.js": "./site/resizer.js",
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin",
|
|
31
|
+
"lib",
|
|
32
|
+
"site"
|
|
33
|
+
],
|
|
34
|
+
"keywords": [
|
|
35
|
+
"h5p",
|
|
36
|
+
"h5p-embed",
|
|
37
|
+
"h5p-self-hosted",
|
|
38
|
+
"h5p-player",
|
|
39
|
+
"iframe",
|
|
40
|
+
"embed",
|
|
41
|
+
"static-site",
|
|
42
|
+
"elearning",
|
|
43
|
+
"cli"
|
|
44
|
+
],
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@missing-elements/h5p-libraries": "^0.1.0",
|
|
47
|
+
"@missing-elements/h5p-offline-player": "^0.5.1",
|
|
48
|
+
"@missing-elements/h5p-runtime": "^0.1.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^26.6.4",
|
|
52
|
+
"@zip.js/zip.js": "^2.23.0",
|
|
53
|
+
"playwright": "^1.50.0",
|
|
54
|
+
"typescript": "^7.0.2",
|
|
55
|
+
"vitest": "^5.0.3"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=20"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"test": "pnpm --filter @missing-elements/h5p-offline-player build && vitest run",
|
|
62
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
63
|
+
}
|
|
6
64
|
}
|
package/site/embed.css
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* The embeddable page: nothing of its own on screen but the element, so the embedding site's
|
|
2
|
+
layout is the layout. `flow-root` keeps the body's height honest for the size it reports. */
|
|
3
|
+
html,
|
|
4
|
+
body {
|
|
5
|
+
margin: 0;
|
|
6
|
+
padding: 0;
|
|
7
|
+
background: transparent;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
body {
|
|
11
|
+
display: flow-root;
|
|
12
|
+
font: 15px/1.5 system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
13
|
+
color: #15171c;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
h5p-player {
|
|
17
|
+
display: block;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.notice {
|
|
21
|
+
margin: 0;
|
|
22
|
+
padding: 0.9rem 1.1rem;
|
|
23
|
+
border: 1px solid #e2e6ec;
|
|
24
|
+
border-radius: 10px;
|
|
25
|
+
background: #f6f8fa;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.notice.error {
|
|
29
|
+
border-color: #f0c2c5;
|
|
30
|
+
background: #fdf2f3;
|
|
31
|
+
color: #8f1b22;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.notice a {
|
|
35
|
+
color: inherit;
|
|
36
|
+
font-weight: 600;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.notice button {
|
|
40
|
+
margin-left: 0.5rem;
|
|
41
|
+
padding: 0.35rem 0.8rem;
|
|
42
|
+
border: 1px solid currentColor;
|
|
43
|
+
border-radius: 6px;
|
|
44
|
+
background: transparent;
|
|
45
|
+
color: inherit;
|
|
46
|
+
font: inherit;
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* The element draws nothing until it is ready, and the first seconds go to registering the worker
|
|
51
|
+
and probing the package. The loader is in the HTML, so it shows before any script has run. */
|
|
52
|
+
.loader {
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: center;
|
|
56
|
+
gap: 0.7rem;
|
|
57
|
+
min-height: 160px;
|
|
58
|
+
color: #5b6472;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.loader[hidden] {
|
|
62
|
+
display: none;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.spinner {
|
|
66
|
+
width: 1.4rem;
|
|
67
|
+
height: 1.4rem;
|
|
68
|
+
border: 3px solid #d9dee6;
|
|
69
|
+
border-top-color: #4a5568;
|
|
70
|
+
border-radius: 50%;
|
|
71
|
+
animation: spin 0.8s linear infinite;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@keyframes spin {
|
|
75
|
+
to {
|
|
76
|
+
transform: rotate(360deg);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@media (prefers-reduced-motion: reduce) {
|
|
81
|
+
.spinner {
|
|
82
|
+
animation-duration: 2.4s;
|
|
83
|
+
}
|
|
84
|
+
}
|
package/site/embed.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/*! @missing-elements/h5p-embed. MIT. */
|
|
2
|
+
/**
|
|
3
|
+
* The embed page: the element alone, driven by the query string, for a site that puts a player
|
|
4
|
+
* domain of its own in an iframe. `startEmbed()` runs it; the page's `main.js` calls it with what
|
|
5
|
+
* `h5p-embed` was told when it wrote the site, and the demo's `/embed` with the demo's copy of the
|
|
6
|
+
* library pack.
|
|
7
|
+
*
|
|
8
|
+
* ?src=<package url>[&libraries=pack|hub|<url> …|none][&preload=auto][&xapi=<parent origin>]
|
|
9
|
+
* [&frame][©right][&export][&icon][&reporting][&fullscreen=off]
|
|
10
|
+
* [&activity-id=<IRI>][&custom-css=<stylesheet url>]
|
|
11
|
+
*
|
|
12
|
+
* Upward it speaks H5P's own resizer protocol — the `hello` / `resize` exchange that h5p.org's
|
|
13
|
+
* embed code and its `h5p-resizer.js` use — so a page that already resizes h5p.org iframes
|
|
14
|
+
* resizes this one without a change, and any other page gets `resizer.js` from this origin. xAPI
|
|
15
|
+
* statements are relayed to the parent only when `xapi=` names the parent's origin, and they are
|
|
16
|
+
* posted to that origin only. Once the content is up the page posts one `report` upward, and a
|
|
17
|
+
* load that fails posts its `error` (see `report` below): what an embedding page that checks a
|
|
18
|
+
* package — Embed My's preview — shows.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Where `libraries=hub` fetches from: the one hub host that sends CORS headers (see the player). */
|
|
22
|
+
const HUB_ORIGIN = 'https://api.h5p.org'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} [options]
|
|
26
|
+
* @param {string | null} [options.librariesPack] the URL of a copy of `@missing-elements/h5p-libraries`
|
|
27
|
+
* on this site, which `libraries=pack` names; without one, `pack` means the hub where allowed
|
|
28
|
+
* @param {string[] | null} [options.packages] the origins packages and library bundles may come
|
|
29
|
+
* from, besides this page's own; `null` plays any, asking first when the storage is this origin's
|
|
30
|
+
* @param {string | null} [options.defaultLibraries] the `libraries` value for an address that has
|
|
31
|
+
* none — `pack`, `hub`, URLs, as the parameter — so a snippet without `&libraries=` still plays
|
|
32
|
+
* an export that carries no libraries; `&libraries=none` turns it off for one address
|
|
33
|
+
* @param {object | null} [options.runtime] the `runtime` export of `@missing-elements/h5p-runtime`,
|
|
34
|
+
* for a page that bundles the element; without it the element looks for `frame-assets/` beside itself
|
|
35
|
+
* @param {boolean} [options.askInOwnFrame] whether a package from another origin waits for a click
|
|
36
|
+
* when this page is framed by a page of its own origin, whose storage it shares. A site whose
|
|
37
|
+
* own preview frames the page for a package the visitor just chose passes `false`
|
|
38
|
+
*/
|
|
39
|
+
export function startEmbed({ librariesPack = null, packages = null, defaultLibraries = null, runtime = null, askInOwnFrame = true } = {}) {
|
|
40
|
+
const params = new URLSearchParams(location.search)
|
|
41
|
+
const player = document.querySelector('h5p-player')
|
|
42
|
+
// Before `src`: the element resolves the runtime's files when a package is set.
|
|
43
|
+
if (runtime) player.runtime = runtime
|
|
44
|
+
const notice = document.querySelector('#notice')
|
|
45
|
+
const loader = document.querySelector('#loader')
|
|
46
|
+
const framed = window.parent !== window
|
|
47
|
+
|
|
48
|
+
/* ---------------------------------------------------------------- notices */
|
|
49
|
+
|
|
50
|
+
const say = (text, kind = '', link = null) => {
|
|
51
|
+
notice.replaceChildren()
|
|
52
|
+
if (text) {
|
|
53
|
+
notice.append(text)
|
|
54
|
+
if (link) {
|
|
55
|
+
const anchor = document.createElement('a')
|
|
56
|
+
anchor.href = link.href
|
|
57
|
+
anchor.target = '_top'
|
|
58
|
+
anchor.rel = 'noopener'
|
|
59
|
+
anchor.textContent = link.text
|
|
60
|
+
notice.append(' ', anchor, '.')
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
notice.className = `notice ${kind}`.trim()
|
|
64
|
+
notice.hidden = !text
|
|
65
|
+
requestAnimationFrame(announce)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const refuse = (text, code = 'refused') => {
|
|
69
|
+
loader.hidden = true
|
|
70
|
+
say(text, 'error')
|
|
71
|
+
post({ context: 'h5p-offline-player', action: 'error', code, message: text })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ---------------------------------------------------------------- sizing, upward */
|
|
75
|
+
|
|
76
|
+
/** The parent hears about the height in the shape h5p-resizer.js expects. Nothing in it is secret. */
|
|
77
|
+
const post = (message, target = '*') => {
|
|
78
|
+
if (framed) window.parent.postMessage(message, target)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The body's own height rather than the document's scrollHeight: the latter can never report
|
|
82
|
+
// less than the frame, so a shrink would never be seen.
|
|
83
|
+
const contentHeight = () => Math.ceil(document.body.getBoundingClientRect().height)
|
|
84
|
+
|
|
85
|
+
const announce = () => post({ context: 'h5p', action: 'resize', scrollHeight: contentHeight() })
|
|
86
|
+
|
|
87
|
+
window.addEventListener('message', (event) => {
|
|
88
|
+
if (event.source !== window.parent || !event.data || event.data.context !== 'h5p') return
|
|
89
|
+
switch (event.data.action) {
|
|
90
|
+
case 'ready':
|
|
91
|
+
// h5p-resizer.js announces itself once it is on the page; it expects a `hello` back.
|
|
92
|
+
post({ context: 'h5p', action: 'hello' })
|
|
93
|
+
break
|
|
94
|
+
case 'hello':
|
|
95
|
+
announce()
|
|
96
|
+
break
|
|
97
|
+
case 'resizePrepared':
|
|
98
|
+
announce()
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
post({ context: 'h5p', action: 'hello' })
|
|
104
|
+
|
|
105
|
+
// The element dispatches `resize` before it applies the height to itself; measure after layout.
|
|
106
|
+
player.addEventListener('resize', () => requestAnimationFrame(announce))
|
|
107
|
+
player.addEventListener('ready', () => requestAnimationFrame(announce))
|
|
108
|
+
|
|
109
|
+
/* ---------------------------------------------------------------- the report, upward */
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What the player learnt about the package, for the embedding page to show: whether the host
|
|
113
|
+
* streamed it or made the browser download it whole (`source.type`), how big it is, what it
|
|
114
|
+
* says it is (`metadata`), where libraries it did not carry came from (`libraryBundle`, `null`
|
|
115
|
+
* when it carried its own), and how long it took here. Posted once, when the content is up:
|
|
116
|
+
*
|
|
117
|
+
* { context: 'h5p-offline-player', action: 'report', source, metadata, libraryBundle, elapsedMs }
|
|
118
|
+
*
|
|
119
|
+
* and to any parent, like the heights: the parent named the package, and the manifest's strings
|
|
120
|
+
* are the package's own to tell. A parent treats them as text. A load that fails before the
|
|
121
|
+
* content is up posts `{ context: 'h5p-offline-player', action: 'error', code, message }`
|
|
122
|
+
* instead, a refusal by this page included (`code: 'refused'`). The shapes are Embed My's,
|
|
123
|
+
* whose preview is built from them.
|
|
124
|
+
*/
|
|
125
|
+
let startedAt = 0
|
|
126
|
+
|
|
127
|
+
player.addEventListener('ready', (event) => {
|
|
128
|
+
const { source, metadata, libraryBundle } = event.detail
|
|
129
|
+
post({
|
|
130
|
+
context: 'h5p-offline-player',
|
|
131
|
+
action: 'report',
|
|
132
|
+
source: source && { type: source.type, size: source.size },
|
|
133
|
+
metadata: metadata && {
|
|
134
|
+
title: metadata.title,
|
|
135
|
+
license: metadata.license,
|
|
136
|
+
licenseVersion: metadata.licenseVersion,
|
|
137
|
+
authors: metadata.authors?.map(({ name }) => name),
|
|
138
|
+
mainLibrary: metadata.mainLibrary
|
|
139
|
+
},
|
|
140
|
+
libraryBundle,
|
|
141
|
+
elapsedMs: Math.round(performance.now() - startedAt)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
/* ---------------------------------------------------------------- xAPI, relayed on request */
|
|
146
|
+
|
|
147
|
+
/** An origin, or nothing: the parameter has to be exactly what `event.origin` will read. */
|
|
148
|
+
const originOf = (value) => {
|
|
149
|
+
if (!value) return null
|
|
150
|
+
try {
|
|
151
|
+
const origin = new URL(value).origin
|
|
152
|
+
return origin !== 'null' && origin === value.replace(/\/$/, '') ? origin : null
|
|
153
|
+
} catch {
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const relayTo = originOf(params.get('xapi'))
|
|
159
|
+
if (relayTo && framed) {
|
|
160
|
+
for (const type of ['xapi', 'finished']) {
|
|
161
|
+
player.addEventListener(type, (event) => {
|
|
162
|
+
post({ context: 'h5p-offline-player', action: type, ...event.detail }, relayTo)
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ---------------------------------------------------------------- errors, and Safari */
|
|
168
|
+
|
|
169
|
+
player.addEventListener('error', (event) => {
|
|
170
|
+
const { code, message } = event.detail
|
|
171
|
+
if (code === 'no-worker' && framed) {
|
|
172
|
+
// Detected, not sniffed: a browser, an in-app one or a page that is not https may give a
|
|
173
|
+
// frame no Service Worker, and the player cannot run without one. Safari does allow it.
|
|
174
|
+
say("This browser does not run the player inside another site's page.", 'error', {
|
|
175
|
+
href: location.href,
|
|
176
|
+
text: 'Open it on its own'
|
|
177
|
+
})
|
|
178
|
+
post({ context: 'h5p-offline-player', action: 'error', code, message })
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
// Once the content is up, a runtime error inside it is the content's business: it keeps
|
|
182
|
+
// running, and a red notice over a working video would say otherwise.
|
|
183
|
+
if (code === 'runtime' && player.state === 'ready') {
|
|
184
|
+
console.warn(`h5p-player: the content reported an error and kept running: ${message}`)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
say(message || code, 'error')
|
|
188
|
+
post({ context: 'h5p-offline-player', action: 'error', code, message })
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
player.addEventListener('statechange', (event) => {
|
|
192
|
+
const { state } = event.detail
|
|
193
|
+
if (state !== 'error') say('')
|
|
194
|
+
// Shown from the HTML on, until the content is up or the load has failed.
|
|
195
|
+
loader.hidden = state === 'ready' || state === 'error' || state === 'idle'
|
|
196
|
+
requestAnimationFrame(announce)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
/* ---------------------------------------------------------------- which hosts */
|
|
200
|
+
|
|
201
|
+
/** The origin of a URL as this page resolves it, or `null` for what is not one. */
|
|
202
|
+
const urlOrigin = (value) => {
|
|
203
|
+
try {
|
|
204
|
+
const url = new URL(value, location.href)
|
|
205
|
+
// A `data:` or `blob:` URL has an opaque origin: name its scheme, so it is never "ours".
|
|
206
|
+
return url.origin === 'null' ? url.protocol : url.origin
|
|
207
|
+
} catch {
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const allowed = packages ? new Set(packages) : null
|
|
213
|
+
/** Whether this player was told it may fetch from `origin`. Always true without a list. */
|
|
214
|
+
const permitted = (origin) => origin === location.origin || !allowed || allowed.has(origin)
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The `libraries` value with `pack` resolved to this site's copy, or an error to show. With a
|
|
218
|
+
* list of hosts, every bundle's origin has to be on it, the hub's included; the CSP that
|
|
219
|
+
* `h5p-embed` wrote says the same, this only says it in words.
|
|
220
|
+
*/
|
|
221
|
+
const librarySources = (value) => {
|
|
222
|
+
const sources = []
|
|
223
|
+
if (value === 'none') return { value: '' }
|
|
224
|
+
for (const token of value.split(/\s+/).filter(Boolean)) {
|
|
225
|
+
if (token === 'pack') {
|
|
226
|
+
// The pack, with the hub behind it for what it lacks, where the hub may be reached: an
|
|
227
|
+
// export without its libraries then plays with no request to h5p.org in the common case.
|
|
228
|
+
// A site set up without the pack falls back to the hub alone, so a snippet written for
|
|
229
|
+
// `pack` keeps playing after a rebuild with --no-libraries.
|
|
230
|
+
if (librariesPack) sources.push(librariesPack)
|
|
231
|
+
if (permitted(HUB_ORIGIN)) sources.push('hub')
|
|
232
|
+
else if (!librariesPack) return { error: 'This player was set up without the library pack, so libraries=pack is not available here.' }
|
|
233
|
+
} else if (token === 'hub') {
|
|
234
|
+
if (!permitted(HUB_ORIGIN)) return { error: 'This player does not fetch libraries from the H5P hub.' }
|
|
235
|
+
sources.push(token)
|
|
236
|
+
} else {
|
|
237
|
+
const origin = urlOrigin(token)
|
|
238
|
+
if (!origin || !permitted(origin)) return { error: `This player does not fetch libraries from ${origin ?? token}.` }
|
|
239
|
+
sources.push(token)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { value: [...new Set(sources)].join(' ') }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The element's display options, by their attribute names, for the embedding page to ask for:
|
|
247
|
+
* `&frame©right&export` shows H5P's action bar with those buttons, `&fullscreen=off` takes
|
|
248
|
+
* that one away, `&activity-id=` names the statements' object and `&custom-css=` restyles the
|
|
249
|
+
* content to the embedding site's taste. Not `custom-js`, `embed-code` or `user`: a script is
|
|
250
|
+
* a capability on this origin that a link should not hand out, the embed is the embed, and a
|
|
251
|
+
* learner's name has no place in a URL.
|
|
252
|
+
*/
|
|
253
|
+
const applyOptions = () => {
|
|
254
|
+
for (const name of ['frame', 'copyright', 'export', 'icon', 'reporting']) {
|
|
255
|
+
if (params.has(name) && params.get(name) !== 'off') player.setAttribute(name, '')
|
|
256
|
+
}
|
|
257
|
+
if (params.get('fullscreen') === 'off') player.setAttribute('fullscreen', 'off')
|
|
258
|
+
for (const name of ['activity-id', 'custom-css']) {
|
|
259
|
+
const value = params.get(name)?.trim()
|
|
260
|
+
if (value) player.setAttribute(name, value)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const start = (value, libraries) => {
|
|
265
|
+
if (libraries) player.setAttribute('libraries', libraries)
|
|
266
|
+
if (params.get('preload') === 'auto') player.setAttribute('preload', 'auto')
|
|
267
|
+
applyOptions()
|
|
268
|
+
startedAt = performance.now()
|
|
269
|
+
player.setAttribute('src', value)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether this document's storage is this origin's own: top level, or framed by this origin. A
|
|
274
|
+
* package's scripts run with the storage of the origin it plays on. In another site's frame
|
|
275
|
+
* that storage is partitioned by the embedding site, so a page can only ever reach what was
|
|
276
|
+
* played under its own embed; opened on its own, a link to a package from elsewhere waits for
|
|
277
|
+
* a click.
|
|
278
|
+
*/
|
|
279
|
+
const sharesOriginStorage = () => {
|
|
280
|
+
if (!framed) return true
|
|
281
|
+
// Treated as another site's frame: the page around it chose this package, so no click.
|
|
282
|
+
if (!askInOwnFrame) return false
|
|
283
|
+
try {
|
|
284
|
+
return window.parent.location.origin === location.origin
|
|
285
|
+
} catch {
|
|
286
|
+
return false // Another origin's frame: reading its location throws, and the storage is partitioned.
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* ---------------------------------------------------------------- load */
|
|
291
|
+
|
|
292
|
+
const src = params.get('src')?.trim()
|
|
293
|
+
if (!src) {
|
|
294
|
+
refuse('No package given. Add ?src=<url of a .h5p file> to the address.', 'no-src')
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
const origin = urlOrigin(src)
|
|
298
|
+
if (!origin) {
|
|
299
|
+
refuse('The package address is not a URL.')
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
if (!permitted(origin)) {
|
|
303
|
+
refuse(`This player does not play packages from ${origin}.`)
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
// The address's own value wins, `none` included; an address without one gets the page's default.
|
|
307
|
+
const libraries = params.get('libraries')?.trim() || defaultLibraries?.trim()
|
|
308
|
+
const sources = libraries ? librarySources(libraries) : { value: '' }
|
|
309
|
+
if (sources.error) {
|
|
310
|
+
refuse(sources.error)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// A host on the list was vouched for when the site was set up; anything else from another
|
|
315
|
+
// origin waits for a click when it would share this origin's storage.
|
|
316
|
+
if (origin === location.origin || allowed || !sharesOriginStorage()) {
|
|
317
|
+
start(src, sources.value)
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
loader.hidden = true
|
|
321
|
+
const host = new URL(src, location.href).host || origin
|
|
322
|
+
const button = document.createElement('button')
|
|
323
|
+
button.type = 'button'
|
|
324
|
+
button.textContent = 'Open the package'
|
|
325
|
+
button.addEventListener('click', () => {
|
|
326
|
+
say('')
|
|
327
|
+
loader.hidden = false
|
|
328
|
+
start(src, sources.value)
|
|
329
|
+
})
|
|
330
|
+
say(
|
|
331
|
+
`This link opens a package from ${host}. A package runs its own scripts on this site, and they ` +
|
|
332
|
+
'can read what other packages saved in this browser. Open it only if you trust that site.'
|
|
333
|
+
)
|
|
334
|
+
notice.append(' ', button)
|
|
335
|
+
}
|
package/site/index.html
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta http-equiv="Content-Security-Policy" content="%CSP%" />
|
|
7
|
+
<meta name="robots" content="noindex" />
|
|
8
|
+
<title>H5P player</title>
|
|
9
|
+
<link rel="stylesheet" href="./embed.css" />
|
|
10
|
+
<script type="module" src="./h5p-player.js"></script>
|
|
11
|
+
<script type="module" src="./main.js"></script>
|
|
12
|
+
</head>
|
|
13
|
+
|
|
14
|
+
<body>
|
|
15
|
+
<div class="loader" id="loader" role="status">
|
|
16
|
+
<span class="spinner" aria-hidden="true"></span>
|
|
17
|
+
<span class="loader-text">Loading…</span>
|
|
18
|
+
</div>
|
|
19
|
+
<h5p-player auto-resize></h5p-player>
|
|
20
|
+
<p class="notice" id="notice" hidden></p>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
package/site/main.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*! @missing-elements/h5p-embed. MIT. */
|
|
2
|
+
// The page's entry: `config.js` is what `h5p-embed` was told when it wrote this site — whether
|
|
3
|
+
// the library pack is here, and which hosts packages may come from.
|
|
4
|
+
import config from './config.js'
|
|
5
|
+
import { startEmbed } from './embed.js'
|
|
6
|
+
|
|
7
|
+
startEmbed({
|
|
8
|
+
librariesPack: config.libraries ? new URL('./libraries.h5p', import.meta.url).href : null,
|
|
9
|
+
packages: config.packages,
|
|
10
|
+
defaultLibraries: config.defaultLibraries
|
|
11
|
+
})
|
package/site/resizer.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*! h5p-offline-player resizer. MIT. Sizes an <iframe> of the H5P embed page to its content. */
|
|
2
|
+
/**
|
|
3
|
+
* The page-side half of H5P's resizer protocol, for a page that frames the embed page
|
|
4
|
+
* (`@missing-elements/h5p-embed`). The frame can only report its height upward, by
|
|
5
|
+
* `postMessage`, and something on the page has to apply it:
|
|
6
|
+
* this script, in one line, or h5p.org's own `h5p-resizer.js`, which speaks the same protocol.
|
|
7
|
+
* This one is served from the player's origin, so an embedding page sends nothing to a third
|
|
8
|
+
* party and does not depend on a path on h5p.org.
|
|
9
|
+
*
|
|
10
|
+
* <script src="https://<player origin>/resizer.js"></script>
|
|
11
|
+
*
|
|
12
|
+
* It answers every frame on the page that speaks the protocol, which is also what h5p.org's
|
|
13
|
+
* script does: a frame can only ever ask for the height of itself. Classic script, no module,
|
|
14
|
+
* so it runs wherever an embed block runs.
|
|
15
|
+
*/
|
|
16
|
+
(function () {
|
|
17
|
+
if (window.__h5pResizer) return;
|
|
18
|
+
window.__h5pResizer = true;
|
|
19
|
+
|
|
20
|
+
var frameOf = function (source) {
|
|
21
|
+
var frames = document.getElementsByTagName('iframe');
|
|
22
|
+
for (var i = 0; i < frames.length; i++) {
|
|
23
|
+
if (frames[i].contentWindow === source) return frames[i];
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
window.addEventListener('message', function (event) {
|
|
29
|
+
var data = event.data;
|
|
30
|
+
if (!data || data.context !== 'h5p' || !event.source) return;
|
|
31
|
+
var frame = frameOf(event.source);
|
|
32
|
+
if (!frame) return;
|
|
33
|
+
switch (data.action) {
|
|
34
|
+
case 'hello':
|
|
35
|
+
// The frame asks whether anyone is listening; the reply makes it start reporting.
|
|
36
|
+
event.source.postMessage({ context: 'h5p', action: 'hello' }, event.origin);
|
|
37
|
+
break;
|
|
38
|
+
case 'prepareResize':
|
|
39
|
+
// The frame is about to measure itself; it wants the box no larger than its content.
|
|
40
|
+
if (typeof data.scrollHeight === 'number' && frame.clientHeight !== data.scrollHeight) {
|
|
41
|
+
frame.style.height = data.scrollHeight + 'px';
|
|
42
|
+
}
|
|
43
|
+
event.source.postMessage({ context: 'h5p', action: 'resizePrepared' }, event.origin);
|
|
44
|
+
break;
|
|
45
|
+
case 'resize':
|
|
46
|
+
if (typeof data.scrollHeight === 'number') frame.style.height = data.scrollHeight + 'px';
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// A frame that loaded before this script said its hello to nobody; tell every frame the
|
|
52
|
+
// listener is here, and the ones that speak the protocol say hello again.
|
|
53
|
+
var announce = function () {
|
|
54
|
+
var frames = document.getElementsByTagName('iframe');
|
|
55
|
+
for (var i = 0; i < frames.length; i++) {
|
|
56
|
+
try { frames[i].contentWindow.postMessage({ context: 'h5p', action: 'ready' }, '*'); } catch (e) { /* not ours */ }
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', announce);
|
|
60
|
+
else announce();
|
|
61
|
+
})();
|