From 16f6857f3dafdade8879b3789e33b3a0794814d1 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Mon, 9 Nov 2026 10:26:47 +0200 Subject: [PATCH] feat: the pages and the preview's report follow player 1.5 The pages described the player of mid-October. For player 1.5.1, embed 1.7.0 and library pack 1.1.0 on the player origin: - The report reader takes libraryBundle.refreshed and downgradedLibraries. A package that carries older libraries says which play at the pack's newer version, where it said "exported without its libraries"; content made for newer libraries than the pack's gets a warning and a guide section. - H5P's own buttons follow the language the package declares (player 1.1); &lang= only covers a package that names none. Home FAQ and /player. - The library pack is one file per library, downloaded as a package needs it, not a 14 MB bundle: guides and the report line. - h5p-player-setup.md is gone from the player's repository: its links go to docs/setup.md and docs/troubleshooting.md. - /player lists h5p-author, the SCORM zip, verify --preview and the VS Code extension; the AI FAQ names the h5p-author skill. - AR Scavenger is not supported (player 1.1.2): troubleshooting says so, and the privacy guide no longer gives it as a working use of the camera. - Hosting: Access-Control-Expose-Headers gains ETag (player 1.5). Co-Authored-By: Claude Opus 5.5 --- docs/hosting-packages.md | 18 +++++--- docs/privacy-and-security.md | 8 ++-- docs/troubleshooting.md | 8 +++- e2e/site.spec.ts | 7 ++- index.html | 10 ++--- player.html | 29 +++++++++---- src/report.test.ts | 82 ++++++++++++++++++++++++++++++++---- src/report.ts | 79 +++++++++++++++++++++++++++++----- 8 files changed, 195 insertions(+), 46 deletions(-) diff --git a/docs/hosting-packages.md b/docs/hosting-packages.md index bccf1a8..ea14d1d 100644 --- a/docs/hosting-packages.md +++ b/docs/hosting-packages.md @@ -87,22 +87,30 @@ For large packages, also support ranged requests and expose their headers: ```text Accept-Ranges: bytes -Access-Control-Expose-Headers: Content-Range, Accept-Ranges, Content-Length +Access-Control-Expose-Headers: Content-Range, Accept-Ranges, Content-Length, ETag ``` With them, a large package starts playing after a few kilobytes. Without them, it is downloaded whole before anything plays, which still works. A simple range such as `bytes=0-99` needs no CORS preflight, so a host that ignores `OPTIONS` requests is not a problem. -GitHub Pages meets both requirements as it comes. The player's [setup guide](https://github.com/missing-elements/h5p-offline-player/blob/main/h5p-player-setup.md#requirements) describes exactly what it checks. +An `ETag` or `Last-Modified` header lets the player tell a file republished at the same address from the old one, and a visitor who comes back reads almost nothing of the package again. The frame can read `Last-Modified` from any host, and an `ETag` only when the host lists it in `Access-Control-Expose-Headers`, as above. + +GitHub Pages meets both requirements as it comes. The player's [setup guide](https://github.com/missing-elements/h5p-offline-player/blob/main/docs/setup.md#requirements) lists exactly what it needs. ## Packages and library files An H5P package normally includes the libraries it needs. Exports from H5P.com and h5p.org usually contain only `content/` and expect the site they came from to provide the libraries. -Embed My plays those too. The player keeps a bundle of the H5P hub's libraries for every content type the hub serves, about 14 MB, served from the player origin and downloaded once per browser, and a package that ships without its libraries takes them from there. It asks no other site for libraries. See [Privacy and security](privacy-and-security.md). +Embed My plays those too. The player keeps a pack of the H5P hub's libraries for every content type the hub serves, one file per library, served from the player origin. A package that ships without its libraries downloads only the ones it needs from there, and the browser keeps each one for the next package that uses it. It asks no other site for libraries. See [Privacy and security](privacy-and-security.md). + +A package written for an older version of a library plays too, and so does one that carries an older version. The pack has the newest version of each library, the player plays that one, and before the activity starts it converts the content with the library's own upgrade steps, as Moodle, WordPress and the other H5P plugins do when a site installs a newer version. If a step fails, the activity plays as it was written. The lines under the preview name the libraries that played newer than the package carries them. To play them exactly as exported instead, add `&refresh-libraries=off` to the end of the `src` in the snippet. + +If the preview still reports missing libraries, the package needs a library the pack does not carry: a new major version the H5P hub does not serve yet, or a library written for a single site. Export the activity as a complete package, including libraries, when your authoring tool offers that option. The player's [libraries guide](https://github.com/missing-elements/h5p-offline-player/blob/main/docs/libraries.md) explains how library sources work. + +### Content made for newer libraries -A package written for an older version of a library plays too. The bundle carries the newest version of each library, and before the activity starts the player converts the content with that library's own upgrade steps, as Moodle, WordPress and the other H5P plugins do when a site installs a newer version. If a step fails, the activity plays as it was written. +H5P.com gets a library's new version weeks before the H5P hub serves it, so an activity made there in those weeks can name a version that nobody else has yet. The player plays it at the newest version of that library the pack has, and the lines under the preview say which. -If the preview still reports missing libraries, the package needs a library the bundle does not carry. Usually it is newer than the bundle: the bundle is rebuilt from the H5P hub every week, and a later release usually carries it. Less often it is a library written for a single site. Export the activity as a complete package, including libraries, when your authoring tool offers that option. The player's [libraries guide](https://github.com/missing-elements/h5p-offline-player/blob/main/docs/libraries.md) explains how library sources work. +Most new versions only add fields, which the older version ignores. Where one changed the shape of the content, a part of the activity can be missing or drawn wrong, so play through every part of it. To refuse such a package instead, add `&downgrade-libraries=off` to the end of the `src` in the snippet. The pack follows the H5P hub, so once the hub serves the newer version, a later release of the player plays the activity as made. ## How big can a package be? diff --git a/docs/privacy-and-security.md b/docs/privacy-and-security.md index 6170653..6b2227a 100644 --- a/docs/privacy-and-security.md +++ b/docs/privacy-and-security.md @@ -12,11 +12,11 @@ Embed only packages you trust, as you would any script. ## When the frame is opened on its own -The frame's address can also be opened directly, as a link, which [Troubleshooting](troubleshooting.md) suggests for some app browsers. Opened that way, a package from another site would share the player origin's own storage with every other package opened like that, so the page plays it as data: none of the package's own scripts run, its libraries come from the player's bundle, and its content is checked against those libraries' field definitions before it is shown, as the H5P plugins for Moodle, WordPress and Drupal check an upload. A package that cannot play without its own copy of a library waits for a click, and the page names the site it comes from before running it. +The frame's address can also be opened directly, as a link, which [Troubleshooting](troubleshooting.md) suggests for some app browsers. Opened that way, a package from another site would share the player origin's own storage with every other package opened like that, so the page plays it as data: none of the package's own scripts run, its libraries come from the player's own library pack, and its content is checked against those libraries' field definitions before it is shown, as the H5P plugins for Moodle, WordPress and Drupal check an upload. A package that cannot play without its own copy of a library waits for a click, and the page names the site it comes from before running it. ## What the frame can do to your page -The snippet gives the frame fullscreen, and the microphone and camera for the content types that record or film: Audio Recorder, Speak the Words and AR Scavenger. The browser asks the visitor before the frame uses either, and for an activity that does neither you can take `microphone; camera` out of the `allow` attribute. The frame gets no location. It cannot read or change your page. But browsers let any iframe navigate the whole tab once the visitor has clicked inside it, so a hostile package could send a visitor who clicks on it to another address. That is one more reason to embed only packages you trust. A site that wants to rule it out can add `sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads"` to the iframe; test the activity afterwards, because a sandbox also stops the frame's own "Open it on its own" link. +The snippet gives the frame fullscreen, and the microphone and camera. Audio Recorder and Speak the Words record with the microphone; the camera is for AR Scavenger, which the player [does not run](troubleshooting.md#an-ar-scavenger-activity-shows-only-its-title). The browser asks the visitor before the frame uses either, and for an activity that does neither you can take `microphone; camera` out of the `allow` attribute. The frame gets no location. It cannot read or change your page. But browsers let any iframe navigate the whole tab once the visitor has clicked inside it, so a hostile package could send a visitor who clicks on it to another address. That is one more reason to embed only packages you trust. A site that wants to rule it out can add `sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads"` to the iframe; test the activity afterwards, because a sandbox also stops the frame's own "Open it on its own" link. The script line, `h5p-resizer.js`, is the one part of the snippet that runs on your page itself, with the same rights as your own scripts. It is about sixty lines, it only sets an iframe's height when that frame reports it, and you can read it at the address in the snippet. Leave it out and the frame keeps the height in the `style` attribute. @@ -24,7 +24,7 @@ The script line, `h5p-resizer.js`, is the one part of the snippet that runs on y No learner records. The embed page never turns on the player's resume feature, so answers and progress are not saved anywhere, and reloading the page starts the activity over. Embed My stores nothing on its side either. -The visitor's browser does cache the package's files, and keeps a list of the packages played there with each one's address, title, licence details, language and when it was last played (Cache Storage and IndexedDB on the player's origin, kept separate per embedding site), so the next load is faster. The list shows which activities were opened on that device. Clearing the browser's site data removes both. +The visitor's browser does cache the package's files and the files of the player's library pack it used, and keeps a list of the packages played there with each one's address, title, licence details, language and when it was last played (Cache Storage and IndexedDB on the player's origin, kept separate per embedding site), so the next load is faster. The list shows which activities were opened on that device. Clearing the browser's site data removes both. ## Cookies and tracking @@ -34,7 +34,7 @@ The pages and the script are served by GitHub Pages. Like any web host, GitHub s H5P content itself may contact services named by the package, such as YouTube, Vimeo, Google Fonts, MathJax, or an organization-hosted media service. Review a package and its third-party content before publishing it. -The frame asks no one else for libraries. A package exported without its libraries takes them from a bundle on the player origin, and from nowhere else. +The frame asks no one else for libraries. A package exported without its libraries takes the ones it needs from the library pack on the player origin, and from nowhere else. ## Data protection diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e828e9e..392492a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -13,7 +13,11 @@ Check that: ## The activity works in my LMS but not in Embed My -An export without its libraries normally plays anyway: the player takes them from its own bundle of the H5P hub's libraries, and upgrades content written for older library versions before it starts. If the preview still names missing libraries, the package needs one the bundle does not carry: usually a version newer than the bundle, less often one written for a single site. Re-export a complete package, including libraries. See [Packages and library files](hosting-packages.md#packages-and-library-files). +An export without its libraries normally plays anyway: the player takes the ones it needs from its own pack of the H5P hub's libraries, and upgrades content written for older library versions before it starts. Content made for a newer version than the pack has plays at the newest one it has, and the preview says so. If the preview still names missing libraries, the package needs one the pack does not carry: a new major version, or a library written for a single site. Re-export a complete package, including libraries. See [Packages and library files](hosting-packages.md#packages-and-library-files). + +## An AR Scavenger activity shows only its title + +The player does not run AR Scavenger. It writes its 3D and AR scripts into the page as inline scripts, which the frame's security policy refuses, so the activity shows its title and no scene, here and wherever the player runs. ## The activity is cut off or leaves blank space @@ -37,7 +41,7 @@ The site's Content Security Policy must allow `frame-src https://embed-my.github ## Other problems -The player's [troubleshooting table](https://github.com/missing-elements/h5p-offline-player/blob/main/h5p-player-setup.md#troubleshooting) covers error codes and less common symptoms in detail, such as formulas shown as raw LaTeX or a missing copyright button. +The player's [troubleshooting guide](https://github.com/missing-elements/h5p-offline-player/blob/main/docs/troubleshooting.md) covers error codes and less common symptoms in detail, such as formulas shown as raw LaTeX or a missing copyright button. ## Reporting a problem diff --git a/e2e/site.spec.ts b/e2e/site.spec.ts index ab21ea6..af465c0 100644 --- a/e2e/site.spec.ts +++ b/e2e/site.spec.ts @@ -49,7 +49,10 @@ test('the home page previews a package and writes its snippet', async ({ page }) await expect(checks.first()).toContainText('Do you know what just happened? · Question Set · CC0 1.0 · by missing-elements') await expect(checks.filter({ hasText: 'Reachable from a browser' })).toHaveAttribute('data-tone', 'ok') await expect(checks.filter({ hasText: /Streams in place|Downloaded whole/ })).toContainText('MB') - await expect(checks.filter({ hasText: 'Carries its libraries' })).toHaveAttribute('data-tone', 'ok') + // The quiz carries its libraries. A player that plays them from its own pack (player origin with + // `pack`) plays the older ones at the pack's newer versions and says so, which is worth knowing, + // not a fault; one that attaches a whole bundle plays them as exported. + await expect(checks.filter({ hasText: 'Carries its libraries' })).toHaveAttribute('data-tone', /^(ok|info)$/) await expect(checks.filter({ hasText: /Ready in \d+\.\d s/ })).toHaveCount(1) expect(problems, problems.join('\n')).toEqual([]) @@ -62,7 +65,7 @@ test('the lines under the preview say where missing libraries came from', async await expect(page.locator('#stage')).toHaveAttribute('data-state', 'ready', { timeout: 60_000 }) const line = page.locator('#checks li').filter({ hasText: 'Exported without its libraries' }) await expect(line).toHaveAttribute('data-tone', 'info') - await expect(line).toContainText("the player's bundle") + await expect(line).toContainText("the player added the ones it needs from its library pack") await expect(line.getByRole('link', { name: 'About library files' })).toHaveAttribute('href', '/docs/hosting-packages#packages-and-library-files') }) diff --git a/index.html b/index.html index c3cfd93..77cf850 100644 --- a/index.html +++ b/index.html @@ -324,7 +324,7 @@

If it plays here, will it play in my LMS?
-

Very likely, not certainly. The player runs the standard H5P runtime with the H5P hub's current libraries, and upgrades content written for older library versions first, as an LMS does when it installs newer ones. Moodle, WordPress or another LMS may run older library versions or changes of their own, and a package made for one site can need a library only that site has. A failure here is the more useful result: a link the browser cannot reach, a package that is not an archive, or libraries that nobody serves will fail in most places.

+

Very likely, not certainly. The player runs the standard H5P runtime with the H5P hub's current libraries, and upgrades content written for older library versions first, as an LMS does when it installs newer ones. Moodle, WordPress or another LMS may run older library versions or changes of their own, and a package made for one site can need a library only that site has. Content made for a library version newer than the hub's, as H5P.com sometimes has for a few weeks, plays at the newest version there is, and the lines under the preview say so. A failure here is the more useful result: a link the browser cannot reach, a package that is not an archive, or libraries that nobody serves will fail in most places.

@@ -358,8 +358,8 @@

Can H5P's own buttons be in my language?
-

Yes, in more than fifty languages. H5P's own buttons and dialogs, such as Fullscreen, Rights of use and the licence names, follow a lang on the frame's address: add &lang=de, or &lang=pt-BR, to the end of the src in the snippet. Without it they are in English. The frame cannot read your page's language itself, because it runs on another site.

-

The activity's own text is in whatever language it was written in. The translations come from Moodle's language packs, so a few strings may stay in English.

+

Yes, in the activity's own language, in any of more than fifty. H5P's own buttons and dialogs, such as Fullscreen, Rights of use and the licence names, follow the language the package declares in its h5p.json, which H5P's own exports write, so they match the activity's text whatever language your page is in.

+

For a package that declares no language, add &lang=de, or &lang=pt-BR, to the end of the src in the snippet; without it they are in English. The translations come from Moodle's language packs, so a few strings may stay in English.

@@ -369,9 +369,9 @@

- Can an AI assistant check a package for me? + Can an AI assistant check a package for me, or make one?
-

Yes. The player's repository ships agent skills for Claude Code, Cursor, Copilot, Codex and the rest. h5p-verify plays a package in a real H5P runtime in a headless browser and reports start-up, errors, missing libraries and a screenshot: the same check as this page, without a link. Two companions make a package's video start at once, and put the player on a site of your own.

+

Yes. The player's repository ships agent skills for Claude Code, Cursor, Copilot, Codex and the rest, and none needs an API key or an H5P server. h5p-verify plays a package in a real H5P runtime in a headless browser and reports start-up, errors, missing libraries and a screenshot: the same check as this page, without a link. h5p-author turns a prompt or a lesson into an activity of any of 28 content types, checks it and opens it for you to play. The others make a package's video start at once, put the player on a site of your own, and turn a package into a SCORM zip for an LMS.

npx skills add missing-elements/h5p-offline-player --skill h5p-verify
diff --git a/player.html b/player.html index 8eaff5b..ec94f1b 100644 --- a/player.html +++ b/player.html @@ -72,10 +72,11 @@

What it does

  • Plays any .h5p package from a URL, or from a file picked on the device, with no upload.
  • Starts a large package after a few kilobytes on a host that answers Range requests, and streams its video.
  • -
  • Plays exports that ship without their libraries, from a pack of the H5P hub's libraries, and upgrades content written for older library versions before it starts, with each library's own upgrade steps.
  • -
  • Shows H5P's own toolbar on request: rights of use, download, fullscreen. Its buttons and dialogs come in more than fifty languages, following the page's language.
  • -
  • Emits xAPI statements and the content's saved state as events, for a page or an LMS to collect.
  • -
  • Ships as one web component, <h5p-player>, plus a worker; the H5P runtime is a package of its own.
  • +
  • Plays exports that ship without their libraries from a pack of the H5P hub's libraries, downloading only the ones a package needs. A package that carries older versions plays the pack's newer ones, its content upgraded first with each library's own upgrade steps, and content made for a version newer than the hub's plays at the newest one there is.
  • +
  • Plays packages you did not make as data, with untrusted: none of their own code runs, and their content is checked against its libraries' field definitions, as the H5P plugins check an upload.
  • +
  • Shows H5P's own toolbar on request: rights of use, download, fullscreen. Its buttons and dialogs come in more than fifty languages, in the language the package declares, so they match its text.
  • +
  • Emits xAPI statements and the content's saved state as events, for a page or an LMS to collect, and runs as a cmi5 unit or, packed by h5p-embed, as a SCORM 1.2 or 2004 activity.
  • +
  • Ships as one web component, <h5p-player>, plus its workers; the H5P runtime is a package of its own.

Where it is

@@ -86,7 +87,7 @@

Where it is

Setup guide - h5p-player-setup.md: with a bundler, with no build step, or as an iframe + Setting up the player: with a bundler, with no build step, or as an embed page in an iframe The player on npm @@ -102,7 +103,15 @@

Where it is

Self-hosted embeds - @missing-elements/h5p-embed (MIT), the embed page as a static site for a domain of your own + @missing-elements/h5p-embed (MIT), the embed page as a static site for a domain of your own, and a package as a SCORM zip for an LMS + + + Making packages + @missing-elements/h5p-author (MIT), a package of any hub content type from a short JSON source + + + VS Code extension + H5P Preview, which plays .h5p files in an editor tab, offline; Cursor and VSCodium install it from Open VSX Demo site @@ -120,8 +129,10 @@

Where it is

Tools that come with it

    -
  • npx @missing-elements/h5p-verify course.h5p plays a package in a real browser runtime and reports missing libraries, start-up errors and a screenshot, before you share it.
  • +
  • npx @missing-elements/h5p-verify course.h5p plays a package in a real browser runtime and reports missing libraries, start-up errors and a screenshot, before you share it. With --preview it opens the package in the real player in your own browser instead, for a person to play through.
  • npx @missing-elements/h5p-normalize course.h5p rewrites a package once so that its video starts at once instead of after the whole file has downloaded.
  • +
  • npx @missing-elements/h5p-author build rivers.json rivers.h5p builds a playable package from a short JSON source that holds only what differs from the content type's defaults, checking every field as H5P's editor would. The h5p-author agent skill writes that source from a prompt or a lesson.
  • +
  • npx @missing-elements/h5p-embed --scorm course.h5p writes a package as a SCORM zip that reports the score, the time and the learner's place to an LMS such as Moodle; its page does the same in the browser, with nothing uploaded.

Run it yourself

@@ -131,10 +142,10 @@

Run it yourself

and deploy it to a domain of your own. Snippets then point at that domain, and no third party sits between your learners and your content. It can also limit which hosts packages come from and which sites may frame it.

-
npx @missing-elements/h5p-embed h5p-player
+
npx @missing-elements/h5p-embed@latest h5p-player

To put the player straight into your own pages instead, the - setup guide + setup guide covers each way to do it. Your packages stay as they are either way. Embed My's own player origin is h5p-embed's page.

diff --git a/src/report.test.ts b/src/report.test.ts index 22adab1..3b87936 100644 --- a/src/report.test.ts +++ b/src/report.test.ts @@ -7,9 +7,22 @@ const quiz: PackageReport = { source: { type: 'range-http', size: 4_324_039 }, metadata: { title: 'Do you know what just happened?', license: 'CC0 1.0', authors: ['missing-elements'], mainLibrary: 'H5P.QuestionSet' }, libraryBundle: null, + downgradedLibraries: [], elapsedMs: 2140 } +/** The libraries the sample quiz carries older than pack 1.1.0, as the player names them. */ +const REFRESHED = [ + { library: 'H5P.Blanks', from: '1.14.13', to: '1.14.37' }, + { library: 'H5P.DragText', from: '1.10.17', to: '1.10.50' }, + { library: 'H5P.JoubelUI', from: '1.3.19', to: '1.3.45' }, + { library: 'H5P.MultiChoice', from: '1.16.14', to: '1.16.27' }, + { library: 'H5P.Question', from: '1.5.15', to: '1.5.53' }, + { library: 'H5P.QuestionSet', from: '1.20.31', to: '1.21.14' }, + { library: 'H5P.TrueFalse', from: '1.8.11', to: '1.8.21' }, + { library: 'H5P.Video', from: '1.6.66', to: '1.6.79' } +] + describe('readPlayerMessage', () => { it('reads a report, holding each field to its shape', () => { expect( @@ -18,7 +31,13 @@ describe('readPlayerMessage', () => { action: 'report', source: { type: 'chunked', size: null }, metadata: { title: ' Quiz ', license: 'CC BY', licenseVersion: '4.0', authors: ['Ada', 7, ''], mainLibrary: 'H5P.QuestionSet', extra: 1 }, - libraryBundle: { url: `${PLAYER}/assets/libraries-abc.h5p`, origin: PLAYER, fromCache: 'yes' }, + libraryBundle: { + url: `${PLAYER}/assets/`, + origin: PLAYER, + fromCache: 'yes', + refreshed: [{ library: 'H5P.Video', from: '1.6.66', to: '1.6.79' }, { library: 'H5P.Blanks', from: 1 }, 'H5P.Text'] + }, + downgradedLibraries: [{ library: 'H5P.MultiChoice', from: '1.17', to: '1.16.27' }, null], elapsedMs: 1234.5 }) ).toEqual({ @@ -26,16 +45,26 @@ describe('readPlayerMessage', () => { report: { source: { type: 'chunked', size: null }, metadata: { title: 'Quiz', license: 'CC BY', licenseVersion: '4.0', authors: ['Ada'], mainLibrary: 'H5P.QuestionSet' }, - libraryBundle: { url: `${PLAYER}/assets/libraries-abc.h5p`, origin: PLAYER, fromCache: false }, + libraryBundle: { url: `${PLAYER}/assets/`, origin: PLAYER, fromCache: false, refreshed: [{ library: 'H5P.Video', from: '1.6.66', to: '1.6.79' }] }, + downgradedLibraries: [{ library: 'H5P.MultiChoice', from: '1.17', to: '1.16.27' }], elapsedMs: 1234.5 } }) }) + it('reads a report from a page older than player 0.10, which names no versions', () => { + const read = readPlayerMessage({ + context: 'h5p-offline-player', + action: 'report', + libraryBundle: { url: `${PLAYER}/assets/libraries-abc.h5p`, origin: PLAYER, fromCache: false } + }) + expect(read).toMatchObject({ report: { libraryBundle: { refreshed: [] }, downgradedLibraries: [] } }) + }) + it('reads a report that knows nothing', () => { expect(readPlayerMessage({ context: 'h5p-offline-player', action: 'report', source: { type: 'ftp' } })).toEqual({ action: 'report', - report: { source: null, metadata: null, libraryBundle: null, elapsedMs: null } + report: { source: null, metadata: null, libraryBundle: null, downgradedLibraries: [], elapsedMs: null } }) }) @@ -76,17 +105,20 @@ describe('describePackage', () => { }) it('says where the libraries came from, and whether that left the player origin', () => { - const bundle = { url: `${PLAYER}/assets/libraries-abc.h5p`, origin: PLAYER, fromCache: false } + const bundle = { url: `${PLAYER}/assets/`, origin: PLAYER, fromCache: false, refreshed: [] } const own = describePackage({ ...quiz, libraryBundle: bundle }, { playerOrigin: PLAYER }) - expect(own.find((line) => line.text.startsWith('Exported without'))).toMatchObject({ tone: 'info' }) - expect(own.find((line) => line.text.startsWith('Exported without'))?.text).toContain('about 14 MB') + expect(own.find((line) => line.text.startsWith('Exported without'))).toMatchObject({ + tone: 'info', + text: "Exported without its libraries: the player added the ones it needs from its library pack, downloaded once per browser and kept.", + link: { href: '/docs/hosting-packages#packages-and-library-files' } + }) const cached = describePackage({ ...quiz, libraryBundle: { ...bundle, fromCache: true } }, { playerOrigin: PLAYER }) expect(cached.find((line) => line.text.startsWith('Exported without'))?.text).toContain('already had') // Only an address that names its own `libraries=` source gets here: the player has no other. const elsewhere = describePackage( - { ...quiz, libraryBundle: { url: 'https://cdn.example.org/libraries.h5p', origin: 'https://cdn.example.org', fromCache: false } }, + { ...quiz, libraryBundle: { url: 'https://cdn.example.org/libraries.h5p', origin: 'https://cdn.example.org', fromCache: false, refreshed: [] } }, { playerOrigin: PLAYER } ) const line = elsewhere.find((item) => item.text.startsWith('Exported without')) @@ -94,8 +126,42 @@ describe('describePackage', () => { expect(line?.text).toContain('https://cdn.example.org') }) + it('says which of the libraries a package carries played at the pack’s newer version', () => { + const bundle = { url: `${PLAYER}/assets/`, origin: PLAYER, fromCache: false, refreshed: REFRESHED } + const lines = describePackage({ ...quiz, libraryBundle: bundle }, { playerOrigin: PLAYER }) + expect(lines.some((line) => line.text.startsWith('Exported without'))).toBe(false) + expect(lines.find((line) => line.text.startsWith('Carries its libraries'))).toEqual({ + tone: 'info', + text: "Carries its libraries, and plays 8 of them at the newer version in the player's library pack: Blanks 1.14.13 → 1.14.37, Drag Text 1.10.17 → 1.10.50 and 6 more.", + link: { href: '/docs/hosting-packages#packages-and-library-files', text: 'About library files' } + }) + + const one = describePackage({ ...quiz, libraryBundle: { ...bundle, refreshed: REFRESHED.slice(-1) } }, { playerOrigin: PLAYER }) + expect(one.find((line) => line.text.startsWith('Carries'))?.text).toBe( + "Carries its libraries, and plays one of them at the newer version in the player's library pack: Video 1.6.66 → 1.6.79." + ) + + const elsewhere = describePackage( + { ...quiz, libraryBundle: { ...bundle, url: 'https://cdn.example.org/parts/', origin: 'https://cdn.example.org' } }, + { playerOrigin: PLAYER } + ) + expect(elsewhere.find((line) => line.text.startsWith('Carries'))).toMatchObject({ tone: 'warn', text: expect.stringContaining('from https://cdn.example.org, a library source on another site') }) + }) + + it('warns about content made for newer libraries than the player has, after the libraries line', () => { + const downgradedLibraries = [{ library: 'H5P.MultiChoice', from: '1.17', to: '1.16.27' }] + const lines = describePackage({ ...quiz, downgradedLibraries }, { playerOrigin: PLAYER }) + const index = lines.findIndex((line) => line.text.startsWith('Made for newer libraries')) + expect(lines[index - 1].text).toContain('Carries its libraries') + expect(lines[index]).toEqual({ + tone: 'warn', + text: 'Made for newer libraries than the player has: Multi Choice 1.17, played at 1.16.27. Most newer versions only add fields, but a part can be missing or drawn wrong, so check every part.', + link: { href: '/docs/hosting-packages#content-made-for-newer-libraries', text: 'About newer libraries' } + }) + }) + it('leaves out what it was not told', () => { - const lines = describePackage({ source: null, metadata: null, libraryBundle: null, elapsedMs: null }, { playerOrigin: PLAYER }) + const lines = describePackage({ source: null, metadata: null, libraryBundle: null, downgradedLibraries: [], elapsedMs: null }, { playerOrigin: PLAYER }) expect(lines).toEqual([{ tone: 'ok', text: 'Carries its libraries: nothing else had to be fetched.' }]) }) diff --git a/src/report.ts b/src/report.ts index 257cfad..5700b87 100644 --- a/src/report.ts +++ b/src/report.ts @@ -7,6 +7,13 @@ * reach visitors, what did it cost, and what does the package say it is. */ +/** A library that played at another version than the package carries or names: `H5P.Video`, `1.6.66`, `1.6.79`. */ +export interface LibraryVersion { + library: string + from: string + to: string +} + export interface PackageReport { /** How the bytes were reached: streamed in place, downloaded whole, or picked from disk. */ source: { type: 'range-http' | 'chunked' | 'file'; size: number | null } | null @@ -18,8 +25,14 @@ export interface PackageReport { authors?: string[] mainLibrary?: string } | null - /** Where libraries the package did not carry came from; `null` when it carried its own. */ - libraryBundle: { url: string; origin: string; fromCache: boolean } | null + /** + * Where libraries came from that the package did not carry, or newer versions of those it did: + * `refreshed` lists the libraries it carries that played at the pack's newer version (player + * 0.10). `null` when it played only its own. + */ + libraryBundle: { url: string; origin: string; fromCache: boolean; refreshed: LibraryVersion[] } | null + /** Libraries that played at an older version than the content names, as no source had it (player 1.2). */ + downgradedLibraries: LibraryVersion[] /** From the package being set to the content being up, on the page's connection. */ elapsedMs: number | null } @@ -32,6 +45,18 @@ const optionalString = (value: unknown): string | undefined => (typeof value === const sizeOf = (value: unknown): number | null => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null) +/** A list of `{ library, from, to }`, keeping only the entries that give all three as text. */ +const versionsOf = (value: unknown): LibraryVersion[] => + Array.isArray(value) + ? value.flatMap((item) => { + if (!isRecord(item)) return [] + const library = optionalString(item.library) + const from = optionalString(item.from) + const to = optionalString(item.to) + return library && from && to ? [{ library, from, to }] : [] + }) + : [] + /** A message from the player's page, read as it documents them, or `null` for anything else. */ export function readPlayerMessage(data: unknown): PlayerMessage | null { if (!isRecord(data) || data.context !== 'h5p-offline-player') return null @@ -64,11 +89,14 @@ export function readPlayerMessage(data: unknown): PlayerMessage | null { if (isRecord(data.libraryBundle)) { const url = optionalString(data.libraryBundle.url) const origin = optionalString(data.libraryBundle.origin) - if (url && origin) libraryBundle = { url, origin, fromCache: data.libraryBundle.fromCache === true } + if (url && origin) { + libraryBundle = { url, origin, fromCache: data.libraryBundle.fromCache === true, refreshed: versionsOf(data.libraryBundle.refreshed) } + } } + const downgradedLibraries = versionsOf(data.downgradedLibraries) const elapsedMs = sizeOf(data.elapsedMs) - return { action: 'report', report: { source, metadata, libraryBundle, elapsedMs } } + return { action: 'report', report: { source, metadata, libraryBundle, downgradedLibraries, elapsedMs } } } /* ------------------------------------------------------------------ the lines */ @@ -117,10 +145,19 @@ export function licenceName(license: string, version?: string): string { return version && !license.includes(version) ? `${license} ${version}` : license } +/** Two libraries by name and version, the rest counted: `Blanks 1.14.13 → 1.14.37, Drag Text 1.10.17 → 1.10.50 and 6 more`. */ +function libraryList(versions: LibraryVersion[], words: (version: LibraryVersion) => string): string { + const named = versions.slice(0, 2).map(words) + const rest = versions.length - named.length + return rest > 0 ? `${named.join(', ')} and ${rest} more` : named.join(' and ') +} + +const LIBRARY_FILES = { href: `${HOSTING}#packages-and-library-files`, text: 'About library files' } + /** The lines for a package that plays, in the order a visitor reads them: what it is, then how it got here. */ export function describePackage(report: PackageReport, { playerOrigin }: { playerOrigin: string }): Check[] { const checks: Check[] = [] - const { source, metadata, libraryBundle, elapsedMs } = report + const { source, metadata, libraryBundle, downgradedLibraries, elapsedMs } = report if (metadata) { const parts = [ @@ -149,21 +186,41 @@ export function describePackage(report: PackageReport, { playerOrigin }: { playe } } + const own = libraryBundle?.origin === playerOrigin + const elsewhere = libraryBundle && !own ? `${libraryBundle.origin}, a library source on another site, which every visitor's browser contacts on its first play` : '' if (!libraryBundle) { checks.push({ tone: 'ok', text: 'Carries its libraries: nothing else had to be fetched.' }) - } else if (libraryBundle.origin === playerOrigin) { + } else if (libraryBundle.refreshed.length) { + // A package that carries its libraries, some of them older than the pack's (player 0.10). + const { refreshed } = libraryBundle + const list = libraryList(refreshed, ({ library, from, to }) => `${contentTypeName(library)} ${from} → ${to}`) + const count = refreshed.length === 1 ? 'one of them' : `${refreshed.length} of them` + checks.push({ + tone: own ? 'info' : 'warn', + text: own + ? `Carries its libraries, and plays ${count} at the newer version in the player's library pack: ${list}.` + : `Carries its libraries, and plays ${count} at a newer version from ${elsewhere}: ${list}.`, + link: LIBRARY_FILES + }) + } else if (own) { checks.push({ tone: 'info', text: libraryBundle.fromCache - ? "Exported without its libraries: they came from the player's bundle, from the copy this browser already had." - : "Exported without its libraries: they came from the player's bundle, about 14 MB, downloaded once per browser and kept.", - link: { href: `${HOSTING}#packages-and-library-files`, text: 'About library files' } + ? "Exported without its libraries: the player added the ones it needs from its library pack, from the copy this browser already had." + : "Exported without its libraries: the player added the ones it needs from its library pack, downloaded once per browser and kept.", + link: LIBRARY_FILES }) } else { + checks.push({ tone: 'warn', text: `Exported without its libraries: they came from ${elsewhere}.`, link: LIBRARY_FILES }) + } + + if (downgradedLibraries.length) { + // Content made for a newer minor version than any source has, played at the newest older one (player 1.2). + const list = libraryList(downgradedLibraries, ({ library, from, to }) => `${contentTypeName(library)} ${from}, played at ${to}`) checks.push({ tone: 'warn', - text: `Exported without its libraries: they came from ${libraryBundle.origin}, a library source on another site, which every visitor's browser contacts on its first play.`, - link: { href: `${HOSTING}#packages-and-library-files`, text: 'About library files' } + text: `Made for newer libraries than the player has: ${list}. Most newer versions only add fields, but a part can be missing or drawn wrong, so check every part.`, + link: { href: `${HOSTING}#content-made-for-newer-libraries`, text: 'About newer libraries' } }) }