@missing-elements/h5p-embed 0.1.0 → 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/README.md CHANGED
@@ -38,6 +38,7 @@ cookies and nothing else on it.
38
38
  |---|---|
39
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
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 |
41
42
  | `--no-libraries` | Leave out the 9.5 MB library pack; `libraries=pack` then means the hub, where allowed |
42
43
  | `--force` | Write into a folder that is not empty, replacing only this tool's files |
43
44
 
@@ -77,7 +78,7 @@ redeploy.
77
78
  | Parameter | Effect |
78
79
  |---|---|
79
80
  | `src=<url>` | The package, required. Encode `&`, `#`, `+`, `%` and spaces in it |
80
- | `libraries=pack`, `hub` or `<url>` | Libraries for an export that has none (h5p.com and h5p.org exports usually do not). `pack` is the copy on this domain, with the hub behind it where allowed, or the hub alone on a site written with `--no-libraries`; several sources may be given, tried in order |
81
+ | `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 |
81
82
  | `frame`, `copyright`, `export`, `icon`, `reporting` | H5P's action bar under the content and its buttons |
82
83
  | `fullscreen=off` | No fullscreen button |
83
84
  | `preload=auto` | Start fetching media at once |
@@ -110,6 +111,25 @@ The checks prove where a message came from, not what it says: a package can post
110
111
  so treat relayed results as the learner's report, not as proof for a grade. Each statement
111
112
  carries `context.revision`, a fingerprint of the package build.
112
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
+
113
133
  ## As a library
114
134
 
115
135
  ```js
@@ -118,8 +138,27 @@ import { buildSite } from '@missing-elements/h5p-embed'
118
138
  await buildSite({ out: 'public/player', packages: ['https://cdn.example.org'] })
119
139
  ```
120
140
 
121
- `@missing-elements/h5p-embed/embed.js` exports `startEmbed({ librariesPack, packages })`, the
122
- page's script, for a site that builds the page into its own pipeline.
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
+ ```
123
162
 
124
163
  ## Licences
125
164
 
package/bin/h5p-embed.mjs CHANGED
@@ -16,6 +16,10 @@ Options:
16
16
  URL redirects to. Default: any https host.
17
17
  --ancestors <origins> only these sites may frame the page (frame-ancestors, sent as a header).
18
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
19
23
  --no-libraries leave out the 9.5 MB library pack that libraries=pack names
20
24
  --force write into a folder that is not empty, replacing only this tool's files
21
25
  -h, --help show this
