Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ apps/demo/ the demo app: the dev server for the whole repository,
demo/page-common.js what the player page, the cmi5 page and the app share:
the message box, the log, `foreignHost` (whether a package is another
site's) and the trust prompt, one copy since 2026-10-15;
demo/languages.js the language select's list for the player page
and the app, read from the runtime's l10n/index.json (#53);
demo/theme.js the light/dark switch, a classic script every page
loads in its head before the stylesheet, since the CSP allows no
inline script and a pinned theme has to be on <html> before the
Expand Down Expand Up @@ -806,7 +808,13 @@ element, and the element acts. That relay is why `frame-document.ts` has a `mess
page is not in English, Thai included, which finds nothing in it. A script rather than JSON
because the frame's `script-src` names the runtime's origin and its `connect-src` does not. A
`runtime` without `l10nJs`, or a runtime directory without the file (its 404 ignored: no
`data-h5p`), leaves the frame English. The embed page takes `&lang=`.
`data-h5p`), leaves the frame English. The embed page takes `&lang=`. The player page and the
app have a language select (2026-10-20, #53), filled from the runtime's `l10n/index.json`, each
language named in its own tongue only where the browser has that language's data — Chromium has
none for Albanian, Armenian and a dozen more, and answered in the browser's language — and set
as the element's `lang`, a change playing the current package again. The page starts on
English, its own; the app on the first of the browser's languages the runtime has, and keeps
the learner's pick in `localStorage`.

The translations are Moodle's: H5P publishes none for the core, each platform translates it
itself, and Moodle's translators (AMOS) are the most complete, under GPL-3.0-or-later; Lumi's set
Expand Down
3 changes: 3 additions & 0 deletions apps/demo/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@
@supports not selector(:popover-open) { .app #menu-button { display: none; } }
.app .menu button { justify-content: flex-start; min-height: 2.5rem; border: 0; background: transparent; font-weight: 400; }
.app .menu button:hover { background: var(--surface-2); }
/* The select keeps the shared sheet's width, or the menu would grow to the longest language's name. */
.app .menu-language { display: grid; gap: 0.35rem; margin-top: 0.25rem; padding: 0.6rem 0.75rem 0; border-top: 1px solid var(--line); }
.app .menu-note { max-width: 16rem; margin: 0; padding: 0.3rem 0.75rem 0.5rem; color: var(--muted); font-size: 0.8rem; line-height: 1.4; }

/* ---------------------------------------------------------------- the screens */

Expand Down
31 changes: 31 additions & 0 deletions apps/demo/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/

import { foreignHost, messageIn, trustPrompt } from '../demo/page-common.js'
import { fillLanguages, offered, preferredLanguage } from '../demo/languages.js'
import { pack } from '@missing-elements/h5p-libraries'
import librariesNotice from '@missing-elements/h5p-libraries/libraries.txt?url'
import { copyOf, fileKey, forget, forgetAll, linkKey, recentFiles, remember } from './recent.js'
Expand Down Expand Up @@ -338,6 +339,36 @@ let current = null
*/
let opens = 0

/**
* The language of H5P's own strings, from the menu: by default the first of the browser's
* languages the runtime has, else English, and once the learner picks one, that, remembered on
* this device. The element reads its `lang` when a load starts, so a change opens the current
* file again, where the learner was: the content saves its state as its document goes. Set here,
* before anything opens, a launch's file and a link's `?src=` included.
*/
const LANGUAGE_KEY = 'h5p-app-language'
const languageSelect = $('#language')
{
let kept = null
try {
kept = localStorage.getItem(LANGUAGE_KEY)
} catch {
// Storage refused, as in some private windows: the browser's language, every time.
}
const browserLanguages = navigator.languages?.length ? navigator.languages : [navigator.language]
fillLanguages(languageSelect, kept && offered(kept) ? kept : preferredLanguage(browserLanguages))
player.setAttribute('lang', languageSelect.value)
}
languageSelect.addEventListener('change', () => {
try {
localStorage.setItem(LANGUAGE_KEY, languageSelect.value)
} catch {
// Not remembered: it holds for this visit.
}
player.setAttribute('lang', languageSelect.value)
void reopen?.(player.getAttribute('libraries'))
})

/** A file's name as a title until the package says its own: without the extension. */
const fileTitle = (name) => name.replace(/\.h5p$/i, '') || name

Expand Down
4 changes: 4 additions & 0 deletions apps/demo/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@
<button type="button" id="menu-open">Open a file…</button>
<button type="button" class="menu-install" id="menu-install" hidden>Install the app</button>
<button type="button" id="menu-about">About this app</button>
<!-- Filled from the runtime's languages by app.js, and remembered on this device. -->
<label class="language menu-language">Language <select id="language" aria-describedby="language-note"></select></label>
<p class="menu-note" id="language-note">Of H5P's own buttons and dialogs. The content's text stays as its author wrote it.</p>
</div>

<main>
Expand Down Expand Up @@ -149,6 +152,7 @@ <h3>Open a file</h3>
<li>The files you opened are listed under it, the latest first. Choose one to open it again where you left off.</li>
<li>Installed from Chrome or Edge, the app opens <code>.h5p</code> files from your file manager: double-click one, or use <em>Open with</em>. Safari and iOS do not hand files to installed apps; use <strong>Choose a file</strong> there.</li>
<li>A link to <code>/app/?src=</code> and a package's address opens that package in the app, for a teacher sending a course by link.</li>
<li>H5P's own buttons and dialogs — full screen, the licence details, the notice when a file has changed — are in your browser's language where the app has it, and in English otherwise. <strong>Language</strong> in the menu changes it. The content's own text, its questions and buttons, stays in the language its author wrote it in.</li>
</ul>

<h3>What works offline</h3>
Expand Down
67 changes: 67 additions & 0 deletions apps/demo/demo/languages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* The languages the runtime has H5P's own strings in — the fullscreen button, Rights of use,
* Reuse, Embed, the licence names, the "content has changed" dialog — for the language select on
* the player page and in the app. Read from the runtime's own index, so a language its weekly
* refresh adds appears here with no change. English is the core's own and not in the index. What
* a select sets is the element's `lang`; which strings a tag gets is the runtime's to decide.
*/

import { aliases, languages } from '../../../packages/runtime/l10n/index.json'

/**
* A tag's name in its own language, capitalised as a list of languages shows it; `null` where the
* browser has none. A browser without a language's data names it in its own language instead —
* Chromium, asked in German for Albanian's own name, answered "Albanisch" — so a name is taken
* only from data for the language itself.
*/
function ownName(tag) {
try {
const names = new Intl.DisplayNames([tag], { type: 'language', fallback: 'none' })
if (names.resolvedOptions().locale.split('-')[0] !== tag.split('-')[0]) return null
const name = names.of(tag)
return name ? name[0].toLocaleUpperCase(tag) + name.slice(1) : null
} catch {
return null
}
}

/**
* Each language as `{ tag, label }`, the label its own name and the English one ("Deutsch —
* German"), in the order of their own names, which is where a speaker looks for theirs.
*/
export const LANGUAGES = [['en', 'English'], ...Object.entries(languages).map(([tag, { name }]) => [tag, name.replace(/^(.+) - (.+)$/, '$1 ($2)')])]
.map(([tag, english]) => {
const own = ownName(tag)
const label = own && own.toLowerCase() !== english.toLowerCase() ? `${own} — ${english}` : english
return { tag, label, order: own ?? english }
})
.sort((a, b) => a.order.localeCompare(b.order, 'en', { sensitivity: 'base' }))
.map(({ tag, label }) => ({ tag, label }))

/** Whether the select offers `tag`, written as the runtime's index writes it. */
export const offered = (tag) => LANGUAGES.some((language) => language.tag === tag)

/** Fills `select` with the languages, `selected` chosen. */
export function fillLanguages(select, selected) {
select.replaceChildren(...LANGUAGES.map(({ tag, label }) => new Option(label, tag, false, tag === selected)))
select.value = selected
}

/**
* The first of `preferred`, the browser's languages with the most wanted first, that the runtime
* has: by its own aliases, and by dropping subtags from the end, as its lookup does; English when
* it has none of them. Only what the select starts on: the runtime looks the tag up itself.
*/
export function preferredLanguage(preferred) {
const tags = new Map(LANGUAGES.map(({ tag }) => [tag.toLowerCase(), tag]))
const aliased = new Map(Object.entries(aliases).map(([alias, tag]) => [alias.toLowerCase(), tag]))
for (const wanted of preferred) {
const subtags = String(wanted).replaceAll('_', '-').toLowerCase().split('-')
for (let length = subtags.length; length > 0; length--) {
const tag = subtags.slice(0, length).join('-')
const found = tags.get(tag) ?? aliased.get(tag)
if (found) return found
}
}
return 'en'
}
21 changes: 21 additions & 0 deletions apps/demo/demo/player-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,27 @@ label.file:has(input:focus-visible) { outline: 2px solid var(--accent); outline-
.mode label:has(input:disabled) input { cursor: default; }
.mode-note { flex: 1 1 20rem; margin: 0; color: var(--muted); font-size: 0.85rem; line-height: 1.45; }

/* The language of H5P's own strings, beside the modes and in the app's menu (demo/languages.js). */
/* At most 16rem, and narrower where that does not fit. Sized by its longest name the select pushed
a phone's form past the screen's edge, and so did a fixed 16rem at 320 px, in all three engines:
a fixed width is also the narrowest a select will go. A percentage width is not. */
.language { display: inline-flex; flex: 0 1 auto; align-items: center; gap: 0.5rem; min-width: 0; max-width: 100%; color: var(--muted); font-size: 0.875rem; font-weight: 500; }
.language select {
flex: 0 1 auto;
width: 100%;
min-width: 0;
max-width: 16rem;
min-height: 2.5rem;
padding: 0.25rem 0.6rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--fg);
font: inherit;
font-weight: 400;
}
.language select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.samples { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.875rem 0 0; }

.samples button {
Expand Down
20 changes: 18 additions & 2 deletions apps/demo/demo/player-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { foreignHost, logInto, messageIn, trustPrompt } from './page-common.js'
import { fillLanguages } from './languages.js'
import { pack } from '@missing-elements/h5p-libraries'

const player = document.querySelector('h5p-player')
Expand All @@ -18,6 +19,7 @@ const bar = document.querySelector('#progress')
const message = document.querySelector('#message')
const log = document.querySelector('#log')
const refreshToggle = document.querySelector('#refresh')
const languageSelect = document.querySelector('#language')
const confirmBox = document.querySelector('#confirm-src')
const modeNote = document.querySelector('#mode-note')
const modeTag = document.querySelector('#mode-tag')
Expand Down Expand Up @@ -241,10 +243,12 @@ const REFRESH_NOTES = {
on: ' Where the pack has a newer version of a library the package carries, that version plays.',
off: ' The libraries the package carries play as exported.'
}
/** What the language select changes, and what it cannot: the content's text is in the package. */
const LANGUAGE_NOTE = " The language is that of H5P's own buttons and dialogs; the content's text stays as its author wrote it."
const playsUntrusted = () => form.elements.mode.value === 'untrusted'
const describeMode = () => {
const mode = form.elements.mode.value
modeNote.textContent = NOTES[mode] + (mode === 'trusted' ? REFRESH_NOTES[refreshToggle.checked ? 'on' : 'off'] : '')
modeNote.textContent = NOTES[mode] + (mode === 'trusted' ? REFRESH_NOTES[refreshToggle.checked ? 'on' : 'off'] : '') + LANGUAGE_NOTE
// Untrusted, none of the package's own libraries play, so there is nothing to refresh.
refreshToggle.disabled = mode === 'untrusted'
}
Expand All @@ -254,12 +258,21 @@ const setMode = (mode) => {
}
setMode(form.elements.mode.value)

/**
* The language of H5P's own strings: English, the page's own, until the visitor picks another.
* The element reads its `lang` when a load starts, as it reads the modes.
*/
fillLanguages(languageSelect, 'en')

/** Puts the form's choices on the element, for the load about to start; returns them for the log. */
const applyMode = () => {
const untrusted = playsUntrusted()
player.untrusted = untrusted
if (refreshToggle.checked) player.removeAttribute('refresh-libraries')
else player.setAttribute('refresh-libraries', 'off')
return untrusted ? ' (untrusted: none of its own code runs)' : refreshToggle.checked ? '' : ' (libraries as exported)'
player.setAttribute('lang', languageSelect.value)
const language = languageSelect.value === 'en' ? '' : ` (H5P's own strings: ${languageSelect.value})`
return (untrusted ? ' (untrusted: none of its own code runs)' : refreshToggle.checked ? '' : ' (libraries as exported)') + language
}

/** Plays a package at a URL, in the switch's mode. */
Expand Down Expand Up @@ -317,6 +330,9 @@ refreshToggle.addEventListener('change', () => {
reload?.()
})

// And in another language: the fullscreen button, Rights of use and the licence names change.
languageSelect.addEventListener('change', () => reload?.())

// A sample marked `data-untrusted` plays as data against this site's bundle: a content-only
// export, as h5p.com and h5p.org make them, the way a site plays a package it did not make. The
// others play in whichever mode the switch is in.
Expand Down
8 changes: 5 additions & 3 deletions apps/demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ <h1>Self-hosted H5P in one web component</h1>
Choose file
<input type="file" id="file" accept=".h5p,application/zip" />
</label>
<!-- The element's two modes, for whatever the visitor plays next, and whether a library the
package carries plays at the pack's newer version: changing either plays the current
package again. See demo/player-page.js. -->
<!-- The element's two modes, for whatever the visitor plays next, whether a library the
package carries plays at the pack's newer version, and the language of H5P's own
strings: changing any of them plays the current package again. See demo/player-page.js. -->
<div class="mode-row">
<div class="mode" role="radiogroup" aria-label="How to play the package" aria-describedby="mode-note">
<label><input type="radio" name="mode" value="trusted" checked />Trusted</label>
Expand All @@ -125,6 +125,8 @@ <h1>Self-hosted H5P in one web component</h1>
<div class="mode">
<label><input type="checkbox" id="refresh" checked aria-describedby="mode-note" />Newest libraries</label>
</div>
<!-- Filled from the runtime's languages by demo/languages.js. -->
<label class="language">Language <select id="language" aria-describedby="mode-note"></select></label>
<p class="mode-note" id="mode-note">
The package's own scripts run on this site. What it lacks comes from this site's library pack.
</p>
Expand Down
46 changes: 43 additions & 3 deletions apps/demo/tests/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ const nextReady = (page: Page) =>
* unless a test brings its own: Playwright cannot answer it, and without it the app takes the path
* Safari and Firefox take, the page's file input. `onDisk` opens it in a profile on disk, as a
* browser window is (closed with `closePage`), and `installed` answers `display-mode` as an
* installed window would; see `profileOnDisk`.
* installed window would; see `profileOnDisk`. `locale` is the browser's language, the
* `navigator.languages` the app picks its default language from.
*/
async function openApp(path = '/app/', { keepPicker = false, onDisk = false, installed = false } = {}) {
const page = onDisk ? await profileOnDisk({ installed }) : await browser.newPage({ viewport: VIEWPORT })
async function openApp(path = '/app/', { keepPicker = false, onDisk = false, installed = false, locale = undefined as string | undefined } = {}) {
const page = onDisk ? await profileOnDisk({ installed }) : await browser.newPage({ viewport: VIEWPORT, locale })
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
if (!keepPicker) {
Expand Down Expand Up @@ -517,6 +518,45 @@ it('has a menu and an About dialog that Escape closes', async () => {
await page.close()
})

it('shows H5P’s own strings in the browser’s language, or the one picked in the menu, and remembers the pick', async () => {
const { page, errors } = await openApp('/app/', { locale: 'fr-FR' })
/** The language the element was given, the one the menu shows, and the frame's fullscreen label. */
const language = () =>
page.evaluate(() => {
const player = document.querySelector('h5p-player')!
const frame = player.shadowRoot!.querySelector('iframe')!.contentWindow as unknown as { H5P?: { t(key: string): string } } | null
return {
lang: player.getAttribute('lang'),
menu: document.querySelector<HTMLSelectElement>('#language')!.value,
fullscreen: frame?.H5P?.t('fullscreen') ?? null
}
})

// French by default, from the browser's own language, which the runtime has.
const choosing = page.waitForEvent('filechooser')
await page.click('#open')
await (await choosing).setFiles(QUIZ)
await playerIs(page, 'ready')
expect(await language()).toEqual({ lang: 'fr', menu: 'fr', fullscreen: 'Plein écran' })
const steps = await page.evaluate(() => history.length)

// Picked in the menu: the open file plays again in German, on the same screen and history step.
await page.click('#menu-button')
const again = nextReady(page)
await page.selectOption('#language', 'de')
await again
expect(await language()).toEqual({ lang: 'de', menu: 'de', fullscreen: 'Vollbild' })
expect(await seen(page)).toMatchObject({ screen: 'player', bar: 'Do you know what just happened?' })
expect(await page.evaluate(() => history.length)).toBe(steps)

// Remembered over the browser's own on the next visit.
await page.reload()
await workerChecked(page)
expect(await language()).toMatchObject({ lang: 'de', menu: 'de' })
expect(errors).toEqual([])
await page.close()
})

it('plays a file dropped on it, and says what went wrong with a link that fails', async () => {
const { page } = await openApp()
await page.evaluate(async () => {
Expand Down
Loading