From b9507d7834cd5112a975a2b70561debda752955c Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Tue, 10 Nov 2026 15:33:36 +0200 Subject: [PATCH] Copy the skill's type files with a source's problems A chat in a browser has the h5p-author skill's text and none of its files, so it guesses how a content type is written. The build page's copy now carries, after the problems, the type files of the source's own content type and of the libraries it names, in the wording measured on the weakest model (h5p-offline-player#172): 42 of 46 sources right by the second reply, 29 without. - vite.config.ts writes /h5p/build/types/ from the pinned h5p-author, with an index of them. - The page fetches them when it lists the problems, so the copy is written within the press; where they cannot be fetched, the problems are copied alone. - h5p-author 0.6.0 to 0.7.0, whose problems say what to write, and the player to 1.6.0, the version it names. Closes #11. --- README.md | 28 ++++++- e2e/build.spec.ts | 143 +++++++++++++++++++++++++++++++--- package.json | 4 +- pnpm-lock.yaml | 22 +++--- src/h5p/build/build-page.ts | 74 ++++++++++++++++-- src/h5p/build/build-source.ts | 52 +++++++++++-- src/h5p/build/index.njk | 1 + vite.config.ts | 38 ++++++++- 8 files changed, 319 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index ec30dcd..1bb13bb 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Docs and tools for the [missing-elements](https://github.com/missing-elements) w | `/` | [src/index.njk](src/index.njk), project list in [src/_data/site.js](src/_data/site.js) | | `/pdfjs-viewer/` | [src/pdfjs-viewer/index.md](src/pdfjs-viewer/index.md) | | `/h5p/build/` | The H5P build page, [src/h5p/build/](src/h5p/build/): see [The H5P build page](#the-h5p-build-page) | +| `/h5p/build/types/` | The h5p-author skill's type files, which the build page copies for the chat, written by [vite.config.ts](vite.config.ts): see [The type files](#the-type-files) | | `/h5p/bundles/` | The build tool and a library bundle per content type, written by [vite.config.ts](vite.config.ts): see [The bundles](#the-bundles) | Built with [Eleventy](https://www.11ty.dev/). The pages ship no framework: the only client-side JavaScript is the theme toggle, code-block buttons, the live preview and the build page, which [Vite](https://vite.dev/) bundles with the H5P player. @@ -51,7 +52,7 @@ Add `try` after the language of a code fence to give it a **▶ Try** button tha ## The H5P build page -`/h5p/build/` is for an AI chat that wrote a source with the [h5p-author skill](https://github.com/missing-elements/h5p-offline-player/tree/main/skills/h5p-author) but cannot build it. ChatGPT's sandbox reaches npm only through OpenAI's own mirror, which was reported refusing every install from July 2026, so `npx @missing-elements/h5p-author` does not run there, and the skill hands over the source and this page's address instead. The page builds with `@missing-elements/h5p-author/browser`, the same build as the command's, and lists every problem at once by its path, ready to copy back into the chat. Its libraries are `@missing-elements/h5p-libraries/install`'s pack, one part per library, so a build fetches only the parts its package needs and carries the editor libraries an H5P site's upload check asks for. The package then plays in the player on the page, and downloads from it. +`/h5p/build/` is for an AI chat that wrote a source with the [h5p-author skill](https://github.com/missing-elements/h5p-offline-player/tree/main/skills/h5p-author) but cannot build it. ChatGPT's sandbox reaches npm only through OpenAI's own mirror, which was reported refusing every install from July 2026, so `npx @missing-elements/h5p-author` does not run there, and the skill hands over the source and this page's address instead. The page builds with `@missing-elements/h5p-author/browser`, the same build as the command's, and lists every problem at once by its path, ready to copy back into the chat with [the type files](#the-type-files) that chat lacked. Its libraries are `@missing-elements/h5p-libraries/install`'s pack, one part per library, so a build fetches only the parts its package needs and carries the editor libraries an H5P site's upload check asks for. The package then plays in the player on the page, and downloads from it. Nothing leaves the browser. A link can carry the source in its fragment, which no server sees: `/h5p/build/#source=`, the bytes being the source's JSON as UTF-8, or that JSON compressed with raw deflate (Node's `zlib.deflateRawSync`, Python's `zlib.compressobj(wbits=-15)`) to keep the link short. A linked source is built at once, unless it names files of its own, which only the person can choose. A source that names a picture by web address gets it only where that host allows CORS. @@ -59,15 +60,34 @@ Nothing leaves the browser. A link can carry the source in its fragment, which n | --- | --- | | `src/h5p/build/index.njk` | The page's markup, in the site's layout, and its Content-Security-Policy: GitHub Pages sends no headers, so a `` tag is the only policy it gets | | `src/h5p/build/build-page.ts` | The page's wiring: the build, the player, the download, a link's source | -| `src/h5p/build/build-source.ts` | What it does with a source with no DOM: the link's fragment, the files a source names, the text for the chat | +| `src/h5p/build/build-source.ts` | What it does with a source with no DOM: the link's fragment, the files a source names, the content types it is written in, the text for the chat | | `src/assets/site.css` | Its styles, under *H5P build page* | -| `vite.config.ts` | The page's script, `/h5p/build/build-page.js`, with the player, its runtime, the pack and their licence files in `/h5p/assets/` | -| `e2e/`, `playwright.config.ts` | The browser tests: a pasted source, one from a link and one with a file of its own build, play and download; a source's problems are listed for the chat; `/h5p/bundles/` serves every bundle and part, and its tool and a bundle build a quiz in an empty folder | +| `vite.config.ts` | The page's script, `/h5p/build/build-page.js`, with the player, its runtime, the pack and their licence files in `/h5p/assets/`, and the type files in `/h5p/build/types/` | +| `e2e/`, `playwright.config.ts` | The browser tests: a pasted source, one from a link and one with a file of its own build, play and download; a source's problems are listed, and copied for the chat with the type files; `/h5p/bundles/` serves every bundle and part, and its tool and a bundle build a quiz in an empty folder | The player's Service Worker is in `/h5p/assets/` too, so its scope is `/h5p/assets/h5p/`: no other page of the site is under it. The address names who runs the page, as H5P Group's trademark policy asks of a name with "H5P" in it, and the page uses the word descriptively. The page and the bundles were embed-my.github.io's `/build` and `/bundles/` until 2026-11-10. Its `/build` becomes a redirect here that keeps the fragment, so the links chats wrote before still build: [embed-my/embed-my.github.io#18](https://github.com/embed-my/embed-my.github.io/pull/18), a step of its own, deployed once this page is live. +### The type files + +A chat in a browser, such as Gemini in Chrome, has the skill's text and none of its files, and can run nothing. It guesses how a content type is written, and each round of problems tells it the names of one level. So **Copy the problems for the chat** copies more than the list: after the problems come the skill's type files of the source's own content type and of the libraries it names, each a type's notes, an example and its fields. + +``` +The build page found 2 problems in the source. Fix them all and write the whole source again: +- params.chapters[0]: is the params of sub-content, without the sub-content around them: write params.chapters[0] as { "library": "H5P.Column", "params": { "content": … } } +- params.chapters[0].metadata.title: is required: the chapter's name in the table of contents + +How these content types are written, from the h5p-author skill's type files: + +# Interactive Book (H5P.InteractiveBook) +… +``` + +`vite.config.ts` writes them to `/h5p/build/types/.md` from the pinned `@missing-elements/h5p-author`, which carries them since 0.7.0, with `index.json`, the author's version and the types there is a file for. They are that release's, so they describe the fields of the build the page runs, and a Dependabot update of the package moves both. The page fetches them when it lists a source's problems, so the copy is written within the button's press; where they cannot be fetched, the problems are copied alone. + +Measured in [h5p-offline-player#172](https://github.com/missing-elements/h5p-offline-player/issues/172) on the weakest Claude model, Haiku 5.5, in that position: with the files beside the first problems, 42 of 46 sources of 23 content types were right by its second reply, and 29 without them. The page copies that text byte for byte: the 39 sources that failed their first build in the test's runs were built again on this page, and all but one copied what the model had been given, the one a Game Map whose problems 0.7.0 says better. + ## The bundles `/h5p/bundles/` is for a chat that runs Node and can fetch a file but reaches no npm registry, as ChatGPT's sandbox can: its download tool takes a file the browsing tool has opened. There the h5p-author skill fetches two files, the build tool and the bundle of the source's content type, unzips the bundle and builds with `node h5p-author.mjs build source.json course.h5p --libraries parts`. The chat then sees every problem itself, fixes its source and hands over the package; the build page stays for a chat that can fetch nothing. diff --git a/e2e/build.spec.ts b/e2e/build.spec.ts index 188c68f..ee91fdc 100644 --- a/e2e/build.spec.ts +++ b/e2e/build.spec.ts @@ -1,12 +1,13 @@ +import { readdir, readFile } from 'node:fs/promises' import { deflateRawSync } from 'node:zlib' -import { expect, test, type Page } from '@playwright/test' +import { expect, test, type APIRequestContext, type Page } from '@playwright/test' /* * The build page, /h5p/build/, in a real browser against the built site: a source pasted, opened * from a link or naming a file of its own becomes a package that plays on the page and downloads - * from it, and a source with problems gets them listed, all at once, for the chat that wrote it. - * Every library comes from this origin, and any Content-Security-Policy violation or page error - * fails the test. + * from it, and a source with problems gets them listed, all at once, for the chat that wrote it, + * and copied with the h5p-author skill's type files, which that chat lacked. Every library comes + * from this origin, and any Content-Security-Policy violation or page error fails the test. */ const RIVERS = { @@ -37,6 +38,19 @@ function watch(page: Page, baseURL: string | undefined): { problems: string[], e return { problems, elsewhere } } +/** What the copy says before the type files, and a type file as this site serves it, as the copy carries it. */ +const TYPES_HEADING = "How these content types are written, from the h5p-author skill's type files:" +const typeFile = async (request: APIRequestContext, type: string): Promise => { + const response = await request.get(`/h5p/build/types/${type}.md`) + expect(response.ok(), type).toBe(true) + return (await response.text()).trim() +} +const copied = async (page: Page): Promise => { + await page.locator('#copy-problems').click() + await expect(page.locator('#copy-result')).toHaveText('Copied.') + return page.evaluate(() => navigator.clipboard.readText()) +} + const playerState = (page: Page) => page.locator('h5p-player').evaluate((element: HTMLElement & { state?: string }) => element.state) async function expectPlays(page: Page): Promise { @@ -77,7 +91,7 @@ test('a pasted source builds, plays and downloads', async ({ page, baseURL }) => expect(problems, problems.join('\n')).toEqual([]) }) -test('a source with problems gets them all listed, ready for the chat', async ({ page, context, baseURL }) => { +test('a source with problems gets them all listed, ready for the chat', async ({ page, context, request, baseURL }) => { const { problems } = watch(page, baseURL) await context.grantPermissions(['clipboard-read', 'clipboard-write']) await page.goto('/h5p/build/') @@ -91,20 +105,127 @@ test('a source with problems gets them all listed, ready for the chat', async ({ 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: ') + // The copy is the problems, then how the source's content type is written: its type file, whole. + await expect(page.locator('#copy-types')).toHaveText('The copy also carries the skill’s notes on H5P.Blanks, with its fields and an example, so the chat does not have to guess.') + const text = await copied(page) + expect(text).toMatch(/^The build page found \d+ problems in the source\. Fix them all and write the whole source again:\n- /) + expect(text).toContain('- colour: ') + const [list, files] = text.split(`\n\n${TYPES_HEADING}\n\n`) + expect(list.split('\n').slice(1).every((line) => line.startsWith('- ')), list).toBe(true) + expect(files).toBe(await typeFile(request, 'H5P.Blanks')) - // Text that is not JSON is a problem of its own, said where it is. + // Text that is not JSON is a problem of its own, said where it is, and names no content type. 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') + await expect(page.locator('#copy-types')).toBeHidden() + expect(await copied(page)).toMatch(/^The build page found 1 problem in the source\. Fix them all and write the whole source again:\n- source: is not valid JSON[^\n]*$/) + expect(problems, problems.join('\n')).toEqual([]) +}) + +test('the copy carries the type files of the source’s type and of the libraries it names', async ({ page, context, request, baseURL }) => { + const { problems } = watch(page, baseURL) + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + // What a chat with the skill's text and no type file wrote (h5p-offline-player#172): a chapter + // without the H5P.Column around it. H5P.Table has no type file, and a library named twice has one. + const text = (words: string) => ({ library: 'H5P.AdvancedText', params: { text: words } }) + const book = { + type: 'H5P.InteractiveBook', title: 'Rivers', language: 'en', license: 'CC BY', authors: [{ name: 'Ann Author', role: 'Author' }], + params: { + chapters: [{ + content: [ + text('The Danube flows into the Black Sea.'), + { library: 'H5P.MultiChoice 1.16', params: { question: 'Where does the Danube end?', answers: [{ text: 'The Black Sea', correct: true }, { text: 'The North Sea', correct: false }] } }, + { library: 'H5P.Table', params: { text: '
Danube
' } }, + text('The Rhine flows into the North Sea.') + ] + }] + } + } + await page.goto('/h5p/build/') + await page.locator('#source').fill(JSON.stringify(book)) + await page.locator('#build').click() + await expect(page.locator('#status')).toHaveAttribute('data-state', 'error') + // The build says what to write, as h5p-author 0.7.0 does. + await expect(page.locator('#problems li').first()).toContainText('write params.chapters[0] as { "library": "H5P.Column", "params": { "content": … } }') + await expect(page.locator('#copy-types')).toHaveText('The copy also carries the skill’s notes on H5P.InteractiveBook, H5P.AdvancedText and H5P.MultiChoice, with their fields and an example, so the chat does not have to guess.') + const [list, files] = (await copied(page)).split(`\n\n${TYPES_HEADING}\n\n`) + expect(list).toContain('- params.chapters[0]: is the params of sub-content') + // The source's own type first, then the libraries in the order it names them. + expect(files).toBe([await typeFile(request, 'H5P.InteractiveBook'), await typeFile(request, 'H5P.AdvancedText'), await typeFile(request, 'H5P.MultiChoice')].join('\n\n---\n\n')) + + // A type the pack does not have has no file: its problems are copied alone. + await page.locator('#source').fill(JSON.stringify({ ...book, type: 'H5P.ImageChoice', params: {} })) + await page.locator('#build').click() + await expect(page.locator('#problems li')).not.toHaveCount(0) + const alone = await copied(page) + expect(alone).toMatch(/^The build page found \d+ problems? in the source\./) + expect(alone).not.toContain(TYPES_HEADING) + await expect(page.locator('#copy-types')).toBeHidden() expect(problems, problems.join('\n')).toEqual([]) }) +test('the problems are copied alone where the type files cannot be fetched, and with them once they can', async ({ page, context, request }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await page.route('**/h5p/build/types/**', (route) => route.fulfill({ status: 503, body: 'unavailable' })) + await page.goto('/h5p/build/') + const { title: _title, ...untitled } = RIVERS + await page.locator('#source').fill(JSON.stringify(untitled)) + await page.locator('#build').click() + await expect(page.locator('#problems li')).toHaveCount(1) + expect(await copied(page)).toMatch(/^The build page found 1 problem in the source\. Fix them all and write the whole source again:\n- title: [^\n]*$/) + await expect(page.locator('#copy-types')).toBeHidden() + + await page.unroute('**/h5p/build/types/**') + await page.locator('#build').click() + await expect(page.locator('#copy-types')).toContainText('H5P.Blanks') + expect((await copied(page)).endsWith(`\n\n${TYPES_HEADING}\n\n${await typeFile(request, 'H5P.Blanks')}`)).toBe(true) +}) + +test('type files that arrive once newer problems are shown are not copied with those', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + let release = (): void => {} + const held = new Promise((resolve) => { release = resolve }) + await page.route('**/h5p/build/types/H5P.Blanks.md', async (route) => { + await held + await route.continue() + }) + await page.goto('/h5p/build/') + const { title: _title, ...untitled } = RIVERS + await page.locator('#source').fill(JSON.stringify(untitled)) + await page.locator('#build').click() + await expect(page.locator('#problems li code')).toHaveText('title') + // Its type file is on its way when the next source's problems replace these: text that names no type. + await page.locator('#source').fill('{ "type": ') + await page.locator('#build').click() + await expect(page.locator('#problems li')).toContainText('is not valid JSON') + const arrived = page.waitForResponse('**/h5p/build/types/H5P.Blanks.md') + release() + await (await arrived).finished() + // Long enough for the page to have taken the file, had it not dropped it. + await page.waitForTimeout(300) + await expect(page.locator('#copy-types')).toBeHidden() + expect(await copied(page)).not.toContain(TYPES_HEADING) +}) + +test('the type files are served as the pinned author package carries them', async ({ request }) => { + const folder = new URL('../node_modules/@missing-elements/h5p-author/references/types/', import.meta.url) + const names = (await readdir(folder)).filter((name) => name.endsWith('.md')).sort() + const listing = await (await request.get('/h5p/build/types/index.json')).json() as { author: string, types: string[] } + const bundles = await (await request.get('/h5p/bundles/index.json')).json() as { author: string } + // The same release as the build's, so the files describe the fields this page's build takes. + expect(listing.author).toBe(bundles.author) + expect(listing.types).toEqual(names.map((name) => name.slice(0, -'.md'.length))) + // Every content type the skill lists, and the parts other content holds. + expect(listing.types.length).toBeGreaterThanOrEqual(32) + for (const type of listing.types) { + const response = await request.get(`/h5p/build/types/${type}.md`) + expect(response.ok(), type).toBe(true) + expect(await response.text(), type).toBe(await readFile(new URL(`${type}.md`, folder), 'utf8')) + } +}) + 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') diff --git a/package.json b/package.json index 71d7c1c..0f367ba 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,9 @@ "vite": "8.3.5" }, "dependencies": { - "@missing-elements/h5p-author": "0.6.0", + "@missing-elements/h5p-author": "0.7.0", "@missing-elements/h5p-libraries": "1.1.0", - "@missing-elements/h5p-offline-player": "1.5.1", + "@missing-elements/h5p-offline-player": "1.6.0", "@missing-elements/h5p-runtime": "1.0.1", "@missing-elements/pdfjs-viewer": "4.5.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72bab58..9fb2994 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,14 +9,14 @@ importers: .: dependencies: '@missing-elements/h5p-author': - specifier: 0.6.0 - version: 0.6.0(@missing-elements/h5p-runtime@1.0.1) + specifier: 0.7.0 + version: 0.7.0(@missing-elements/h5p-runtime@1.0.1) '@missing-elements/h5p-libraries': specifier: 1.1.0 version: 1.1.0 '@missing-elements/h5p-offline-player': - specifier: 1.5.1 - version: 1.5.1(@missing-elements/h5p-runtime@1.0.1) + specifier: 1.6.0 + version: 1.6.0(@missing-elements/h5p-runtime@1.0.1) '@missing-elements/h5p-runtime': specifier: 1.0.1 version: 1.0.1 @@ -101,8 +101,8 @@ packages: resolution: {integrity: sha512-oI7m8pa7/IAU/3lqRU9vjBbs20iKFo7x+1K9kT3aVira6scc1X9MjBdgLCHzLJeJ7iB6wydioA+kr9/qPnvmlQ==} engines: {node: '>=18'} - '@missing-elements/h5p-author@0.6.0': - resolution: {integrity: sha512-sJRIGjuOs8Aj9RtaivBz39rqRJLVC4L6vqBffGRGV+V8Zj4ESrLjLnlE1hlLnY8AfccPOZCOzrnO5EG6a4CBiQ==} + '@missing-elements/h5p-author@0.7.0': + resolution: {integrity: sha512-jjp70nsWCYoajMk+aksd2WXt0LHOGlTclyEzlownETpMssb2Y9r7LCSJsx/36LVVJuEc8ZltNMWHPK2XC58U/g==} engines: {node: '>=22'} hasBin: true @@ -115,8 +115,8 @@ packages: engines: {node: '>=22'} hasBin: true - '@missing-elements/h5p-offline-player@1.5.1': - resolution: {integrity: sha512-vxIqhwtd4aNb/doLea4WrnGmL0zGLvtjsXBvft7UVNVqNlC/Com1gG0y/KTQ6rrdLdfFv7WgD0YP6UNkfdaYpg==} + '@missing-elements/h5p-offline-player@1.6.0': + resolution: {integrity: sha512-Kn5jZBLZSYIiRNbiEVMdFzyatLTy2PPZyzwEBZ9iA8YvMqohRAfC+IKbDkwdbl+Y3pIYFtCnnIbDZy1aTRJgmQ==} engines: {node: '>=20'} peerDependencies: '@missing-elements/h5p-runtime': ^1.0.0 @@ -1118,11 +1118,11 @@ snapshots: minimatch: 3.1.5 slash: 3.0.0 - '@missing-elements/h5p-author@0.6.0(@missing-elements/h5p-runtime@1.0.1)': + '@missing-elements/h5p-author@0.7.0(@missing-elements/h5p-runtime@1.0.1)': 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-offline-player': 1.6.0(@missing-elements/h5p-runtime@1.0.1) '@zip.js/zip.js': 2.24.0 transitivePeerDependencies: - '@missing-elements/h5p-runtime' @@ -1133,7 +1133,7 @@ snapshots: dependencies: '@zip.js/zip.js': 2.24.0 - '@missing-elements/h5p-offline-player@1.5.1(@missing-elements/h5p-runtime@1.0.1)': + '@missing-elements/h5p-offline-player@1.6.0(@missing-elements/h5p-runtime@1.0.1)': dependencies: '@zip.js/zip.js': 2.24.0 optionalDependencies: diff --git a/src/h5p/build/build-page.ts b/src/h5p/build/build-page.ts index 1bb57a5..fbc2952 100644 --- a/src/h5p/build/build-page.ts +++ b/src/h5p/build/build-page.ts @@ -2,7 +2,7 @@ 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 { forTheChat, matchFiles, namedFiles, packageName, parseSource, sized, sourceFromFragment, typesNamed, type Problem, type TypeFile } from './build-source' /** * The build page, `/h5p/build/`: a source an AI chat wrote with the h5p-author skill becomes a @@ -12,8 +12,10 @@ import { forTheChat, matchFiles, namedFiles, packageName, parseSource, sized, so * `@missing-elements/h5p-libraries/install`'s pack, which Vite emits into `/h5p/assets/` 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. The page's markup is - * `index.njk` beside this file, and its styles are the site's own, in `site.css`. + * source carries it in its fragment, `#source=`, which no server sees. A source with problems gets + * them listed, and copied for the chat with the skill's type files, which this site serves beside + * the page (`TYPES`). The page's markup is `index.njk` beside this file, and its styles are the + * site's own, in `site.css`. */ type Opened = Awaited> @@ -37,6 +39,7 @@ const problemsBox = $('problems-box') const problemsList = $('problems') const copyProblems = $('copy-problems') const copyResult = $('copy-result') +const copyTypes = $('copy-types') const notesBox = $('notes-box') const notesList = $('notes') const playStep = $('play-step') @@ -51,6 +54,16 @@ let opened: Promise | null = null const chosen = new Map() /** The problems last shown, for the copy button. */ let shownProblems: Problem[] = [] +/** Where the site serves the h5p-author skill's type files, and the list of them (vite.config.ts). */ +const TYPES = '/h5p/build/types/' +/** The type files there are, asked for once, and each one's text as it was fetched. */ +let typeIndex: Promise> | null = null +const typeTexts = new Map>() +/** The type files of the problems shown, once they are here, and the fetch that brings them. */ +let shownTypeFiles: TypeFile[] = [] +let typeFilesArriving: Promise | null = null +/** Counts the lists of problems shown, so that type files fetched for an earlier one are dropped. */ +let shows = 0 /** 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. */ @@ -71,11 +84,58 @@ function fillList(list: HTMLUListElement, items: Problem[]): void { })) } -function showProblems(problems: Problem[]): void { +/** + * The type files of the content types a source is written in, of those the skill has a file for: + * what the chat that wrote it lacked. None where they cannot be fetched, and the problems are then + * copied without them. + */ +async function typeFilesOf(source: unknown): Promise { + const names = typesNamed(source) + if (!names.length) return [] + const fetched = async (path: string): Promise => { + const response = await fetch(`${TYPES}${path}`) + if (!response.ok) throw new Error(`${path}: ${response.status}`) + return response + } + try { + typeIndex ??= fetched('index.json').then(async (response) => new Set((await response.json() as { types: string[] }).types)) + const known = await typeIndex + return await Promise.all(names.filter((name) => known.has(name)).map(async (type) => { + let text = typeTexts.get(type) + if (!text) typeTexts.set(type, text = fetched(`${type}.md`).then((response) => response.text())) + return { type, text: await text } + })) + } catch { + // Asked for again with the next problems. + typeIndex = null + typeTexts.clear() + return [] + } +} + +/** + * Lists the problems. With the source they are of, its type files are fetched at once, so that the + * copy button has them when it is pressed: a browser lets a page write to the clipboard only + * within the press. + */ +function showProblems(problems: Problem[], source?: unknown): void { + const show = ++shows shownProblems = problems + shownTypeFiles = [] fillList(problemsList, problems) problemsBox.hidden = problems.length === 0 copyResult.textContent = '' + copyTypes.hidden = true + typeFilesArriving = problems.length && source !== undefined + ? typeFilesOf(source).then((files) => { + if (show !== shows) return + shownTypeFiles = files + typeFilesArriving = null + const names = new Intl.ListFormat('en-GB', { type: 'conjunction' }).format(files.map(({ type }) => type)) + copyTypes.textContent = `The copy also carries the skill’s notes on ${names}, with ${files.length === 1 ? 'its' : 'their'} fields and an example, so the chat does not have to guess.` + copyTypes.hidden = files.length === 0 + }) + : null } function showNotes(notes: Problem[]): void { @@ -154,7 +214,7 @@ async function build(): Promise { 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) + showProblems(problems, parsed.source) } 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. @@ -198,7 +258,9 @@ buildButton.addEventListener('click', () => void build()) copyProblems.addEventListener('click', async () => { try { - await navigator.clipboard.writeText(forTheChat(shownProblems)) + // Only where the button is pressed before the type files are here, which takes a moment. + while (typeFilesArriving) await typeFilesArriving + await navigator.clipboard.writeText(forTheChat(shownProblems, shownTypeFiles)) copyResult.textContent = 'Copied.' } catch { copyResult.textContent = 'The browser did not allow copying; select the list and copy it.' diff --git a/src/h5p/build/build-source.ts b/src/h5p/build/build-source.ts index 7dbe51c..d41f003 100644 --- a/src/h5p/build/build-source.ts +++ b/src/h5p/build/build-source.ts @@ -1,7 +1,7 @@ /** * 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. + * with no DOM: the source a link carries, the files a source names, the content types it is + * written in, and the text 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. */ @@ -10,6 +10,12 @@ export interface Problem { message: string } +/** A content type's file of the h5p-author skill: its notes, an example and its fields. */ +export interface TypeFile { + type: string + text: 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 @@ -103,13 +109,47 @@ export function matchFiles(paths: string[], chosen: File[]): { files: Record() + const add = (value: unknown): void => { + const name = typeof value === 'string' ? value.trim().split(/\s+/)[0] : '' + if (name) found.add(name) + } + const walk = (value: unknown): void => { + if (Array.isArray(value)) value.forEach(walk) + else if (value && typeof value === 'object') { + add((value as { library?: unknown }).library) + Object.values(value).forEach(walk) + } + } + if (source && typeof source === 'object' && !Array.isArray(source)) add((source as { type?: unknown }).type) + walk(source) + return [...found] +} + +/** + * The problems, as the text a person pastes back into the chat that wrote the source, and after + * them the type files of the content types the source is written in. A chat in a browser has the + * skill's text and none of its files, so it guesses a type's fields, and a problem tells it one + * level's names a round. With the files beside the first problems, the weakest model tried wrote + * 42 of 46 sources right by its second reply, and 29 without them (h5p-offline-player#172). The + * wording is the one measured. + */ +export function forTheChat(problems: Problem[], typeFiles: TypeFile[] = []): string { const count = `${problems.length} problem${problems.length === 1 ? '' : 's'}` - return [ + const lines = [ `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') + ] + if (typeFiles.length) { + lines.push('', "How these content types are written, from the h5p-author skill's type files:", '', typeFiles.map(({ text }) => text.trim()).join('\n\n---\n\n')) + } + return lines.join('\n') } /** diff --git a/src/h5p/build/index.njk b/src/h5p/build/index.njk index 2e1d954..0b977c7 100644 --- a/src/h5p/build/index.njk +++ b/src/h5p/build/index.njk @@ -62,6 +62,7 @@ csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' da

Paste them into the chat that wrote the source: it fixes them all and writes the source again.

+