@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 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
- # Temporary Holding Version
1
+ # @missing-elements/h5p-embed
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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][&copyright][&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&copyright&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
+ }
@@ -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
+ })
@@ -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
+ })();