Open a file
.h5p files from your file manager: double-click one, or use Open with. Safari and iOS do not hand files to installed apps; use Choose a file there./app/?src= and a package's address opens that package in the app, for a teacher sending a course by link.What works offline
diff --git a/apps/demo/demo/languages.js b/apps/demo/demo/languages.js new file mode 100644 index 0000000..71d5deb --- /dev/null +++ b/apps/demo/demo/languages.js @@ -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' +} diff --git a/apps/demo/demo/player-page.css b/apps/demo/demo/player-page.css index b4ad495..d62f6b8 100644 --- a/apps/demo/demo/player-page.css +++ b/apps/demo/demo/player-page.css @@ -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 { diff --git a/apps/demo/demo/player-page.js b/apps/demo/demo/player-page.js index d3f392e..9b67e3d 100644 --- a/apps/demo/demo/player-page.js +++ b/apps/demo/demo/player-page.js @@ -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') @@ -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') @@ -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' } @@ -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. */ @@ -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. diff --git a/apps/demo/index.html b/apps/demo/index.html index 1f8e327..7ddde04 100644 --- a/apps/demo/index.html +++ b/apps/demo/index.html @@ -114,9 +114,9 @@Self-hosted H5P in one web component
Choose file - +Self-hosted H5P in one web component
The package's own scripts run on this site. What it lacks comes from this site's library pack.
diff --git a/apps/demo/tests/app.test.ts b/apps/demo/tests/app.test.ts index ac0cae2..3b51568 100644 --- a/apps/demo/tests/app.test.ts +++ b/apps/demo/tests/app.test.ts @@ -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) { @@ -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