Skip to content

Commit 7ca9c62

Browse files
committed
The site offers the h5p-author skill as zips to install in Claude and ChatGPT
vite.config.ts writes two zips into /h5p/skill/ from the pinned author package, the files that folder serves: h5p-author.zip, the skill's folder at its root, which Claude's app takes, and h5p-author-chatgpt.zip, the same files under skills/h5p-author/ with an Agent Plugins plugin.json, which ChatGPT's Plugins take. Every entry has one date, so a pin writes the same bytes each build. @zip.js/zip.js becomes a direct dependency, at the version the lockfile already held. The guide gets "Or install the skill once": Claude, ChatGPT, Open WebUI and agents with a terminal. The skill page and the README point to it. Closes #40.
1 parent 7278ba8 commit 7ca9c62

6 files changed

Lines changed: 135 additions & 5 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ The site of the [missing-elements](https://github.com/missing-elements) web comp
99
| `/` | 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) |
1010
| `/pdfjs-viewer/` | The docs of `<pdfjs-viewer-element>`, with a live preview | [src/pdfjs-viewer/](src/pdfjs-viewer/) |
1111
| `/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/) |
12-
| `/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) |
12+
| `/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) |
1313
| `/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) |
1414
| `/h5p/build/types/` | The skill's type files, which the build page copies for the chat | written by [vite.config.ts](vite.config.ts) |
1515

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@missing-elements/h5p-libraries": "1.1.0",
3333
"@missing-elements/h5p-offline-player": "1.6.0",
3434
"@missing-elements/h5p-runtime": "1.0.1",
35-
"@missing-elements/pdfjs-viewer": "4.5.0"
35+
"@missing-elements/pdfjs-viewer": "4.5.0",
36+
"@zip.js/zip.js": "2.24.0"
3637
}
3738
}

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/h5p/create-with-ai/index.njk‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ socialImage:
1212
alt: "The build page: its headline, Build an H5P activity with AI, and a course presentation playing beside it."
1313
# The top bar's links to the page's own sections (topbar.njk).
1414
topbarLinks:
15+
- { href: "#install", text: "Install" }
1516
- { href: "#prompts", text: "Prompts" }
1617
- { href: "#types", text: "Types" }
1718
- { href: "#open-source", text: "Open source" }
@@ -39,7 +40,8 @@ csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; o
3940
H5P. It writes the activity, and <a href="/h5p/build/">the build page</a> makes it a
4041
<code>.h5p</code> file for Moodle or any other H5P site. Both are free and
4142
<a href="#open-source">open source</a>, with no account and nothing to install: you need only a
42-
chat that can open a web page.
43+
chat that can open a web page. In Claude or ChatGPT you can also
44+
<a href="#install">install the skill once</a>.
4345
</p>
4446
<p><a class="button button-solid button-large" href="/h5p/build/">Open the build page</a></p>
4547
</header>
@@ -111,6 +113,40 @@ csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; o
111113
</p>
112114
</section>
113115

