diff --git a/README.md b/README.md index 7973652..0b30918 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 ``` @@ -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. @@ -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 `` 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. diff --git a/e2e/skill.spec.ts b/e2e/skill.spec.ts new file mode 100644 index 0000000..b33b33c --- /dev/null +++ b/e2e/skill.spec.ts @@ -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 { + 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(//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) +}) diff --git a/eleventy.config.js b/eleventy.config.js index 81c732d..a128bde 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -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 by // base.njk, and holding no double quote) allows its inline scripts by hash, as they are written: diff --git a/package.json b/package.json index a28c006..c5f296c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a9fd17..3f145b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@missing-elements/h5p-author': - specifier: 0.8.0 - version: 0.8.0(@missing-elements/h5p-runtime@1.0.1) + specifier: 0.9.0 + version: 0.9.0(@missing-elements/h5p-runtime@1.0.1) '@missing-elements/h5p-libraries': specifier: 1.1.0 version: 1.1.0 @@ -101,8 +101,8 @@ packages: resolution: {integrity: sha512-oI7m8pa7/IAU/3lqRU9vjBbs20iKFo7x+1K9kT3aVira6scc1X9MjBdgLCHzLJeJ7iB6wydioA+kr9/qPnvmlQ==} engines: {node: '>=18'} - '@missing-elements/h5p-author@0.8.0': - resolution: {integrity: sha512-F85BWeJ3eJsZ8OVgb/MamSb+n/WTXOIauSMQtTq/LX4dJ1Oa03KxKSlmdkl6O0dQdQt25qGx9IGlVLYJLiWxaw==} + '@missing-elements/h5p-author@0.9.0': + resolution: {integrity: sha512-8WzY2M+aGdwJNqF493gPjtgQFkw36GgrQ3xUQQH/ZkSBZePMZDKnsj2PfSooFisz8lTVPxIzvWgnQihz25kTKw==} engines: {node: '>=22'} hasBin: true @@ -1118,7 +1118,7 @@ snapshots: minimatch: 3.1.5 slash: 3.0.0 - '@missing-elements/h5p-author@0.8.0(@missing-elements/h5p-runtime@1.0.1)': + '@missing-elements/h5p-author@0.9.0(@missing-elements/h5p-runtime@1.0.1)': dependencies: '@missing-elements/h5p-libraries': 1.1.0 '@missing-elements/h5p-normalize': 1.3.0 diff --git a/src/_data/skill.js b/src/_data/skill.js new file mode 100644 index 0000000..1c8551a --- /dev/null +++ b/src/_data/skill.js @@ -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], +}; diff --git a/src/assets/site.css b/src/assets/site.css index 9b4cbbc..e286755 100644 --- a/src/assets/site.css +++ b/src/assets/site.css @@ -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; } diff --git a/src/h5p/skill/index.json.11ty.js b/src/h5p/skill/index.json.11ty.js new file mode 100644 index 0000000..6553811 --- /dev/null +++ b/src/h5p/skill/index.json.11ty.js @@ -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`; diff --git a/src/h5p/skill/index.njk b/src/h5p/skill/index.njk new file mode 100644 index 0000000..bb8ffe7 --- /dev/null +++ b/src/h5p/skill/index.njk @@ -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 , 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 , is none. -#} +{%- set base = site.url + "/h5p/skill/" -%} +
+
+ {{ site.name }} +
+

The h5p-author skill

+ {% include "theme-switch.njk" %} +
+

+ Its files, each at its full address, for an AI chat that cannot install the skill. +

+

+ Read SKILL.md first and follow its steps. A file it names, such as + references/authoring.md or a content type's references/types/<Type>.md, + is the one listed here under that path. These are the files of h5p-author {{ skill.version }}, + the release the build page at {{ site.url }}/h5p/build/ runs. +

+

+ Where skills can be installed, install it instead: + npx skills add missing-elements/h5p-offline-player --skill h5p-author. +

+
+ +
+

Read first

+ +
+ +
+

The files it names

+
    + {%- for path in skill.named %} +
  • {{ base }}{{ path }} + {%- if path == "references/authoring.md" %}: the brief, read once + {%- elif path == "LICENSE" %}: the skill's licence, MIT, which goes with it{% endif %}
  • + {%- endfor %} +
+
+ +
+

A content type's file

+

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

+ +
+ + +
diff --git a/vite.config.ts b/vite.config.ts index 03beda3..029f55a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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). */ @@ -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',