From 24b0979e19799867e69d4239b65882cb8b44b5b0 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Tue, 10 Nov 2026 03:26:44 +0200 Subject: [PATCH 1/2] feat: /build sends a visit on to missing-elements.github.io/h5p/build/, with its fragment The build page moved to the Missing Elements site, in that site's own look (missing-elements/missing-elements.github.io#8). build.html is now a redirect that keeps the address's fragment, so a link a chat wrote before, #source= and all, still builds; without JavaScript it refreshes to the new address. The page's code and tests go with it. /bundles/ stays until the next pack release, for copies of the h5p-author skill installed before the move. Closes #17 --- README.md | 49 ++++---- build.html | 94 +++------------ e2e/build-redirect.spec.ts | 45 +++++++ e2e/build.spec.ts | 167 -------------------------- src/build-page.ts | 225 ----------------------------------- src/build-source.ts | 132 --------------------- src/build.css | 237 ------------------------------------- src/h5p-page.ts | 3 +- tsconfig.json | 2 - vite.config.ts | 5 + 10 files changed, 87 insertions(+), 872 deletions(-) create mode 100644 e2e/build-redirect.spec.ts delete mode 100644 e2e/build.spec.ts delete mode 100644 src/build-page.ts delete mode 100644 src/build-source.ts delete mode 100644 src/build.css diff --git a/README.md b/README.md index 1226185..dc5f758 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ The player origin of Embed My: what a snippet points at. The site people use to |---|---| | `/h5p?src=` | 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=` 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 @@ -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 @@ -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 @@ -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 | | `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, with or without JavaScript; `/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 `` 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=`, 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=`, 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 @@ -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. diff --git a/build.html b/build.html index 182fbbd..e07932b 100644 --- a/build.html +++ b/build.html @@ -3,87 +3,23 @@ - - Build an activity · Embed My - + + The build page has moved · Embed My + + + + -
-

Build an H5P activity from a source

-

- For an AI chat that wrote a source with the - h5p-author skill - 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 .h5p file that any H5P site - takes. Nothing is uploaded: the package is built in your browser, from the libraries this - site serves. -

- -
-

1 Source

-

- - -

-

- - -

-
- - - -
-

2 Build

-

-

- - -
- - -
- -
-

- Built with h5p-offline-player - and @missing-elements/h5p-author, 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. -

-
+

+ The build page has moved to + missing-elements.github.io/h5p/build. +

