@missing-elements/h5p-embed 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,8 +3,11 @@
3
3
  Writes the H5P embed page as a static site for a player domain of your own. Any site then embeds
4
4
  a package with an iframe and one script line. No H5P server, no backend, no third-party service.
5
5
 
6
+ Needs Node 22 or later. Write `@latest`: without it, npm on an older Node quietly installs the
7
+ last version that supported it, which writes a player from before 1.0.
8
+
6
9
  ```bash
7
- npx @missing-elements/h5p-embed h5p-player # the folder defaults to ./h5p-player
10
+ npx @missing-elements/h5p-embed@latest h5p-player # the folder defaults to ./h5p-player
8
11
  ```
9
12
 
10
13
  Deploy it to any static host, then embed:
@@ -27,7 +30,7 @@ a subdomain shares your site's cookies.
27
30
 
28
31
  | Option | Effect |
29
32
  |---|---|
30
- | `--packages <origins>` | Play packages and fetch library bundles only from these origins (the site's own is always allowed). Add `https://cdn.jsdelivr.net` for `libraries=pack` on a site built with `--no-libraries`. Default: any https host |
33
+ | `--packages <origins>` | Play packages and fetch library bundles only from these origins (the site's own is always allowed). Each host on its own, with no wildcard: the page compares exact origins. Add `https://cdn.jsdelivr.net` for `libraries=pack` on a site built with `--no-libraries`. Default: any https host |
31
34
  | `--ancestors <origins>` | Only these sites may frame the page (`frame-ancestors`, header only). Default: any site |
32
35
  | `--default-libraries <sources>` | The `libraries` value for addresses that name none: `pack` or URLs. Checked against `--packages`. Default: none, so exports without libraries are refused unless the address asks |
33
36
  | `--no-libraries` | Leave out the library pack, 13.8 MB in `libraries/`, one archive per library, of which a learner's browser fetches only those a package needs; `libraries=pack` then fetches them from jsDelivr, where allowed |
@@ -40,7 +43,7 @@ through:** the browser's policy applies to each hop, and a redirect off the list
40
43
  network error.
41
44
 
42
45
  ```bash
43
- npx @missing-elements/h5p-embed h5p-player \
46
+ npx @missing-elements/h5p-embed@latest h5p-player \
44
47
  --packages https://cdn.example.org \
45
48
  --ancestors "https://www.example.org https://lms.example.org"
46
49
  ```
@@ -74,7 +77,7 @@ To update, run the command again with `--force` and redeploy.
74
77
 
75
78
  | Parameter | Effect |
76
79
  |---|---|
77
- | `src=<url>` | The package, required. Encode `&`, `#`, `+`, `%` and spaces |
80
+ | `src=<url>` | The package, required: an `http` or `https` URL, or a path on the player domain. Encode `&`, `#`, `+`, `%` and spaces |
78
81
  | `libraries=pack`, `<url>` or `none` | Where libraries come from; several are tried in order. `pack` is this domain's copy, or jsDelivr's without one: a package gets the libraries it lacks from it, and those it carries at the pack's newer versions. `none` turns off `--default-libraries` |
79
82
  | `refresh-libraries=off` | Play the libraries a package carries as exported; `pack` then only fills gaps |
80
83
  | `frame`, `copyright`, `export`, `icon`, `reporting` | H5P's action bar and its buttons |
@@ -82,7 +85,7 @@ To update, run the command again with `--force` and redeploy.
82
85
  | `preload=auto` | Start fetching media at once |
83
86
  | `activity-id=<IRI>` | The xAPI object id, instead of the package URL |
84
87
  | `custom-css=<url>` | A stylesheet loaded into the content |
85
- | `lang=<tag>` | The language of H5P's own buttons and dialogs, such as `de` or `pt-BR`. Default: English |
88
+ | `lang=<tag>` | The language of H5P's own buttons and dialogs, such as `de` or `pt-BR`, for a package that does not name its own. A package that names its language in `h5p.json` gets them in that language, matching its content. Default: English |
86
89
  | `xapi=<origin>` | Relay statements to the embedding page |
87
90
 
88
91
  A custom script and a learner's name cannot be set on the address. Opened unframed, the page plays
@@ -135,6 +138,7 @@ export, which `libraries=pack` then plays from) or `packBase` (the folder of a c
135
138
  `@missing-elements/h5p-runtime` export, for a bundled element) and `askInOwnFrame` (default
136
139
  `true`; `false` plays a package from another origin as it is when a same-origin page frames it).
137
140
  `librariesPack`, the URL of a whole `libraries.h5p`, still works, as a bundle that only fills gaps.
141
+ Both entries ship with TypeScript declarations.
138
142
 
139
143
  ```js
140
144
  import '@missing-elements/h5p-offline-player'
package/lib/build.mjs CHANGED
@@ -23,7 +23,11 @@ const PAGE_FILES = ['embed.js', 'embed.css', 'main.js', 'resizer.js']
23
23
  /** The player's files, by the names the element looks for beside itself. */
24
24
  const PLAYER_FILES = ['h5p-player.js', 'h5p-sw.js', 'h5p-jobs.js']
25
25
 
26
- /** Where an installed package's file is, or a message that says which one is missing. */
26
+ /**
27
+ * Where an installed package's file is, or a message that says which one is missing.
28
+ * @param {string} specifier
29
+ * @param {string} hint
30
+ */
27
31
  function installed(specifier, hint) {
28
32
  try {
29
33
  return require.resolve(specifier)
@@ -35,7 +39,13 @@ function installed(specifier, hint) {
35
39
  /**
36
40
  * An origin as the CSP and the page compare it: `https://host[:port]`, or plain http on a loopback
37
41
  * address for trying it locally. Anything with a path, a query or a trailing slash is refused
38
- * rather than trimmed, so a typo is not silently a different policy.
42
+ * rather than trimmed, so a typo is not silently a different policy. The host is held to what a
43
+ * CSP host is, letters, digits and hyphens between dots: the URL parser takes `*`, `;` and `"` in
44
+ * a host, and a wildcard would allow what the page then refuses, by exact origin, on every load,
45
+ * while a quote would end the policy in the files that quote it (#58).
46
+ * @internal
47
+ * @param {string} value
48
+ * @returns {string}
39
49
  */
40
50
  export function parseOrigin(value) {
41
51
  let url
@@ -48,6 +58,9 @@ export function parseOrigin(value) {
48
58
  if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
49
59
  throw new EmbedError(`Not an https origin: ${value}`)
50
60
  }
61
+ if (!loopback && !/^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/.test(url.hostname)) {
62
+ throw new EmbedError(`Not an origin: ${value}. A host is letters, digits, hyphens and dots, and each host is listed on its own, with no wildcard`)
63
+ }
51
64
  if (url.origin !== value) throw new EmbedError(`Not an origin: ${value}. Write it as ${url.origin}, with no path or trailing slash`)
52
65
  return url.origin
53
66
  }
@@ -59,6 +72,7 @@ const PACK_CDN_ORIGIN = 'https://cdn.jsdelivr.net'
59
72
  * A `libraries` value for addresses that name none, checked against the site it goes into: the
60
73
  * tokens the page understands (`pack`, `none`, https URLs), and with a host list, nothing
61
74
  * the page would then refuse on every load. Returns the value normalised to single spaces.
75
+ * @internal
62
76
  *
63
77
  * @param {string | null | undefined} value
64
78
  * @param {object} [site]
@@ -97,7 +111,12 @@ export function parseDefaultLibraries(value, { packages = null, libraries = true
97
111
  return tokens.join(' ')
98
112
  }
99
113
 
100
- /** Origins given as a list, or as one string separated by commas or whitespace. */
114
+ /**
115
+ * Origins given as a list, or as one string separated by commas or whitespace.
116
+ * @internal
117
+ * @param {string[] | string | null | undefined} values
118
+ * @returns {string[] | null}
119
+ */
101
120
  export function parseOrigins(values) {
102
121
  if (values == null) return null
103
122
  const list = (Array.isArray(values) ? values : [values]).flatMap((value) => String(value).split(/[\s,]+/)).filter(Boolean)
@@ -111,6 +130,7 @@ export function parseOrigins(values) {
111
130
  * it — and names who may frame the page; without it any site may.
112
131
  */
113
132
  /**
133
+ * @internal
114
134
  * @param {object} [options]
115
135
  * @param {string[] | null} [options.packages]
116
136
  * @param {string[] | null} [options.ancestors]
@@ -134,7 +154,11 @@ export function contentSecurityPolicy({ packages = null, ancestors = null, meta
134
154
  return directives.join('; ')
135
155
  }
136
156
 
137
- /** Header rules, one set for every host format: the policy everywhere, and the caching that matters. */
157
+ /**
158
+ * Header rules, one set for every host format: the policy everywhere, and the caching that matters.
159
+ * @param {string} csp
160
+ * @returns {{ path: string, headers: Record<string, string> }[]}
161
+ */
138
162
  function headerRules(csp) {
139
163
  const revalidate = 'public, max-age=3600, stale-while-revalidate=86400'
140
164
  // Asked again on every load, a 304 when nothing changed. The runtime's files keep their names
@@ -161,13 +185,20 @@ function headerRules(csp) {
161
185
  ]
162
186
  }
163
187
 
164
- /** Netlify's and Cloudflare Pages' `_headers`. */
188
+ /**
189
+ * Netlify's and Cloudflare Pages' `_headers`.
190
+ * @param {ReturnType<typeof headerRules>} rules
191
+ */
165
192
  function netlifyHeaders(rules) {
166
193
  return rules.map(({ path, headers }) => `${path}\n${Object.entries(headers).map(([key, value]) => ` ${key}: ${value}`).join('\n')}`).join('\n') + '\n'
167
194
  }
168
195
 
169
- /** Vercel's `vercel.json`, for deploying the folder as a project of its own. */
196
+ /**
197
+ * Vercel's `vercel.json`, for deploying the folder as a project of its own.
198
+ * @param {ReturnType<typeof headerRules>} rules
199
+ */
170
200
  function vercelConfig(rules) {
201
+ /** @param {string} path */
171
202
  const source = (path) => (path === '/*' ? '/(.*)' : path.replace(/\*$/, '(.*)'))
172
203
  return JSON.stringify(
173
204
  { headers: rules.map(({ path, headers }) => ({ source: source(path), headers: Object.entries(headers).map(([key, value]) => ({ key, value })) })) },
@@ -184,13 +215,19 @@ function vercelConfig(rules) {
184
215
  * in every format, which is safe because the policy carries only single quotes.
185
216
  */
186
217
 
187
- /** The rule for every path, and the ones for a single path or prefix. */
218
+ /**
219
+ * The rule for every path, and the ones for a single path or prefix.
220
+ * @param {ReturnType<typeof headerRules>} rules
221
+ */
188
222
  function splitRules(rules) {
189
- const all = rules.find((rule) => rule.path === '/*')
223
+ const all = /** @type {ReturnType<typeof headerRules>[number]} */ (rules.find((rule) => rule.path === '/*'))
190
224
  return { all, specific: rules.filter((rule) => rule !== all) }
191
225
  }
192
226
 
193
- /** Caddy's `Caddyfile`: a site block to run as it is, or to paste into one. */
227
+ /**
228
+ * Caddy's `Caddyfile`: a site block to run as it is, or to paste into one.
229
+ * @param {ReturnType<typeof headerRules>} rules
230
+ */
194
231
  function caddyfile(rules) {
195
232
  const { all, specific } = splitRules(rules)
196
233
  const lines = [
@@ -210,10 +247,13 @@ function caddyfile(rules) {
210
247
  * nginx: a file to `include` inside the `server` block that serves the folder. nginx's
211
248
  * `add_header` in a `location` replaces every `add_header` inherited from the server level
212
249
  * rather than adding to it, so each location repeats the headers that apply everywhere.
250
+ * @param {ReturnType<typeof headerRules>} rules
213
251
  */
214
252
  function nginxConf(rules) {
215
253
  const { all, specific } = splitRules(rules)
254
+ /** @param {[string, string]} entry */
216
255
  const header = ([key, value]) => `add_header ${key} "${value}" always;`
256
+ /** @param {string} path */
217
257
  const matcher = (path) => (path.endsWith('/*') ? `^~ ${path.slice(0, -1)}` : `= ${path}`)
218
258
  const lines = [
219
259
  '# Written by h5p-embed. Include this file inside the server block that serves the folder:',
@@ -229,10 +269,15 @@ function nginxConf(rules) {
229
269
  return lines.join('\n') + '\n'
230
270
  }
231
271
 
232
- /** Apache's `.htaccess`, for a folder under a vhost that allows it. */
272
+ /**
273
+ * Apache's `.htaccess`, for a folder under a vhost that allows it.
274
+ * @param {ReturnType<typeof headerRules>} rules
275
+ */
233
276
  function htaccess(rules) {
234
277
  const { all, specific } = splitRules(rules)
278
+ /** @param {[string, string]} entry */
235
279
  const header = ([key, value]) => `Header always set ${key} "${value}"`
280
+ /** @param {ReturnType<typeof headerRules>[number]} rule */
236
281
  const block = ({ path, headers }) => {
237
282
  const body = Object.entries(headers).map((entry) => `\t${header(entry)}`).join('\n')
238
283
  // <Files> matches a name in any directory; a prefix needs the request URI, with no anchor at
@@ -250,6 +295,7 @@ function htaccess(rules) {
250
295
  return lines.join('\n') + '\n'
251
296
  }
252
297
 
298
+ /** @param {{ version: string, libraries: boolean }} site */
253
299
  function notice({ version, libraries }) {
254
300
  return `This folder is the H5P embed page, written by @missing-elements/h5p-embed.
255
301
 
@@ -277,7 +323,10 @@ https://github.com/missing-elements/h5p-offline-player/tree/main/packages/embed
277
323
  `
278
324
  }
279
325
 
280
- /** Whether a directory exists and has anything in it. A path that is a file is an error. */
326
+ /**
327
+ * Whether a directory exists and has anything in it. A path that is a file is an error.
328
+ * @param {string} dir
329
+ */
281
330
  async function occupied(dir) {
282
331
  let info
283
332
  try {
@@ -289,6 +338,19 @@ async function occupied(dir) {
289
338
  return (await readdir(dir)).length > 0
290
339
  }
291
340
 
341
+ /**
342
+ * What `buildSite` wrote, for a summary.
343
+ * @typedef {object} BuiltSite
344
+ * @property {string} out the folder, as an absolute path
345
+ * @property {string} version the player's version, as its `dist/VERSION` says
346
+ * @property {string} csp the policy the headers carry
347
+ * @property {boolean} libraries whether the site carries the library pack's parts
348
+ * @property {string[] | null} packages the origins packages may come from; `null` for any
349
+ * @property {string[] | null} ancestors the origins that may frame the page; `null` for any
350
+ * @property {string | null} defaultLibraries the `libraries` value for addresses that name none
351
+ * @property {number} size the folder's size in bytes
352
+ */
353
+
292
354
  /**
293
355
  * Writes the site into `out`. Refuses a folder that already has files in it unless `force`, and
294
356
  * even then only replaces the files it writes, so pointing it at the wrong folder costs nothing
@@ -301,6 +363,7 @@ async function occupied(dir) {
301
363
  * @param {string[] | string | null} [options.ancestors] the only origins that may frame the page
302
364
  * @param {string | null} [options.defaultLibraries] the `libraries` value for addresses that name none
303
365
  * @param {boolean} [options.force]
366
+ * @returns {Promise<BuiltSite>}
304
367
  */
305
368
  export async function buildSite({ out, libraries = true, packages = null, ancestors = null, defaultLibraries = null, force = false }) {
306
369
  if (!out) throw new EmbedError('No output folder given.')
@@ -366,6 +429,7 @@ export async function buildSite({ out, libraries = true, packages = null, ancest
366
429
  /**
367
430
  * The folder's size in bytes, for the summary. Paths from a recursive `readdir` rather than
368
431
  * `Dirent.parentPath`, which Node 20 has only from 20.12.
432
+ * @param {string} dir
369
433
  */
370
434
  async function sizeOf(dir) {
371
435
  let total = 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@missing-elements/h5p-embed",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
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
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,8 +20,14 @@
20
20
  "h5p-embed": "./bin/h5p-embed.mjs"
21
21
  },
22
22
  "exports": {
23
- ".": "./lib/build.mjs",
24
- "./embed.js": "./site/embed.js",
23
+ ".": {
24
+ "types": "./types/lib/build.d.mts",
25
+ "default": "./lib/build.mjs"
26
+ },
27
+ "./embed.js": {
28
+ "types": "./types/site/embed.d.ts",
29
+ "default": "./site/embed.js"
30
+ },
25
31
  "./embed.css": "./site/embed.css",
26
32
  "./resizer.js": "./site/resizer.js",
27
33
  "./package.json": "./package.json"
@@ -29,7 +35,8 @@
29
35
  "files": [
30
36
  "bin",
31
37
  "lib",
32
- "site"
38
+ "site",
39
+ "types"
33
40
  ],
34
41
  "keywords": [
35
42
  "h5p",
@@ -44,21 +51,22 @@
44
51
  ],
45
52
  "dependencies": {
46
53
  "@missing-elements/h5p-libraries": "^1.0.0",
47
- "@missing-elements/h5p-offline-player": "^1.0.1",
48
- "@missing-elements/h5p-runtime": "^1.0.0"
54
+ "@missing-elements/h5p-runtime": "^1.0.0",
55
+ "@missing-elements/h5p-offline-player": "^1.1.1"
49
56
  },
50
57
  "devDependencies": {
51
58
  "@types/node": "^26.6.5",
52
- "@zip.js/zip.js": "^2.23.0",
53
- "playwright": "^1.64.0",
59
+ "@zip.js/zip.js": "^2.24.0",
60
+ "playwright": "^1.64.1",
54
61
  "typescript": "^7.0.2",
55
- "vitest": "^5.0.3"
62
+ "vitest": "^5.0.5"
56
63
  },
57
64
  "engines": {
58
65
  "node": ">=22"
59
66
  },
60
67
  "scripts": {
61
68
  "test": "pnpm --filter @missing-elements/h5p-offline-player build && vitest run",
62
- "typecheck": "tsc -p tsconfig.json"
69
+ "typecheck": "pnpm types && tsc -p tsconfig.json",
70
+ "types": "node -e \"require('node:fs').rmSync('types', { recursive: true, force: true })\" && tsc -p tsconfig.types.json"
63
71
  }
64
72
  }
package/site/embed.js CHANGED
@@ -18,6 +18,10 @@
18
18
  * package shows under its preview.
19
19
  */
20
20
 
21
+ /**
22
+ * @import { H5PPlayerElement, PlayerErrorDetail, PlayerStateChangeDetail, ReadyDetail } from '@missing-elements/h5p-offline-player'
23
+ */
24
+
21
25
  /** Where the element's `libraries="pack"` fetches the library pack from, for a site without its own copy. */
22
26
  const PACK_CDN_ORIGIN = 'https://cdn.jsdelivr.net'
23
27
 
@@ -25,7 +29,7 @@ const PACK_CDN_ORIGIN = 'https://cdn.jsdelivr.net'
25
29
  * @param {object} [options]
26
30
  * @param {string | null} [options.packBase] the folder of this site's copy of the library pack's
27
31
  * parts (`parts/` of `@missing-elements/h5p-libraries`), which `libraries=pack` plays from
28
- * @param {{ libraries: object[] } | null} [options.pack] the same, as the `pack` export a bundler
32
+ * @param {H5PPlayerElement['pack']} [options.pack] the same, as the `pack` export a bundler
29
33
  * resolves; it wins over `packBase`. Without either, `pack` is the element's copy on jsDelivr
30
34
  * @param {string | null} [options.librariesPack] the URL of a whole `libraries.h5p` on this site,
31
35
  * for a page written before the pack was split: played as any bundle, with no refresh
@@ -35,7 +39,7 @@ const PACK_CDN_ORIGIN = 'https://cdn.jsdelivr.net'
35
39
  * @param {string | null} [options.defaultLibraries] the `libraries` value for an address that has
36
40
  * none — `pack` or URLs, as the parameter — so a snippet without `&libraries=` still plays
37
41
  * an export that carries no libraries; `&libraries=none` turns it off for one address
38
- * @param {object | null} [options.runtime] the `runtime` export of `@missing-elements/h5p-runtime`,
42
+ * @param {H5PPlayerElement['runtime']} [options.runtime] the `runtime` export of `@missing-elements/h5p-runtime`,
39
43
  * for a page that bundles the element; without it the element looks for `frame-assets/` beside itself
40
44
  * @param {boolean} [options.askInOwnFrame] whether a package from another origin plays `untrusted`,
41
45
  * with a click to play it with its own code when it needs that, when this page is framed by a
@@ -44,19 +48,24 @@ const PACK_CDN_ORIGIN = 'https://cdn.jsdelivr.net'
44
48
  */
45
49
  export function startEmbed({ packBase = null, pack = null, librariesPack = null, packages = null, defaultLibraries = null, runtime = null, askInOwnFrame = true } = {}) {
46
50
  const params = new URLSearchParams(location.search)
47
- const player = document.querySelector('h5p-player')
51
+ const player = /** @type {H5PPlayerElement} */ (document.querySelector('h5p-player'))
48
52
  // Before `src`: the element resolves the runtime's files, and reads where the pack is, when a
49
53
  // package is set.
50
54
  if (runtime) player.runtime = runtime
51
55
  if (pack) player.pack = pack
52
56
  else if (packBase) player.setAttribute('pack-base', packBase)
53
57
  const ownPack = Boolean(pack || packBase)
54
- const notice = document.querySelector('#notice')
55
- const loader = document.querySelector('#loader')
58
+ const notice = /** @type {HTMLElement} */ (document.querySelector('#notice'))
59
+ const loader = /** @type {HTMLElement} */ (document.querySelector('#loader'))
56
60
  const framed = window.parent !== window
57
61
 
58
62
  /* ---------------------------------------------------------------- notices */
59
63
 
64
+ /**
65
+ * @param {string} text
66
+ * @param {string} [kind]
67
+ * @param {{ href: string, text: string } | null} [link]
68
+ */
60
69
  const say = (text, kind = '', link = null) => {
61
70
  notice.replaceChildren()
62
71
  if (text) {
@@ -75,6 +84,10 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
75
84
  requestAnimationFrame(announce)
76
85
  }
77
86
 
87
+ /**
88
+ * @param {string} text
89
+ * @param {string} [code]
90
+ */
78
91
  const refuse = (text, code = 'refused') => {
79
92
  loader.hidden = true
80
93
  say(text, 'error')
@@ -87,6 +100,8 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
87
100
  * A message to the page that frames this one: the height in the shape h5p-resizer.js expects,
88
101
  * and `report`, `error` and `xapi` in this page's own. To any parent unless a target is named,
89
102
  * since nothing in the height is secret; the xAPI relay names the origin that opted in.
103
+ * @param {object} message
104
+ * @param {string} [target]
90
105
  */
91
106
  const post = (message, target = '*') => {
92
107
  if (framed) window.parent.postMessage(message, target)
@@ -139,7 +154,7 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
139
154
  let startedAt = 0
140
155
 
141
156
  player.addEventListener('ready', (event) => {
142
- const { source, metadata, libraryBundle } = event.detail
157
+ const { source, metadata, libraryBundle } = /** @type {CustomEvent<ReadyDetail>} */ (event).detail
143
158
  post({
144
159
  context: 'h5p-offline-player',
145
160
  action: 'report',
@@ -158,7 +173,10 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
158
173
 
159
174
  /* ---------------------------------------------------------------- xAPI, relayed on request */
160
175
 
161
- /** An origin, or nothing: the parameter has to be exactly what `event.origin` will read. */
176
+ /**
177
+ * An origin, or nothing: the parameter has to be exactly what `event.origin` will read.
178
+ * @param {string | null} value
179
+ */
162
180
  const originOf = (value) => {
163
181
  if (!value) return null
164
182
  try {
@@ -173,18 +191,22 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
173
191
  if (relayTo && framed) {
174
192
  for (const type of ['xapi', 'finished']) {
175
193
  player.addEventListener(type, (event) => {
176
- post({ context: 'h5p-offline-player', action: type, ...event.detail }, relayTo)
194
+ post({ context: 'h5p-offline-player', action: type, .../** @type {CustomEvent<object>} */ (event).detail }, relayTo)
177
195
  })
178
196
  }
179
197
  }
180
198
 
181
199
  /* ---------------------------------------------------------------- errors, and Safari */
182
200
 
183
- /** Asks whether to play an untrusted package with its own code; set when one is played so. */
201
+ /**
202
+ * Asks whether to play an untrusted package with its own code; set when one is played so.
203
+ * @type {(() => void) | null}
204
+ */
184
205
  let askToTrust = null
185
206
 
186
207
  player.addEventListener('error', (event) => {
187
- const { code, message, missingLibraries } = event.detail
208
+ // The element's own `error`, which the DOM's types take for a script's `ErrorEvent`.
209
+ const { code, message, missingLibraries } = /** @type {CustomEvent<PlayerErrorDetail>} */ (/** @type {unknown} */ (event)).detail
188
210
  // Played untrusted, it cannot do without its own copy of a library: a click decides.
189
211
  if (missingLibraries?.carried && player.untrusted && askToTrust) {
190
212
  askToTrust()
@@ -211,7 +233,7 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
211
233
  })
212
234
 
213
235
  player.addEventListener('statechange', (event) => {
214
- const { state } = event.detail
236
+ const { state } = /** @type {CustomEvent<PlayerStateChangeDetail>} */ (event).detail
215
237
  if (state !== 'error') say('')
216
238
  // Shown from the HTML on, until the content is up or the load has failed.
217
239
  loader.hidden = state === 'ready' || state === 'error' || state === 'idle'
@@ -220,7 +242,10 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
220
242
 
221
243
  /* ---------------------------------------------------------------- which hosts */
222
244
 
223
- /** The origin of a URL as this page resolves it, or `null` for what is not one. */
245
+ /**
246
+ * The origin of a URL as this page resolves it, or `null` for what is not one.
247
+ * @param {string} value
248
+ */
224
249
  const urlOrigin = (value) => {
225
250
  try {
226
251
  const url = new URL(value, location.href)
@@ -231,16 +256,38 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
231
256
  }
232
257
  }
233
258
 
259
+ /**
260
+ * A package's address as the element is given it: resolved against this page, and only when it
261
+ * is `http` or `https`. `null` for anything else, a `javascript:` or `data:` URL or text that is
262
+ * not a URL. The element gets the parsed URL's `href`, never the text, so `src` holds what was
263
+ * checked.
264
+ * @param {string} value
265
+ */
266
+ const packageUrl = (value) => {
267
+ try {
268
+ const url = new URL(value, location.href)
269
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url : null
270
+ } catch {
271
+ return null
272
+ }
273
+ }
274
+
234
275
  const allowed = packages ? new Set(packages) : null
235
- /** Whether this player was told it may fetch from `origin`. Always true without a list. */
276
+ /**
277
+ * Whether this player was told it may fetch from `origin`. Always true without a list.
278
+ * @param {string} origin
279
+ */
236
280
  const permitted = (origin) => origin === location.origin || !allowed || allowed.has(origin)
237
281
 
238
282
  /**
239
283
  * The `libraries` value with `pack` resolved to this site's copy, or an error to show. With a
240
284
  * list of hosts, every bundle's origin has to be on it, jsDelivr's included when the pack comes
241
285
  * from there; the CSP that `h5p-embed` wrote says the same, this only says it in words.
286
+ * @param {string} value
287
+ * @returns {{ value?: string, error?: string }}
242
288
  */
243
289
  const librarySources = (value) => {
290
+ /** @type {string[]} */
244
291
  const sources = []
245
292
  if (value === 'none') return { value: '' }
246
293
  for (const token of value.split(/\s+/).filter(Boolean)) {
@@ -266,7 +313,8 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
266
313
  * that one away, `&refresh-libraries=off` plays a package's own libraries even where the pack
267
314
  * has newer ones, `&activity-id=` names the statements' object and `&custom-css=` restyles the
268
315
  * content to the embedding site's taste. `&lang=` is the embedding page's language, which this
269
- * page cannot read across origins: H5P's own buttons and dialogs follow it. Not `custom-js`,
316
+ * page cannot read across origins: H5P's own buttons and dialogs follow it for a package that
317
+ * does not name its own language, and follow the content's where it does. Not `custom-js`,
270
318
  * `embed-code` or `user`: a script is a capability on this origin that a link should not hand
271
319
  * out, the embed is the embed, and a learner's name has no place in a URL.
272
320
  */
@@ -286,6 +334,10 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
286
334
  if (lang && /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(lang)) player.setAttribute('lang', lang)
287
335
  }
288
336
 
337
+ /**
338
+ * @param {string} value
339
+ * @param {string | undefined} libraries
340
+ */
289
341
  const start = (value, libraries) => {
290
342
  if (libraries) player.setAttribute('libraries', libraries)
291
343
  if (params.get('preload') === 'auto') player.setAttribute('preload', 'auto')
@@ -319,11 +371,12 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
319
371
  refuse('No package given. Add ?src=<url of a .h5p file> to the address.', 'no-src')
320
372
  return
321
373
  }
322
- const origin = urlOrigin(src)
323
- if (!origin) {
324
- refuse('The package address is not a URL.')
374
+ const address = packageUrl(src)
375
+ if (!address) {
376
+ refuse('The package address is not an http or https URL.')
325
377
  return
326
378
  }
379
+ const { origin } = address
327
380
  if (!permitted(origin)) {
328
381
  refuse(`This player does not play packages from ${origin}.`)
329
382
  return
@@ -339,14 +392,14 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
339
392
  // A host on the list was vouched for when the site was set up, and in another site's frame the
340
393
  // storage is that site's own: those play as they are.
341
394
  if (origin === location.origin || allowed || !sharesOriginStorage()) {
342
- start(src, sources.value)
395
+ start(address.href, sources.value)
343
396
  return
344
397
  }
345
398
 
346
399
  // Anything else from another origin would share this origin's storage. It plays untrusted, as
347
400
  // data with the library pack's code and none of its own, and waits for a click that names the
348
401
  // host only when it cannot play without its own, or there is no pack to play it with.
349
- const host = new URL(src, location.href).host || origin
402
+ const { host } = address
350
403
  askToTrust = () => {
351
404
  loader.hidden = true
352
405
  const button = document.createElement('button')
@@ -357,7 +410,7 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
357
410
  loader.hidden = false
358
411
  player.untrusted = false
359
412
  player.removeAttribute('src')
360
- start(src, sources.value)
413
+ start(address.href, sources.value)
361
414
  })
362
415
  say(
363
416
  `This package from ${host} plays only with its own scripts. They would run on this site and ` +
@@ -375,5 +428,5 @@ export function startEmbed({ packBase = null, pack = null, librariesPack = null,
375
428
  return
376
429
  }
377
430
  player.untrusted = true
378
- start(src, sitesSources)
431
+ start(address.href, sitesSources)
379
432
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Writes the embed page as a static site: one folder, deployable to any static host, that a site
3
+ * frames from a player domain of its own. Everything comes from installed packages — the element
4
+ * and its workers from the player's `dist/`, the H5P runtime from its own package as
5
+ * `frame-assets/`, the library pack's parts from `@missing-elements/h5p-libraries` as
6
+ * `libraries/` — so the versions are the ones this package was published with, and nothing is
7
+ * fetched.
8
+ */
9
+ export declare class EmbedError extends Error {
10
+ }
11
+ export type BuiltSite = {
12
+ /**
13
+ * the folder, as an absolute path
14
+ */
15
+ out: string;
16
+ /**
17
+ * the player's version, as its `dist/VERSION` says
18
+ */
19
+ version: string;
20
+ /**
21
+ * the policy the headers carry
22
+ */
23
+ csp: string;
24
+ /**
25
+ * whether the site carries the library pack's parts
26
+ */
27
+ libraries: boolean;
28
+ /**
29
+ * the origins packages may come from; `null` for any
30
+ */
31
+ packages: string[] | null;
32
+ /**
33
+ * the origins that may frame the page; `null` for any
34
+ */
35
+ ancestors: string[] | null;
36
+ /**
37
+ * the `libraries` value for addresses that name none
38
+ */
39
+ defaultLibraries: string | null;
40
+ /**
41
+ * the folder's size in bytes
42
+ */
43
+ size: number;
44
+ };
45
+ /**
46
+ * What `buildSite` wrote, for a summary.
47
+ * @typedef {object} BuiltSite
48
+ * @property {string} out the folder, as an absolute path
49
+ * @property {string} version the player's version, as its `dist/VERSION` says
50
+ * @property {string} csp the policy the headers carry
51
+ * @property {boolean} libraries whether the site carries the library pack's parts
52
+ * @property {string[] | null} packages the origins packages may come from; `null` for any
53
+ * @property {string[] | null} ancestors the origins that may frame the page; `null` for any
54
+ * @property {string | null} defaultLibraries the `libraries` value for addresses that name none
55
+ * @property {number} size the folder's size in bytes
56
+ */
57
+ /**
58
+ * Writes the site into `out`. Refuses a folder that already has files in it unless `force`, and
59
+ * even then only replaces the files it writes, so pointing it at the wrong folder costs nothing
60
+ * that was not its own.
61
+ *
62
+ * @param {object} options
63
+ * @param {string} options.out
64
+ * @param {boolean} [options.libraries] include the library pack's parts, for `libraries=pack` (default true)
65
+ * @param {string[] | string | null} [options.packages] the only origins packages may come from
66
+ * @param {string[] | string | null} [options.ancestors] the only origins that may frame the page
67
+ * @param {string | null} [options.defaultLibraries] the `libraries` value for addresses that name none
68
+ * @param {boolean} [options.force]
69
+ * @returns {Promise<BuiltSite>}
70
+ */
71
+ export declare function buildSite({ out, libraries, packages, ancestors, defaultLibraries, force }: {
72
+ out: string;
73
+ libraries?: boolean;
74
+ packages?: string[] | string | null;
75
+ ancestors?: string[] | string | null;
76
+ defaultLibraries?: string | null;
77
+ force?: boolean;
78
+ }): Promise<BuiltSite>;
@@ -0,0 +1,50 @@
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|<url> …|none][&refresh-libraries=off][&preload=auto]
9
+ * [&xapi=<parent origin>][&frame][&copyright][&export][&icon][&reporting][&fullscreen=off]
10
+ * [&activity-id=<IRI>][&custom-css=<stylesheet url>][&lang=<language tag>]
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 shows under its preview.
19
+ */
20
+ import type { H5PPlayerElement } from '@missing-elements/h5p-offline-player';
21
+ /**
22
+ * @param {object} [options]
23
+ * @param {string | null} [options.packBase] the folder of this site's copy of the library pack's
24
+ * parts (`parts/` of `@missing-elements/h5p-libraries`), which `libraries=pack` plays from
25
+ * @param {H5PPlayerElement['pack']} [options.pack] the same, as the `pack` export a bundler
26
+ * resolves; it wins over `packBase`. Without either, `pack` is the element's copy on jsDelivr
27
+ * @param {string | null} [options.librariesPack] the URL of a whole `libraries.h5p` on this site,
28
+ * for a page written before the pack was split: played as any bundle, with no refresh
29
+ * @param {string[] | null} [options.packages] the origins packages and library bundles may come
30
+ * from, besides this page's own; `null` plays any, one from another origin `untrusted` when the
31
+ * storage is this origin's
32
+ * @param {string | null} [options.defaultLibraries] the `libraries` value for an address that has
33
+ * none — `pack` or URLs, as the parameter — so a snippet without `&libraries=` still plays
34
+ * an export that carries no libraries; `&libraries=none` turns it off for one address
35
+ * @param {H5PPlayerElement['runtime']} [options.runtime] the `runtime` export of `@missing-elements/h5p-runtime`,
36
+ * for a page that bundles the element; without it the element looks for `frame-assets/` beside itself
37
+ * @param {boolean} [options.askInOwnFrame] whether a package from another origin plays `untrusted`,
38
+ * with a click to play it with its own code when it needs that, when this page is framed by a
39
+ * page of its own origin, whose storage it shares. A site whose own preview frames the page for
40
+ * a package the visitor just chose passes `false`
41
+ */
42
+ export declare function startEmbed({ packBase, pack, librariesPack, packages, defaultLibraries, runtime, askInOwnFrame }?: {
43
+ packBase?: string | null;
44
+ pack?: H5PPlayerElement['pack'];
45
+ librariesPack?: string | null;
46
+ packages?: string[] | null;
47
+ defaultLibraries?: string | null;
48
+ runtime?: H5PPlayerElement['runtime'];
49
+ askInOwnFrame?: boolean;
50
+ }): void;