@@ -28,6 +32,7 @@ try {
28
32
  options: {
29
33
  packages: { type: 'string', multiple: true },
30
34
  ancestors: { type: 'string', multiple: true },
35
+ 'default-libraries': { type: 'string' },
31
36
  'no-libraries': { type: 'boolean' },
32
37
  force: { type: 'boolean' },
33
38
  help: { type: 'boolean', short: 'h' },
@@ -61,6 +66,7 @@ try {
61
66
  libraries: !values['no-libraries'],
62
67
  packages: values.packages ?? null,
63
68
  ancestors: values.ancestors ?? null,
69
+ defaultLibraries: values['default-libraries'] ?? null,
64
70
  force: values.force ?? false
65
71
  })
66
72
  const below = relative(process.cwd(), site.out)
@@ -69,6 +75,7 @@ try {
69
75
  console.log(`Wrote ${folder}/ (${megabytes(site.size)}): the embed page, ${parts.join(', ')}.`)
70
76
  console.log(`Packages from: ${site.packages ? `this site, ${site.packages.join(', ')}` : 'any https host'}.`)
71
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'}.`)
72
79
  console.log(`
73
80
  Deploy the folder to a domain that holds nothing else — a separate registrable domain, not a
74
81
  subdomain of your site — then embed a package:
package/lib/build.mjs CHANGED
@@ -51,6 +51,47 @@ export function parseOrigin(value) {
51
51
  return url.origin
52
52
  }
53
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
+
54
95
  /** Origins given as a list, or as one string separated by commas or whitespace. */
55
96
  export function parseOrigins(values) {
56
97
  if (values == null) return null
@@ -169,15 +210,17 @@ async function occupied(dir) {
169
210
  * @param {boolean} [options.libraries] include the library pack, for `libraries=pack` (default true)
170
211
  * @param {string[] | string | null} [options.packages] the only origins packages may come from
171
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
172
214
  * @param {boolean} [options.force]
173
215
  */
174
- export async function buildSite({ out, libraries = true, packages = null, ancestors = null, force = false }) {
216
+ export async function buildSite({ out, libraries = true, packages = null, ancestors = null, defaultLibraries = null, force = false }) {
175
217
  if (!out) throw new EmbedError('No output folder given.')
176
218
  const target = resolve(out)
177
219
  const allowedPackages = parseOrigins(packages)
178
220
  const allowedAncestors = parseOrigins(ancestors)
179
221
  if (allowedPackages?.length === 0) throw new EmbedError('--packages names no origin.')
180
222
  if (allowedAncestors?.length === 0) throw new EmbedError('--ancestors names no origin.')
223
+ const fallbackLibraries = parseDefaultLibraries(defaultLibraries, { packages: allowedPackages, libraries })
181
224
  // Checked with --force too: a path that is a file is refused either way.
182
225
  if ((await occupied(target)) && !force) {
183
226
  throw new EmbedError(`${out} is not empty. Choose an empty folder, or pass --force to replace the files this writes.`)
@@ -205,7 +248,7 @@ export async function buildSite({ out, libraries = true, packages = null, ancest
205
248
  await writeFile(join(target, 'index.html'), html.replace('%CSP%', contentSecurityPolicy({ packages: allowedPackages, meta: true })))
206
249
  await writeFile(
207
250
  join(target, 'config.js'),
208
- `// Written by h5p-embed: what main.js hands the page.\nexport default ${JSON.stringify({ libraries: Boolean(pack), packages: allowedPackages })}\n`
251
+ `// Written by h5p-embed: what main.js hands the page.\nexport default ${JSON.stringify({ libraries: Boolean(pack), packages: allowedPackages, defaultLibraries: fallbackLibraries })}\n`
209
252
  )
210
253
 
211
254
  const csp = contentSecurityPolicy({ packages: allowedPackages, ancestors: allowedAncestors })
@@ -214,7 +257,16 @@ export async function buildSite({ out, libraries = true, packages = null, ancest
214
257
  await writeFile(join(target, 'vercel.json'), vercelConfig(rules))
215
258
  await writeFile(join(target, 'NOTICE.txt'), notice({ version, libraries: Boolean(pack) }))
216
259
 
217
- return { out: target, version, csp, libraries: Boolean(pack), packages: allowedPackages, ancestors: allowedAncestors, size: await sizeOf(target) }
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
+ }
218
270
  }
219
271
 
220
272
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@missing-elements/h5p-embed",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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": {
@@ -42,24 +42,23 @@
42
42
  "elearning",
43
43
  "cli"
44
44
  ],
45
- "scripts": {
46
- "test": "pnpm --filter @missing-elements/h5p-offline-player build && vitest run",
47
- "typecheck": "tsc -p tsconfig.json",
48
- "prepack": "node -e \"require('node:fs').copyFileSync('../../LICENSE', 'LICENSE')\"",
49
- "prepublishOnly": "pnpm typecheck && pnpm test"
50
- },
51
45
  "dependencies": {
52
- "@missing-elements/h5p-libraries": "workspace:^",
53
- "@missing-elements/h5p-offline-player": "workspace:^",
54
- "@missing-elements/h5p-runtime": "workspace:^"
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"
55
49
  },
56
50
  "devDependencies": {
57
51
  "@types/node": "^26.6.4",
52
+ "@zip.js/zip.js": "^2.23.0",
58
53
  "playwright": "^1.50.0",
59
54
  "typescript": "^7.0.2",
60
55
  "vitest": "^5.0.3"
61
56
  },
62
57
  "engines": {
63
58
  "node": ">=20"
59
+ },
60
+ "scripts": {
61
+ "test": "pnpm --filter @missing-elements/h5p-offline-player build && vitest run",
62
+ "typecheck": "tsc -p tsconfig.json"
64
63
  }
65
- }
64
+ }
package/site/embed.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * `h5p-embed` was told when it wrote the site, and the demo's `/embed` with the demo's copy of the
6
6
  * library pack.
7
7
  *
8
- * ?src=<package url>[&libraries=pack|hub|<url> …][&preload=auto][&xapi=<parent origin>]
8
+ * ?src=<package url>[&libraries=pack|hub|<url> …|none][&preload=auto][&xapi=<parent origin>]
9
9
  * [&frame][&copyright][&export][&icon][&reporting][&fullscreen=off]
10
10
  * [&activity-id=<IRI>][&custom-css=<stylesheet url>]
11
11
  *
@@ -13,7 +13,9 @@
13
13
  * embed code and its `h5p-resizer.js` use — so a page that already resizes h5p.org iframes
14
14
  * resizes this one without a change, and any other page gets `resizer.js` from this origin. xAPI
15
15
  * statements are relayed to the parent only when `xapi=` names the parent's origin, and they are
16
- * posted to that origin only.
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.
17
19
  */
18
20
 
19
21
  /** Where `libraries=hub` fetches from: the one hub host that sends CORS headers (see the player). */
@@ -22,13 +24,23 @@ const HUB_ORIGIN = 'https://api.h5p.org'
22
24
  /**
23
25
  * @param {object} [options]
24
26
  * @param {string | null} [options.librariesPack] the URL of a copy of `@missing-elements/h5p-libraries`
25
- * on this site, which `libraries=pack` names; without one, `pack` is refused
27
+ * on this site, which `libraries=pack` names; without one, `pack` means the hub where allowed
26
28
  * @param {string[] | null} [options.packages] the origins packages and library bundles may come
27
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`
28
38
  */
