Repository navigation
Expand file tree
/
Copy pathvite.config.ts
More file actions
382 lines (369 loc) · 19.5 KB
/
Copy pathvite.config.ts
File metadata and controls
382 lines (369 loc) · 19.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
import { execFileSync } from 'node:child_process'
import { readdirSync, readFileSync } from 'node:fs'
import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { gzipSync } from 'node:zlib'
import { buildPackage, writeBundles } from '@missing-elements/h5p-author'
import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from '@zip.js/zip.js'
import { build, defineConfig, type Plugin } from 'vite'
/*
* The site's H5P part, `/h5p/`, which Vite builds into `_site/` once Eleventy has written the pages
* there (`pnpm build`):
*
* - `/h5p/build/build-page.js`, the build page's script, which `src/h5p/build/index.njk` loads. The
* player, its runtime and the library pack name their files relative to their own modules, and
* Vite emits them into `/h5p/assets/` with everything else. The player's Service Worker lands
* there too, so its scope is `/h5p/assets/h5p/`: no other page of the site is under it.
* - `/h5p/build/types/`, the h5p-author skill's type files, which the page copies for the chat
* with a source's problems (see `typeFiles()`).
* - `/h5p/build/sample.h5p` and `sample.json`, the sample the page plays where it is opened with no
* source, and its source (see `sample()`).
* - `/h5p/bundles/`, for a chat that reaches no npm registry (see `bundles()`).
*
* `/h5p/skill/`, the h5p-author skill's files and a page of their addresses, is Eleventy's
* (`eleventy.config.js`, `src/h5p/skill/`). The page lists the bundles too, written before them:
* it names them from the author package's `contentTypes`, the list `writeBundles` writes from.
*
* Moved here from embed-my.github.io's `/build` and `/bundles/` (#7).
*/
/** Where the files a package's licence asks to travel with it are emitted, beside them. */
const ASSETS = 'h5p/assets'
/** Where the skill's type files are emitted, beside the build page that copies them. */
const TYPES = 'h5p/build/types'
/**
* Vite writes into Eleventy's `_site/`, so it cannot empty its output folder: the folders only it
* writes are emptied instead, before it writes them again.
*/
function emptyOwnFolders(): Plugin {
let outDir = ''
return {
name: 'missing-elements:empty-own-folders',
apply: 'build',
configResolved(config) {
outDir = resolve(config.root, config.build.outDir)
},
async buildStart() {
for (const folder of [ASSETS, TYPES, 'h5p/bundles']) await rm(join(outDir, folder), { recursive: true, force: true })
}
}
}
/**
* The text files that travel with the code: the runtime's licence and notice, which it asks to go
* with copies of it (GPL-3.0), and the library pack's licence list, which asks the same of its
* libraries. Vite emits the scripts, fonts and parts as hashed assets and would leave them behind.
*/
function licenceFiles(): Plugin {
const runtime = new URL('./node_modules/@missing-elements/h5p-runtime/dist/', import.meta.url)
const libraries = new URL('./node_modules/@missing-elements/h5p-libraries/', import.meta.url)
return {
name: 'missing-elements:licence-files',
apply: 'build',
generateBundle() {
for (const file of ['LICENSE.txt', 'NOTICE.txt']) {
this.emitFile({ type: 'asset', fileName: `${ASSETS}/runtime-${file}`, source: readFileSync(new URL(file, runtime)) })
}
this.emitFile({ type: 'asset', fileName: `${ASSETS}/libraries-LICENSES.txt`, source: readFileSync(new URL('libraries.txt', libraries)) })
}
}
}
/**
* `/h5p/build/types/`, the h5p-author skill's type files, as the pinned author package carries
* them since 0.7.0: `<Type>.md`, a content type's notes, an example and its fields, and
* `index.json`, the author's version and the types there is a file for.
*
* A chat in a browser has the skill's text and none of its files, so it guesses how a content type
* is written, and learns one level's names from each round of problems. The build page copies the
* files of a source's own type, and of the libraries it names, for the chat along with the
* source's problems (h5p-offline-player#172). They are the release's, so they describe the fields
* of the build this page runs.
*/
function typeFiles(): Plugin {
const author = new URL('./node_modules/@missing-elements/h5p-author/', import.meta.url)
const folder = new URL('references/types/', author)
return {
name: 'missing-elements:type-files',
apply: 'build',
generateBundle() {
const names = readdirSync(folder).filter((name) => name.endsWith('.md')).sort()
if (!names.length) throw new Error(`@missing-elements/h5p-author has no type files in ${fileURLToPath(folder)}`)
for (const name of names) this.emitFile({ type: 'asset', fileName: `${TYPES}/${name}`, source: readFileSync(new URL(name, folder)) })
const { version } = JSON.parse(readFileSync(new URL('package.json', author), 'utf8')) as { version: string }
const listing = { author: version, types: names.map((name) => name.slice(0, -'.md'.length)) }
this.emitFile({ type: 'asset', fileName: `${TYPES}/index.json`, source: `${JSON.stringify(listing, null, 2)}\n` })
}
}
}
/**
* `/h5p/build/sample.h5p`, the sample the build page plays for a visitor who came with no source,
* and `/h5p/build/sample.json`, its source, which the page puts in its editor when asked.
*
* The source is `src/h5p/build/sample.json`, written as the h5p-author skill has a chat write one.
* The package is built from it here by the pinned author package, as the command builds it, so it
* is what the page itself would build from that source, with the editor libraries an H5P site's
* upload check asks for: a visitor can download it and try it in their own Moodle. A source the
* pinned build refuses stops the site's build, with its problems.
*
* It is built here and not in the visitor's browser because the player reads a package from its
* address in ranges: of this one's 4.9 MB it takes the ten libraries that play, where a build in
* the page would fetch every part.
*/
function sample(): Plugin {
const source = new URL('./src/h5p/build/sample.json', import.meta.url)
let outDir = ''
return {
name: 'missing-elements:sample',
apply: 'build',
configResolved(config) {
outDir = resolve(config.root, config.build.outDir)
},
async writeBundle() {
const folder = join(outDir, 'h5p', 'build')
const text = await readFile(source, 'utf8')
// The build never writes over a file, and `_site/` may hold the last build's.
const output = join(folder, 'sample.h5p')
await rm(output, { force: true })
await buildPackage(JSON.parse(text), output)
await writeFile(join(folder, 'sample.json'), text)
}
}
}
/**
* `/h5p/bundles/`, for a chat that runs Node but reaches no npm registry. The h5p-author skill
* builds there from what it gets from here:
*
* - `h5p-author-build.tar.gz`: the command line, every part of the pack with its index, and
* `libraries.txt`, which the person downloads once and attaches to a chat whose code can fetch
* nothing. ChatGPT's cannot: its download tool saved none of eleven files tried on 2026-11-13,
* ours or any site's, and an installed plugin's files never reach it. With this attached it
* built, as Claude and Grok did. Every type in one file, since it is downloaded before a type is
* chosen; a `.tar.gz`, since ChatGPT took the same files as a `.zip` for a plugin to install and
* refused it.
* - `<type>.zip`, one per content type in the pack (`h5p-author bundle --all`): `parts/`, the
* pack's whole index with the parts a build of that type can read, and `libraries.txt`. With
* the command line, two files a chat that can fetch takes from here: Grok built that way on
* 2026-11-13, its `npx` having timed out.
* - `parts/`: every part of the pack under its own name, and its index. A bundle holds no addon,
* so a build whose text calls for one stops and names its part, which the chat fetches from here.
* - `h5p-author.mjs`: the command line as one file, everything it imports bundled in by Vite, so it
* runs with nothing installed. It takes `--libraries`: the npm package's pinned pack is not with
* it. Since 0.9.8 its `--version` prints the versions from copies of the two package.json files
* Vite writes in (h5p-offline-player#198); before, it crashed, finding neither beside it. The
* build runs it and stops where it does not print the versions `index.json` says.
* - `index.json`: the author's and the pack's versions, and each bundle's file, size and parts.
* - `libraries.txt`: the pack's licence list, which asks to travel with its libraries.
*
* Written after the page, from the packages the lockfile pins: `_site/` holds them, git does not.
*/
function bundles(): Plugin {
const root = fileURLToPath(new URL('.', import.meta.url))
const author = new URL('./node_modules/@missing-elements/h5p-author/', import.meta.url)
const libraries = new URL('./node_modules/@missing-elements/h5p-libraries/', import.meta.url)
const version = async (from: URL) => (JSON.parse(await readFile(new URL('package.json', from), 'utf8')) as { version: string }).version
let outDir = ''
return {
name: 'missing-elements:bundles',
apply: 'build',
configResolved(config) {
outDir = resolve(config.root, config.build.outDir)
},
async writeBundle() {
const folder = join(outDir, 'h5p', 'bundles')
const written = await writeBundles(folder)
const parts = join(folder, 'parts')
await mkdir(parts)
const index = JSON.parse(await readFile(new URL('parts/index.json', libraries), 'utf8')) as { libraries: Array<{ folder: string }> }
for (const { folder: part } of index.libraries) await copyFile(new URL(`parts/${part}.h5p`, libraries), join(parts, `${part}.h5p`))
await copyFile(new URL('parts/index.json', libraries), join(parts, 'index.json'))
await copyFile(new URL('libraries.txt', libraries), join(folder, 'libraries.txt'))
await build({
configFile: false,
root,
logLevel: 'warn',
publicDir: false,
build: {
ssr: fileURLToPath(new URL('bin/h5p-author.mjs', author)),
outDir: folder,
emptyOutDir: false,
copyPublicDir: false,
minify: false,
reportCompressedSize: false,
rolldownOptions: { output: { entryFileNames: 'h5p-author.mjs', format: 'es', codeSplitting: false } }
},
ssr: { noExternal: true, target: 'node' }
})
// The tool says its own versions, as a model checking it first asks: those this site pins.
const expected = `@missing-elements/h5p-author ${await version(author)}, library pack ${await version(libraries)}`
const printed = execFileSync(process.execPath, [join(folder, 'h5p-author.mjs'), '--version'], { encoding: 'utf8' }).trim()
if (printed !== expected) throw new Error(`h5p-author.mjs --version printed "${printed}", not "${expected}"`)
// The archive lays them out as a build from the bundles does: `tar -xzf`, then `--libraries parts`.
const archive = tarGz([
['h5p-author.mjs', await readFile(join(folder, 'h5p-author.mjs'))],
['libraries.txt', await readFile(join(folder, 'libraries.txt'))],
['parts/index.json', await readFile(join(parts, 'index.json'))],
...(await Promise.all(index.libraries.map(async ({ folder: part }) => [`parts/${part}.h5p`, await readFile(join(parts, `${part}.h5p`))] as const)))
])
await writeFile(join(folder, ARCHIVE), archive)
const listing = {
author: await version(author),
pack: await version(libraries),
tool: 'h5p-author.mjs',
parts: 'parts/',
licences: 'libraries.txt',
archive: { file: ARCHIVE, size: archive.length },
bundles: written.map(({ output, type, parts, size }) => {
const [machineName, typeVersion] = type.split(' ')
return { type: machineName, version: typeVersion, file: basename(output), size, parts: parts.length }
})
}
await writeFile(join(folder, 'index.json'), `${JSON.stringify(listing, null, 2)}\n`)
}
}
}
/** 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 archive in `/h5p/bundles/` that a person attaches to a chat (see `bundles()`). */
const ARCHIVE = 'h5p-author-build.tar.gz'
/**
* A gzipped ustar archive of the files, with an entry for each folder before what is in it, every
* date `ZIP_DATE`'s day and no owner: the same files write the same bytes on any computer. Node has
* gzip and no tar; ustar is a 512-byte header per entry, and every tar since 1988 reads it.
*/
function tarGz(files: ReadonlyArray<readonly [string, Uint8Array]>): Buffer {
const date = Date.UTC(2000, 0, 1) / 1000
const header = (name: string, size: number, type: '0' | '5') => {
if (Buffer.byteLength(name) > 100) throw new Error(`${name}: a ustar name holds 100 bytes`)
const block = Buffer.alloc(512)
block.write(name, 0, 'utf8')
block.write(type === '5' ? '0000755\0' : '0000644\0', 100, 'ascii')
block.write('0000000\0', 108, 'ascii')
block.write('0000000\0', 116, 'ascii')
block.write(`${size.toString(8).padStart(11, '0')}\0`, 124, 'ascii')
block.write(`${date.toString(8).padStart(11, '0')}\0`, 136, 'ascii')
block.write(' '.repeat(8), 148, 'ascii')
block.write(type, 156, 'ascii')
block.write('ustar\0', 257, 'ascii')
block.write('00', 263, 'ascii')
let sum = 0
for (const byte of block) sum += byte
block.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 'ascii')
return block
}
const chunks: Buffer[] = []
const folders = new Set<string>()
for (const [path, bytes] of files) {
const parts = 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)
chunks.push(header(name, 0, '5'))
}
chunks.push(header(path, bytes.length, '0'), Buffer.from(bytes), Buffer.alloc((512 - (bytes.length % 512)) % 512))
}
chunks.push(Buffer.alloc(1024))
const gz = gzipSync(Buffer.concat(chunks), { level: 9 })
gz[9] = 3 // the header's OS byte, Unix, which zlib otherwise takes from the platform it was built on
return gz
}
/**
* 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, and Grok's under Skills, Import (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, the first into Claude
* and into Grok. ChatGPT's code cannot read a plugin's files, so neither carries the build tool:
* where a chat's code cannot reach it, the person attaches `bundles()`'s archive, or the build page
* builds. The plugin's description leads with what sets it apart, in the owner's words: privacy
* first, no MCP server, so nothing a person makes passes through a server of ours.
*/
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: 'Privacy first, no MCP server: create H5P content from a prompt, a lesson or supplied material, as a playable .h5p (quizzes, flash cards, presentations, interactive books and more). It is built in your chat\'s own sandbox or in your browser, and nothing you make is sent to us.',
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: 'H5P activities from a prompt: privacy first, no MCP server',
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(), skillZips()],
build: {
outDir: '_site',
emptyOutDir: false,
assetsDir: ASSETS,
rolldownOptions: {
input: { 'build-page': 'src/h5p/build/build-page.ts' },
output: { entryFileNames: 'h5p/build/[name].js' }
}
}
})