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
49 changes: 21 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ 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 |
| `/bundles/` | For a chat that runs Node and fetches files but reaches no npm registry: the build tool as one file, a bundle of libraries per content type, every library part on its own, their licences and an `index.json` (see *The bundles*) |
| `/build` | A redirect to the build page, which moved to [missing-elements.github.io/h5p/build/](https://missing-elements.github.io/h5p/build/) (#17), keeping the link's `#source=` (see *The build page moved*) |
| `/bundles/` | For a chat that runs Node and fetches files but reaches no npm registry: the build tool as one file, a bundle of libraries per content type, every library part on its own, their licences and an `index.json`. Kept until the next pack release (see *The bundles*) |
| `/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 @@ -23,7 +23,6 @@ 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 @@ -51,7 +50,7 @@ A package exported without its libraries, as H5P.com and h5p.org export them, ge
`@missing-elements/h5p-libraries`: the H5P hub's libraries for every content type it serves, one part per library,
which Vite emits as hashed assets and the page uses by default (`pack`, `defaultLibraries: 'pack'`). A package
downloads only the parts it needs, each checked against the pack's SHA-256 and kept in the browser for every package
that uses it; the build page reads the same parts. Nothing is behind the pack: since player 0.6 there is no H5P hub
that uses it. Nothing is behind the pack: since player 0.6 there is no H5P hub
fallback, so a content type the pack lacks is reported as missing libraries, and the page makes no request to a third
party by itself. The frame upgrades content written for an older minor version of a library to the one the pack
serves, with that library's `upgrades.js`, so a package that carries older libraries plays the pack's newer ones
Expand All @@ -71,37 +70,25 @@ than English. The pack'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) |
| `build.html` | `/build`, the redirect to the build page's new address, which keeps the link's fragment when JavaScript runs |
| `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; 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; `/bundles/` lists and serves every bundle and part, and its tool and a bundle build a quiz in an empty folder, a part named by the build fetched for math |
| `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; `/build` sends a visit on to the build page's new address, with its fragment when JavaScript runs, and without it when it does not; `/bundles/` lists and serves every bundle and part, and its tool and a bundle build a quiz in an empty folder, a part named by the build fetched for math |
| `vite.config.ts` | The page, its Content-Security-Policy, `h5p-resizer.js` and the licence files, copied from their packages, and `/bundles/`, written from them |

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.
## The build page moved

`/build` was 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)
became a `.h5p` file there, built in the browser, played and downloaded. It moved on 2026-11-10 to
[missing-elements.github.io/h5p/build/](https://missing-elements.github.io/h5p/build/), the
Missing Elements site, in that site's own look (#17); its code and tests are there now. `/build`
here sends a visit on to it with the address's fragment, so a link a chat wrote before,
`/build#source=<base64url>`, still builds its source. Without JavaScript the page refreshes to the
new address, which can't carry the fragment, and links to it.

## The bundles

Expand All @@ -125,6 +112,12 @@ Dependabot update of `@missing-elements/h5p-author` or `@missing-elements/h5p-li
them, and they live in `dist/` 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.

They moved with the build page: missing-elements.github.io serves the same files at
[`/h5p/bundles/`](https://missing-elements.github.io/h5p/bundles/index.json), and the skill fetches
them from there. They stay here only for copies of the skill installed before the move, until the
next pack release; then `bundles()` goes, with `e2e/bundles.spec.ts` and the
`@missing-elements/h5p-author` dependency.

## Deploy

A push to `main` runs `.github/workflows/deploy.yml`: build, browser tests, then `dist/` goes to GitHub Pages.
Expand Down
94 changes: 15 additions & 79 deletions build.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,87 +3,23 @@
<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>
<meta name="robots" content="noindex" />
<title>The build page has moved · Embed My</title>
<link rel="canonical" href="https://missing-elements.github.io/h5p/build/" />
<!--
The build page moved to missing-elements.github.io (#17). A visit here goes on there with its
fragment, so a link a chat wrote before, #source= and all, still builds its source.
-->
<script>
location.replace('https://missing-elements.github.io/h5p/build/' + location.hash)
</script>
<noscript><meta http-equiv="refresh" content="0; url=https://missing-elements.github.io/h5p/build/" /></noscript>
</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>
<p>
The build page has moved to
<a href="https://missing-elements.github.io/h5p/build/">missing-elements.github.io/h5p/build</a>.
</p>
</body>
</html>
46 changes: 46 additions & 0 deletions e2e/build-redirect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { expect, test, type Page } from '@playwright/test'

/*
* /build was the build page until it moved to missing-elements.github.io/h5p/build/ (#17). A visit
* goes on there with its fragment, so a link a chat wrote before, `#source=` and all, still builds.
* The other site is answered here by a stand-in: the tests check where the browser goes.
*/

const TARGET = 'https://missing-elements.github.io/h5p/build/'

/** Answers the other site with a stand-in page, and collects page errors and policy violations. */
async function watch(page: Page): Promise<string[]> {
const problems: string[] = []
page.on('console', (message) => {
if (message.type() === 'error' || /Content.Security.Policy/i.test(message.text())) problems.push(message.text())
})
page.on('pageerror', (error) => problems.push(error.message))
await page.route(/^https:\/\/missing-elements\.github\.io\//, (route) => route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>Stand-in</title>' }))
return problems
}

test('sends /build on to the build page, keeping the #source= fragment', async ({ page }) => {
const problems = await watch(page)
const source = Buffer.from(JSON.stringify({ type: 'H5P.Blanks', title: 'Rivers' })).toString('base64url')
await page.goto(`/build#source=${source}`)
await page.waitForURL(`${TARGET}#source=${source}`)

// A link with a query, as ChatGPT adds one, keeps its fragment too.
await page.goto(`/build?utm_source=chatgpt.com#source=${source}`)
await page.waitForURL(`${TARGET}#source=${source}`)

await page.goto('/build')
await page.waitForURL(TARGET)
expect(problems, problems.join('\n')).toEqual([])
})

test.describe('without JavaScript', () => {
test.use({ javaScriptEnabled: false })

test('still sends /build on to the build page, though without the fragment', async ({ page }) => {
const problems = await watch(page)
await page.goto('/build#source=e30')
await page.waitForURL(TARGET)
expect(problems, problems.join('\n')).toEqual([])
})
})
Loading
Loading