@missing-elements/h5p-embed 0.0.0-stage → 0.1.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 +129 -2
- package/bin/h5p-embed.mjs +88 -0
- package/lib/build.mjs +231 -0
- package/package.json +63 -4
- package/site/embed.css +84 -0
- package/site/embed.js +279 -0
- package/site/index.html +22 -0
- package/site/main.js +10 -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,130 @@
|
|
|
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
|
+
| `--no-libraries` | Leave out the 9.5 MB library pack; `libraries=pack` then means the hub, where allowed |
|
|
42
|
+
| `--force` | Write into a folder that is not empty, replacing only this tool's files |
|
|
43
|
+
|
|
44
|
+
Origins are written `https://host.example`, comma or space separated, with no path or trailing
|
|
45
|
+
slash. `http://localhost` is accepted for trying it locally.
|
|
46
|
+
|
|
47
|
+
List every host a package URL passes through. The page checks the address it is given, but the
|
|
48
|
+
browser's policy also applies to each redirect, so a listed host that redirects to a CDN off the
|
|
49
|
+
list fails as an ordinary network error, with nothing naming the list as the cause.
|
|
50
|
+
|
|
51
|
+
A player domain for one organisation is best locked to its own hosts:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx @missing-elements/h5p-embed h5p-player \
|
|
55
|
+
--packages https://cdn.example.org \
|
|
56
|
+
--ancestors "https://www.example.org https://lms.example.org"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Hosting
|
|
60
|
+
|
|
61
|
+
| Host | Policy and caching |
|
|
62
|
+
|---|---|
|
|
63
|
+
| Netlify, Cloudflare Pages | `_headers`, written beside the page |
|
|
64
|
+
| Vercel | `vercel.json`, written beside the page; deploy the folder as a project |
|
|
65
|
+
| 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 |
|
|
66
|
+
|
|
67
|
+
The site works at the domain's root or under a path (`https://example.github.io/player/`): every
|
|
68
|
+
URL in it is relative, and the player's Service Worker takes the scope `<folder>/h5p/`. Serve it
|
|
69
|
+
over https; a frame in a page on plain http has no Service Worker.
|
|
70
|
+
|
|
71
|
+
`h5p-sw.js` should be served with `Cache-Control: no-cache`, as the header files say, so an update
|
|
72
|
+
reaches learners on their next visit. To update, run the command again with `--force` and
|
|
73
|
+
redeploy.
|
|
74
|
+
|
|
75
|
+
## The address
|
|
76
|
+
|
|
77
|
+
| Parameter | Effect |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `src=<url>` | The package, required. Encode `&`, `#`, `+`, `%` and spaces in it |
|
|
80
|
+
| `libraries=pack`, `hub` or `<url>` | Libraries for an export that has none (h5p.com and h5p.org exports usually do not). `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 |
|
|
81
|
+
| `frame`, `copyright`, `export`, `icon`, `reporting` | H5P's action bar under the content and its buttons |
|
|
82
|
+
| `fullscreen=off` | No fullscreen button |
|
|
83
|
+
| `preload=auto` | Start fetching media at once |
|
|
84
|
+
| `activity-id=<IRI>` | The object id every xAPI statement names, instead of the package URL |
|
|
85
|
+
| `custom-css=<url>` | A stylesheet of yours, loaded into the content |
|
|
86
|
+
| `xapi=<origin>` | Relay statements to the embedding page, see below |
|
|
87
|
+
|
|
88
|
+
Not available on the address: a custom script, and a learner's name. A script is a capability on
|
|
89
|
+
the player's origin that a link should not hand out, and a name has no place in a URL.
|
|
90
|
+
|
|
91
|
+
Opened on its own (not framed), the page asks before playing a package from another origin
|
|
92
|
+
unless `--packages` names it: there, every package shares the domain's storage.
|
|
93
|
+
|
|
94
|
+
## Results
|
|
95
|
+
|
|
96
|
+
Add `&xapi=<the embedding page's origin>` and the frame posts every statement to that origin
|
|
97
|
+
and no other:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
const frame = document.querySelector('iframe')
|
|
101
|
+
addEventListener('message', ({ source, origin, data }) => {
|
|
102
|
+
if (source !== frame.contentWindow || origin !== 'https://h5p-player.example.net') return
|
|
103
|
+
if (data?.context !== 'h5p-offline-player') return
|
|
104
|
+
if (data.action === 'xapi') send(data.statement) // every statement
|
|
105
|
+
if (data.action === 'finished') send(data.statement) // the final one, with result.score
|
|
106
|
+
})
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The checks prove where a message came from, not what it says: a package can post any statement,
|
|
110
|
+
so treat relayed results as the learner's report, not as proof for a grade. Each statement
|
|
111
|
+
carries `context.revision`, a fingerprint of the package build.
|
|
112
|
+
|
|
113
|
+
## As a library
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
import { buildSite } from '@missing-elements/h5p-embed'
|
|
117
|
+
|
|
118
|
+
await buildSite({ out: 'public/player', packages: ['https://cdn.example.org'] })
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`@missing-elements/h5p-embed/embed.js` exports `startEmbed({ librariesPack, packages })`, the
|
|
122
|
+
page's script, for a site that builds the page into its own pipeline.
|
|
123
|
+
|
|
124
|
+
## Licences
|
|
125
|
+
|
|
126
|
+
This package is MIT. The folder it writes also carries
|
|
127
|
+
[`@missing-elements/h5p-runtime`](https://www.npmjs.com/package/@missing-elements/h5p-runtime), the
|
|
128
|
+
H5P core runtime, under the GPL-3.0, as `frame-assets/` with its `LICENSE.txt` and `NOTICE.txt`;
|
|
129
|
+
and, unless `--no-libraries`, the H5P hub's libraries under their own licences, listed in
|
|
130
|
+
`libraries.txt`. `NOTICE.txt` in the folder says which file is what.
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
--no-libraries leave out the 9.5 MB library pack that libraries=pack names
|
|
20
|
+
--force write into a folder that is not empty, replacing only this tool's files
|
|
21
|
+
-h, --help show this
|
|
22
|
+
-v, --version print the version`
|
|
23
|
+
|
|
24
|
+
let args
|
|
25
|
+
try {
|
|
26
|
+
args = parseArgs({
|
|
27
|
+
allowPositionals: true,
|
|
28
|
+
options: {
|
|
29
|
+
packages: { type: 'string', multiple: true },
|
|
30
|
+
ancestors: { type: 'string', multiple: true },
|
|
31
|
+
'no-libraries': { type: 'boolean' },
|
|
32
|
+
force: { type: 'boolean' },
|
|
33
|
+
help: { type: 'boolean', short: 'h' },
|
|
34
|
+
version: { type: 'boolean', short: 'v' }
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error(`h5p-embed: ${error instanceof Error ? error.message : error}\n\n${USAGE}`)
|
|
39
|
+
process.exit(2)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { values, positionals } = args
|
|
43
|
+
if (values.help) {
|
|
44
|
+
console.log(USAGE)
|
|
45
|
+
process.exit(0)
|
|
46
|
+
}
|
|
47
|
+
if (values.version) {
|
|
48
|
+
console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version)
|
|
49
|
+
process.exit(0)
|
|
50
|
+
}
|
|
51
|
+
if (positionals.length > 1) {
|
|
52
|
+
console.error(`h5p-embed: one folder at most, got ${positionals.length}\n\n${USAGE}`)
|
|
53
|
+
process.exit(2)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const megabytes = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const site = await buildSite({
|
|
60
|
+
out: positionals[0] ?? 'h5p-player',
|
|
61
|
+
libraries: !values['no-libraries'],
|
|
62
|
+
packages: values.packages ?? null,
|
|
63
|
+
ancestors: values.ancestors ?? null,
|
|
64
|
+
force: values.force ?? false
|
|
65
|
+
})
|
|
66
|
+
const below = relative(process.cwd(), site.out)
|
|
67
|
+
const folder = below === '' ? '.' : below.startsWith('..') ? site.out : below
|
|
68
|
+
const parts = [`the player ${site.version}`, 'the H5P runtime', site.libraries ? 'the library pack' : null].filter(Boolean)
|
|
69
|
+
console.log(`Wrote ${folder}/ (${megabytes(site.size)}): the embed page, ${parts.join(', ')}.`)
|
|
70
|
+
console.log(`Packages from: ${site.packages ? `this site, ${site.packages.join(', ')}` : 'any https host'}.`)
|
|
71
|
+
console.log(`Framed by: ${site.ancestors ? site.ancestors.join(', ') : 'any site'}.`)
|
|
72
|
+
console.log(`
|
|
73
|
+
Deploy the folder to a domain that holds nothing else — a separate registrable domain, not a
|
|
74
|
+
subdomain of your site — then embed a package:
|
|
75
|
+
|
|
76
|
+
<iframe src="https://<player domain>/?src=<package url>" allow="fullscreen"
|
|
77
|
+
style="width: 100%; border: 0"></iframe>
|
|
78
|
+
<script src="https://<player domain>/resizer.js"></script>
|
|
79
|
+
|
|
80
|
+
_headers (Netlify, Cloudflare Pages) and vercel.json (Vercel) carry the policy and the caching.
|
|
81
|
+
Other hosts, GitHub Pages among them, get the policy from the page itself${site.ancestors ? ', without --ancestors,\nwhich only a header can carry' : ''}.`)
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error instanceof EmbedError) {
|
|
84
|
+
console.error(`h5p-embed: ${error.message}`)
|
|
85
|
+
process.exit(1)
|
|
86
|
+
}
|
|
87
|
+
throw error
|
|
88
|
+
}
|
package/lib/build.mjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
/** Origins given as a list, or as one string separated by commas or whitespace. */
|
|
55
|
+
export function parseOrigins(values) {
|
|
56
|
+
if (values == null) return null
|
|
57
|
+
const list = (Array.isArray(values) ? values : [values]).flatMap((value) => String(value).split(/[\s,]+/)).filter(Boolean)
|
|
58
|
+
return [...new Set(list.map(parseOrigin))]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The page's policy. `connect-src` is where the element and its workers may fetch packages and
|
|
63
|
+
* library bundles from: any https host by default, which is what a player for links needs, or
|
|
64
|
+
* exactly the hosts given. `frame-ancestors` goes only into a header — a `<meta>` policy ignores
|
|
65
|
+
* it — and names who may frame the page; without it any site may.
|
|
66
|
+
*/
|
|
67
|
+
/**
|
|
68
|
+
* @param {object} [options]
|
|
69
|
+
* @param {string[] | null} [options.packages]
|
|
70
|
+
* @param {string[] | null} [options.ancestors]
|
|
71
|
+
* @param {boolean} [options.meta] for the page's `<meta>`, which cannot carry `frame-ancestors`
|
|
72
|
+
*/
|
|
73
|
+
export function contentSecurityPolicy({ packages = null, ancestors = null, meta = false } = {}) {
|
|
74
|
+
const directives = [
|
|
75
|
+
"default-src 'self'",
|
|
76
|
+
"script-src 'self'",
|
|
77
|
+
"style-src 'self'",
|
|
78
|
+
"img-src 'self' data: blob:",
|
|
79
|
+
"font-src 'self'",
|
|
80
|
+
`connect-src ${["'self'", ...(packages ?? ['https:'])].join(' ')}`,
|
|
81
|
+
"worker-src 'self' blob:",
|
|
82
|
+
"frame-src 'self'",
|
|
83
|
+
"object-src 'none'",
|
|
84
|
+
"base-uri 'self'",
|
|
85
|
+
"form-action 'self'"
|
|
86
|
+
]
|
|
87
|
+
if (ancestors && !meta) directives.push(`frame-ancestors ${ancestors.join(' ')}`)
|
|
88
|
+
return directives.join('; ')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Header rules, one set for every host format: the policy everywhere, and the caching that matters. */
|
|
92
|
+
function headerRules(csp) {
|
|
93
|
+
const revalidate = 'public, max-age=3600, stale-while-revalidate=86400'
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
path: '/*',
|
|
97
|
+
headers: {
|
|
98
|
+
'Content-Security-Policy': csp,
|
|
99
|
+
'X-Content-Type-Options': 'nosniff',
|
|
100
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin'
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
// The worker is checked on every load, so an update reaches learners on their next visit.
|
|
104
|
+
{ path: '/h5p-sw.js', headers: { 'Cache-Control': 'no-cache' } },
|
|
105
|
+
{ path: '/frame-assets/*', headers: { 'Cache-Control': revalidate } },
|
|
106
|
+
{ path: '/resizer.js', headers: { 'Cache-Control': revalidate } },
|
|
107
|
+
{ path: '/libraries.h5p', headers: { 'Cache-Control': revalidate } }
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Netlify's and Cloudflare Pages' `_headers`. */
|
|
112
|
+
function netlifyHeaders(rules) {
|
|
113
|
+
return rules.map(({ path, headers }) => `${path}\n${Object.entries(headers).map(([key, value]) => ` ${key}: ${value}`).join('\n')}`).join('\n') + '\n'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Vercel's `vercel.json`, for deploying the folder as a project of its own. */
|
|
117
|
+
function vercelConfig(rules) {
|
|
118
|
+
const source = (path) => (path === '/*' ? '/(.*)' : path.replace(/\*$/, '(.*)'))
|
|
119
|
+
return JSON.stringify(
|
|
120
|
+
{ headers: rules.map(({ path, headers }) => ({ source: source(path), headers: Object.entries(headers).map(([key, value]) => ({ key, value })) })) },
|
|
121
|
+
null,
|
|
122
|
+
2
|
|
123
|
+
) + '\n'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function notice({ version, libraries }) {
|
|
127
|
+
return `This folder is the H5P embed page, written by @missing-elements/h5p-embed.
|
|
128
|
+
|
|
129
|
+
index.html, main.js, embed.js, embed.css, resizer.js, config.js
|
|
130
|
+
the embed page and the sizing script: MIT
|
|
131
|
+
h5p-player.js, h5p-sw.js, h5p-jobs.js
|
|
132
|
+
@missing-elements/h5p-offline-player ${version}: MIT. The two workers also
|
|
133
|
+
carry zip.js, BSD-3-Clause, its licence in each file's header.
|
|
134
|
+
frame-assets/ @missing-elements/h5p-runtime, the H5P core runtime: GPL-3.0-only. Its
|
|
135
|
+
LICENSE.txt and NOTICE.txt say what it is and where its source is; keep them
|
|
136
|
+
with it.
|
|
137
|
+
${libraries ? ` libraries.h5p @missing-elements/h5p-libraries, the H5P hub's libraries, each under its
|
|
138
|
+
own licence, listed in libraries.txt.
|
|
139
|
+
` : ''}
|
|
140
|
+
Serve it from a domain that holds nothing else, and frame it:
|
|
141
|
+
|
|
142
|
+
<iframe src="https://<this domain>/?src=<package url>" allow="fullscreen"
|
|
143
|
+
style="width: 100%; border: 0"></iframe>
|
|
144
|
+
<script src="https://<this domain>/resizer.js"></script>
|
|
145
|
+
|
|
146
|
+
https://github.com/missing-elements/h5p-offline-player/tree/main/packages/embed
|
|
147
|
+
`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Whether a directory exists and has anything in it. A path that is a file is an error. */
|
|
151
|
+
async function occupied(dir) {
|
|
152
|
+
let info
|
|
153
|
+
try {
|
|
154
|
+
info = await stat(dir)
|
|
155
|
+
} catch {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
if (!info.isDirectory()) throw new EmbedError(`${dir} is a file, not a folder.`)
|
|
159
|
+
return (await readdir(dir)).length > 0
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Writes the site into `out`. Refuses a folder that already has files in it unless `force`, and
|
|
164
|
+
* even then only replaces the files it writes, so pointing it at the wrong folder costs nothing
|
|
165
|
+
* that was not its own.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} options
|
|
168
|
+
* @param {string} options.out
|
|
169
|
+
* @param {boolean} [options.libraries] include the library pack, for `libraries=pack` (default true)
|
|
170
|
+
* @param {string[] | string | null} [options.packages] the only origins packages may come from
|
|
171
|
+
* @param {string[] | string | null} [options.ancestors] the only origins that may frame the page
|
|
172
|
+
* @param {boolean} [options.force]
|
|
173
|
+
*/
|
|
174
|
+
export async function buildSite({ out, libraries = true, packages = null, ancestors = null, force = false }) {
|
|
175
|
+
if (!out) throw new EmbedError('No output folder given.')
|
|
176
|
+
const target = resolve(out)
|
|
177
|
+
const allowedPackages = parseOrigins(packages)
|
|
178
|
+
const allowedAncestors = parseOrigins(ancestors)
|
|
179
|
+
if (allowedPackages?.length === 0) throw new EmbedError('--packages names no origin.')
|
|
180
|
+
if (allowedAncestors?.length === 0) throw new EmbedError('--ancestors names no origin.')
|
|
181
|
+
// Checked with --force too: a path that is a file is refused either way.
|
|
182
|
+
if ((await occupied(target)) && !force) {
|
|
183
|
+
throw new EmbedError(`${out} is not empty. Choose an empty folder, or pass --force to replace the files this writes.`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const player = dirname(installed('@missing-elements/h5p-offline-player/dist/h5p-player.js', 'Run `pnpm build` in the workspace, or reinstall this package.'))
|
|
187
|
+
const runtime = dirname(installed('@missing-elements/h5p-runtime/dist/h5p.css', 'Run `pnpm build` in the workspace, or reinstall this package.'))
|
|
188
|
+
const pack = libraries ? installed('@missing-elements/h5p-libraries/libraries.h5p', 'Reinstall this package, or pass --no-libraries.') : null
|
|
189
|
+
const version = (await readFile(join(player, 'VERSION'), 'utf8').catch(() => 'unknown')).trim()
|
|
190
|
+
|
|
191
|
+
await mkdir(target, { recursive: true })
|
|
192
|
+
for (const name of PAGE_FILES) await cp(join(SITE, name), join(target, name))
|
|
193
|
+
for (const name of PLAYER_FILES) await cp(join(player, name), join(target, name))
|
|
194
|
+
await rm(join(target, 'frame-assets'), { recursive: true, force: true })
|
|
195
|
+
await cp(runtime, join(target, 'frame-assets'), { recursive: true })
|
|
196
|
+
if (pack) {
|
|
197
|
+
await cp(pack, join(target, 'libraries.h5p'))
|
|
198
|
+
await cp(join(dirname(pack), 'libraries.txt'), join(target, 'libraries.txt'))
|
|
199
|
+
} else {
|
|
200
|
+
await rm(join(target, 'libraries.h5p'), { force: true })
|
|
201
|
+
await rm(join(target, 'libraries.txt'), { force: true })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const html = await readFile(join(SITE, 'index.html'), 'utf8')
|
|
205
|
+
await writeFile(join(target, 'index.html'), html.replace('%CSP%', contentSecurityPolicy({ packages: allowedPackages, meta: true })))
|
|
206
|
+
await writeFile(
|
|
207
|
+
join(target, 'config.js'),
|
|
208
|
+
`// Written by h5p-embed: what main.js hands the page.\nexport default ${JSON.stringify({ libraries: Boolean(pack), packages: allowedPackages })}\n`
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
const csp = contentSecurityPolicy({ packages: allowedPackages, ancestors: allowedAncestors })
|
|
212
|
+
const rules = headerRules(csp)
|
|
213
|
+
await writeFile(join(target, '_headers'), netlifyHeaders(rules))
|
|
214
|
+
await writeFile(join(target, 'vercel.json'), vercelConfig(rules))
|
|
215
|
+
await writeFile(join(target, 'NOTICE.txt'), notice({ version, libraries: Boolean(pack) }))
|
|
216
|
+
|
|
217
|
+
return { out: target, version, csp, libraries: Boolean(pack), packages: allowedPackages, ancestors: allowedAncestors, size: await sizeOf(target) }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The folder's size in bytes, for the summary. Paths from a recursive `readdir` rather than
|
|
222
|
+
* `Dirent.parentPath`, which Node 20 has only from 20.12.
|
|
223
|
+
*/
|
|
224
|
+
async function sizeOf(dir) {
|
|
225
|
+
let total = 0
|
|
226
|
+
for (const name of await readdir(dir, { recursive: true })) {
|
|
227
|
+
const info = await stat(join(dir, name))
|
|
228
|
+
if (info.isFile()) total += info.size
|
|
229
|
+
}
|
|
230
|
+
return total
|
|
231
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@missing-elements/h5p-embed",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Self-hosted H5P embeds: writes the H5P embed page as a static site for a player domain of your own, then any site frames it with an iframe and one script line. No H5P server, no third-party service; optional allowlists for package hosts and embedding sites.",
|
|
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
|
+
"scripts": {
|
|
46
|
+
"test": "pnpm --filter @missing-elements/h5p-offline-player build && vitest run",
|
|
47
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
48
|
+
"prepack": "node -e \"require('node:fs').copyFileSync('../../LICENSE', 'LICENSE')\"",
|
|
49
|
+
"prepublishOnly": "pnpm typecheck && pnpm test"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@missing-elements/h5p-libraries": "workspace:^",
|
|
53
|
+
"@missing-elements/h5p-offline-player": "workspace:^",
|
|
54
|
+
"@missing-elements/h5p-runtime": "workspace:^"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^26.6.4",
|
|
58
|
+
"playwright": "^1.50.0",
|
|
59
|
+
"typescript": "^7.0.2",
|
|
60
|
+
"vitest": "^5.0.3"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=20"
|
|
64
|
+
}
|
|
65
|
+
}
|
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,279 @@
|
|
|
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> …][&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.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Where `libraries=hub` fetches from: the one hub host that sends CORS headers (see the player). */
|
|
20
|
+
const HUB_ORIGIN = 'https://api.h5p.org'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} [options]
|
|
24
|
+
* @param {string | null} [options.librariesPack] the URL of a copy of `@missing-elements/h5p-libraries`
|
|
25
|
+
* on this site, which `libraries=pack` names; without one, `pack` is refused
|
|
26
|
+
* @param {string[] | null} [options.packages] the origins packages and library bundles may come
|
|
27
|
+
* from, besides this page's own; `null` plays any, asking first when the storage is this origin's
|
|
28
|
+
*/
|
|
29
|
+
export function startEmbed({ librariesPack = null, packages = null } = {}) {
|
|
30
|
+
const params = new URLSearchParams(location.search)
|
|
31
|
+
const player = document.querySelector('h5p-player')
|
|
32
|
+
const notice = document.querySelector('#notice')
|
|
33
|
+
const loader = document.querySelector('#loader')
|
|
34
|
+
const framed = window.parent !== window
|
|
35
|
+
|
|
36
|
+
/* ---------------------------------------------------------------- notices */
|
|
37
|
+
|
|
38
|
+
const say = (text, kind = '', link = null) => {
|
|
39
|
+
notice.replaceChildren()
|
|
40
|
+
if (text) {
|
|
41
|
+
notice.append(text)
|
|
42
|
+
if (link) {
|
|
43
|
+
const anchor = document.createElement('a')
|
|
44
|
+
anchor.href = link.href
|
|
45
|
+
anchor.target = '_top'
|
|
46
|
+
anchor.rel = 'noopener'
|
|
47
|
+
anchor.textContent = link.text
|
|
48
|
+
notice.append(' ', anchor, '.')
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
notice.className = `notice ${kind}`.trim()
|
|
52
|
+
notice.hidden = !text
|
|
53
|
+
requestAnimationFrame(announce)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const refuse = (text) => {
|
|
57
|
+
loader.hidden = true
|
|
58
|
+
say(text, 'error')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ---------------------------------------------------------------- sizing, upward */
|
|
62
|
+
|
|
63
|
+
/** The parent hears about the height in the shape h5p-resizer.js expects. Nothing in it is secret. */
|
|
64
|
+
const post = (message, target = '*') => {
|
|
65
|
+
if (framed) window.parent.postMessage(message, target)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The body's own height rather than the document's scrollHeight: the latter can never report
|
|
69
|
+
// less than the frame, so a shrink would never be seen.
|
|
70
|
+
const contentHeight = () => Math.ceil(document.body.getBoundingClientRect().height)
|
|
71
|
+
|
|
72
|
+
const announce = () => post({ context: 'h5p', action: 'resize', scrollHeight: contentHeight() })
|
|
73
|
+
|
|
74
|
+
window.addEventListener('message', (event) => {
|
|
75
|
+
if (event.source !== window.parent || !event.data || event.data.context !== 'h5p') return
|
|
76
|
+
switch (event.data.action) {
|
|
77
|
+
case 'ready':
|
|
78
|
+
// h5p-resizer.js announces itself once it is on the page; it expects a `hello` back.
|
|
79
|
+
post({ context: 'h5p', action: 'hello' })
|
|
80
|
+
break
|
|
81
|
+
case 'hello':
|
|
82
|
+
announce()
|
|
83
|
+
break
|
|
84
|
+
case 'resizePrepared':
|
|
85
|
+
announce()
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
post({ context: 'h5p', action: 'hello' })
|
|
91
|
+
|
|
92
|
+
// The element dispatches `resize` before it applies the height to itself; measure after layout.
|
|
93
|
+
player.addEventListener('resize', () => requestAnimationFrame(announce))
|
|
94
|
+
player.addEventListener('ready', () => requestAnimationFrame(announce))
|
|
95
|
+
|
|
96
|
+
/* ---------------------------------------------------------------- xAPI, relayed on request */
|
|
97
|
+
|
|
98
|
+
/** An origin, or nothing: the parameter has to be exactly what `event.origin` will read. */
|
|
99
|
+
const originOf = (value) => {
|
|
100
|
+
if (!value) return null
|
|
101
|
+
try {
|
|
102
|
+
const origin = new URL(value).origin
|
|
103
|
+
return origin !== 'null' && origin === value.replace(/\/$/, '') ? origin : null
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const relayTo = originOf(params.get('xapi'))
|
|
110
|
+
if (relayTo && framed) {
|
|
111
|
+
for (const type of ['xapi', 'finished']) {
|
|
112
|
+
player.addEventListener(type, (event) => {
|
|
113
|
+
post({ context: 'h5p-offline-player', action: type, ...event.detail }, relayTo)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* ---------------------------------------------------------------- errors, and Safari */
|
|
119
|
+
|
|
120
|
+
player.addEventListener('error', (event) => {
|
|
121
|
+
const { code, message } = event.detail
|
|
122
|
+
if (code === 'no-worker' && framed) {
|
|
123
|
+
// Detected, not sniffed: a browser, an in-app one or a page that is not https may give a
|
|
124
|
+
// frame no Service Worker, and the player cannot run without one. Safari does allow it.
|
|
125
|
+
say("This browser does not run the player inside another site's page.", 'error', {
|
|
126
|
+
href: location.href,
|
|
127
|
+
text: 'Open it on its own'
|
|
128
|
+
})
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
// Once the content is up, a runtime error inside it is the content's business: it keeps
|
|
132
|
+
// running, and a red notice over a working video would say otherwise.
|
|
133
|
+
if (code === 'runtime' && player.state === 'ready') {
|
|
134
|
+
console.warn(`h5p-player: the content reported an error and kept running: ${message}`)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
say(message || code, 'error')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
player.addEventListener('statechange', (event) => {
|
|
141
|
+
const { state } = event.detail
|
|
142
|
+
if (state !== 'error') say('')
|
|
143
|
+
// Shown from the HTML on, until the content is up or the load has failed.
|
|
144
|
+
loader.hidden = state === 'ready' || state === 'error' || state === 'idle'
|
|
145
|
+
requestAnimationFrame(announce)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
/* ---------------------------------------------------------------- which hosts */
|
|
149
|
+
|
|
150
|
+
/** The origin of a URL as this page resolves it, or `null` for what is not one. */
|
|
151
|
+
const urlOrigin = (value) => {
|
|
152
|
+
try {
|
|
153
|
+
const url = new URL(value, location.href)
|
|
154
|
+
// A `data:` or `blob:` URL has an opaque origin: name its scheme, so it is never "ours".
|
|
155
|
+
return url.origin === 'null' ? url.protocol : url.origin
|
|
156
|
+
} catch {
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const allowed = packages ? new Set(packages) : null
|
|
162
|
+
/** Whether this player was told it may fetch from `origin`. Always true without a list. */
|
|
163
|
+
const permitted = (origin) => origin === location.origin || !allowed || allowed.has(origin)
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The `libraries` value with `pack` resolved to this site's copy, or an error to show. With a
|
|
167
|
+
* list of hosts, every bundle's origin has to be on it, the hub's included; the CSP that
|
|
168
|
+
* `h5p-embed` wrote says the same, this only says it in words.
|
|
169
|
+
*/
|
|
170
|
+
const librarySources = (value) => {
|
|
171
|
+
const sources = []
|
|
172
|
+
for (const token of value.split(/\s+/).filter(Boolean)) {
|
|
173
|
+
if (token === 'pack') {
|
|
174
|
+
// The pack, with the hub behind it for what it lacks, where the hub may be reached: an
|
|
175
|
+
// export without its libraries then plays with no request to h5p.org in the common case.
|
|
176
|
+
// A site set up without the pack falls back to the hub alone, so a snippet written for
|
|
177
|
+
// `pack` keeps playing after a rebuild with --no-libraries.
|
|
178
|
+
if (librariesPack) sources.push(librariesPack)
|
|
179
|
+
if (permitted(HUB_ORIGIN)) sources.push('hub')
|
|
180
|
+
else if (!librariesPack) return { error: 'This player was set up without the library pack, so libraries=pack is not available here.' }
|
|
181
|
+
} else if (token === 'hub') {
|
|
182
|
+
if (!permitted(HUB_ORIGIN)) return { error: 'This player does not fetch libraries from the H5P hub.' }
|
|
183
|
+
sources.push(token)
|
|
184
|
+
} else {
|
|
185
|
+
const origin = urlOrigin(token)
|
|
186
|
+
if (!origin || !permitted(origin)) return { error: `This player does not fetch libraries from ${origin ?? token}.` }
|
|
187
|
+
sources.push(token)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { value: [...new Set(sources)].join(' ') }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The element's display options, by their attribute names, for the embedding page to ask for:
|
|
195
|
+
* `&frame©right&export` shows H5P's action bar with those buttons, `&fullscreen=off` takes
|
|
196
|
+
* that one away, `&activity-id=` names the statements' object and `&custom-css=` restyles the
|
|
197
|
+
* content to the embedding site's taste. Not `custom-js`, `embed-code` or `user`: a script is
|
|
198
|
+
* a capability on this origin that a link should not hand out, the embed is the embed, and a
|
|
199
|
+
* learner's name has no place in a URL.
|
|
200
|
+
*/
|
|
201
|
+
const applyOptions = () => {
|
|
202
|
+
for (const name of ['frame', 'copyright', 'export', 'icon', 'reporting']) {
|
|
203
|
+
if (params.has(name) && params.get(name) !== 'off') player.setAttribute(name, '')
|
|
204
|
+
}
|
|
205
|
+
if (params.get('fullscreen') === 'off') player.setAttribute('fullscreen', 'off')
|
|
206
|
+
for (const name of ['activity-id', 'custom-css']) {
|
|
207
|
+
const value = params.get(name)?.trim()
|
|
208
|
+
if (value) player.setAttribute(name, value)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const start = (value, libraries) => {
|
|
213
|
+
if (libraries) player.setAttribute('libraries', libraries)
|
|
214
|
+
if (params.get('preload') === 'auto') player.setAttribute('preload', 'auto')
|
|
215
|
+
applyOptions()
|
|
216
|
+
player.setAttribute('src', value)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Whether this document's storage is this origin's own: top level, or framed by this origin. A
|
|
221
|
+
* package's scripts run with the storage of the origin it plays on. In another site's frame
|
|
222
|
+
* that storage is partitioned by the embedding site, so a page can only ever reach what was
|
|
223
|
+
* played under its own embed; opened on its own, a link to a package from elsewhere waits for
|
|
224
|
+
* a click.
|
|
225
|
+
*/
|
|
226
|
+
const sharesOriginStorage = () => {
|
|
227
|
+
if (!framed) return true
|
|
228
|
+
try {
|
|
229
|
+
return window.parent.location.origin === location.origin
|
|
230
|
+
} catch {
|
|
231
|
+
return false // Another origin's frame: reading its location throws, and the storage is partitioned.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* ---------------------------------------------------------------- load */
|
|
236
|
+
|
|
237
|
+
const src = params.get('src')?.trim()
|
|
238
|
+
if (!src) {
|
|
239
|
+
refuse('No package given. Add ?src=<url of a .h5p file> to the address.')
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
const origin = urlOrigin(src)
|
|
243
|
+
if (!origin) {
|
|
244
|
+
refuse('The package address is not a URL.')
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
if (!permitted(origin)) {
|
|
248
|
+
refuse(`This player does not play packages from ${origin}.`)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
const libraries = params.get('libraries')?.trim()
|
|
252
|
+
const sources = libraries ? librarySources(libraries) : { value: '' }
|
|
253
|
+
if (sources.error) {
|
|
254
|
+
refuse(sources.error)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// A host on the list was vouched for when the site was set up; anything else from another
|
|
259
|
+
// origin waits for a click when it would share this origin's storage.
|
|
260
|
+
if (origin === location.origin || allowed || !sharesOriginStorage()) {
|
|
261
|
+
start(src, sources.value)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
loader.hidden = true
|
|
265
|
+
const host = new URL(src, location.href).host || origin
|
|
266
|
+
const button = document.createElement('button')
|
|
267
|
+
button.type = 'button'
|
|
268
|
+
button.textContent = 'Open the package'
|
|
269
|
+
button.addEventListener('click', () => {
|
|
270
|
+
say('')
|
|
271
|
+
loader.hidden = false
|
|
272
|
+
start(src, sources.value)
|
|
273
|
+
})
|
|
274
|
+
say(
|
|
275
|
+
`This link opens a package from ${host}. A package runs its own scripts on this site, and they ` +
|
|
276
|
+
'can read what other packages saved in this browser. Open it only if you trust that site.'
|
|
277
|
+
)
|
|
278
|
+
notice.append(' ', button)
|
|
279
|
+
}
|
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,10 @@
|
|
|
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
|
+
})
|
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
|
+
})();
|