Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ The page and the bundles were embed-my.github.io's `/build` and `/bundles/` unti

### 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.
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 named below it, each a type's notes, an example and its fields. The source's own type is the one the build takes it to be, h5p-author's `sourceType` (since 0.8.0): its `type` as written or as it is spoken of, `"interactive-book"`, or with none the one under `contentType` or `library`, as a chat with no skill text writes it. Below it, a library counts by its machine name, under any key: a slide's element `{ "type": "text" }` is no H5P.Text.

```
The build page found 2 problems in the source. Fix them all and write the whole source again:
Expand All @@ -86,7 +86,9 @@ How these content types are written, from the h5p-author skill's type files:

`vite.config.ts` writes them to `/h5p/build/types/<Type>.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.
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. And in [#174](https://github.com/missing-elements/h5p-offline-player/issues/174), with no skill text at all, the position of a chat given only a link to the skill: with h5p-author 0.7.1 and the file of a type named as it is spoken of, 10 of 12 sources were right by the second reply and all 12 by the third; with 0.7.0 and the page's rule before, 3 and 8 by the fourth, four never.

The page copies that text byte for byte. The 14 rounds that failed in the second test were built again on this page, and each copied what the model had been given. Of the 39 sources that failed their first build in the first, 36 did; the other three carry the same type files, and their problems read better since: a Game Map (0.7.0), and two books whose chapters had a title beside them, 13 problems now 7 (0.7.1).

## The bundles

Expand Down
40 changes: 39 additions & 1 deletion e2e/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ test('the copy carries the type files of the source’s type and of the librarie
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.
// The build says what to write, as h5p-author does since 0.7.0.
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`)
Expand All @@ -166,6 +166,44 @@ test('the copy carries the type files of the source’s type and of the librarie
expect(problems, problems.join('\n')).toEqual([])
})

test('the copy carries the type file of a type named as it is spoken of, or under contentType, as the build reads it', async ({ page, context, request, baseURL }) => {
const { problems } = watch(page, baseURL)
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
const META = { title: 'Physics', language: 'en', license: 'CC BY', authors: [{ name: 'Ann Author', role: 'Author' }] }
/** The type files a source's copy carries after its problems, once a problem of it is shown. */
const filesOf = async (source: unknown, said: string): Promise<string | undefined> => {
await page.goto('/h5p/build/')
await page.locator('#source').fill(JSON.stringify(source))
await page.locator('#build').click()
await expect(page.locator('#problems')).toContainText(said)
return (await copied(page)).split(`\n\n${TYPES_HEADING}\n\n`)[1]
}
const files = async (...types: string[]) => (await Promise.all(types.map((type) => typeFile(request, type)))).join('\n\n---\n\n')

// As the weakest model wrote one with no skill text (h5p-offline-player#174): the content's
// fields beside `type`, the type as it is spoken of, a question named by its machine name.
const spoken = { type: 'interactive-book', title: 'Physics', chapters: [{ title: 'Newton', content: [{ type: 'H5P.MultiChoice', question: 'Which law?', answers: [{ text: 'F = ma', correct: true }] }] }] }
expect(await filesOf(spoken, 'takes the machine name, "H5P.InteractiveBook"')).toBe(await files('H5P.InteractiveBook', 'H5P.MultiChoice'))
await expect(page.locator('#copy-types')).toHaveText('The copy also carries the skill’s notes on H5P.InteractiveBook and H5P.MultiChoice, with their fields and an example, so the chat does not have to guess.')

// As Gemini in Chrome wrote one from a link to the skill: the type under `contentType`, the
// source's own fields in `metadata`, sub-content naming its library the same way.
const gemini = {
contentType: 'H5P.InteractiveBook',
metadata: { title: 'Physics', license: 'CC BY', authors: META.authors },
params: { chapters: [{ title: 'Newton', content: [{ contentType: 'H5P.AdvancedText', params: { text: '<p>F = ma</p>' } }] }] }
}
expect(await filesOf(gemini, 'the content type is its type: write "type": "H5P.InteractiveBook"')).toBe(await files('H5P.InteractiveBook', 'H5P.AdvancedText'))

// A name the build does not read for the type, under another key, is none; nor below it is a
// text that only reads like one: a slide's element `{ "type": "text" }` is no H5P.Text.
expect(await filesOf({ ...META, contentTypeName: 'interactive book', params: {} }, 'contentTypeName')).toBeUndefined()
await expect(page.locator('#copy-types')).toBeHidden()
const slides = { ...META, type: 'H5P.CoursePresentation', params: { presentation: { slides: [{ elements: [{ type: 'text', x: 5, y: 5, text: 'Newton' }] }] } } }
expect(await filesOf(slides, 'params.presentation.slides[0].elements[0]')).toBe(await files('H5P.CoursePresentation'))
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' }))
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"vite": "8.3.5"
},
"dependencies": {
"@missing-elements/h5p-author": "0.7.0",
"@missing-elements/h5p-author": "0.8.0",
"@missing-elements/h5p-libraries": "1.1.0",
"@missing-elements/h5p-offline-player": "1.6.0",
"@missing-elements/h5p-runtime": "1.0.1",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions src/h5p/build/build-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,17 @@ function fillList(list: HTMLUListElement, items: Problem[]): void {
* copied without them.
*/
async function typeFilesOf(source: unknown): Promise<TypeFile[]> {
const names = typesNamed(source)
if (!names.length) return []
if (!source || typeof source !== 'object') return []
const fetched = async (path: string): Promise<Response> => {
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) => {
// The listed types are what a name is read against, as it is or as it is spoken of.
const names = typesNamed(source, await typeIndex)
return await Promise.all(names.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 }
Expand Down
36 changes: 22 additions & 14 deletions src/h5p/build/build-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* written in, and the text a chat is given back. The browser tests reach it through the page.
*/

import { sourceType } from '@missing-elements/h5p-author/browser'

/** A problem or a note, as the author package reports them: where in the source, and what. */
export interface Problem {
path: string
Expand Down Expand Up @@ -110,24 +112,30 @@ export function matchFiles(paths: string[], chosen: File[]): { files: Record<str
}

/**
* The content types a source is written in, by machine name: its own, then every library it names
* as sub-content, in the order they come, each once. Their type files are what the chat that wrote
* the source lacked.
* The content types a source is written in, of those listed, by machine name: its own as the build
* reads it, then every one named below it as it is, under any key, in the order they come, each
* once. Their type files are what the chat that wrote the source lacked.
*
* The source's own type is h5p-author's reading of it, `sourceType`: its `type` as it is or as it is
* spoken of, "interactive-book", or with none the one under `contentType` or `library`. A chat with
* no skill text writes it so, and a rule of the page's own, `type` as written, gave its first copy
* no file (h5p-offline-player#176). Below it a name counts only as it is, a listed machine name with
* or without a major and minor: what reads like one, a slide's element `{ "type": "text" }`, is no
* H5P.Text.
*/
export function typesNamed(source: unknown): string[] {
export function typesNamed(source: unknown, listed: Iterable<string>): string[] {
const names = new Set(listed)
const found = new Set<string>()
const add = (value: unknown): void => {
const name = typeof value === 'string' ? value.trim().split(/\s+/)[0] : ''
if (name) found.add(name)
}
const own = sourceType(source, names)
if (own) found.add(own)
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 (typeof value === 'string') {
// A name is short: a sentence that happens to hold one is not it.
const name = value.length <= 64 ? /^\s*(\S+)(?:\s+\d+\.\d+)?\s*$/.exec(value)?.[1] : undefined
if (name && names.has(name)) found.add(name)
} else if (Array.isArray(value)) value.forEach(walk)
else if (value && typeof value === 'object') Object.values(value).forEach(walk)
}
if (source && typeof source === 'object' && !Array.isArray(source)) add((source as { type?: unknown }).type)
walk(source)
return [...found]
}
Expand Down
Loading