From ad710ef49087ae0ccabc587181520bf6e5a5a6db Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 18 Oct 2026 22:30:25 +0300 Subject: [PATCH 1/3] feat!: vendor the H5P core from h5p-php-library and retire h5p-standalone The runtime took the core from h5p-standalone, which copies it about once a year and records no upstream revision, and its loader walked every library.json from the frame. Now `pnpm core:sync` vendors h5p-php-library at a commit (cb64a1f, 2026-08-11, core API 1.28) into packages/runtime/vendor/h5p-core/, taking what and in what order from upstream's own H5PCore::$scripts and $styles; the build joins and minifies the scripts into frame.bundle.js (48.5 kB gzipped, against 54.6 for the two bundles it replaces) and rebuilds the stylesheets as before. The boot script builds H5PIntegration itself, field for field as h5p-standalone did, with the core's English strings from getLocalization() at the same commit, and loads what the worker's new `boot` route names: every library's scripts and styles in h5p-standalone's order, ported to the letter (its walk, its edges, toposort 2.0.2 and its tie-breaks). The order is checked against h5p-standalone's own code on 500 random graphs, and on 68 packages (the demo's, nine fixtures, the sweep's 55) captured from the frame before and after: the same files in the same order. One fix fell out: a package with folders named without versions, played with the pack, whose parts are named with them, ended h5p-standalone's boot on a 404, since it named every folder by its first dependency's probe; the walk tries each library both ways. BREAKING CHANGE: the runtime has no main.bundle.js and its `runtime` export no `mainJs`; its boot script needs a worker that answers the `boot` route. The worker still writes the old document for a runtime up to 0.2 handed over with `mainJs`. The player's peer range moves at release. Closes #37 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 168 +- README.md | 6 +- docs/h5p-standalone.md | 4 +- package.json | 1 + packages/embed/tests/build.test.ts | 2 +- packages/player/NOTICE.md | 5 +- packages/player/scripts/build-fixtures.mjs | 32 +- packages/player/scripts/lib/fixture-names.mjs | 1 + packages/player/scripts/sync-h5p-assets.mjs | 4 +- packages/player/src/frame-fonts.ts | 2 +- packages/player/src/h5p-offline-player.ts | 9 +- packages/player/src/shared/protocol.ts | 35 +- packages/player/src/sw/frame-document.ts | 27 +- packages/player/src/sw/library-load.ts | 193 ++ packages/player/src/sw/mount.ts | 66 +- packages/player/src/sw/package-reader.ts | 8 +- packages/player/src/sw/routes.ts | 14 +- packages/player/tests/browser/l10n.test.ts | 1 - packages/player/tests/browser/options.test.ts | 5 +- packages/player/tests/browser/pack.test.ts | 14 + .../player/tests/browser/playback.test.ts | 6 +- packages/player/tests/browser/runtime.test.ts | 8 +- .../player/tests/browser/upgrades.test.ts | 4 +- packages/player/tests/browser/utils.ts | 3 +- .../tests/browser/virtual-server.test.ts | 2 +- .../player/tests/unit/frame-document.test.ts | 54 +- packages/player/tests/unit/libraries.test.ts | 2 +- .../player/tests/unit/library-load.test.ts | 229 ++ .../player/tests/unit/merged-manifest.test.ts | 2 +- packages/player/tests/unit/routes.test.ts | 6 + packages/runtime/NOTICE.md | 51 +- packages/runtime/README.md | 17 +- packages/runtime/index.d.ts | 4 +- packages/runtime/package.json | 6 +- packages/runtime/scripts/build.mjs | 181 +- packages/runtime/scripts/l10n-rules.mjs | 38 +- packages/runtime/scripts/sync-core.mjs | 158 + packages/runtime/src/frame-boot.ts | 328 +- packages/runtime/src/l10n.ts | 2 +- packages/runtime/tsconfig.json | 1 + packages/runtime/vendor/h5p-core/SOURCE.json | 28 + .../vendor/h5p-core/fonts/h5p-core-30.woff2 | Bin 0 -> 15080 bytes .../h5p-core/fonts/h5p-hub-publish.woff2 | Bin 0 -> 1384 bytes .../vendor/h5p-core/fonts/h5p-theme.woff2 | Bin 0 -> 10244 bytes .../fonts/inter/Inter-ExtraBold.woff2 | Bin 0 -> 114856 bytes .../fonts/inter/Inter-ExtraBoldItalic.woff2 | Bin 0 -> 121516 bytes .../vendor/h5p-core/fonts/inter/LICENSE.txt | 92 + ...k-ext_latin_latin-ext_vietnamese-600.woff2 | Bin 0 -> 112048 bytes ...latin_latin-ext_vietnamese-600italic.woff2 | Bin 0 -> 118096 bytes ...xt_latin_latin-ext_vietnamese-italic.woff2 | Bin 0 -> 114132 bytes ...t_latin_latin-ext_vietnamese-regular.woff2 | Bin 0 -> 108656 bytes .../vendor/h5p-core/fonts/open-sans/OFL.txt | 88 + ...atin-ext_math_symbols_vietnamese-600.woff2 | Bin 0 -> 56200 bytes ...xt_math_symbols_vietnamese-600italic.woff2 | Bin 0 -> 59372 bytes ...atin-ext_math_symbols_vietnamese-700.woff2 | Bin 0 -> 54424 bytes ...xt_math_symbols_vietnamese-700italic.woff2 | Bin 0 -> 56748 bytes ...n-ext_math_symbols_vietnamese-italic.woff2 | Bin 0 -> 59328 bytes ...-ext_math_symbols_vietnamese-regular.woff2 | Bin 0 -> 56272 bytes .../vendor/h5p-core/images/throbber.gif | Bin 0 -> 1638 bytes .../vendor/h5p-core/js/h5p-action-bar.js | 107 + .../h5p-core/js/h5p-confirmation-dialog.js | 416 +++ .../vendor/h5p-core/js/h5p-content-type.js | 41 + .../h5p-core/js/h5p-event-dispatcher.js | 258 ++ .../runtime/vendor/h5p-core/js/h5p-tooltip.js | 377 +++ .../vendor/h5p-core/js/h5p-x-api-event.js | 331 ++ .../runtime/vendor/h5p-core/js/h5p-x-api.js | 119 + packages/runtime/vendor/h5p-core/js/h5p.js | 2936 +++++++++++++++++ packages/runtime/vendor/h5p-core/js/jquery.js | 22 + .../vendor/h5p-core/js/request-queue.js | 436 +++ packages/runtime/vendor/h5p-core/strings.json | 69 + .../styles/h5p-confirmation-dialog.css | 311 ++ .../h5p-core/styles/h5p-core-button.css | 91 + .../vendor/h5p-core/styles/h5p-fonts.css | 103 + .../vendor/h5p-core/styles/h5p-table.css | 56 + .../h5p-core/styles/h5p-theme-variables.css | 139 + .../vendor/h5p-core/styles/h5p-theme.css | 184 ++ .../vendor/h5p-core/styles/h5p-tooltip.css | 38 + .../runtime/vendor/h5p-core/styles/h5p.css | 716 ++++ pnpm-lock.yaml | 4 - 79 files changed, 8286 insertions(+), 375 deletions(-) create mode 100644 packages/player/src/sw/library-load.ts create mode 100644 packages/player/tests/unit/library-load.test.ts create mode 100644 packages/runtime/scripts/sync-core.mjs create mode 100644 packages/runtime/vendor/h5p-core/SOURCE.json create mode 100644 packages/runtime/vendor/h5p-core/fonts/h5p-core-30.woff2 create mode 100644 packages/runtime/vendor/h5p-core/fonts/h5p-hub-publish.woff2 create mode 100644 packages/runtime/vendor/h5p-core/fonts/h5p-theme.woff2 create mode 100644 packages/runtime/vendor/h5p-core/fonts/inter/Inter-ExtraBold.woff2 create mode 100644 packages/runtime/vendor/h5p-core/fonts/inter/Inter-ExtraBoldItalic.woff2 create mode 100644 packages/runtime/vendor/h5p-core/fonts/inter/LICENSE.txt create mode 100644 packages/runtime/vendor/h5p-core/fonts/inter/inter-v18-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-600.woff2 create mode 100644 packages/runtime/vendor/h5p-core/fonts/inter/inter-v18-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-600italic.woff2 create mode 100644 packages/runtime/vendor/h5p-core/fonts/inter/inter-v18-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-italic.woff2 create mode 100644 packages/runtime/vendor/h5p-core/fonts/inter/inter-v18-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-regular.woff2 create mode 100644 packages/runtime/vendor/h5p-core/fonts/open-sans/OFL.txt create mode 100644 packages/runtime/vendor/h5p-core/fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-600.woff2 create mode 100644 packages/runtime/vendor/h5p-core/fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-600italic.woff2 create mode 100644 packages/runtime/vendor/h5p-core/fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-700.woff2 create mode 100644 packages/runtime/vendor/h5p-core/fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-700italic.woff2 create mode 100644 packages/runtime/vendor/h5p-core/fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-italic.woff2 create mode 100644 packages/runtime/vendor/h5p-core/fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-regular.woff2 create mode 100644 packages/runtime/vendor/h5p-core/images/throbber.gif create mode 100644 packages/runtime/vendor/h5p-core/js/h5p-action-bar.js create mode 100644 packages/runtime/vendor/h5p-core/js/h5p-confirmation-dialog.js create mode 100644 packages/runtime/vendor/h5p-core/js/h5p-content-type.js create mode 100644 packages/runtime/vendor/h5p-core/js/h5p-event-dispatcher.js create mode 100644 packages/runtime/vendor/h5p-core/js/h5p-tooltip.js create mode 100644 packages/runtime/vendor/h5p-core/js/h5p-x-api-event.js create mode 100644 packages/runtime/vendor/h5p-core/js/h5p-x-api.js create mode 100644 packages/runtime/vendor/h5p-core/js/h5p.js create mode 100644 packages/runtime/vendor/h5p-core/js/jquery.js create mode 100644 packages/runtime/vendor/h5p-core/js/request-queue.js create mode 100644 packages/runtime/vendor/h5p-core/strings.json create mode 100644 packages/runtime/vendor/h5p-core/styles/h5p-confirmation-dialog.css create mode 100644 packages/runtime/vendor/h5p-core/styles/h5p-core-button.css create mode 100644 packages/runtime/vendor/h5p-core/styles/h5p-fonts.css create mode 100644 packages/runtime/vendor/h5p-core/styles/h5p-table.css create mode 100644 packages/runtime/vendor/h5p-core/styles/h5p-theme-variables.css create mode 100644 packages/runtime/vendor/h5p-core/styles/h5p-theme.css create mode 100644 packages/runtime/vendor/h5p-core/styles/h5p-tooltip.css create mode 100644 packages/runtime/vendor/h5p-core/styles/h5p.css diff --git a/AGENTS.md b/AGENTS.md index 360b967..d380238 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,8 @@ packages/player/ @missing-elements/h5p-offline-player — the published package-reader.ts zip index + per-entry serving strategy pack-plan.ts which parts of the pack a package needs, and which of its own folders they replace (`planPack`): pure, unit-tested in tests/unit/pack-plan.test.ts + library-load.ts what a frame loads, its libraries' scripts and styles in order (`LibraryLoad`), + which the `boot` route answers: h5p-standalone's walk and sort, ported (#37) frame-document.ts the frame HTML the worker synthesizes, and its CSP; it names the runtime's frame-boot.js with `\n` : '' + const legacyLoader = loader ? `\n` : '' // The last rule in the style block is for H5P.Video's YouTube handler, which pins its iframe // over the 16:9 box it builds with one line that reaches into the YouTube API object's minified @@ -416,8 +426,7 @@ ${fontFaces}
- -${strings} +${legacyLoader}${strings} ` diff --git a/packages/player/src/sw/library-load.ts b/packages/player/src/sw/library-load.ts new file mode 100644 index 0000000..1943a2d --- /dev/null +++ b/packages/player/src/sw/library-load.ts @@ -0,0 +1,193 @@ +import type { LibraryLoad } from '../shared/protocol' +import { dependenciesIn, type LibraryDependency, type PackageManifest } from './package-reader' + +/** + * What the frame loads, and in what order: every library the content needs, its scripts and its + * stylesheets, dependencies first. Until the core was vendored (#37) h5p-standalone worked this out + * in the frame, asking the virtual server for each `library.json` in turn, after a probe of + * whether the package names its folders with versions; the worker has the archives and the rules + * by which a folder answers, so it answers the frame with the result instead, on the `boot` route. + * + * The order is h5p-standalone's to the letter — the same walk, the same topological sort with the + * same tie-breaks — because it is the order every content type has been played in here, the + * sweep's included, and the order among libraries that do not depend on each other is a choice + * the content was tested against. Two things differ, neither of which changes the order of any + * package measured on 2026-10-18 (the demo's, the fixtures and the sweep's 55): a library is named + * by the folder it is served from, so two requests that one folder answers — content asking for + * 1.0 where 1.1 is served, and a library asking for 1.1 — load it once rather than twice; and a + * dependency cycle, which h5p-standalone threw on, is broken where it closes. + */ + +/** One library as the walk found it: the folder that answered, and what its `library.json` says. */ +export interface WalkedLibrary { + folder: string + machineName: string + major: number + minor: number + /** Its `preloadedDependencies`, as the dependencies were written. */ + dependencies: LibraryDependency[] + /** Its `preloadedJs` and `preloadedCss`, as entry names under the package's root. */ + scripts: string[] + styles: string[] +} + +/** How the walk finds a library: the folder that answers for it and its parsed `library.json`, or null. */ +export type LibraryLookup = (dependency: LibraryDependency) => Promise<{ folder: string; library: unknown } | null> + +/** A dependency by name and version, as a map key; a folder may answer for more than one. */ +const keyOf = (dependency: LibraryDependency) => `${dependency.machineName} ${dependency.majorVersion}.${dependency.minorVersion}` + +/** + * The libraries `roots` need, in the order h5p-standalone's `loadDependencies` found them: the roots + * first, then a level at a time, each level the dependencies the one before it named that are not + * found yet. The lookups of a level run together. A library nothing answers for is named in + * `missing`, as the folder its version would have, and its own dependencies go unwalked. + */ +export async function walkLibraries( + roots: readonly LibraryDependency[], + lookup: LibraryLookup +): Promise<{ libraries: WalkedLibrary[]; folderOf: Map; missing: string[] }> { + const libraries: WalkedLibrary[] = [] + const byFolder = new Map() + /** Which folder answered for each dependency, by `keyOf`. */ + const folderOf = new Map() + const missing: string[] = [] + const asked = new Set() + /** Those not asked for yet, each once, and from now on asked. */ + const unasked = (dependencies: LibraryDependency[]) => + dependencies.filter((dependency) => { + const key = keyOf(dependency) + if (asked.has(key)) return false + asked.add(key) + return true + }) + + let level = unasked([...roots]) + while (level.length > 0) { + const answers = await Promise.all(level.map((dependency) => lookup(dependency))) + const found: WalkedLibrary[] = [] + answers.forEach((answer, index) => { + const dependency = level[index] + if (!answer) { + missing.push(`${dependency.machineName}-${dependency.majorVersion}.${dependency.minorVersion}`) + return + } + folderOf.set(keyOf(dependency), answer.folder) + if (byFolder.has(answer.folder)) return + const library = walkedLibrary(answer.folder, answer.library, dependency) + byFolder.set(answer.folder, library) + libraries.push(library) + found.push(library) + }) + level = unasked(found.flatMap((library) => library.dependencies)) + } + return { libraries, folderOf, missing } +} + +/** + * The scripts and stylesheets of `libraries`, dependencies first: h5p-standalone's + * `sortDependencies`, edge for edge. A library with no dependencies is an edge of one, whose missing + * end `toposort` takes for a node of its own; that node decides nothing but the tie-breaks, and it is + * kept so that they come out the same. + */ +export function loadOrder(libraries: readonly WalkedLibrary[], folderOf: ReadonlyMap): { scripts: string[]; styles: string[] } { + const edges: Array<[string, string | undefined]> = [] + const byFolder = new Map(libraries.map((library) => [library.folder, library])) + for (const library of libraries) { + const dependencies = library.dependencies.flatMap((dependency) => folderOf.get(keyOf(dependency)) ?? []) + if (dependencies.length === 0) edges.push([library.folder, undefined]) + for (const dependency of dependencies) edges.push([library.folder, dependency]) + } + + const scripts: string[] = [] + const styles: string[] = [] + for (const folder of toposort(edges).reverse()) { + const library = folder === undefined ? undefined : byFolder.get(folder) + if (!library) continue + scripts.push(...library.scripts) + styles.push(...library.styles) + } + return { scripts, styles } +} + +/** + * What the frame loads for a package whose libraries `walkLibraries` found from `manifest`'s + * dependencies: the main library at the version served, the files in order, and the folders the + * core's `H5P.getLibraryPath` cannot name by version — `libraryDirectories`, for a package that + * keeps a library in a folder named without one. `null` when the main library is not among them. + */ +export function libraryLoad( + manifest: PackageManifest, + walk: { libraries: readonly WalkedLibrary[]; folderOf: ReadonlyMap } +): Omit | null { + const main = (manifest.preloadedDependencies ?? []).find((dependency) => dependency.machineName === manifest.mainLibrary) + const mainFolder = main && walk.folderOf.get(keyOf(main)) + const served = mainFolder === undefined ? undefined : walk.libraries.find((library) => library.folder === mainFolder) + if (!served) return null + + const directories: Record = {} + for (const library of walk.libraries) { + const named = `${library.machineName}-${library.major}.${library.minor}` + if (library.folder !== named) directories[named] = library.folder + } + return { library: `${served.machineName} ${served.major}.${served.minor}`, ...loadOrder(walk.libraries, walk.folderOf), directories } +} + +/** A `library.json` as the walk needs it; untrusted, so every field is held to its shape. */ +function walkedLibrary(folder: string, library: unknown, asked: LibraryDependency): WalkedLibrary { + const json = (library ?? {}) as Record + const version = (part: unknown, fallback: number) => + typeof part === 'number' && Number.isInteger(part) && part >= 0 ? part : typeof part === 'string' && /^\d+$/.test(part) ? Number(part) : fallback + return { + folder, + machineName: typeof json.machineName === 'string' ? json.machineName : asked.machineName, + major: version(json.majorVersion, Number(asked.majorVersion)), + minor: version(json.minorVersion, Number(asked.minorVersion)), + dependencies: dependenciesIn(json), + scripts: filesIn(folder, json.preloadedJs), + styles: filesIn(folder, json.preloadedCss) + } +} + +/** + * A `preloadedJs` or `preloadedCss` as entry names under the package's root, `/`, in + * order. A path that would leave the folder — absolute, with a scheme, or climbing out with `..` — + * is left out: the file comes from an archive, and a page names it as a script. + */ +export function filesIn(folder: string, files: unknown): string[] { + if (!Array.isArray(files)) return [] + return files.flatMap((file) => { + const path = (file as { path?: unknown } | null)?.path + if (typeof path !== 'string' || !path || /^[\\/]|^[a-z][a-z\d+.-]*:|(?:^|[\\/])\.\.(?:[\\/]|$)/i.test(path)) return [] + return [`${folder}/${path}`] + }) +} + +/** + * `toposort` 2.0.2 by Marcel Klehr (MIT), the sort h5p-standalone bundles, ported as it is but for a + * cycle: where it threw, the edge that closes one is passed over, since a node counts as visited + * from the moment it is entered, and the rest still sorts. Every node comes out before the nodes + * its edges point at; the last node first seen is visited first, and a node's edges last to first. + */ +export function toposort(edges: ReadonlyArray): T[] { + const nodes = [...new Set(edges.flatMap(([from, to]) => [from, to]))] + const outgoing = new Map>() + for (const [from, to] of edges) { + if (!outgoing.has(from)) outgoing.set(from, new Set()) + if (!outgoing.has(to)) outgoing.set(to, new Set()) + outgoing.get(from)!.add(to) + } + + let cursor = nodes.length + const sorted = new Array(cursor) + const visited = new Set() + const visit = (node: T) => { + if (visited.has(node)) return + visited.add(node) + const children = [...(outgoing.get(node) ?? [])] + for (let child = children.length - 1; child >= 0; child--) visit(children[child]) + sorted[--cursor] = node + } + for (let index = nodes.length - 1; index >= 0; index--) visit(nodes[index]) + return sorted +} diff --git a/packages/player/src/sw/mount.ts b/packages/player/src/sw/mount.ts index 03747df..96df18f 100644 --- a/packages/player/src/sw/mount.ts +++ b/packages/player/src/sw/mount.ts @@ -28,6 +28,7 @@ import { contentTypeOf } from '../shared/mime' import { PlayerError, type FromWorkerMessage, + type LibraryLoad, type LoadSettings, type PackOffer, type PackPlan, @@ -38,7 +39,8 @@ import { import { contentRange, parseRange, type ByteRange } from '../shared/range' import { openSource } from '../shared/source' import * as db from '../shared/idb' -import { type IndexedEntry, type LocatedEntry, PackageReader, explainMissingLibraries, locationOf } from './package-reader' +import { type IndexedEntry, type LocatedEntry, PackageReader, dependenciesIn, explainMissingLibraries, libraryFolderNames, locationOf } from './package-reader' +import { libraryLoad, walkLibraries } from './library-load' import { STORED } from '../shared/local-header' import { CONTENT_CHECK_HEADER, buildFrameDocument, createNonce } from './frame-document' import { loadSegment, matchRoute, routesFor, type LoadRef, type RouteMatch, type Routes } from './routes' @@ -318,6 +320,9 @@ class VirtualServer { case 'frame': return await this.serveFrame(match) + case 'boot': + return await this.serveBoot(match) + case 'entry': return await this.serveEntry(event, match, match.path) @@ -346,18 +351,23 @@ class VirtualServer { await bookkeeping(db.touchPackage(load.pkgId)) - // From the reader the element's `index` left open, never a new one: a picked file's reader - // asks the page for the file through the frame, and the frame is what is being served. With - // no reader the list is left out, and the frame asks for each script and takes a 404 as none. - const upgrades = await this.readers.get(loadSegment(load))?.then( - (reader) => reader.foldersWithUpgrades(), - () => undefined - ) + // For a runtime up to 0.2, whose boot script takes the list from the document: from the + // reader the element's `index` left open, never a new one, since a picked file's reader asks + // the page for the file through the frame, and the frame is what is being served. With no + // reader the list is left out, and the frame asks for each script and takes a 404 as none. A + // later runtime asks the `boot` route, whose answer carries it. + const upgrades = settings.frameAssets.mainJs + ? await this.readers.get(loadSegment(load))?.then( + (reader) => reader.foldersWithUpgrades(), + () => undefined + ) + : undefined const nonce = createNonce() const body = buildFrameDocument({ pkgId: load.pkgId, virtualRoot: `${this.routes.virtual}${loadSegment(load)}`, + bootUrl: `${this.routes.boot}${loadSegment(load)}`, assets: settings.frameAssets, nonce, title: record.title, @@ -380,6 +390,46 @@ class VirtualServer { }) } + /** + * What the frame loads (`LibraryLoad`): every library the content needs, its scripts and + * stylesheets dependencies first, as `sw/library-load.ts` works them out from the manifest the + * runtime is served — the merged one when archives are attached. The boot script of a runtime + * after 0.2 asks for it as it starts. Asked by the frame rather than written into its document, + * because only a request from the frame can open a reader the worker lost on a restart: for a + * picked file the page is asked for the file through the frame, which, while its own document is + * being served, does not exist yet. + * + * A library is looked up the way `get()` serves its files — the folder its version names, the + * bare machine name, a compatible newer minor — and on an archive still arriving it is waited + * for as any entry the frame asks for is. One that nothing answers for is named in the refusal. + */ + private async serveBoot(load: LoadRef): Promise { + const reader = await this.refresh(load, await this.reader(load)) + const manifest = reader.hasFallbacks ? await reader.mergedManifest() : reader.manifest + const walk = await walkLibraries(dependenciesIn(manifest), async (dependency) => { + for (const folder of libraryFolderNames(dependency)) { + const name = `${folder}/library.json` + let located = reader.get(name) + if (!located && reader.partial) { + const outcome = await this.awaitEntry(load, reader, name) + if (outcome !== 'stalled') located = outcome.located + } + if (located) { + const served = located.entry.name + return { folder: served.slice(0, served.indexOf('/')), library: await this.readJson(located) } + } + } + return null + }) + if (walk.missing.length > 0) { + const missingLibraries = { mainLibrary: manifest.mainLibrary, folders: walk.missing.sort(), all: false } + throw new PlayerError('bad-archive', explainMissingLibraries(missingLibraries), { missingLibraries }) + } + const libraries = libraryLoad(manifest, walk) + if (!libraries) throw new PlayerError('bad-archive', `h5p.json names ${String(manifest.mainLibrary)} as its main library, and does not list it among its dependencies`) + return json({ ...libraries, upgrades: reader.foldersWithUpgrades() } satisfies LibraryLoad) + } + /* ---------------------------------------------------------------- package entries */ private async serveEntry(event: FetchEvent, load: LoadRef, rawPath: string): Promise { diff --git a/packages/player/src/sw/package-reader.ts b/packages/player/src/sw/package-reader.ts index f4163bf..2ed4124 100644 --- a/packages/player/src/sw/package-reader.ts +++ b/packages/player/src/sw/package-reader.ts @@ -1047,7 +1047,7 @@ export function textMatches(params: unknown, patterns: RegExp[]): boolean { } /** The well-formed entries of a manifest's or a `library.json`'s `preloadedDependencies`. */ -function dependenciesIn(value: unknown): LibraryDependency[] { +export function dependenciesIn(value: unknown): LibraryDependency[] { const dependencies = (value as { preloadedDependencies?: unknown } | null)?.preloadedDependencies if (!Array.isArray(dependencies)) return [] const version = (part: unknown) => typeof part === 'number' || (typeof part === 'string' && /^\d+$/.test(part)) @@ -1058,10 +1058,10 @@ function dependenciesIn(value: unknown): LibraryDependency[] { } /** - * The two folder names a library may be stored under. h5p-standalone probes the versioned one - * first and falls back to the bare machine name, so a package is usable with either. + * The two folder names a library may be stored under: the versioned one, and the bare machine + * name older packages use. Each is tried in that order, as h5p-standalone tried them. */ -function libraryFolderNames(dependency: LibraryDependency): string[] { +export function libraryFolderNames(dependency: LibraryDependency): string[] { return [ `${dependency.machineName}-${dependency.majorVersion}.${dependency.minorVersion}`, dependency.machineName diff --git a/packages/player/src/sw/routes.ts b/packages/player/src/sw/routes.ts index 456d2ed..671bf19 100644 --- a/packages/player/src/sw/routes.ts +++ b/packages/player/src/sw/routes.ts @@ -1,7 +1,8 @@ /** * URL shape of the virtual routes. Our own registration uses the scope `h5p/`, so routes * sit directly under it; `mountH5P` puts them at `h5p/`. Both spell the same thing — - * `…/h5p/virtual/…` and `…/h5p/frame/…` — so everything downstream works off one base. + * `…/h5p/virtual/…`, `…/h5p/frame/…` and `…/h5p/boot/…` — so everything downstream works off one + * base. */ import { LOAD_KEY_BYTES, PKG_ID_LENGTH } from '../shared/pkg-id' @@ -11,6 +12,8 @@ export interface Routes { base: string virtual: string frame: string + /** What a frame loads, its libraries in order (`LibraryLoad`), which its boot script asks for. */ + boot: string ping: string } @@ -22,6 +25,7 @@ export function routesFor(scope: string): Routes { base, virtual: `${base}virtual/`, frame: `${base}frame/`, + boot: `${base}boot/`, ping: `${base}virtual/_ping` } } @@ -38,6 +42,7 @@ export interface LoadRef { export type RouteMatch = | { kind: 'ping' } | ({ kind: 'frame' } & LoadRef) + | ({ kind: 'boot' } & LoadRef) | ({ kind: 'entry'; path: string } & LoadRef) | { kind: 'none' } @@ -58,9 +63,10 @@ function parseLoad(segment: string): LoadRef | null { export function matchRoute(routes: Routes, url: string): RouteMatch { if (url === routes.ping) return { kind: 'ping' } - if (url.startsWith(routes.frame)) { - const load = parseLoad(url.slice(routes.frame.length).split(/[/?#]/, 1)[0]) - return load ? { kind: 'frame', ...load } : { kind: 'none' } + for (const kind of ['frame', 'boot'] as const) { + if (!url.startsWith(routes[kind])) continue + const load = parseLoad(url.slice(routes[kind].length).split(/[/?#]/, 1)[0]) + return load ? { kind, ...load } : { kind: 'none' } } if (url.startsWith(routes.virtual)) { diff --git a/packages/player/tests/browser/l10n.test.ts b/packages/player/tests/browser/l10n.test.ts index 15a1c8c..0bb3f79 100644 --- a/packages/player/tests/browser/l10n.test.ts +++ b/packages/player/tests/browser/l10n.test.ts @@ -93,7 +93,6 @@ describe('the core’s strings', () => { it('come from the runtime property’s own files when a host hands them over, and stay English when it has none', async () => { const base = new URL('/frame-assets/', location.href).href const runtime = { - mainJs: `${base}main.bundle.js`, frameJs: `${base}frame.bundle.js`, frameCss: `${base}h5p.css`, bootJs: `${base}frame-boot.js`, diff --git a/packages/player/tests/browser/options.test.ts b/packages/player/tests/browser/options.test.ts index 6734932..1a80dbc 100644 --- a/packages/player/tests/browser/options.test.ts +++ b/packages/player/tests/browser/options.test.ts @@ -4,7 +4,8 @@ import '../../src/h5p-offline-player' import { FIXTURES, createPlayer, frameDocument, frameWindow, play, waitForEvent } from './utils' /** - * h5p-standalone's display and loading options, as the element's attributes. Each is read off the + * The display and loading options, by the names h5p-standalone gave them, as the element's + * attributes. Each is read off the * runtime's own configuration (`H5PIntegration`) or the frame's DOM, not off the element, so what * is pinned is what the content actually sees. */ @@ -43,7 +44,7 @@ async function complete(player: H5PPlayerElement): Promise { return (await xapi).detail.statement } -describe('h5p-standalone options', () => { +describe('the display and loading options', () => { it('hides the action bar by default and shows it when asked, with the buttons the content is told to offer', async () => { const plain = await play(FIXTURES.basic) expect(frameDocument(plain).querySelector('.h5p-actions')).toBeNull() diff --git a/packages/player/tests/browser/pack.test.ts b/packages/player/tests/browser/pack.test.ts index 3aae7d9..7d5da3a 100644 --- a/packages/player/tests/browser/pack.test.ts +++ b/packages/player/tests/browser/pack.test.ts @@ -69,6 +69,20 @@ describe('the library pack, split into parts', () => { expect(await loadOf(player)).toMatchObject({ parts: ['H5P.OfflineTest-1.2.h5p'], superseded: ['H5P.OfflineTest'] }) }) + it("plays the pack's folders, named with versions, beside the package's own named without", async () => { + // The content type comes from the pack at 1.2; the extra library, which the pack has at the + // package's own version, from the package's folder named without one. h5p-standalone named + // every folder one way, by its first dependency, and ended its boot on the extra library's 404. + const player = await play(fresh(FIXTURES.unversionedPack, 'mixed'), withPack()) + + expect(messageOf(player)).toBe(`${BASIC_MESSAGE} [1.1] [1.2]`) + expect(await loadOf(player)).toMatchObject({ parts: ['H5P.OfflineTest-1.2.h5p'], superseded: ['H5P.OfflineTest'] }) + const view = frameWindow(player) as Window & { h5pOfflineExtraLoaded?: boolean; H5PIntegration: { libraryDirectories: Record } } + expect(view.h5pOfflineExtraLoaded).toBe(true) + // So that the core's `H5P.getLibraryPath` finds the folder its name does not say. + expect(view.H5PIntegration.libraryDirectories).toEqual({ 'H5P.OfflineExtra-1.0': 'H5P.OfflineExtra' }) + }) + it('plays the package as exported with refresh-libraries="off"', async () => { const player = await play(fresh(FIXTURES.basic, 'off'), withPack({ 'refresh-libraries': 'off' })) diff --git a/packages/player/tests/browser/playback.test.ts b/packages/player/tests/browser/playback.test.ts index 3e71ca6..8c3ed39 100644 --- a/packages/player/tests/browser/playback.test.ts +++ b/packages/player/tests/browser/playback.test.ts @@ -215,9 +215,9 @@ describe('playing a package', () => { }) it('plays a package whose library folders carry no version', async () => { - // Older packages are built this way. h5p-standalone probes the versioned name first and the - // 404 it gets is what tells it to fall back to the bare one, so that 404 has to be a clean - // 404 rather than an error. + // Older packages are built this way. Each library is looked for under the versioned name + // first, and the 404 it gets is what sends the lookup to the bare one, so that 404 has to be a + // clean 404 rather than an error. const player = await play(FIXTURES.unversioned) expect(player.state).toBe('ready') diff --git a/packages/player/tests/browser/runtime.test.ts b/packages/player/tests/browser/runtime.test.ts index 71e3002..9c0d108 100644 --- a/packages/player/tests/browser/runtime.test.ts +++ b/packages/player/tests/browser/runtime.test.ts @@ -11,7 +11,6 @@ import { FIXTURES, createPlayer, frameDocument, waitForSettled } from './utils' describe('the runtime property', () => { const base = new URL('/frame-assets/', location.href).href const runtime = { - mainJs: `${base}main.bundle.js?via=runtime`, frameJs: `${base}frame.bundle.js?via=runtime`, frameCss: `${base}h5p.css?via=runtime`, bootJs: `${base}frame-boot.js?via=runtime`, @@ -29,14 +28,15 @@ describe('the runtime property', () => { expect(player.state).toBe('ready') const scripts = [...frameDocument(player).querySelectorAll('script[src]')].map((script) => script.getAttribute('src')) - expect(scripts).toContain(runtime.mainJs) + // The boot script under the nonce, and the core it loads after it. expect(scripts).toContain(runtime.bootJs) + expect(scripts).toContain(runtime.frameJs) expect(frameDocument(player).querySelector('style')?.textContent).toContain(runtime.fonts[0].url) }) - it('refuses a value missing one of the four files, since that frame would never boot', () => { + it('refuses a value missing one of the three files, since that frame would never boot', () => { const player = createPlayer() - player.runtime = { mainJs: 'a', frameJs: 'b', frameCss: 'c' } as never + player.runtime = { frameJs: 'b', frameCss: 'c' } as never expect(player.runtime).toBeNull() player.runtime = runtime expect(player.runtime?.bootJs).toBe(runtime.bootJs) diff --git a/packages/player/tests/browser/upgrades.test.ts b/packages/player/tests/browser/upgrades.test.ts index 519202d..ead6ac5 100644 --- a/packages/player/tests/browser/upgrades.test.ts +++ b/packages/player/tests/browser/upgrades.test.ts @@ -68,8 +68,8 @@ describe('content written for an older minor', () => { }) it('names a main library whose minor is 0 in full', async () => { - // h5p-standalone wrote "H5P.OfflineTest 1", which the core took for the folder - // `H5P.OfflineTest-1.undefined`, and every statement's category with it. + // h5p-standalone, which loaded the runtime until #37, wrote "H5P.OfflineTest 1", which the + // core took for the folder `H5P.OfflineTest-1.undefined`, and every statement's category with it. const player = await play(FIXTURES.basic) expect(content(player).libraryInfo.versionedNameNoSpaces).toBe('H5P.OfflineTest-1.0') }) diff --git a/packages/player/tests/browser/utils.ts b/packages/player/tests/browser/utils.ts index 759babe..f577652 100644 --- a/packages/player/tests/browser/utils.ts +++ b/packages/player/tests/browser/utils.ts @@ -63,7 +63,8 @@ export const FIXTURES = { writtenFor12: '/fixtures/upgrade-1.2.h5p', upgradeFails: '/fixtures/upgrade-fails.h5p', /** Library folders with no version suffix, the shape older packages use. */ - unversioned: '/fixtures/unversioned.h5p' + unversioned: '/fixtures/unversioned.h5p', + unversionedPack: '/fixtures/unversioned-pack.h5p' } as const /** diff --git a/packages/player/tests/browser/virtual-server.test.ts b/packages/player/tests/browser/virtual-server.test.ts index d1397cb..be551d2 100644 --- a/packages/player/tests/browser/virtual-server.test.ts +++ b/packages/player/tests/browser/virtual-server.test.ts @@ -55,7 +55,7 @@ describe('the virtual file server', () => { expect(response.status).toBe(416) }) - it('404s a missing entry, which is how h5p-standalone probes folder naming', async () => { + it('404s a missing entry, which is how a library is looked for under its other folder name', async () => { const player = await play(FIXTURES.basic) const response = await frameFetch(player, virtualUrl(player, 'H5P.OfflineTest/library.json')) expect(response.status).toBe(404) diff --git a/packages/player/tests/unit/frame-document.test.ts b/packages/player/tests/unit/frame-document.test.ts index 02c89f6..cd7a7db 100644 --- a/packages/player/tests/unit/frame-document.test.ts +++ b/packages/player/tests/unit/frame-document.test.ts @@ -13,16 +13,22 @@ const PKG = 'b'.repeat(32) const options = { pkgId: PKG, virtualRoot: `https://site.example/assets/h5p/virtual/${PKG}`, + bootUrl: `https://site.example/assets/h5p/boot/${PKG}`, assets: { - mainJs: 'https://site.example/frame-assets/main.bundle.js', frameJs: 'https://site.example/frame-assets/frame.bundle.js', frameCss: 'https://site.example/frame-assets/h5p.css', bootJs: 'https://site.example/frame-assets/frame-boot.js' - }, + } as { mainJs?: string; frameJs: string; frameCss: string; bootJs?: string }, nonce: 'deadbeef', title: 'A course' } +/** A record an element wrote with the runtime up to 0.2, which boots through h5p-standalone's loader. */ +const legacy = { ...options, assets: { ...options.assets, mainJs: 'https://site.example/frame-assets/main.bundle.js' } } + +/** The boot configuration the document carries. */ +const bootConfigOf = (html: string) => JSON.parse(/id="h5p-boot-config">([^<]*) { it('allows the per-response nonce and nothing else inline', () => { const csp = buildContentSecurityPolicy(options) @@ -34,7 +40,6 @@ describe('buildContentSecurityPolicy', () => { const csp = buildContentSecurityPolicy({ ...options, assets: { - mainJs: 'https://cdn.example/dist/main.bundle.js', frameJs: 'https://cdn.example/dist/frame.bundle.js', frameCss: 'https://cdn.example/dist/frame-assets/h5p.css' } @@ -113,7 +118,6 @@ describe('the policy for an untrusted package', () => { const sources = scriptSources(buildContentSecurityPolicy({ ...options, untrusted: true })) expect(sources).not.toContain("'self'") expect(sources).toContain(`${options.virtualRoot}/`) - expect(sources).toContain(options.assets.mainJs) expect(sources).toContain(options.assets.frameJs) expect(sources).toContain(options.assets.bootJs) expect(sources).toContain("'nonce-deadbeef'") @@ -214,7 +218,7 @@ describe('buildFrameDocument', () => { expect(html).not.toMatch(/' })).not.toContain('"') }) - it('tells the boot script which library folders carry an upgrades.js, and leaves the list out when it has none', () => { - // The boot script fetches upgrade steps only from these, so it never asks for a script that - // is not there; with no list, from a worker older than it, it asks and takes a 404 as none. - expect(buildFrameDocument({ ...options, upgrades: ['H5P.QuestionSet-1.21', 'H5P.Summary-1.10'] })).toContain( - '"upgrades":["H5P.QuestionSet-1.21","H5P.Summary-1.10"]' - ) - expect(html).not.toContain('"upgrades"') + it("names the load's boot route, where the boot script asks what to load, and no loader", () => { + const html = buildFrameDocument({ ...options, upgrades: ['H5P.QuestionSet-1.21'] }) + expect(bootConfigOf(html).boot).toBe(options.bootUrl) + expect(html).not.toContain('main.bundle.js') + // The route answers with the list of folders that carry an `upgrades.js`, with the rest. + expect(bootConfigOf(html).upgrades).toBeUndefined() + }) + + it('writes the document a runtime up to 0.2 expects: its loader first, and the upgrade list', () => { + // A record such an element wrote names h5p-standalone's `main.bundle.js`, whose boot script + // walks the libraries in the frame and takes the folders that carry an `upgrades.js` from here; + // with no list, from a worker older than it, it asks and takes a 404 as none. + const html = buildFrameDocument({ ...legacy, upgrades: ['H5P.QuestionSet-1.21', 'H5P.Summary-1.10'] }) + expect(html).toContain('') + expect(html.indexOf('main.bundle.js')).toBeLessThan(html.indexOf('frame-boot.js')) + expect(html).toContain('"upgrades":["H5P.QuestionSet-1.21","H5P.Summary-1.10"]') + expect(bootConfigOf(html).boot).toBeUndefined() + expect(buildFrameDocument(legacy)).not.toContain('"upgrades"') + expect(buildContentSecurityPolicy({ ...legacy, untrusted: true })).toContain(legacy.assets.mainJs) }) }) @@ -341,13 +357,15 @@ describe('the boot script', () => { expect(source).toContain("document.getElementById('h5p-boot-config')") }) - it('uses div embedding, so there is no inner about:blank frame to inherit a controller', () => { - expect(source).toMatch(/embedType:\s*["']div["']/) + it('mounts the content as a div, so there is no inner about:blank frame to inherit a controller', () => { + expect(source).toContain("content.className = 'h5p-content'") + expect(source).not.toMatch(/createElement\(["']iframe["']\)/) }) it('turns off every result endpoint, because there is no server to post to', () => { expect(source).toMatch(/postUserStatistics:\s*false/) - expect(source).toMatch(/saveFreq:\s*false/) + // Saves go to the element, and only with `resume`. + expect(source).toMatch(/saveFreq:[^\n]*\bresume\b[^\n]*:\s*false,/) }) it('reports a runtime script or stylesheet that fails to load, in the capture phase', () => { @@ -374,7 +392,7 @@ describe('the boot script', () => { describe('the host’s frame options', () => { const withOptions = (frameOptions: object) => buildFrameDocument({ ...options, frameOptions } as typeof options & { frameOptions: object }) - it('ride into the boot config by h5p-standalone’s names', () => { + it('ride into the boot config by the names h5p-standalone gave them', () => { const html = withOptions({ frame: true, copyright: true, reportingIsEnabled: true, activityId: 'https://lms.example/a/1' }) expect(html).toContain('"options":{"frame":true,"copyright":true,"reportingIsEnabled":true,"activityId":"https://lms.example/a/1"}') }) @@ -444,7 +462,7 @@ describe('the package’s metadata', () => { describe('the frame’s language', () => { const l10nJs = 'https://site.example/frame-assets/l10n.js' const translated = { ...options, assets: { ...options.assets, l10nJs } } - const bootLanguage = (html: string) => JSON.parse(/id="h5p-boot-config">([^<]*) bootConfigOf(html).language const htmlTag = (html: string) => /]*>/.exec(html)![0] it('is English when neither the element nor the package says otherwise, with no translations', () => { @@ -457,7 +475,7 @@ describe('the frame’s language', () => { it('names the runtime’s translations ahead of the boot script, and tells it the element’s language', () => { const html = buildFrameDocument({ ...translated, language: 'de-AT' }) const at = html.indexOf(``) - expect(at).toBeGreaterThan(html.indexOf('main.bundle.js')) + expect(at).toBeGreaterThan(0) expect(at).toBeLessThan(html.indexOf('frame-boot.js')) expect(bootLanguage(html)).toBe('de-AT') }) diff --git a/packages/player/tests/unit/libraries.test.ts b/packages/player/tests/unit/libraries.test.ts index d8f1b8c..8bd1c74 100644 --- a/packages/player/tests/unit/libraries.test.ts +++ b/packages/player/tests/unit/libraries.test.ts @@ -35,7 +35,7 @@ describe('findMissingLibraries', () => { expect(missing).toEqual([]) }) - it('accepts a bare machine name, which h5p-standalone falls back to', () => { + it('accepts a bare machine name, the folder older packages use', () => { const missing = findMissingLibraries( entries('H5P.InteractiveVideo/library.json', 'H5P.Video/library.json'), manifest diff --git a/packages/player/tests/unit/library-load.test.ts b/packages/player/tests/unit/library-load.test.ts new file mode 100644 index 0000000..a7c612c --- /dev/null +++ b/packages/player/tests/unit/library-load.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest' +import { filesIn, libraryLoad, loadOrder, toposort, walkLibraries, type LibraryLookup } from '../../src/sw/library-load' +import type { LibraryDependency } from '../../src/sw/package-reader' + +/** + * The order the frame loads libraries in has to be h5p-standalone's, which every content type has + * been played in here. So h5p-standalone's own algorithm is kept below as the oracle: its + * `loadDependencies` and `sortDependencies` from `src/h5p-standalone.ts` at v3.8.2, with the + * `toposort` 2.0.2 it bundles (Marcel Klehr, MIT), each as it was written but for types. + */ +function referenceToposort(edges: Array>): Array { + const nodes = Array.from(new Set(edges.flatMap((edge) => [edge[0], edge[1]]))) + let cursor = nodes.length + const sorted = new Array(cursor) + const visited: Record = {} + let i = cursor + const outgoingEdges = new Map>() + for (const edge of edges) { + if (!outgoingEdges.has(edge[0])) outgoingEdges.set(edge[0], new Set()) + if (!outgoingEdges.has(edge[1])) outgoingEdges.set(edge[1], new Set()) + outgoingEdges.get(edge[0])!.add(edge[1]) + } + const nodesHash = new Map(nodes.map((node, index) => [node, index])) + const visit = (node: string | undefined, index: number, predecessors: Set) => { + if (predecessors.has(node)) throw new Error('Cyclic dependency') + if (visited[index]) return + visited[index] = true + const outgoing = Array.from(outgoingEdges.get(node) || new Set()) + let n = outgoing.length + if (n) { + predecessors.add(node) + do { + const child = outgoing[--n] + visit(child, nodesHash.get(child)!, predecessors) + } while (n) + predecessors.delete(node) + } + sorted[--cursor] = node + } + while (i--) if (!visited[i]) visit(nodes[i], i, new Set()) + return sorted +} + +interface Fake { + folder: string + dependencies: string[] + js: string[] + css: string[] +} + +function referenceOrder(roots: string[], libraries: Map): { scripts: string[]; styles: string[] } { + // loadDependencies: a level at a time, duplicates in a level and all. + const dependencies: Fake[] = [] + let toFind = roots + while (toFind.length > 0) { + const discovered = toFind.map((folder) => libraries.get(folder)!) + const findNext: string[] = [] + for (const library of discovered) { + dependencies.push(library) + for (const dependency of library.dependencies) { + const found = dependencies.find((each) => each.folder === dependency) + const inLevel = discovered.find((each) => each.folder === dependency) + if (!found && !inLevel) findNext.push(dependency) + } + } + toFind = findNext + } + // sortDependencies. + const graph: Array> = [] + const css: Record = {} + const js: Record = {} + for (const dependency of dependencies) { + if (dependency.dependencies.length === 0) graph.push([dependency.folder]) + for (const node of dependency.dependencies) graph.push([dependency.folder, node]) + css[dependency.folder] = dependency.css.map((path) => `${dependency.folder}/${path}`) + js[dependency.folder] = dependency.js.map((path) => `${dependency.folder}/${path}`) + } + const styles: string[] = [] + const scripts: string[] = [] + for (const name of referenceToposort(graph).reverse()) { + if (name === undefined) continue + styles.push(...css[name]) + scripts.push(...js[name]) + } + return { scripts, styles } +} + +/** A small, seeded generator, so a failure names a graph that can be played again. */ +function random(seed: number) { + let state = seed + return () => { + state = (state * 1103515245 + 12345) % 2147483648 + return state / 2147483648 + } +} + +const dependencyOf = (folder: string): LibraryDependency => { + const [, machineName, major, minor] = /^(.+)-(\d+)\.(\d+)$/.exec(folder)! + return { machineName, majorVersion: Number(major), minorVersion: Number(minor) } +} + +/** The walk's view of the fakes: each answered from its own folder. */ +const lookupIn = + (libraries: Map): LibraryLookup => + async (dependency) => { + const folder = `${dependency.machineName}-${dependency.majorVersion}.${dependency.minorVersion}` + const library = libraries.get(folder) + if (!library) return null + return { + folder, + library: { + machineName: dependency.machineName, + majorVersion: dependency.majorVersion, + minorVersion: dependency.minorVersion, + preloadedDependencies: library.dependencies.map(dependencyOf), + preloadedJs: library.js.map((path) => ({ path })), + preloadedCss: library.css.map((path) => ({ path })) + } + } + } + +describe('the order the frame loads libraries in', () => { + it("is h5p-standalone's, on five hundred dependency graphs", async () => { + for (let seed = 1; seed <= 500; seed++) { + const next = random(seed) + const count = 1 + Math.floor(next() * 14) + const folders = Array.from({ length: count }, (_, index) => `H5P.L${index}-1.${Math.floor(next() * 3)}`) + const libraries = new Map() + folders.forEach((folder, index) => { + // Each depends only on libraries after it, so the graph has no cycle, in a random order. + const later = folders.slice(index + 1).filter(() => next() < 0.35) + later.sort(() => next() - 0.5) + const js = next() < 0.85 ? ['a.js', ...(next() < 0.3 ? ['b.js'] : [])] : [] + const css = next() < 0.6 ? ['a.css'] : [] + libraries.set(folder, { folder, dependencies: later, js, css }) + }) + const roots = folders.filter((_, index) => index === 0 || next() < 0.3) + roots.sort(() => next() - 0.5) + + const walk = await walkLibraries(roots.map(dependencyOf), lookupIn(libraries)) + expect(loadOrder(walk.libraries, walk.folderOf), `seed ${seed}`).toEqual(referenceOrder(roots, libraries)) + } + }) + + it('loads a library once when two versions ask for the folder that serves both', async () => { + // Content written for Text 1.0 and a library asking for 1.1, where 1.1 is what is served: + // h5p-standalone named both folders and loaded the file twice. + const lookup: LibraryLookup = async ({ machineName, majorVersion, minorVersion }) => { + if (machineName === 'H5P.Text') return { folder: 'H5P.Text-1.1', library: { machineName, majorVersion: 1, minorVersion: 1, preloadedJs: [{ path: 'text.js' }] } } + return { + folder: `${machineName}-${majorVersion}.${minorVersion}`, + library: { machineName, majorVersion, minorVersion, preloadedDependencies: [{ machineName: 'H5P.Text', majorVersion: 1, minorVersion: 1 }], preloadedJs: [{ path: 'set.js' }] } + } + } + const walk = await walkLibraries( + [ + { machineName: 'H5P.Set', majorVersion: 1, minorVersion: 0 }, + { machineName: 'H5P.Text', majorVersion: 1, minorVersion: 0 } + ], + lookup + ) + expect(walk.libraries.map((library) => library.folder)).toEqual(['H5P.Set-1.0', 'H5P.Text-1.1']) + expect(loadOrder(walk.libraries, walk.folderOf).scripts).toEqual(['H5P.Text-1.1/text.js', 'H5P.Set-1.0/set.js']) + }) + + it('breaks a dependency cycle where it closes, where h5p-standalone threw', async () => { + const libraries = new Map([ + ['H5P.A-1.0', { folder: 'H5P.A-1.0', dependencies: ['H5P.B-1.0'], js: ['a.js'], css: [] }], + ['H5P.B-1.0', { folder: 'H5P.B-1.0', dependencies: ['H5P.A-1.0'], js: ['b.js'], css: [] }] + ]) + expect(() => referenceOrder(['H5P.A-1.0'], libraries)).toThrow('Cyclic dependency') + const walk = await walkLibraries([dependencyOf('H5P.A-1.0')], lookupIn(libraries)) + // The sort enters at B, the last node first seen, and reaches A, whose edge back to B closes + // the cycle and is passed over: A counts as B's dependency, and loads first. + expect(loadOrder(walk.libraries, walk.folderOf).scripts).toEqual(['H5P.A-1.0/a.js', 'H5P.B-1.0/b.js']) + }) + + it('names what nothing answers for, and does not walk below it', async () => { + const libraries = new Map([['H5P.A-1.0', { folder: 'H5P.A-1.0', dependencies: ['H5P.Gone-2.3'], js: ['a.js'], css: [] }]]) + const walk = await walkLibraries([dependencyOf('H5P.A-1.0')], lookupIn(libraries)) + expect(walk.missing).toEqual(['H5P.Gone-2.3']) + expect(walk.libraries.map((library) => library.folder)).toEqual(['H5P.A-1.0']) + }) +}) + +describe('the sort', () => { + it('puts every node before the nodes its edges point at', () => { + expect(toposort([['a', 'b'], ['b', 'c'], ['a', 'c']])).toEqual(['a', 'b', 'c']) + }) +}) + +describe("a library's files", () => { + it('are named under its folder, in the order its library.json gives', () => { + expect(filesIn('H5P.A-1.0', [{ path: 'js/a.js' }, { path: 'dist/b.js' }])).toEqual(['H5P.A-1.0/js/a.js', 'H5P.A-1.0/dist/b.js']) + }) + + it('leave out a path that would leave the folder, and anything that is not one', () => { + const paths = ['../other/x.js', 'js/../../x.js', '/x.js', '\\\\x.js', 'https://cdn.example/x.js', 'c:x.js', '..\\\\x.js', '', 7, null] + expect(filesIn('H5P.A-1.0', paths.map((path) => ({ path })))).toEqual([]) + expect(filesIn('H5P.A-1.0', 'js/a.js')).toEqual([]) + }) +}) + +describe('what the frame is told to load', () => { + const walk = (folder: string, majorVersion = 1, minorVersion = 2) => + walkLibraries([{ machineName: 'H5P.Main', majorVersion: 1, minorVersion: 0 }], async () => ({ + folder, + library: { machineName: 'H5P.Main', majorVersion, minorVersion, preloadedJs: [{ path: 'main.js' }] } + })) + const manifest = { mainLibrary: 'H5P.Main', preloadedDependencies: [{ machineName: 'H5P.Main', majorVersion: 1, minorVersion: 0 }] } + + it('names the main library at the version served, in full', async () => { + const load = libraryLoad(manifest, await walk('H5P.Main-1.2')) + expect(load).toEqual({ library: 'H5P.Main 1.2', scripts: ['H5P.Main-1.2/main.js'], styles: [], directories: {} }) + // A minor of 0 is written, where h5p-standalone left it out and the core read the folder as + // `H5P.Main-1.undefined`. + expect(libraryLoad(manifest, await walk('H5P.Main-1.0', 1, 0))?.library).toBe('H5P.Main 1.0') + }) + + it("tells the core where a library is that a folder's name does not say", async () => { + // An older package keeps a library in a folder named without its version; the core's + // `H5P.getLibraryPath` builds the name with one. + expect(libraryLoad(manifest, await walk('H5P.Main'))?.directories).toEqual({ 'H5P.Main-1.2': 'H5P.Main' }) + }) + + it('is nothing when the main library is not among the dependencies', async () => { + expect(libraryLoad({ ...manifest, mainLibrary: 'H5P.Other' }, await walk('H5P.Main-1.2'))).toBeNull() + }) +}) diff --git a/packages/player/tests/unit/merged-manifest.test.ts b/packages/player/tests/unit/merged-manifest.test.ts index dc69e7c..eb1a662 100644 --- a/packages/player/tests/unit/merged-manifest.test.ts +++ b/packages/player/tests/unit/merged-manifest.test.ts @@ -92,7 +92,7 @@ describe('mergedManifest with the pack attached', () => { it('keeps a dependency the pack carries only in a newer minor, as the content wrote it', async () => { const merged = await (await withPack(stripped)).mergedManifest() // 1.20 and 1.16, not the pack's 1.21 and 1.17: the file server maps the folder, and the - // content's own version is what h5p-standalone asks for. + // content's own version is what the runtime asks for. expect(merged.preloadedDependencies?.[0]).toEqual({ machineName: 'H5P.QuestionSet', majorVersion: '1', minorVersion: '20' }) expect(merged.preloadedDependencies?.[1]).toMatchObject({ machineName: 'H5P.MultiChoice', minorVersion: 16 }) }) diff --git a/packages/player/tests/unit/routes.test.ts b/packages/player/tests/unit/routes.test.ts index f2f13d6..ff53447 100644 --- a/packages/player/tests/unit/routes.test.ts +++ b/packages/player/tests/unit/routes.test.ts @@ -33,6 +33,12 @@ describe('matchRoute', () => { expect(matchRoute(routes, `${routes.frame}${PKG}`)).toEqual({ kind: 'frame', pkgId: PKG }) }) + it("matches the frame's question of what to load, under the same load", () => { + expect(routes.boot).toBe('https://site.example/assets/h5p/boot/') + expect(matchRoute(routes, `${routes.boot}${loadSegment({ pkgId: PKG, view: VIEW })}`)).toEqual({ kind: 'boot', pkgId: PKG, view: VIEW }) + expect(matchRoute(routes, `${routes.boot}not-a-package`)).toEqual({ kind: 'none' }) + }) + it('matches an entry request and keeps the whole path', () => { expect(matchRoute(routes, `${routes.virtual}${PKG}/content/images/a.png`)).toEqual({ kind: 'entry', diff --git a/packages/runtime/NOTICE.md b/packages/runtime/NOTICE.md index 857d0f0..99f38f5 100644 --- a/packages/runtime/NOTICE.md +++ b/packages/runtime/NOTICE.md @@ -8,31 +8,35 @@ which is MIT, carries none of this: the player's element and workers exchange on ## The H5P runtime in `dist/` — GPL-3.0 -`dist/` holds the runtime the player loads inside its frame, taken from -[h5p-standalone](https://github.com/tunapanda/h5p-standalone): the scripts' code unmodified, -each given a leading licence comment, the stylesheet rebuilt into `h5p.css` with its icon fonts -and image inlined as data URLs and its Inter and Open Sans faces moved to `fonts/`, listed in -`MANIFEST.json` for the player's frame document to declare. h5p-standalone's own code is MIT -(Copyright (c) 2015 Tunapanda), but `frame.bundle.js` is built from the H5P core scripts in its -`vendor/h5p/js/` — `h5p.js`, `h5p-event-dispatcher.js`, `h5p-x-api.js`, `h5p-x-api-event.js`, -`h5p-content-type.js`, `h5p-confirmation-dialog.js`, `request-queue.js`, `h5p-action-bar.js`, -`h5p-tooltip.js` — which come from [h5p/h5p-php-library](https://github.com/h5p/h5p-php-library), -licensed under the **GNU General Public License v3.0**. The core stylesheet (`h5p.css`), the -`h5p-*` icon fonts and the throbber image inlined in it come from the same repository. Upstream -confirmed this in [tunapanda/h5p-standalone#188](https://github.com/tunapanda/h5p-standalone/issues/188); -its npm metadata still says MIT. +`dist/` holds the runtime the player loads inside its frame: the H5P core from +[h5p/h5p-php-library](https://github.com/h5p/h5p-php-library), licensed under the **GNU General +Public License v3.0**, at the commit `vendor/h5p-core/SOURCE.json` in this package's source names +(`cb64a1f3884408487c3178e31fc140f5d45ca165` of 2026-08-11 as of this writing; `dist/NOTICE.txt` +and `dist/MANIFEST.json` name the one a build took). `frame.bundle.js` is the core's scripts — +`jquery.js`, `h5p.js`, `h5p-event-dispatcher.js`, `h5p-x-api-event.js`, `h5p-x-api.js`, +`h5p-content-type.js`, `h5p-confirmation-dialog.js`, `h5p-action-bar.js`, `request-queue.js`, +`h5p-tooltip.js`, the list and the order upstream's `H5PCore::$scripts` gives — joined into one +file under a licence comment and minified. `h5p.css` is the core's stylesheets, the eight +`H5PCore::$styles` names, joined and rebuilt so that it stands alone: its icon fonts and image +inlined as data URLs, its Inter and Open Sans faces moved to `fonts/` and listed in +`MANIFEST.json` for the player's frame document to declare. `vendor/h5p-core/` in the +[repository](https://github.com/missing-elements/h5p-offline-player/tree/main/packages/runtime) +holds the files as upstream has them, and `scripts/sync-core.mjs` takes them from upstream. + +Until 2026-10-18 the core came by way of [h5p-standalone](https://github.com/tunapanda/h5p-standalone), +whose copy of it records no upstream revision +([tunapanda/h5p-standalone#188](https://github.com/tunapanda/h5p-standalone/issues/188)); none of +h5p-standalone's own code is in this package any more. The full licence text is in [LICENSE](LICENSE) and, so that it travels with the files, in -`dist/LICENSE.txt`; `dist/NOTICE.txt` lists the files and names the h5p-standalone tag actually -vendored, which is the corresponding source. Keep both beside the directory when you copy or -serve it. h5p-standalone does not record which h5p-php-library revision `vendor/h5p/` was taken -from. +`dist/LICENSE.txt`; `dist/NOTICE.txt` lists the files and names the h5p-php-library commit they +were built from, which is the corresponding source together with this package's build. Keep both +beside the directory when you copy or serve it. Also inside `dist/`: -- jQuery 3.5.1, in `frame.bundle.js` — MIT, Copyright JS Foundation and other contributors, - . -- regenerator-runtime, in `main.bundle.js` — MIT, named in the file's own leading comment. +- jQuery 3.5.1, in `frame.bundle.js`, where the core keeps it in `js/jquery.js` — MIT, Copyright + JS Foundation and other contributors, . - Inter, `fonts/inter-*.woff2` — SIL Open Font License 1.1, see `fonts/Inter-LICENSE.txt`. - Open Sans, `fonts/open-sans-*.woff2` — SIL Open Font License 1.1, see `fonts/OpenSans-OFL.txt`. @@ -45,7 +49,10 @@ of the player's own code that runs inside the H5P document and calls the core's element, which talks to it over `postMessage` only. It includes `src/content-upgrade.ts`, which runs the libraries' own upgrade steps on content written for an older minor, by the rules of `js/h5p-content-upgrade-process.js` in h5p-php-library (GPL-3.0); it was written for this package -rather than copied from that file. +rather than copied from that file. And it carries the core's English interface strings, the +player's part of `getLocalization()` in `h5p.classes.php` at the same commit as the core, which +`scripts/sync-core.mjs` takes into `vendor/h5p-core/strings.json`: the settings it builds start +from them, and a language's strings from `l10n.js` are laid over them. It also includes `src/content-check.ts`, which checks the content of a package played `untrusted` against its libraries' semantics. That one is a port: of `H5PContentValidator` in `h5p.classes.php` @@ -73,7 +80,7 @@ had none or one that broke a placeholder. The file opens with a comment naming i A site that serves `dist/` to browsers is distributing GPL-3.0 code, with what that entails for keeping the notices and making the source available; the notices above and the files beside the directory are what it needs. With a bundler the files are emitted under hashed names and the -text files do not travel; `frame.bundle.js`, `main.bundle.js`, `h5p.css`, `frame-boot.js` and +text files do not travel; `frame.bundle.js`, `h5p.css`, `frame-boot.js` and `l10n.js` therefore open with a `/*! … */` comment naming their licences and the corresponding source, which bundlers leave in an emitted asset. diff --git a/packages/runtime/README.md b/packages/runtime/README.md index 2538d87..de0be5d 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -26,10 +26,19 @@ Without one, point the element at a copy of `dist/`: Or copy `dist/` to `frame-assets/` next to `h5p-player.js`, where the element looks by default. -`dist/` holds `main.bundle.js` and `frame.bundle.js` (h5p-standalone's loader, the H5P core and -jQuery), `h5p.css`, `fonts/`, `frame-boot.js`, `l10n.js`, `MANIFEST.json`, `LICENSE.txt` and -`NOTICE.txt`. Use a runtime version within the range the player names as its optional peer -dependency. +`dist/` holds `frame.bundle.js` (the H5P core's scripts, jQuery among them), `h5p.css`, `fonts/`, +`frame-boot.js`, `l10n.js`, `MANIFEST.json`, `LICENSE.txt` and `NOTICE.txt`. Use a runtime version +within the range the player names as its optional peer dependency. + +## The H5P core + +The core is h5p-php-library's, the scripts and stylesheets an H5P site loads, at the commit +`vendor/h5p-core/SOURCE.json` names, with the list of files and their order taken from +`H5PCore::$scripts` and `H5PCore::$styles` at that commit. `pnpm core:sync master` in this package +moves it to upstream's newest commit, `pnpm core:sync ` to another, and the build joins the +files as they are. The boot script builds the core's settings and loads each library's scripts and +stylesheets in the order the player's Service Worker gives it, which is the order h5p-standalone +loaded them in until this package dropped it. ## Checking content diff --git a/packages/runtime/index.d.ts b/packages/runtime/index.d.ts index 5dddf68..1bdf9e2 100644 --- a/packages/runtime/index.d.ts +++ b/packages/runtime/index.d.ts @@ -17,9 +17,7 @@ export interface RuntimeFont { export interface FrameRuntime { /** This package's version. */ version: string - /** h5p-standalone `main.bundle.js`: the loader that walks dependencies and boots the runtime. */ - mainJs: string - /** h5p-standalone `frame.bundle.js`: h5p.js, jQuery and the core runtime. */ + /** `frame.bundle.js`: the H5P core's scripts, jQuery and h5p.js among them, from h5p-php-library. */ frameJs: string /** The core stylesheet, rebuilt to stand alone: icon fonts and images inlined. */ frameCss: string diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 3abafde..81943e1 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -34,21 +34,19 @@ ], "keywords": [ "h5p", - "h5p-standalone", + "h5p-core", "h5p-offline-player", "runtime" ], "scripts": { "build": "node scripts/build.mjs", + "core:sync": "node scripts/sync-core.mjs", "l10n": "node scripts/build-l10n.mjs", "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json", "test": "pnpm typecheck && vitest run", "test:unit": "pnpm typecheck && vitest run", "prepublishOnly": "pnpm test && pnpm build" }, - "dependencies": { - "h5p-standalone": "^3.8.2" - }, "devDependencies": { "@types/node": "^26.6.5", "@zip.js/zip.js": "^2.23.0", diff --git a/packages/runtime/scripts/build.mjs b/packages/runtime/scripts/build.mjs index c6d0936..84e04b6 100644 --- a/packages/runtime/scripts/build.mjs +++ b/packages/runtime/scripts/build.mjs @@ -1,16 +1,22 @@ import { cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { build } from 'esbuild' +import { build, transform } from 'esbuild' +import { CORE_DIR } from './sync-core.mjs' /** - * Builds `dist/`: the h5p-standalone runtime vendored from `node_modules`, the frame's boot - * script compiled from `src/frame-boot.ts`, and the notices — everything the player loads inside - * its frame, and nothing the player itself is made of. Then writes `index.js`, which names every - * one of those files with a static `new URL` so that a bundler emits them. + * Builds `dist/`: the H5P core from `vendor/h5p-core/`, which `scripts/sync-core.mjs` takes from + * h5p/h5p-php-library at a pinned commit, the frame's boot script compiled from + * `src/frame-boot.ts`, and the notices — everything the player loads inside its frame, and nothing + * the player itself is made of. Then writes `index.js`, which names every one of those files with + * a static `new URL` so that a bundler emits them. * - * The runtime scripts' code is copied unmodified; each only gains a leading licence comment (see - * below). The player synthesizes the document they run in; this package does not patch H5P. + * `frame.bundle.js` is the core's scripts in the order `H5PCore::$scripts` gives, joined into one + * file under a licence comment and minified: the way an H5P site loads them, one global script + * after another. Minifying keeps every top-level name, which is what the scripts share, and only + * shortens the names inside functions; h5p-standalone's bundle was minified too, and unminified + * the file costs every frame's first load about 20 kB more, gzipped. The player synthesizes the + * document the scripts run in; this package does not patch H5P. * * Every file in `dist/` stands alone, because `index.js` names each one with its own * `new URL('./dist/', import.meta.url)` and a consumer's bundler emits each under a hashed @@ -33,9 +39,8 @@ import { build } from 'esbuild' * from the committed `l10n/`, which `scripts/build-l10n.mjs` writes. * * `dist/` also gets a `LICENSE.txt` and a `NOTICE.txt`, written here rather than kept by hand so - * that they always name the h5p-standalone version actually vendored — that tag is the - * corresponding source — and so that they land beside the files they describe wherever `dist/` - * is copied or served. + * that they always name the h5p-php-library commit actually vendored — the corresponding source — + * and so that they land beside the files they describe wherever `dist/` is copied or served. */ const rootDir = resolve(import.meta.dirname, '..') @@ -69,51 +74,46 @@ export const DIST_DIR = resolve(rootDir, 'dist') */ export async function buildRuntime({ quiet = false } = {}) { const packageJson = JSON.parse(await readFile(resolve(rootDir, 'package.json'), 'utf8')) - const standaloneDir = resolve(rootDir, 'node_modules', 'h5p-standalone', 'dist') - - if (!(await exists(standaloneDir))) { - fail('node_modules/h5p-standalone is missing. Run "pnpm install" at the repository root first.') - } - - const standaloneVersion = JSON.parse( - await readFile(resolve(rootDir, 'node_modules', 'h5p-standalone', 'package.json'), 'utf8') - ).version + const sourcePath = resolve(CORE_DIR, 'SOURCE.json') + if (!(await exists(sourcePath))) fail('vendor/h5p-core/ is missing. Run "pnpm core:sync" in packages/runtime.') + /** @type {{ repository: string, commit: string, date: string, coreApi: string, scripts: string[], styles: string[] }} */ + const core = JSON.parse(await readFile(sourcePath, 'utf8')) + const corresponding = `${core.repository}/tree/${core.commit}` + const coreName = `h5p/h5p-php-library at ${core.commit.slice(0, 10)} (${core.date.slice(0, 10)})` await rm(DIST_DIR, { recursive: true, force: true }) await mkdir(DIST_DIR, { recursive: true }) - // `main.bundle.js` walks the package's dependencies; `frame.bundle.js` is h5p.js, jQuery and the - // core runtime. Each is given a `/*! … */` header naming its licences, because a consumer's - // bundler emits each file on its own under a hashed name and none of the text files in this - // directory travel with it — a user's build of the player's 0.1.5 shipped `frame.bundle-*.js`, - // the GPL file, with no notice at all, and `main.bundle-*.js` pointing at a - // `main.bundle.js.LICENSE.txt` that was never emitted. Bundlers keep a `/*!` comment in an asset - // they emit as is, and webpack's Terser, which minifies emitted `.js` assets too, moves it into - // a `.LICENSE.txt` it does ship. The pointer webpack left in `main.bundle.js` is replaced by the - // notice it pointed at, so that file is not shipped. Nothing after the first line changes. - const corresponding = `https://github.com/tunapanda/h5p-standalone/tree/v${standaloneVersion}` - const headers = { - 'frame.bundle.js': `/*! H5P core runtime (h5p.js and its companions) from h5p/h5p-php-library, GPL-3.0-only (https://github.com/h5p/h5p-php-library). jQuery 3.5.1, MIT (https://jquery.org/license). h5p-standalone ${standaloneVersion}, MIT, Copyright (c) 2015 Tunapanda. Corresponding source: ${corresponding}. See NOTICE.md in @missing-elements/h5p-runtime. */`, - 'main.bundle.js': `/*! h5p-standalone ${standaloneVersion}, MIT, Copyright (c) 2015 Tunapanda (${corresponding}). See NOTICE.md in @missing-elements/h5p-runtime. */` + // The core's scripts, one after another. A `;` between them, so that a file ending without one + // cannot run into the next: `})` followed by `(function` would call the one with the other. + // The file opens with a `/*! … */` comment naming its licences, because a consumer's bundler + // emits each file on its own under a hashed name and none of the text files in this directory + // travel with it — a user's build of the player's 0.1.5 shipped the GPL runtime with no notice + // at all. Bundlers keep a `/*!` comment in an asset they emit as is, and webpack's Terser, which + // minifies emitted `.js` assets too, moves it into a `.LICENSE.txt` it does ship. + const scripts = [] + for (const path of core.scripts) { + const file = resolve(CORE_DIR, path) + if (!(await exists(file))) fail(`vendor/h5p-core/${path} is missing; run "pnpm core:sync" again`) + scripts.push(`/* ${path} */\n${(await readFile(file, 'utf8')).trimEnd()}\n;`) } - const WEBPACK_POINTER = '/*! For license information please see main.bundle.js.LICENSE.txt */\n' - - for (const [file, header] of Object.entries(headers)) { - const source = resolve(standaloneDir, file) - if (!(await exists(source))) fail(`Required runtime file missing: ${source}`) - let code = await readFile(source, 'utf8') - if (file === 'main.bundle.js') { - if (!code.startsWith(WEBPACK_POINTER)) fail(`${source} no longer starts with webpack's licence pointer`) - const pointed = (await readFile(resolve(standaloneDir, 'main.bundle.js.LICENSE.txt'), 'utf8')).trim() - code = `${pointed}\n${code.slice(WEBPACK_POINTER.length)}` - } - await writeFile(resolve(DIST_DIR, file), `${header}\n${code}`, 'utf8') + const jquery = /^\/\*! jQuery v(\d+\.\d+\.\d+)/.exec(await readFile(resolve(CORE_DIR, 'js', 'jquery.js'), 'utf8'))?.[1] + if (!jquery) fail("vendor/h5p-core/js/jquery.js no longer opens with jQuery's licence comment") + const frameHeader = `/*! H5P core runtime (${core.scripts.map((path) => path.slice('js/'.length)).join(', ')}) from ${coreName}, GPL-3.0-only. Includes jQuery ${jquery}, MIT (https://jquery.org/license). Corresponding source: ${corresponding}. See NOTICE.txt beside this file or NOTICE.md in @missing-elements/h5p-runtime. */` + // A plain script, not a module: no format is given, so top-level names stay as they are. The + // target is the one the boot script is built for; the core itself uses ES2020 syntax already. + const { code: frameCode } = await transform(scripts.join('\n'), { loader: 'js', minify: true, legalComments: 'inline', target: 'es2022' }) + await writeFile(resolve(DIST_DIR, 'frame.bundle.js'), `${frameHeader}\n${frameCode}`, 'utf8') + + // The core's stylesheets, one after another in the order `H5PCore::$styles` gives, as one sheet. + const stylesDir = resolve(CORE_DIR, 'styles') + const sheets = [] + for (const path of core.styles) { + const file = resolve(CORE_DIR, path) + if (!(await exists(file))) fail(`vendor/h5p-core/${path} is missing; run "pnpm core:sync" again`) + sheets.push(await readFile(file, 'utf8')) } - - // `styles/h5p.css` is the stylesheet the runtime loads; `h5p-fonts.css` beside it repeats its - // `@font-face` rules for the editor and is never requested by a player. - const stylesheet = resolve(standaloneDir, 'styles', 'h5p.css') - if (!(await exists(stylesheet))) fail(`Required runtime file missing: ${stylesheet}`) + const stylesheet = 'vendor/h5p-core/styles' // Rules are matched on the source text rather than parsed; `@font-face` bodies hold no braces. const FONT_FACE = /@font-face\s*\{([^}]*)\}/g @@ -123,7 +123,7 @@ export async function buildRuntime({ quiet = false } = {}) { // A face that does not fit the shape the player's frame document accepts fails the build here, // where it is seen, rather than being dropped by the frame at runtime, where nothing would say // why the text fell back. - const source = await readFile(stylesheet, 'utf8') + const source = sheets.join('\n') const fonts = [] const stripped = source.replace(FONT_FACE, (rule, body) => { const url = body.match(TEXT_FONT) @@ -138,7 +138,7 @@ export async function buildRuntime({ quiet = false } = {}) { // overwrite the first under this name, so it is refused until the naming accounts for it. const file = `${url[1]}-${weight}${style === 'italic' ? '-italic' : ''}.woff2` if (fonts.some((font) => font.file === file)) fail(`Two @font-face rules map to ${file} in ${stylesheet}: ${rule}`) - fonts.push({ family, style, weight, file, source: resolve(standaloneDir, 'fonts', url[1], url[2]) }) + fonts.push({ family, style, weight, file, source: resolve(CORE_DIR, 'fonts', url[1], url[2]) }) return '' }) if (fonts.length === 0) fail(`No Inter or Open Sans @font-face found in ${stylesheet}`) @@ -150,7 +150,7 @@ export async function buildRuntime({ quiet = false } = {}) { } await build({ - stdin: { contents: stripped, resolveDir: resolve(standaloneDir, 'styles'), loader: 'css', sourcefile: 'h5p.css' }, + stdin: { contents: stripped, resolveDir: stylesDir, loader: 'css', sourcefile: 'h5p.css' }, outfile: resolve(DIST_DIR, 'h5p.css'), bundle: true, minify: true, @@ -161,7 +161,7 @@ export async function buildRuntime({ quiet = false } = {}) { // reachable even where LICENSE.txt and NOTICE.txt beside it do not travel. legalComments: 'none', banner: { - css: `/*! H5P core stylesheet from h5p-standalone ${standaloneVersion}, GPL-3.0 (https://github.com/h5p/h5p-php-library). See NOTICE.txt beside this file or NOTICE.md in @missing-elements/h5p-runtime. */` + css: `/*! H5P core stylesheets (${core.styles.map((path) => path.slice('styles/'.length)).join(', ')}) from ${coreName}, GPL-3.0-only. Corresponding source: ${corresponding}. See NOTICE.txt beside this file or NOTICE.md in @missing-elements/h5p-runtime. */` }, loader: { '.woff2': 'dataurl', '.woff': 'dataurl', '.gif': 'dataurl', '.svg': 'dataurl', '.png': 'dataurl' } }) @@ -185,8 +185,8 @@ export async function buildRuntime({ quiet = false } = {}) { }) // The fonts' licences, which sit in their own folders upstream. - await cp(resolve(standaloneDir, 'fonts', 'inter', 'LICENSE.txt'), resolve(DIST_DIR, 'fonts', 'Inter-LICENSE.txt')) - await cp(resolve(standaloneDir, 'fonts', 'open-sans', 'OFL.txt'), resolve(DIST_DIR, 'fonts', 'OpenSans-OFL.txt')) + await cp(resolve(CORE_DIR, 'fonts', 'inter', 'LICENSE.txt'), resolve(DIST_DIR, 'fonts', 'Inter-LICENSE.txt')) + await cp(resolve(CORE_DIR, 'fonts', 'open-sans', 'OFL.txt'), resolve(DIST_DIR, 'fonts', 'OpenSans-OFL.txt')) // The core's strings in other languages, from `l10n/` (`scripts/build-l10n.mjs`), all in one // classic script with the aliases beside them: the player's frame document names it before the @@ -217,13 +217,13 @@ export async function buildRuntime({ quiet = false } = {}) { const manifest = { runtime: packageJson.version, - h5pStandalone: standaloneVersion, + core: { repository: core.repository, commit: core.commit, date: core.date, coreApi: core.coreApi }, fonts: fonts.map(({ family, style, weight, file }) => ({ family, style, weight, file })) } await writeFile(resolve(DIST_DIR, 'MANIFEST.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') await cp(resolve(rootDir, 'LICENSE'), resolve(DIST_DIR, 'LICENSE.txt')) - await writeFile(resolve(DIST_DIR, 'NOTICE.txt'), distNotice(standaloneVersion, packageJson.version), 'utf8') + await writeFile(resolve(DIST_DIR, 'NOTICE.txt'), distNotice(core, jquery, packageJson.version), 'utf8') // `index.js`: one static `new URL` per file, the one shape Vite, Rollup and webpack 5 emit as an // asset and rewrite. Generated, because the font list comes from the sheet. Written only when @@ -233,7 +233,7 @@ export async function buildRuntime({ quiet = false } = {}) { const currentIndex = (await exists(indexPath)) ? await readFile(indexPath, 'utf8') : '' if (currentIndex !== index) await writeFile(indexPath, index, 'utf8') - if (!quiet) console.log(`[h5p-runtime] h5p-standalone v${standaloneVersion} and the boot script -> dist/`) + if (!quiet) console.log(`[h5p-runtime] the H5P core from ${coreName}, core API ${core.coreApi}, and the boot script -> dist/`) return manifest } @@ -264,7 +264,6 @@ function unbundled(href) { */ export const runtime = { version: ${JSON.stringify(version)}, - mainJs: ${url('main.bundle.js')}, frameJs: ${url('frame.bundle.js')}, frameCss: ${url('h5p.css')}, bootJs: ${url('frame-boot.js')}, @@ -282,44 +281,54 @@ ${fonts ` } -function distNotice(standalone, runtime) { +function distNotice(core, jquery, runtime) { + /** A list of file names, as lines of the notice indented under its item. */ + const wrap = (names) => { + const lines = [''] + for (const [index, name] of names.entries()) { + const word = index < names.length - 1 ? `${name},` : name + if (lines.at(-1) && lines.at(-1).length + word.length + 1 > 84) lines.push('') + lines[lines.length - 1] = lines.at(-1) ? `${lines.at(-1)} ${word}` : word + } + return lines.join('\n ') + } return `@missing-elements/h5p-runtime ${runtime} -- third-party notice These files are the H5P runtime that loads inside its frame, and the script that -boots it there. The runtime was copied from h5p-standalone ${standalone} -(https://github.com/tunapanda/h5p-standalone, tag v${standalone}): the scripts' code unmodified, -each given a leading licence comment, the stylesheet rebuilt so that it stands alone, its icon -fonts and images inlined as data URLs, its Inter and Open Sans faces moved to fonts/ and listed -in MANIFEST.json for the player's frame document to declare. Keep this file and LICENSE.txt -beside them when you copy or serve the directory. +boots it there. The runtime is the H5P core from h5p/h5p-php-library at commit +${core.commit} (${core.date.slice(0, 10)}, core API ${core.coreApi}): +the scripts as upstream wrote them, joined into one file under a licence comment, and the +stylesheets joined and rebuilt so that the sheet stands alone, its icon fonts and image inlined +as data URLs and its Inter and Open Sans faces moved to fonts/ and listed in MANIFEST.json for +the player's frame document to declare. Keep this file and LICENSE.txt beside them when you copy +or serve the directory. frame.bundle.js - - H5P core: h5p.js, h5p-event-dispatcher.js, h5p-x-api.js, h5p-x-api-event.js, - h5p-content-type.js, h5p-confirmation-dialog.js, request-queue.js, h5p-action-bar.js, - h5p-tooltip.js, from https://github.com/h5p/h5p-php-library (vendor/h5p/js/ in - h5p-standalone). GNU General Public License v3.0 -- LICENSE.txt in this directory. - - jQuery 3.5.1. MIT, Copyright JS Foundation and other contributors, https://jquery.org/license - - h5p-standalone's own frame code. MIT, Copyright (c) 2015 Tunapanda. + - H5P core, from https://github.com/h5p/h5p-php-library: + ${wrap(core.scripts.map((path) => path.slice('js/'.length)))}. GNU General Public License v3.0 -- + LICENSE.txt in this directory. + - jQuery ${jquery}, in jquery.js above. MIT, Copyright JS Foundation and other contributors, + https://jquery.org/license h5p.css - - H5P core styles/h5p.css, with the h5p-core-30, h5p-hub-publish and h5p-theme icon fonts and - images/throbber.gif inlined. Same origin, GNU General Public License v3.0 -- LICENSE.txt. + - H5P core, from the same repository: + ${wrap(core.styles.map((path) => path.slice('styles/'.length)))}, + with the h5p-core-30, h5p-hub-publish and h5p-theme icon fonts and images/throbber.gif + inlined. Same origin, GNU General Public License v3.0 -- LICENSE.txt. fonts/inter-* Inter, SIL Open Font License 1.1 -- fonts/Inter-LICENSE.txt fonts/open-sans-* Open Sans, SIL Open Font License 1.1 -- fonts/OpenSans-OFL.txt -main.bundle.js - - h5p-standalone. MIT, Copyright (c) 2015 Tunapanda. Includes regenerator-runtime (MIT), - named in its own leading comment. - frame-boot.js - The frame's boot script, from src/frame-boot.ts in this package. Copyright (c) 2026 missing-elements, GNU General Public License v3.0 -- LICENSE.txt. It runs in the H5P document and calls the core's API, which is why it is part of this package and not of - the MIT player. It includes a port of H5PContentValidator from h5p/h5p-php-library - (GNU General Public License v3.0) and of the filter_xss functions of Drupal 7's - includes/common.inc (Copyright Drupal contributors, GNU General Public License v2.0 or - later), which check the content of a package played untrusted. + the MIT player. It builds the core's settings from the core's own English strings + (H5PCore::getLocalization() at the commit above), and includes a port of + H5PContentValidator from h5p/h5p-php-library (GNU General Public License v3.0) and of + the filter_xss functions of Drupal 7's includes/common.inc (Copyright Drupal + contributors, GNU General Public License v2.0 or later), which check the content of a + package played untrusted. l10n.js - The core's interface strings in other languages, taken from Moodle's language packs @@ -329,10 +338,10 @@ l10n.js when they were taken. Corresponding source for the GPL parts as built: -https://github.com/tunapanda/h5p-standalone/tree/v${standalone} (src/ and vendor/h5p/), and -https://github.com/missing-elements/h5p-offline-player/tree/main/packages/runtime. -h5p-standalone does not record which h5p-php-library revision vendor/h5p/ was taken from -(https://github.com/tunapanda/h5p-standalone/issues/188). +${core.repository}/tree/${core.commit} +(js/ and styles/), and +https://github.com/missing-elements/h5p-offline-player/tree/main/packages/runtime, whose +vendor/h5p-core/ holds the same files and scripts/ the build that joins them. ` } diff --git a/packages/runtime/scripts/l10n-rules.mjs b/packages/runtime/scripts/l10n-rules.mjs index 9d37d4d..bd5ee32 100644 --- a/packages/runtime/scripts/l10n-rules.mjs +++ b/packages/runtime/scripts/l10n-rules.mjs @@ -7,38 +7,20 @@ import { resolve } from 'node:path' */ /** - * The core's interface strings in English, by key: the `l10n.H5P` defaults h5p-standalone puts - * into `H5PIntegration`, which the frame lays a language's strings over. Read from the installed - * bundle, so the keys are the ones the vendored core knows, and in its order. + * The core's interface strings in English, by key: the player's part of `getLocalization()` in + * the vendored core, which the boot script puts into `H5PIntegration` and lays a language's + * strings over. `scripts/sync-core.mjs` writes it with the core, so the keys are the ones the + * vendored core knows, and in its order. They came from h5p-standalone's bundle until the core was + * vendored (#37): the same 67, word for word. * * @returns {Record} */ export function coreStrings() { - const path = resolve(import.meta.dirname, '..', 'node_modules', 'h5p-standalone', 'dist', 'main.bundle.js') - const bundle = readFileSync(path, 'utf8') - const start = bundle.indexOf('l10n:{H5P:{') - if (start < 0) throw new Error(`[h5p-runtime] ${path} no longer defines l10n.H5P where expected`) - - // The object literal, matched brace for brace; a brace inside a string does not count. - let depth = 0 - let quote = '' - let end = start + 'l10n:'.length - for (; end < bundle.length; end++) { - const char = bundle[end] - if (quote) { - if (char === '\\') end++ - else if (char === quote) quote = '' - } else if (char === '"' || char === "'") quote = char - else if (char === '{') depth++ - else if (char === '}' && --depth === 0) break - } - const literal = bundle.slice(start + 'l10n:'.length, end + 1) - if (!/^\{H5P:\{[\s\S]*\}\}$/.test(literal)) throw new Error(`[h5p-runtime] could not read l10n.H5P from ${path}`) - - /** @type {{ H5P: Record }} */ - const l10n = new Function(`return ${literal}`)() - const strings = Object.fromEntries(Object.entries(l10n.H5P).filter(([, value]) => typeof value === 'string')) - if (Object.keys(strings).length < 50) throw new Error(`[h5p-runtime] only ${Object.keys(strings).length} strings in l10n.H5P`) + const path = resolve(import.meta.dirname, '..', 'vendor', 'h5p-core', 'strings.json') + /** @type {Record} */ + const parsed = JSON.parse(readFileSync(path, 'utf8')) + const strings = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === 'string')) + if (Object.keys(strings).length < 50) throw new Error(`[h5p-runtime] only ${Object.keys(strings).length} strings in ${path}`) return /** @type {Record} */ (strings) } diff --git a/packages/runtime/scripts/sync-core.mjs b/packages/runtime/scripts/sync-core.mjs new file mode 100644 index 0000000..6b8b7a6 --- /dev/null +++ b/packages/runtime/scripts/sync-core.mjs @@ -0,0 +1,158 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { dirname, posix, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +/** + * Vendors the H5P core from h5p/h5p-php-library at a commit, into `vendor/h5p-core/`, which is + * committed: `pnpm core:sync master` moves it to upstream's newest commit, `pnpm core:sync ` + * to that one, and `pnpm core:sync` alone fetches the commit already recorded again, which leaves + * the tree as it was unless something upstream was rewritten. + * + * What is taken, and in what order, is upstream's own word rather than a list kept here: the + * scripts and stylesheets a player loads are `H5PCore::$scripts` and `H5PCore::$styles` in + * `h5p.classes.php` at that commit, the fonts and images are the ones those stylesheets reference, + * and the core's English strings are the player's part of `H5PCore::getLocalization()`, which + * `scripts/build.mjs` gives the frame as its defaults and `scripts/build-l10n.mjs` translates. + * The commit, its date and those lists go into `SOURCE.json`, which the build reads. + * + * Until 2026-10-18 the core came through h5p-standalone, which copies it about once a year and + * records no revision; issue #37. + */ + +const REPOSITORY = 'h5p/h5p-php-library' +const rootDir = resolve(import.meta.dirname, '..') +export const CORE_DIR = resolve(rootDir, 'vendor', 'h5p-core') + +/** + * `getLocalization()` lists the player's strings and then, from `mainTitle` on, the strings of the + * hub's sharing wizard, which only an editor shows. The player's are taken: the keys a frame's + * `H5PIntegration.l10n.H5P` has always had, and the ones Moodle's packs translate. + */ +const FIRST_EDITOR_STRING = 'mainTitle' + +const fail = (message) => { + throw new Error(`[h5p-core] ${message}`) +} + +/** GitHub's API, with a token when the environment has one: sixty requests an hour without. */ +async function api(path) { + const headers = { accept: 'application/vnd.github+json', 'user-agent': 'h5p-offline-player core sync' } + if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}` + const response = await fetch(`https://api.github.com/repos/${REPOSITORY}/${path}`, { headers }) + if (!response.ok) fail(`GitHub answered ${response.status} for ${path}`) + return response.json() +} + +async function raw(commit, path) { + const response = await fetch(`https://raw.githubusercontent.com/${REPOSITORY}/${commit}/${path}`) + if (!response.ok) fail(`${path} at ${commit}: ${response.status}`) + return new Uint8Array(await response.arrayBuffer()) +} + +/** A PHP string literal's value: single quotes take `\'` and `\\`, double quotes a few more. */ +function phpString(literal) { + const quote = literal[0] + const body = literal.slice(1, -1) + if (quote === "'") return body.replace(/\\(['\\])/g, '$1') + return body.replace(/\\(["\\$])/g, '$1').replace(/\\n/g, '\n').replace(/\\t/g, '\t') +} + +const STRING = String.raw`'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"` + +/** The entries of `public static $ = array(…);` in `h5p.classes.php`, in order. */ +export function staticList(source, name) { + const match = new RegExp(String.raw`public static \$${name} = array\(([\s\S]*?)\);`).exec(source) + if (!match) fail(`h5p.classes.php no longer declares H5PCore::$${name}`) + return [...match[1].matchAll(new RegExp(STRING, 'g'))].map(([literal]) => phpString(literal)) +} + +/** `H5PCore::$coreApi`, as `major.minor`. */ +export function coreApi(source) { + const match = /public static \$coreApi = array\(\s*'majorVersion' => (\d+),\s*'minorVersion' => (\d+)/.exec(source) + if (!match) fail('h5p.classes.php no longer declares H5PCore::$coreApi') + return `${match[1]}.${match[2]}` +} + +/** The player's English strings from `getLocalization()`, by key, in upstream's order. */ +export function playerStrings(source) { + const start = source.indexOf('public function getLocalization()') + if (start < 0) fail('h5p.classes.php no longer has getLocalization()') + const strings = {} + const entry = new RegExp(String.raw`'([A-Za-z0-9]+)' => (?:\$this->h5pF->t\((${STRING})\)|array\()`, 'g') + entry.lastIndex = start + for (let match; (match = entry.exec(source)); ) { + if (match[1] === FIRST_EDITOR_STRING) return strings + // A nested array is the sharing wizard's too; the player has none. + if (match[2] === undefined) fail(`getLocalization() has a nested array before ${FIRST_EDITOR_STRING}: ${match[1]}`) + strings[match[1]] = phpString(match[2]) + } + return fail(`getLocalization() no longer has "${FIRST_EDITOR_STRING}", where the player's strings end`) +} + +/** The relative URLs a stylesheet reaches, `../fonts/…` and `../images/…`, without query or fragment. */ +function stylesheetAssets(css) { + return [...css.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g)] + .map(([, url]) => url.split(/[?#]/)[0]) + .filter((url) => !/^(?:[a-z]+:|\/|#)/i.test(url)) +} + +export async function syncCore(ref) { + const recorded = await readFile(resolve(CORE_DIR, 'SOURCE.json'), 'utf8').then(JSON.parse, () => null) + const wanted = ref ?? recorded?.commit + if (!wanted) fail('No commit recorded yet: name one, as in "pnpm core:sync master"') + + const { sha: commit, commit: details } = await api(`commits/${encodeURIComponent(wanted)}`) + const classes = new TextDecoder().decode(await raw(commit, 'h5p.classes.php')) + const scripts = staticList(classes, 'scripts') + const styles = staticList(classes, 'styles') + const strings = playerStrings(classes) + + for (const path of scripts) if (!/^js\/[\w.-]+\.js$/.test(path)) fail(`H5PCore::$scripts names ${path}, not a script in js/`) + for (const path of styles) if (!/^styles\/[\w.-]+\.css$/.test(path)) fail(`H5PCore::$styles names ${path}, not a stylesheet in styles/`) + if (!scripts.includes('js/jquery.js') || !scripts.includes('js/h5p.js')) fail('H5PCore::$scripts no longer names jquery.js and h5p.js') + if (Object.keys(strings).length < 60 || !strings.fullscreen) fail(`only ${Object.keys(strings).length} strings before ${FIRST_EDITOR_STRING}`) + + const files = new Map() + for (const path of [...scripts, ...styles]) files.set(path, await raw(commit, path)) + + // The fonts and images the stylesheets reach, and the licence beside each font family's files. + const { tree } = await api(`git/trees/${commit}?recursive=1`) + const inTree = new Set(tree.filter((node) => node.type === 'blob').map((node) => node.path)) + const assets = new Set() + for (const path of styles) { + for (const url of stylesheetAssets(new TextDecoder().decode(files.get(path)))) { + const asset = posix.normalize(posix.join(posix.dirname(path), url)) + if (!/^(fonts|images)\//.test(asset) || !inTree.has(asset)) fail(`${path} reaches ${url}, which is not a font or an image at ${commit}`) + assets.add(asset) + } + } + const fontFolders = new Set([...assets].filter((path) => /^fonts\/[^/]+\//.test(path)).map(posix.dirname)) + for (const path of inTree) { + if (fontFolders.has(posix.dirname(path)) && /^(LICENSE|OFL)[^/]*$/i.test(posix.basename(path))) assets.add(path) + } + for (const path of [...assets].sort()) files.set(path, await raw(commit, path)) + + await rm(CORE_DIR, { recursive: true, force: true }) + for (const [path, bytes] of files) { + await mkdir(dirname(resolve(CORE_DIR, path)), { recursive: true }) + await writeFile(resolve(CORE_DIR, path), bytes) + } + await writeFile(resolve(CORE_DIR, 'strings.json'), `${JSON.stringify(strings, null, 2)}\n`) + const source = { + repository: `https://github.com/${REPOSITORY}`, + commit, + date: details.committer.date, + coreApi: coreApi(classes), + scripts, + styles + } + await writeFile(resolve(CORE_DIR, 'SOURCE.json'), `${JSON.stringify(source, null, 2)}\n`) + + const moved = recorded && recorded.commit !== commit ? ` (was ${recorded.commit.slice(0, 10)})` : '' + console.log(`[h5p-core] ${REPOSITORY} at ${commit.slice(0, 10)}${moved}, ${source.date}, core API ${source.coreApi}: ${scripts.length} scripts, ${styles.length} stylesheets, ${assets.size} fonts and images, ${Object.keys(strings).length} strings -> vendor/h5p-core/`) + return source +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + await syncCore(process.argv[2]) +} diff --git a/packages/runtime/src/frame-boot.ts b/packages/runtime/src/frame-boot.ts index 60923ad..792d512 100644 --- a/packages/runtime/src/frame-boot.ts +++ b/packages/runtime/src/frame-boot.ts @@ -1,7 +1,13 @@ /** - * Runs inside the frame. It boots h5p-standalone against the virtual file server, forwards xAPI - * to the element and relays the worker's job requests — the Service Worker can only message a - * client, and the element is the one that owns the Jobs worker. + * Runs inside the frame. It boots the H5P core against the virtual file server, forwards xAPI to + * the element and relays the worker's job requests — the Service Worker can only message a client, + * and the element is the one that owns the Jobs worker. + * + * Booting is what a site's H5P plugin does for its iframe: it writes the core's settings, + * `H5PIntegration`, loads the core's scripts and stylesheets and then each library's, and calls + * `H5P.init`. What to load comes from the worker's `boot` route, which has walked the libraries + * (`LibraryLoad` in the player); until the core was vendored (#37) h5p-standalone did all of it + * here, and the settings are built as it built them, field for field, so content sees no change. * * Built by `scripts/build.mjs` to `dist/frame-boot.js`, a classic script the player's Service * Worker names with `'; + } + return tags; + }; + + return '' + + createStyleTags(H5PIntegration.core.styles) + + createStyleTags(H5PIntegration.contents['cid-' + contentId].styles) + + createScriptTags(H5PIntegration.core.scripts) + + createScriptTags(H5PIntegration.contents['cid-' + contentId].scripts) + + ''; +}; + +/** + * When embedded the communicator helps talk to the parent page. + * + * @type {Communicator} + */ +H5P.communicator = (function () { + /** + * @class + * @private + */ + function Communicator() { + var self = this; + + // Maps actions to functions + var actionHandlers = {}; + + // Register message listener + window.addEventListener('message', function receiveMessage(event) { + if (window.parent !== event.source || event.data.context !== 'h5p') { + return; // Only handle messages from parent and in the correct context + } + + if (actionHandlers[event.data.action] !== undefined) { + actionHandlers[event.data.action](event.data); + } + } , false); + + + /** + * Register action listener. + * + * @param {string} action What you are waiting for + * @param {function} handler What you want done + */ + self.on = function (action, handler) { + actionHandlers[action] = handler; + }; + + /** + * Send a message to the all mighty father. + * + * @param {string} action + * @param {Object} [data] payload + */ + self.send = function (action, data) { + if (data === undefined) { + data = {}; + } + data.context = 'h5p'; + data.action = action; + + // Parent origin can be anything + window.parent.postMessage(data, '*'); + }; + } + + return (window.postMessage && window.addEventListener ? new Communicator() : undefined); +})(); + +/** + * Enter semi fullscreen for the given H5P instance + * + * @param {H5P.jQuery} $element Content container. + * @param {Object} instance + * @param {function} exitCallback Callback function called when user exits fullscreen. + * @param {H5P.jQuery} $body For internal use. Gives the body of the iframe. + */ +H5P.semiFullScreen = function ($element, instance, exitCallback, body) { + H5P.fullScreen($element, instance, exitCallback, body, true); +}; + +/** + * Enter fullscreen for the given H5P instance. + * + * @param {H5P.jQuery} $element Content container. + * @param {Object} instance + * @param {function} exitCallback Callback function called when user exits fullscreen. + * @param {H5P.jQuery} $body For internal use. Gives the body of the iframe. + * @param {Boolean} forceSemiFullScreen + */ +H5P.fullScreen = function ($element, instance, exitCallback, body, forceSemiFullScreen) { + if (H5P.exitFullScreen !== undefined) { + return; // Cannot enter new fullscreen until previous is over + } + + if (H5P.isFramed && H5P.externalEmbed === false) { + // Trigger resize on wrapper in parent window. + window.parent.H5P.fullScreen($element, instance, exitCallback, H5P.$body.get(), forceSemiFullScreen); + H5P.isFullscreen = true; + H5P.exitFullScreen = function () { + window.parent.H5P.exitFullScreen(); + }; + H5P.on(instance, 'exitFullScreen', function () { + H5P.isFullscreen = false; + H5P.exitFullScreen = undefined; + }); + return; + } + + var $container = $element; + var $classes, $iframe, $body; + if (body === undefined) { + $body = H5P.$body; + } + else { + // We're called from an iframe. + $body = H5P.jQuery(body); + $classes = $body.add($element.get()); + var iframeSelector = '#h5p-iframe-' + $element.parent().data('content-id'); + $iframe = H5P.jQuery(iframeSelector); + $element = $iframe.parent(); // Put iframe wrapper in fullscreen, not container. + } + + $classes = $element.add(H5P.$body).add($classes); + + /** + * Prepare for resize by setting the correct styles. + * + * @private + * @param {string} classes CSS + */ + var before = function (classes) { + $classes.addClass(classes); + + if ($iframe !== undefined) { + // Set iframe to its default size(100%). + $iframe.css('height', ''); + } + }; + + /** + * Gets called when fullscreen mode has been entered. + * Resizes and sets focus on content. + * + * @private + */ + var entered = function () { + // Do not rely on window resize events. + H5P.trigger(instance, 'resize'); + H5P.trigger(instance, 'focus'); + H5P.trigger(instance, 'enterFullScreen'); + }; + + /** + * Gets called when fullscreen mode has been exited. + * Resizes and sets focus on content. + * + * @private + * @param {string} classes CSS + */ + var done = function (classes) { + H5P.isFullscreen = false; + $classes.removeClass(classes); + + // Do not rely on window resize events. + H5P.trigger(instance, 'resize'); + H5P.trigger(instance, 'focus'); + + H5P.exitFullScreen = undefined; + if (exitCallback !== undefined) { + exitCallback(); + } + + H5P.trigger(instance, 'exitFullScreen'); + }; + + H5P.isFullscreen = true; + if (H5P.fullScreenBrowserPrefix === undefined || forceSemiFullScreen === true) { + // Create semi fullscreen. + + if (H5P.isFramed) { + return; // TODO: Should we support semi-fullscreen for IE9 & 10 ? + } + + before('h5p-semi-fullscreen'); + var $disable = H5P.jQuery('
').appendTo($container.find('.h5p-content-controls')); + var keyup, disableSemiFullscreen = H5P.exitFullScreen = function () { + if (prevViewportContent) { + // Use content from the previous viewport tag + h5pViewport.content = prevViewportContent; + } + else { + // Remove viewport tag + head.removeChild(h5pViewport); + } + $disable.remove(); + $body.unbind('keyup', keyup); + done('h5p-semi-fullscreen'); + }; + keyup = function (event) { + if (event.keyCode === 27) { + disableSemiFullscreen(); + } + }; + $disable.click(disableSemiFullscreen); + $body.keyup(keyup); + + // Disable zoom + var prevViewportContent, h5pViewport; + var metaTags = document.getElementsByTagName('meta'); + for (var i = 0; i < metaTags.length; i++) { + if (metaTags[i].name === 'viewport') { + // Use the existing viewport tag + h5pViewport = metaTags[i]; + prevViewportContent = h5pViewport.content; + break; + } + } + if (!prevViewportContent) { + // Create a new viewport tag + h5pViewport = document.createElement('meta'); + h5pViewport.name = 'viewport'; + } + h5pViewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0'; + if (!prevViewportContent) { + // Insert the new viewport tag + var head = document.getElementsByTagName('head')[0]; + head.appendChild(h5pViewport); + } + + entered(); + } + else { + // Create real fullscreen. + + before('h5p-fullscreen'); + var first, eventName = (H5P.fullScreenBrowserPrefix === 'ms' ? 'MSFullscreenChange' : H5P.fullScreenBrowserPrefix + 'fullscreenchange'); + document.addEventListener(eventName, function fullscreenCallback() { + if (first === undefined) { + // We are entering fullscreen mode + first = false; + entered(); + return; + } + + // We are exiting fullscreen + done('h5p-fullscreen'); + document.removeEventListener(eventName, fullscreenCallback, false); + }); + + if (H5P.fullScreenBrowserPrefix === '') { + $element[0].requestFullscreen(); + } + else { + var method = (H5P.fullScreenBrowserPrefix === 'ms' ? 'msRequestFullscreen' : H5P.fullScreenBrowserPrefix + 'RequestFullScreen'); + var params = (H5P.fullScreenBrowserPrefix === 'webkit' && H5P.safariBrowser === 0 ? Element.ALLOW_KEYBOARD_INPUT : undefined); + $element[0][method](params); + } + + // Allows everone to exit + H5P.exitFullScreen = function () { + if (H5P.fullScreenBrowserPrefix === '') { + document.exitFullscreen(); + } + else if (H5P.fullScreenBrowserPrefix === 'moz') { + document.mozCancelFullScreen(); + } + else { + document[H5P.fullScreenBrowserPrefix + 'ExitFullscreen'](); + } + }; + } +}; + +(function () { + /** + * Helper for adding a query parameter to an existing path that may already + * contain one or a hash. + * + * @param {string} path + * @param {string} parameter + * @return {string} + */ + H5P.addQueryParameter = function (path, parameter) { + let newPath, secondSplit; + const firstSplit = path.split('?'); + if (firstSplit[1]) { + // There is already an existing query + secondSplit = firstSplit[1].split('#'); + newPath = firstSplit[0] + '?' + secondSplit[0] + '&'; + } + else { + // No existing query, just need to take care of the hash + secondSplit = firstSplit[0].split('#'); + newPath = secondSplit[0] + '?'; + } + newPath += parameter; + if (secondSplit[1]) { + // Add back the hash + newPath += '#' + secondSplit[1]; + } + return newPath; + }; + + /** + * Helper for setting the crossOrigin attribute + the complete correct source. + * Note: This will start loading the resource. + * + * @param {Element} element DOM element, typically img, video or audio + * @param {Object} source File object from parameters/json_content (created by H5PEditor) + * @param {number} contentId Needed to determine the complete correct file path + */ + H5P.setSource = function (element, source, contentId) { + let path = source.path; + + const crossOrigin = H5P.getCrossOrigin(source); + if (crossOrigin) { + element.crossOrigin = crossOrigin; + + if (H5PIntegration.crossoriginCacheBuster) { + // Some sites may want to add a cache buster in case the same resource + // is used elsewhere without the crossOrigin attribute + path = H5P.addQueryParameter(path, H5PIntegration.crossoriginCacheBuster); + } + } + else { + // In case this element has been used before. + element.removeAttribute('crossorigin'); + } + + element.src = H5P.getPath(path, contentId); + }; + + /** + * Check if the given path has a protocol. + * + * @private + * @param {string} path + * @return {string} + */ + var hasProtocol = function (path) { + return path.match(/^[a-z0-9]+:\/\//i); + }; + + /** + * Get the crossOrigin policy to use for img, video and audio tags on the current site. + * + * @param {Object|string} source File object from parameters/json_content - Can also be URL(deprecated usage) + * @returns {string|null} crossOrigin attribute value required by the source + */ + H5P.getCrossOrigin = function (source) { + if (typeof source !== 'object') { + // Deprecated usage. + return H5PIntegration.crossorigin && H5PIntegration.crossoriginRegex && source.match(H5PIntegration.crossoriginRegex) ? H5PIntegration.crossorigin : null; + } + + if (H5PIntegration.crossorigin && !hasProtocol(source.path)) { + // This is a local file, use the local crossOrigin policy. + return H5PIntegration.crossorigin; + // Note: We cannot use this for all external sources since we do not know + // each server's individual policy. We could add support for a list of + // external sources and their policy later on. + } + }; + + /** + * Find the path to the content files based on the id of the content. + * Also identifies and returns absolute paths. + * + * @param {string} path + * Relative to content folder or absolute. + * @param {number} contentId + * ID of the content requesting the path. + * @returns {string} + * Complete URL to path. + */ + H5P.getPath = function (path, contentId) { + if (hasProtocol(path)) { + return path; + } + + var prefix; + var isTmpFile = (path.substr(-4,4) === '#tmp'); + if (contentId !== undefined && !isTmpFile) { + // Check for custom override URL + if (H5PIntegration.contents !== undefined && + H5PIntegration.contents['cid-' + contentId]) { + prefix = H5PIntegration.contents['cid-' + contentId].contentUrl; + } + if (!prefix) { + prefix = H5PIntegration.url + '/content/' + contentId; + } + } + else if (window.H5PEditor !== undefined) { + prefix = H5PEditor.filesPath; + } + else { + return; + } + + if (!hasProtocol(prefix)) { + // Use absolute urls + prefix = window.location.protocol + "//" + window.location.host + prefix; + } + + return prefix + '/' + path; + }; +})(); + +/** + * THIS FUNCTION IS DEPRECATED, USE getPath INSTEAD + * Will be remove march 2016. + * + * Find the path to the content files folder based on the id of the content + * + * @deprecated + * Will be removed march 2016. + * @param contentId + * Id of the content requesting the path + * @returns {string} + * URL + */ +H5P.getContentPath = function (contentId) { + return H5PIntegration.url + '/content/' + contentId; +}; + +/** + * Get library class constructor from H5P by classname. + * Note that this class will only work for resolve "H5P.NameWithoutDot". + * Also check out {@link H5P.newRunnable} + * + * Used from libraries to construct instances of other libraries' objects by name. + * + * @param {string} name Name of library + * @returns {Object} Class constructor + */ +H5P.classFromName = function (name) { + var arr = name.split("."); + return this[arr[arr.length - 1]]; +}; + +/** + * A safe way of creating a new instance of a runnable H5P. + * + * @param {Object} library + * Library/action object form params. + * @param {number} contentId + * Identifies the content. + * @param {H5P.jQuery} [$attachTo] + * Element to attach the instance to. + * @param {boolean} [skipResize] + * Skip triggering of the resize event after attaching. + * @param {Object} [extras] + * Extra parameters for the H5P content constructor + * @returns {Object} + * Instance. + */ +H5P.newRunnable = function (library, contentId, $attachTo, skipResize, extras) { + var nameSplit, versionSplit, machineName; + try { + nameSplit = library.library.split(' ', 2); + machineName = nameSplit[0]; + versionSplit = nameSplit[1].split('.', 2); + } + catch (err) { + return H5P.error('Invalid library string: ' + library.library); + } + + if ((library.params instanceof Object) !== true || (library.params instanceof Array) === true) { + H5P.error('Invalid library params for: ' + library.library); + return H5P.error(library.params); + } + + // Find constructor function + var constructor; + try { + nameSplit = nameSplit[0].split('.'); + constructor = window; + for (var i = 0; i < nameSplit.length; i++) { + constructor = constructor[nameSplit[i]]; + } + if (typeof constructor !== 'function') { + throw null; + } + } + catch (err) { + return H5P.error('Unable to find constructor for: ' + library.library); + } + + if (extras === undefined) { + extras = {}; + } + if (library.subContentId) { + extras.subContentId = library.subContentId; + } + + if (library.userDatas && library.userDatas.state && H5PIntegration.saveFreq) { + extras.previousState = library.userDatas.state; + } + + if (library.metadata) { + extras.metadata = library.metadata; + } + + // Makes all H5P libraries extend H5P.ContentType: + var standalone = extras.standalone || false; + // This order makes it possible for an H5P library to override H5P.ContentType functions! + constructor.prototype = H5P.jQuery.extend({}, H5P.ContentType(standalone).prototype, constructor.prototype); + + var instance; + // Some old library versions have their own custom third parameter. + // Make sure we don't send them the extras. + // (they will interpret it as something else) + if (H5P.jQuery.inArray(library.library, ['H5P.CoursePresentation 1.0', 'H5P.CoursePresentation 1.1', 'H5P.CoursePresentation 1.2', 'H5P.CoursePresentation 1.3']) > -1) { + instance = new constructor(library.params, contentId); + } + else { + instance = new constructor(library.params, contentId, extras); + } + + if (instance.$ === undefined) { + instance.$ = H5P.jQuery(instance); + } + + if (instance.contentId === undefined) { + instance.contentId = contentId; + } + if (instance.subContentId === undefined && library.subContentId) { + instance.subContentId = library.subContentId; + } + if (instance.parent === undefined && extras && extras.parent) { + instance.parent = extras.parent; + } + if (instance.libraryInfo === undefined) { + instance.libraryInfo = { + versionedName: library.library, + versionedNameNoSpaces: machineName + '-' + versionSplit[0] + '.' + versionSplit[1], + machineName: machineName, + majorVersion: versionSplit[0], + minorVersion: versionSplit[1] + }; + } + + if ($attachTo !== undefined) { + $attachTo.toggleClass('h5p-standalone', standalone); + instance.attach($attachTo); + H5P.trigger(instance, 'domChanged', { + '$target': $attachTo, + 'library': machineName, + 'key': 'newLibrary' + }, {'bubbles': true, 'external': true}); + + if (skipResize === undefined || !skipResize) { + // Resize content. + H5P.trigger(instance, 'resize'); + } + } + return instance; +}; + +/** + * Used to print useful error messages. (to JavaScript error console) + * + * @param {*} err Error to print. + */ +H5P.error = function (err) { + if (window.console !== undefined && console.error !== undefined) { + console.error(err.stack ? err.stack : err); + } +}; + +/** + * Translate text strings. + * + * @param {string} key + * Translation identifier, may only contain a-zA-Z0-9. No spaces or special chars. + * @param {Object} [vars] + * Data for placeholders. + * @param {string} [ns] + * Translation namespace. Defaults to H5P. + * @returns {string} + * Translated text + */ +H5P.t = function (key, vars, ns) { + if (ns === undefined) { + ns = 'H5P'; + } + + if (H5PIntegration.l10n[ns] === undefined) { + return '[Missing translation namespace "' + ns + '"]'; + } + + if (H5PIntegration.l10n[ns][key] === undefined) { + return '[Missing translation "' + key + '" in "' + ns + '"]'; + } + + var translation = H5PIntegration.l10n[ns][key]; + + if (vars !== undefined) { + // Replace placeholder with variables. + for (var placeholder in vars) { + translation = translation.replace(placeholder, vars[placeholder]); + } + } + + return translation; +}; + +/** + * Creates a new popup dialog over the H5P content. + * + * @class + * @param {string} name + * Used for html class. + * @param {string} title + * Used for header. + * @param {string} content + * Displayed inside the dialog. + * @param {H5P.jQuery} $element + * Which DOM element the dialog should be inserted after. + * @param {H5P.jQuery} $returnElement + * Which DOM element the focus should be moved to on close + */ +H5P.Dialog = function (name, title, content, $element, $returnElement) { + /** @alias H5P.Dialog# */ + var self = this; + this.activeElement = document.activeElement; + var $dialog = H5P.jQuery('') + .insertAfter($element) + .click(function (e) { + if (e && e.originalEvent && e.originalEvent.preventClosing) { + return; + } + + self.close(); + }) + .children('.h5p-inner') + .click(function (e) { + e.originalEvent.preventClosing = true; + }) + .find('.h5p-close') + .click(function () { + self.close(); + }) + .keypress(function (e) { + if (e.which === 13 || e.which === 32) { + self.close(); + return false; + } + }) + .end() + .find('a') + .click(function (e) { + e.stopPropagation(); + }) + .end() + .end(); + + /** + * Opens the dialog. + */ + self.open = function (scrollbar) { + if (scrollbar) { + $dialog.css('height', '100%'); + } + setTimeout(function () { + $dialog.addClass('h5p-open'); // Fade in + // Triggering an event, in case something has to be done after dialog has been opened. + H5P.jQuery(self).trigger('dialog-opened', [$dialog]); + $dialog.focus(); + }, 1); + }; + + /** + * Closes the dialog. + */ + self.close = function () { + $dialog.removeClass('h5p-open'); // Fade out + setTimeout(function () { + $dialog.remove(); + H5P.jQuery(self).trigger('dialog-closed', [$dialog]); + $element.attr('tabindex', '-1'); + if ($returnElement) { + $returnElement.focus(); + } + else if(self.activeElement) { + self.activeElement.focus(); + } + else { + $element.focus(); + } + }, 200); + }; +}; + +/** + * Gather copyright information for the given content. + * + * @param {Object} instance + * H5P instance to get copyright information for. + * @param {Object} parameters + * Parameters of the content instance. + * @param {number} contentId + * Identifies the H5P content + * @param {Object} metadata + * Metadata of the content instance. + * @returns {string} Copyright information. + */ +H5P.getCopyrights = function (instance, parameters, contentId, metadata) { + var copyrights; + + if (instance.getCopyrights !== undefined) { + try { + // Use the instance's own copyright generator + copyrights = instance.getCopyrights(); + } + catch (err) { + // Failed, prevent crashing page. + } + } + + if (copyrights === undefined) { + // Create a generic flat copyright list + copyrights = new H5P.ContentCopyrights(); + H5P.findCopyrights(copyrights, parameters, contentId); + } + + var metadataCopyrights = H5P.buildMetadataCopyrights(metadata, instance.libraryInfo.machineName); + if (metadataCopyrights !== undefined) { + copyrights.addMediaInFront(metadataCopyrights); + } + + if (copyrights !== undefined) { + // Convert to string + copyrights = copyrights.toString(); + } + return copyrights; +}; + +/** + * Gather a flat list of copyright information from the given parameters. + * + * @param {H5P.ContentCopyrights} info + * Used to collect all information in. + * @param {(Object|Array)} parameters + * To search for file objects in. + * @param {number} contentId + * Used to insert thumbnails for images. + * @param {Object} extras - Extras. + * @param {object} extras.metadata - Metadata + * @param {object} extras.machineName - Library name of some kind. + * Metadata of the content instance. + */ +H5P.findCopyrights = function (info, parameters, contentId, extras) { + // If extras are + if (extras) { + extras.params = parameters; + buildFromMetadata(extras, extras.machineName, contentId); + } + + var lastContentTypeName; + // Cycle through parameters + for (var field in parameters) { + if (!parameters.hasOwnProperty(field)) { + continue; // Do not check + } + + /** + * @deprecated This hack should be removed after 2017-11-01 + * The code that was using this was removed by HFP-574 + * This note was seen on 2018-04-04, and consultation with + * higher authorities lead to keeping the code for now ;-) + */ + if (field === 'overrideSettings') { + console.warn("The semantics field 'overrideSettings' is DEPRECATED and should not be used."); + console.warn(parameters); + continue; + } + + var value = parameters[field]; + + if (value && value.library && typeof value.library === 'string') { + lastContentTypeName = value.library.split(' ')[0]; + } + else if (value && value.library && typeof value.library === 'object') { + lastContentTypeName = (value.library.library && typeof value.library.library === 'string') ? value.library.library.split(' ')[0] : lastContentTypeName; + } + + if (value instanceof Array) { + // Cycle through array + H5P.findCopyrights(info, value, contentId); + } + else if (value instanceof Object) { + buildFromMetadata(value, lastContentTypeName, contentId); + + // Check if object is a file with copyrights (old core) + if (value.copyright === undefined || + value.copyright.license === undefined || + value.path === undefined || + value.mime === undefined) { + + // Nope, cycle throught object + H5P.findCopyrights(info, value, contentId); + } + else { + // Found file, add copyrights + var copyrights = new H5P.MediaCopyright(value.copyright); + if (value.width !== undefined && value.height !== undefined) { + copyrights.setThumbnail(new H5P.Thumbnail(H5P.getPath(value.path, contentId), value.width, value.height)); + } + info.addMedia(copyrights); + } + } + } + + function buildFromMetadata(data, name, contentId) { + if (data.metadata) { + const metadataCopyrights = H5P.buildMetadataCopyrights(data.metadata, name); + if (metadataCopyrights !== undefined) { + if (data.params && data.params.contentName === 'Image' && data.params.file) { + const path = data.params.file.path; + const width = data.params.file.width; + const height = data.params.file.height; + metadataCopyrights.setThumbnail(new H5P.Thumbnail(H5P.getPath(path, contentId), width, height, data.params.alt)); + } + info.addMedia(metadataCopyrights); + } + } + } +}; + +H5P.buildMetadataCopyrights = function (metadata) { + if (metadata && metadata.license !== undefined && metadata.license !== 'U') { + var dataset = { + contentType: metadata.contentType, + title: metadata.title, + author: (metadata.authors && metadata.authors.length > 0) ? metadata.authors.map(function (author) { + return (author.role) ? author.name + ' (' + author.role + ')' : author.name; + }).join(', ') : undefined, + source: metadata.source, + year: (metadata.yearFrom) ? (metadata.yearFrom + ((metadata.yearTo) ? '-' + metadata.yearTo: '')) : undefined, + license: metadata.license, + version: metadata.licenseVersion, + licenseExtras: metadata.licenseExtras, + changes: (metadata.changes && metadata.changes.length > 0) ? metadata.changes.map(function (change) { + return change.log + (change.author ? ', ' + change.author : '') + (change.date ? ', ' + change.date : ''); + }).join(' / ') : undefined + }; + + return new H5P.MediaCopyright(dataset); + } +}; + +/** + * Display a dialog containing the download button and copy button. + * + * @param {H5P.jQuery} $element + * @param {Object} contentData + * @param {Object} library + * @param {Object} instance + * @param {number} contentId + */ +H5P.openReuseDialog = function ($element, contentData, library, instance, contentId) { + let html = ''; + let buttonCount = 0; + if (contentData.displayOptions.export) { + html += ''; + buttonCount += 1; + } + if (contentData.displayOptions.export && contentData.displayOptions.copy) { + html += '
or
'; + } + if (contentData.displayOptions.copy) { + html += ''; + buttonCount += 1; + } + + const dialog = new H5P.Dialog('reuse', H5P.t('reuseContent'), html, $element); + + // Selecting embed code when dialog is opened + H5P.jQuery(dialog).on('dialog-opened', function (e, $dialog) { + const scrollContent = $dialog[0].querySelector('.h5p-scroll-content'); + scrollContent.style.setProperty('--button-count', buttonCount); + H5P.jQuery('More Info').click(function (e) { + e.stopPropagation(); + }).appendTo($dialog.find('h2')); + $dialog.find('.h5p-download-button').click(function () { + window.location.href = contentData.exportUrl; + instance.triggerXAPI('downloaded'); + dialog.close(); + }); + $dialog.find('.h5p-copy-button').click(function () { + const item = new H5P.ClipboardItem(library); + item.contentId = contentId; + H5P.setClipboard(item); + instance.triggerXAPI('copied'); + dialog.close(); + H5P.attachToastTo( + H5P.jQuery('.h5p-content:first')[0], + H5P.t('contentCopied'), + { + position: { + horizontal: 'centered', + vertical: 'centered', + noOverflowX: true + } + } + ); + }); + H5P.trigger(instance, 'resize'); + }).on('dialog-closed', function () { + H5P.trigger(instance, 'resize'); + }); + + dialog.open(); +}; + +/** + * Display a dialog containing the embed code. + * + * @param {H5P.jQuery} $element + * Element to insert dialog after. + * @param {string} embedCode + * The embed code. + * @param {string} resizeCode + * The advanced resize code + * @param {Object} size + * The content's size. + * @param {number} size.width + * @param {number} size.height + */ +H5P.openEmbedDialog = function ($element, embedCode, resizeCode, size, instance) { + var fullEmbedCode = embedCode + resizeCode; + var dialog = new H5P.Dialog('embed', H5P.t('embed'), '' + H5P.t('size') + ': × px
' + H5P.t('showAdvanced') + '

' + H5P.t('advancedHelp') + '

', $element); + + // Selecting embed code when dialog is opened + H5P.jQuery(dialog).on('dialog-opened', function (event, $dialog) { + var $inner = $dialog.find('.h5p-inner'); + var $scroll = $inner.find('.h5p-scroll-content'); + var diff = $scroll.outerHeight() - $scroll.innerHeight(); + var positionInner = function () { + H5P.trigger(instance, 'resize'); + }; + + // Handle changing of width/height + var $w = $dialog.find('.h5p-embed-size:eq(0)'); + var $h = $dialog.find('.h5p-embed-size:eq(1)'); + var getNum = function ($e, d) { + var num = parseFloat($e.val()); + if (isNaN(num)) { + return d; + } + return Math.ceil(num); + }; + var updateEmbed = function () { + $dialog.find('.h5p-embed-code-container:first').val(fullEmbedCode.replace(':w', getNum($w, size.width)).replace(':h', getNum($h, size.height))); + }; + + $w.change(updateEmbed); + $h.change(updateEmbed); + updateEmbed(); + + // Select text and expand textareas + $dialog.find('.h5p-embed-code-container').each(function () { + H5P.jQuery(this).css('height', this.scrollHeight + 'px').focus(function () { + H5P.jQuery(this).select(); + }); + }); + $dialog.find('.h5p-embed-code-container').eq(0).select(); + positionInner(); + + // Expand advanced embed + var expand = function () { + var $expander = H5P.jQuery(this); + var $content = $expander.next(); + if ($content.is(':visible')) { + $expander.removeClass('h5p-open').text(H5P.t('showAdvanced')).attr('aria-expanded', 'true'); + $content.hide(); + } + else { + $expander.addClass('h5p-open').text(H5P.t('hideAdvanced')).attr('aria-expanded', 'false'); + $content.show(); + } + $dialog.find('.h5p-embed-code-container').each(function () { + H5P.jQuery(this).css('height', this.scrollHeight + 'px'); + }); + positionInner(); + }; + $dialog.find('.h5p-expander').click(expand).keypress(function (event) { + if (event.keyCode === 32) { + expand.apply(this); + return false; + } + }); + }).on('dialog-closed', function () { + H5P.trigger(instance, 'resize'); + }); + + dialog.open(); +}; + +/** + * Show a toast message. + * + * The reference element could be dom elements the toast should be attached to, + * or e.g. the document body for general toast messages. + * + * @param {DOM} element Reference element to show toast message for. + * @param {string} message Message to show. + * @param {object} [config] Configuration. + * @param {string} [config.style=h5p-toast] Style name for the tooltip. + * @param {number} [config.duration=3000] Toast message length in ms. + * @param {object} [config.position] Relative positioning of the toast. + * @param {string} [config.position.horizontal=centered] [before|left|centered|right|after]. + * @param {string} [config.position.vertical=below] [above|top|centered|bottom|below]. + * @param {number} [config.position.offsetHorizontal=0] Extra horizontal offset. + * @param {number} [config.position.offsetVertical=0] Extra vetical offset. + * @param {boolean} [config.position.noOverflowLeft=false] True to prevent overflow left. + * @param {boolean} [config.position.noOverflowRight=false] True to prevent overflow right. + * @param {boolean} [config.position.noOverflowTop=false] True to prevent overflow top. + * @param {boolean} [config.position.noOverflowBottom=false] True to prevent overflow bottom. + * @param {boolean} [config.position.noOverflowX=false] True to prevent overflow left and right. + * @param {boolean} [config.position.noOverflowY=false] True to prevent overflow top and bottom. + * @param {object} [config.position.overflowReference=document.body] DOM reference for overflow. + */ +H5P.attachToastTo = function (element, message, config) { + if (element === undefined || message === undefined) { + return; + } + + const eventPath = function (evt) { + var path = (evt.composedPath && evt.composedPath()) || evt.path; + var target = evt.target; + + if (path != null) { + // Safari doesn't include Window, but it should. + return (path.indexOf(window) < 0) ? path.concat(window) : path; + } + + if (target === window) { + return [window]; + } + + function getParents(node, memo) { + memo = memo || []; + var parentNode = node.parentNode; + + if (!parentNode) { + return memo; + } + else { + return getParents(parentNode, memo.concat(parentNode)); + } + } + + return [target].concat(getParents(target), window); + }; + + /** + * Handle click while toast is showing. + */ + const clickHandler = function (event) { + /* + * A common use case will be to attach toasts to buttons that are clicked. + * The click would remove the toast message instantly without this check. + * Children of the clicked element are also ignored. + */ + var path = eventPath(event); + if (path.indexOf(element) !== -1) { + return; + } + clearTimeout(timer); + removeToast(); + }; + + + + /** + * Remove the toast message. + */ + const removeToast = function () { + document.removeEventListener('click', clickHandler); + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }; + + /** + * Get absolute coordinates for the toast. + * + * @param {DOM} element Reference element to show toast message for. + * @param {DOM} toast Toast element. + * @param {object} [position={}] Relative positioning of the toast message. + * @param {string} [position.horizontal=centered] [before|left|centered|right|after]. + * @param {string} [position.vertical=below] [above|top|centered|bottom|below]. + * @param {number} [position.offsetHorizontal=0] Extra horizontal offset. + * @param {number} [position.offsetVertical=0] Extra vetical offset. + * @param {boolean} [position.noOverflowLeft=false] True to prevent overflow left. + * @param {boolean} [position.noOverflowRight=false] True to prevent overflow right. + * @param {boolean} [position.noOverflowTop=false] True to prevent overflow top. + * @param {boolean} [position.noOverflowBottom=false] True to prevent overflow bottom. + * @param {boolean} [position.noOverflowX=false] True to prevent overflow left and right. + * @param {boolean} [position.noOverflowY=false] True to prevent overflow top and bottom. + * @return {object} + */ + const getToastCoordinates = function (element, toast, position) { + position = position || {}; + position.offsetHorizontal = position.offsetHorizontal || 0; + position.offsetVertical = position.offsetVertical || 0; + + const toastRect = toast.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + let left = 0; + let top = 0; + + // Compute horizontal position + switch (position.horizontal) { + case 'before': + left = elementRect.left - toastRect.width - position.offsetHorizontal; + break; + case 'after': + left = elementRect.left + elementRect.width + position.offsetHorizontal; + break; + case 'left': + left = elementRect.left + position.offsetHorizontal; + break; + case 'right': + left = elementRect.left + elementRect.width - toastRect.width - position.offsetHorizontal; + break; + case 'centered': + left = elementRect.left + elementRect.width / 2 - toastRect.width / 2 + position.offsetHorizontal; + break; + default: + left = elementRect.left + elementRect.width / 2 - toastRect.width / 2 + position.offsetHorizontal; + } + + // Compute vertical position + switch (position.vertical) { + case 'above': + top = elementRect.top - toastRect.height - position.offsetVertical; + break; + case 'below': + top = elementRect.top + elementRect.height + position.offsetVertical; + break; + case 'top': + top = elementRect.top + position.offsetVertical; + break; + case 'bottom': + top = elementRect.top + elementRect.height - toastRect.height - position.offsetVertical; + break; + case 'centered': + top = elementRect.top + elementRect.height / 2 - toastRect.height / 2 + position.offsetVertical; + break; + default: + top = elementRect.top + elementRect.height + position.offsetVertical; + } + + // Prevent overflow + const overflowElement = document.body; + const bounds = overflowElement.getBoundingClientRect(); + if ((position.noOverflowLeft || position.noOverflowX) && (left < bounds.x)) { + left = bounds.x; + } + if ((position.noOverflowRight || position.noOverflowX) && ((left + toastRect.width) > (bounds.x + bounds.width))) { + left = bounds.x + bounds.width - toastRect.width; + } + if ((position.noOverflowTop || position.noOverflowY) && (top < bounds.y)) { + top = bounds.y; + } + if ((position.noOverflowBottom || position.noOverflowY) && ((top + toastRect.height) > (bounds.y + bounds.height))) { + left = bounds.y + bounds.height - toastRect.height; + } + + return {left: left, top: top}; + }; + + // Sanitization + config = config || {}; + config.style = config.style || 'h5p-toast'; + config.duration = config.duration || 3000; + + // Build toast + const toast = document.createElement('div'); + toast.setAttribute('id', config.style); + toast.classList.add('h5p-toast-disabled'); + toast.classList.add(config.style); + + const msg = document.createElement('span'); + msg.innerHTML = message; + toast.appendChild(msg); + + document.body.appendChild(toast); + + // The message has to be set before getting the coordinates + const coordinates = getToastCoordinates(element, toast, config.position); + toast.style.left = Math.round(coordinates.left) + 'px'; + toast.style.top = Math.round(coordinates.top) + 'px'; + + toast.classList.remove('h5p-toast-disabled'); + const timer = setTimeout(removeToast, config.duration); + + // The toast can also be removed by clicking somewhere + document.addEventListener('click', clickHandler); +}; + +/** + * Copyrights for a H5P Content Library. + * + * @class + */ +H5P.ContentCopyrights = function () { + var label; + var media = []; + var content = []; + + /** + * Set label. + * + * @param {string} newLabel + */ + this.setLabel = function (newLabel) { + label = newLabel; + }; + + /** + * Add sub content. + * + * @param {H5P.MediaCopyright} newMedia + */ + this.addMedia = function (newMedia) { + if (newMedia !== undefined) { + media.push(newMedia); + } + }; + + /** + * Add sub content in front. + * + * @param {H5P.MediaCopyright} newMedia + */ + this.addMediaInFront = function (newMedia) { + if (newMedia !== undefined) { + media.unshift(newMedia); + } + }; + + /** + * Add sub content. + * + * @param {H5P.ContentCopyrights} newContent + */ + this.addContent = function (newContent) { + if (newContent !== undefined) { + content.push(newContent); + } + }; + + /** + * Print content copyright. + * + * @returns {string} HTML. + */ + this.toString = function () { + var html = ''; + + // Add media rights + for (var i = 0; i < media.length; i++) { + html += media[i]; + } + + // Add sub content rights + for (i = 0; i < content.length; i++) { + html += content[i]; + } + + + if (html !== '') { + // Add a label to this info + if (label !== undefined) { + html = '

' + label + '

' + html; + } + + // Add wrapper + html = '
' + html + '
'; + } + + return html; + }; +}; + +/** + * A ordered list of copyright fields for media. + * + * @class + * @param {Object} copyright + * Copyright information fields. + * @param {Object} [labels] + * Translation of labels. + * @param {Array} [order] + * Order of the fields. + * @param {Object} [extraFields] + * Add extra copyright fields. + */ +H5P.MediaCopyright = function (copyright, labels, order, extraFields) { + var thumbnail; + var list = new H5P.DefinitionList(); + + /** + * Get translated label for field. + * + * @private + * @param {string} fieldName + * @returns {string} + */ + var getLabel = function (fieldName) { + if (labels === undefined || labels[fieldName] === undefined) { + return H5P.t(fieldName); + } + + return labels[fieldName]; + }; + + /** + * Get humanized value for the license field. + * + * @private + * @param {string} license + * @param {string} [version] + * @returns {string} + */ + var humanizeLicense = function (license, version) { + var copyrightLicense = H5P.copyrightLicenses[license]; + + // Build license string + var value = ''; + if (!(license === 'PD' && version)) { + // Add license label + value += (copyrightLicense.hasOwnProperty('label') ? copyrightLicense.label : copyrightLicense); + } + + // Check for version info + var versionInfo; + if (copyrightLicense.versions) { + if (copyrightLicense.versions.default && (!version || !copyrightLicense.versions[version])) { + version = copyrightLicense.versions.default; + } + if (version && copyrightLicense.versions[version]) { + versionInfo = copyrightLicense.versions[version]; + } + } + + if (versionInfo) { + // Add license version + if (value) { + value += ' '; + } + value += (versionInfo.hasOwnProperty('label') ? versionInfo.label : versionInfo); + } + + // Add link if specified + var link; + if (copyrightLicense.hasOwnProperty('link')) { + link = copyrightLicense.link.replace(':version', copyrightLicense.linkVersions ? copyrightLicense.linkVersions[version] : version); + } + else if (versionInfo && copyrightLicense.hasOwnProperty('link')) { + link = versionInfo.link; + } + if (link) { + value = '' + value + ''; + } + + // Generate parenthesis + var parenthesis = ''; + if (license !== 'PD' && license !== 'C') { + parenthesis += license; + } + if (version && version !== 'CC0 1.0') { + if (parenthesis && license !== 'GNU GPL') { + parenthesis += ' '; + } + parenthesis += version; + } + if (parenthesis) { + value += ' (' + parenthesis + ')'; + } + if (license === 'C') { + value += ' ©'; + } + + return value; + }; + + if (copyright !== undefined) { + // Add the extra fields + for (var field in extraFields) { + if (extraFields.hasOwnProperty(field)) { + copyright[field] = extraFields[field]; + } + } + + if (order === undefined) { + // Set default order + order = ['contentType', 'title', 'license', 'author', 'year', 'source', 'licenseExtras', 'changes']; + } + + for (var i = 0; i < order.length; i++) { + var fieldName = order[i]; + if (copyright[fieldName] !== undefined && copyright[fieldName] !== '') { + var humanValue = copyright[fieldName]; + if (fieldName === 'license') { + humanValue = humanizeLicense(copyright.license, copyright.version); + } + if (fieldName === 'source') { + humanValue = (humanValue) ? '' + humanValue + '' : undefined; + } + list.add(new H5P.Field(getLabel(fieldName), humanValue)); + } + } + } + + /** + * Set thumbnail. + * + * @param {H5P.Thumbnail} newThumbnail + */ + this.setThumbnail = function (newThumbnail) { + thumbnail = newThumbnail; + }; + + /** + * Checks if this copyright is undisclosed. + * I.e. only has the license attribute set, and it's undisclosed. + * + * @returns {boolean} + */ + this.undisclosed = function () { + if (list.size() === 1) { + var field = list.get(0); + if (field.getLabel() === getLabel('license') && field.getValue() === humanizeLicense('U')) { + return true; + } + } + return false; + }; + + /** + * Print media copyright. + * + * @returns {string} HTML. + */ + this.toString = function () { + var html = ''; + + if (this.undisclosed()) { + return html; // No need to print a copyright with a single undisclosed license. + } + + if (thumbnail !== undefined) { + html += thumbnail; + } + html += list; + + if (html !== '') { + html = ''; + } + + return html; + }; +}; + +/** + * A simple and elegant class for creating thumbnails of images. + * + * @class + * @param {string} source + * @param {number} width + * @param {number} height + * @param {string} alt + * alternative text for the thumbnail + */ +H5P.Thumbnail = function (source, width, height, alt) { + var thumbWidth, thumbHeight = 100; + if (width !== undefined) { + thumbWidth = Math.round(thumbHeight * (width / height)); + } + + /** + * Print thumbnail. + * + * @returns {string} HTML. + */ + this.toString = function () { + return '' + (alt ? alt : '') + ''; + }; +}; + +/** + * Simple data structure class for storing a single field. + * + * @class + * @param {string} label + * @param {string} value + */ +H5P.Field = function (label, value) { + /** + * Public. Get field label. + * + * @returns {String} + */ + this.getLabel = function () { + return label; + }; + + /** + * Public. Get field value. + * + * @returns {String} + */ + this.getValue = function () { + return value; + }; +}; + +/** + * Simple class for creating a definition list. + * + * @class + */ +H5P.DefinitionList = function () { + var fields = []; + + /** + * Add field to list. + * + * @param {H5P.Field} field + */ + this.add = function (field) { + fields.push(field); + }; + + /** + * Get Number of fields. + * + * @returns {number} + */ + this.size = function () { + return fields.length; + }; + + /** + * Get field at given index. + * + * @param {number} index + * @returns {H5P.Field} + */ + this.get = function (index) { + return fields[index]; + }; + + /** + * Print definition list. + * + * @returns {string} HTML. + */ + this.toString = function () { + var html = ''; + for (var i = 0; i < fields.length; i++) { + var field = fields[i]; + html += '
' + field.getLabel() + '
' + field.getValue() + '
'; + } + return (html === '' ? html : '
' + html + '
'); + }; +}; + +/** + * THIS FUNCTION/CLASS IS DEPRECATED AND WILL BE REMOVED. + * + * Helper object for keeping coordinates in the same format all over. + * + * @deprecated + * Will be removed march 2016. + * @class + * @param {number} x + * @param {number} y + * @param {number} w + * @param {number} h + */ +H5P.Coords = function (x, y, w, h) { + if ( !(this instanceof H5P.Coords) ) + return new H5P.Coords(x, y, w, h); + + /** @member {number} */ + this.x = 0; + /** @member {number} */ + this.y = 0; + /** @member {number} */ + this.w = 1; + /** @member {number} */ + this.h = 1; + + if (typeof(x) === 'object') { + this.x = x.x; + this.y = x.y; + this.w = x.w; + this.h = x.h; + } + else { + if (x !== undefined) { + this.x = x; + } + if (y !== undefined) { + this.y = y; + } + if (w !== undefined) { + this.w = w; + } + if (h !== undefined) { + this.h = h; + } + } + return this; +}; + +/** + * Parse library string into values. + * + * @param {string} library + * library in the format "machineName majorVersion.minorVersion" + * @returns {Object} + * library as an object with machineName, majorVersion and minorVersion properties + * return false if the library parameter is invalid + */ +H5P.libraryFromString = function (library) { + var regExp = /(.+)\s(\d+)\.(\d+)$/g; + var res = regExp.exec(library); + if (res !== null) { + return { + 'machineName': res[1], + 'majorVersion': parseInt(res[2]), + 'minorVersion': parseInt(res[3]) + }; + } + else { + return false; + } +}; + +/** + * Get the path to the library + * + * @param {string} library + * The library identifier in the format "machineName-majorVersion.minorVersion". + * @returns {string} + * The full path to the library. + */ +H5P.getLibraryPath = function (library) { + if (H5PIntegration && + H5PIntegration.libraryDirectories && + library in H5PIntegration.libraryDirectories) { + // Use H5PIntegration.libraryDirectories if it exists for this library + library = H5PIntegration.libraryDirectories[library]; + } + + if (H5PIntegration.urlLibraries !== undefined) { + // This is an override for those implementations that has a different libraries URL, e.g. Moodle + return H5PIntegration.urlLibraries + '/' + library; + } + else { + return H5PIntegration.url + '/libraries/' + library; + } +}; + +/** + * Recursivly clone the given object. + * + * @param {Object|Array} object + * Object to clone. + * @param {boolean} [recursive] + * @returns {Object|Array} + * A clone of object. + */ +H5P.cloneObject = function (object, recursive) { + // TODO: Consider if this needs to be in core. Doesn't $.extend do the same? + var clone = object instanceof Array ? [] : {}; + + for (var i in object) { + if (object.hasOwnProperty(i)) { + if (recursive !== undefined && recursive && typeof object[i] === 'object') { + clone[i] = H5P.cloneObject(object[i], recursive); + } + else { + clone[i] = object[i]; + } + } + } + + return clone; +}; + +/** + * Remove all empty spaces before and after the value. + * + * @param {string} value + * @returns {string} + */ +H5P.trim = function (value) { + return value.replace(/^\s+|\s+$/g, ''); + + // TODO: Only include this or String.trim(). What is best? + // I'm leaning towards implementing the missing ones: http://kangax.github.io/compat-table/es5/ + // So should we make this function deprecated? +}; + +/** + * Recursive function that detects deep empty structures. + * + * @param {*} value + * @returns {bool} + */ +H5P.isEmpty = value => { + if (!value && value !== 0 && value !== false) { + return true; // undefined, null, NaN and empty strings. + } + else if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + if (!H5P.isEmpty(value[i])) { + return false; // Array contains a non-empty value + } + } + return true; // Empty array + } + else if (typeof value === 'object') { + for (let prop in value) { + if (value.hasOwnProperty(prop) && !H5P.isEmpty(value[prop])) { + return false; // Object contains a non-empty value + } + } + return true; // Empty object + } + return false; +}; + +/** + * Check if JavaScript path/key is loaded. + * + * @param {string} path + * @returns {boolean} + */ +H5P.jsLoaded = function (path) { + H5PIntegration.loadedJs = H5PIntegration.loadedJs || []; + return H5P.jQuery.inArray(path, H5PIntegration.loadedJs) !== -1; +}; + +/** + * Check if styles path/key is loaded. + * + * @param {string} path + * @returns {boolean} + */ +H5P.cssLoaded = function (path) { + H5PIntegration.loadedCss = H5PIntegration.loadedCss || []; + return H5P.jQuery.inArray(path, H5PIntegration.loadedCss) !== -1; +}; + +/** + * Shuffle an array in place. + * + * @param {Array} array + * Array to shuffle + * @returns {Array} + * The passed array is returned for chaining. + */ +H5P.shuffleArray = function (array) { + // TODO: Consider if this should be a part of core. I'm guessing very few libraries are going to use it. + if (!(array instanceof Array)) { + return; + } + + var i = array.length, j, tempi, tempj; + if ( i === 0 ) return false; + while ( --i ) { + j = Math.floor( Math.random() * ( i + 1 ) ); + tempi = array[i]; + tempj = array[j]; + array[i] = tempj; + array[j] = tempi; + } + return array; +}; + +/** + * Post finished results for user. + * + * @deprecated + * Do not use this function directly, trigger the finish event instead. + * Will be removed march 2016 + * @param {number} contentId + * Identifies the content + * @param {number} score + * Achieved score/points + * @param {number} maxScore + * The maximum score/points that can be achieved + * @param {number} [time] + * Reported time consumption/usage + */ +H5P.setFinished = function (contentId, score, maxScore, time) { + var validScore = typeof score === 'number' || score instanceof Number; + if (validScore && H5PIntegration.postUserStatistics === true) { + /** + * Return unix timestamp for the given JS Date. + * + * @private + * @param {Date} date + * @returns {Number} + */ + var toUnix = function (date) { + return Math.round(date.getTime() / 1000); + }; + + // Post the results + const data = { + contentId: contentId, + score: score, + maxScore: maxScore, + opened: toUnix(H5P.opened[contentId]), + finished: toUnix(new Date()), + time: time + }; + H5P.jQuery.post(H5PIntegration.ajax.setFinished, data) + .fail(function () { + H5P.offlineRequestQueue.add(H5PIntegration.ajax.setFinished, data); + }); + } +}; + +// Add indexOf to browsers that lack them. (IEs) +if (!Array.prototype.indexOf) { + Array.prototype.indexOf = function (needle) { + for (var i = 0; i < this.length; i++) { + if (this[i] === needle) { + return i; + } + } + return -1; + }; +} + +// Need to define trim() since this is not available on older IEs, +// and trim is used in several libs +if (String.prototype.trim === undefined) { + String.prototype.trim = function () { + return H5P.trim(this); + }; +} + +/** + * Trigger an event on an instance + * + * Helper function that triggers an event if the instance supports event handling + * + * @param {Object} instance + * Instance of H5P content + * @param {string} eventType + * Type of event to trigger + * @param {*} data + * @param {Object} extras + */ +H5P.trigger = function (instance, eventType, data, extras) { + // Try new event system first + if (instance.trigger !== undefined) { + instance.trigger(eventType, data, extras); + } + // Try deprecated event system + else if (instance.$ !== undefined && instance.$.trigger !== undefined) { + instance.$.trigger(eventType); + } +}; + +/** + * Register an event handler + * + * Helper function that registers an event handler for an event type if + * the instance supports event handling + * + * @param {Object} instance + * Instance of H5P content + * @param {string} eventType + * Type of event to listen for + * @param {H5P.EventCallback} handler + * Callback that gets triggered for events of the specified type + */ +H5P.on = function (instance, eventType, handler) { + // Try new event system first + if (instance.on !== undefined) { + instance.on(eventType, handler); + } + // Try deprecated event system + else if (instance.$ !== undefined && instance.$.on !== undefined) { + instance.$.on(eventType, handler); + } +}; + +/** + * Generate random UUID + * + * @returns {string} UUID + */ +H5P.createUUID = function () { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (char) { + var random = Math.random()*16|0, newChar = char === 'x' ? random : (random&0x3|0x8); + return newChar.toString(16); + }); +}; + +/** + * Create title + * + * @param {string} rawTitle + * @param {number} maxLength + * @returns {string} + */ +H5P.createTitle = function (rawTitle, maxLength) { + if (!rawTitle) { + return ''; + } + if (maxLength === undefined) { + maxLength = 60; + } + var title = H5P.jQuery('
') + .text( + // Strip tags + rawTitle.replace(/(<([^>]+)>)/ig,"") + // Escape + ).text(); + if (title.length > maxLength) { + title = title.substr(0, maxLength - 3) + '...'; + } + return title; +}; + +// Wrap in privates +(function ($) { + + /** + * Creates ajax requests for inserting, updateing and deleteing + * content user data. + * + * @private + * @param {number} contentId What content to store the data for. + * @param {string} dataType Identifies the set of data for this content. + * @param {string} subContentId Identifies sub content + * @param {function} [done] Callback when ajax is done. + * @param {object} [data] To be stored for future use. + * @param {boolean} [preload=false] Data is loaded when content is loaded. + * @param {boolean} [invalidate=false] Data is invalidated when content changes. + * @param {boolean} [async=true] + */ + function contentUserDataAjax(contentId, dataType, subContentId, done, data, preload, invalidate, async) { + if (H5PIntegration.user === undefined) { + // Not logged in, no use in saving. + done('Not signed in.'); + return; + } + + var options = { + url: H5PIntegration.ajax.contentUserData.replace(':contentId', contentId).replace(':dataType', dataType).replace(':subContentId', subContentId ? subContentId : 0), + dataType: 'json', + async: async === undefined ? true : async + }; + if (data !== undefined) { + options.type = 'POST'; + options.data = { + data: (data === null ? 0 : data), + preload: (preload ? 1 : 0), + invalidate: (invalidate ? 1 : 0), + contentHash: (invalidate && H5PIntegration.contents['cid-' + contentId].jsonContentHash ? H5PIntegration.contents['cid-' + contentId].jsonContentHash : 0) + }; + } + else { + options.type = 'GET'; + } + if (done !== undefined) { + options.error = function (xhr, error) { + done(error); + }; + options.success = function (response) { + if (!response.success) { + done(response.message); + return; + } + + if (response.data === false || response.data === undefined) { + done(); + return; + } + + done(undefined, response.data); + }; + } + + $.ajax(options); + } + + /** + * Get user data for given content. + * + * @param {number} contentId + * What content to get data for. + * @param {string} dataId + * Identifies the set of data for this content. + * @param {function} done + * Callback with error and data parameters. + * @param {string} [subContentId] + * Identifies which data belongs to sub content. + */ + H5P.getUserData = function (contentId, dataId, done, subContentId) { + if (!subContentId) { + subContentId = 0; // Default + } + + H5PIntegration.contents = H5PIntegration.contents || {}; + var content = H5PIntegration.contents['cid-' + contentId] || {}; + var preloadedData = content.contentUserData; + if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId] !== undefined) { + if (preloadedData[subContentId][dataId] === 'RESET') { + done(undefined, null); + return; + } + try { + done(undefined, JSON.parse(preloadedData[subContentId][dataId])); + } + catch (err) { + done(err); + } + } + else { + contentUserDataAjax(contentId, dataId, subContentId, function (err, data) { + if (err || data === undefined) { + done(err, data); + return; // Error or no data + } + + // Cache in preloaded + if (content.contentUserData === undefined) { + content.contentUserData = preloadedData = {}; + } + if (preloadedData[subContentId] === undefined) { + preloadedData[subContentId] = {}; + } + preloadedData[subContentId][dataId] = data; + + // Done. Try to decode JSON + try { + done(undefined, JSON.parse(data)); + } + catch (e) { + done(e); + } + }); + } + }; + + /** + * Async error handling. + * + * @callback H5P.ErrorCallback + * @param {*} error + */ + + /** + * Set user data for given content. + * + * @param {number} contentId + * What content to get data for. + * @param {string} dataId + * Identifies the set of data for this content. + * @param {Object} data + * The data that is to be stored. + * @param {Object} [extras] + * Extra properties + * @param {string} [extras.subContentId] + * Identifies which data belongs to sub content. + * @param {boolean} [extras.preloaded=true] + * If the data should be loaded when content is loaded. + * @param {boolean} [extras.deleteOnChange=false] + * If the data should be invalidated when the content changes. + * @param {H5P.ErrorCallback} [extras.errorCallback] + * Callback with error as parameters. + * @param {boolean} [extras.async=true] + */ + H5P.setUserData = function (contentId, dataId, data, extras) { + var options = H5P.jQuery.extend(true, {}, { + subContentId: 0, + preloaded: true, + deleteOnChange: false, + async: true + }, extras); + + try { + data = JSON.stringify(data); + } + catch (err) { + if (options.errorCallback) { + options.errorCallback(err); + } + return; // Failed to serialize. + } + + var content = H5PIntegration.contents['cid-' + contentId]; + if (content === undefined) { + content = H5PIntegration.contents['cid-' + contentId] = {}; + } + if (!content.contentUserData) { + content.contentUserData = {}; + } + var preloadedData = content.contentUserData; + if (preloadedData[options.subContentId] === undefined) { + preloadedData[options.subContentId] = {}; + } + if (data === preloadedData[options.subContentId][dataId]) { + return; // No need to save this twice. + } + + preloadedData[options.subContentId][dataId] = data; + contentUserDataAjax(contentId, dataId, options.subContentId, function (error) { + if (options.errorCallback && error) { + options.errorCallback(error); + } + }, data, options.preloaded, options.deleteOnChange, options.async); + }; + + /** + * Delete user data for given content. + * + * @param {number} contentId + * What content to remove data for. + * @param {string} dataId + * Identifies the set of data for this content. + * @param {string} [subContentId] + * Identifies which data belongs to sub content. + */ + H5P.deleteUserData = function (contentId, dataId, subContentId) { + if (!subContentId) { + subContentId = 0; // Default + } + + // Remove from preloaded/cache + var preloadedData = H5PIntegration.contents['cid-' + contentId].contentUserData; + if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId]) { + delete preloadedData[subContentId][dataId]; + } + + contentUserDataAjax(contentId, dataId, subContentId, undefined, null); + }; + + /** + * Function for getting content for a certain ID + * + * @param {number} contentId + * @return {Object} + */ + H5P.getContentForInstance = function (contentId) { + var key = 'cid-' + contentId; + var exists = H5PIntegration && H5PIntegration.contents && + H5PIntegration.contents[key]; + + return exists ? H5PIntegration.contents[key] : undefined; + }; + + /** + * Prepares the content parameters for storing in the clipboard. + * + * @class + * @param {Object} parameters The parameters for the content to store + * @param {string} [genericProperty] If only part of the parameters are generic, which part + * @param {string} [specificKey] If the parameters are specific, what content type does it fit + * @returns {Object} Ready for the clipboard + */ + H5P.ClipboardItem = function (parameters, genericProperty, specificKey) { + var self = this; + + /** + * Set relative dimensions when params contains a file with a width and a height. + * Very useful to be compatible with wysiwyg editors. + * + * @private + */ + var setDimensionsFromFile = function () { + if (!self.generic) { + return; + } + var params = self.specific[self.generic]; + if (!params.params.file || !params.params.file.width || !params.params.file.height) { + return; + } + + self.width = 20; // % + self.height = (params.params.file.height / params.params.file.width) * self.width; + }; + + if (!genericProperty) { + genericProperty = 'action'; + parameters = { + action: parameters + }; + } + + self.specific = parameters; + + if (genericProperty && parameters[genericProperty]) { + self.generic = genericProperty; + } + if (specificKey) { + self.from = specificKey; + } + + if (window.H5PEditor && H5PEditor.contentId) { + self.contentId = H5PEditor.contentId; + } + + if (!self.specific.width && !self.specific.height) { + setDimensionsFromFile(); + } + }; + + /** + * Store item in the H5P Clipboard. + * + * @param {H5P.ClipboardItem|*} clipboardItem + */ + H5P.clipboardify = function (clipboardItem) { + if (!(clipboardItem instanceof H5P.ClipboardItem)) { + clipboardItem = new H5P.ClipboardItem(clipboardItem); + } + H5P.setClipboard(clipboardItem); + }; + + /** + * Retrieve parsed clipboard data. + * + * @return {Object} + */ + H5P.getClipboard = function () { + return parseClipboard(); + }; + + /** + * Set item in the H5P Clipboard. + * + * @param {H5P.ClipboardItem|object} clipboardItem - Data to be set. + */ + H5P.setClipboard = function (clipboardItem) { + localStorage.setItem('h5pClipboard', JSON.stringify(clipboardItem)); + + // Trigger an event so all 'Paste' buttons may be enabled. + H5P.externalDispatcher.trigger('datainclipboard', {reset: false}); + }; + + /** + * Get config for a library + * + * @param string machineName + * @return Object + */ + H5P.getLibraryConfig = function (machineName) { + var hasConfig = H5PIntegration.libraryConfig && H5PIntegration.libraryConfig[machineName]; + return hasConfig ? H5PIntegration.libraryConfig[machineName] : {}; + }; + + /** + * Get item from the H5P Clipboard. + * + * @private + * @return {Object} + */ + var parseClipboard = function () { + var clipboardData = localStorage.getItem('h5pClipboard'); + if (!clipboardData) { + return; + } + + // Try to parse clipboard dat + try { + clipboardData = JSON.parse(clipboardData); + } + catch (err) { + console.error('Unable to parse JSON from clipboard.', err); + return; + } + + // Update file URLs and reset content Ids + recursiveUpdate(clipboardData.specific, function (path) { + var isTmpFile = (path.substr(-4, 4) === '#tmp'); + if (!isTmpFile && clipboardData.contentId && !path.match(/^https?:\/\//i)) { + // Comes from existing content + + let prefix; + if (H5PEditor.contentId) { + // .. to existing content + prefix = '../' + clipboardData.contentId + '/'; + } + else { + // .. to new content + prefix = (H5PEditor.contentRelUrl ? H5PEditor.contentRelUrl : '../content/') + clipboardData.contentId + '/'; + } + return path.substr(0, prefix.length) === prefix ? path : prefix + path; + } + + return path; // Will automatically be looked for in tmp folder + }); + + + if (clipboardData.generic) { + // Use reference instead of key + clipboardData.generic = clipboardData.specific[clipboardData.generic]; + } + + return clipboardData; + }; + + /** + * Update file URLs and reset content IDs. + * Useful when copying content. + * + * @private + * @param {object} params Reference + * @param {function} handler Modifies the path to work when pasted + */ + var recursiveUpdate = function (params, handler) { + for (var prop in params) { + if (params.hasOwnProperty(prop) && params[prop] instanceof Object) { + var obj = params[prop]; + if (obj.path !== undefined && obj.mime !== undefined) { + obj.path = handler(obj.path); + } + else { + if (obj.library !== undefined && obj.subContentId !== undefined) { + // Avoid multiple content with same ID + delete obj.subContentId; + } + recursiveUpdate(obj, handler); + } + } + } + }; + + // Init H5P when page is fully loadded + $(document).ready(function () { + + window.addEventListener('storage', function (event) { + // Pick up clipboard changes from other tabs + if (event.key === 'h5pClipboard') { + // Trigger an event so all 'Paste' buttons may be enabled. + H5P.externalDispatcher.trigger('datainclipboard', {reset: event.newValue === null}); + } + }); + + var ccVersions = { + 'default': '4.0', + '4.0': H5P.t('licenseCC40'), + '3.0': H5P.t('licenseCC30'), + '2.5': H5P.t('licenseCC25'), + '2.0': H5P.t('licenseCC20'), + '1.0': H5P.t('licenseCC10'), + }; + + /** + * Maps copyright license codes to their human readable counterpart. + * + * @type {Object} + */ + H5P.copyrightLicenses = { + 'U': H5P.t('licenseU'), + 'CC BY': { + label: H5P.t('licenseCCBY'), + link: 'http://creativecommons.org/licenses/by/:version', + versions: ccVersions + }, + 'CC BY-SA': { + label: H5P.t('licenseCCBYSA'), + link: 'http://creativecommons.org/licenses/by-sa/:version', + versions: ccVersions + }, + 'CC BY-ND': { + label: H5P.t('licenseCCBYND'), + link: 'http://creativecommons.org/licenses/by-nd/:version', + versions: ccVersions + }, + 'CC BY-NC': { + label: H5P.t('licenseCCBYNC'), + link: 'http://creativecommons.org/licenses/by-nc/:version', + versions: ccVersions + }, + 'CC BY-NC-SA': { + label: H5P.t('licenseCCBYNCSA'), + link: 'http://creativecommons.org/licenses/by-nc-sa/:version', + versions: ccVersions + }, + 'CC BY-NC-ND': { + label: H5P.t('licenseCCBYNCND'), + link: 'http://creativecommons.org/licenses/by-nc-nd/:version', + versions: ccVersions + }, + 'CC0 1.0': { + label: H5P.t('licenseCC010'), + link: 'https://creativecommons.org/publicdomain/zero/1.0/' + }, + 'GNU GPL': { + label: H5P.t('licenseGPL'), + link: 'http://www.gnu.org/licenses/gpl-:version-standalone.html', + linkVersions: { + 'v3': '3.0', + 'v2': '2.0', + 'v1': '1.0' + }, + versions: { + 'default': 'v3', + 'v3': H5P.t('licenseV3'), + 'v2': H5P.t('licenseV2'), + 'v1': H5P.t('licenseV1') + } + }, + 'PD': { + label: H5P.t('licensePD'), + versions: { + 'CC0 1.0': { + label: H5P.t('licenseCC010'), + link: 'https://creativecommons.org/publicdomain/zero/1.0/' + }, + 'CC PDM': { + label: H5P.t('licensePDM'), + link: 'https://creativecommons.org/publicdomain/mark/1.0/' + } + } + }, + 'ODC PDDL': 'Public Domain Dedication and Licence', + 'CC PDM': { + label: H5P.t('licensePDM'), + link: 'https://creativecommons.org/publicdomain/mark/1.0/' + }, + 'C': H5P.t('licenseC'), + }; + + /** + * Indicates if H5P is embedded on an external page using iframe. + * @member {boolean} H5P.externalEmbed + */ + + // Relay events to top window. This must be done before H5P.init + // since events may be fired on initialization. + if (H5P.isFramed && H5P.externalEmbed === false) { + H5P.externalDispatcher.on('*', function (event) { + window.parent.H5P.externalDispatcher.trigger.call(this, event); + }); + } + + /** + * Prevent H5P Core from initializing. Must be overriden before document ready. + * @member {boolean} H5P.preventInit + */ + if (!H5P.preventInit) { + // Note that this start script has to be an external resource for it to + // load in correct order in IE9. + H5P.init(document.body); + } + + if (H5PIntegration.saveFreq !== false) { + // When was the last state stored + var lastStoredOn = 0; + // Store the current state of the H5P when leaving the page. + var storeCurrentState = function () { + // Make sure at least 250 ms has passed since last save + var currentTime = new Date().getTime(); + if (currentTime - lastStoredOn > 250) { + lastStoredOn = currentTime; + for (var i = 0; i < H5P.instances.length; i++) { + var instance = H5P.instances[i]; + if (instance.getCurrentState instanceof Function || + typeof instance.getCurrentState === 'function') { + var state = instance.getCurrentState(); + if (state !== undefined) { + // Async is not used to prevent the request from being cancelled. + H5P.setUserData(instance.contentId, 'state', state, {deleteOnChange: true, async: false}); + } + } + } + } + }; + // iPad does not support beforeunload, therefore using unload + H5P.$window.one('beforeunload unload', function () { + // Only want to do this once + H5P.$window.off('pagehide beforeunload unload'); + storeCurrentState(); + }); + // pagehide is used on iPad when tabs are switched + H5P.$window.on('pagehide', storeCurrentState); + } + }); + +})(H5P.jQuery); diff --git a/packages/runtime/vendor/h5p-core/js/jquery.js b/packages/runtime/vendor/h5p-core/js/jquery.js new file mode 100644 index 0000000..4f34d75 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/js/jquery.js @@ -0,0 +1,22 @@ +/*! jQuery v3.5.1 | (c) JS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.5.1",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function D(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||j,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,j=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function qe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function Le(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function He(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Oe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):("number"==typeof f.top&&(f.top+="px"),"number"==typeof f.left&&(f.left+="px"),c.css(f))}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=$e(y.pixelPosition,function(e,t){if(t)return t=Be(e,n),Me.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 00'); + + dialog.querySelector('.h5p-confirmation-dialog-text').appendChild(countDownText); + const countDownNum = countDownText.querySelector('.count-down-num'); + + // Create throbber + const throbberWrapper = document.createElement('div'); + throbberWrapper.classList.add('throbber-wrapper'); + const throbber = document.createElement('div'); + throbber.classList.add('sending-requests-throbber'); + throbberWrapper.appendChild(throbber); + + requestQueue.on('requestQueued', function (e) { + // Already processing queue, wait until queue has finished processing before showing dialog + if (e.data && e.data.processingQueue) { + return; + } + + if (!isAttached) { + const rootContent = document.body.querySelector('.h5p-content'); + if (!rootContent) { + return; + } + offlineDialog.appendTo(rootContent); + rootContent.appendChild(throbberWrapper); + isAttached = true; + } + + startCountDown(); + }.bind(this)); + + requestQueue.on('queueEmptied', function (e) { + if (e.data && e.data.length) { + // New requests were added while processing queue or requests failed again. Re-queue requests. + startCountDown(true); + return; + } + + // Successfully emptied queue + clearInterval(currentInterval); + toggleThrobber(false); + intervalIndex = -1; + if (isShowing) { + offlineDialog.hide(); + isShowing = false; + } + + requestQueue.displayToastMessage( + H5P.t('offlineSuccessfulSubmit'), + true, + { + position: { + vertical: 'top', + offsetVertical: '100', + } + } + ); + + }.bind(this)); + + offlineDialog.on('confirmed', function () { + // Show dialog on next render in case it is being hidden by the 'confirm' button + isShowing = false; + setTimeout(function () { + retryRequests(); + }, 100); + }.bind(this)); + + // Initialize listener for when requests are added to queue + window.addEventListener('online', function () { + retryRequests(); + }.bind(this)); + + // Listen for queued requests outside the iframe + window.addEventListener('message', function (event) { + const isValidQueueEvent = window.parent === event.source + && event.data.context === 'h5p' + && event.data.action === 'queueRequest'; + + if (!isValidQueueEvent) { + return; + } + + this.add(event.data.url, event.data.data); + }.bind(this)); + + /** + * Toggle throbber visibility + * + * @param {boolean} [forceShow] Will force throbber visibility if set + */ + const toggleThrobber = function (forceShow) { + isLoading = !isLoading; + if (forceShow !== undefined) { + isLoading = forceShow; + } + + if (isLoading && isShowing) { + offlineDialog.hide(); + isShowing = false; + } + + if (isLoading) { + throbberWrapper.classList.add('show'); + } + else { + throbberWrapper.classList.remove('show'); + } + }; + /** + * Retries the failed requests + */ + const retryRequests = function () { + clearInterval(currentInterval); + toggleThrobber(true); + requestQueue.resumeQueue(); + }; + + /** + * Increments retry interval + */ + const incrementRetryInterval = function () { + intervalIndex += 1; + if (intervalIndex >= retryIntervals.length) { + intervalIndex = retryIntervals.length - 1; + } + }; + + /** + * Starts counting down to retrying queued requests. + * + * @param forceDelayedShow + */ + const startCountDown = function (forceDelayedShow) { + // Already showing, wait for retry + if (isShowing) { + return; + } + + toggleThrobber(false); + if (!isShowing) { + if (forceDelayedShow) { + // Must force delayed show since dialog may be hiding, and confirmation dialog does not + // support this. + setTimeout(function () { + offlineDialog.show(0); + }, 100); + } + else { + offlineDialog.show(0); + } + } + isShowing = true; + startTime = new Date().getTime(); + incrementRetryInterval(); + clearInterval(currentInterval); + currentInterval = setInterval(updateCountDown, 100); + }; + + /** + * Updates the count down timer. Retries requests when time expires. + */ + const updateCountDown = function () { + const time = new Date().getTime(); + const timeElapsed = Math.floor((time - startTime) / 1000); + const timeLeft = retryIntervals[intervalIndex] - timeElapsed; + countDownNum.textContent = timeLeft.toString(); + + // Retry interval reached, retry requests + if (timeLeft <= 0) { + retryRequests(); + } + }; + + /** + * Add request to offline request queue. Only supports posts for now. + * + * @param {string} url The request url + * @param {Object} data The request data + */ + this.add = function (url, data) { + // Only queue request if it failed because we are offline + if (window.navigator.onLine) { + return false; + } + + requestQueue.add(url, data); + }; + }; + + return offlineRequestQueue; +})(H5P.RequestQueue, H5P.ConfirmationDialog); diff --git a/packages/runtime/vendor/h5p-core/strings.json b/packages/runtime/vendor/h5p-core/strings.json new file mode 100644 index 0000000..f066a82 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/strings.json @@ -0,0 +1,69 @@ +{ + "fullscreen": "Fullscreen", + "disableFullscreen": "Disable fullscreen", + "download": "Download", + "copyrights": "Rights of use", + "embed": "Embed", + "size": "Size", + "showAdvanced": "Show advanced", + "hideAdvanced": "Hide advanced", + "advancedHelp": "Include this script on your website if you want dynamic sizing of the embedded content:", + "copyrightInformation": "Rights of use", + "close": "Close", + "title": "Title", + "author": "Author", + "year": "Year", + "source": "Source", + "license": "License", + "thumbnail": "Thumbnail", + "noCopyrights": "No copyright information available for this content.", + "reuse": "Reuse", + "reuseContent": "Reuse Content", + "reuseDescription": "Reuse this content.", + "downloadDescription": "Download this content as a H5P file.", + "copyrightsDescription": "View copyright information for this content.", + "embedDescription": "View the embed code for this content.", + "h5pDescription": "Visit H5P.org to check out more cool content.", + "contentChanged": "This content has changed since you last used it.", + "startingOver": "You'll be starting over.", + "by": "by", + "showMore": "Show more", + "showLess": "Show less", + "subLevel": "Sublevel", + "confirmDialogHeader": "Confirm action", + "confirmDialogBody": "Please confirm that you wish to proceed. This action is not reversible.", + "cancelLabel": "Cancel", + "confirmLabel": "Confirm", + "licenseU": "Undisclosed", + "licenseCCBY": "Attribution", + "licenseCCBYSA": "Attribution-ShareAlike", + "licenseCCBYND": "Attribution-NoDerivs", + "licenseCCBYNC": "Attribution-NonCommercial", + "licenseCCBYNCSA": "Attribution-NonCommercial-ShareAlike", + "licenseCCBYNCND": "Attribution-NonCommercial-NoDerivs", + "licenseCC40": "4.0 International", + "licenseCC30": "3.0 Unported", + "licenseCC25": "2.5 Generic", + "licenseCC20": "2.0 Generic", + "licenseCC10": "1.0 Generic", + "licenseGPL": "General Public License", + "licenseV3": "Version 3", + "licenseV2": "Version 2", + "licenseV1": "Version 1", + "licensePD": "Public Domain", + "licenseCC010": "CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", + "licensePDM": "Public Domain Mark", + "licenseC": "Copyright", + "contentType": "Content Type", + "licenseExtras": "License Extras", + "changes": "Changelog", + "contentCopied": "Content is copied to the clipboard", + "connectionLost": "Connection lost. Results will be stored and sent when you regain connection.", + "connectionReestablished": "Connection reestablished.", + "resubmitScores": "Attempting to submit stored results.", + "offlineDialogHeader": "Your connection to the server was lost", + "offlineDialogBody": "We were unable to send information about your completion of this task. Please check your internet connection.", + "offlineDialogRetryMessage": "Retrying in :num....", + "offlineDialogRetryButtonLabel": "Retry now", + "offlineSuccessfulSubmit": "Successfully submitted results." +} diff --git a/packages/runtime/vendor/h5p-core/styles/h5p-confirmation-dialog.css b/packages/runtime/vendor/h5p-core/styles/h5p-confirmation-dialog.css new file mode 100644 index 0000000..6e2efa6 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p-confirmation-dialog.css @@ -0,0 +1,311 @@ +.h5p-confirmation-dialog-background { + position: fixed; + height: 100%; + width: 100%; + left: 0; + top: 0; + background: rgba(44, 44, 44, 0.9); + background-color: hsl(from var(--h5p-theme-main-cta-base) h calc(s * 0.3) 18% / 40%); + opacity: 1; + visibility: visible; + -webkit-transition: opacity 0.1s, linear 0s, visibility 0s linear 0s; + transition: opacity 0.1s linear 0s, visibility 0s linear 0s; + z-index: 201; +} +.h5p-confirmation-dialog-background.hidden { + display: none; +} +.h5p-confirmation-dialog-background.hiding { + opacity: 0; + visibility: hidden; + -webkit-transition: opacity 0.1s, linear 0s, visibility 0s linear 0.1s; + transition: opacity 0.1s linear 0s, visibility 0s linear 0.1s; +} +.h5p-confirmation-dialog-popup:focus { + outline: none; +} +.h5p-confirmation-dialog-popup { + position: absolute; + display: flex; + flex-direction: column; + justify-content: center; + box-sizing: border-box; + max-width: 35em; + min-width: 25em; + top: 2em; + left: 50%; + -webkit-transform: translate(-50%, 0%); + -ms-transform: translate(-50%, 0%); + transform: translate(-50%, 0%); + color: #555; + -webkit-transition: transform 0.1s ease-in; + transition: transform 0.1s ease-in; +} +.h5p-confirmation-dialog-popup.hidden { + -webkit-transform: translate(-50%, 50%); + -ms-transform: translate(-50%, 50%); + transform: translate(-50%, 50%); +} +.h5p-confirmation-dialog-header { + padding: 1.5em; + background: #fff; + color: #356593; +} +.h5p-confirmation-dialog-header-text { + font-size: 1.25em; +} +.h5p-confirmation-dialog-body { + background: #fafbfc; + border-top: solid 1px #dde0e9; + padding: 1.25em 1.5em; +} +.h5p-confirmation-dialog-text { + margin-bottom: 1.5em; +} +.h5p-confirmation-dialog-buttons { + float: right; +} +button.h5p-confirmation-dialog-exit:visited, button.h5p-confirmation-dialog-exit:link, button.h5p-confirmation-dialog-exit { + position: absolute; + background: none; + border: none; + font-size: 2.5em; + top: -0.9em; + right: -1.15em; + color: #fff; + cursor: pointer; + text-decoration: none; +} +button.h5p-confirmation-dialog-exit:focus, button.h5p-confirmation-dialog-exit:hover { + color: #E4ECF5; +} +.h5p-confirmation-dialog-exit:before { + font-family: "H5P"; + content: "\e890"; +} +.h5p-core-button.h5p-confirmation-dialog-confirm-button { + @media screen and (max-width: 576px) { + padding-left: 1.5rem; + + span { + display: none; + } + } + + padding-left: 0.75em; + margin-bottom: 0; +} +.h5p-core-button.h5p-confirmation-dialog-confirm-button:before { + content: "\e601"; + margin-top: -6px; + display: inline-block; +} +.h5p-confirmation-dialog-popup.offline .h5p-confirmation-dialog-buttons { + float: none; + text-align: center; +} +.h5p-confirmation-dialog-popup.offline .count-down { + font-family: Arial; + margin-top: 0.15em; + color: #000; +} +.h5p-confirmation-dialog-popup.offline .h5p-confirmation-dialog-confirm-button:before { + content: "\e90b"; + font-weight: normal; + vertical-align: text-bottom; +} +.throbber-wrapper { + display: none; + position: absolute; + height: 100%; + width: 100%; + top: 0; + left: 0; + z-index: 1; + background: rgba(44, 44, 44, 0.9); + background-color: hsl(from var(--h5p-theme-main-cta-base) h calc(s * 0.3) 18% / 40%); +} +.throbber-wrapper.show { + display: block; +} +.throbber-wrapper .throbber-container { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} +.throbber-wrapper .sending-requests-throbber { + position: absolute; + top: 7em; + left: 50%; + transform: translateX(-50%); +} +.throbber-wrapper .sending-requests-throbber:before { + display: block; + font-family: 'H5P'; + content: "\e90b"; + color: white; + font-size: 10em; + animation: request-throbber 1.5s infinite linear; +} +@keyframes request-throbber { + from { + transform: rotate(0); + } + to { + transform: rotate(359deg); + } +} + +@media (prefers-reduced-motion) { + .h5p-confirmation-dialog-background { + -webkit-transition: none; + transition: none; + + .h5p-confirmation-dialog-popup { + -webkit-transition: none; + transition: none; + } + + &.hiding { + .h5p-confirmation-dialog-popup { + opacity: 0; + } + } + } +} + +/* H5P Theme */ + +.h5p-theme .h5p-confirmation-dialog-popup { + max-width: 45rem; + width: calc(80% - 2*var(--h5p-theme-spacing-l)); + min-width: unset; + color: var(--h5p-theme-text-primary); +} + +.h5p-theme .h5p-confirmation-dialog-header { + display: flex; + align-items: center; + color: var(--h5p-theme-text-primary); + padding: var(--h5p-theme-spacing-m) var(--h5p-theme-spacing-l); + font-weight: bold; + background-color: var(--h5p-theme-ui-base); + border-radius: var(--h5p-theme-border-radius-large) var(--h5p-theme-border-radius-large) 0 0; +} + +.h5p-theme .h5p-confirmation-dialog-header-text { + font-size: var(--h5p-theme-font-size-xl); + margin-right: 3em; +} + +.h5p-theme .h5p-confirmation-dialog-body { + background: var(--h5p-theme-ui-base); + border-top: 0px; + padding: 0 var(--h5p-theme-spacing-l) var(--h5p-theme-spacing-l) var(--h5p-theme-spacing-l); + border-radius: 0 0 var(--h5p-theme-border-radius-large) var(--h5p-theme-border-radius-large); +} + +.h5p-theme button.h5p-confirmation-dialog-exit:visited, +.h5p-theme button.h5p-confirmation-dialog-exit:link, +.h5p-theme button.h5p-confirmation-dialog-exit { + font-size: var(--h5p-theme-font-size-xl); + color: var(--h5p-theme-text-primary); + top: var(--h5p-theme-spacing-s); + right: var(--h5p-theme-spacing-s); + line-height: calc(var(--h5p-theme-spacing-s) * 3.0836); + padding: 0; +} + +.h5p-theme button.h5p-confirmation-dialog-exit:focus-visible { + outline: 2px ridge var(--h5p-theme-contrast-cta-white); + border-radius: 100%; + outline-offset: 2px; +} + +.h5p-theme .h5p-confirmation-dialog-exit:before { + transform: translateY(-0.48em); + font-family: "h5p-theme"; + content: "\e910"; + padding: var(--h5p-theme-spacing-s); + border-radius: 50%; +} + +.h5p-theme .h5p-confirmation-dialog-exit:hover:before { + color: var(--h5p-theme-text-primary); + background-color: var(--h5p-theme-alternative-base); +} + +/* TODO: check if code above makes this redundant */ +.h5p-theme .h5p-core-button:focus-visible { + background-color: var(--h5p-theme-contrast-cta-white); +} + +.h5p-theme .h5p-confirmation-dialog-background { + background-color: rgba(0,0,0,0.85); /* Fallback color */ + background-color: hsl(from var(--h5p-theme-main-cta-base) h calc(s * 0.3) 18% / 40%); +} + +.h5p-theme .h5p-confirmation-dialog-buttons { + container-type: inline-size; + container-name: h5p-confirmation-dialog-buttons; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--h5p-theme-spacing-s); + width: 100%; +} + +.h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-secondary-cta { + padding-inline: calc(var(--h5p-theme-spacing-l)); +} + +.h5p-theme .h5p-core-button.h5p-confirmation-dialog-confirm-button:before { + content: "\e908"; + margin-top: unset; + font-size: unset; + font-family: "h5p-theme"; + display: inline-block; + line-height: unset; + padding-right: unset; +} + +@container (max-width: 400px) { + .h5p-theme .h5p-confirmation-dialog-buttons button.h5p-theme-cancel::before { + content: "\e910"; + font-family: "h5p-theme"; + font-size: 90%; + } + + .h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-primary-cta:hover { + padding-inline: var(--h5p-theme-spacing-l); + } + + .h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-primary-cta:disabled:before, + .h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-secondary-cta:disabled:before, + .h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-primary-cta:before { + margin-right: 0; + opacity: 1; + padding: 0; + position: unset; + text-indent: 0; + transform: translate(0, 0); + } + + .h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-secondary-cta:before { + margin-right: 0; + } + + .h5p-theme .h5p-confirmation-dialog-buttons span { + display: none; + } +} + +@container (max-width: 250px) { + .h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-primary-cta:hover { + padding-inline: var(--h5p-theme-spacing-s); + } + .h5p-theme .h5p-confirmation-dialog-buttons .h5p-theme-secondary-cta { + padding-inline: var(--h5p-theme-spacing-s); + } +} diff --git a/packages/runtime/vendor/h5p-core/styles/h5p-core-button.css b/packages/runtime/vendor/h5p-core/styles/h5p-core-button.css new file mode 100644 index 0000000..b708729 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p-core-button.css @@ -0,0 +1,91 @@ +button.h5p-core-button:visited, +button.h5p-core-button:link, +button.h5p-core-button { + font-family: "Open Sans", sans-serif; + font-weight: 600; + font-size: 1em; + line-height: 1.2; + padding: 0.5em 1.25em; + border-radius: 2em; + + background: #2579c6; + color: #fff; + + cursor: pointer; + border: none; + box-shadow: none; + outline: none; + + display: inline-block; + text-align: center; + text-shadow: none; + vertical-align: baseline; + text-decoration: none; + + -webkit-transition: initial; + transition: initial; +} +button.h5p-core-button:focus { + background: #1f67a8; +} +button.h5p-core-button:hover { + background: rgba(31, 103, 168, 0.83); +} +button.h5p-core-button:active { + background: #104888; +} +button.h5p-core-button:before { + font-family: 'H5P'; + padding-right: 0.15em; + font-size: 1.5em; + vertical-align: middle; + line-height: 0.7; +} +button.h5p-core-cancel-button:visited, +button.h5p-core-cancel-button:link, +button.h5p-core-cancel-button { + border: none; + background: none; + color: #a00; + margin-right: 1em; + font-size: 1em; + text-decoration: none; + cursor: pointer; +} +button.h5p-core-cancel-button:hover, +button.h5p-core-cancel-button:focus { + background: none; + border: none; + color: #e40000; +} + +/* H5P theme */ + +.h5p-theme .h5p-core-cancel-button:focus { + color: var(--h5p-theme-main-cta-base); + border: solid 3px var(--h5p-theme-main-cta-base); +} + +.h5p-theme .h5p-core-cancel-button:focus:active { + color: var(--h5p-theme-contrast-cta); +} + +.h5p-theme .h5p-core-cancel-button:hover { + background-color: var(--h5p-theme-main-cta-base); + border: solid 3px var(--h5p-theme-main-cta-base); + color: var(--h5p-theme-contrast-cta); + cursor: pointer; +} + +.h5p-theme .h5p-core-button { + display: flex; +} + +.h5p-theme .h5p-core-button:before { + font-family: 'h5p-theme'; + padding-right: unset; + font-size: unset; + vertical-align: unset; + line-height: unset; +} + diff --git a/packages/runtime/vendor/h5p-core/styles/h5p-fonts.css b/packages/runtime/vendor/h5p-core/styles/h5p-fonts.css new file mode 100644 index 0000000..9fd2ba1 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p-fonts.css @@ -0,0 +1,103 @@ +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + src: url('../fonts/inter/inter-v18-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-regular.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + src: url('../fonts/inter/inter-v18-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-italic.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + src: url('../fonts/inter/inter-v18-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-600.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + src: url('../fonts/inter/inter-v18-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-600italic.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + src: url('../fonts/inter/Inter-ExtraBold.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + src: url('../fonts/inter/Inter-ExtraBoldItalic.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + src: url('../fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-regular.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + src: url('../fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-italic.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + src: url('../fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-600.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + src: url('../fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-600italic.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Open Sans'; + font-style: normal; + font-weight: 700; + src: url('../fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-700.woff2') format('woff2'); +} +@font-face { + font-display: swap; + font-family: 'Open Sans'; + font-style: italic; + font-weight: 700; + src: url('../fonts/open-sans/open-sans-v40-cyrillic_cyrillic-ext_greek_greek-ext_hebrew_latin_latin-ext_math_symbols_vietnamese-700italic.woff2') format('woff2'); +} +@font-face { + font-family: 'h5p'; + src: url('../fonts/h5p-core-30.woff2') format('woff2'); + font-weight: normal; + font-style: normal; +} +@font-face { + font-family: 'h5p-hub-publish'; + src: url('../fonts/h5p-hub-publish.woff2') format('woff2'); + font-weight: normal; + font-style: normal; + font-display: block; +} +@font-face { + font-family: 'h5p-theme'; + src: url('../fonts/h5p-theme.woff2') format('woff2'); + font-weight: normal; + font-style: normal; +} \ No newline at end of file diff --git a/packages/runtime/vendor/h5p-core/styles/h5p-table.css b/packages/runtime/vendor/h5p-core/styles/h5p-table.css new file mode 100644 index 0000000..e781bdc --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p-table.css @@ -0,0 +1,56 @@ +/* Table styling for content types to imitate ckeditor 5 */ +.h5p-iframe { + /* The figure around the table */ + figure.table { + display: table; + table-layout: fixed; + margin: 0 auto; + padding: 0; + float: left; + + /* The actual table */ + table { + border-collapse: collapse; + height: 100%; + width: 100%; + border-spacing: 0; + border-width: 1px; + border-color: #494949; + + td, th { + padding: 1px; + border-color: #494949; + border-bottom-style: solid; + } + + td { + border-width: 0.083em; + } + + th { + text-align: left; + border-width: .167em; + } + + tr:last-child > td { + border-bottom-style: none; + } + } + + figcaption { + background-color: transparent; + caption-side: top; + color: #333; + display: table-caption; + font-size: .75em; + outline-offset: -1px; + padding: .6em; + text-align: center; + word-break: break-word; + } + } + + .table-overflow-protection { + clear: both; + } +} diff --git a/packages/runtime/vendor/h5p-core/styles/h5p-theme-variables.css b/packages/runtime/vendor/h5p-core/styles/h5p-theme-variables.css new file mode 100644 index 0000000..4b591e0 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p-theme-variables.css @@ -0,0 +1,139 @@ +:root { + --h5p-theme-main-cta-base: #006FBF; + --h5p-theme-secondary-cta-base: #E3E9F1; + --h5p-theme-alternative-base: #F1F5FB; + --h5p-theme-background: #F9FBFF; + --h5p-theme-focus: #006FBF; /*TODO: not yet in use */ + + --h5p-theme-main-cta-light: #0D74C0; /*Deviates from +/-5% lightness rule, but is needed for WCAG AA*/ + --h5p-theme-main-cta-dark: #005FA3; + --h5p-theme-contrast-cta: #EBF7FF; + --h5p-theme-contrast-cta-white: #006FBF; + + --h5p-theme-contrast-cta-light: color-mix(in srgb, var(--h5p-theme-main-cta-base), transparent 90%); + + --h5p-theme-secondary-cta-light: #F5F7FA; + --h5p-theme-secondary-cta-dark: #D3DCE9; + --h5p-theme-secondary-contrast-cta: #202122; + --h5p-theme-secondary-contrast-cta-hover: #EBF7FF; + + --h5p-theme-alternative-light: #F7F9FD; + --h5p-theme-alternative-dark: #DBE5F5; + --h5p-theme-alternative-darker: #C7D7EF; + + --h5p-theme-text-primary: #101729; + --h5p-theme-text-secondary: #354054; + --h5p-theme-text-third: #737373; + --h5p-theme-ui-base: #FFFFFF; + --h5p-theme-stroke-1: #DCDFFA; + --h5p-theme-stroke-2: #EDEEFB; + --h5p-theme-stroke-3: #E5E5E5; + --h5p-theme-border-radius-large: 0.5rem; + --h5p-theme-border-radius-medium: 0.375rem; + --h5p-theme-border-radius-small: 0.25rem; + --h5p-theme-font-name: "Inter", sans-serif; + + --h5p-theme-feedback-correct-main: #256D1D; + --h5p-theme-feedback-correct-secondary: #f3fcf0; + --h5p-theme-feedback-correct-third: #cff1c2; + + --h5p-theme-feedback-incorrect-main: #a13236; + --h5p-theme-feedback-incorrect-secondary: #faf0f4; + --h5p-theme-feedback-incorrect-third: #f6dce7; + + --h5p-theme-feedback-neutral-main: #E6C81D; + --h5p-theme-feedback-neutral-secondary: #5E4817; + --h5p-theme-feedback-neutral-third: #F0EBCB; + + /* SPACING */ + + /* Small (default) */ + --h5p-theme-spacing-xl-primary-small: calc(var(--h5p-theme-spacing-xl-primary-medium)*0.8); + --h5p-theme-spacing-primary-small: calc(var(--h5p-theme-spacing-primary-medium)*0.8); + --h5p-theme-spacing-secondary-small: calc(var(--h5p-theme-spacing-secondary-medium)*0.8); + --h5p-theme-spacing-third-small: calc(var(--h5p-theme-spacing-third-medium)*0.8); + --h5p-theme-spacing-fourth-small: calc(var(--h5p-theme-spacing-fourth-medium)*0.8); + --h5p-theme-spacing-fifth-small: calc(var(--h5p-theme-spacing-fifth-medium)*0.8); + + --h5p-theme-font-size-xxl-small: calc(var(--h5p-theme-font-size-xxl-medium)*1); + --h5p-theme-font-size-xl-small: calc(var(--h5p-theme-font-size-xl-medium)*1); + --h5p-theme-font-size-l-small: calc(var(--h5p-theme-font-size-l-medium)*1); + --h5p-theme-font-size-m-small: calc(var(--h5p-theme-font-size-m-medium)*1); + --h5p-theme-font-size-s-small: calc(var(--h5p-theme-font-size-s-medium)*1); + + /* Medium */ + --h5p-theme-spacing-xl-primary-medium: calc(var(--h5p-theme-spacing-xl-primary-large)*0.8); + --h5p-theme-spacing-primary-medium: calc(var(--h5p-theme-spacing-primary-large)*0.8); + --h5p-theme-spacing-secondary-medium: calc(var(--h5p-theme-spacing-secondary-large)*0.8); + --h5p-theme-spacing-third-medium: calc(var(--h5p-theme-spacing-third-large)*0.8); + --h5p-theme-spacing-fourth-medium: calc(var(--h5p-theme-spacing-fourth-large)*0.8); + --h5p-theme-spacing-fifth-medium: calc(var(--h5p-theme-spacing-fifth-large)*0.8); + + --h5p-theme-font-size-xxl-medium: calc(var(--h5p-theme-font-size-xxl-large)*0.9); + --h5p-theme-font-size-xl-medium: calc(var(--h5p-theme-font-size-xl-large)*0.9); + --h5p-theme-font-size-l-medium: calc(var(--h5p-theme-font-size-l-large)*0.9); + --h5p-theme-font-size-m-medium: calc(var(--h5p-theme-font-size-m-large)*0.9); + --h5p-theme-font-size-s-medium: calc(var(--h5p-theme-font-size-s-large)*0.9); + + /* Large */ + --h5p-theme-spacing-xl-primary-large: 3rem; + --h5p-theme-spacing-primary-large: 2rem; + --h5p-theme-spacing-secondary-large: 1.5rem; + --h5p-theme-spacing-third-large: 1rem; + --h5p-theme-spacing-fourth-large: 0.65rem; + --h5p-theme-spacing-fifth-large: 0.5rem; + + --h5p-theme-font-size-xxl-large: 1.5rem; + --h5p-theme-font-size-xl-large: 1.25rem; + --h5p-theme-font-size-l-large: 1.125rem; + --h5p-theme-font-size-m-large: 1rem; + --h5p-theme-font-size-s-large: 0.85rem; +} + +.h5p-content, +.h5peditor { + --h5p-theme-spacing-xl: var(--h5p-theme-spacing-xl-primary-small); + --h5p-theme-spacing-l: var(--h5p-theme-spacing-primary-small); + --h5p-theme-spacing-m: var(--h5p-theme-spacing-secondary-small); + --h5p-theme-spacing-s: var(--h5p-theme-spacing-third-small); + --h5p-theme-spacing-xs: var(--h5p-theme-spacing-fourth-small); + --h5p-theme-spacing-xxs: var(--h5p-theme-spacing-fifth-small); + --h5p-theme-font-size-xxl: var(--h5p-theme-font-size-xxl-small); + --h5p-theme-font-size-xl: var(--h5p-theme-font-size-xl-small); + --h5p-theme-font-size-l: var(--h5p-theme-font-size-l-small); + --h5p-theme-font-size-m: var(--h5p-theme-font-size-m-small); + --h5p-theme-font-size-s: var(--h5p-theme-font-size-s-small); + --h5p-theme-scaling: 0.6; +} + +.h5p-content.h5p-medium, +.h5peditor.h5p-medium { + --h5p-theme-spacing-xl: var(--h5p-theme-spacing-xl-primary-medium); + --h5p-theme-spacing-l: var(--h5p-theme-spacing-primary-medium); + --h5p-theme-spacing-m: var(--h5p-theme-spacing-secondary-medium); + --h5p-theme-spacing-s: var(--h5p-theme-spacing-third-medium); + --h5p-theme-spacing-xs: var(--h5p-theme-spacing-fourth-medium); + --h5p-theme-spacing-xxs: var(--h5p-theme-spacing-fifth-medium); + --h5p-theme-font-size-xxl: var(--h5p-theme-font-size-xxl-medium); + --h5p-theme-font-size-xl: var(--h5p-theme-font-size-xl-medium); + --h5p-theme-font-size-l: var(--h5p-theme-font-size-l-medium); + --h5p-theme-font-size-m: var(--h5p-theme-font-size-m-medium); + --h5p-theme-font-size-s: var(--h5p-theme-font-size-s-medium); + --h5p-theme-scaling: 0.8; +} + +.h5p-content.h5p-large, +.h5peditor.h5p-large { + --h5p-theme-spacing-xl: var(--h5p-theme-spacing-xl-primary-large); + --h5p-theme-spacing-l: var(--h5p-theme-spacing-primary-large); + --h5p-theme-spacing-m: var(--h5p-theme-spacing-secondary-large); + --h5p-theme-spacing-s: var(--h5p-theme-spacing-third-large); + --h5p-theme-spacing-xs: var(--h5p-theme-spacing-fourth-large); + --h5p-theme-spacing-xxs: var(--h5p-theme-spacing-fifth-large); + --h5p-theme-font-size-xxl: var(--h5p-theme-font-size-xxl-large); + --h5p-theme-font-size-xl: var(--h5p-theme-font-size-xl-large); + --h5p-theme-font-size-l: var(--h5p-theme-font-size-l-large); + --h5p-theme-font-size-m: var(--h5p-theme-font-size-m-large); + --h5p-theme-font-size-s: var(--h5p-theme-font-size-s-large); + --h5p-theme-scaling: 1; +} diff --git a/packages/runtime/vendor/h5p-core/styles/h5p-theme.css b/packages/runtime/vendor/h5p-core/styles/h5p-theme.css new file mode 100644 index 0000000..5aec336 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p-theme.css @@ -0,0 +1,184 @@ +.h5p-theme .h5p-content { + font-family: var(--h5p-theme-font-name); +} + +.h5p-theme *:before, .h5p-theme *:after { + font-weight: normal; +} + +/* Form elements don't naturally inherit font-family */ +.h5p-theme .h5p-content input, .h5p-theme .h5p-content textarea, .h5p-theme .h5p-content button { + font-family: inherit; +} + +/* DIALOGS */ +.h5p-content:has(.h5p-theme) .h5p-popup-dialog { + background-color: rgba(0,0,0,0.85); /* Fallback color */ + background-color: hsl(from var(--h5p-theme-main-cta-base) h calc(s * 0.3) 18% / 40%); + container-name: h5p-popup-dialog; + container-type: inline-size; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-inner { + color: var(--h5p-theme-text-primary); + border-radius: var(--h5p-theme-border-radius-large); +} + +.h5p-content:has(.h5p-theme) .h5p-reuse-dialog .h5p-inner { + max-width: 45rem; + width: fit-content; +} + +.h5p-content:has(.h5p-theme) .h5p-reuse-dialog.h5p-popup-dialog.h5p-open .h5p-scroll-content { + border-top: 0; + padding-top: 4.5em; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog.h5p-reuse-dialog .h5p-inner > h2 { + box-shadow: none; +} + +.h5p-content:has(.h5p-theme) .h5p-reuse-dialog .h5p-inner > h2 > a { + color: var(--h5p-theme-main-cta-base); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-inner > h2 { + background-color: var(--h5p-theme-ui-base); + display: flex; + align-items: center; + color: var(--h5p-theme-text-primary); + padding: var(--h5p-theme-spacing-m) var(--h5p-theme-spacing-l); + font-weight: bold; + border-bottom: 0; + background-color: var(--h5p-theme-ui-base); + border-radius: var(--h5p-theme-border-radius-large) var(--h5p-theme-border-radius-large) 0 0 ; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog.h5p-open .h5p-scroll-content { + padding: var(--h5p-theme-spacing-xl) var(--h5p-theme-spacing-l) var(--h5p-theme-spacing-l) var(--h5p-theme-spacing-l); + display: grid; + grid-template-columns: repeat(var(--button-count, 2), 1fr); + background: var(--h5p-theme-ui-base); + border-radius: var(--h5p-theme-border-radius-large); + grid-gap: var(--h5p-theme-spacing-s); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog.h5p-copyrights-dialog.h5p-open .h5p-scroll-content { + grid-template-columns: unset; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog.h5p-embed-dialog.h5p-open .h5p-scroll-content, .h5p-expander-content, +.h5p-content:has(.h5p-theme) .h5p-popup-dialog.h5p-content-user-data-reset-dialog.h5p-open .h5p-scroll-content { + display: block; +} + +/* Same as primary CTA */ +.h5p-content:has(.h5p-theme) .h5p-dialog-ok-button { + padding: var(--h5p-theme-spacing-xs) var(--h5p-theme-spacing-l); + background-color: var(--h5p-theme-main-cta-base); + color: var(--h5p-theme-contrast-cta); + border-radius: var(--h5p-theme-border-radius-medium); + font-weight: 600; + cursor: pointer; + line-height: 1; + font-size: var(--h5p-theme-font-size-m); + transition: all 0.1s linear 0s; + transition-property: text-indent, padding; + border: solid 3px var(--h5p-theme-main-cta-base); +} + +.h5p-content:has(.h5p-theme) .h5p-dialog-ok-button:hover { + background-color: var(--h5p-theme-main-cta-light); + border-color: var(--h5p-theme-main-cta-light); +} + +.h5p-content:has(.h5p-theme) .h5p-dialog-ok-button:active { + background-color: var(--h5p-theme-main-cta-dark); + border-color: var(--h5p-theme-main-cta-dark); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-close { + font-size: var(--h5p-theme-font-size-xl); + margin: var(--h5p-theme-spacing-xs) var(--h5p-theme-spacing-s); + color: var(--h5p-theme-text-primary); + text-align: center; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-close { + display: inline-block; + padding: var(--h5p-theme-spacing-s); + border-radius: 50%; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-close:after { + font-family: "h5p-theme"; + content: "\e910"; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-close:hover { + color: var(--h5p-theme-text-primary); + background-color: var(--h5p-theme-alternative-base); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-horizontal-line-text { + display: none; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-big-button:before { + position: unset; + font-family: 'h5p-theme'; + color: var(--h5p-theme-contrast-cta-white); + display: block; + margin-bottom: var(--h5p-theme-spacing-s); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-button-title { + color: var(--h5p-theme-contrast-cta-white); + font-size: var(--h5p-theme-font-size-l); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-button-description { + color: var(--h5p-theme-text-third); + font-size: var(--h5p-theme-font-size-s); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-big-button { + padding: var(--h5p-theme-spacing-m); + background: var(--h5p-theme-ui-base); + border: solid 2px var(--h5p-theme-stroke-2); + max-width: 20rem; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-copy-button:before { + content: "\e912"; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-download-button:before { + content: "\e911"; + width: max-content; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-download-button:disabled:before { + content: "\e90d"; + font-family: "h5p"; +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-big-button:hover { + background-color: var(--h5p-theme-main-cta-base); +} + +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-big-button:hover.h5p-big-button:before, +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-big-button:hover .h5p-button-title, +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-big-button:hover .h5p-button-description { + color: var(--h5p-theme-contrast-cta); +} + +@container h5p-popup-dialog (width < 480px) { + .h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-big-button { + max-width: 100%; + } + + .h5p-content:has(.h5p-theme) .h5p-popup-dialog.h5p-open .h5p-scroll-content { + grid-template-columns: auto; + } +} diff --git a/packages/runtime/vendor/h5p-core/styles/h5p-tooltip.css b/packages/runtime/vendor/h5p-core/styles/h5p-tooltip.css new file mode 100644 index 0000000..e792008 --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p-tooltip.css @@ -0,0 +1,38 @@ +.h5p-tooltip { + display: none; + position: absolute; + + z-index: 110; + + font-size: 0.8rem; + line-height: 1.2; + text-align: left; + + padding: 0.25rem 0.5rem; + white-space: normal; + word-break: break-all; + width: max-content; + max-width: min(300px, 90%); + + background: #000; + color: #FFF; + + cursor: default; +} +.h5p-tooltip-narrow { + max-width: min(300px, 70%); +} +.h5p-tooltip-visible { + display: block; +} + +.h5p-iframe:has(.h5p-theme) .h5p-tooltip { + background-color: var(--h5p-theme-text-secondary); + color: var(--h5p-theme-ui-base); + font-size: var(--h5p-theme-font-size-m); + max-width: min(400px, 90%); + line-height: 1; + /*border: solid 1px var(--h5p-theme-stroke-1); + box-shadow: 0 0 0.5em #2c2c2c;*/ + border-radius: var(--h5p-theme-border-radius-medium); +} \ No newline at end of file diff --git a/packages/runtime/vendor/h5p-core/styles/h5p.css b/packages/runtime/vendor/h5p-core/styles/h5p.css new file mode 100644 index 0000000..ee798dc --- /dev/null +++ b/packages/runtime/vendor/h5p-core/styles/h5p.css @@ -0,0 +1,716 @@ +html.h5p-iframe, html.h5p-iframe > body { + font-family: Sans-Serif; /* Use the browser's default sans-serif font. (Since Heletica doesn't look nice on Windows, and Arial on OS X.) */ + width: 100%; + height: 100%; + margin: 0; + padding: 0; +} +html.h5p-iframe .h5p-theme, +html.h5p-iframe > body .h5p-theme, +.h5peditor .h5p-theme { + font-family: var( + --h5p-theme-font-name + ); /* Use the browser's default sans-serif font. (Since Heletica doesn't look nice on Windows, and Arial on OS X.) */ +} +.h5p-semi-fullscreen, .h5p-fullscreen, html.h5p-iframe .h5p-container { + overflow: hidden; +} +.h5p-content { + position: relative; + background: #fefefe; + border: 1px solid #EEE; + border-bottom: none; + box-sizing: border-box; + -moz-box-sizing: border-box; +} +.h5p-noselect +{ + -khtml-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + user-select: none; +} +html.h5p-iframe .h5p-content { + font-size: 16px; + line-height: 1.5em; + width: 100%; + height: auto; + -webkit-text-size-adjust: none; + text-size-adjust: none; +} +html.h5p-iframe .h5p-fullscreen .h5p-content, +html.h5p-iframe .h5p-semi-fullscreen .h5p-content { + height: 100%; +} +.h5p-content.h5p-no-frame, +.h5p-fullscreen .h5p-content, +.h5p-semi-fullscreen .h5p-content { + border: 0; +} +.h5p-container { + position: relative; + z-index: 1; + overflow: hidden; +} +.h5p-iframe-wrapper.h5p-fullscreen { + background-color: #000; +} +body.h5p-semi-fullscreen { + position: fixed; + width: 100%; + height: 100%; +} +.h5p-container.h5p-semi-fullscreen { + position: fixed; + top: 0; + left: 0; + z-index: 101; + width: 100%; + height: 100%; + background-color: #FFF; +} + +.h5p-content-controls { + margin: 0; + position: absolute; + right: 0; + top: 0; + z-index: 3; +} +.h5p-fullscreen .h5p-content-controls { + display: none; +} + +.h5p-content-controls > a:link, .h5p-content-controls > a:visited, a.h5p-disable-fullscreen:link, a.h5p-disable-fullscreen:visited { + color: #e5eef6; +} + +.h5p-theme .h5p-content-controls .h5p-enable-fullscreen:before { + font-family: 'h5p-theme'; + font-weight: bold; + content: "\e915"; +} +.h5p-theme .h5p-content-controls .h5p-disable-fullscreen:before { + font-family: 'h5p-theme'; + font-weight: bold; + content: "\e914"; +} +.h5p-theme .h5p-content-controls .h5p-enable-fullscreen, .h5p-theme .h5p-content-controls .h5p-disable-fullscreen { + cursor: pointer; + color: #EEE; + background: rgb(0,0,0); + background: rgba(0,0,0,0.3); + font-size: var(--h5p-theme-font-size-m); + text-indent: 0.04em; + padding: var(--h5p-theme-spacing-xs); +} + +.h5p-theme .h5p-content-controls .h5p-enable-fullscreen:focus-visible, .h5p-theme .h5p-content-controls .h5p-disable-fullscreen:focus-visible { + outline: 2px ridge var(--h5p-theme-contrast-cta-white); + border-radius: 4px; + outline-offset: 2px; +} + +.h5p-enable-fullscreen:before { + font-family: 'H5P'; + content: "\e88c"; +} +.h5p-disable-fullscreen:before { + font-family: 'H5P'; + content: "\e891"; +} +.h5p-enable-fullscreen, .h5p-disable-fullscreen { + cursor: pointer; + color: #EEE; + background: rgb(0,0,0); + background: rgba(0,0,0,0.3); + line-height: 0.975em; + font-size: 2em; + width: 1.125em; + height: 1em; + text-indent: 0.04em; +} +.h5p-disable-fullscreen { + line-height: 0.925em; + width: 1.1em; + height: 0.9em; +} + +.h5p-enable-fullscreen:focus, +.h5p-disable-fullscreen:focus { + outline-style: solid; + outline-width: 1px; + outline-offset: 0.25em; +} + +.h5p-enable-fullscreen:hover, .h5p-disable-fullscreen:hover { + background: rgba(0,0,0,0.5); +} +.h5p-content-controls.themed .h5p-enable-fullscreen:hover, .h5p-content-controls.themed .h5p-disable-fullscreen:hover { + background: rgba(0,0,0,0.5); +} +.h5p-semi-fullscreen .h5p-enable-fullscreen { + display: none; +} + +div.h5p-fullscreen { + width: 100%; + height: 100%; +} +.h5p-iframe-wrapper { + width: auto; + height: auto; +} + +.h5p-fullscreen .h5p-iframe-wrapper, +.h5p-semi-fullscreen .h5p-iframe-wrapper { + width: 100%; + height: 100%; +} + +.h5p-iframe-wrapper.h5p-semi-fullscreen { + width: auto; + height: auto; + background: black; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100001; +} +.h5p-iframe-wrapper.h5p-semi-fullscreen .buttons { + position: absolute; + top: 0; + right: 0; + z-index: 20; +} +.h5p-iframe-wrapper iframe.h5p-iframe { + /* Hack for IOS landscape / portrait */ + width: 10px; + min-width: 100%; + *width: 100%; + /* End of hack */ + height: 100%; + z-index: 10; + overflow: hidden; + border: 0; + display: block; +} + +.h5p-content ul.h5p-actions { + box-sizing: border-box; + -moz-box-sizing: border-box; + list-style: none; + padding: 0px 10px; + margin: 0; + height: 25px; + font-size: 12px; + background: #FAFAFA; + border-top: 1px solid #EEE; + border-bottom: 1px solid #EEE; + clear: both; + font-family: Sans-Serif; +} +.h5p-fullscreen .h5p-actions, .h5p-semi-fullscreen .h5p-actions { + display: none; +} +.h5p-actions > .h5p-button { + float: left; + cursor: pointer; + margin: 0 0.5em 0 0; + background: none; + padding: 0 0.75em 0 0.25em; + vertical-align: top; + color: #707070; + text-decoration: none; + outline: none; + line-height: 22px; +} +.h5p-actions button:hover { + color: #333; +} +.h5p-actions button:active, +.h5p-actions button:focus, +.h5p-actions .h5p-link:active, +.h5p-actions .h5p-link:focus { + color: #666; +} +.h5p-actions button { + display: inline-flex; + padding: 0; + margin: 0; + color: #6A6A6A; + position: relative; + + /* Disable default button style */ + background: none; + border: none; + font: inherit; + cursor: pointer; + + line-height: 2; +} +.h5p-actions button:focus, +.h5p-actions .h5p-link:focus { + outline-style: solid; + outline-width: thin; + outline-offset: -2px; + outline-color: #5981A1; +} +.h5p-actions button:before { + font-family: 'H5P'; + font-size: 20px; + line-height: 23px; + vertical-align: bottom; + padding-right: 0; +} +.h5p-actions > .h5p-button.h5p-export > button:before { + content: "\e90b"; +} +.h5p-actions > .h5p-button.h5p-copyrights > button:before { + content: "\e88f"; +} +.h5p-actions > .h5p-button.h5p-embed > button:before { + content: "\e892"; +} +.h5p-actions .h5p-link { + float: right; + margin-right: 0; + font-size: 2.0em; + line-height: 23px; + position: relative; + color: #6a6a6a; + text-decoration: none; + outline: none; +} +.h5p-actions .h5p-link:before { + font-family: 'H5P'; + content: "\e88e"; + vertical-align: bottom; +} +.h5p-actions > li { + margin: 0; + list-style: none; +} +.h5p-popup-dialog { + position: absolute; + top: 0; + left: 0; + width: 100%; + min-height: 100%; + z-index: 100; + padding: 2em; + box-sizing: border-box; + -moz-box-sizing: border-box; + opacity: 0; + -webkit-transition: opacity 0.2s; + -moz-transition: opacity 0.2s; + -o-transition: opacity 0.2s; + transition: opacity 0.2s; + background:#000; + background:rgba(0,0,0,0.75); +} +.h5p-popup-dialog.h5p-open { + opacity: 1; +} +.h5p-popup-dialog .h5p-inner { + box-sizing: border-box; + -moz-box-sizing: border-box; + background: #fff; + height: 100%; + max-height: 100%; + position: relative; +} +.h5p-popup-dialog .h5p-inner > h2 { + position: absolute; + box-sizing: border-box; + -moz-box-sizing: border-box; + width: 100%; + margin: 0; + background: #eee; + display: block; + color: #656565; + font-size: 1.25em; + padding: 0.325em 0.5em 0.25em; + line-height: 1.25em; + border-bottom: 1px solid #ccc; + z-index: 2; +} +.h5p-popup-dialog .h5p-inner > h2 > a { + font-size: 12px; + margin-left: 1em; +} +/* TODO: Fix the color of the link */ +.h5p-theme .h5p-popup-dialog .h5p-inner > h2 > a{ + color: var(--h5p-theme-main-cta-base); +} +.h5p-embed-dialog .h5p-inner, +.h5p-reuse-dialog .h5p-inner, +.h5p-content-user-data-reset-dialog .h5p-inner { + min-width: 316px; + max-width: 400px; + left: 50%; + top: 50%; + transform: translateX(-50%); +} +.h5p-theme .h5p-embed-dialog .h5p-inner, +.h5p-theme .h5p-reuse-dialog .h5p-inner, +.h5p-theme .h5p-content-user-data-reset-dialog .h5p-inner { + max-width: 45rem !important; + min-width: 35rem !important; +} +.h5p-content:has(.h5p-theme) .h5p-embed-dialog .h5p-embed-code-container, +.h5p-content:has(.h5p-theme) .h5p-embed-size{ + border: 1px solid var(--h5p-theme-stroke-1); + background-color: var(--h5p-theme-ui-base); + padding: var(--h5p-theme-spacing-xs); + font-size: var(--h5p-theme-font-size-m); + line-height: 1; + min-height: var(--h5p-theme-spacing-l); + letter-spacing: normal; + color: var(--h5p-theme-text-third); + border-radius: var(--h5p-theme-border-radius-medium); +} +.h5p-content:has(.h5p-theme) .h5p-content-copyrights { + border-left: none; + margin-left: 0; + padding-left: 0; + border-left: solid 2px var(--h5p-theme-alternative-dark); + padding: 5px; +} +.h5p-content:has(.h5p-theme) .h5p-media-copyright{ + border: solid 1px var(--h5p-theme-alternative-dark); + padding: var(--h5p-theme-spacing-s); + background: var(--h5p-theme-alternative-light); + border-radius: var(--h5p-theme-border-radius-medium); + margin-bottom: 1rem; +} +.h5p-embed-dialog .h5p-embed-code-container, +.h5p-embed-size { + resize: none; + outline: none; + width: 100%; + padding: 0.375em 0.5em 0.25em; + margin: 0; + overflow: hidden; + border: 1px solid #ccc; + box-shadow: 0 1px 2px 0 #d0d0d0 inset; + font-size: 0.875em; + letter-spacing: 0.065em; + font-family: sans-serif; + white-space: pre; + line-height: 1.5em; + height: 2.0714em; + background: #f5f5f5; + box-sizing: border-box; + -moz-box-sizing: border-box; +} +.h5p-content:has(.h5p-theme) .h5p-embed-dialog .h5p-embed-code-container{ + display: block; + +} +.h5p-embed-dialog .h5p-embed-code-container:focus { + height: 5em; +} +.h5p-embed-size { + width: 3.5em; + text-align: right; + margin: 0.5em 0; + line-height: 2em; +} +.h5p-popup-dialog .h5p-scroll-content { + border-top: 2.25em solid transparent; + padding: 1em; + box-sizing: border-box; + -moz-box-sizing: border-box; + color: #555555; + z-index: 1; +} +.h5p-content:has(.h5p-theme) .h5p-popup-dialog .h5p-scroll-content { + color: var(--h5p-theme-text-secondary); +} +.h5p-popup-dialog.h5p-open .h5p-scroll-content { + overflow: auto; + overflow-x: hidden; + overflow-y: auto; + height: 100%; +} +.h5p-popup-dialog .h5p-scroll-content::-webkit-scrollbar { + width: 8px; +} +.h5p-popup-dialog .h5p-scroll-content::-webkit-scrollbar-track { + background: #e0e0e0; +} +.h5p-popup-dialog .h5p-scroll-content::-webkit-scrollbar-thumb { + box-shadow: 0 0 10px #000 inset; + border-radius: 4px; +} +.h5p-popup-dialog .h5p-close { + cursor: pointer; + font-size: 2em; + position: absolute; + right: 0; + top: 0; + width: 1.125em; + height: 1.125em; + line-height: 1.125em; + color: #656565; + cursor: pointer; + text-indent: -0.065em; + z-index: 3 +} +.h5p-popup-dialog .h5p-close:after { + font-family: 'H5P'; + content: "\e894"; +} +.h5p-popup-dialog .h5p-close:focus-visible { + outline: 2px ridge var(--h5p-theme-contrast-cta-white); + outline-offset: 2px; +} +.h5p-popup-dialog .h5p-close:hover:after, +.h5p-popup-dialog .h5p-close:focus:after { + color: #454545; +} +.h5p-popup-dialog .h5p-close:active:after { + color: #252525; +} +.h5p-poopup-dialog h2 { + margin: 0.25em 0 0.5em; +} +.h5p-popup-dialog h3 { + margin: 0.75em 0 0.25em; +} +.h5p-popup-dialog dl { + margin: 0.25em 0 0.75em; +} +.h5p-content:has(.h5p-theme) .h5p-popup-dialog dl{ + margin: 0.25em 0 0.25em; +} + +.h5p-popup-dialog dt { + float: left; + margin: 0 0.75em 0 0; + font-weight: bold; +} +.h5p-popup-dialog dt:after { + content: ':'; +} +.h5p-popup-dialog dd { + margin: 0; +} +.h5p-expander { + cursor: pointer; + font-size: 1.125em; + margin: 0.5em 0 0; + display: inline-block; +} +.h5p-content:has(.h5p-theme) .h5p-expander{ + border: solid 1px var(--h5p-theme-stroke-1); + padding: var(--h5p-theme-spacing-xs); + border-radius: var(--h5p-theme-border-radius-medium); + font-size: var(--h5p-theme-font-size-m); + background-color: var(--h5p-theme-alternative-base); +} +.h5p-content:has(.h5p-theme) .h5p-popup-dialog dt{ + margin: 0 0.25em 0 0; +} +.h5p-expander:before { + content: "+"; + width: 1em; + display: inline-block; + font-weight: bold; +} +.h5p-expander.h5p-open:before { + content: "-"; + text-indent: 0.125em; +} +.h5p-expander:hover, +.h5p-expander:focus { + color: #303030; +} +.h5p-expander:active { + color: #202020; +} +.h5p-expander-content { + display: none; +} +.h5p-expander-content p { + margin: 0.5em 0; +} +.h5p-content-copyrights { + border-left: 0.25em solid #d0d0d0; + margin-left: 0.25em; + padding-left: 0.25em; +} +.h5p-throbber { + background: url('../images/throbber.gif?ver=1.2.1') 10px center no-repeat; + padding-left: 38px; + min-height: 30px; + line-height: 30px; +} +.h5p-dialog-ok-button { + cursor: default; + float: right; + outline: none; + border: 2px solid #ccc; + padding: 0.25em 0.75em 0.125em; + background: #eee; +} +.h5p-dialog-ok-button:hover, +.h5p-dialog-ok-button:focus { + background: #fafafa; +} +.h5p-dialog-ok-button:focus-visible { + outline: 2px ridge var(--h5p-theme-contrast-cta-white); + outline-offset: 2px; +} +.h5p-dialog-ok-button:active { + background: #eeffee; +} +.h5p-big-button { + line-height: 1.25; + display: block; + position: relative; + cursor: pointer; + width: 100%; + padding: 1em 1em 1em 3.75em; + text-align: left; + border: 1px solid #dedede; + background: linear-gradient(#ffffff, #f1f1f2); + border-radius: 0.25em; +} +.h5p-big-button:before { + font-family: 'h5p'; + content: "\e893"; + line-height: 1; + font-size: 3em; + color: #2747f7; + position: absolute; + left: 0.125em; + top: 0.125em; +} +.h5p-copy-button:before { + content: "\e905"; +} +.h5p-big-button:hover { + border: 1px solid #2747f7; + background: #eff1fe; +} +.h5p-big-button:active { + border: 1px solid #dedede; + background: #dfe4fe; +} +.h5p-button-title { + color: #2747f7; + font-size: 15px; + font-weight: bold; + margin-bottom: 0.5em; +} +.h5p-button-description { + color: #757575; +} +.h5p-horizontal-line-text { + border-top: 1px solid #dadada; + line-height: 1; + color: #474747; + text-align: center; + position: relative; + margin: 1.25em 0; +} +.h5p-horizontal-line-text > span { + background: white; + padding: 0.5em; + position: absolute; + top: -1em; + left: 50%; + transform: translateX(-50%); +} +.h5p-toast { + font-size: 0.75em; + background-color: rgba(0, 0, 0, 0.9); + color: #fff; + z-index: 110; + position: absolute; + padding: 0 0.5em; + line-height: 2; + border-radius: 4px; + white-space: nowrap; + pointer-events: none; + top: 0; + opacity: 1; + visibility: visible; + transition: opacity 1s; +} +.h5p-toast-disabled { + opacity: 0; + visibility: hidden; +} +.h5p-content code, +.h5peditor code { + color: #3d3d3d; + background: #e0e0e0; + border-radius: 2px; + padding: 0 5px; +} +.h5p-content pre > code, +.h5peditor pre > code { + background-color: #fafafa; + padding: 5px; + display: block; + line-height: normal; + border: 1px solid #c7c7c7; + border-left-width: 4px; + max-width: 100%; + white-space: pre; + overflow: auto; +} + + +/* This is loaded as part of Core and not Editor since this needs to be outside the editor iframe */ +.h5peditor-semi-fullscreen { + width: 100%; + height: 100%; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 101; +} +iframe.h5peditor-semi-fullscreen { + background: #fff; + z-index: 100001; +} + +.h5p-content.using-mouse *:not(textarea):focus { + outline: none !important; +} + +.h5p-content-hub-button:before { + font-family: "h5p"; + margin-right: 0.5em; + font-size: 0.7em; + line-height: 1; +} + +.h5p-content-hub-button.unpublish:before { + content: "\e917"; +} + +.h5p-content-hub-button.waiting:before, +.h5p-content-hub-button.sync:before { + content: "\e916"; +} + +.h5p-content-hub-button.waiting:before { + display: inline-block; + animation: rotate 2s linear infinite; +} + +@keyframes rotate { + to { + transform: rotate(360deg); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1e5a0b..d22f26b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,10 +155,6 @@ importers: version: 5.0.3(@types/node@26.6.5)(@vitest/browser-playwright@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)) packages/runtime: - dependencies: - h5p-standalone: - specifier: ^3.8.2 - version: 3.8.2 devDependencies: '@types/node': specifier: ^26.6.5 From cdc1c524137a30f80163363a4da89b59fe3d6522 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 18 Oct 2026 22:38:52 +0300 Subject: [PATCH 2/3] ci: leave the vendored H5P core out of CodeQL The core under packages/runtime/vendor/h5p-core/ is h5p-php-library's code at a pinned commit, unchanged. CodeQL's two findings in it (js/insecure-randomness, the anonymous learner id H5P.createUUID makes with Math.random) are upstream's, and before #37 the same code came from node_modules/, which was not scanned. Co-Authored-By: Claude Opus 5.5 --- .github/codeql/codeql-config.yml | 7 +++++++ .github/workflows/codeql.yml | 1 + AGENTS.md | 3 ++- 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 .github/codeql/codeql-config.yml diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..c6af081 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,7 @@ +name: CodeQL config + +# The H5P core as h5p-php-library has it, at the commit vendor/h5p-core/SOURCE.json names, and +# not changed here: a finding in it is upstream's, reported there and taken with `pnpm core:sync`. +# Until #37 the same code came from h5p-standalone under node_modules/, which was not scanned either. +paths-ignore: + - packages/runtime/vendor diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e5d8cb5..cce1f01 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,4 +25,5 @@ jobs: with: languages: javascript-typescript build-mode: none + config-file: ./.github/codeql/codeql-config.yml - uses: github/codeql-action/analyze@v4 diff --git a/AGENTS.md b/AGENTS.md index d380238..a7c6638 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,8 @@ packages/runtime/ @missing-elements/h5p-runtime — the H5P core runtime vendor/h5p-core/ the core as upstream has it, committed: js/, styles/, the fonts and image the styles reach, strings.json (the player's English strings from `getLocalization()`), SOURCE.json (the commit, and `H5PCore::$scripts` and - `$styles`, which say what is taken and in what order) + `$styles`, which say what is taken and in what order); not scanned by + CodeQL (.github/codeql/codeql-config.yml), since a finding there is upstream's l10n/ the core's interface strings, one JSON file per language, from Moodle's language packs, and index.json, their sources and aliases; committed, written by scripts/build-l10n.mjs (`pnpm l10n`, and every Monday by From 54f4f5e5ca4edd44683373abd1da2e5f295866cd Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 18 Oct 2026 22:53:48 +0300 Subject: [PATCH 3/3] fix: a stalled download answers the boot route with a retry, not a missing library On a host without Range, a lookup the worker's walk could not finish because the download stopped moving fell through to "the package does not carry H5P.Foo", a 422 naming a library the package may well carry, after waiting the stall bound twice. It answers 503 with Retry-After now, as an entry's request does. And the walk goes on with the reader `awaitEntry` hands back, as `serveEntry` does: once the archive is whole that is a new reader, and every later lookup on the old partial one opened another. Co-Authored-By: Claude Fable 5.1 --- packages/player/src/sw/mount.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/player/src/sw/mount.ts b/packages/player/src/sw/mount.ts index 96df18f..74ee141 100644 --- a/packages/player/src/sw/mount.ts +++ b/packages/player/src/sw/mount.ts @@ -404,15 +404,25 @@ class VirtualServer { * for as any entry the frame asks for is. One that nothing answers for is named in the refusal. */ private async serveBoot(load: LoadRef): Promise { - const reader = await this.refresh(load, await this.reader(load)) + // `let`: a wait on a partial reader hands back the reader to go on with, which is a new one + // once the archive is whole, as in `serveEntry`. + let reader = await this.refresh(load, await this.reader(load)) const manifest = reader.hasFallbacks ? await reader.mergedManifest() : reader.manifest + // A download that stops moving is a stall, answered with a retry as an entry's is, and not a + // library the package lacks: a lookup that found one ends the walk's waiting. + let stalled: string | undefined const walk = await walkLibraries(dependenciesIn(manifest), async (dependency) => { for (const folder of libraryFolderNames(dependency)) { const name = `${folder}/library.json` let located = reader.get(name) - if (!located && reader.partial) { + if (!located && reader.partial && !stalled) { const outcome = await this.awaitEntry(load, reader, name) - if (outcome !== 'stalled') located = outcome.located + if (outcome === 'stalled') { + stalled = name + return null + } + reader = outcome.reader + located = outcome.located } if (located) { const served = located.entry.name @@ -421,6 +431,7 @@ class VirtualServer { } return null }) + if (stalled) return retryLater(`The download has stalled before ${stalled}`) if (walk.missing.length > 0) { const missingLibraries = { mainLibrary: manifest.mainLibrary, folders: walk.missing.sort(), all: false } throw new PlayerError('bad-archive', explainMissingLibraries(missingLibraries), { missingLibraries })