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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The player origin of Embed My: what a snippet points at. The site people use to
|---|---|
| `/h5p?src=<package url>` | The player page the snippet frames: the player alone, driven by the query string. Upward it speaks the resizer protocol, and posts one `report` on what it learnt about the package, or the `error` that stopped it |
| `/h5p-resizer.js` | The sizing script the snippet's second line names |
| `/build` | The build page: a source an AI chat wrote with the [h5p-author skill](https://github.com/missing-elements/h5p-offline-player/tree/main/skills/h5p-author) becomes a `.h5p` file, built in the browser, played on the page and downloaded from it. `/build#source=<base64url>` carries a source in the link |
| `/samples/*.h5p` | The three demo packages the site offers (CC0 and CC BY), and `quiz-without-libraries.h5p`, the quiz as H5P.com would export it, which the browser tests play |

It has no custom domain, on purpose. A GitHub Pages address has nothing to renew and nothing anyone
Expand All @@ -21,6 +22,7 @@ Needs Node 22.12 or later and pnpm (its version is pinned in `package.json`).
```bash
pnpm install
pnpm dev # dev server, http://localhost:5173/h5p?src=http://localhost:5173/samples/quiz.h5p
# and the build page, http://localhost:5173/build
pnpm typecheck # TypeScript, no output
pnpm build # type-check, then build into dist/
pnpm preview # serve dist/, http://localhost:4173
Expand Down Expand Up @@ -63,14 +65,38 @@ than English. The bundle's licence list is emitted as
| Path | What it holds |
|---|---|
| `h5p.html`, `src/h5p-page.ts` | The player page: `@missing-elements/h5p-embed`'s embed page, started with this origin's options |
| `build.html`, `src/build-page.ts`, `src/build-source.ts`, `src/build.css` | The build page: the page's wiring, and what it does with a source with no DOM (the link's fragment, the files a source names, the text for the chat) |
| `public/samples/` | The sample packages, copied into the build as they are |
| `e2e/`, `playwright.config.ts` | The browser tests: the page plays a sample, with and without its libraries, a framing page gets its height and the report, a bad link gets the error |
| `e2e/`, `playwright.config.ts` | The browser tests: the page plays a sample, with and without its libraries, a framing page gets its height and the report, a bad link gets the error; the build page builds, plays and downloads a pasted source, one from a link and one with a file of its own, and lists a source's problems for the chat |
| `vite.config.ts` | The page, its Content-Security-Policy, `h5p-resizer.js` and the licence files, copied from their packages |

The page gets a `Content-Security-Policy` in a `<meta>` tag at build: GitHub Pages sends no headers, so the tag is
the only policy there is. The dev server gets none, because it injects scripts and styles of its own. The browser
tests fail on any violation.

## The build page

`/build` is for a chat that wrote an h5p-author source but cannot build it. ChatGPT's sandbox
reaches npm only through OpenAI's own mirror, which was reported refusing every install from July
2026 (503, then 404), and the public registry not at all, so `npx @missing-elements/h5p-author`
does not run there. The skill then hands over the source and
this page's address. The page builds with `@missing-elements/h5p-author/browser`, the same build as
the command's: every problem listed at once by its path, ready to copy back into the chat. Its
libraries are `@missing-elements/h5p-libraries/install`'s pack, which Vite emits here 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:
`/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: those only the
person can choose, matched by name, and the page waits for them. A source that names a picture by
web address gets it only where that host allows CORS.

The page's address has no "H5P" in it, as H5P Group's trademark policy asks of a name that does
not say who made it; its heading uses the word descriptively.

## Deploy

A push to `main` runs `.github/workflows/deploy.yml`: build, browser tests, then `dist/` goes to GitHub Pages.
Expand Down
89 changes: 89 additions & 0 deletions build.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Turn the source an AI chat wrote with the h5p-author skill into a .h5p file, in your browser." />
<title>Build an activity · Embed My</title>
<script type="module" src="/src/build-page.ts"></script>
</head>

<body>
<main>
<h1>Build an H5P activity from a source</h1>
<p class="lead">
For an AI chat that wrote a source with the
<a href="https://github.com/missing-elements/h5p-offline-player/tree/main/skills/h5p-author">h5p-author skill</a>
but could not build it, as in ChatGPT, where the build tool does not install. Paste
the source here, play the activity, and download a <code>.h5p</code> file that any H5P site
takes. Nothing is uploaded: the package is built in your browser, from the libraries this
site serves.
</p>

<section class="step" aria-labelledby="source-heading">
<h2 id="source-heading"><span class="step-number">1</span> Source</h2>
<p>
<label for="source">The source, as JSON</label>
<textarea id="source" rows="12" spellcheck="false" autocomplete="off" placeholder='{ "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."] } }'></textarea>
</p>
<p>
<label for="source-file" class="inline-label">Or open a file</label>
<input type="file" id="source-file" accept=".json,application/json,text/plain" />
</p>
</section>

<section class="step" id="files-step" aria-labelledby="files-heading" hidden>
<h2 id="files-heading"><span class="step-number">2</span> Files</h2>
<p>The source names these files. Choose them from your computer; each is matched by its name.</p>
<ul class="file-list" id="file-list"></ul>
<p>
<label for="files" class="inline-label">Choose files</label>
<input type="file" id="files" multiple />
</p>
</section>

<section class="step" aria-labelledby="build-heading">
<h2 id="build-heading"><span class="step-number" id="build-number">2</span> Build</h2>
<p><button type="button" id="build" class="primary">Build</button></p>
<p class="status" id="status" role="status" aria-live="polite"></p>
<div id="problems-box" hidden>
<ul class="messages problems" id="problems"></ul>
<p>
<button type="button" id="copy-problems">Copy the problems for the chat</button>
<span class="hint" id="copy-result" role="status"></span>
</p>
<p class="hint">Paste them into the chat that wrote the source: it fixes them all and writes the source again.</p>
</div>
<div id="notes-box" hidden>
<h3>Notes</h3>
<ul class="messages" id="notes"></ul>
</div>
</section>

<section class="step" id="play-step" aria-labelledby="play-heading" hidden>
<h2 id="play-heading"><span class="step-number" id="play-number">3</span> Play and download</h2>
<p>
Work through all of it, wrong answers included, and check the answers and feedback
against your material. The build checks the structure, not whether the content is right.
</p>
<p><a class="button primary" id="download" download>Download the .h5p</a></p>
<div class="player-box">
<h5p-player id="player"></h5p-player>
</div>
<p class="hint">
The package carries H5P's libraries, each under its own licence, most MIT, listed in
<a href="/assets/libraries-LICENSES.txt">the pack's licence list</a>: check those terms
before you share it.
</p>
</section>
</main>

<footer>
<p>
Built with <a href="https://github.com/missing-elements/h5p-offline-player">h5p-offline-player</a>
and <code>@missing-elements/h5p-author</code>, MIT. The H5P runtime the player loads is
GPL-3.0. Not affiliated with H5P Group; H5P is a registered trademark of H5P Group AS.
</p>
</footer>
</body>
</html>
143 changes: 143 additions & 0 deletions e2e/build.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { deflateRawSync } from 'node:zlib'
import { expect, test, type Page } from '@playwright/test'

/*
* The build page in a real browser against the built files: 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('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVR4nGP4z8DAwMDAxMDAwMDAAAANHQEDasKb6QAAAABJRU5ErkJggg==', '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('/build')
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')

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('/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(`/build#source=${deflated}`)
await expectPlays(page)
await expect(page.locator('#source')).toHaveValue(JSON.stringify(RIVERS))

const plain = Buffer.from(JSON.stringify({ ...RIVERS, title: 'Seas' })).toString('base64url')
await page.goto(`/build#source=${plain}`)
await expect(page.locator('#status')).toContainText('Built “Seas”')
await expectPlays(page)

expect(elsewhere, 'nothing was fetched from another site').toEqual([])
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(`/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')
expect(problems, problems.join('\n')).toEqual([])
})
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@
"vite": "^8.3.5"
},
"dependencies": {
"@missing-elements/h5p-embed": "^1.3.1",
"@missing-elements/h5p-libraries": "^1.0.0",
"@missing-elements/h5p-offline-player": "^1.2.0",
"@missing-elements/h5p-runtime": "^1.0.0"
"@missing-elements/h5p-author": "^0.5.0",
"@missing-elements/h5p-embed": "^1.7.0",
"@missing-elements/h5p-libraries": "^1.1.0",
"@missing-elements/h5p-offline-player": "^1.5.1",
"@missing-elements/h5p-runtime": "^1.0.1"
}
}
Loading
Loading