Skip to content

Commit 2e2ae0d

Browse files
authored
Merge pull request #16 from missing-elements/feat/h5p-skill
Serve the h5p-author skill, with a page of its files' full addresses
2 parents 5485479 + 0890f91 commit 2e2ae0d

10 files changed

Lines changed: 195 additions & 8 deletions

File tree

‎README.md‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Docs and tools for the [missing-elements](https://github.com/missing-elements) w
99
| `/h5p/build/` | The H5P build page, [src/h5p/build/](src/h5p/build/): see [The H5P build page](#the-h5p-build-page) |
1010
| `/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) |
1111
| `/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) |
12+
| `/h5p/skill/` | The h5p-author skill's files and a page of their addresses, [src/h5p/skill/](src/h5p/skill/), copied by [eleventy.config.js](eleventy.config.js): see [The skill](#the-skill) |
1213

1314
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.
1415

@@ -21,7 +22,7 @@ pnpm install
2122
pnpm dev # http://localhost:8080
2223
pnpm build # type-checks, then writes _site/: the pages with Eleventy, then /h5p/ with Vite
2324
pnpm preview # serves _site/ as GitHub Pages does, http://localhost:4173
24-
pnpm e2e # the build page and the bundles in a browser, against _site/: build first, and once
25+
pnpm e2e # the build page, the bundles and the skill, against _site/: build first, and once
2526
# per machine: pnpm exec playwright install chromium
2627
```
2728

@@ -63,7 +64,7 @@ Nothing leaves the browser. A link can carry the source in its fragment, which n
6364
| `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 |
6465
| `src/assets/site.css` | Its styles, under *H5P build page* |
6566
| `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/` |
66-
| `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 |
67+
| `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; `/h5p/skill/` serves the skill's files as the package carries them, and writes out every address |
6768

6869
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.
6970

@@ -103,3 +104,13 @@ The page copies that text byte for byte. The 14 rounds that failed in the second
103104
| `/h5p/bundles/libraries.txt` | The pack's licence list, which asks to travel with its libraries |
104105

105106
`vite.config.ts` writes them at build from the packages the lockfile pins, so a Dependabot update of `@missing-elements/h5p-author` or `@missing-elements/h5p-libraries` moves them, and they live in `_site/` only, not in git: 113 MB with pack 1.1.0, well within GitHub Pages' 1 GB. A question type's bundle is about 1 MB, a Question Set's 2.6 MB, a container's up to 8.4 MB. embed-my.github.io keeps serving its own `/bundles/` until the next pack release, for copies of the skill installed before the move.
107+
108+
## The skill
109+
110+
`/h5p/skill/` serves the h5p-author skill as the pinned `@missing-elements/h5p-author` carries it since 0.9.0: `SKILL.md`, its licence and `references/`, the brief and a file per content type, copied as they are by `eleventy.config.js`. It is for a chat that cannot install the skill and reads it from a web address. From GitHub that can fail: GitHub limits anonymous reads per address, chat apps fetch from addresses they share, and the Claude app answered "I couldn't access the GitHub SKILL.md file due to rate limiting". GitHub Pages serves this site with no such limit.
111+
112+
Give a chat the page's address, `https://missing-elements.github.io/h5p/skill/`, not `SKILL.md`'s. The page, `src/h5p/skill/index.njk`, writes out the full address of every file, each as its link's text, because a chat's fetch tool may open only addresses it has seen whole: Claude's web fetch is documented to fetch only addresses given in the conversation or found in an earlier search or fetch, and `SKILL.md`'s relative links (`./references/authoring.md`) and the `<Type>` in a type file's address are not whole. `/h5p/skill/index.json` lists the author's version and the files, from `src/_data/skill.js`, which stops the build if the pinned package carries no `SKILL.md`.
113+
114+
The addresses carry no version. The site builds from one pinned release, the one the build page runs, so a versioned folder would hold only that one, and an address with the previous version, given to a chat before, would stop working at the next update. The page and `index.json` say which release it is, and a Dependabot update of the package moves the files with the build page.
115+
116+
GitHub Pages serves `LICENSE`, which has no extension, as `application/octet-stream`; a chat never needs to read it. `pnpm preview` and the browser tests answer a missing file with a 404, as GitHub Pages does (`appType: 'mpa'` in `vite.config.ts`), so a test that a file is served fails when it is not.

‎e2e/skill.spec.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { readdir, readFile } from 'node:fs/promises'
2+
import { join, relative, sep } from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
import { expect, test } from '@playwright/test'
5+
6+
/*
7+
* /h5p/skill/ as a chat that cannot install the h5p-author skill reads it: a page that writes out
8+
* the full address of every file, since a chat's fetch tool may open only addresses it has seen
9+
* whole; each file there as the pinned author package carries it; and every address SKILL.md gives,
10+
* relative to itself or written out, served by this site.
11+
*/
12+
13+
const SITE = 'https://missing-elements.github.io'
14+
const author = fileURLToPath(new URL('../node_modules/@missing-elements/h5p-author/', import.meta.url))
15+
16+
/** Every file of the skill in the pinned package, by its path in the skill: SKILL.md, the licence, all of references/. */
17+
async function carried(): Promise<string[]> {
18+
const entries = await readdir(join(author, 'references'), { recursive: true, withFileTypes: true })
19+
const references = entries.filter((entry) => entry.isFile()).map((entry) => relative(author, join(entry.parentPath, entry.name)).split(sep).join('/'))
20+
return ['SKILL.md', 'LICENSE', ...references].sort()
21+
}
22+
23+
test('the page writes out the full address of every file of the skill, as its link and its text', async ({ page, request }) => {
24+
const files = await carried()
25+
expect(files).toEqual(expect.arrayContaining(['references/authoring.md', 'references/types/H5P.QuestionSet.md']))
26+
await page.goto('/h5p/skill/')
27+
const links = await page.locator(`main a[href^="${SITE}/h5p/skill/"]`).evaluateAll((anchors) =>
28+
anchors.map((anchor) => ({ href: anchor.getAttribute('href'), text: anchor.textContent?.trim() })))
29+
expect(links[0].href).toBe(`${SITE}/h5p/skill/SKILL.md`)
30+
expect(links.map(({ href }) => href).sort()).toEqual(files.map((file) => `${SITE}/h5p/skill/${file}`))
31+
for (const { href, text } of links) expect(text).toBe(href)
32+
33+
const { version } = JSON.parse(await readFile(join(author, 'package.json'), 'utf8')) as { version: string }
34+
await expect(page.locator('main')).toContainText(`h5p-author ${version}`)
35+
const listing = await (await request.get('/h5p/skill/index.json')).json() as { author: string, files: string[] }
36+
expect(listing.author).toBe(version)
37+
expect(listing.files[0]).toBe('SKILL.md')
38+
expect([...listing.files].sort()).toEqual(files)
39+
})
40+
41+
test('serves each file at its address as the pinned author package carries it', async ({ request }) => {
42+
for (const file of await carried()) {
43+
const response = await request.get(`/h5p/skill/${file}`)
44+
expect(response.ok(), file).toBe(true)
45+
expect((await response.body()).equals(await readFile(join(author, file))), file).toBe(true)
46+
}
47+
})
48+
49+
test('serves every address SKILL.md gives, relative to itself or on this site', async ({ request }) => {
50+
const skill = await (await request.get('/h5p/skill/SKILL.md')).text()
51+
const here = new URL(`${SITE}/h5p/skill/SKILL.md`)
52+
const relativeLinks = [...skill.matchAll(/\]\((?![a-z]+:|#)([^)\s]+)\)/g)].map(([, link]) => new URL(link, here).pathname)
53+
expect(relativeLinks).toEqual(expect.arrayContaining(['/h5p/skill/LICENSE', '/h5p/skill/references/authoring.md']))
54+
// Its addresses on this site, with a content type for the name it leaves to fill in, and no fragment.
55+
const filled = skill.replace(/<type>/gi, 'H5P.QuestionSet')
56+
const onSite = [...new Set([...filled.matchAll(/https:\/\/missing-elements\.github\.io(\/[^\s`)>"'#]*)/g)].map(([, path]) => path))]
57+
expect(onSite).toEqual(expect.arrayContaining(['/h5p/build/types/H5P.QuestionSet.md', '/h5p/bundles/H5P.QuestionSet.zip', '/h5p/build/']))
58+
for (const path of [...relativeLinks, ...onSite]) expect((await request.get(path)).ok(), path).toBe(true)
59+
})

‎eleventy.config.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ export default function (eleventyConfig) {
1616
eleventyConfig.addPassthroughCopy("src/pdfjs-viewer/docs.js");
1717
// The docs run the exact package version they describe, served from this site.
1818
eleventyConfig.addPassthroughCopy({ "node_modules/@missing-elements/pdfjs-viewer/dist": "pdfjs-viewer/vendor" });
19+
// The h5p-author skill, as the pinned author package carries it, listed by src/h5p/skill/.
20+
const author = "node_modules/@missing-elements/h5p-author";
21+
eleventyConfig.addPassthroughCopy({
22+
[`${author}/SKILL.md`]: "h5p/skill/SKILL.md",
23+
[`${author}/LICENSE`]: "h5p/skill/LICENSE",
24+
[`${author}/references`]: "h5p/skill/references",
25+
});
1926

2027
// A page with a Content-Security-Policy (`csp` in its front matter, written first in <head> by
2128
// base.njk, and holding no double quote) allows its inline scripts by hash, as they are written:

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"vite": "8.3.5"
2323
},
2424
"dependencies": {
25-
"@missing-elements/h5p-author": "0.8.0",
25+
"@missing-elements/h5p-author": "0.9.0",
2626
"@missing-elements/h5p-libraries": "1.1.0",
2727
"@missing-elements/h5p-offline-player": "1.6.0",
2828
"@missing-elements/h5p-runtime": "1.0.1",

‎pnpm-lock.yaml‎

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

‎src/_data/skill.js‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { existsSync, readdirSync, readFileSync } from "node:fs";
2+
import { join, relative, sep } from "node:path";
3+
4+
// The h5p-author skill as the pinned @missing-elements/h5p-author carries it since 0.9.0: its
5+
// SKILL.md, licence and references/, which eleventy.config.js copies to /h5p/skill/ as they are.
6+
// src/h5p/skill/ lists them, each at its full address.
7+
const author = "node_modules/@missing-elements/h5p-author";
8+
const { version } = JSON.parse(readFileSync(`${author}/package.json`, "utf8"));
9+
if (!existsSync(`${author}/SKILL.md`)) throw new Error(`@missing-elements/h5p-author ${version} carries no SKILL.md: 0.9.0 is the first that does`);
10+
11+
const references = readdirSync(`${author}/references`, { recursive: true, withFileTypes: true })
12+
.filter((entry) => entry.isFile())
13+
.map((entry) => relative(author, join(entry.parentPath, entry.name)).split(sep).join("/"))
14+
.sort();
15+
const types = references.filter((path) => path.startsWith("references/types/"));
16+
17+
export default {
18+
version,
19+
// What SKILL.md names and the type files, apart, as the page lists them; `files` is every one.
20+
named: [...references.filter((path) => !types.includes(path)), "LICENSE"],
21+
types,
22+
files: ["SKILL.md", ...references.filter((path) => !types.includes(path)), "LICENSE", ...types],
23+
};

‎src/assets/site.css‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,9 @@ tbody th a:hover code { text-decoration: underline; }
546546
.elements p { margin: 0 0 0.5rem; color: var(--muted); }
547547
.elements .links { display: flex; gap: 1rem; font-size: 0.875rem; }
548548

549+
/* The h5p-author skill's files, /h5p/skill/: whole addresses, which give way on a phone. */
550+
.skill-files a { overflow-wrap: anywhere; }
551+
549552
/* H5P build page, /h5p/build/ */
550553
.build { --ok: var(--live); --bad: var(--t-tag); }
551554
.build-brand { margin-bottom: 1.25rem; font-size: 0.9375rem; }

‎src/h5p/skill/index.json.11ty.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// /h5p/skill/index.json: the author package's version and every file of the skill served here
2+
// (src/_data/skill.js), for a script.
3+
export const data = { permalink: "/h5p/skill/index.json", eleventyExcludeFromCollections: true };
4+
5+
export const render = ({ skill }) => `${JSON.stringify({ author: skill.version, files: skill.files }, null, 2)}\n`;

‎src/h5p/skill/index.njk‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
layout: base.njk
3+
title: The h5p-author skill · Missing Elements
4+
description: The h5p-author skill's files, each at its full address, for an AI chat that cannot install the skill.
5+
# No script but the site's: base.njk writes the policy first in <head>, and the color-scheme script
6+
# is added to script-src by its hash at build (eleventy.config.js).
7+
csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; object-src 'none'; base-uri 'self'"
8+
---
9+
{#- Every address is written out whole, as its link's text too: a chat's fetch tool may open only
10+
the addresses it has seen in full, and a relative link, or SKILL.md's <Type>, is none. -#}
11+
{%- set base = site.url + "/h5p/skill/" -%}
12+
<main class="content skill" id="main">
13+
<header class="docs-header">
14+
<a class="brand build-brand" href="/"><img src="/assets/logo.svg" alt="" width="24" height="24">{{ site.name }}</a>
15+
<div class="docs-header-row">
16+
<h1>The h5p-author skill</h1>
17+
{% include "theme-switch.njk" %}
18+
</div>
19+
<p class="lead">
20+
Its files, each at its full address, for an AI chat that cannot install the skill.
21+
</p>
22+
<p>
23+
Read <code>SKILL.md</code> first and follow its steps. A file it names, such as
24+
<code>references/authoring.md</code> or a content type's <code>references/types/&lt;Type&gt;.md</code>,
25+
is the one listed here under that path. These are the files of h5p-author {{ skill.version }},
26+
the release the build page at <a href="{{ site.url }}/h5p/build/">{{ site.url }}/h5p/build/</a> runs.
27+
</p>
28+
<p>
29+
Where skills can be installed, install it instead:
30+
<code>npx skills add missing-elements/h5p-offline-player --skill h5p-author</code>.
31+
</p>
32+
</header>
33+
34+
<section aria-labelledby="first-heading">
35+
<h2 id="first-heading">Read first</h2>
36+
<ul class="skill-files">
37+
<li><a href="{{ base }}SKILL.md">{{ base }}SKILL.md</a>: the skill's steps</li>
38+
</ul>
39+
</section>
40+
41+
<section aria-labelledby="named-heading">
42+
<h2 id="named-heading">The files it names</h2>
43+
<ul class="skill-files">
44+
{%- for path in skill.named %}
45+
<li><a href="{{ base }}{{ path }}">{{ base }}{{ path }}</a>
46+
{%- if path == "references/authoring.md" %}: the brief, read once
47+
{%- elif path == "LICENSE" %}: the skill's licence, MIT, which goes with it{% endif %}</li>
48+
{%- endfor %}
49+
</ul>
50+
</section>
51+
52+
<section aria-labelledby="types-heading">
53+
<h2 id="types-heading">A content type's file</h2>
54+
<p>
55+
One per content type: its notes, an example and its fields. Read the file of the type the
56+
source is written in, and those of the parts it holds, such as H5P.AdvancedText or H5P.Image.
57+
</p>
58+
<ul class="skill-files">
59+
{%- for path in skill.types %}
60+
<li><a href="{{ base }}{{ path }}">{{ base }}{{ path }}</a></li>
61+
{%- endfor %}
62+
</ul>
63+
</section>
64+
65+
<footer class="docs-footer">
66+
<p>
67+
The skill and <code>@missing-elements/h5p-author</code> are MIT, from
68+
<a href="https://github.com/missing-elements/h5p-offline-player/tree/main/skills/h5p-author">h5p-offline-player</a>.
69+
Not affiliated with H5P Group; H5P is a registered trademark of H5P Group AS.
70+
</p>
71+
<p>Found a mistake? <a href="https://github.com/missing-elements/missing-elements.github.io/blob/main/{{ page.inputPath | replace('./', '') }}">Edit this page</a> or <a href="https://github.com/missing-elements/h5p-offline-player/issues">open an issue</a>.</p>
72+
</footer>
73+
</main>

‎vite.config.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import { build, defineConfig, type Plugin } from 'vite'
1717
* with a source's problems (see `typeFiles()`).
1818
* - `/h5p/bundles/`, for a chat that reaches no npm registry (see `bundles()`).
1919
*
20+
* `/h5p/skill/`, the h5p-author skill's files and a page of their addresses, is Eleventy's
21+
* (`eleventy.config.js`, `src/h5p/skill/`).
22+
*
2023
* Moved here from embed-my.github.io's `/build` and `/bundles/` (#7).
2124
*/
2225

@@ -163,6 +166,9 @@ function bundles(): Plugin {
163166

164167
export default defineConfig({
165168
publicDir: false,
169+
// A missing file is a 404, as on GitHub Pages, not the home page: the browser tests read
170+
// `vite preview`'s answers, and an SPA's fallback would answer every address with a page.
171+
appType: 'mpa',
166172
plugins: [emptyOwnFolders(), licenceFiles(), typeFiles(), bundles()],
167173
build: {
168174
outDir: '_site',

0 commit comments

Comments
 (0)