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
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Docs and tools for the [missing-elements](https://github.com/missing-elements) w
| `/h5p/build/` | The H5P build page, [src/h5p/build/](src/h5p/build/): see [The H5P build page](#the-h5p-build-page) |
| `/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) |
| `/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) |
| `/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) |

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.

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

Expand Down Expand Up @@ -63,7 +64,7 @@ Nothing leaves the browser. A link can carry the source in its fragment, which n
| `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 |
| `src/assets/site.css` | Its styles, under *H5P build page* |
| `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/` |
| `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 |
| `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 |

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.

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

`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.

## The skill

`/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.

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`.

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.

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.
59 changes: 59 additions & 0 deletions e2e/skill.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { readdir, readFile } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { expect, test } from '@playwright/test'

/*
* /h5p/skill/ as a chat that cannot install the h5p-author skill reads it: a page that writes out
* the full address of every file, since a chat's fetch tool may open only addresses it has seen
* whole; each file there as the pinned author package carries it; and every address SKILL.md gives,
* relative to itself or written out, served by this site.
*/

const SITE = 'https://missing-elements.github.io'
const author = fileURLToPath(new URL('../node_modules/@missing-elements/h5p-author/', import.meta.url))

/** Every file of the skill in the pinned package, by its path in the skill: SKILL.md, the licence, all of references/. */
async function carried(): Promise<string[]> {
const entries = await readdir(join(author, 'references'), { recursive: true, withFileTypes: true })
const references = entries.filter((entry) => entry.isFile()).map((entry) => relative(author, join(entry.parentPath, entry.name)).split(sep).join('/'))
return ['SKILL.md', 'LICENSE', ...references].sort()
}

test('the page writes out the full address of every file of the skill, as its link and its text', async ({ page, request }) => {
const files = await carried()
expect(files).toEqual(expect.arrayContaining(['references/authoring.md', 'references/types/H5P.QuestionSet.md']))
await page.goto('/h5p/skill/')
const links = await page.locator(`main a[href^="${SITE}/h5p/skill/"]`).evaluateAll((anchors) =>
anchors.map((anchor) => ({ href: anchor.getAttribute('href'), text: anchor.textContent?.trim() })))
expect(links[0].href).toBe(`${SITE}/h5p/skill/SKILL.md`)
expect(links.map(({ href }) => href).sort()).toEqual(files.map((file) => `${SITE}/h5p/skill/${file}`))
for (const { href, text } of links) expect(text).toBe(href)

const { version } = JSON.parse(await readFile(join(author, 'package.json'), 'utf8')) as { version: string }
await expect(page.locator('main')).toContainText(`h5p-author ${version}`)
const listing = await (await request.get('/h5p/skill/index.json')).json() as { author: string, files: string[] }
expect(listing.author).toBe(version)
expect(listing.files[0]).toBe('SKILL.md')
expect([...listing.files].sort()).toEqual(files)
})

test('serves each file at its address as the pinned author package carries it', async ({ request }) => {
for (const file of await carried()) {
const response = await request.get(`/h5p/skill/${file}`)
expect(response.ok(), file).toBe(true)
expect((await response.body()).equals(await readFile(join(author, file))), file).toBe(true)
}
})

test('serves every address SKILL.md gives, relative to itself or on this site', async ({ request }) => {
const skill = await (await request.get('/h5p/skill/SKILL.md')).text()
const here = new URL(`${SITE}/h5p/skill/SKILL.md`)
const relativeLinks = [...skill.matchAll(/\]\((?![a-z]+:|#)([^)\s]+)\)/g)].map(([, link]) => new URL(link, here).pathname)
expect(relativeLinks).toEqual(expect.arrayContaining(['/h5p/skill/LICENSE', '/h5p/skill/references/authoring.md']))
// Its addresses on this site, with a content type for the name it leaves to fill in, and no fragment.
const filled = skill.replace(/<type>/gi, 'H5P.QuestionSet')
const onSite = [...new Set([...filled.matchAll(/https:\/\/missing-elements\.github\.io(\/[^\s`)>"'#]*)/g)].map(([, path]) => path))]
expect(onSite).toEqual(expect.arrayContaining(['/h5p/build/types/H5P.QuestionSet.md', '/h5p/bundles/H5P.QuestionSet.zip', '/h5p/build/']))
for (const path of [...relativeLinks, ...onSite]) expect((await request.get(path)).ok(), path).toBe(true)
})
7 changes: 7 additions & 0 deletions eleventy.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export default function (eleventyConfig) {
eleventyConfig.addPassthroughCopy("src/pdfjs-viewer/docs.js");
// The docs run the exact package version they describe, served from this site.
eleventyConfig.addPassthroughCopy({ "node_modules/@missing-elements/pdfjs-viewer/dist": "pdfjs-viewer/vendor" });
// The h5p-author skill, as the pinned author package carries it, listed by src/h5p/skill/.
const author = "node_modules/@missing-elements/h5p-author";
eleventyConfig.addPassthroughCopy({
[`${author}/SKILL.md`]: "h5p/skill/SKILL.md",
[`${author}/LICENSE`]: "h5p/skill/LICENSE",
[`${author}/references`]: "h5p/skill/references",
});

// A page with a Content-Security-Policy (`csp` in its front matter, written first in <head> by
// base.njk, and holding no double quote) allows its inline scripts by hash, as they are written:
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.8.0",
"@missing-elements/h5p-author": "0.9.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.

23 changes: 23 additions & 0 deletions src/_data/skill.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, relative, sep } from "node:path";

