diff --git a/README.md b/README.md index bbdd283..7de4c3b 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ 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 | | `/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 @@ -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 @@ -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 `` 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. + ## 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 new file mode 100644 index 0000000..182fbbd --- /dev/null +++ b/build.html @@ -0,0 +1,89 @@ + + + + + + + Build an activity · 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. +

+
+ + diff --git a/e2e/build.spec.ts b/e2e/build.spec.ts new file mode 100644 index 0000000..16f4ba6 --- /dev/null +++ b/e2e/build.spec.ts @@ -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 { + 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([]) +}) diff --git a/package.json b/package.json index 4436be2..27276d0 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e044c91..e055e19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,18 +8,21 @@ importers: .: dependencies: + '@missing-elements/h5p-author': + specifier: ^0.5.0 + version: 0.5.0(@missing-elements/h5p-runtime@1.0.1) '@missing-elements/h5p-embed': - specifier: ^1.3.1 - version: 1.3.1 + specifier: ^1.7.0 + version: 1.7.0 '@missing-elements/h5p-libraries': - specifier: ^1.0.0 - version: 1.0.0 + specifier: ^1.1.0 + version: 1.1.0 '@missing-elements/h5p-offline-player': - specifier: ^1.2.0 - version: 1.2.0(@missing-elements/h5p-runtime@1.0.0) + specifier: ^1.5.1 + version: 1.5.1(@missing-elements/h5p-runtime@1.0.1) '@missing-elements/h5p-runtime': - specifier: ^1.0.0 - version: 1.0.0 + specifier: ^1.0.1 + version: 1.0.1 devDependencies: '@playwright/test': specifier: ^1.64.1 @@ -36,26 +39,27 @@ importers: packages: - '@missing-elements/h5p-embed@1.3.1': - resolution: {integrity: sha512-RRMmzYZh8ySvyiUgl6DTFO7faYSo+/BmAa9wuKv80j0tIwNYIB6b9tTI01116ZbU+tyYbe8HHonXdGjMLPts2Q==} + '@missing-elements/h5p-author@0.5.0': + resolution: {integrity: sha512-5QiB0VBk8Hx2gvy5xL+7eOGCTyBB+t6CPWNR075xXjrj4ExBQ94OfbJv+ojkPeyKFb+wkQ/8Obh72cezzFqCHw==} engines: {node: '>=22'} hasBin: true - '@missing-elements/h5p-libraries@1.0.0': - resolution: {integrity: sha512-Ii9naBozRohB2THKckOlutgVd4AYoLT7CXQf9hiavDGGGCYfz84a/4scVOC5Mz98ryetI8tar2EjZ5r9zjUDCQ==} - engines: {node: '>=20'} + '@missing-elements/h5p-embed@1.7.0': + resolution: {integrity: sha512-d6gDJURVPW/mh1WWVwwSe6yqud/S03ynVHRQBbkXd+iG2tXNVkTnbSHh99yTuwWBsft0gQSHjNfjf6H1aYyjlA==} + engines: {node: '>=22'} + hasBin: true - '@missing-elements/h5p-offline-player@1.2.0': - resolution: {integrity: sha512-4RDBPg6ZE3qTqMXEaDwPwxBwZ7PqJYtru9gWCZCtsAStMqgoVSFgiJE215SWJt+CUoxR+d+/9pYnZ43SqiQxng==} + '@missing-elements/h5p-libraries@1.1.0': + resolution: {integrity: sha512-gH4Qpvms3hDKzxSjAHg0UXQqUlkNPravGoMDmJH04XqVuNo41FG3JuxsuTzUV6k9LO3sD72XJ34K0VwThKGcTg==} engines: {node: '>=20'} - peerDependencies: - '@missing-elements/h5p-runtime': ^1.0.0 - peerDependenciesMeta: - '@missing-elements/h5p-runtime': - optional: true - '@missing-elements/h5p-offline-player@1.2.0': - resolution: {integrity: sha512-4RDBPg6ZE3qTqMXEaDwPwxBwZ7PqJYtru9gWCZCtsAStMqgoVSFgiJE215SWJt+CUoxR+d+/9pYnZ43SqiQxng==} + '@missing-elements/h5p-normalize@1.3.0': + resolution: {integrity: sha512-QuJsxpsSp/aG6MIBRGZk9TDyjLG+k7DdNo8pgdiGT2S3mC8C0l8Vj6SzvyIpDx5egiQSI8F+tRkRuFiVgUP0bg==} + engines: {node: '>=22'} + hasBin: true + + '@missing-elements/h5p-offline-player@1.5.1': + resolution: {integrity: sha512-vxIqhwtd4aNb/doLea4WrnGmL0zGLvtjsXBvft7UVNVqNlC/Com1gG0y/KTQ6rrdLdfFv7WgD0YP6UNkfdaYpg==} engines: {node: '>=20'} peerDependencies: '@missing-elements/h5p-runtime': ^1.0.0 @@ -63,8 +67,8 @@ packages: '@missing-elements/h5p-runtime': optional: true - '@missing-elements/h5p-runtime@1.0.0': - resolution: {integrity: sha512-gcM5wBnUQoingA1WrF7tnjlobmh0UIhe0WE7KE1hLMpPkpNww1B94Fi+VSc4gTSpmAoR9upT969eaEd0ffZC8g==} + '@missing-elements/h5p-runtime@1.0.1': + resolution: {integrity: sha512-/cvjh7a9X9DMHEzIOqCMJAngEJxrCOCr33NI+FOM5UGdXR7Er0CYpMqQKGslrS2hVJIlWJ9iI8YG/Bw2/vISlA==} engines: {node: '>=20'} '@oxc-project/types@0.156.0': @@ -485,28 +489,36 @@ packages: snapshots: - '@missing-elements/h5p-embed@1.3.1': + '@missing-elements/h5p-author@0.5.0(@missing-elements/h5p-runtime@1.0.1)': dependencies: - '@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-runtime': 1.0.0 + '@missing-elements/h5p-libraries': 1.1.0 + '@missing-elements/h5p-normalize': 1.3.0 + '@missing-elements/h5p-offline-player': 1.5.1(@missing-elements/h5p-runtime@1.0.1) '@zip.js/zip.js': 2.24.0 + transitivePeerDependencies: + - '@missing-elements/h5p-runtime' - '@missing-elements/h5p-libraries@1.0.0': {} + '@missing-elements/h5p-embed@1.7.0': + dependencies: + '@missing-elements/h5p-libraries': 1.1.0 + '@missing-elements/h5p-normalize': 1.3.0 + '@missing-elements/h5p-offline-player': 1.5.1(@missing-elements/h5p-runtime@1.0.1) + '@missing-elements/h5p-runtime': 1.0.1 + '@zip.js/zip.js': 2.24.0 + + '@missing-elements/h5p-libraries@1.1.0': {} - '@missing-elements/h5p-offline-player@1.2.0(@missing-elements/h5p-runtime@1.0.0)': + '@missing-elements/h5p-normalize@1.3.0': dependencies: '@zip.js/zip.js': 2.24.0 - optionalDependencies: - '@missing-elements/h5p-runtime': 1.0.0 - '@missing-elements/h5p-offline-player@1.2.0(@missing-elements/h5p-runtime@1.0.0)': + '@missing-elements/h5p-offline-player@1.5.1(@missing-elements/h5p-runtime@1.0.1)': dependencies: '@zip.js/zip.js': 2.24.0 optionalDependencies: - '@missing-elements/h5p-runtime': 1.0.0 + '@missing-elements/h5p-runtime': 1.0.1 - '@missing-elements/h5p-runtime@1.0.0': {} + '@missing-elements/h5p-runtime@1.0.1': {} '@oxc-project/types@0.156.0': {} diff --git a/src/build-page.ts b/src/build-page.ts new file mode 100644 index 0000000..e9634a6 --- /dev/null +++ b/src/build-page.ts @@ -0,0 +1,225 @@ +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 new file mode 100644 index 0000000..6f31176 --- /dev/null +++ b/src/build-source.ts @@ -0,0 +1,129 @@ +/** + * 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. */ +export function packageName(title: unknown): string { + const words = String(title ?? '') + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) + .replace(/-+$/, '') + return `${words || '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 new file mode 100644 index 0000000..96288f2 --- /dev/null +++ b/src/build.css @@ -0,0 +1,237 @@ +/* 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/tsconfig.json b/tsconfig.json index c52b472..c25df3a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,8 @@ }, "include": [ "src/h5p-page.ts", + "src/build-page.ts", + "src/build-source.ts", "src/h5p-embed.d.ts", "e2e", "playwright.config.ts", diff --git a/vite.config.ts b/vite.config.ts index 09fcc81..6045fa1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -86,7 +86,7 @@ export default defineConfig({ plugins: [cspMeta(), packageFiles()], build: { rollupOptions: { - input: { h5p: 'h5p.html' } + input: { h5p: 'h5p.html', build: 'build.html' } } } })