116+
{#- The zips are written by vite.config.ts from the release the site pins, so each holds the skill
117+
the prompt above sends a chat to. Each was uploaded as it is, by the owner, on 2026-11-13. -#}
118+
<section aria-labelledby="install">
119+
<h2 id="install">Or install the skill once</h2>
120+
<p>
121+
Installed, the skill stays in your chat: ask for an activity with no address to paste, such as
122+
"Use the h5p-author skill to make a quiz of five questions on the rivers of Europe".
123+
</p>
124+
<ul>
125+
<li>
126+
<strong>Claude</strong>, on claude.ai or in its apps: download
127+
<a href="/h5p/skill/h5p-author.zip" download>h5p-author.zip</a> and upload it under Customize,
128+
then Skills, with code execution on. Claude builds the <code>.h5p</code> file in the chat.
129+
</li>
130+
<li>
131+
<strong>ChatGPT</strong>: download
132+
<a href="/h5p/skill/h5p-author-chatgpt.zip" download>h5p-author-chatgpt.zip</a> and upload it
133+
under Plugins. ChatGPT cannot install the build tool, so it hands over a link to the build
134+
page. If the page says the link arrived damaged, ask for the link again, or for the source
135+
as a file, and open that on the build page.
136+
</li>
137+
<li>
138+
<strong>Open WebUI</strong>: an administrator imports the skill under Import from URL, from
139+
its folder on GitHub,
140+
<code>https://github.com/missing-elements/h5p-offline-player/tree/main/skills/h5p-author</code>.
141+
</li>
142+
<li>
143+
<strong>An agent with a terminal</strong>, such as Claude Code, Codex or Cursor:
144+
<code>npx skills add missing-elements/h5p-offline-player --skill h5p-author</code>.
145+
</li>
146+
</ul>
147+
<p>The zips hold h5p-author {{ skill.version }}, the release the build page runs.</p>
148+
</section>
149+
114150
<section aria-labelledby="prompts">
115151
<h2 id="prompts">Prompts to start from</h2>
116152
<p>

‎src/h5p/skill/index.njk‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; o
3838
</p>
3939
<p>
4040
Where skills can be installed, install it instead:
41-
<code>npx skills add missing-elements/h5p-offline-player --skill h5p-author</code>.
41+
<code>npx skills add missing-elements/h5p-offline-player --skill h5p-author</code>, or in
42+
Claude's or ChatGPT's app from the zips at
43+
<a href="{{ site.url }}/h5p/create-with-ai/#install">{{ site.url }}/h5p/create-with-ai/#install</a>.
4244
</p>
4345
</header>
4446

‎vite.config.ts‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
33
import { basename, join, resolve } from 'node:path'
44
import { fileURLToPath } from 'node:url'
55
import { buildPackage, writeBundles } from '@missing-elements/h5p-author'
6+
import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from '@zip.js/zip.js'
67
import { build, defineConfig, type Plugin } from 'vite'
78

89
/*
@@ -202,12 +203,99 @@ function bundles(): Plugin {
202203
}
203204
}
204205

206+
/** One date for every entry, as `h5p-author bundle` writes its zips: the same pin writes the same bytes. */
207+
const ZIP_DATE = new Date(2000, 0, 1, 2)
208+
209+
/**
210+
* The h5p-author skill as zips to install, beside the files `/h5p/skill/` serves, from the same
211+
* pinned author package:
212+
*
213+
* - `h5p-author.zip`: the skill's folder at the zip's root, `h5p-author/SKILL.md`, which Claude's
214+
* app takes under Customize, Skills (as does Open WebUI's import).
215+
* - `h5p-author-chatgpt.zip`: the same files as a plugin, under `skills/h5p-author/`, with its
216+
* `plugin.json` at the root, which ChatGPT's Plugins take. A zip with the folder at its root is
217+
* refused there ("Plugin archive must contain … `skills/*\/SKILL.md`"). The manifest is the Agent
218+
* Plugins one, which allows no field but its own ten, with ChatGPT's name for the plugin under
219+
* `extensions`; a name that leads with "H5P" says who made it.
220+
*
221+
* Both were uploaded as they are written here, by the owner on 2026-11-13. ChatGPT's code cannot
222+
* read a plugin's files, so neither carries the build tool: where a chat cannot install it, the
223+
* build page builds.
224+
*/
225+
function skillZips(): Plugin {
226+
const author = new URL('./node_modules/@missing-elements/h5p-author/', import.meta.url)
227+
const guide = 'https://missing-elements.github.io/h5p/create-with-ai/'
228+
let outDir = ''
229+
return {
230+
name: 'missing-elements:skill-zips',
231+
apply: 'build',
232+
configResolved(config) {
233+
outDir = resolve(config.root, config.build.outDir)
234+
},
235+
async writeBundle() {
236+
const { version } = JSON.parse(await readFile(new URL('package.json', author), 'utf8')) as { version: string }
237+
// The files eleventy.config.js copies to /h5p/skill/: SKILL.md, the licence and references/.
238+
const references = readdirSync(new URL('references/', author), { recursive: true, withFileTypes: true })
239+
.filter((entry) => entry.isFile())
240+
.map((entry) => join(entry.parentPath, entry.name).slice(fileURLToPath(author).length).split('\\').join('/'))
241+
const files = ['SKILL.md', 'LICENSE', ...references].sort()
242+
const skill = await Promise.all(files.map(async (path) => [path, await readFile(new URL(path, author))] as const))
243+
const manifest = {
244+
$schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
245+
name: 'h5p-author',
246+
version,
247+
description: 'Create H5P content from a prompt, a lesson or supplied material, as a playable .h5p: quizzes, flash cards, presentations, interactive books and more.',
248+
author: { name: 'Missing Elements', url: 'https://github.com/missing-elements' },
249+
homepage: guide,
250+
repository: 'https://github.com/missing-elements/h5p-offline-player',
251+
license: 'MIT',
252+
keywords: ['h5p', 'quiz', 'e-learning', 'authoring'],
253+
extensions: {
254+
'com.openai': {
255+
interface: {
256+
displayName: 'H5P Author by Missing Elements',
257+
shortDescription: 'Make a playable H5P activity from a prompt or your material',
258+
developerName: 'Missing Elements',
259+
websiteURL: guide
260+
}
261+
}
262+
}
263+
}
264+
const folder = join(outDir, 'h5p', 'skill')
265+
await mkdir(folder, { recursive: true })
266+
await writeFile(join(folder, 'h5p-author.zip'), await zipOf('h5p-author/', skill))
267+
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`)]]))
268+
}
269+
}
270+
}
271+
272+
/**
273+
* A zip of the files under `prefix`, after the files at its root, with an entry for each folder
274+
* before what is in it, as `git archive` writes one: the shape of the zips first uploaded.
275+
*/
276+
async function zipOf(prefix: string, files: ReadonlyArray<readonly [string, Uint8Array]>, atRoot: ReadonlyArray<readonly [string, Uint8Array]> = []): Promise<Uint8Array> {
277+
const writer = new ZipWriter(new Uint8ArrayWriter(), { useWebWorkers: false, lastModDate: ZIP_DATE, extendedTimestamp: false })
278+
for (const [path, bytes] of atRoot) await writer.add(path, new Uint8ArrayReader(bytes))
279+
const folders = new Set<string>()
280+
for (const [path, bytes] of files) {
281+
const parts = `${prefix}${path}`.split('/')
282+
for (let depth = 1; depth < parts.length; depth++) {
283+
const name = `${parts.slice(0, depth).join('/')}/`
284+
if (folders.has(name)) continue
285+
folders.add(name)
286+
await writer.add(name, undefined, { directory: true })
287+
}
288+
await writer.add(`${prefix}${path}`, new Uint8ArrayReader(bytes))
289+
}
290+
return writer.close()
291+
}
292+
205293
export default defineConfig({
206294
publicDir: false,
207295
// A missing file is a 404, as on GitHub Pages, not the home page: `pnpm preview` answers as the
208296
// site does, where an SPA's fallback would answer every address with a page.
209297
appType: 'mpa',
210-
plugins: [emptyOwnFolders(), licenceFiles(), typeFiles(), sample(), bundles()],
298+
plugins: [emptyOwnFolders(), licenceFiles(), typeFiles(), sample(), bundles(), skillZips()],
211299
build: {
212300
outDir: '_site',
213301
emptyOutDir: false,

0 commit comments

Comments
 (0)