Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
version: 2
updates:
# The docs run the pdfjs-viewer version they describe, so new releases arrive as their own PR.
# The docs run the pdfjs-viewer version they describe, and /h5p/ serves the H5P packages it
# builds with, so their releases arrive as their own PRs.
- package-ecosystem: npm
directory: /
schedule:
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,12 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
# The H5P build page and the bundles in a real browser, against the built site.
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm e2e
- if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-traces
path: test-results
if-no-files-found: ignore
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
node_modules/
_site/
.DS_Store

# Playwright
test-results/
playwright-report/
52 changes: 47 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
# missing-elements.github.io

Docs for the [missing-elements](https://github.com/missing-elements) web components, published at <https://missing-elements.github.io>.
Docs and tools for the [missing-elements](https://github.com/missing-elements) web components, published at <https://missing-elements.github.io>.

| Path | Source |
| --- | --- |
| `/` | [src/index.njk](src/index.njk), project list in [src/_data/site.js](src/_data/site.js) |
| `/pdfjs-viewer/` | [src/pdfjs-viewer/index.md](src/pdfjs-viewer/index.md) |
| `/h5p/build/` | The H5P build page, [src/h5p/build/](src/h5p/build/): see [The H5P build page](#the-h5p-build-page) |
| `/h5p/bundles/` | The build tool and a library bundle per content type, written by [vite.config.ts](vite.config.ts): see [The bundles](#the-bundles) |

Built with [Eleventy](https://www.11ty.dev/). The pages ship no framework: the only client-side JavaScript is the theme toggle, code-block buttons and the live preview.
Built with [Eleventy](https://www.11ty.dev/). The pages ship no framework: the only client-side JavaScript is the theme toggle, code-block buttons, the live preview and the build page, which [Vite](https://vite.dev/) bundles with the H5P player.

## Develop

Needs Node 22.12 or later.

```bash
pnpm install
pnpm dev # http://localhost:8080
pnpm build # writes _site/
pnpm dev # http://localhost:8080
pnpm build # type-checks, then writes _site/: the pages with Eleventy, then /h5p/ with Vite
pnpm preview # serves _site/ as GitHub Pages does, http://localhost:4173
pnpm e2e # the build page and the bundles in a browser, against _site/: build first, and once
# per machine: pnpm exec playwright install chromium
```

Pushes to `main` deploy through [.github/workflows/pages.yml](.github/workflows/pages.yml). Pull requests get a build check from [.github/workflows/build.yml](.github/workflows/build.yml).
`pnpm dev` serves the pages as Eleventy writes them; the build page's script is there only after a `pnpm build`. The bundles test unzips a bundle with `unzip`, as the h5p-author skill's steps do: macOS and Linux have it, and so does CI's Ubuntu runner; on Windows, run the tests under WSL.

Pushes to `main` deploy through [.github/workflows/pages.yml](.github/workflows/pages.yml). Pull requests get a build check from [.github/workflows/build.yml](.github/workflows/build.yml), which runs the browser tests too.

## Editing the pdfjs-viewer docs

Expand All @@ -39,3 +48,36 @@ Add `try` after the language of a code fence to give it a **▶ Try** button tha
- **JavaScript snippets** run exactly as written. `document.querySelector('pdfjs-viewer-element')` returns the preview element.
- Every Try button comes with a **Reset** button that puts the preview back to its defaults.
- **`try=fresh`** replaces the preview with a new element first. Use it for settings that only apply when the viewer is built, such as `setViewerOptions` and the "before connect" attributes.

## The H5P build page

`/h5p/build/` is for an AI chat that wrote a source with the [h5p-author skill](https://github.com/missing-elements/h5p-offline-player/tree/main/skills/h5p-author) but cannot build it. ChatGPT's sandbox reaches npm only through OpenAI's own mirror, which was reported refusing every install from July 2026, so `npx @missing-elements/h5p-author` does not run there, and the skill hands over the source and this page's address instead. The page builds with `@missing-elements/h5p-author/browser`, the same build as the command's, and lists every problem at once by its path, ready to copy back into the chat. Its libraries are `@missing-elements/h5p-libraries/install`'s pack, one part per library, so a build fetches only the parts its package needs and carries the editor libraries an H5P site's upload check asks for. The package then plays in the player on the page, and downloads from it.

Nothing leaves the browser. A link can carry the source in its fragment, which no server sees: `/h5p/build/#source=<base64url>`, the bytes being the source's JSON as UTF-8, or that JSON compressed with raw deflate (Node's `zlib.deflateRawSync`, Python's `zlib.compressobj(wbits=-15)`) to keep the link short. A linked source is built at once, unless it names files of its own, which only the person can choose. A source that names a picture by web address gets it only where that host allows CORS.

| File | What it holds |
| --- | --- |
| `src/h5p/build/index.njk` | The page's markup, in the site's layout, and its Content-Security-Policy: GitHub Pages sends no headers, so a `<meta>` tag is the only policy it gets |
| `src/h5p/build/build-page.ts` | The page's wiring: the build, the player, the download, a link's source |
| `src/h5p/build/build-source.ts` | What it does with a source with no DOM: the link's fragment, the files a source names, the text for the chat |
| `src/assets/site.css` | Its styles, under *H5P build page* |
| `vite.config.ts` | The page's script, `/h5p/build/build-page.js`, with the player, its runtime, the pack and their licence files in `/h5p/assets/` |
| `e2e/`, `playwright.config.ts` | The browser tests: a pasted source, one from a link and one with a file of its own build, play and download; a source's problems are listed for the chat; `/h5p/bundles/` serves every bundle and part, and its tool and a bundle build a quiz in an empty folder |

The player's Service Worker is in `/h5p/assets/` too, so its scope is `/h5p/assets/h5p/`: no other page of the site is under it. The address names who runs the page, as H5P Group's trademark policy asks of a name with "H5P" in it, and the page uses the word descriptively.

The page and the bundles were embed-my.github.io's `/build` and `/bundles/` until 2026-11-10. Its `/build` becomes a redirect here that keeps the fragment, so the links chats wrote before still build: [embed-my/embed-my.github.io#18](https://github.com/embed-my/embed-my.github.io/pull/18), a step of its own, deployed once this page is live.

## The bundles

`/h5p/bundles/` is for a chat that runs Node and can fetch a file but reaches no npm registry, as ChatGPT's sandbox can: its download tool takes a file the browsing tool has opened. There the h5p-author skill fetches two files, the build tool and the bundle of the source's content type, unzips the bundle and builds with `node h5p-author.mjs build source.json course.h5p --libraries parts`. The chat then sees every problem itself, fixes its source and hands over the package; the build page stays for a chat that can fetch nothing.

| Path | What it holds |
| --- | --- |
| `/h5p/bundles/<type>.zip` | One per content type in the pack, `H5P.QuestionSet.zip`: `parts/`, the pack's whole index with the parts a build of that type can read, and `libraries.txt`. Written by `h5p-author bundle --all` |
| `/h5p/bundles/parts/<part>.h5p` | Every part of the pack under its own name, and `parts/index.json`. No bundle holds an addon: a source whose text calls for one, H5P.MathDisplay for math, stops the build, which names the part to fetch from here |
| `/h5p/bundles/h5p-author.mjs` | The author package's command line as one file, everything it imports bundled in, so it runs with nothing installed. It needs `--libraries`, and has no `--version`: the npm package's own files are not with it |
| `/h5p/bundles/index.json` | The author package's and the pack's versions, and each bundle's file, size and number of parts |
| `/h5p/bundles/libraries.txt` | The pack's licence list, which asks to travel with its libraries |

`vite.config.ts` writes them at build from the packages the lockfile pins, so a Dependabot update of `@missing-elements/h5p-author` or `@missing-elements/h5p-libraries` moves them, and they live in `_site/` only, not in git: 113 MB with pack 1.1.0, well within GitHub Pages' 1 GB. A question type's bundle is about 1 MB, a Question Set's 2.6 MB, a container's up to 8.4 MB. embed-my.github.io keeps serving its own `/bundles/` until the next pack release, for copies of the skill installed before the move.
176 changes: 176 additions & 0 deletions e2e/build.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { deflateRawSync } from 'node:zlib'
import { expect, test, type Page } from '@playwright/test'

/*
* The build page, /h5p/build/, in a real browser against the built site: a source pasted, opened
* from a link or naming a file of its own becomes a package that plays on the page and downloads
* from it, and a source with problems gets them listed, all at once, for the chat that wrote it.
* Every library comes from this origin, and any Content-Security-Policy violation or page error
* fails the test.
*/

const RIVERS = {
type: 'H5P.Blanks',
title: 'Rivers',
language: 'en',
license: 'CC BY',
authors: [{ name: 'Ann Author', role: 'Author' }],
params: { questions: ['The *Danube* flows into the Black Sea.'] }
}

/** A 2×2 PNG, red. */
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR42mP4z8AARAwQCgAf7gP9Y167WwAAAABJRU5ErkJggg==', 'base64')

/** Collects console errors, CSP reports, page errors and requests that leave the origin. */
function watch(page: Page, baseURL: string | undefined): { problems: string[], elsewhere: string[] } {
const problems: string[] = []
const elsewhere: string[] = []
page.on('console', (message) => {
const text = message.text()
if (message.type() === 'error' || /Content.Security.Policy/i.test(text)) problems.push(text)
})
page.on('pageerror', (error) => problems.push(error.message))
page.on('request', (request) => {
const url = request.url()
if (/^https?:/.test(url) && !url.startsWith(baseURL ?? '')) elsewhere.push(url)
})
return { problems, elsewhere }
}

const playerState = (page: Page) => page.locator('h5p-player').evaluate((element: HTMLElement & { state?: string }) => element.state)

async function expectPlays(page: Page): Promise<void> {
await expect(page.locator('#status')).toHaveAttribute('data-state', 'ready', { timeout: 40_000 })
await expect.poll(() => playerState(page)).toBe('ready')
await expect(page.frameLocator('h5p-player iframe').first().locator('.h5p-content, .h5p-container').first()).toBeVisible()
}

test('a pasted source builds, plays and downloads', async ({ page, baseURL }) => {
const { problems, elsewhere } = watch(page, baseURL)
await page.goto('/h5p/build/')
await expect(page.locator('meta[http-equiv="Content-Security-Policy"]')).toHaveCount(1)
// The site's own layout: its theme switch, which site.js shows once it runs under the page's policy.
await expect(page.locator('[data-theme-toggle]')).toBeVisible()
await expect(page.locator('#files-step')).toBeHidden()
// A chat hands the source in a code fence; the page takes what is inside it.
await page.locator('#source').fill('```json\n' + JSON.stringify(RIVERS, null, 2) + '\n```')
await page.locator('#build').click()
await expectPlays(page)
await expect(page.locator('#status')).toContainText('Built “Rivers”: H5P.Blanks, ')
await expect(page.locator('#problems-box')).toBeHidden()
await expect(page.frameLocator('h5p-player iframe').first().locator('.h5p-blanks')).toContainText('flows into the Black Sea')
// The player's worker is scoped to its own folder of assets: no other page of the site is under it.
const scopes = await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).map(({ scope }) => new URL(scope).pathname))
expect(scopes).toEqual(['/h5p/assets/h5p/'])

const [download] = await Promise.all([page.waitForEvent('download'), page.locator('#download').click()])
expect(download.suggestedFilename()).toBe('rivers.h5p')
const stream = await download.createReadStream()
const chunks: Buffer[] = []
for await (const chunk of stream) chunks.push(chunk as Buffer)
const zip = Buffer.concat(chunks)
expect(zip.subarray(0, 4).toString('latin1'), 'a zip').toBe('PK\x03\x04')
// The libraries an H5P site's upload check asks for are in it, as the command's build writes them.
expect(zip.includes(Buffer.from('H5PEditor.'))).toBe(true)

expect(elsewhere, 'nothing was fetched from another site').toEqual([])
expect(problems, problems.join('\n')).toEqual([])
})

test('a source with problems gets them all listed, ready for the chat', async ({ page, context, baseURL }) => {
const { problems } = watch(page, baseURL)
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.goto('/h5p/build/')
const { title: _title, ...untitled } = RIVERS
await page.locator('#source').fill(JSON.stringify({ ...untitled, colour: 'blue' }))
await page.locator('#build').click()
await expect(page.locator('#status')).toHaveAttribute('data-state', 'error')
const listed = page.locator('#problems li')
// Each problem by its path, every one at once.
await expect(listed.locator('code', { hasText: /^colour$/ })).toHaveCount(1)
await expect(listed.locator('code', { hasText: /^title$/ })).toHaveCount(1)
await expect(page.locator('#play-step')).toBeHidden()

await page.locator('#copy-problems').click()
await expect(page.locator('#copy-result')).toHaveText('Copied.')
const copied = await page.evaluate(() => navigator.clipboard.readText())
expect(copied).toMatch(/^The build page found \d+ problems in the source\. Fix them all and write the whole source again:\n- /)
expect(copied).toContain('- colour: ')

// Text that is not JSON is a problem of its own, said where it is.
await page.locator('#source').fill('{ "type": "H5P.Blanks", ')
await page.locator('#build').click()
await expect(listed).toHaveCount(1)
await expect(listed.first()).toContainText('is not valid JSON')
expect(problems, problems.join('\n')).toEqual([])
})

test('a link carrying the source builds it at once, deflated or not', async ({ page, baseURL }) => {
const { problems, elsewhere } = watch(page, baseURL)
const deflated = deflateRawSync(Buffer.from(JSON.stringify(RIVERS))).toString('base64url')
await page.goto(`/h5p/build/#source=${deflated}`)
await expectPlays(page)
await expect(page.locator('#source')).toHaveValue(JSON.stringify(RIVERS))

// Plain base64url, with umlauts and ß: the bytes are UTF-8, and the file name keeps the letters.
const plain = Buffer.from(JSON.stringify({ ...RIVERS, language: 'de', title: 'Größte Flüsse' })).toString('base64url')
await page.goto(`/h5p/build/#source=${plain}`)
await expect(page.locator('#status')).toContainText('Built “Größte Flüsse”')
await expectPlays(page)
await expect(page.locator('#download')).toHaveAttribute('download', 'größte-flüsse.h5p')

// A title in another script keeps its letters too, rather than becoming activity.h5p.
await page.goto(`/h5p/build/#source=${Buffer.from(JSON.stringify({ ...RIVERS, language: 'uk', title: 'Річки України' })).toString('base64url')}`)
await expectPlays(page)
await expect(page.locator('#download')).toHaveAttribute('download', 'річки-україни.h5p')

expect(elsewhere, 'nothing was fetched from another site').toEqual([])
expect(problems, problems.join('\n')).toEqual([])
})

test('a Question Set shows its cover title as written, tags and entities nowhere', async ({ page, baseURL }) => {
const { problems } = watch(page, baseURL)
// h5p-author 0.5.0 wrapped this plain field in <div>, which the set draws as text (h5p-offline-player#164).
const title = "Germany's Surrender: die größten Ereignisse"
const quiz = {
type: 'H5P.QuestionSet', title: 'Surrender', language: 'en', license: 'CC BY', authors: [{ name: 'Ann Author', role: 'Author' }],
params: {
introPage: { showIntroPage: true, title, introduction: 'Five questions.' },
questions: [{ library: 'H5P.TrueFalse', params: { question: 'The war in Europe ended in 1945.', correct: 'true' } }]
}
}
await page.goto(`/h5p/build/#source=${Buffer.from(JSON.stringify(quiz)).toString('base64url')}`)
await expectPlays(page)
await expect(page.frameLocator('h5p-player iframe').first().locator('h2').first()).toHaveText(title)
expect(problems, problems.join('\n')).toEqual([])
})

test('a source that names a file waits for it, then builds with it', async ({ page, baseURL }) => {
const { problems } = watch(page, baseURL)
const delta = {
type: 'H5P.ImageHotspots',
title: 'Delta',
language: 'en',
license: 'CC BY',
authors: [{ name: 'Ann Author', role: 'Author' }],
params: {
image: { path: 'pictures/delta.png' },
backgroundImageAltText: 'A river delta seen from above',
hotspots: [{ position: { x: 30, y: 40 }, header: 'Main channel', content: [{ library: 'H5P.Text', params: { text: 'Most of the water flows here.' } }] }]
}
}
await page.goto(`/h5p/build/#source=${Buffer.from(JSON.stringify(delta)).toString('base64url')}`)
await expect(page.locator('#files-step')).toBeVisible()
await expect(page.locator('#build-number')).toHaveText('3')
await expect(page.locator('#file-list li')).toHaveAttribute('data-chosen', 'false')
await expect(page.locator('#status')).toHaveText('The source names files: choose them, then Build.')

await page.locator('#files').setInputFiles({ name: 'delta.png', mimeType: 'image/png', buffer: PNG })
await expect(page.locator('#file-list li')).toHaveAttribute('data-chosen', 'true')
await page.locator('#build').click()
await expectPlays(page)
await expect(page.locator('#status')).toContainText('1 media file')
// The picture reaches the content and decodes: 2×2, as chosen.
await expect.poll(() => page.frameLocator('h5p-player iframe').first().locator('img').first().evaluate((img: HTMLImageElement) => img.naturalWidth)).toBe(2)
expect(problems, problems.join('\n')).toEqual([])
})
Loading
Loading