// The h5p-author skill as the pinned @missing-elements/h5p-author carries it since 0.9.0: its
// SKILL.md, licence and references/, which eleventy.config.js copies to /h5p/skill/ as they are.
// src/h5p/skill/ lists them, each at its full address.
const author = "node_modules/@missing-elements/h5p-author";
const { version } = JSON.parse(readFileSync(`${author}/package.json`, "utf8"));
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`);

const references = readdirSync(`${author}/references`, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => relative(author, join(entry.parentPath, entry.name)).split(sep).join("/"))
.sort();
const types = references.filter((path) => path.startsWith("references/types/"));

export default {
version,
// What SKILL.md names and the type files, apart, as the page lists them; `files` is every one.
named: [...references.filter((path) => !types.includes(path)), "LICENSE"],
types,
files: ["SKILL.md", ...references.filter((path) => !types.includes(path)), "LICENSE", ...types],
};
3 changes: 3 additions & 0 deletions src/assets/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,9 @@ tbody th a:hover code { text-decoration: underline; }
.elements p { margin: 0 0 0.5rem; color: var(--muted); }
.elements .links { display: flex; gap: 1rem; font-size: 0.875rem; }

/* The h5p-author skill's files, /h5p/skill/: whole addresses, which give way on a phone. */
.skill-files a { overflow-wrap: anywhere; }

/* H5P build page, /h5p/build/ */
.build { --ok: var(--live); --bad: var(--t-tag); }
.build-brand { margin-bottom: 1.25rem; font-size: 0.9375rem; }
Expand Down
5 changes: 5 additions & 0 deletions src/h5p/skill/index.json.11ty.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// /h5p/skill/index.json: the author package's version and every file of the skill served here
// (src/_data/skill.js), for a script.
export const data = { permalink: "/h5p/skill/index.json", eleventyExcludeFromCollections: true };

export const render = ({ skill }) => `${JSON.stringify({ author: skill.version, files: skill.files }, null, 2)}\n`;
73 changes: 73 additions & 0 deletions src/h5p/skill/index.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
layout: base.njk
title: The h5p-author skill · Missing Elements
description: The h5p-author skill's files, each at its full address, for an AI chat that cannot install the skill.
# No script but the site's: base.njk writes the policy first in <head>, and the color-scheme script
# is added to script-src by its hash at build (eleventy.config.js).
csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; object-src 'none'; base-uri 'self'"
---
{#- Every address is written out whole, as its link's text too: a chat's fetch tool may open only
the addresses it has seen in full, and a relative link, or SKILL.md's <Type>, is none. -#}
{%- set base = site.url + "/h5p/skill/" -%}
<main class="content skill" id="main">
<header class="docs-header">
<a class="brand build-brand" href="/"><img src="/assets/logo.svg" alt="" width="24" height="24">{{ site.name }}</a>
<div class="docs-header-row">
<h1>The h5p-author skill</h1>
{% include "theme-switch.njk" %}
</div>
<p class="lead">
Its files, each at its full address, for an AI chat that cannot install the skill.
</p>
<p>
Read <code>SKILL.md</code> first and follow its steps. A file it names, such as
<code>references/authoring.md</code> or a content type's <code>references/types/&lt;Type&gt;.md</code>,
is the one listed here under that path. These are the files of h5p-author {{ skill.version }},
the release the build page at <a href="{{ site.url }}/h5p/build/">{{ site.url }}/h5p/build/</a> runs.
</p>
<p>
Where skills can be installed, install it instead:
<code>npx skills add missing-elements/h5p-offline-player --skill h5p-author</code>.
</p>
</header>

<section aria-labelledby="first-heading">
<h2 id="first-heading">Read first</h2>
<ul class="skill-files">
<li><a href="{{ base }}SKILL.md">{{ base }}SKILL.md</a>: the skill's steps</li>
</ul>
</section>

<section aria-labelledby="named-heading">
<h2 id="named-heading">The files it names</h2>
<ul class="skill-files">
{%- for path in skill.named %}
<li><a href="{{ base }}{{ path }}">{{ base }}{{ path }}</a>
{%- if path == "references/authoring.md" %}: the brief, read once
{%- elif path == "LICENSE" %}: the skill's licence, MIT, which goes with it{% endif %}</li>
{%- endfor %}
</ul>
</section>

<section aria-labelledby="types-heading">
<h2 id="types-heading">A content type's file</h2>
<p>
One per content type: its notes, an example and its fields. Read the file of the type the
source is written in, and those of the parts it holds, such as H5P.AdvancedText or H5P.Image.
</p>
<ul class="skill-files">
{%- for path in skill.types %}
<li><a href="{{ base }}{{ path }}">{{ base }}{{ path }}</a></li>
{%- endfor %}
</ul>
</section>

<footer class="docs-footer">
<p>
The skill and <code>@missing-elements/h5p-author</code> are MIT, from
<a href="https://github.com/missing-elements/h5p-offline-player/tree/main/skills/h5p-author">h5p-offline-player</a>.
Not affiliated with H5P Group; H5P is a registered trademark of H5P Group AS.
</p>
<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>
</footer>
</main>
6 changes: 6 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import { build, defineConfig, type Plugin } from 'vite'
* with a source's problems (see `typeFiles()`).
* - `/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/`).
*
* Moved here from embed-my.github.io's `/build` and `/bundles/` (#7).
*/

Expand Down Expand Up @@ -163,6 +166,9 @@ function bundles(): Plugin {

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