29
- export function startEmbed({ librariesPack = null, packages = null } = {}) {
39
+ export function startEmbed({ librariesPack = null, packages = null, defaultLibraries = null, runtime = null, askInOwnFrame = true } = {}) {
30
40
  const params = new URLSearchParams(location.search)
31
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
32
44
  const notice = document.querySelector('#notice')
33
45
  const loader = document.querySelector('#loader')
34
46
  const framed = window.parent !== window
@@ -53,9 +65,10 @@ export function startEmbed({ librariesPack = null, packages = null } = {}) {
53
65
  requestAnimationFrame(announce)
54
66
  }
55
67
 
56
- const refuse = (text) => {
68
+ const refuse = (text, code = 'refused') => {
57
69
  loader.hidden = true
58
70
  say(text, 'error')
71
+ post({ context: 'h5p-offline-player', action: 'error', code, message: text })
59
72
  }
60
73
 
61
74
  /* ---------------------------------------------------------------- sizing, upward */
@@ -93,6 +106,42 @@ export function startEmbed({ librariesPack = null, packages = null } = {}) {
93
106
  player.addEventListener('resize', () => requestAnimationFrame(announce))
94
107
  player.addEventListener('ready', () => requestAnimationFrame(announce))
95
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
+
96
145
  /* ---------------------------------------------------------------- xAPI, relayed on request */
97
146
 
98
147
  /** An origin, or nothing: the parameter has to be exactly what `event.origin` will read. */
@@ -126,6 +175,7 @@ export function startEmbed({ librariesPack = null, packages = null } = {}) {
126
175
  href: location.href,
127
176
  text: 'Open it on its own'
128
177
  })
178
+ post({ context: 'h5p-offline-player', action: 'error', code, message })
129
179
  return
130
180
  }
131
181
  // Once the content is up, a runtime error inside it is the content's business: it keeps
@@ -135,6 +185,7 @@ export function startEmbed({ librariesPack = null, packages = null } = {}) {
135
185
  return
136
186
  }
137
187
  say(message || code, 'error')
188
+ post({ context: 'h5p-offline-player', action: 'error', code, message })
138
189
  })
139
190
 
140
191
  player.addEventListener('statechange', (event) => {
@@ -169,6 +220,7 @@ export function startEmbed({ librariesPack = null, packages = null } = {}) {
169
220
  */
170
221
  const librarySources = (value) => {
171
222
  const sources = []
223
+ if (value === 'none') return { value: '' }
172
224
  for (const token of value.split(/\s+/).filter(Boolean)) {
173
225
  if (token === 'pack') {
174
226
  // The pack, with the hub behind it for what it lacks, where the hub may be reached: an
@@ -213,6 +265,7 @@ export function startEmbed({ librariesPack = null, packages = null } = {}) {
213
265
  if (libraries) player.setAttribute('libraries', libraries)
214
266
  if (params.get('preload') === 'auto') player.setAttribute('preload', 'auto')
215
267
  applyOptions()
268
+ startedAt = performance.now()
216
269
  player.setAttribute('src', value)
217
270
  }
218
271
 
@@ -225,6 +278,8 @@ export function startEmbed({ librariesPack = null, packages = null } = {}) {
225
278
  */
226
279
  const sharesOriginStorage = () => {
227
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
228
283
  try {
229
284
  return window.parent.location.origin === location.origin
230
285
  } catch {
@@ -236,7 +291,7 @@ export function startEmbed({ librariesPack = null, packages = null } = {}) {
236
291
 
237
292
  const src = params.get('src')?.trim()
238
293
  if (!src) {
239
- refuse('No package given. Add ?src=<url of a .h5p file> to the address.')
294
+ refuse('No package given. Add ?src=<url of a .h5p file> to the address.', 'no-src')
240
295
  return
241
296
  }
242
297
  const origin = urlOrigin(src)
@@ -248,7 +303,8 @@ export function startEmbed({ librariesPack = null, packages = null } = {}) {
248
303
  refuse(`This player does not play packages from ${origin}.`)
249
304
  return
250
305
  }
251
- const libraries = params.get('libraries')?.trim()
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()
252
308
  const sources = libraries ? librarySources(libraries) : { value: '' }
253
309
  if (sources.error) {
254
310
  refuse(sources.error)
package/site/main.js CHANGED
@@ -6,5 +6,6 @@ import { startEmbed } from './embed.js'
6
6
 
7
7
  startEmbed({
8
8
  librariesPack: config.libraries ? new URL('./libraries.h5p', import.meta.url).href : null,
9
- packages: config.packages
9
+ packages: config.packages,
10
+ defaultLibraries: config.defaultLibraries
10
11
  })