diff --git a/e2e/build-redirect.spec.ts b/e2e/build-redirect.spec.ts new file mode 100644 index 0000000..8a0426e --- /dev/null +++ b/e2e/build-redirect.spec.ts @@ -0,0 +1,45 @@ +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 { + 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: 'Stand-in' })) + 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', async ({ page }) => { + await watch(page) + await page.goto('/build') + await page.waitForURL(TARGET) + }) +}) diff --git a/e2e/build.spec.ts b/e2e/build.spec.ts deleted file mode 100644 index 4248339..0000000 --- a/e2e/build.spec.ts +++ /dev/null @@ -1,167 +0,0 @@ -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 { - 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)) - - // 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(`/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(`/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
, 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(`/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(`/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([]) -}) diff --git a/src/build-page.ts b/src/build-page.ts deleted file mode 100644 index e9634a6..0000000 --- a/src/build-page.ts +++ /dev/null @@ -1,225 +0,0 @@ -import '@missing-elements/h5p-offline-player' -import { buildPackage, openPack, SourceError } from '@missing-elements/h5p-author/browser' -import { pack as installPack } from '@missing-elements/h5p-libraries/install' -import { runtime } from '@missing-elements/h5p-runtime' -import { forTheChat, matchFiles, namedFiles, packageName, parseSource, sized, sourceFromFragment, type Problem } from './build-source' -import './build.css' - -/** - * The build page, `/build`: a source an AI chat wrote with the h5p-author skill becomes a `.h5p` - * file here, in the browser, for a chat whose sandbox cannot install the build tool (ChatGPT's npm - * mirror was reported refusing installs). The build is `@missing-elements/h5p-author/browser`, the same build as the - * command's; its libraries are `@missing-elements/h5p-libraries/install`'s pack, which Vite emits - * here one part per library, the build fetching only the parts a package needs. The package then - * plays in the player on this page and downloads from it. Nothing leaves the browser: a link that - * carries a source carries it in its fragment, `#source=`, which no server sees. - */ - -type Opened = Awaited> - -const $ = (id: string): T => { - const element = document.getElementById(id) - if (!element) throw new Error(`The page has no #${id}`) - return element as T -} - -const sourceField = $('source') -const sourceFile = $('source-file') -const filesStep = $('files-step') -const fileList = $('file-list') -const filesInput = $('files') -const buildButton = $('build') -const buildNumber = $('build-number') -const playNumber = $('play-number') -const status = $('status') -const problemsBox = $('problems-box') -const problemsList = $('problems') -const copyProblems = $('copy-problems') -const copyResult = $('copy-result') -const notesBox = $('notes-box') -const notesList = $('notes') -const playStep = $('play-step') -const download = $('download') -const player = $('player') as HTMLElementTagNameMap['h5p-player'] - -player.runtime = runtime - -/** The pack, opened at the first build and kept for the page's builds. */ -let opened: Promise | null = null -/** The files chosen so far, by name; a file chosen again under the same name replaces the first. */ -const chosen = new Map() -/** The problems last shown, for the copy button. */ -let shownProblems: Problem[] = [] -/** The address of the package last built, given back when the next one replaces it. */ -let packageUrl: string | null = null -/** Counts builds, so that one overtaken by a newer one shows nothing. */ -let builds = 0 - -function setStatus(text: string, state: 'idle' | 'building' | 'ready' | 'error'): void { - status.textContent = text - status.dataset.state = state -} - -function fillList(list: HTMLUListElement, items: Problem[]): void { - list.replaceChildren(...items.map(({ path, message }) => { - const item = document.createElement('li') - const where = document.createElement('code') - where.textContent = path - item.append(where, ` ${message}`) - return item - })) -} - -function showProblems(problems: Problem[]): void { - shownProblems = problems - fillList(problemsList, problems) - problemsBox.hidden = problems.length === 0 - copyResult.textContent = '' -} - -function showNotes(notes: Problem[]): void { - fillList(notesList, notes) - notesBox.hidden = notes.length === 0 -} - -/** The step numbers, with the Files step shown or not. */ -function number(withFiles: boolean): void { - filesStep.hidden = !withFiles - buildNumber.textContent = withFiles ? '3' : '2' - playNumber.textContent = withFiles ? '4' : '3' -} - -/** The source's files, each marked chosen or not; the step is shown only for a source that names some. */ -function updateFiles(): string[] { - const parsed = parseSource(sourceField.value) - const paths = 'source' in parsed ? namedFiles(parsed.source) : [] - const { files } = matchFiles(paths, [...chosen.values()]) - fileList.replaceChildren(...paths.map((path) => { - const item = document.createElement('li') - const name = document.createElement('code') - name.textContent = path - const file = files[path] - item.dataset.chosen = String(Boolean(file)) - item.append(name, file ? ` chosen, ${sized(file.size)}` : ' not chosen yet') - return item - })) - number(paths.length > 0) - return paths -} - -/** What the build says beyond its notes: interface texts it could not translate. */ -function translationNotes(language: string, translations: Awaited>['translations']): Problem[] { - if (/^en(?:-|$)/i.test(language)) return [] - const notes: Problem[] = [] - for (const { library, translation, strings, english } of translations) { - if (!translation) notes.push({ path: library, message: `has no translation for ${language}: its ${strings} interface texts are English.` }) - else if (english.length) notes.push({ path: library, message: `${english.length} of ${strings} interface texts are as in English, untranslated or the same word: ${english.map(({ field }) => field).join(', ')}.` }) - } - return notes -} - -async function build(): Promise { - const run = ++builds - showProblems([]) - showNotes([]) - const parsed = parseSource(sourceField.value) - if ('problem' in parsed) { - setStatus('The source cannot be read.', 'error') - showProblems([parsed.problem]) - return - } - const { files } = matchFiles(updateFiles(), [...chosen.values()]) - setStatus('Building…', 'building') - buildButton.disabled = true - try { - opened ??= openPack(installPack) - const libraries = await opened - const result = await buildPackage(parsed.source, { libraries, files }) - if (run !== builds) return - const title = String(result.manifest.title ?? '') - const name = packageName(title) - if (packageUrl) URL.revokeObjectURL(packageUrl) - packageUrl = URL.createObjectURL(result.blob) - download.href = packageUrl - download.download = name - download.textContent = `Download ${name}` - showNotes([...result.notes, ...translationNotes(String(result.manifest.language ?? ''), result.translations)]) - const media = result.media.length ? `, ${result.media.length} media file${result.media.length === 1 ? '' : 's'}` : '' - setStatus(`Built “${title}”: ${String(result.manifest.mainLibrary)}, ${result.libraries.length} libraries${media}, ${sized(result.blob.size)}. Starting it below…`, 'building') - playStep.hidden = false - player.file = new File([result.blob], name, { type: 'application/zip' }) - } catch (error) { - if (run !== builds) return - if (error instanceof SourceError) { - const problems = (error as SourceError & { problems: Problem[] }).problems - setStatus(`The source has ${problems.length} problem${problems.length === 1 ? '' : 's'}; nothing was built.`, 'error') - showProblems(problems) - } else { - setStatus(`The build failed: ${error instanceof Error ? error.message : error}`, 'error') - // A pack that could not open is opened again at the next build. - opened = null - } - } finally { - if (run === builds) buildButton.disabled = false - } -} - -player.addEventListener('ready', () => { - if (status.dataset.state !== 'building') return - setStatus(status.textContent!.replace(/ Starting it below…$/, ' It plays below.'), 'ready') -}) -player.addEventListener('error', (event) => { - const { message } = (event as unknown as CustomEvent<{ message: string }>).detail - if (player.state === 'ready') return // the content runs on; the frame logs it - setStatus(`It was built, but does not play: ${message}`, 'error') -}) - -let typing: ReturnType | undefined -sourceField.addEventListener('input', () => { - clearTimeout(typing) - typing = setTimeout(updateFiles, 300) -}) - -sourceFile.addEventListener('change', async () => { - const file = sourceFile.files?.[0] - if (!file) return - sourceField.value = await file.text() - updateFiles() -}) - -filesInput.addEventListener('change', () => { - for (const file of filesInput.files ?? []) chosen.set(file.name, file) - filesInput.value = '' - updateFiles() -}) - -buildButton.addEventListener('click', () => void build()) - -copyProblems.addEventListener('click', async () => { - try { - await navigator.clipboard.writeText(forTheChat(shownProblems)) - copyResult.textContent = 'Copied.' - } catch { - copyResult.textContent = 'The browser did not allow copying; select the list and copy it.' - } -}) - -/** A link's source is built at once, unless it names files, which only the person can choose. */ -async function fromLink(): Promise { - let text: string | null - try { - text = await sourceFromFragment(location.hash) - } catch (error) { - setStatus(error instanceof Error ? error.message : String(error), 'error') - return - } - if (text === null) return - sourceField.value = text - const paths = updateFiles() - if (paths.length) setStatus('The source names files: choose them, then Build.', 'idle') - else await build() -} - -updateFiles() -void fromLink() -window.addEventListener('hashchange', () => void fromLink()) diff --git a/src/build-source.ts b/src/build-source.ts deleted file mode 100644 index 7dbe51c..0000000 --- a/src/build-source.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * What the build page does with a source before and after `@missing-elements/h5p-author` sees it, - * with no DOM: the source a link carries, the files a source names, and the lines a chat is given - * back. The browser tests reach it through the page. - */ - -/** A problem or a note, as the author package reports them: where in the source, and what. */ -export interface Problem { - path: string - message: string -} - -/** - * The source a link carries in its fragment, `#source=`, which the browser never - * sends to a server. The bytes are the source's JSON, as UTF-8, or that JSON compressed with - * raw deflate, which a chat's sandbox writes with Node's `zlib.deflateRawSync` or Python's - * `zlib.compressobj(wbits=-15)` to keep the link short. Returns `null` for a fragment that - * carries none, and throws for one that carries something else. - */ -export async function sourceFromFragment(hash: string): Promise { - const value = new URLSearchParams(hash.replace(/^#/, '')).get('source') - if (!value) return null - let bytes: Uint8Array - try { - const base64 = value.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '') - bytes = Uint8Array.from(atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4)), (char) => char.charCodeAt(0)) - } catch { - throw new Error('The link’s #source= is not base64url') - } - const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes) - if (isJson(text)) return text - try { - const inflated = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')) - const inflatedText = await new Response(inflated).text() - if (isJson(inflatedText)) return inflatedText - } catch { - // Not deflate either; said below. - } - throw new Error('The link’s #source= is neither JSON nor deflated JSON in base64url') -} - -function isJson(text: string): boolean { - if (!/^\s*\{/.test(text)) return false - try { - JSON.parse(text) - return true - } catch { - return false - } -} - -/** - * Reads the text as a source, or says where it is not JSON, as a problem the chat can fix. - */ -export function parseSource(text: string): { source: unknown } | { problem: Problem } { - if (!text.trim()) return { problem: { path: 'source', message: 'is empty: paste the JSON the chat wrote' } } - // A chat often hands the source inside a Markdown code fence; take what is inside it. - const fenced = /^\s*```[\w-]*\s*\n([\s\S]*?)\n\s*```\s*$/.exec(text) - try { - return { source: JSON.parse(fenced ? fenced[1] : text) } - } catch (error) { - return { problem: { path: 'source', message: `is not valid JSON: ${error instanceof Error ? error.message : error}` } } - } -} - -/** Whether a file's `path` is a web address, which the build fetches itself, rather than a file to choose. */ -const isAddress = (path: string): boolean => /^(?:https?|data):/i.test(path.trim()) - -/** - * The files a source names by name rather than by web address: every `{ "path": … }` in it, in - * the order they come. The author package looks each one up by the path as written. - */ -export function namedFiles(source: unknown): string[] { - const found = new Set() - const walk = (value: unknown): void => { - if (Array.isArray(value)) value.forEach(walk) - else if (value && typeof value === 'object') { - const path = (value as { path?: unknown }).path - if (typeof path === 'string' && path.trim() && !isAddress(path)) found.add(path) - Object.values(value).forEach(walk) - } - } - walk(source) - return [...found] -} - -/** A path's last part, which is what a file chosen from a computer is called. */ -export const baseName = (path: string): string => path.slice(path.replace(/\\/g, '/').lastIndexOf('/') + 1) - -/** - * The files to build with: each path the source names, matched to a chosen file by its name, - * keyed by the path as the source writes it. - */ -export function matchFiles(paths: string[], chosen: File[]): { files: Record, missing: string[] } { - const byName = new Map(chosen.map((file) => [file.name, file])) - const files: Record = {} - const missing: string[] = [] - for (const path of paths) { - const file = byName.get(baseName(path)) - if (file) files[path] = file - else missing.push(path) - } - return { files, missing } -} - -/** The problems, as the text a person pastes back into the chat that wrote the source. */ -export function forTheChat(problems: Problem[]): string { - const count = `${problems.length} problem${problems.length === 1 ? '' : 's'}` - return [ - `The build page found ${count} in the source. Fix them all and write the whole source again:`, - ...problems.map(({ path, message }) => `- ${path}: ${message}`) - ].join('\n') -} - -/** - * A file name for the package, from its title: its letters, marks and digits in any script, lower - * case, joined by hyphens, at most 60 of them. "Die größten Ereignisse" is - * `die-größten-ereignisse.h5p`, and a Cyrillic or Greek title keeps its letters, where a slug of - * ASCII alone turned ß into a hyphen and such a title into `activity.h5p`. - */ -export function packageName(title: unknown): string { - const words = String(title ?? '') - .normalize('NFC') - .toLowerCase() - .replace(/[^\p{L}\p{M}\p{N}]+/gu, '-') - .replace(/^-+|-+$/g, '') - const short = Array.from(words).slice(0, 60).join('').replace(/-+$/, '') - return `${short || 'activity'}.h5p` -} - -/** A size in kilobytes below a megabyte, in megabytes above. */ -export const sized = (bytes: number): string => (bytes < 1_000_000 ? `${Math.max(1, Math.round(bytes / 1000))} kB` : `${(bytes / 1_000_000).toFixed(1)} MB`) diff --git a/src/build.css b/src/build.css deleted file mode 100644 index 96288f2..0000000 --- a/src/build.css +++ /dev/null @@ -1,237 +0,0 @@ -/* The build page. System fonts and colours that follow the system's light or dark scheme. */ - -:root { - color-scheme: light dark; - --bg: #ffffff; - --fg: #1d2330; - --muted: #5a6273; - --line: #d8dce4; - --panel: #f5f6f9; - --accent: #2357c6; - --accent-fg: #ffffff; - --ok: #17703a; - --bad: #b3261e; - --code: #eef0f4; -} - -@media (prefers-color-scheme: dark) { - :root { - --bg: #14171d; - --fg: #e6e8ee; - --muted: #a3aab8; - --line: #333a46; - --panel: #1c2028; - --accent: #7aa2ff; - --accent-fg: #0d1220; - --ok: #6fd28f; - --bad: #ff8a80; - --code: #262b35; - } -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - background: var(--bg); - color: var(--fg); - font: 16px/1.55 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -} - -main, -footer { - max-width: 52rem; - margin: 0 auto; - padding: 0 16px; -} - -main { - padding-top: 2rem; -} - -h1 { - font-size: 1.75rem; - line-height: 1.25; - margin: 0 0 0.75rem; -} - -h2 { - font-size: 1.2rem; - margin: 0 0 0.75rem; - display: flex; - align-items: center; - gap: 0.6rem; -} - -h3 { - font-size: 1rem; - margin: 1rem 0 0.25rem; -} - -a { - color: var(--accent); -} - -code { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.9em; - background: var(--code); - padding: 0.05em 0.3em; - border-radius: 4px; - overflow-wrap: anywhere; -} - -.lead { - color: var(--muted); - margin: 0 0 1.5rem; -} - -.step { - border-top: 1px solid var(--line); - padding: 1.25rem 0 0.5rem; -} - -.step-number { - display: inline-grid; - place-items: center; - width: 1.7rem; - height: 1.7rem; - border-radius: 50%; - background: var(--accent); - color: var(--accent-fg); - font-size: 0.95rem; -} - -label { - display: block; - font-weight: 600; - margin-bottom: 0.35rem; -} - -label.inline-label { - display: inline; - margin-right: 0.5rem; -} - -textarea { - width: 100%; - min-height: 12rem; - resize: vertical; - padding: 0.6rem 0.7rem; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--panel); - color: var(--fg); - font: 0.9rem/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} - -textarea:focus-visible, -button:focus-visible, -.button:focus-visible, -input:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -input[type='file'] { - max-width: 100%; -} - -button, -.button { - display: inline-block; - font: inherit; - padding: 0.5rem 1rem; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--panel); - color: var(--fg); - cursor: pointer; - text-decoration: none; -} - -button.primary, -.button.primary { - background: var(--accent); - border-color: var(--accent); - color: var(--accent-fg); - font-weight: 600; -} - -button:disabled { - opacity: 0.6; - cursor: progress; -} - -.status:empty { - display: none; -} - -.status { - padding: 0.6rem 0.8rem; - border-radius: 8px; - background: var(--panel); - border-left: 4px solid var(--line); -} - -.status[data-state='ready'] { - border-left-color: var(--ok); -} - -.status[data-state='error'] { - border-left-color: var(--bad); -} - -.status[data-state='building'] { - border-left-color: var(--accent); -} - -.messages { - padding-left: 1.2rem; -} - -.messages li { - margin: 0.25rem 0; -} - -.problems li::marker { - color: var(--bad); -} - -.file-list { - padding-left: 1.2rem; -} - -.file-list li[data-chosen='true']::marker { - color: var(--ok); -} - -.file-list li[data-chosen='false'] { - color: var(--muted); -} - -.hint { - color: var(--muted); - font-size: 0.9rem; -} - -.player-box { - border: 1px solid var(--line); - border-radius: 8px; - overflow: hidden; - background: #ffffff; -} - -h5p-player { - display: block; -} - -footer { - margin-top: 2rem; - padding-bottom: 2rem; - border-top: 1px solid var(--line); - color: var(--muted); - font-size: 0.85rem; -} diff --git a/src/h5p-page.ts b/src/h5p-page.ts index fbf661f..dc2efc7 100644 --- a/src/h5p-page.ts +++ b/src/h5p-page.ts @@ -20,8 +20,7 @@ import { runtime } from '@missing-elements/h5p-runtime' * the browser for every package that uses it, where the whole `libraries.h5p` (15.5 MB since * pack 1.1.0, 1.8 MB of it libraries the player never reads) was downloaded at the first package * that lacked anything. A package that carries an older version of a library plays the pack's - * newer one (player 0.10), unless the address says `&refresh-libraries=off`. The build page reads - * the same parts. + * newer one (player 0.10), unless the address says `&refresh-libraries=off`. * - `defaultLibraries: 'pack'`: an export without its libraries (H5P.com, h5p.org) takes them from * that pack, and from nowhere else: the player has no hub fallback since 0.6, so the page asks no * third party for anything. Snippets have never carried `&libraries=`, so this keeps them playing diff --git a/tsconfig.json b/tsconfig.json index ba29d2a..225848f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,8 +24,6 @@ }, "include": [ "src/h5p-page.ts", - "src/build-page.ts", - "src/build-source.ts", "e2e", "playwright.config.ts", "vite.config.ts" diff --git a/vite.config.ts b/vite.config.ts index c023ffe..749b40b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -91,6 +91,11 @@ function packageFiles(): Plugin { * ChatGPT's sandbox (#14). The h5p-author skill builds there from two files fetched from here: the * command line, and the bundle of the source's content type. * + * Kept until the next library pack release only. missing-elements.github.io serves the same at + * `/h5p/bundles/` since the build page moved there (#17), and the skill fetches from there; copies + * of the skill installed before still fetch from here. Then this plugin goes, with its test and the + * `@missing-elements/h5p-author` dependency. + * * - `.zip`, one per content type in the pack (`h5p-author bundle --all`): `parts/`, the * pack's whole index with the parts a build of that type can read, and `libraries.txt`. * - `parts/`: every part of the pack under its own name, and its index. A bundle holds no addon, From 0830b10e7a3a81c1c6c79f54e1b4b826e8d35c71 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Tue, 10 Nov 2026 03:31:04 +0200 Subject: [PATCH 2/2] fix: say the fragment survives the redirect only with JavaScript, and check the no-script test's errors Without JavaScript the refresh goes to the new address with no fragment; the README said otherwise. The no-script test now asserts no page, console or policy errors, as the other does, and names what it leaves out. --- README.md | 4 ++-- e2e/build-redirect.spec.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index dc5f758..986d21a 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,9 @@ 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` | `/build`, the redirect to the build page's new address, which keeps the link's fragment | +| `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; `/build` sends a visit on to the build page's new address with its fragment, with or without JavaScript; `/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 `` tag at build: GitHub Pages sends no headers, so the tag is diff --git a/e2e/build-redirect.spec.ts b/e2e/build-redirect.spec.ts index 8a0426e..ed7fa42 100644 --- a/e2e/build-redirect.spec.ts +++ b/e2e/build-redirect.spec.ts @@ -37,9 +37,10 @@ test('sends /build on to the build page, keeping the #source= fragment', async ( test.describe('without JavaScript', () => { test.use({ javaScriptEnabled: false }) - test('still sends /build on to the build page', async ({ page }) => { - await watch(page) - await page.goto('/build') + 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([]) }) })