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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The site of the [missing-elements](https://github.com/missing-elements) web comp
| `/` | The project list, the tools (the build page, which links the skill's page), and a footer of links | [src/index.njk](src/index.njk); the lists are in [src/_data/site.js](src/_data/site.js) |
| `/pdfjs-viewer/` | The docs of `<pdfjs-viewer-element>`, with a live preview | [src/pdfjs-viewer/](src/pdfjs-viewer/) |
| `/h5p/build/` | The build page, for people: what it makes, a sample that plays, and the build of a chat's source into a `.h5p`, in the visitor's browser | [src/h5p/build/](src/h5p/build/) |
| `/h5p/skill/` | The skill's files, and a page that lists each at its full address, for a chat | [src/h5p/skill/](src/h5p/skill/), copied by [eleventy.config.js](eleventy.config.js) |
| `/h5p/skill/` | The skill's files, and a page that lists each at its full address, for a chat; and the skill as zips to install: `h5p-author.zip` for Claude, `h5p-author-chatgpt.zip` for ChatGPT's Plugins | [src/h5p/skill/](src/h5p/skill/), copied by [eleventy.config.js](eleventy.config.js); the zips written by [vite.config.ts](vite.config.ts) |
| `/h5p/bundles/` | The skill's build tool and a library bundle per content type, for a chat that runs Node but cannot reach npm | written by [vite.config.ts](vite.config.ts) |
| `/h5p/build/types/` | The skill's type files, which the build page copies for the chat | written by [vite.config.ts](vite.config.ts) |

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@missing-elements/h5p-libraries": "1.1.0",
"@missing-elements/h5p-offline-player": "1.6.0",
"@missing-elements/h5p-runtime": "1.0.1",
"@missing-elements/pdfjs-viewer": "4.5.0"
"@missing-elements/pdfjs-viewer": "4.5.0",
"@zip.js/zip.js": "2.24.0"
}
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

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

38 changes: 37 additions & 1 deletion src/h5p/create-with-ai/index.njk
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ socialImage:
alt: "The build page: its headline, Build an H5P activity with AI, and a course presentation playing beside it."
# The top bar's links to the page's own sections (topbar.njk).
topbarLinks:
- { href: "#install", text: "Install" }
- { href: "#prompts", text: "Prompts" }
- { href: "#types", text: "Types" }
- { href: "#open-source", text: "Open source" }
Expand Down Expand Up @@ -39,7 +40,8 @@ csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; o
H5P. It writes the activity, and <a href="/h5p/build/">the build page</a> makes it a
<code>.h5p</code> file for Moodle or any other H5P site. Both are free and
<a href="#open-source">open source</a>, with no account and nothing to install: you need only a
chat that can open a web page.
chat that can open a web page. In Claude or ChatGPT you can also
<a href="#install">install the skill once</a>.
</p>
<p><a class="button button-solid button-large" href="/h5p/build/">Open the build page</a></p>
</header>
Expand Down Expand Up @@ -111,6 +113,40 @@ csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; o
</p>
</section>

{#- The zips are written by vite.config.ts from the release the site pins, so each holds the skill
the prompt above sends a chat to. Each was uploaded as it is, by the owner, on 2026-11-13. -#}
<section aria-labelledby="install">
<h2 id="install">Or install the skill once</h2>
<p>
Installed, the skill stays in your chat: ask for an activity with no address to paste, such as
"Use the h5p-author skill to make a quiz of five questions on the rivers of Europe".
</p>
<ul>
<li>
<strong>Claude</strong>, on claude.ai or in its apps: download
<a href="/h5p/skill/h5p-author.zip" download>h5p-author.zip</a> and upload it under Customize,
then Skills, with code execution on. Claude builds the <code>.h5p</code> file in the chat.
</li>
<li>
<strong>ChatGPT</strong>: download
<a href="/h5p/skill/h5p-author-chatgpt.zip" download>h5p-author-chatgpt.zip</a> and upload it
under Plugins. ChatGPT cannot install the build tool, so it hands over a link to the build
page. If the page says the link arrived damaged, ask for the link again, or for the source
as a file, and open that on the build page.
</li>
<li>
<strong>Open WebUI</strong>: an administrator imports the skill under Import from URL, from
its folder on GitHub,
<code>https://github.com/missing-elements/h5p-offline-player/tree/main/skills/h5p-author</code>.
</li>
<li>
<strong>An agent with a terminal</strong>, such as Claude Code, Codex or Cursor:
<code>npx skills add missing-elements/h5p-offline-player --skill h5p-author</code>.
</li>
</ul>
<p>The zips hold h5p-author {{ skill.version }}, the release the build page runs.</p>
</section>

<section aria-labelledby="prompts">
<h2 id="prompts">Prompts to start from</h2>
<p>
Expand Down
4 changes: 3 additions & 1 deletion src/h5p/skill/index.njk
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; o
</p>
<p>
Where skills can be installed, install it instead:
<code>npx skills add missing-elements/h5p-offline-player --skill h5p-author</code>.
<code>npx skills add missing-elements/h5p-offline-player --skill h5p-author</code>, or in
Claude's or ChatGPT's app from the zips at
<a href="{{ site.url }}/h5p/create-with-ai/#install">{{ site.url }}/h5p/create-with-ai/#install</a>.
</p>
</header>

Expand Down
90 changes: 89 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildPackage, writeBundles } from '@missing-elements/h5p-author'
import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from '@zip.js/zip.js'
import { build, defineConfig, type Plugin } from 'vite'

/*
Expand Down Expand Up @@ -202,12 +203,99 @@ function bundles(): Plugin {
}
}

/** One date for every entry, as `h5p-author bundle` writes its zips: the same pin writes the same bytes. */
const ZIP_DATE = new Date(2000, 0, 1, 2)

/**
* The h5p-author skill as zips to install, beside the files `/h5p/skill/` serves, from the same
* pinned author package:
*
* - `h5p-author.zip`: the skill's folder at the zip's root, `h5p-author/SKILL.md`, which Claude's
* app takes under Customize, Skills (as does Open WebUI's import).
* - `h5p-author-chatgpt.zip`: the same files as a plugin, under `skills/h5p-author/`, with its
* `plugin.json` at the root, which ChatGPT's Plugins take. A zip with the folder at its root is
* refused there ("Plugin archive must contain … `skills/*\/SKILL.md`"). The manifest is the Agent
* Plugins one, which allows no field but its own ten, with ChatGPT's name for the plugin under
* `extensions`; a name that leads with "H5P" says who made it.
*
* Both were uploaded as they are written here, by the owner on 2026-11-13. ChatGPT's code cannot
* read a plugin's files, so neither carries the build tool: where a chat cannot install it, the
* build page builds.
*/
function skillZips(): Plugin {
const author = new URL('./node_modules/@missing-elements/h5p-author/', import.meta.url)
const guide = 'https://missing-elements.github.io/h5p/create-with-ai/'
let outDir = ''
return {
name: 'missing-elements:skill-zips',
apply: 'build',
configResolved(config) {
outDir = resolve(config.root, config.build.outDir)
},
async writeBundle() {
const { version } = JSON.parse(await readFile(new URL('package.json', author), 'utf8')) as { version: string }
// The files eleventy.config.js copies to /h5p/skill/: SKILL.md, the licence and references/.
const references = readdirSync(new URL('references/', author), { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => join(entry.parentPath, entry.name).slice(fileURLToPath(author).length).split('\\').join('/'))
const files = ['SKILL.md', 'LICENSE', ...references].sort()
const skill = await Promise.all(files.map(async (path) => [path, await readFile(new URL(path, author))] as const))
const manifest = {
$schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
name: 'h5p-author',
version,
description: 'Create H5P content from a prompt, a lesson or supplied material, as a playable .h5p: quizzes, flash cards, presentations, interactive books and more.',
author: { name: 'Missing Elements', url: 'https://github.com/missing-elements' },
homepage: guide,
repository: 'https://github.com/missing-elements/h5p-offline-player',
license: 'MIT',
keywords: ['h5p', 'quiz', 'e-learning', 'authoring'],
extensions: {
'com.openai': {
interface: {
displayName: 'H5P Author by Missing Elements',
shortDescription: 'Make a playable H5P activity from a prompt or your material',
developerName: 'Missing Elements',
websiteURL: guide
}
}
}
}
const folder = join(outDir, 'h5p', 'skill')
await mkdir(folder, { recursive: true })
await writeFile(join(folder, 'h5p-author.zip'), await zipOf('h5p-author/', skill))
await writeFile(join(folder, 'h5p-author-chatgpt.zip'), await zipOf('skills/h5p-author/', skill, [['plugin.json', new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}\n`)]]))
}
}
}

/**
* A zip of the files under `prefix`, after the files at its root, with an entry for each folder
* before what is in it, as `git archive` writes one: the shape of the zips first uploaded.
*/
async function zipOf(prefix: string, files: ReadonlyArray<readonly [string, Uint8Array]>, atRoot: ReadonlyArray<readonly [string, Uint8Array]> = []): Promise<Uint8Array> {
const writer = new ZipWriter(new Uint8ArrayWriter(), { useWebWorkers: false, lastModDate: ZIP_DATE, extendedTimestamp: false })
for (const [path, bytes] of atRoot) await writer.add(path, new Uint8ArrayReader(bytes))
const folders = new Set<string>()
for (const [path, bytes] of files) {
const parts = `${prefix}${path}`.split('/')
for (let depth = 1; depth < parts.length; depth++) {
const name = `${parts.slice(0, depth).join('/')}/`
if (folders.has(name)) continue
folders.add(name)
await writer.add(name, undefined, { directory: true })
}
await writer.add(`${prefix}${path}`, new Uint8ArrayReader(bytes))
}
return writer.close()
}

export default defineConfig({
publicDir: false,
// A missing file is a 404, as on GitHub Pages, not the home page: `pnpm preview` answers as the
// site does, where an SPA's fallback would answer every address with a page.
appType: 'mpa',
plugins: [emptyOwnFolders(), licenceFiles(), typeFiles(), sample(), bundles()],
plugins: [emptyOwnFolders(), licenceFiles(), typeFiles(), sample(), bundles(), skillZips()],
build: {
outDir: '_site',
emptyOutDir: false,
Expand Down
Loading